From a7cd6970a49da280d665522147187b6b346a2ca8 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 26 Aug 2026 15:21:29 +0300 Subject: [PATCH 01/82] feat: support API key for CryptoCompare market source (#14746) --- .../explorer/market/source/crypto_compare.ex | 7 ++--- .../market/source/crypto_compare_test.exs | 30 +++++++++++++++++++ bin/install_chrome_headless.sh | 2 +- config/runtime.exs | 1 + docker-compose/envs/common-blockscout.env | 1 + 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/explorer/lib/explorer/market/source/crypto_compare.ex b/apps/explorer/lib/explorer/market/source/crypto_compare.ex index 0b7cc0e9801..572af5dc176 100644 --- a/apps/explorer/lib/explorer/market/source/crypto_compare.ex +++ b/apps/explorer/lib/explorer/market/source/crypto_compare.ex @@ -89,10 +89,9 @@ defmodule Explorer.Market.Source.CryptoCompare do end defp headers do - if config(:api_key) do - [{"Authorization", "Apikey #{config(:api_key)}"}] - else - [] + case config(:api_key) do + empty when empty in [nil, ""] -> [] + api_key -> [{"Authorization", "Apikey #{api_key}"}] end end diff --git a/apps/explorer/test/explorer/market/source/crypto_compare_test.exs b/apps/explorer/test/explorer/market/source/crypto_compare_test.exs index edd6226fbca..d248df33927 100644 --- a/apps/explorer/test/explorer/market/source/crypto_compare_test.exs +++ b/apps/explorer/test/explorer/market/source/crypto_compare_test.exs @@ -125,6 +125,36 @@ defmodule Explorer.Market.Source.CryptoCompareTest do } ]} == CryptoCompare.fetch_native_coin_price_history(3) end + + test "sets Authorization header if api key is configured", %{bypass: bypass} do + config = Application.get_env(:explorer, CryptoCompare) + Application.put_env(:explorer, CryptoCompare, Keyword.merge(config, api_key: "test_api_key")) + + on_exit(fn -> Application.put_env(:explorer, CryptoCompare, config) end) + + Bypass.expect_once(bypass, "GET", "data/v2/histoday", fn conn -> + assert Conn.get_req_header(conn, "authorization") == ["Apikey test_api_key"] + + Conn.resp(conn, 200, json_data_v2_histoday()) + end) + + assert {:ok, _} = CryptoCompare.fetch_native_coin_price_history(3) + end + + test "doesn't set Authorization header if api key is empty", %{bypass: bypass} do + config = Application.get_env(:explorer, CryptoCompare) + Application.put_env(:explorer, CryptoCompare, Keyword.merge(config, api_key: "")) + + on_exit(fn -> Application.put_env(:explorer, CryptoCompare, config) end) + + Bypass.expect_once(bypass, "GET", "data/v2/histoday", fn conn -> + assert Conn.get_req_header(conn, "authorization") == [] + + Conn.resp(conn, 200, json_data_v2_histoday()) + end) + + assert {:ok, _} = CryptoCompare.fetch_native_coin_price_history(3) + end end describe "secondary_coin_price_history_fetching_enabled?" do diff --git a/bin/install_chrome_headless.sh b/bin/install_chrome_headless.sh index dd6219ce36a..5944e2f2abf 100755 --- a/bin/install_chrome_headless.sh +++ b/bin/install_chrome_headless.sh @@ -2,7 +2,7 @@ export DISPLAY=:99.0 sh -e /etc/init.d/xvfb start #export CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json" | jq -r '.channels' | jq -r '.Stable' | jq -r '.version') -export CHROMEDRIVER_VERSION=150.0.7871.125 +export CHROMEDRIVER_VERSION=152.0.7977.65 curl -L -O "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip" unzip -j chromedriver-linux64.zip sudo chmod +x chromedriver diff --git a/config/runtime.exs b/config/runtime.exs index c7db8f4a3ce..a71ba22407d 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -529,6 +529,7 @@ config :explorer, Explorer.Market.Source.CoinMarketCap, config :explorer, Explorer.Market.Source.CryptoCompare, base_url: ConfigHelper.parse_url_env_var("MARKET_CRYPTOCOMPARE_BASE_URL", "https://min-api.cryptocompare.com"), + api_key: System.get_env("MARKET_CRYPTOCOMPARE_API_KEY"), coin_symbol: System.get_env("MARKET_CRYPTOCOMPARE_COIN_SYMBOL", coin), secondary_coin_symbol: System.get_env("MARKET_CRYPTOCOMPARE_SECONDARY_COIN_SYMBOL") || diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index b0b69cf8b90..ed568d357ec 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -70,6 +70,7 @@ DISABLE_MARKET=true # MARKET_COINMARKETCAP_COIN_ID= # MARKET_COINMARKETCAP_SECONDARY_COIN_ID= # MARKET_CRYPTOCOMPARE_BASE_URL= +# MARKET_CRYPTOCOMPARE_API_KEY= # MARKET_CRYPTOCOMPARE_COIN_SYMBOL= # MARKET_CRYPTOCOMPARE_SECONDARY_COIN_SYMBOL= # MARKET_CRYPTORANK_SECONDARY_COIN_ID= From 99f2c8474b1ff88874205d13b38a82873a058081 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:48:29 +0300 Subject: [PATCH 02/82] chore(deps): bump redix from 1.6.0 to 1.8.0 (#14738) 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 cf265a01644..3d2f6b93688 100644 --- a/mix.lock +++ b/mix.lock @@ -156,7 +156,7 @@ "que": {:hex, :que, "0.12.0", "4629c156036b8a3bf9ffdd79aa4069ab414822144d3ef031b5cc61fc805942d3", [:mix], [{:ex_utils, "~> 0.1", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.6", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "906651d60b1036840f1afacc0036541583eda9ba4b0b03f322530a1c84100507"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "redix": {:hex, :redix, "1.6.0", "694179c7a3c71bffac8848fcbfe16f6f6e2a1e020f00a2ad01bc6484815470d1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eccb05e02f21c0c3ca57513e6bacb4dd48e6406dadbd7ff9fbe07bd6745999"}, + "redix": {:hex, :redix, "1.8.0", "f0a2d1dc047a4a1b3004525d27e5df7a4e5b42768246bf161e44bb5e744f2a34", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24288583e0b08cde42aa781053cd14d992a02976fe06986fb664b8ebfcf0f853"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "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"}, From 509e8f612450649f48cdf4af9d80bebf4ce25201 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:35:59 +0300 Subject: [PATCH 03/82] chore(deps-dev): bump phoenix_live_reload from 1.6.2 to 1.7.0 (#14654) 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 3d2f6b93688..180a649e6af 100644 --- a/mix.lock +++ b/mix.lock @@ -135,9 +135,9 @@ "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.10", "eb4958045f71d4962373e9ed5967b592375a9dafc73f52997f3996759998a39d", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bcf9d64846b770bc64b1a58dc40af406d80eb61b6e516f7aea4cc8cc15e0a1d9"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, From b6e0e0adde5112580b1f9beb9e0452c48734303c Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:34:32 +0300 Subject: [PATCH 04/82] perf: Remove unconditional contract_address preload for tokens (#14750) --- .../controllers/api/v2/token_controller.ex | 23 +++++++++++++++++-- .../transaction_interpretation.ex | 23 ++++++++++++++++--- apps/explorer/lib/explorer/chain.ex | 1 - 3 files changed, 41 insertions(+), 6 deletions(-) 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 e140b16d8fa..20b23aad0f4 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 @@ -1,7 +1,11 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TokenController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] + + use Utils.CompileTimeEnvHelper, + bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]], + chain_type: [:explorer, :chain_type] + use OpenApiSpex.ControllerSpecs alias BlockScoutWeb.{AccessHelper, AuthenticationHelper} @@ -48,7 +52,22 @@ defmodule BlockScoutWeb.API.V2.TokenController do @api_true [api?: true] - @token_options [api?: true, necessity_by_association: %{reputation_association() => :optional}] + case @chain_type do + :filecoin -> + @chain_type_token_necessity_by_association %{contract_address: :optional} + + :zilliqa -> + @chain_type_token_necessity_by_association %{contract_address: :optional} + + _ -> + @chain_type_token_necessity_by_association %{} + end + + @token_options [ + api?: true, + necessity_by_association: + Map.merge(%{reputation_association() => :optional}, @chain_type_token_necessity_by_association) + ] operation :token, summary: "Retrieve detailed information about a specific token", 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 5b53275d17e..4d588047a9b 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 @@ -27,12 +27,29 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do @request_error_msg "Error while sending request to Transaction Interpretation Service" @api_true api?: true @items_limit 50 + + case @chain_type do + :filecoin -> + @chain_type_token_necessity_by_association %{contract_address: :optional} + + :zilliqa -> + @chain_type_token_necessity_by_association %{contract_address: :optional} + + _ -> + @chain_type_token_necessity_by_association %{} + end + @token_options [ api?: true, - necessity_by_association: %{ - reputation_association() => :optional - } + necessity_by_association: + Map.merge( + %{ + reputation_association() => :optional + }, + @chain_type_token_necessity_by_association + ) ] + @internal_transaction_address_preloads [ address_preloads: [ created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index a127a18c07f..c995fc4bfda 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -2475,7 +2475,6 @@ defmodule Explorer.Chain do query |> join_associations(necessity_by_association) - |> preload(:contract_address) |> select_repo(options).one() |> case do nil -> From 8f4799722d41c6ebf009a84b68007ec1bd605947 Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:52:51 +0300 Subject: [PATCH 05/82] perf: Remove redundant preloads in api/v2/addresses/* (#14751) --- .../address_coin_balance_controller.ex | 4 +- .../controllers/api/v2/address_controller.ex | 59 +++++++++---------- .../controllers/api/v2/fallback_controller.ex | 9 +++ .../explorer/chain/address/coin_balance.ex | 29 ++++----- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex index b5a2997df70..fe61bf158c5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex @@ -18,11 +18,11 @@ defmodule BlockScoutWeb.AddressCoinBalanceController do def index(conn, %{"address_id" => address_hash_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.hash_to_address(address_hash, []), + {:ok, _address} <- Chain.hash_to_address(address_hash, []), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do full_options = paging_options(params) - coin_balances_plus_one = CoinBalance.address_to_coin_balances(address, full_options) + coin_balances_plus_one = CoinBalance.address_hash_to_coin_balances(address_hash, full_options) {coin_balances, next_page} = split_list_by_page(coin_balances_plus_one) 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 5f9118b36fe..2e526c60d5d 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 @@ -250,8 +250,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec counters(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def counters(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - # TODO: check if @address_options is needed here - case Chain.hash_to_address(address_hash, @address_options) do + case Chain.hash_to_address(address_hash, Keyword.merge(@api_true, necessity_by_association: %{})) do {:ok, address} -> {validation_count} = Counters.address_counters(address, @api_true) @@ -314,8 +313,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> token_balances = address_hash |> Chain.fetch_last_token_balances( @@ -395,8 +394,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec transactions(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def transactions(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> options = [necessity_by_association: address_transactions_necessity_by_association()] |> Keyword.merge(@api_true) @@ -519,8 +518,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), {:ok, token_address_hash} <- validate_optional_address_hash(params[:token], params), token_address_exists <- (token_address_hash && Token.check_token_exists(token_address_hash)) || :ok do - case {Chain.hash_to_address(address_hash, @address_options), token_address_exists} do - {{:ok, _address}, :ok} -> + case {Address.check_address_exists(address_hash, @api_true), token_address_exists} do + {:ok, :ok} -> paging_options = paging_options(params) options = @@ -607,8 +606,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec internal_transactions(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def internal_transactions(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> full_options = [ address_preloads: [ @@ -682,8 +681,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do def logs(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), {:ok, topic} <- validate_optional_topic(params[:topic]) do - case Chain.hash_to_address(address_hash, @api_true) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> options = params |> paging_options() @@ -755,8 +754,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec blocks_validated(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def blocks_validated(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> full_options = [ necessity_by_association: %{ @@ -822,11 +821,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec coin_balance_history(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def coin_balance_history(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> full_options = params |> paging_options() |> Keyword.merge(@api_true) - results_plus_one = CoinBalance.address_to_coin_balances(address, full_options) + results_plus_one = CoinBalance.address_hash_to_coin_balances(address_hash, full_options) {coin_balances, next_page} = split_list_by_page(results_plus_one) @@ -884,8 +883,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def coin_balance_history_by_day(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> balances_by_day = address_hash |> Chain.address_to_balances_by_day(@api_true) @@ -945,8 +944,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> results_plus_one = address_hash |> Chain.fetch_paginated_last_token_balances( @@ -1017,8 +1016,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec withdrawals(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def withdrawals(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> options = @api_true |> Keyword.merge(paging_options(params)) withdrawals_plus_one = address_hash |> Chain.address_hash_to_withdrawals(options) {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) @@ -1150,8 +1149,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do beacon_deposits: :beacon_deposits_count } - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> counters_json = address_hash |> Counters.address_limited_counters(@api_true) @@ -1227,8 +1226,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec nft_list(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def nft_list(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> results_plus_one = Instance.nft_list( address_hash, @@ -1303,8 +1302,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec nft_collections(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def nft_collections(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> + case Address.check_address_exists(address_hash, @api_true) do + :ok -> results_plus_one = Instance.nft_collections( address_hash, @@ -1369,7 +1368,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec celo_election_rewards(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def celo_election_rewards(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), - {:ok, _address} <- Chain.hash_to_address(address_hash, api?: true) do + :ok <- Address.check_address_exists(address_hash, api?: true) do full_options = @celo_election_rewards_options |> Keyword.put( diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex index 8e50705e493..a768392e55f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex @@ -128,6 +128,15 @@ defmodule BlockScoutWeb.API.V2.FallbackController do |> call({:not_found, nil}) end + def call(conn, :not_found) do + Logger.debug(fn -> + [":not_found"] + end) + + conn + |> call({:not_found, nil}) + end + def call(conn, {:error, %Changeset{} = changeset}) do conn |> put_status(:unprocessable_entity) diff --git a/apps/explorer/lib/explorer/chain/address/coin_balance.ex b/apps/explorer/lib/explorer/chain/address/coin_balance.ex index a961bf15cc9..6c4538c77e8 100644 --- a/apps/explorer/lib/explorer/chain/address/coin_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/coin_balance.ex @@ -167,28 +167,23 @@ defmodule Explorer.Chain.Address.CoinBalance do end @doc """ - Retrieves paginated coin balance records for a given address with timestamp interpolation. + Retrieves paginated coin balance records for a given address hash with timestamp interpolation. - This function fetches coin balance history for an address, applying pagination - and performing timestamp calculations for blocks. It includes an optimization - that returns an empty list immediately when the paging key is `{0}`, avoiding - unnecessary database queries. For other cases, it processes balances by - filtering records with values, calculating block timestamp ranges, and - interpolating timestamps for intermediate blocks when multiple blocks are - present. + Fetches coin balance history for an address, applying pagination and performing + timestamp calculations for blocks. Returns an empty list immediately when the + paging key is `{0}`, avoiding unnecessary database queries. ## Parameters - - `address`: Address.t() - The address record to fetch coin balances for - - `options`: [Chain.paging_options() | Chain.api?()] - Query options including - paging configuration and API mode selection + - `address_hash`: `Hash.Address.t()` - The address hash to fetch coin balances for. + - `options`: keyword list with paging and API mode options. ## Returns - `[t()]` - List of coin balance records sorted by block number in descending order, with interpolated timestamps, or empty list if paging key is `{0}` or - no balances exist + no balances exist. """ - @spec address_to_coin_balances(Address.t(), [Chain.paging_options() | Chain.api?()]) :: [t()] - def address_to_coin_balances(address, options) do + @spec address_hash_to_coin_balances(Hash.Address.t(), [Chain.paging_options() | Chain.api?()]) :: [t()] + def address_hash_to_coin_balances(address_hash, options) do paging_options = Keyword.get(options, :paging_options, PagingOptions.default_paging_options()) case paging_options do @@ -196,13 +191,13 @@ defmodule Explorer.Chain.Address.CoinBalance do [] _ -> - address_to_coin_balances_internal(address, options, paging_options) + address_hash_to_coin_balances_internal(address_hash, options, paging_options) end end - defp address_to_coin_balances_internal(address, options, paging_options) do + defp address_hash_to_coin_balances_internal(address_hash, options, paging_options) do balances_raw = - address.hash + address_hash |> fetch_coin_balances(paging_options) |> page_coin_balances(paging_options) |> Chain.select_repo(options).all() From 3b4147abdbb973e4c5a7d96d568765a4a61b1537 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:47:28 +0400 Subject: [PATCH 06/82] perf: Reduce addresses preload queries count (#14755) --- .../controllers/api/v2/address_controller.ex | 71 ++++++++++++------- apps/explorer/lib/explorer/chain.ex | 69 ++++++++++++++++-- 2 files changed, 107 insertions(+), 33 deletions(-) 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 2e526c60d5d..2578e1a5950 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 @@ -73,8 +73,6 @@ defmodule BlockScoutWeb.API.V2.AddressController do @token_transfer_necessity_by_association [ necessity_by_association: %{ - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, :block => :optional, :transaction => :optional, [token: reputation_association()] => :optional @@ -82,6 +80,33 @@ defmodule BlockScoutWeb.API.V2.AddressController do api?: true ] + # Address-info associations shared by every participant role of a list item. + # Loaded once for the whole page by `Chain.preload_address_participants/4` + # rather than per role through `necessity_by_association`. + @transaction_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + proxy_implementations_association() => :optional + } + + @token_transfer_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :smart_contract => :optional, + proxy_implementations_association() => :optional + } + + @transaction_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + + @token_transfer_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] + case @chain_identity do {:optimism, :celo} -> @chain_type_address_necessity_by_association %{ @@ -419,7 +444,14 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: + transactions + |> Chain.preload_address_participants( + @transaction_address_fields, + @transaction_participant_necessity_by_association, + @api_true + ) + |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) @@ -435,31 +467,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + # Address participants are loaded separately by + # `Chain.preload_address_participants/4`, which shares one query pass between + # `from`/`to`/`created_contract` instead of repeating it per role. defp address_transactions_necessity_by_association do - %{ - [ - created_contract_address: [ - :scam_badge, - :names, - proxy_implementations_association() - ] - ] => :optional, - [ - from_address: [ - :scam_badge, - :names, - proxy_implementations_association() - ] - ] => :optional, - [ - to_address: [ - :scam_badge, - :names, - proxy_implementations_association() - ] - ] => :optional, - :block => :optional - } + %{:block => :optional} |> Map.merge(@chain_type_transaction_necessity_by_association) end @@ -548,6 +560,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> render(:token_transfers, %{ token_transfers: token_transfers + |> Chain.preload_address_participants( + @token_transfer_address_fields, + @token_transfer_participant_necessity_by_association, + @api_true + ) |> Instance.preload_nft(@api_true) |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index c995fc4bfda..0ec23f22dd6 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1174,12 +1174,7 @@ defmodule Explorer.Chain do |> 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}) + addresses = addresses_by_hash(participant_hashes, address_necessity_by_association, options) to_address = addresses @@ -1202,6 +1197,68 @@ defmodule Explorer.Chain do } end + @doc """ + Loads address-info associations for every address referenced by a list of items in a single query pass. + + Addresses shared between items, and between roles of the same item, are + deduplicated. Preloading the same associations per role instead — as + `necessity_by_association` does — repeats both the `addresses` query and + every nested association query once per role. This issues one `addresses` + query and one query per entry in `address_necessity_by_association`, + regardless of how many roles are populated. + + Must run before `Explorer.Chain.Address.MetadataPreloader`, which writes ENS + and metadata into the very address structs assigned here. + + ## Parameters + - `items`: The list of structs whose address associations are populated. + - `address_fields`: The `{hash_field, association_field}` pairs to populate, for + example `[{:from_address_hash, :from_address}, {:to_address_hash, :to_address}]`. + - `address_necessity_by_association`: A map of address associations to load, + shared by every role. + - `options`: An optional keyword list of options, such as selecting a specific repository. + + ## Returns + - The list of items with every association named in `address_fields` set to the + matching `t:Explorer.Chain.Address.t/0`, or to `nil` when the hash field is + `nil` or no address row exists. + """ + @spec preload_address_participants([struct()], [{atom(), atom()}], %{any() => :optional | :required}, [api?]) :: + [struct()] + def preload_address_participants(items, address_fields, address_necessity_by_association, options) + + def preload_address_participants([], _address_fields, _address_necessity_by_association, _options), do: [] + + def preload_address_participants(items, address_fields, address_necessity_by_association, options) do + addresses = + items + |> Enum.flat_map(fn item -> + Enum.map(address_fields, fn {hash_field, _association_field} -> Map.fetch!(item, hash_field) end) + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> addresses_by_hash(address_necessity_by_association, options) + + Enum.map(items, fn item -> + Enum.reduce(address_fields, item, fn {hash_field, association_field}, item_acc -> + Map.replace!(item_acc, association_field, Map.get(addresses, Map.fetch!(item_acc, hash_field))) + end) + end) + end + + @spec addresses_by_hash([Hash.Address.t()], %{any() => :optional | :required}, [api?]) :: %{ + Hash.Address.t() => Address.t() + } + defp addresses_by_hash([], _address_necessity_by_association, _options), do: %{} + + defp addresses_by_hash(hashes, address_necessity_by_association, options) do + Address + |> where([address], address.hash in ^hashes) + |> join_associations(address_necessity_by_association) + |> select_repo(options).all() + |> Map.new(&{&1.hash, &1}) + 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) From 0995dc3296c1a8d53454ca54c79af0753ef6e33e Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:40:38 +0400 Subject: [PATCH 07/82] perf: Deduplicate addresses preloads (#14758) --- .../controllers/api/v2/address_controller.ex | 38 ++++++++++++++----- .../controllers/api/v2/token_controller.ex | 17 +++++++++ .../api/v2/transaction_controller.ex | 21 ++++++++-- .../controllers/tokens/transfer_controller.ex | 21 ++++++++++ .../lib/explorer/chain/token_transfer.ex | 4 +- 5 files changed, 85 insertions(+), 16 deletions(-) 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 2578e1a5950..0a5f6c3d041 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 @@ -107,6 +107,19 @@ defmodule BlockScoutWeb.API.V2.AddressController do {:to_address_hash, :to_address} ] + @internal_transaction_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :smart_contract => :optional, + proxy_implementations_association() => :optional + } + + @internal_transaction_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + case @chain_identity do {:optimism, :celo} -> @chain_type_address_necessity_by_association %{ @@ -625,15 +638,15 @@ defmodule BlockScoutWeb.API.V2.AddressController do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Address.check_address_exists(address_hash, @api_true) do :ok -> + # Nested address-info preloads are intentionally absent from + # `:address_preloads`: `address_to_internal_transactions/2` resolves the + # bare addresses and aligns the `*_address_hash` fields for both the + # hash-based and the address-id-based schema, after which + # `Chain.preload_address_participants/4` loads the info for all three + # roles in a single pass instead of one per role. full_options = - [ - address_preloads: [ - created_contract_address: [:scam_badge, :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()] - ] - ] - |> Keyword.merge(paging_options(params)) + params + |> paging_options() |> Keyword.merge(current_filter(params)) |> Keyword.merge(@api_true) @@ -647,7 +660,14 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:internal_transactions, %{ - internal_transactions: internal_transactions |> maybe_preload_ens_and_metadata(), + internal_transactions: + internal_transactions + |> Chain.preload_address_participants( + @internal_transaction_address_fields, + @internal_transaction_participant_necessity_by_association, + @api_true + ) + |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) 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 20b23aad0f4..42d4716f8ea 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 @@ -52,6 +52,18 @@ defmodule BlockScoutWeb.API.V2.TokenController do @api_true [api?: true] + @token_transfer_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :smart_contract => :optional, + proxy_implementations_association() => :optional + } + + @token_transfer_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] + case @chain_type do :filecoin -> @chain_type_token_necessity_by_association %{contract_address: :optional} @@ -203,6 +215,11 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> render(:token_transfers, %{ token_transfers: token_transfers + |> Chain.preload_address_participants( + @token_transfer_address_fields, + @token_transfer_participant_necessity_by_association, + @api_true + ) |> Instance.preload_nft(@api_true) |> maybe_preload_ens_and_metadata(:token_transfers), 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 81f89a4847a..d25fb44e548 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 @@ -105,13 +105,21 @@ defmodule BlockScoutWeb.API.V2.TransactionController do } @token_transfers_necessity_by_association %{ - [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 } + @token_transfer_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + SmartContract.association_without_abi() => :optional, + proxy_implementations_association() => :optional + } + + @token_transfer_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] + # Transfer from/to address preloads are handled by # `Chain.preload_transaction_participants/3`, see # `@transaction_participants_necessity_by_association`. @@ -707,6 +715,11 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> render(:token_transfers, %{ token_transfers: token_transfers + |> Chain.preload_address_participants( + @token_transfer_address_fields, + @token_transfer_participant_necessity_by_association, + @api_true + ) |> Instance.preload_nft(@api_true) |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex index a7aa73ec6e3..941835a8624 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex @@ -14,10 +14,23 @@ defmodule BlockScoutWeb.Tokens.TransferController do import BlockScoutWeb.Chain, only: [split_list_by_page: 1, paging_options: 1, next_page_params: 3] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) @burn_address_hash burn_address_hash + @token_transfer_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :smart_contract => :optional, + proxy_implementations_association() => :optional + } + + @token_transfer_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] + def index(conn, %{"token_id" => address_hash_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, token} <- Chain.token_from_address_hash(address_hash), @@ -25,6 +38,14 @@ defmodule BlockScoutWeb.Tokens.TransferController do {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do {token_transfers_paginated, next_page} = split_list_by_page(token_transfers) + token_transfers_paginated = + Chain.preload_address_participants( + token_transfers_paginated, + @token_transfer_address_fields, + @token_transfer_participant_necessity_by_association, + [] + ) + next_page_path = case next_page_params(next_page, token_transfers_paginated, params) do nil -> diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index 2ac8c87b7ed..5ffe1820f6f 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -279,9 +279,7 @@ defmodule Explorer.Chain.TokenTransfer do preloads = DenormalizationHelper.extend_transaction_preload([ :transaction, - [token: reputation_association()], - [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], - [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] + [token: reputation_association()] ]) only_consensus_transfers_query() From 160bba231a483b138acd63f85848c474fe238114 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:50:28 +0300 Subject: [PATCH 08/82] chore(deps): bump typed_ecto_schema from 0.4.3 to 0.5.0 (#14761) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/explorer/mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 616f7957fa1..f29eebbf3e9 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -121,7 +121,7 @@ defmodule Explorer.Mixfile do {:cloak_ecto, "~> 1.3.0"}, {:redix, "~> 1.1"}, {:hammer_backend_redis, "~> 7.0"}, - {:typed_ecto_schema, "~> 0.4.1"}, + {:typed_ecto_schema, "~> 0.5.0"}, {:ueberauth, "~> 0.7"}, {:recon, "~> 2.5"}, {:varint, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index 180a649e6af..e2308acb2e3 100644 --- a/mix.lock +++ b/mix.lock @@ -174,7 +174,7 @@ "tesla": {:hex, :tesla, "1.21.2", "71b3a8a000802fa82f0b87c22acbca1e8f6a03f23d727e01b66687111681debc", [: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.5.0", [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", "296bbafd1a328533c57e6b0e93078983cf40a827546e58a20464ce75485d5254"}, "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"}, + "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.5.0", "cf0e387727dd898b6a67c80c8528d54bdb96e2318ee07dfbd65848fec2cde91f", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "061c500aa5ce03ed4defd85a2fa8ba9a53d036fc096459d570e7a4de05171ed1"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, "ueberauth_auth0": {:hex, :ueberauth_auth0, "2.1.0", "0632d5844049fa2f26823f15e1120aa32f27df6f27ce515a4b04641736594bf4", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "8d3b30fa27c95c9e82c30c4afb016251405706d2e9627e603c3c9787fd1314fc"}, From 45fb7a23d75df0502fa107b6bea6e6684659a741 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:21:40 +0300 Subject: [PATCH 09/82] chore(deps): bump phoenix_live_view from 1.2.10 to 1.2.11 (#14764) 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 e2308acb2e3..31b9a9459f2 100644 --- a/mix.lock +++ b/mix.lock @@ -136,7 +136,7 @@ "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.10", "eb4958045f71d4962373e9ed5967b592375a9dafc73f52997f3996759998a39d", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bcf9d64846b770bc64b1a58dc40af406d80eb61b6e516f7aea4cc8cc15e0a1d9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.11", "1c83fdfd007e8d554076baee00ee6f55bb9c87ad9439f33ee520e47ae4a983bb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4087267cbd4dc4ed7ac36512a97830af8e9c857ca7cb22557f8640fc5f9d306"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, From 99b2ade85d4baeaf96bf3e66a15321b3b3334cac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:41:53 +0300 Subject: [PATCH 10/82] chore(deps): bump oban from 2.23.1 to 2.24.0 (#14763) 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 31b9a9459f2..4ade0fedc17 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.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"}, + "oban": {:hex, :oban, "2.24.0", "cd877a089ead66658c40864fc616e90c75ddba95559fffae3fccaf05da58a4e5", [: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", "ab497b2be51191a2d9d7a1b267714463891be711b20487b7b65b5c2269e4b312"}, "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"}, From 8b70593f5907b3c3ec299cb7d7adff31ac90c72a Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 3 Sep 2026 12:31:41 +0300 Subject: [PATCH 11/82] perf: Remove unused GasUsageSum cache with heavy DB query (#14771) --- .../css/components/_custom_tooltips.scss | 12 --- .../controllers/api/v2/stats_controller.ex | 3 +- .../controllers/chain_controller.ex | 4 +- .../schemas/api/v2/stats/response.ex | 1 - .../templates/chain/show.html.eex | 11 --- .../api/v2/stats_controller_test.exs | 1 - apps/explorer/lib/explorer/application.ex | 2 - .../chain/cache/counters/gas_usage_sum.ex | 97 ------------------- config/runtime.exs | 4 - docker-compose/envs/common-blockscout.env | 1 - 10 files changed, 2 insertions(+), 134 deletions(-) delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex diff --git a/apps/block_scout_web/assets/css/components/_custom_tooltips.scss b/apps/block_scout_web/assets/css/components/_custom_tooltips.scss index 9328e761b3c..56d9411c025 100644 --- a/apps/block_scout_web/assets/css/components/_custom_tooltips.scss +++ b/apps/block_scout_web/assets/css/components/_custom_tooltips.scss @@ -84,18 +84,6 @@ $tooltip-background-color: $btn-line-color !default; } } -.tooltip-gas-usage { - .tooltip-inner { - @media (min-width: 576px) { - max-width: 150px !important; - } - @media (min-width: 768px) { - max-width: 150px !important; - } - width: 150px !important; - } -} - .tooltip-gas-tracker { .tooltip-inner { @media (min-width: 576px) { diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex index 96ab4cf6b35..073258705fc 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex @@ -24,7 +24,7 @@ defmodule BlockScoutWeb.API.V2.StatsController do alias BlockScoutWeb.API.V2.Helper alias BlockScoutWeb.Chain.MarketHistoryChartController alias Explorer.{Chain, Market} - alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, GasUsageSum, TransactionsCount} + alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, TransactionsCount} alias Explorer.Chain.Cache.GasPriceOracle alias Explorer.Chain.Supply.RSK alias Explorer.Chain.Transaction.History.TransactionStats @@ -105,7 +105,6 @@ defmodule BlockScoutWeb.API.V2.StatsController do "coin_price" => exchange_rate.fiat_value, "coin_price_change_percentage" => coin_price_change, "secondary_coin_price" => secondary_coin_exchange_rate.fiat_value, - "total_gas_used" => GasUsageSum.total() |> to_string(), "transactions_today" => Enum.at(transaction_stats, 0).number_of_transactions |> to_string(), "gas_used_today" => Enum.at(transaction_stats, 0).gas_used, "gas_prices" => gas_prices, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex index 5b2579ce8d6..d215c96f446 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex @@ -8,7 +8,7 @@ defmodule BlockScoutWeb.ChainController do alias BlockScoutWeb.{ChainView, Controller} alias Explorer.{Chain, PagingOptions, Repo} alias Explorer.Chain.{Address, Block, Hash, Transaction} - alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, GasUsageSum, TransactionsCount} + alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, TransactionsCount} alias Explorer.Chain.Search alias Explorer.Chain.Supply.RSK alias Explorer.Market @@ -16,7 +16,6 @@ defmodule BlockScoutWeb.ChainController do def show(conn, _params) do transactions_count = TransactionsCount.get() - total_gas_usage = GasUsageSum.total() block_count = BlocksCount.get() address_count = AddressesCount.fetch() @@ -51,7 +50,6 @@ defmodule BlockScoutWeb.ChainController do chart_data_paths: chart_data_paths, market_cap_calculation: market_cap_calculation, transaction_estimated_count: transactions_count, - total_gas_usage: total_gas_usage, transactions_path: recent_transactions_path(conn, :index), transaction_stats: transaction_stats, block_count: block_count, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex index be2bd076a4a..eee389ef387 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex @@ -109,7 +109,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response do static_gas_price: General.IntegerStringNullable, total_addresses: General.IntegerString, total_blocks: General.IntegerString, - total_gas_used: General.IntegerString, total_transactions: General.IntegerString, transactions_today: General.IntegerString, tvl: General.IntegerStringNullable diff --git a/apps/block_scout_web/lib/block_scout_web/templates/chain/show.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/chain/show.html.eex index 6f065e60338..5d9442d9dc7 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/chain/show.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/chain/show.html.eex @@ -117,17 +117,6 @@ <%= BlockScoutWeb.Cldr.Number.to_string!(@transaction_estimated_count, format: "#,###") %> - <%= if @total_gas_usage > 0 do %> - " - class="custom-tooltip-total-transactions"> - - - <% end %>
diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs index e8925384dd0..d28f0eb923e 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs @@ -28,7 +28,6 @@ defmodule BlockScoutWeb.API.V2.StatsControllerTest do assert Map.has_key?(response, "total_transactions") assert Map.has_key?(response, "average_block_time") assert Map.has_key?(response, "coin_price") - assert Map.has_key?(response, "total_gas_used") assert Map.has_key?(response, "transactions_today") assert Map.has_key?(response, "gas_used_today") assert Map.has_key?(response, "gas_prices") diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index c2ad1da0359..4be5c481d20 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -26,7 +26,6 @@ defmodule Explorer.Application do AddressesCoinBalanceSumMinusBurnt, AddressTabsElementsCount, BlocksCount, - GasUsageSum, PendingBlockOperationCount, PendingTransactionOperationCount, TransactionsCount @@ -92,7 +91,6 @@ defmodule Explorer.Application do Blocks, ChainId, GasPriceOracle, - GasUsageSum, PendingBlockOperationCount, PendingTransactionOperationCount, TransactionsCount, diff --git a/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex deleted file mode 100644 index 3c5d4e194d4..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex +++ /dev/null @@ -1,97 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.GasUsageSum do - @moduledoc """ - Cache for total gas usage. - """ - require Logger - - import Ecto.Query, - only: [ - from: 2 - ] - - use Explorer.Chain.MapCache, - name: :gas_usage, - key: :sum, - key: :async_task, - global_ttl: :infinity, - ttl_check_interval: :timer.seconds(1), - callback: &async_task_on_deletion(&1) - - alias Explorer.Chain.Cache.Counters.Helper, as: CacheCountersHelper - alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Chain.Transaction - alias Explorer.Repo - - @cache_key "gas_usage_sum" - - @spec total() :: non_neg_integer() - def total do - cached_value_from_ets = __MODULE__.get_sum() - - CacheCountersHelper.evaluate_count(@cache_key, cached_value_from_ets) - end - - defp handle_fallback(:sum) do - # This will get the task PID if one exists, check if it's running and launch - # a new task if task doesn't exist or it's not running. - # See next `handle_fallback` definition - safe_get_async_task() - - {:return, nil} - end - - defp handle_fallback(:async_task) do - if Application.get_env(:explorer, __MODULE__)[:enabled] do - # If this gets called it means an async task was requested, but none exists - # so a new one needs to be launched - {:ok, task} = - Task.start_link(fn -> - try do - result = fetch_sum_gas_used() - - params = %{ - counter_type: @cache_key, - value: result - } - - LastFetchedCounter.upsert(params) - - set_sum(%ConCache.Item{ - ttl: CacheCountersHelper.ttl(__MODULE__, "CACHE_TOTAL_GAS_USAGE_PERIOD"), - value: result - }) - rescue - e -> - Logger.debug([ - "Couldn't update total gas used: ", - Exception.format(:error, e, __STACKTRACE__) - ]) - end - - set_async_task(nil) - end) - - {:update, task} - else - {:update, nil} - end - end - - # By setting this as a `callback` an async task will be started each time the - # `sum` expires (unless there is one already running) - defp async_task_on_deletion({:delete, _, :sum}), do: safe_get_async_task() - - defp async_task_on_deletion(_data), do: nil - - @spec fetch_sum_gas_used() :: non_neg_integer - defp fetch_sum_gas_used do - query = - from( - t0 in Transaction, - select: fragment("SUM(t0.gas_used)") - ) - - Repo.one!(query, timeout: :infinity) || 0 - end -end diff --git a/config/runtime.exs b/config/runtime.exs index a71ba22407d..d239dc0251e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -397,10 +397,6 @@ config :explorer, Explorer.Chain.Cache.Counters.AddressesCoinBalanceSum, global_ config :explorer, Explorer.Chain.Cache.Counters.AddressesCoinBalanceSumMinusBurnt, global_ttl: address_sum_global_ttl -config :explorer, Explorer.Chain.Cache.Counters.GasUsageSum, - global_ttl: ConfigHelper.parse_time_env_var("CACHE_TOTAL_GAS_USAGE_PERIOD", "2h"), - enabled: ConfigHelper.parse_bool_env_var("CACHE_TOTAL_GAS_USAGE_COUNTER_ENABLED") - config :explorer, Explorer.Chain.Cache.Counters.BlocksCount, global_ttl: ConfigHelper.parse_time_env_var("CACHE_BLOCK_COUNT_PERIOD", "2h") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index ed568d357ec..f148615eeb1 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -121,7 +121,6 @@ RELEASE_LINK= # CACHE_BLOCK_COUNT_PERIOD=7200 # CACHE_TXS_COUNT_PERIOD=7200 # CACHE_ADDRESS_SUM_PERIOD=3600 -# CACHE_TOTAL_GAS_USAGE_PERIOD=7200 # CACHE_ADDRESS_TRANSACTIONS_GAS_USAGE_COUNTER_PERIOD=1800 # CACHE_TOKEN_HOLDERS_COUNTER_PERIOD=3600 # CACHE_TOKEN_TRANSFERS_COUNTER_PERIOD=3600 From 5b10b4396e8ddac6e93d7660b6dcf31d6e6ed1a9 Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:45:45 +0300 Subject: [PATCH 12/82] fix: Forward [api?: true] where missed (#14740) --- .../graphql/resolvers/internal_transaction.ex | 8 +- apps/explorer/lib/explorer/chain.ex | 2 +- apps/explorer/lib/explorer/chain/address.ex | 32 +++-- .../lib/explorer/chain/address/counters.ex | 8 +- .../lib/explorer/chain/advanced_filter.ex | 123 +++++++++++++----- .../lib/explorer/chain/bridged_token.ex | 2 +- .../explorer/chain/internal_transaction.ex | 19 +-- .../lib/explorer/chain/transaction.ex | 18 +-- apps/explorer/lib/explorer/etherscan.ex | 4 +- .../utility/address_id_to_address_hash.ex | 36 ++--- .../fetcher/on_demand/internal_transaction.ex | 94 ++++++------- .../on_demand/contract_creator_test.exs | 24 ++-- 12 files changed, 220 insertions(+), 150 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex index be0e4ab25bc..2363321700b 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex @@ -2,7 +2,7 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.InternalTransaction do @moduledoc false - import Explorer.Chain, only: [hash_to_transaction: 1] + import Explorer.Chain, only: [hash_to_transaction: 2] alias Absinthe.Relay.Connection alias BlockScoutWeb.Chain @@ -11,12 +11,12 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.InternalTransaction do alias Indexer.Fetcher.OnDemand.InternalTransaction, as: InternalTransactionOnDemand def get_by(%{transaction_hash: transaction_hash, index: index} = args, _) do - case hash_to_transaction(transaction_hash) do + case hash_to_transaction(transaction_hash, api?: true) do {:ok, transaction} -> if InternalTransaction.present_in_db?(transaction.block_number) do GraphQL.get_internal_transaction(args) else - options = [paging_options: %PagingOptions{page_size: index + 1}] + options = [paging_options: %PagingOptions{page_size: index + 1}, api?: true] transaction |> InternalTransactionOnDemand.fetch_by_transaction(options) @@ -41,7 +41,7 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.InternalTransaction do defp options(%{before: _}), do: [] - defp options(%{count: count}), do: [paging_options: %PagingOptions{page_size: count}] + defp options(%{count: count}), do: [paging_options: %PagingOptions{page_size: count}, api?: true] defp options(_), do: [] end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 0ec23f22dd6..f52edb9e5f6 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -882,7 +882,7 @@ defmodule Explorer.Chain do |> select_repo(options).one() |> then(fn address -> if Keyword.get(options, :preload_contract_creation_internal_transaction, false) do - Address.maybe_preload_contract_creation_internal_transaction(address, select_repo(options)) + Address.maybe_preload_contract_creation_internal_transaction(address, options) else address end diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index 57bb153ec3a..cbfc7bfa74e 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -473,11 +473,9 @@ defmodule Explorer.Chain.Address do do: address def maybe_preload_smart_contract_associations(%__MODULE__{contract_code: _} = address, associations, options) do - repo = Chain.select_repo(options) - address - |> repo.preload(associations) - |> maybe_preload_contract_creation_internal_transaction(repo) + |> Chain.select_repo(options).preload(associations) + |> maybe_preload_contract_creation_internal_transaction(options) end @doc """ @@ -849,7 +847,7 @@ defmodule Explorer.Chain.Address do ## Parameters - `addresses`: An `Explorer.Chain.Address.t/0`, a list of addresses, `[]`, or `nil` - - `repo`: The repo module used to execute the query. Defaults to `Explorer.Repo` + - `options`: A keyword list of options used to select the repo (for example, `api?: true`) ## Returns @@ -858,14 +856,14 @@ defmodule Explorer.Chain.Address do - A single address with `:contract_creation_internal_transaction` populated when the input is a single struct """ - @spec maybe_preload_contract_creation_internal_transaction([__MODULE__.t()] | __MODULE__.t() | nil, module()) :: + @spec maybe_preload_contract_creation_internal_transaction([__MODULE__.t()] | __MODULE__.t() | nil, [Chain.api?()]) :: [__MODULE__.t()] | __MODULE__.t() | nil - def maybe_preload_contract_creation_internal_transaction(addresses, repo \\ Repo) + def maybe_preload_contract_creation_internal_transaction(addresses, options) - def maybe_preload_contract_creation_internal_transaction([], _repo), do: [] - def maybe_preload_contract_creation_internal_transaction(nil, _repo), do: nil + def maybe_preload_contract_creation_internal_transaction([], _options), do: [] + def maybe_preload_contract_creation_internal_transaction(nil, _options), do: nil - def maybe_preload_contract_creation_internal_transaction(addresses, repo) when is_list(addresses) do + def maybe_preload_contract_creation_internal_transaction(addresses, options) when is_list(addresses) do if Application.get_env(:explorer, :api_disable_contract_creation_internal_transaction_association, false) do addresses else @@ -873,21 +871,21 @@ defmodule Explorer.Chain.Address do internal_transactions_map = contract_creation_internal_transaction_preload_query() - |> InternalTransaction.where_address_match(:created_contract_address, address_hashes) - |> repo.all() - |> InternalTransaction.preload_addresses([], repo) + |> InternalTransaction.where_address_match_by_hash(:created_contract_address, address_hashes, options) + |> Chain.select_repo(options).all() + |> InternalTransaction.preload_addresses([], Chain.select_repo(options)) |> Map.new(&{&1.created_contract_address_hash, &1}) Enum.map(addresses, &%{&1 | contract_creation_internal_transaction: internal_transactions_map[&1.hash]}) end end - def maybe_preload_contract_creation_internal_transaction(address, repo) do + def maybe_preload_contract_creation_internal_transaction(address, options) do if Application.get_env(:explorer, :api_disable_contract_creation_internal_transaction_association, false) do address else [address] - |> maybe_preload_contract_creation_internal_transaction(repo) + |> maybe_preload_contract_creation_internal_transaction(options) |> List.first() end end @@ -1022,7 +1020,7 @@ defmodule Explorer.Chain.Address do def creation_internal_transaction_query(address_hash) do InternalTransaction |> InternalTransaction.join_transaction_query() - |> InternalTransaction.where_address_match(:created_contract_address, address_hash) + |> InternalTransaction.where_address_match_by_hash(:created_contract_address, address_hash, []) |> where(as(:transaction).status == ^:ok) |> order_by([it], desc: it.block_number, desc: it.transaction_index, desc: it.index) |> limit(1) @@ -1058,7 +1056,7 @@ defmodule Explorer.Chain.Address do |> Chain.select_repo(options).one() |> then(fn address -> if Keyword.get(options, :preload_contract_creation_internal_transaction, false) do - Address.maybe_preload_contract_creation_internal_transaction(address) + Address.maybe_preload_contract_creation_internal_transaction(address, options) else address end diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 4624da788cf..4fd8adefdad 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -258,11 +258,11 @@ defmodule Explorer.Chain.Address.Counters do end end - defp address_hash_to_internal_transactions_limited_count_query(address_hash) do + defp address_hash_to_internal_transactions_limited_count_query(address_hash, options) do query_to_address_hash_wrapped = InternalTransaction |> InternalTransaction.where_nonpending_operation() - |> InternalTransaction.where_address_fields_match(address_hash, :to) + |> InternalTransaction.where_address_fields_match(address_hash, :to, options) |> InternalTransaction.where_is_different_from_parent_transaction() |> limit(@counters_limit) |> wrapped_union_subquery() @@ -270,7 +270,7 @@ defmodule Explorer.Chain.Address.Counters do query_from_address_hash_wrapped = InternalTransaction |> InternalTransaction.where_nonpending_operation() - |> InternalTransaction.where_address_fields_match(address_hash, :from_address_hash) + |> InternalTransaction.where_address_fields_match(address_hash, :from_address_hash, options) |> InternalTransaction.where_is_different_from_parent_transaction() |> limit(@counters_limit) |> wrapped_union_subquery() @@ -475,7 +475,7 @@ defmodule Explorer.Chain.Address.Counters do configure_task( :internal_transactions, cached_counters, - address_hash_to_internal_transactions_limited_count_query(address_hash), + address_hash_to_internal_transactions_limited_count_query(address_hash, options), address_hash, options ) diff --git a/apps/explorer/lib/explorer/chain/advanced_filter.ex b/apps/explorer/lib/explorer/chain/advanced_filter.ex index bb65a51ce46..bab9581f309 100644 --- a/apps/explorer/lib/explorer/chain/advanced_filter.ex +++ b/apps/explorer/lib/explorer/chain/advanced_filter.ex @@ -836,7 +836,8 @@ defmodule Explorer.Chain.AdvancedFilter do options[:from_address_hashes], options[:to_address_hashes], options[:address_relation], - order_by + order_by, + options ) end @@ -1221,14 +1222,14 @@ defmodule Explorer.Chain.AdvancedFilter do end end - defp filter_internal_transactions_by_addresses(query, from_addresses, to_addresses, relation, order_by) do + defp filter_internal_transactions_by_addresses(query, from_addresses, to_addresses, relation, order_by, options) do order_by = fn query -> query |> exclude(:order_by) |> order_by.() end case {process_address_inclusion(from_addresses), process_address_inclusion(to_addresses)} do {nil, nil} -> query - {from, nil} -> do_filter_internal_transactions_by_address(query, from, :from_address, order_by) - {nil, to} -> do_filter_internal_transactions_by_address(query, to, :to_address, order_by) - {from, to} -> do_filter_internal_transactions_by_both_addresses(query, from, to, relation, order_by) + {from, nil} -> do_filter_internal_transactions_by_address(query, from, :from_address, order_by, options) + {nil, to} -> do_filter_internal_transactions_by_address(query, to, :to_address, order_by, options) + {from, to} -> do_filter_internal_transactions_by_both_addresses(query, from, to, relation, order_by, options) end end @@ -1253,12 +1254,12 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end - defp do_filter_internal_transactions_by_address(query, {:include, addresses}, binding, order_by) do + defp do_filter_internal_transactions_by_address(query, {:include, addresses}, binding, order_by, options) do queries = addresses |> Enum.map(fn address -> query - |> InternalTransaction.where_address_match(binding, address) + |> InternalTransaction.where_address_match_by_hash(binding, address, options) |> order_by.() end) |> map_first(&subquery/1) @@ -1267,8 +1268,8 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(queries))) end - defp do_filter_internal_transactions_by_address(query, {:exclude, addresses}, binding, order_by) do - address_ids = AddressIdToAddressHash.hashes_to_ids(addresses) + defp do_filter_internal_transactions_by_address(query, {:exclude, addresses}, binding, order_by, options) do + address_ids = AddressIdToAddressHash.hashes_to_ids(addresses, options) address_id_field = String.to_existing_atom("#{binding}_id") address_hash_field = String.to_existing_atom("#{binding}_hash") @@ -1403,19 +1404,33 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end - defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:include, to}, :and, order_by) do + defp do_filter_internal_transactions_by_both_addresses( + query, + {:include, from}, + {:include, to}, + :and, + order_by, + options + ) do query - |> InternalTransaction.where_address_match(:from_address, from) - |> InternalTransaction.where_address_match(:to_address, to) + |> InternalTransaction.where_address_match_by_hash(:from_address, from, options) + |> InternalTransaction.where_address_match_by_hash(:to_address, to, options) |> order_by.() end - defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:include, to}, _relation, order_by) do + defp do_filter_internal_transactions_by_both_addresses( + query, + {:include, from}, + {:include, to}, + _relation, + order_by, + options + ) do from_queries = from |> Enum.map(fn from_address -> query - |> InternalTransaction.where_address_match(:from_address, from_address) + |> InternalTransaction.where_address_match_by_hash(:from_address, from_address, options) |> order_by.() end) @@ -1423,7 +1438,7 @@ defmodule Explorer.Chain.AdvancedFilter do to |> Enum.map(fn to_address -> query - |> InternalTransaction.where_address_match(:to_address, to_address) + |> InternalTransaction.where_address_match_by_hash(:to_address, to_address, options) |> order_by.() end) @@ -1436,14 +1451,21 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(union_query))) end - defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:exclude, to}, :and, order_by) do - to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:include, from}, + {:exclude, to}, + :and, + order_by, + options + ) do + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to, options) from_queries = from |> Enum.map(fn from_address -> query - |> InternalTransaction.where_address_match(:from_address, from_address) + |> InternalTransaction.where_address_match_by_hash(:from_address, from_address, options) |> where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) |> order_by.() end) @@ -1453,14 +1475,21 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(from_queries))) end - defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:exclude, to}, _relation, order_by) do - to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:include, from}, + {:exclude, to}, + _relation, + order_by, + options + ) do + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to, options) from_queries = from |> Enum.map(fn from_address -> query - |> InternalTransaction.where_address_match(:from_address, from_address) + |> InternalTransaction.where_address_match_by_hash(:from_address, from_address, options) |> or_where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) |> order_by.() end) @@ -1470,14 +1499,21 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(from_queries))) end - defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:include, to}, :and, order_by) do - from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:exclude, from}, + {:include, to}, + :and, + order_by, + options + ) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from, options) to_queries = to |> Enum.map(fn to_address -> query - |> InternalTransaction.where_address_match(:to_address, to_address) + |> InternalTransaction.where_address_match_by_hash(:to_address, to_address, options) |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) |> order_by.() end) @@ -1487,14 +1523,21 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(to_queries))) end - defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:include, to}, _relation, order_by) do - from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:exclude, from}, + {:include, to}, + _relation, + order_by, + options + ) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from, options) to_queries = to |> Enum.map(fn to_address -> query - |> InternalTransaction.where_address_match(:to_address, to_address) + |> InternalTransaction.where_address_match_by_hash(:to_address, to_address, options) |> or_where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) |> order_by.() end) @@ -1504,9 +1547,16 @@ defmodule Explorer.Chain.AdvancedFilter do order_by.(from(internal_transaction in subquery(to_queries))) end - defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:exclude, to}, :and, order_by) do - from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) - to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:exclude, from}, + {:exclude, to}, + :and, + order_by, + options + ) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from, options) + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to, options) query |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) @@ -1514,9 +1564,16 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end - defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:exclude, to}, _relation, order_by) do - from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) - to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + defp do_filter_internal_transactions_by_both_addresses( + query, + {:exclude, from}, + {:exclude, to}, + _relation, + order_by, + options + ) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from, options) + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to, options) query |> where(as(:from_address).hash not in ^from or as(:to_address).hash not in ^to) diff --git a/apps/explorer/lib/explorer/chain/bridged_token.ex b/apps/explorer/lib/explorer/chain/bridged_token.ex index e6cea0c8857..c34b42d14cd 100644 --- a/apps/explorer/lib/explorer/chain/bridged_token.ex +++ b/apps/explorer/lib/explorer/chain/bridged_token.ex @@ -230,7 +230,7 @@ defmodule Explorer.Chain.BridgedToken do created_from_internal_transaction_query = InternalTransaction - |> InternalTransaction.where_address_match(:created_contract_address, token_address_hash) + |> InternalTransaction.where_address_match_by_hash(:created_contract_address, token_address_hash, []) created_from_internal_transaction = created_from_internal_transaction_query diff --git a/apps/explorer/lib/explorer/chain/internal_transaction.ex b/apps/explorer/lib/explorer/chain/internal_transaction.ex index d02e35d929a..0e3496a5ad8 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction.ex @@ -508,8 +508,8 @@ defmodule Explorer.Chain.InternalTransaction do - returns a query considering that the given address_hash can be: to_address_hash, from_address_hash, created_contract_address_hash from internal_transactions' table. """ - def where_address_fields_match(query, address_hash, direction) do - address_id = AddressIdToAddressHash.hash_to_id(address_hash) + def where_address_fields_match(query, address_hash, direction, options) do + address_id = AddressIdToAddressHash.hash_to_id(address_hash, options) case direction do :to -> @@ -576,14 +576,15 @@ defmodule Explorer.Chain.InternalTransaction do An `Ecto.Query.t/0` with the address filter applied. """ - @spec where_address_match( + @spec where_address_match_by_hash( Ecto.Query.t() | module(), :from_address | :to_address | :created_contract_address, - Hash.Address.t() | [Hash.Address.t()] + Hash.Address.t() | [Hash.Address.t()], + [Chain.api?()] ) :: Ecto.Query.t() - def where_address_match(query, address_field, address_hash_or_hashes) do + def where_address_match_by_hash(query, address_field, address_hash_or_hashes, options) do address_hashes = List.wrap(address_hash_or_hashes) - address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes) + address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes, options) where_address_match(query, address_field, address_hashes, address_ids) end @@ -892,7 +893,7 @@ defmodule Explorer.Chain.InternalTransaction do query_to_address_hash_wrapped = __MODULE__ |> where_nonpending_operation() - |> where_address_fields_match(hash, :to) + |> where_address_fields_match(hash, :to, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() |> common_where_limit_order(paging_options) @@ -901,7 +902,7 @@ defmodule Explorer.Chain.InternalTransaction do query_from_address_hash_wrapped = __MODULE__ |> where_nonpending_operation() - |> where_address_fields_match(hash, :from_address_hash) + |> where_address_fields_match(hash, :from_address_hash, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() |> common_where_limit_order(paging_options) @@ -921,7 +922,7 @@ defmodule Explorer.Chain.InternalTransaction do else __MODULE__ |> where_nonpending_operation() - |> where_address_fields_match(hash, direction) + |> where_address_fields_match(hash, direction, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() |> common_where_limit_order(paging_options) diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 8ee5a01ffa4..012fca533bb 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -2404,7 +2404,7 @@ defmodule Explorer.Chain.Transaction do """ @spec decode_transactions([__MODULE__.t()], boolean(), Keyword.t()) :: [nil | {:ok, String.t(), String.t(), map()}] def decode_transactions(transactions, skip_sig_provider?, opts) do - smart_contract_full_abi_map = combine_smart_contract_full_abi_map(transactions) + smart_contract_full_abi_map = combine_smart_contract_full_abi_map(transactions, opts) # first we assemble an empty methods map, so that decoded_input_data will skip ContractMethod.t() lookup and decoding empty_methods_map = @@ -2485,7 +2485,7 @@ defmodule Explorer.Chain.Transaction do defp decode_remaining_transaction({decoded, _}, _, _, _, _), do: decoded - defp combine_smart_contract_full_abi_map(transactions) do + defp combine_smart_contract_full_abi_map(transactions, opts) do # parse unique address hashes of smart-contracts from to_address and created_contract_address properties of the transactions list unique_to_address_hashes = transactions @@ -2498,14 +2498,16 @@ defmodule Explorer.Chain.Transaction do # query from the DB proxy implementation objects for those address hashes multiple_proxy_implementations = - Implementation.get_proxy_implementations_for_multiple_proxies(unique_to_address_hashes) + Implementation.get_proxy_implementations_for_multiple_proxies(unique_to_address_hashes, opts) # query from the DB address objects with smart_contract preload for all found above proxy and implementation addresses addresses_with_smart_contracts = multiple_proxy_implementations |> Enum.flat_map(fn proxy_implementations -> proxy_implementations.address_hashes end) |> Enum.concat(unique_to_address_hashes) - |> Chain.hashes_to_addresses(necessity_by_association: %{smart_contract: :optional}) + |> Chain.hashes_to_addresses( + Keyword.merge(Keyword.take(opts, [:api?]), necessity_by_association: %{smart_contract: :optional}) + ) |> Enum.into(%{}, &{&1.hash, &1}) # combine map %{proxy_address_hash => implementation address hashes} @@ -3092,19 +3094,19 @@ defmodule Explorer.Chain.Transaction do @doc """ Finds all transactions of a certain block number """ - def get_transactions_of_block_number(block_number) do + def get_transactions_of_block_number(block_number, options \\ []) do block_number |> transactions_with_block_number() - |> Repo.all() + |> Chain.select_repo(options).all() end @doc """ Finds all transactions of a certain block numbers """ - def get_transactions_of_block_numbers(block_numbers) do + def get_transactions_of_block_numbers(block_numbers, options \\ []) do block_numbers |> transactions_for_block_numbers() - |> Repo.all() + |> Chain.select_repo(options).all() end @doc """ diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index 95c12d4dcba..ea38e4cec84 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -26,6 +26,8 @@ defmodule Explorer.Etherscan do end_timestamp: nil } + @api_true [api?: true] + @doc """ Returns the maximum allowed page size number. @@ -204,7 +206,7 @@ defmodule Explorer.Etherscan do |> Enum.map(fn direction -> options |> consensus_internal_transactions_with_transactions_and_blocks_query() - |> InternalTransaction.where_address_fields_match(address_hash, direction) + |> InternalTransaction.where_address_fields_match(address_hash, direction, @api_true) |> InternalTransaction.where_is_different_from_parent_transaction() |> InternalTransaction.include_zero_value(options.include_zero_value) |> where_start_block_match_internal_transaction(options) diff --git a/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex b/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex index 499027b48b6..056ae6fc926 100644 --- a/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex +++ b/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex @@ -6,6 +6,7 @@ defmodule Explorer.Utility.AddressIdToAddressHash do use Explorer.Schema + alias Explorer.Chain alias Explorer.Chain.{Address, Hash} alias Explorer.Repo @@ -100,12 +101,13 @@ defmodule Explorer.Utility.AddressIdToAddressHash do ## Returns - The address_id if found, nil otherwise """ - @spec hash_to_id(Hash.Address.t()) :: integer() | nil - def hash_to_id(nil), do: nil + @spec hash_to_id(Hash.Address.t(), [Chain.api?()]) :: integer() | nil + def hash_to_id(hash, options \\ []) + def hash_to_id(nil, _options), do: nil - def hash_to_id(hash) do + def hash_to_id(hash, options) do [hash] - |> hashes_to_ids() + |> hashes_to_ids(options) |> List.first() end @@ -118,12 +120,12 @@ defmodule Explorer.Utility.AddressIdToAddressHash do ## Returns - A list of address ids for the matching mappings """ - @spec hashes_to_ids([Hash.Address.t()]) :: [integer()] - def hashes_to_ids(hashes) do + @spec hashes_to_ids([Hash.Address.t()], [Chain.api?()]) :: [integer()] + def hashes_to_ids(hashes, options \\ []) do __MODULE__ |> where([a], a.address_hash in ^hashes) |> select([a], a.address_id) - |> Repo.all() + |> Chain.select_repo(options).all() end @doc """ @@ -139,12 +141,14 @@ defmodule Explorer.Utility.AddressIdToAddressHash do - The address hash if found - `nil` if the id is `nil` or no mapping exists """ - @spec id_to_hash(integer() | nil) :: Hash.Address.t() | nil - def id_to_hash(nil), do: nil + @spec id_to_hash(integer() | nil, [Chain.api?()]) :: Hash.Address.t() | nil + def id_to_hash(id, options \\ []) + + def id_to_hash(nil, _options), do: nil - def id_to_hash(id) do + def id_to_hash(id, options) do [id] - |> ids_to_hashes() + |> ids_to_hashes(options) |> List.first() end @@ -157,13 +161,15 @@ defmodule Explorer.Utility.AddressIdToAddressHash do ## Returns - A list of address hashes for the matching mappings """ - @spec ids_to_hashes([integer()]) :: [Hash.Address.t()] - def ids_to_hashes([]), do: [] + @spec ids_to_hashes([integer()], [Chain.api?()]) :: [Hash.Address.t()] + def ids_to_hashes(ids, options \\ []) - def ids_to_hashes(ids) do + def ids_to_hashes([], _options), do: [] + + def ids_to_hashes(ids, options) do __MODULE__ |> where([a], a.address_id in ^ids) |> select([a], a.address_hash) - |> Repo.all() + |> Chain.select_repo(options).all() end end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex index 045c2a0b448..0c5b761da1b 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex @@ -12,7 +12,6 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do alias Explorer.{Chain, Etherscan, PagingOptions} alias Explorer.Chain.{Block, BlockNumberHelper, Hash, InternalTransaction, Transaction} alias Explorer.Chain.Cache.BlockNumber - alias Explorer.Repo alias Explorer.Utility.{AddressIdToAddressHash, InternalTransactionsAddressPlaceholder} alias Indexer.Fetcher.InternalTransaction, as: InternalTransactionFetcher @@ -141,26 +140,26 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do |> Enum.map(&serialize/1) |> different_from_parent_transaction() |> Enum.sort_by(& &1.index) - |> join_associations(necessity_by_association) + |> join_associations(necessity_by_association, options) |> page_internal_transaction(paging_options) |> Enum.take(paging_options.page_size) - |> Repo.preload(:block) - |> InternalTransaction.preload_error() + |> Chain.select_repo(options).preload(:block) + |> InternalTransaction.preload_error(options) |> InternalTransaction.preload_transaction() |> InternalTransaction.preload_addresses(options) :ignore -> [transaction.block_number] - |> fetch_block_internal_transactions() + |> fetch_block_internal_transactions(options) |> Enum.map(&serialize/1) |> Enum.filter(&(&1.block_number == transaction.block_number and &1.transaction_index == transaction.index)) |> different_from_parent_transaction() |> Enum.sort_by(& &1.index) - |> join_associations(necessity_by_association) + |> join_associations(necessity_by_association, options) |> page_internal_transaction(paging_options) |> Enum.take(paging_options.page_size) - |> Repo.preload(:block) - |> InternalTransaction.preload_error() + |> Chain.select_repo(options).preload(:block) + |> InternalTransaction.preload_error(options) |> InternalTransaction.preload_transaction() |> InternalTransaction.preload_addresses(options) @@ -209,7 +208,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do unlimited? = Keyword.get(options, :unlimited) [block.number] - |> fetch_block_internal_transactions() + |> fetch_block_internal_transactions(options) |> Enum.map(&serialize/1) |> different_from_parent_transaction() |> filter_by_type(type_filter, call_type_filter) @@ -217,8 +216,8 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do |> page_block_internal_transaction(paging_options) |> Enum.sort_by(&{&1.transaction_index, &1.index}) |> then(&if unlimited?, do: &1, else: Enum.take(&1, paging_options.page_size)) - |> join_associations(necessity_by_association) - |> InternalTransaction.preload_error() + |> join_associations(necessity_by_association, options) + |> InternalTransaction.preload_error(options) |> InternalTransaction.preload_transaction() end end @@ -257,7 +256,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do paging_options = Keyword.get(options, :paging_options, @default_paging_options) - address_id = AddressIdToAddressHash.hash_to_id(address_hash) + address_id = AddressIdToAddressHash.hash_to_id(address_hash, options) block_number_from_paging_options = case paging_options do @@ -291,7 +290,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do index_internal_transaction_desc_order = Keyword.get(options, :index_internal_transaction_desc_order, true) address_id - |> do_fetch_for_address(max_block_number, from_block, paging_options.page_size, sum_mode, sort_direction) + |> do_fetch_for_address(max_block_number, from_block, paging_options.page_size, sum_mode, sort_direction, options) |> Enum.map(&serialize/1) |> filter_by_address(address_hash, direction) |> different_from_parent_transaction() @@ -300,23 +299,24 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do }) |> Enum.sort_by(&{&1.block_number, &1.transaction_index, &1.index}, sort_func) |> Enum.take(paging_options.page_size) - |> join_associations(necessity_by_association) - |> Repo.preload(:block) - |> InternalTransaction.preload_error() + |> join_associations(necessity_by_association, options) + |> Chain.select_repo(options).preload(:block) + |> InternalTransaction.preload_error(options) |> InternalTransaction.preload_transaction() end end - defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, acc \\ []) + defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, options, acc \\ []) - defp do_fetch_for_address(_, to_block, from_block, _, _, _, acc) - when is_integer(from_block) and is_integer(to_block) and from_block >= to_block, do: acc + defp do_fetch_for_address(_, to_block, from_block, _, _, _, _options, acc) + when is_integer(from_block) and is_integer(to_block) and from_block >= to_block, + do: acc - defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, acc) do + defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, options, acc) do internal_transactions = address_id - |> get_block_numbers_for_address(to_block, from_block, limit, sum_mode, sort_direction) - |> fetch_block_internal_transactions() + |> get_block_numbers_for_address(to_block, from_block, limit, sum_mode, sort_direction, options) + |> fetch_block_internal_transactions(options) result = Enum.concat(internal_transactions, acc) @@ -335,6 +335,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do limit - count, sum_mode, sort_direction, + options, result ) @@ -346,6 +347,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do limit - count, sum_mode, sort_direction, + options, result ) end @@ -369,7 +371,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do options = Map.merge(Etherscan.default_options(), raw_options) transaction - |> fetch_by_transaction(paging_options: %PagingOptions{page_size: options.page_size}) + |> fetch_by_transaction(paging_options: %PagingOptions{page_size: options.page_size}, api?: true) |> Enum.map(ðerscan_serialize/1) end @@ -400,12 +402,13 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do from_block: Map.get(options, :startblock), to_block: Map.get(options, :endblock), sort_direction: Map.get(options, :order_by_direction), - index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc + index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc, + api?: true ] address_hash |> fetch_by_address(prepared_options) - |> Repo.preload(:block) + |> Chain.select_repo(prepared_options).preload(:block) |> Enum.map(ðerscan_serialize/1) end @@ -427,13 +430,14 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do from_block: Map.get(options, :startblock), to_block: Map.get(options, :endblock), sort_direction: Map.get(options, :order_by_direction), - index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc + index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc, + api?: true ] prepared_options |> fetch_latest() |> different_from_parent_transaction() - |> Repo.preload(:block) + |> Chain.select_repo(prepared_options).preload(:block) |> Enum.map(ðerscan_serialize/1) end @@ -461,9 +465,9 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do defp fetch_enough(_start_number, _end_number, _count, _options, acc), do: acc - defp get_block_numbers_for_address(nil, _end_block, _start_block, _limit, _sum_mode, _order), do: [] + defp get_block_numbers_for_address(nil, _end_block, _start_block, _limit, _sum_mode, _order, _options), do: [] - defp get_block_numbers_for_address(address_id, end_block, start_block, limit, sum_mode, order) do + defp get_block_numbers_for_address(address_id, end_block, start_block, limit, sum_mode, order, options) do ranked_query = InternalTransactionsAddressPlaceholder |> where([q], q.address_id == ^address_id) @@ -547,7 +551,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do select: c.block_number ) - Repo.all(final_query) + Chain.select_repo(options).all(final_query) end defp filter_by_address(internal_transactions, address_hash, direction) do @@ -567,9 +571,9 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do end) end - defp fetch_block_internal_transactions([]), do: [] + defp fetch_block_internal_transactions([], _options), do: [] - defp fetch_block_internal_transactions(block_numbers) do + defp fetch_block_internal_transactions(block_numbers, options) do if internal_transactions_fetching_disabled?() do [] else @@ -580,7 +584,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do fetch_blocks_internal_transactions(block_numbers, json_rpc_named_arguments) else block_numbers - |> transactions_to_trace() + |> transactions_to_trace(options) |> fetch_transactions_internal_transactions(json_rpc_named_arguments) end end @@ -617,10 +621,10 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do # Collects traceable transactions of all the blocks with a single DB query, # preserving the order of `block_numbers` (and transaction index order within # a block). - defp transactions_to_trace(block_numbers) do + defp transactions_to_trace(block_numbers, options) do transactions_by_block_number = block_numbers - |> Transaction.get_transactions_of_block_numbers() + |> Transaction.get_transactions_of_block_numbers(options) |> Transaction.filter_non_traceable_transactions() |> Enum.group_by(& &1.block_number) @@ -760,22 +764,22 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do Stream.filter(internal_transactions, &(&1.call_type in call_types)) end - defp join_associations(records, necessity_by_association) + defp join_associations(records, necessity_by_association, options) when is_list(records) and is_map(necessity_by_association) do Enum.reduce(necessity_by_association, records, fn {association, necessity}, acc -> - join_association(acc, association, necessity) + join_association(acc, association, necessity, options) end) end - defp join_association(records, [{association, nested_preload}], :optional) + defp join_association(records, [{association, nested_preload}], :optional, options) when is_atom(association) do - Repo.preload(records, [{association, nested_preload}]) + Chain.select_repo(options).preload(records, [{association, nested_preload}]) end - defp join_association(records, [{association, nested_preload}], :required) + defp join_association(records, [{association, nested_preload}], :required, options) when is_atom(association) do records - |> Repo.preload([{association, nested_preload}]) + |> Chain.select_repo(options).preload([{association, nested_preload}]) |> Enum.filter(fn struct -> case Map.fetch(struct, association) do {:ok, value} -> not is_nil(value) @@ -784,13 +788,13 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do end) end - defp join_association(records, association, :optional) do - Repo.preload(records, association) + defp join_association(records, association, :optional, options) do + Chain.select_repo(options).preload(records, association) end - defp join_association(records, association, :required) do + defp join_association(records, association, :required, options) do records - |> Repo.preload(association) + |> Chain.select_repo(options).preload(association) |> Enum.filter(fn struct -> case Map.fetch(struct, association) do {:ok, value} -> diff --git a/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs index 2b0f35e17e4..9724725f2ac 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs @@ -67,7 +67,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ignore = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) end @@ -81,7 +81,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) end @@ -95,7 +95,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) end @@ -114,7 +114,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) end @@ -128,7 +128,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) end @@ -154,7 +154,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(300) @@ -197,7 +197,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(300) @@ -232,7 +232,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(300) @@ -269,7 +269,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(1200) @@ -301,7 +301,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(5400) @@ -337,7 +337,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(300) @@ -382,7 +382,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ContractCreatorOnDemand.trigger_fetch( contract_address |> Repo.preload([:contract_creation_transaction]) - |> Address.maybe_preload_contract_creation_internal_transaction() + |> Address.maybe_preload_contract_creation_internal_transaction([]) ) :timer.sleep(300) From 17976c6779338b2f4b5a1a0e0b5f71a331338bcd Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 4 Sep 2026 11:23:07 +0300 Subject: [PATCH 13/82] perf: Batch uncataloged token transfers scan via Migrator framework (#14774) --- apps/explorer/config/config.exs | 1 + apps/explorer/config/runtime/test.exs | 1 + apps/explorer/lib/explorer/application.ex | 1 + .../lib/explorer/chain/token_transfer.ex | 12 +- ...blocks_with_uncataloged_token_transfers.ex | 59 ++++++++++ .../explorer/chain/token_transfer_test.exs | 32 ++++- ..._with_uncataloged_token_transfers_test.exs | 98 ++++++++++++++++ apps/indexer/lib/indexer/supervisor.ex | 6 +- .../temporary/uncataloged_token_transfers.ex | 105 ----------------- .../uncataloged_token_transfers_test.exs | 110 ------------------ config/runtime.exs | 7 ++ docker-compose/envs/common-blockscout.env | 3 + 12 files changed, 206 insertions(+), 229 deletions(-) create mode 100644 apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex create mode 100644 apps/explorer/test/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers_test.exs delete mode 100644 apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex delete mode 100644 apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index eedd6b54845..211ff4e69f9 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -144,6 +144,7 @@ for migrator <- [ Explorer.Migrator.UnescapeQuotesInTokens, Explorer.Migrator.UnescapeAmpersandsInTokens, Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, + Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds ] do diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index d42b6c5cc0d..10e5825742d 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -77,6 +77,7 @@ for migrator <- [ Explorer.Migrator.UnescapeQuotesInTokens, Explorer.Migrator.UnescapeAmpersandsInTokens, Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, + Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, Explorer.Migrator.DeleteZeroValueInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 4be5c481d20..5458e303811 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -228,6 +228,7 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.UnescapeQuotesInTokens, :indexer), configure_mode_dependent_process(Explorer.Migrator.UnescapeAmpersandsInTokens, :indexer), configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithMissingTransactions, :indexer), + configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, :indexer), configure_mode_dependent_process(Explorer.Migrator.DeleteZeroValueInternalTransactions, :indexer), configure_mode_dependent_process(Explorer.Migrator.EmptyInternalTransactionsData, :indexer), diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index 5ffe1820f6f..d29f6f394f8 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -686,21 +686,23 @@ defmodule Explorer.Chain.TokenTransfer do end @doc """ - Returns a list of block numbers token transfer `t:Log.t/0`s that don't have an - associated `t:TokenTransfer.t/0` record. + Returns a list of block numbers within the given range that contain token + transfer `t:Log.t/0`s without an associated `t:TokenTransfer.t/0` record. """ - @spec uncataloged_token_transfer_block_numbers :: {:ok, [non_neg_integer()]} - def uncataloged_token_transfer_block_numbers do + @spec uncataloged_token_transfer_block_numbers(non_neg_integer(), non_neg_integer()) :: [non_neg_integer()] + def uncataloged_token_transfer_block_numbers(from_block_number, to_block_number) do query = from(l in Log, as: :log, + where: l.block_number >= ^from_block_number, + where: l.block_number <= ^to_block_number, where: ^token_transfer_log_filter_dynamic(), where: not exists(token_transfer_exists_query()), select: l.block_number, distinct: l.block_number ) - Repo.stream_reduce(query, [], &[&1 | &2]) + Repo.all(query, timeout: :infinity) end # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex new file mode 100644 index 00000000000..711d87d8f73 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers do + @moduledoc """ + Searches for blocks containing token transfer logs that don't have an + associated token transfer record and adds them to missing block ranges so + they are re-fetched and the missed token transfers get cataloged. + + Missed token transfers happen due to formats that weren't supported at the + time they were parsed during main indexing. The migration processes blocks + in batches from the maximum block number down to 0 and persists its + progress, so it runs to completion only once. To re-run it after a parser + update, delete the "reindex_blocks_with_uncataloged_token_transfers" record + from the `migration_status` table. + """ + + use Explorer.Migrator.FillingMigration + + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.TokenTransfer + alias Explorer.Migrator.FillingMigration + alias Explorer.Utility.MissingBlockRange + + @migration_name "reindex_blocks_with_uncataloged_token_transfers" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(%{"max_block_number" => from_block_number} = state) do + limit = batch_size() * concurrency() + to_block_number = max(from_block_number - limit + 1, 0) + + {Enum.to_list(from_block_number..to_block_number//-1), %{state | "max_block_number" => to_block_number - 1}} + end + + def last_unprocessed_identifiers(state) do + state + |> Map.put("max_block_number", BlockNumber.get_max()) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + {min_block_number, max_block_number} = Enum.min_max(block_numbers) + + case TokenTransfer.uncataloged_token_transfer_block_numbers(min_block_number, max_block_number) do + [] -> :ok + uncataloged_block_numbers -> MissingBlockRange.add_ranges_by_block_numbers(uncataloged_block_numbers) + end + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/test/explorer/chain/token_transfer_test.exs b/apps/explorer/test/explorer/chain/token_transfer_test.exs index bd4b67a2c63..ca78f06979c 100644 --- a/apps/explorer/test/explorer/chain/token_transfer_test.exs +++ b/apps/explorer/test/explorer/chain/token_transfer_test.exs @@ -330,7 +330,7 @@ defmodule Explorer.Chain.TokenTransferTest do end end - describe "uncataloged_token_transfer_block_numbers/0" do + describe "uncataloged_token_transfer_block_numbers/2" do test "returns a list of block numbers" do block = insert(:block) address = insert(:address) @@ -351,7 +351,31 @@ defmodule Explorer.Chain.TokenTransferTest do ) block_number = log.block_number - assert {:ok, [^block_number]} = TokenTransfer.uncataloged_token_transfer_block_numbers() + assert [^block_number] = TokenTransfer.uncataloged_token_transfer_block_numbers(block_number, block_number) + end + + test "does not return block numbers outside the given range" do + block = insert(:block) + address = insert(:address) + + log = + insert(:token_transfer_log, + transaction: + insert(:transaction, + block_number: block.number, + block_hash: block.hash, + cumulative_gas_used: 0, + gas_used: 0, + index: 0 + ), + block: block, + address_hash: address.hash, + address: address + ) + + block_number = log.block_number + assert [] = TokenTransfer.uncataloged_token_transfer_block_numbers(block_number + 1, block_number + 100) + assert [] = TokenTransfer.uncataloged_token_transfer_block_numbers(max(block_number - 100, 0), block_number - 1) end end @@ -364,7 +388,7 @@ defmodule Explorer.Chain.TokenTransferTest do ) block_number = log.block_number - assert {:ok, [^block_number]} = TokenTransfer.uncataloged_token_transfer_block_numbers() + assert [^block_number] = TokenTransfer.uncataloged_token_transfer_block_numbers(block_number, block_number) end test "does not return block numbers when matching token transfer exists for Celo epoch blocks" do @@ -398,7 +422,7 @@ defmodule Explorer.Chain.TokenTransferTest do to_address: to_address ) - assert {:ok, []} = TokenTransfer.uncataloged_token_transfer_block_numbers() + assert [] = TokenTransfer.uncataloged_token_transfer_block_numbers(log.block_number, log.block_number) end end diff --git a/apps/explorer/test/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers_test.exs new file mode 100644 index 00000000000..62906d12eec --- /dev/null +++ b/apps/explorer/test/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers_test.exs @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfersTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.{MigrationStatus, ReindexBlocksWithUncatalogedTokenTransfers} + alias Explorer.Repo + alias Explorer.Utility.MissingBlockRange + + setup do + Repo.delete_all( + from(ms in MigrationStatus, where: ms.migration_name == ^"reindex_blocks_with_uncataloged_token_transfers") + ) + + configuration = Application.get_env(:explorer, ReindexBlocksWithUncatalogedTokenTransfers) + + Application.put_env( + :explorer, + ReindexBlocksWithUncatalogedTokenTransfers, + Keyword.merge(configuration || [], batch_size: 100, concurrency: 1) + ) + + on_exit(fn -> + Application.put_env(:explorer, ReindexBlocksWithUncatalogedTokenTransfers, configuration) + end) + end + + test "adds blocks with uncataloged token transfers to missing block ranges" do + uncataloged_block = insert(:block) + address = insert(:address) + + insert(:token_transfer_log, + transaction: + insert(:transaction, + block_number: uncataloged_block.number, + block_hash: uncataloged_block.hash, + cumulative_gas_used: 0, + gas_used: 0, + index: 0 + ), + block: uncataloged_block, + block_number: uncataloged_block.number, + address_hash: address.hash, + address: address + ) + + cataloged_block = insert(:block) + + cataloged_transaction = + insert(:transaction, + block_number: cataloged_block.number, + block_hash: cataloged_block.hash, + cumulative_gas_used: 0, + gas_used: 0, + index: 0 + ) + + cataloged_log = + insert(:token_transfer_log, + transaction: cataloged_transaction, + block: cataloged_block, + block_number: cataloged_block.number, + address_hash: address.hash, + address: address + ) + + insert(:token_transfer, + transaction: cataloged_transaction, + block: cataloged_block, + block_number: cataloged_block.number, + log_index: cataloged_log.index + ) + + assert MigrationStatus.get_status("reindex_blocks_with_uncataloged_token_transfers") == nil + + ReindexBlocksWithUncatalogedTokenTransfers.start_link([]) + + wait_for_results( + fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"reindex_blocks_with_uncataloged_token_transfers" and ms.status == "completed" + ) + ) + end, + 60 + ) + + ranges = Repo.all(MissingBlockRange) + + assert Enum.any?(ranges, fn range -> + range.from_number >= uncataloged_block.number and range.to_number <= uncataloged_block.number + end) + + refute Enum.any?(ranges, fn range -> + range.from_number >= cataloged_block.number and range.to_number <= cataloged_block.number + end) + end +end diff --git a/apps/indexer/lib/indexer/supervisor.ex b/apps/indexer/lib/indexer/supervisor.ex index 642e15e5998..ce4abe299da 100644 --- a/apps/indexer/lib/indexer/supervisor.ex +++ b/apps/indexer/lib/indexer/supervisor.ex @@ -72,10 +72,7 @@ defmodule Indexer.Supervisor do alias Indexer.Migrator.RecoveryWETHTokenTransfers - alias Indexer.Temporary.{ - UncatalogedTokenTransfers, - UnclesWithoutIndex - } + alias Indexer.Temporary.UnclesWithoutIndex alias Indexer.Utils.EventNotificationsCleaner @@ -279,7 +276,6 @@ defmodule Indexer.Supervisor do configure(EventNotificationsCleaner, [[]]), # Temporary workers - {UncatalogedTokenTransfers.Supervisor, [[]]}, {UnclesWithoutIndex.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, {PendingOpsCleaner, [[], []]}, diff --git a/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex b/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex deleted file mode 100644 index a6cca831def..00000000000 --- a/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex +++ /dev/null @@ -1,105 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Indexer.Temporary.UncatalogedTokenTransfers do - @moduledoc """ - Catalogs token transfer logs missing an accompanying token transfer record. - - Missed token transfers happen due to formats that aren't supported at the time - they were parsed during main indexing. Updated the parser and rebooting will allow - this process to properly catalog those missed token transfers. - """ - - use GenServer - use Indexer.Fetcher - - require Logger - - alias Explorer.Chain.TokenTransfer - alias Explorer.Utility.MissingBlockRange - alias Indexer.Temporary.UncatalogedTokenTransfers - - def child_spec([init_arguments]) do - child_spec([init_arguments, []]) - end - - def child_spec([_init_arguments, _gen_server_options] = start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(init_arguments, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, init_arguments, gen_server_options) - end - - def init(opts) do - sup_pid = Keyword.fetch!(opts, :supervisor) - retry_interval = Keyword.get(opts, :retry_interval, 10_000) - - send(self(), :scan) - - state = %{ - block_numbers: [], - retry_interval: retry_interval, - sup_pid: sup_pid, - task_ref: nil - } - - {:ok, state} - end - - def handle_info(:scan, state) do - {:ok, block_numbers} = TokenTransfer.uncataloged_token_transfer_block_numbers() - - case block_numbers do - [] -> - Supervisor.stop(state.sup_pid, :normal) - {:noreply, state} - - block_numbers -> - Process.send_after(self(), :push_front_blocks, state.retry_interval) - {:noreply, %{state | block_numbers: block_numbers}} - end - end - - def handle_info(:push_front_blocks, %{block_numbers: block_numbers} = state) do - %Task{ref: ref} = async_push_front(block_numbers) - {:noreply, %{state | task_ref: ref}} - end - - def handle_info({ref, :ok}, %{task_ref: ref, sup_pid: sup_pid}) do - Process.demonitor(ref, [:flush]) - Supervisor.stop(sup_pid, :normal) - {:stop, :shutdown} - end - - def handle_info({ref, {:error, reason}}, %{task_ref: ref, retry_interval: millis} = state) do - case reason do - :queue_unavailable -> :ok - _ -> Logger.error(fn -> inspect(reason) end) - end - - Process.demonitor(ref, [:flush]) - Process.send_after(self(), :push_front_blocks, millis) - - {:noreply, %{state | task_ref: nil}} - end - - def handle_info({:DOWN, ref, :process, _, _}, %{task_ref: ref, retry_interval: millis} = state) do - Process.send_after(self(), :push_front_blocks, millis) - {:noreply, %{state | task_ref: nil}} - end - - defp async_push_front(block_numbers) do - Task.Supervisor.async_nolink( - UncatalogedTokenTransfers.TaskSupervisor, - MissingBlockRange, - :add_ranges_by_block_numbers, - [block_numbers] - ) - end -end diff --git a/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs b/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs deleted file mode 100644 index 830196857a6..00000000000 --- a/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Indexer.Temporary.UncatalogedTokenTransfersTest do - use Explorer.DataCase - - alias Indexer.Temporary.UncatalogedTokenTransfers - - @moduletag :capture_log - - describe "start_link/1" do - test "starts the worker" do - assert {:ok, _pid} = UncatalogedTokenTransfers.start_link(supervisor: self()) - end - end - - describe "init/1" do - test "sends message to self" do - pid = self() - - assert {:ok, %{task_ref: nil, block_numbers: [], sup_pid: ^pid}} = - UncatalogedTokenTransfers.init(supervisor: self()) - - assert_received :scan - end - end - - describe "handle_info with :scan" do - test "sends shutdown to supervisor" do - state = %{task_ref: nil, block_numbers: [], sup_pid: self()} - Task.async(fn -> UncatalogedTokenTransfers.handle_info(:scan, state) end) - assert_receive {_, _, {:terminate, :normal}}, 200 - end - - test "sends message to self when uncataloged token transfers are found" do - block = insert(:block) - address = insert(:address) - - transaction = - insert(:transaction, - block_number: block.number, - block_hash: block.hash, - cumulative_gas_used: 0, - gas_used: 0, - index: 0 - ) - - log = - insert(:token_transfer_log, - transaction: transaction, - address_hash: address.hash, - address: address, - block: block - ) - - block_number = log.block_number - - expected_state = %{task_ref: nil, block_numbers: [block_number], retry_interval: 1} - state = %{task_ref: nil, block_numbers: [], retry_interval: 1} - - assert {:noreply, ^expected_state} = UncatalogedTokenTransfers.handle_info(:scan, state) - assert_receive :push_front_blocks - end - end - - describe "handle_info with :push_front_blocks" do - test "starts a task" do - task_sup_pid = start_supervised!({Task.Supervisor, name: UncatalogedTokenTransfers.TaskSupervisor}) - - state = %{task_ref: nil, block_numbers: [1]} - assert {:noreply, %{task_ref: task_ref}} = UncatalogedTokenTransfers.handle_info(:push_front_blocks, state) - assert is_reference(task_ref) - - refute_receive {^task_ref, {:error, :queue_unavailable}} - assert_receive {^task_ref, :ok} - - stop_supervised(task_sup_pid) - end - end - - describe "handle_info with task ref tuple" do - test "sends shutdown to supervisor on success" do - ref = Process.monitor(self()) - state = %{task_ref: ref, block_numbers: [], sup_pid: self()} - Task.async(fn -> assert UncatalogedTokenTransfers.handle_info({ref, :ok}, state) end) - assert_receive {_, _, {:terminate, :normal}} - end - - test "sends message to self to try again on failure" do - ref = Process.monitor(self()) - state = %{task_ref: ref, block_numbers: [1], sup_pid: self(), retry_interval: 1} - expected_state = %{state | task_ref: nil} - - assert {:noreply, ^expected_state} = - UncatalogedTokenTransfers.handle_info({ref, {:error, :queue_unavailable}}, state) - - assert_receive :push_front_blocks - end - end - - describe "handle_info with failed task" do - test "sends message to self to try again" do - ref = Process.monitor(self()) - state = %{task_ref: ref, block_numbers: [1], sup_pid: self(), retry_interval: 1} - - assert {:noreply, %{task_ref: nil}} = - UncatalogedTokenTransfers.handle_info({:DOWN, ref, :process, self(), :EXIT}, state) - - assert_receive :push_front_blocks - end - end -end diff --git a/config/runtime.exs b/config/runtime.exs index d239dc0251e..2181b9bc333 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -851,6 +851,13 @@ config :explorer, Explorer.Migrator.ReindexBlocksWithMissingTransactions, timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_TIMEOUT", "0s"), enabled: ConfigHelper.parse_bool_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_ENABLED", "false") +config :explorer, Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, + batch_size: + ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_BATCH_SIZE", 1000), + concurrency: + ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_CONCURRENCY", 1), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_TIMEOUT", "0s") + config :explorer, Explorer.Migrator.RestoreOmittedWETHTransfers, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_CONCURRENCY", 5), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_BATCH_SIZE", 50), diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index f148615eeb1..3284431f5d9 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -580,6 +580,9 @@ ACCOUNT_REDIS_URL=redis://redis-db:6379 # MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_CONCURRENCY= # MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_TIMEOUT= # MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_ENABLED= +# MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_BATCH_SIZE= +# MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_CONCURRENCY= +# MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_TIMEOUT= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_BATCH_SIZE= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_CONCURRENCY= # MIGRATION_ARBITRUM_DA_RECORDS_NORMALIZATION_BATCH_SIZE= From 8c191ddd8a6ca795849ab5da918877c09a36d57b Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 4 Sep 2026 14:43:50 +0300 Subject: [PATCH 14/82] perf: Switch address counters to incremental consolidation (#14759) --- .../controllers/address_controller.ex | 18 +- .../controllers/api/v2/address_controller.ex | 17 +- .../api/v2/address_controller_test.exs | 7 +- apps/explorer/config/config.exs | 17 +- apps/explorer/config/runtime/test.exs | 4 + apps/explorer/lib/explorer/application.ex | 6 +- apps/explorer/lib/explorer/chain/address.ex | 3 +- .../lib/explorer/chain/address/counters.ex | 222 ++---- .../chain/cache/background_migrations.ex | 9 + .../chain/cache/counters/address_counters.ex | 525 +++++++++++++ .../counters/address_counters_consolidator.ex | 721 ++++++++++++++++++ .../counters/address_token_transfers_count.ex | 86 --- .../counters/address_transactions_count.ex | 86 --- .../address_transactions_gas_usage_sum.ex | 88 --- .../explorer/chain/cache/counters/helper.ex | 16 +- apps/explorer/lib/explorer/chain/import.ex | 55 ++ .../explorer/chain/import/runner/blocks.ex | 157 +++- .../migrator/backfill_address_counters.ex | 107 +++ .../utility/address_counters_refetch_block.ex | 52 ++ ...0_add_counters_updated_at_to_addresses.exs | 10 + ...create_address_counters_refetch_blocks.exs | 12 + .../address_counters_consolidator_test.exs | 290 +++++++ .../cache/counters/address_counters_test.exs | 244 ++++++ .../chain/import/runner/blocks_test.exs | 94 ++- .../backfill_address_counters_test.exs | 105 +++ config/runtime.exs | 25 +- docker-compose/envs/common-blockscout.env | 15 +- 27 files changed, 2522 insertions(+), 469 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/address_counters.ex create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex create mode 100644 apps/explorer/lib/explorer/migrator/backfill_address_counters.ex create mode 100644 apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex create mode 100644 apps/explorer/priv/repo/migrations/20260831000000_add_counters_updated_at_to_addresses.exs create mode 100644 apps/explorer/priv/repo/migrations/20260831000001_create_address_counters_refetch_blocks.exs create mode 100644 apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs create mode 100644 apps/explorer/test/explorer/chain/cache/counters/address_counters_test.exs create mode 100644 apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex index 3d936fffece..f68551b900a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex @@ -19,7 +19,7 @@ defmodule BlockScoutWeb.AddressController do alias Explorer.{Chain, Market} alias Explorer.Chain.{Address, Wei} alias Explorer.Chain.Address.Counters - alias Explorer.Chain.Cache.Counters.AddressesCount + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressesCount} alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand alias Phoenix.View @@ -176,16 +176,18 @@ defmodule BlockScoutWeb.AddressController do def address_counters(conn, %{"id" => address_hash_string}) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, address} <- Chain.hash_to_address(address_hash) do - {validation_count} = Counters.address_counters(address) + validation_count = Counters.address_to_validation_count(address.hash, []) - transactions_from_db = address.transactions_count || 0 - token_transfers_from_db = address.token_transfers_count || 0 - address_gas_usage_from_db = address.gas_used || 0 + %{ + transactions_count: transactions_count, + token_transfers_count: token_transfers_count, + gas_used: gas_used + } = AddressCounters.fetch(address) json(conn, %{ - transaction_count: transactions_from_db, - token_transfer_count: token_transfers_from_db, - gas_usage_count: address_gas_usage_from_db, + transaction_count: transactions_count, + token_transfer_count: token_transfers_count, + gas_usage_count: gas_used, validation_count: validation_count }) else 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 0a5f6c3d041..f72534d4257 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 @@ -49,6 +49,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do alias Explorer.{Chain, Market, PagingOptions} alias Explorer.Chain.{Address, Beacon.Deposit, Block, Hash, Token, Transaction} alias Explorer.Chain.Address.{CoinBalance, Counters} + alias Explorer.Chain.Cache.Counters.AddressCounters alias Explorer.Chain.Token.FiatValue alias Explorer.Chain.Token.Instance @@ -290,16 +291,18 @@ defmodule BlockScoutWeb.API.V2.AddressController do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, Keyword.merge(@api_true, necessity_by_association: %{})) do {:ok, address} -> - {validation_count} = Counters.address_counters(address, @api_true) + validation_count = Counters.address_to_validation_count(address.hash, @api_true) - transactions_from_db = address.transactions_count || 0 - token_transfers_from_db = address.token_transfers_count || 0 - address_gas_usage_from_db = address.gas_used || 0 + %{ + transactions_count: transactions_count, + token_transfers_count: token_transfers_count, + gas_used: gas_used + } = AddressCounters.fetch(address) json(conn, %{ - transactions_count: to_string(transactions_from_db), - token_transfers_count: to_string(token_transfers_from_db), - gas_usage_count: to_string(address_gas_usage_from_db), + transactions_count: to_string(transactions_count), + token_transfers_count: to_string(token_transfers_count), + gas_usage_count: to_string(gas_used), validations_count: to_string(validation_count) }) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs index 38f6f211ea7..5e0f0f99dfb 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs @@ -7,7 +7,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do alias ABI.{TypeDecoder, TypeEncoder} alias Explorer.{Chain, Repo, TestHelper} - alias Explorer.Chain.Address.Counters + alias Explorer.Chain.Cache.Counters.AddressCountersConsolidator alias Explorer.Chain.{ Address, @@ -608,9 +608,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:block, miner: address) - Counters.transactions_count(address) - Counters.token_transfers_count(address) - Counters.gas_usage_count(address) + safe_block = Repo.aggregate(Block, :max, :number) + AddressCountersConsolidator.consolidate_addresses([address.hash], safe_block) request = get(conn, "/api/v2/addresses/#{address.hash}/counters") json_response = json_response(request, 200) diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 211ff4e69f9..11dfd949dc5 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -37,9 +37,9 @@ config :explorer, Explorer.Chain.Cache.Counters.AddressesCount, enabled: true, enable_consolidation: true -config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum, - enabled: true, - enable_consolidation: true +config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, enabled: true + +config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, enabled: true config :explorer, Explorer.Chain.Cache.Counters.AddressTokensUsdSum, enabled: true, @@ -92,14 +92,6 @@ config :explorer, Explorer.Chain.Cache.Counters.TokenTransfersCount, enabled: true, enable_consolidation: true -config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsCount, - enabled: true, - enable_consolidation: true - -config :explorer, Explorer.Chain.Cache.Counters.AddressTokenTransfersCount, - enabled: true, - enable_consolidation: true - config :explorer, Explorer.Chain.Cache.Counters.BlockBurntFeeCount, enabled: true, enable_consolidation: true @@ -146,7 +138,8 @@ for migrator <- [ Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, Explorer.Migrator.EmptyInternalTransactionsData, - Explorer.Migrator.FillInternalTransactionsAddressIds + Explorer.Migrator.FillInternalTransactionsAddressIds, + Explorer.Migrator.BackfillAddressCounters ] do config :explorer, migrator, enabled: true end diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index 10e5825742d..84e068f714e 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -15,6 +15,9 @@ config :explorer, Explorer.Chain.Cache.Counters.AverageBlockTime, enabled: false # This causes a ConnectionOwnership error config :explorer, Explorer.Chain.Transaction.History.Historian, enabled: false +config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, enabled: false +config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, enabled: false + for counter <- [ Explorer.Chain.Cache.Counters.AddressesCount, Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount, @@ -81,6 +84,7 @@ for migrator <- [ Explorer.Migrator.DeleteZeroValueInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, + Explorer.Migrator.BackfillAddressCounters, # Heavy DB index operations Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex, diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 5458e303811..ee94f58bba9 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -152,9 +152,8 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Chain.Transaction.History.Historian, :indexer), configure(Explorer.Chain.Events.Listener), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressesCount, :api), - configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTransactionsCount, :api), - configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTokenTransfersCount, :api), - configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum, :api), + configure(Explorer.Chain.Cache.Counters.AddressCounters), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressCountersConsolidator, :indexer), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTokensUsdSum, :api), configure(Explorer.Chain.Cache.Counters.TokenHoldersCount), configure(Explorer.Chain.Cache.Counters.TokenTransfersCount), @@ -190,6 +189,7 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.TokenTransferBlockConsensus, :indexer), configure_mode_dependent_process(Explorer.Migrator.RestoreOmittedWETHTransfers, :indexer), configure_mode_dependent_process(Explorer.Migrator.FilecoinPendingAddressOperations, :indexer), + configure_mode_dependent_process(Explorer.Migrator.BackfillAddressCounters, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloL2Epochs, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloAccounts, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloAggregatedElectionRewards, :indexer), diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index cbfc7bfa74e..fc115c80cac 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -136,6 +136,7 @@ defmodule Explorer.Chain.Address.Schema do field(:transactions_count, :integer) field(:token_transfers_count, :integer) field(:gas_used, :integer) + field(:counters_updated_at, :integer) :: Block.block_number() | nil field(:ens_domain_name, :string, virtual: true) field(:metadata, :any, virtual: true) field(:contract_creation_internal_transaction, :map, virtual: true) @@ -191,7 +192,7 @@ defmodule Explorer.Chain.Address do import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] - @optional_attrs ~w(contract_code fetched_coin_balance fetched_coin_balance_block_number nonce verified gas_used transactions_count token_transfers_count)a + @optional_attrs ~w(contract_code fetched_coin_balance fetched_coin_balance_block_number nonce verified gas_used transactions_count token_transfers_count counters_updated_at)a @chain_type_optional_attrs (case @chain_type do :filecoin -> ~w(filecoin_id filecoin_robust filecoin_actor_type)a diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 4fd8adefdad..82734d4b087 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -11,14 +11,9 @@ defmodule Explorer.Chain.Address.Counters do import Explorer.Chain, only: [select_repo: 1, wrapped_union_subquery: 1] - alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.Cache.Counters.{ - AddressTabsElementsCount, - AddressTokenTransfersCount, - AddressTransactionsCount, - AddressTransactionsGasUsageSum - } + alias Explorer.Chain.Cache.Counters.AddressTabsElementsCount alias Explorer.Chain.{ Address, @@ -117,23 +112,22 @@ defmodule Explorer.Chain.Address.Counters do select_repo(options).one(query) end - def address_hash_to_transaction_count_query(address_hash) do + @doc """ + Builds a query for collated transactions sent from or received by the given + address, optionally bounded by a `(from_block_number, to_block_number]` + block range (either bound may be `nil` to leave that side open). + """ + @spec address_hash_to_transaction_count_query( + Hash.Address.t(), + Block.block_number() | nil, + Block.block_number() | nil + ) :: Ecto.Query.t() + def address_hash_to_transaction_count_query(address_hash, from_block_number \\ nil, to_block_number \\ nil) do dynamic = Transaction.where_transactions_to_from(address_hash) Transaction |> where([transaction], ^dynamic) - end - - @spec address_hash_to_transaction_count(Hash.Address.t()) :: non_neg_integer() - def address_hash_to_transaction_count(address_hash) do - query = address_hash_to_transaction_count_query(address_hash) - - Repo.aggregate(query, :count, :hash, timeout: :infinity) - end - - @spec address_to_transaction_count(Address.t()) :: non_neg_integer() - def address_to_transaction_count(address) do - address_hash_to_transaction_count(address.hash) + |> where_block_number_in_range(from_block_number, to_block_number) end @doc """ @@ -147,67 +141,68 @@ defmodule Explorer.Chain.Address.Counters do end @doc """ - Calculates the total gas used by incoming transactions to a given address. + Builds a query for the transactions contributing to the gas usage sum of the + given address, optionally bounded by a `(from_block_number, to_block_number]` + block range. - This function queries the database for all transactions where the - `to_address_hash` matches the provided `address_hash`, and sums up the - `gas_used` for these transactions. - - ## Parameters - - `address_hash`: The address hash to query for incoming transactions. - - ## Returns - - The total gas used by incoming transactions, or `nil` if no transactions - are found or if the sum is null. + `direction_field` selects which side of the transaction is attributed to the + address (see `gas_usage_direction_field/1`). """ - @spec address_to_incoming_transaction_gas_usage(Hash.Address.t()) :: Decimal.t() | nil - def address_to_incoming_transaction_gas_usage(address_hash) do - to_address_query = - from( - transaction in Transaction, - where: transaction.to_address_hash == ^address_hash - ) - - Repo.aggregate(to_address_query, :sum, :gas_used, timeout: :infinity) + @spec address_to_gas_usage_sum_query( + Hash.Address.t(), + :to_address_hash | :from_address_hash, + Block.block_number() | nil, + Block.block_number() | nil + ) :: Ecto.Query.t() + def address_to_gas_usage_sum_query(address_hash, direction_field, from_block_number \\ nil, to_block_number \\ nil) do + Transaction + |> where([transaction], field(transaction, ^direction_field) == ^address_hash) + |> where_block_number_in_range(from_block_number, to_block_number) end @doc """ - Calculates the total gas used by outgoing transactions from a given address. - - This function queries the database for all transactions where the - `from_address_hash` matches the provided `address_hash`, and sums up the - `gas_used` for these transactions. - - ## Parameters - - `address_hash`: the address to query. + Returns the transaction side whose `gas_used` is accumulated into the + address gas usage counter: incoming transactions for smart contracts, + outgoing transactions for EOAs (including EOAs with delegated code). - ## Returns - - The total gas used, or `nil` if no transactions are found or if the sum is null. + The address must have `contract_code` loaded. """ - @spec address_to_outcoming_transaction_gas_usage(Hash.Address.t()) :: Decimal.t() | nil - def address_to_outcoming_transaction_gas_usage(address_hash) do - to_address_query = - from( - transaction in Transaction, - where: transaction.from_address_hash == ^address_hash - ) - - Repo.aggregate(to_address_query, :sum, :gas_used, timeout: :infinity) + @spec gas_usage_direction_field(Address.t()) :: :to_address_hash | :from_address_hash + def gas_usage_direction_field(address) do + if Address.smart_contract?(address) && !Address.eoa_with_code?(address) do + :to_address_hash + else + :from_address_hash + end end - def address_to_token_transfer_count_query(address_hash) do - from( - token_transfer in TokenTransfer, - where: token_transfer.to_address_hash == ^address_hash, - or_where: token_transfer.from_address_hash == ^address_hash + @doc """ + Builds a query for token transfers sent from or received by the given + address, optionally bounded by a `(from_block_number, to_block_number]` + block range. + """ + @spec address_to_token_transfer_count_query( + Hash.Address.t(), + Block.block_number() | nil, + Block.block_number() | nil + ) :: Ecto.Query.t() + def address_to_token_transfer_count_query(address_hash, from_block_number \\ nil, to_block_number \\ nil) do + TokenTransfer + |> where( + [token_transfer], + token_transfer.to_address_hash == ^address_hash or token_transfer.from_address_hash == ^address_hash ) + |> where_block_number_in_range(from_block_number, to_block_number) end - @spec address_to_token_transfer_count(Address.t()) :: non_neg_integer() - def address_to_token_transfer_count(address) do - query = address_to_token_transfer_count_query(address.hash) - - Repo.aggregate(query, :count, timeout: :infinity) + defp where_block_number_in_range(query, from_block_number, to_block_number) do + query + |> then(fn q -> + if is_nil(from_block_number), do: q, else: where(q, [t], t.block_number > ^from_block_number) + end) + |> then(fn q -> + if is_nil(to_block_number), do: q, else: where(q, [t], t.block_number <= ^to_block_number) + end) end def address_hash_to_token_balances_query(address_hash) do @@ -218,46 +213,6 @@ defmodule Explorer.Chain.Address.Counters do ) end - @doc """ - Calculates the total gas usage for a given address. - - This function determines the appropriate gas usage calculation based on the - address type: - - - For smart contracts (excluding EOAs with code), it first checks the gas - usage of incoming transactions. If there are no incoming transactions or - their gas usage is zero, it falls back to the gas usage of outgoing - transactions. - - For regular addresses and EOAs with code, it calculates the gas usage of - outgoing transactions. - - ## Parameters - - `address`: The address to calculate gas usage for. - - ## Returns - - The total gas usage for the address. - - `nil` if no relevant transactions are found or if the sum is null. - """ - @spec address_to_gas_usage_count(Address.t()) :: Decimal.t() | nil - def address_to_gas_usage_count(address) do - if Address.smart_contract?(address) and not Address.eoa_with_code?(address) do - incoming_transaction_gas_usage = address_to_incoming_transaction_gas_usage(address.hash) - - cond do - !incoming_transaction_gas_usage -> - address_to_outcoming_transaction_gas_usage(address.hash) - - Decimal.compare(incoming_transaction_gas_usage, 0) == :eq -> - address_to_outcoming_transaction_gas_usage(address.hash) - - true -> - incoming_transaction_gas_usage - end - else - address_to_outcoming_transaction_gas_usage(address.hash) - end - end - defp address_hash_to_internal_transactions_limited_count_query(address_hash, options) do query_to_address_hash_wrapped = InternalTransaction @@ -287,55 +242,6 @@ defmodule Explorer.Chain.Address.Counters do ) end - def address_counters(address, options \\ []) do - validation_count_task = - Task.async(fn -> - address_to_validation_count(address.hash, options) - end) - - Task.start_link(fn -> - transactions_count(address) - end) - - Task.start_link(fn -> - token_transfers_count(address) - end) - - Task.start_link(fn -> - gas_usage_count(address) - end) - - [ - validation_count_task - ] - |> Task.yield_many(:infinity) - |> Enum.map(fn {_task, res} -> - case res do - {:ok, result} -> - result - - {:exit, reason} -> - raise "Query fetching address counters terminated: #{inspect(reason)}" - - nil -> - raise "Query fetching address counters timed out." - end - end) - |> List.to_tuple() - end - - def transactions_count(address) do - AddressTransactionsCount.fetch(address) - end - - def token_transfers_count(address) do - AddressTokenTransfersCount.fetch(address) - end - - def gas_usage_count(address) do - AddressTransactionsGasUsageSum.fetch(address) - end - @spec address_limited_counters(Hash.t(), Keyword.t()) :: %{atom() => counter} def address_limited_counters(address_hash, options) do cached_counters = diff --git a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex index bc4ac7ae245..8b3339ff0fe 100644 --- a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex +++ b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex @@ -70,6 +70,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :heavy_indexes_create_addresses_hash_contract_code_not_null_index_finished, key: :heavy_indexes_create_address_ids_internal_transactions_indexes_finished, key: :fill_internal_transactions_address_ids_finished, + key: :backfill_address_counters_finished, key: :heavy_indexes_create_logs_address_hash_first_topic_second_topic_block_number_index_finished, key: :heavy_indexes_create_address_current_token_balances_address_hash_block_number_index_finished @@ -79,6 +80,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do AddressCurrentTokenBalanceTokenType, AddressTokenBalanceTokenType, ArbitrumDaRecordsNormalization, + BackfillAddressCounters, BackfillMultichainSearchDB, EmptyInternalTransactionsData, FillInternalTransactionsAddressIds, @@ -426,6 +428,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:backfill_address_counters_finished) do + set_and_return_migration_status( + BackfillAddressCounters, + &set_backfill_address_counters_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_create_logs_address_hash_first_topic_second_topic_block_number_index_finished) do set_and_return_migration_status( CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_counters.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_counters.ex new file mode 100644 index 00000000000..847da796d1b --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_counters.ex @@ -0,0 +1,525 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.AddressCounters do + @moduledoc """ + Shared ETS cache for the per-address counters: transactions count, token + transfers count and gas usage sum. + + The durable values live in the `addresses` table columns + (`transactions_count`, `token_transfers_count`, `gas_used`) and are advanced + incrementally by `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` + up to the block number stored in `addresses.counters_updated_at`. This + module keeps two ETS tables on top of them: + + * `:address_counters` — the display cache consumed by the API. Entries are + seeded from the DB columns on read (`fetch/1`), incremented in place with + the deltas of newly imported transactions and token transfers + (`handle_new_data/3`), re-based from the DB by the consolidator + (`refresh_from_consolidation/4`) and evicted a configurable TTL after + their last authoritative base (seed or consolidator re-base), so any + accumulated live-bump drift is bounded by the TTL even for addresses that + are read constantly. Live bumps never create entries, so every cached + value always sits on top of a real DB-derived base. + + * `:address_counters_dirty` — the consolidator work list: address hash bytes + mapped to the maximum block number seen for the address since its last + consolidation. Markers are written for every import (realtime and catchup) + on nodes where the consolidator runs (`:indexer`/`:all` modes) and are + capped at a configurable size — a dropped marker only delays consolidation + until the address's next activity, it never corrupts the counters. + + On pure API nodes of split deployments the display cache is kept warm by the + realtime `:transactions`/`:token_transfers` chain events (dirty markers are + not written there — no local consolidator would ever drain them); on indexer + and all-mode nodes `Explorer.Chain.Import` calls `handle_new_data/3` + directly, so chain events are ignored there to avoid double counting. + """ + + use GenServer + + alias Explorer.Chain.Address.Counters + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.Helper + alias Explorer.Chain.Events.Subscriber + alias Explorer.Chain.Hash + + @cache_name :address_counters + @dirty_cache_name :address_counters_dirty + + @typedoc """ + Per-address contribution of a set of imported (or deleted) transactions and + token transfers. `gas_in`/`gas_out` are tracked separately because which one + is accumulated into the gas usage counter depends on the address type (see + `Explorer.Chain.Address.Counters.gas_usage_direction_field/1`). + """ + @type delta :: %{ + transactions_count: non_neg_integer(), + token_transfers_count: non_neg_integer(), + gas_in: non_neg_integer(), + gas_out: non_neg_integer(), + max_block_number: non_neg_integer() + } + + @type counters :: %{ + transactions_count: non_neg_integer(), + token_transfers_count: non_neg_integer(), + gas_used: non_neg_integer() + } + + @empty_delta %{transactions_count: 0, token_transfers_count: 0, gas_in: 0, gas_out: 0, max_block_number: 0} + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_args) do + Helper.create_cache_table(@cache_name, write_concurrency: true) + Helper.create_cache_table(@dirty_cache_name, write_concurrency: true) + + if Explorer.mode() == :api do + Subscriber.to(:transactions, :realtime) + Subscriber.to(:token_transfers, :realtime) + end + + schedule_eviction() + + {:ok, %{}} + end + + @doc """ + Returns the counters for the given address: the cached values when present, + the `addresses` table columns of the given struct otherwise (seeding the + cache along the way). + + An address whose counters were never calculated (all three columns are + `NULL`) is reported as all-zero and marked dirty so that the consolidator + computes its values ahead of the background backfill (on nodes with a local + consolidator; on pure API nodes such addresses stay at zero until the + backfill reaches them). + """ + @spec fetch(Explorer.Chain.Address.t()) :: counters() + def fetch(address) do + if cache_table_exists?() do + bytes = hash_bytes(address.hash) + + case snapshot(bytes) do + nil -> seed_from_db(address, bytes) + counters -> counters + end + else + db_counters(address) + end + end + + @doc """ + Registers newly imported `transactions` and `token_transfers` in the cache: + marks the involved addresses dirty for the consolidator and, when `live?` is + `true` (realtime imports), increments the already-cached display values. + """ + @spec handle_new_data([map()], [map()], boolean()) :: :ok + def handle_new_data(transactions, token_transfers, live?) do + if cache_table_exists?() do + transactions + |> compute_deltas(token_transfers) + |> apply_new_deltas(live?) + end + + :ok + end + + defp apply_new_deltas(deltas, _live?) when map_size(deltas) == 0, do: :ok + + defp apply_new_deltas(deltas, live?) do + if live? do + Enum.each(deltas, fn {bytes, delta} -> bump_live(bytes, delta) end) + end + + deltas + |> Enum.map(fn {bytes, delta} -> {bytes, delta.max_block_number} end) + |> mark_dirty() + end + + @doc """ + Computes per-address deltas for the given transactions and token transfers. + + Rows without a block number (pending transactions) are skipped. A + transaction (or transfer) between an address and itself contributes once to + the address counts, matching the `from = address OR to = address` semantics + of the full aggregates. The optional `include?` predicate receives the + address hash bytes and the row block number and can exclude single + contributions (used to restrict deltas to blocks at or below an address + consolidation watermark). + """ + @spec compute_deltas([map()], [map()], (binary(), non_neg_integer() -> boolean())) :: %{binary() => delta()} + def compute_deltas(transactions, token_transfers, include? \\ fn _hash_bytes, _block_number -> true end) do + deltas = Enum.reduce(transactions, %{}, &add_transaction_delta(&2, &1, include?)) + + Enum.reduce(token_transfers, deltas, &add_token_transfer_delta(&2, &1, include?)) + end + + defp add_transaction_delta(acc, %{block_number: nil}, _include?), do: acc + + defp add_transaction_delta(acc, transaction, include?) do + block_number = transaction.block_number + from_bytes = hash_bytes(transaction.from_address_hash) + to_bytes = hash_bytes(transaction.to_address_hash) + gas_used = Helper.gas_to_integer(transaction.gas_used) + + [from_bytes, to_bytes] + |> participants(block_number, include?) + |> Enum.reduce(acc, fn bytes, inner_acc -> + add_delta(inner_acc, bytes, block_number, %{ + transactions_count: 1, + token_transfers_count: 0, + gas_in: if(bytes == to_bytes, do: gas_used, else: 0), + gas_out: if(bytes == from_bytes, do: gas_used, else: 0) + }) + end) + end + + defp add_token_transfer_delta(acc, %{block_number: nil}, _include?), do: acc + + defp add_token_transfer_delta(acc, token_transfer, include?) do + block_number = token_transfer.block_number + + [hash_bytes(token_transfer.from_address_hash), hash_bytes(token_transfer.to_address_hash)] + |> participants(block_number, include?) + |> Enum.reduce(acc, fn bytes, inner_acc -> + add_delta(inner_acc, bytes, block_number, %{ + transactions_count: 0, + token_transfers_count: 1, + gas_in: 0, + gas_out: 0 + }) + end) + end + + defp participants(hash_bytes_list, block_number, include?) do + hash_bytes_list + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.filter(&include?.(&1, block_number)) + end + + @doc """ + Increments the cached display values of the given address by the given + delta. Only already-cached addresses are updated — live bumps never create + entries, so a cached value always originates from a DB read. + """ + @spec bump_live(binary(), delta()) :: :ok + def bump_live(hash_bytes, delta) do + if :ets.member(@cache_name, {hash_bytes, :transactions_count}) do + gas_delta = + case Helper.fetch_from_ets_cache(@cache_name, {hash_bytes, :gas_direction}) do + :to_address_hash -> delta.gas_in + :from_address_hash -> delta.gas_out + _ -> 0 + end + + :ets.update_counter(@cache_name, {hash_bytes, :transactions_count}, delta.transactions_count) + :ets.update_counter(@cache_name, {hash_bytes, :token_transfers_count}, delta.token_transfers_count) + :ets.update_counter(@cache_name, {hash_bytes, :gas_used}, gas_delta) + end + + :ok + rescue + # the entry was evicted between the membership check and the update + ArgumentError -> :ok + end + + @doc """ + Returns the currently cached display values of the given address or `nil` + when the address is not cached. + """ + @spec snapshot(binary()) :: counters() | nil + def snapshot(hash_bytes) do + with true <- cache_table_exists?(), + [{_, transactions_count}] <- :ets.lookup(@cache_name, {hash_bytes, :transactions_count}), + [{_, token_transfers_count}] <- :ets.lookup(@cache_name, {hash_bytes, :token_transfers_count}), + [{_, gas_used}] <- :ets.lookup(@cache_name, {hash_bytes, :gas_used}) do + %{transactions_count: transactions_count, token_transfers_count: token_transfers_count, gas_used: gas_used} + else + _ -> nil + end + end + + @doc """ + Re-bases the cached display values of the given address on the values just + written to the DB by the consolidator. + + The write is an adjustment (`new value - snapshot`) rather than an + overwrite, so live bumps applied concurrently — always for blocks above the + consolidated range — survive on top of the new base. + """ + @spec refresh_from_consolidation(binary(), :to_address_hash | :from_address_hash, counters(), counters() | nil) :: + :ok + def refresh_from_consolidation(hash_bytes, direction_field, new_values, snapshot) do + if not is_nil(snapshot) and :ets.member(@cache_name, {hash_bytes, :transactions_count}) do + :ets.update_counter( + @cache_name, + {hash_bytes, :transactions_count}, + new_values.transactions_count - snapshot.transactions_count + ) + + :ets.update_counter( + @cache_name, + {hash_bytes, :token_transfers_count}, + new_values.token_transfers_count - snapshot.token_transfers_count + ) + + :ets.update_counter(@cache_name, {hash_bytes, :gas_used}, new_values.gas_used - snapshot.gas_used) + :ets.insert(@cache_name, {{hash_bytes, :gas_direction}, direction_field}) + :ets.insert(@cache_name, {{hash_bytes, :ts}, Helper.current_time()}) + end + + :ok + rescue + # the entry was evicted between the membership check and the update + ArgumentError -> :ok + end + + @doc """ + Drops the cached display values of the given addresses so that the next read + re-seeds them from the DB. Used after out-of-band DB counter corrections + (block re-fetch deltas, deep reorg resets). + """ + @spec invalidate([binary()]) :: :ok + def invalidate(hash_bytes_list) do + if cache_table_exists?() do + Enum.each(hash_bytes_list, &delete_display_entries/1) + end + + :ok + end + + @doc """ + Marks addresses dirty for the consolidator. Accepts a list of + `{hash_bytes, block_number}` pairs; for every address the maximum seen block + number is kept. The update is serialized through the cache process to avoid + losing the maximum on concurrent writes. + + A no-op on nodes without a local consolidator (pure API mode) — markers + would never be drained there. + """ + @spec mark_dirty([{binary(), non_neg_integer()}]) :: :ok + def mark_dirty(pairs) when is_list(pairs) do + if consolidator_local?() do + GenServer.cast(__MODULE__, {:mark_dirty, pairs}) + end + + :ok + end + + @doc """ + Marks the given address dirty at the current maximum block number, asking + the consolidator to (re)calculate its counters from scratch when + `counters_updated_at` is `NULL` or incrementally otherwise. + """ + @spec mark_dirty_at_max_block(Hash.Address.t()) :: :ok + def mark_dirty_at_max_block(address_hash) do + mark_dirty([{hash_bytes(address_hash), BlockNumber.get_max()}]) + end + + @doc """ + Returns `true` when there are no dirty markers (or the cache is not started). + """ + @spec dirty_empty?() :: boolean() + def dirty_empty? do + :ets.whereis(@dirty_cache_name) == :undefined or :ets.info(@dirty_cache_name, :size) == 0 + end + + @doc """ + Returns cache statistics for inspection (the process state itself is empty — + all data lives in the ETS tables): the number of addresses in the display + cache, the number of dirty markers awaiting consolidation, and the memory + held by both tables in bytes. + """ + @spec stats() :: %{ + cached_addresses: non_neg_integer(), + dirty_markers: non_neg_integer(), + memory_bytes: non_neg_integer() + } + def stats do + if cache_table_exists?() do + word_size = :erlang.system_info(:wordsize) + + %{ + cached_addresses: :ets.select_count(@cache_name, [{{{:_, :ts}, :_}, [], [true]}]), + dirty_markers: :ets.info(@dirty_cache_name, :size), + memory_bytes: (:ets.info(@cache_name, :memory) + :ets.info(@dirty_cache_name, :memory)) * word_size + } + else + %{cached_addresses: 0, dirty_markers: 0, memory_bytes: 0} + end + end + + @typedoc """ + Opaque `:ets.select/1` continuation of a dirty-markers traversal (the + `:ets.continuation/0` type is not exported by `:ets`). + """ + @type dirty_markers_continuation :: term() + + @doc """ + Starts (with a limit) or continues (with a previously returned continuation) + a traversal of the dirty markers. Returns `{[{hash_bytes, block_number}], continuation}` + or `:"$end_of_table"`. + """ + @spec select_dirty(pos_integer() | dirty_markers_continuation()) :: + {[{binary(), non_neg_integer()}], dirty_markers_continuation()} | :"$end_of_table" + def select_dirty(limit) when is_integer(limit) do + :ets.select(@dirty_cache_name, [{{:"$1", :"$2"}, [], [{{:"$1", :"$2"}}]}], limit) + end + + def select_dirty(continuation) do + :ets.select(continuation) + end + + @doc """ + Deletes the dirty markers of the given addresses unless a concurrent import + bumped them above `safe_block` in the meantime (such markers stay for the + next consolidation cycle). + """ + @spec delete_dirty_markers([binary()], non_neg_integer()) :: :ok + def delete_dirty_markers(hash_bytes_list, safe_block) do + if :ets.whereis(@dirty_cache_name) != :undefined do + Enum.each(hash_bytes_list, fn bytes -> + :ets.select_delete(@dirty_cache_name, [{{bytes, :"$1"}, [{:"=<", :"$1", safe_block}], [true]}]) + end) + end + + :ok + end + + @doc """ + Converts an address hash (or already-extracted hash bytes) to the binary key + used in the cache tables. + """ + @spec hash_bytes(Hash.Address.t() | binary() | nil) :: binary() | nil + def hash_bytes(nil), do: nil + def hash_bytes(%Hash{bytes: bytes}), do: bytes + def hash_bytes(bytes) when is_binary(bytes), do: bytes + + def cache_name, do: @cache_name + def dirty_cache_name, do: @dirty_cache_name + + @impl true + def handle_cast({:mark_dirty, pairs}, state) do + Enum.each(pairs, fn {bytes, block_number} -> insert_dirty_marker(bytes, block_number) end) + + {:noreply, state} + end + + defp insert_dirty_marker(bytes, block_number) do + case :ets.lookup(@dirty_cache_name, bytes) do + [{_, existing}] when existing >= block_number -> + :ok + + [{_, _existing}] -> + :ets.insert(@dirty_cache_name, {bytes, block_number}) + + [] -> + # cap the work list: a dropped marker only delays consolidation until + # the address's next activity (during initial sync all addresses are + # covered by the backfill migration anyway) + if :ets.info(@dirty_cache_name, :size) < max_dirty_markers() do + :ets.insert(@dirty_cache_name, {bytes, block_number}) + end + end + end + + @impl true + def handle_info({:chain_event, :transactions, :realtime, transactions}, state) do + handle_new_data(transactions, [], true) + {:noreply, state} + end + + def handle_info({:chain_event, :token_transfers, :realtime, token_transfers}, state) do + handle_new_data([], token_transfers, true) + {:noreply, state} + end + + def handle_info(:evict, state) do + evict_stale_entries() + schedule_eviction() + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp seed_from_db(address, bytes) do + if is_nil(address.transactions_count) and is_nil(address.token_transfers_count) and is_nil(address.gas_used) do + mark_dirty([{bytes, BlockNumber.get_max()}]) + + %{transactions_count: 0, token_transfers_count: 0, gas_used: 0} + else + counters = db_counters(address) + + :ets.insert_new(@cache_name, [ + {{bytes, :transactions_count}, counters.transactions_count}, + {{bytes, :token_transfers_count}, counters.token_transfers_count}, + {{bytes, :gas_used}, counters.gas_used}, + {{bytes, :gas_direction}, Counters.gas_usage_direction_field(address)}, + {{bytes, :ts}, Helper.current_time()} + ]) + + counters + end + end + + defp db_counters(address) do + %{ + transactions_count: address.transactions_count || 0, + token_transfers_count: address.token_transfers_count || 0, + gas_used: address.gas_used || 0 + } + end + + defp add_delta(acc, bytes, block_number, contribution) do + acc + |> Map.put_new(bytes, @empty_delta) + |> Map.update!(bytes, fn delta -> + %{ + transactions_count: delta.transactions_count + contribution.transactions_count, + token_transfers_count: delta.token_transfers_count + contribution.token_transfers_count, + gas_in: delta.gas_in + contribution.gas_in, + gas_out: delta.gas_out + contribution.gas_out, + max_block_number: max(delta.max_block_number, block_number) + } + end) + end + + defp delete_display_entries(bytes) do + Enum.each([:transactions_count, :token_transfers_count, :gas_used, :gas_direction, :ts], fn key -> + :ets.delete(@cache_name, {bytes, key}) + end) + end + + defp evict_stale_entries do + threshold = Helper.current_time() - ttl() + + @cache_name + |> :ets.select([{{{:"$1", :ts}, :"$2"}, [{:<, :"$2", threshold}], [:"$1"]}]) + |> Enum.each(&delete_display_entries/1) + end + + defp cache_table_exists? do + :ets.whereis(@cache_name) != :undefined + end + + defp consolidator_local? do + Explorer.mode() in [:indexer, :all] + end + + defp max_dirty_markers do + Application.get_env(:explorer, __MODULE__)[:max_dirty_markers] || 1_000_000 + end + + defp schedule_eviction do + Process.send_after(self(), :evict, max(div(ttl(), 4), :timer.minutes(1))) + end + + defp ttl do + Application.get_env(:explorer, __MODULE__)[:ttl] || :timer.hours(2) + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex new file mode 100644 index 00000000000..e2f1b1d80b9 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex @@ -0,0 +1,721 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do + @moduledoc """ + The single writer of the per-address counters columns + (`addresses.transactions_count`, `addresses.token_transfers_count`, + `addresses.gas_used`). + + Periodically consolidates the counters of the addresses marked dirty in + `Explorer.Chain.Cache.Counters.AddressCounters` (a cycle is skipped entirely + when no address was marked since the last one): + + * an address with a `counters_updated_at` watermark gets three cheap + range-bounded aggregates over `(counters_updated_at, safe_block]` whose + results are added to the columns; + * an address without a watermark (`NULL` — not yet backfilled, or reset by + a deep reorg) gets full aggregates bounded by `safe_block` written as + absolute values. + + `safe_block` stays a configurable lag behind the chain head so that shallow + reorgs never invalidate consolidated ranges, and never advances past the + lowest missing block range or block pending re-fetch counter correction, so + that catchup and block re-fetch cannot make consolidated ranges lose or + double-count rows. + + Each cycle starts by settling pending block re-fetch corrections: for every + block whose old content was subtracted when it was queued for re-fetch (see + `Explorer.Utility.AddressCountersRefetchBlock`) and which has been fully + re-imported since, the new content is added back to the counters of the + addresses whose watermark already covers the block. + + Known accepted limitations: + + * pending transactions are not counted (all aggregates are bounded by block + number) — the legacy on-view counters included them; + * `transactions_count`/`token_transfers_count` are clamped at the int4 + maximum of their columns; + * a reorg deeper than the safety lag that lands while a consolidation cycle + is aggregating the very same address can leave the forked content counted + until the address is recalculated (watermark reset) for another reason. + """ + + use GenServer + + import Ecto.Query + + require Logger + + alias Explorer.Chain.{Address, Block, Hash, TokenTransfer, Transaction} + alias Explorer.Chain.Address.Counters + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{AddressCounters, Helper} + alias Explorer.Repo + alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} + + @int4_max 2_147_483_647 + @refetch_blocks_chunk_size 100 + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_args) do + schedule_next_consolidation(:timer.seconds(10)) + + {:ok, %{cycle_task: nil, cycle_started_at: nil, last_cycle_finished_at: nil}} + end + + # A cycle can run for a long time (draining a large dirty backlog, full + # recalculations of heavy addresses), so it runs in a separate supervised + # task: the process itself stays responsive to system messages + # (`:sys.get_state/1`, observer) and its state reports the running cycle. + # The next cycle is scheduled only once the previous one finished, so cycles + # never overlap. + @impl true + def handle_info(:consolidate, %{cycle_task: nil} = state) do + task = Task.Supervisor.async_nolink(Explorer.TaskSupervisor, &consolidate/0) + + {:noreply, %{state | cycle_task: task, cycle_started_at: DateTime.utc_now()}} + end + + # a stray tick while a cycle is still running — the next one is scheduled on + # its completion + def handle_info(:consolidate, state), do: {:noreply, state} + + def handle_info({ref, _result}, %{cycle_task: %Task{ref: ref}} = state) do + Process.demonitor(ref, [:flush]) + + {:noreply, finish_cycle(state)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{cycle_task: %Task{ref: ref}} = state) do + Logger.error(fn -> ["Address counters consolidation cycle crashed: ", inspect(reason)] end) + + {:noreply, finish_cycle(state)} + end + + def handle_info(_, state), do: {:noreply, state} + + defp finish_cycle(state) do + schedule_next_consolidation(interval()) + + %{state | cycle_task: nil, cycle_started_at: nil, last_cycle_finished_at: DateTime.utc_now()} + end + + @doc """ + Runs one consolidation cycle synchronously. Exposed for tests and manual + runs; the supervised process runs it in a task on every `:consolidate` tick. + """ + @spec consolidate() :: :ok + def consolidate do + apply_pending_refetch_deltas() + + unless AddressCounters.dirty_empty?() do + case safe_block() do + nil -> :ok + safe_block -> consolidate_dirty(AddressCounters.select_dirty(batch_size() * concurrency()), safe_block) + end + end + + :ok + end + + @doc """ + Returns the highest block number consolidated ranges may currently cover or + `nil` when consolidation is not possible yet: the chain head minus the + configured safety lag, additionally capped below the lowest missing block + range and the lowest block pending a re-fetch counter correction. + + While the chain is not yet indexed down to the configured first block — + initial backward sync, before the missing ranges collector has recorded the + unindexed tail — no safe block exists at all: consolidating past blocks that + are absent but not yet registered in `missing_block_ranges` would lose their + contributions forever. + """ + @spec safe_block() :: non_neg_integer() | nil + def safe_block do + if lower_chain_indexed?() do + [lagged_head(), min_missing_block_cap(), min_pending_refetch_cap()] + |> Enum.reject(&is_nil/1) + |> Enum.min(fn -> nil end) + |> case do + safe_block when is_integer(safe_block) and safe_block > 0 -> safe_block + _ -> nil + end + end + end + + defp lower_chain_indexed? do + first_block = Application.get_env(:indexer, :first_block) + + case BlockNumber.get_min() do + min_block_number when is_integer(min_block_number) -> min_block_number <= first_block + _ -> false + end + end + + defp lagged_head do + case BlockNumber.get_max() do + max_block_number when is_integer(max_block_number) -> max_block_number - safe_block_lag() + _ -> nil + end + end + + defp min_missing_block_cap do + case MissingBlockRange.fetch_min_max() do + %{min: min} when is_integer(min) -> min - 1 + _ -> nil + end + end + + defp min_pending_refetch_cap do + case AddressCountersRefetchBlock.min_block_number() do + block_number when is_integer(block_number) -> block_number - 1 + _ -> nil + end + end + + @doc """ + Consolidates the given addresses up to `safe_block` synchronously: full + aggregates for addresses without a `counters_updated_at` watermark, + incremental range aggregates for the rest. Used by + `Explorer.Migrator.BackfillAddressCounters` and available for on-demand + recalculation. + """ + @spec consolidate_addresses([Hash.Address.t()], non_neg_integer()) :: :ok + def consolidate_addresses(address_hashes, safe_block) do + address_hashes + |> Enum.map(& &1.bytes) + |> load_addresses() + |> Enum.reject(&(&1.counters_updated_at && &1.counters_updated_at >= safe_block)) + |> case do + [] -> :ok + addresses -> consolidate_chunk(addresses, safe_block) + end + + :ok + end + + defp consolidate_dirty(:"$end_of_table", _safe_block), do: :ok + + defp consolidate_dirty({entries, continuation}, safe_block) do + process_entries(entries, safe_block) + consolidate_dirty(AddressCounters.select_dirty(continuation), safe_block) + end + + defp process_entries(entries, safe_block) do + entry_bytes = Enum.map(entries, fn {bytes, _marker_block} -> bytes end) + addresses_by_bytes = Map.new(load_addresses(entry_bytes), &{&1.hash.bytes, &1}) + + {found_bytes, missing_bytes} = Enum.split_with(entry_bytes, &Map.has_key?(addresses_by_bytes, &1)) + + # markers without a corresponding address row are garbage + AddressCounters.delete_dirty_markers(missing_bytes, safe_block) + + found_bytes + |> Enum.map(&Map.fetch!(addresses_by_bytes, &1)) + |> Enum.reject(&(&1.counters_updated_at && &1.counters_updated_at >= safe_block)) + |> Enum.chunk_every(batch_size()) + |> Task.async_stream(&consolidate_chunk(&1, safe_block), max_concurrency: concurrency(), timeout: :infinity) + |> Stream.run() + end + + defp consolidate_chunk(addresses, safe_block) do + results = + addresses + |> Enum.map(&compute_address_deltas(&1, safe_block)) + |> Enum.reject(&is_nil/1) + + {increments, inits} = Enum.split_with(results, & &1.address.counters_updated_at) + + updated_rows = apply_increments(increments, safe_block) ++ apply_inits(inits, safe_block) + updated_values_by_bytes = Map.new(updated_rows, &{&1.hash.bytes, &1}) + + updated_results = + Enum.filter(results, fn result -> Map.has_key?(updated_values_by_bytes, result.address.hash.bytes) end) + + Enum.each(updated_results, fn result -> + bytes = result.address.hash.bytes + new_values = Map.fetch!(updated_values_by_bytes, bytes) + + AddressCounters.refresh_from_consolidation( + bytes, + result.direction_field, + %{ + transactions_count: new_values.transactions_count || 0, + token_transfers_count: new_values.token_transfers_count || 0, + gas_used: new_values.gas_used || 0 + }, + result.snapshot + ) + end) + + updated_results + |> Enum.map(& &1.address.hash.bytes) + |> AddressCounters.delete_dirty_markers(safe_block) + end + + defp compute_address_deltas(address, safe_block) do + from_block_number = address.counters_updated_at + direction_field = Counters.gas_usage_direction_field(address) + snapshot = AddressCounters.snapshot(address.hash.bytes) + + transactions_count = + address.hash + |> Counters.address_hash_to_transaction_count_query(from_block_number, safe_block) + |> Repo.aggregate(:count, :hash, timeout: query_timeout()) + + token_transfers_count = + address.hash + |> Counters.address_to_token_transfer_count_query(from_block_number, safe_block) + |> Repo.aggregate(:count, timeout: query_timeout()) + + gas_used = + address.hash + |> Counters.address_to_gas_usage_sum_query(direction_field, from_block_number, safe_block) + |> Repo.aggregate(:sum, :gas_used, timeout: query_timeout()) + |> Helper.gas_to_integer() + + %{ + address: address, + direction_field: direction_field, + snapshot: snapshot, + transactions_count: transactions_count, + token_transfers_count: token_transfers_count, + gas_used: gas_used + } + rescue + error -> + Logger.warning(fn -> + [ + "Failed to consolidate counters for address #{to_string(address.hash)}: ", + Exception.format(:error, error, __STACKTRACE__) + ] + end) + + nil + end + + defp apply_increments([], _safe_block), do: [] + + defp apply_increments(results, safe_block) do + hashes = Enum.map(results, & &1.address.hash.bytes) + expected_froms = Enum.map(results, & &1.address.counters_updated_at) + transactions_deltas = Enum.map(results, & &1.transactions_count) + token_transfers_deltas = Enum.map(results, & &1.token_transfers_count) + gas_deltas = Enum.map(results, & &1.gas_used) + + query = + from( + address in Address, + join: + deltas in fragment( + """ + (SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS expected_from, + unnest(?::bigint[]) AS transactions_delta, unnest(?::bigint[]) AS token_transfers_delta, + unnest(?::bigint[]) AS gas_delta) + """, + ^hashes, + ^expected_froms, + ^transactions_deltas, + ^token_transfers_deltas, + ^gas_deltas + ), + on: address.hash == deltas.hash, + where: address.hash in subquery(addresses_lock_query(hashes)), + where: address.counters_updated_at == deltas.expected_from, + update: [ + set: [ + transactions_count: + fragment("LEAST(COALESCE(?, 0) + ?, ?)", address.transactions_count, deltas.transactions_delta, @int4_max), + token_transfers_count: + fragment( + "LEAST(COALESCE(?, 0) + ?, ?)", + address.token_transfers_count, + deltas.token_transfers_delta, + @int4_max + ), + gas_used: fragment("COALESCE(?, 0) + ?", address.gas_used, deltas.gas_delta), + counters_updated_at: ^safe_block, + updated_at: ^DateTime.utc_now() + ] + ], + select: %{ + hash: address.hash, + transactions_count: address.transactions_count, + token_transfers_count: address.token_transfers_count, + gas_used: address.gas_used + } + ) + + {_count, rows} = Repo.update_all(query, [], timeout: query_timeout()) + + rows + end + + defp apply_inits([], _safe_block), do: [] + + defp apply_inits(results, safe_block) do + hashes = Enum.map(results, & &1.address.hash.bytes) + transactions_counts = Enum.map(results, & &1.transactions_count) + token_transfers_counts = Enum.map(results, & &1.token_transfers_count) + gas_sums = Enum.map(results, & &1.gas_used) + + query = + from( + address in Address, + join: + values in fragment( + """ + (SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS transactions_count, + unnest(?::bigint[]) AS token_transfers_count, unnest(?::bigint[]) AS gas_used) + """, + ^hashes, + ^transactions_counts, + ^token_transfers_counts, + ^gas_sums + ), + on: address.hash == values.hash, + where: address.hash in subquery(addresses_lock_query(hashes)), + where: is_nil(address.counters_updated_at), + update: [ + set: [ + transactions_count: fragment("LEAST(?, ?)", values.transactions_count, @int4_max), + token_transfers_count: fragment("LEAST(?, ?)", values.token_transfers_count, @int4_max), + gas_used: values.gas_used, + counters_updated_at: ^safe_block, + updated_at: ^DateTime.utc_now() + ] + ], + select: %{ + hash: address.hash, + transactions_count: address.transactions_count, + token_transfers_count: address.token_transfers_count, + gas_used: address.gas_used + } + ) + + {_count, rows} = Repo.update_all(query, [], timeout: query_timeout()) + + rows + end + + defp load_addresses(hash_bytes_list) do + hashes = Enum.map(hash_bytes_list, &%Hash{byte_count: 20, bytes: &1}) + + query = + from(address in Address, + where: address.hash in ^hashes, + select: + struct(address, [ + :hash, + :contract_code, + :transactions_count, + :token_transfers_count, + :gas_used, + :counters_updated_at + ]) + ) + + Repo.all(query, timeout: query_timeout()) + end + + ## Block re-fetch corrections (positive side) + + defp apply_pending_refetch_deltas do + # a block is ready to settle once its re-fetch completed: the consensus + # block no longer requires a re-fetch AND the block is no longer covered + # by a missing range (the range row survives until the whole re-import — + # all its stages — succeeded, so old content can no longer be observed) + ready_query = + from(refetch_block in AddressCountersRefetchBlock, + as: :refetch_block, + where: + not exists( + from(block in Block, + where: + block.number == parent_as(:refetch_block).block_number and block.consensus == true and + block.refetch_needed == true, + select: 1 + ) + ), + where: + not exists( + from(range in MissingBlockRange, + where: + range.from_number >= parent_as(:refetch_block).block_number and + range.to_number <= parent_as(:refetch_block).block_number, + select: 1 + ) + ), + select: refetch_block.block_number, + limit: @refetch_blocks_chunk_size + ) + + case Repo.all(ready_query, timeout: query_timeout()) do + [] -> + :ok + + block_numbers -> + settle_refetched_blocks(block_numbers) + # more rows may be ready + apply_pending_refetch_deltas() + end + rescue + error -> + Logger.error(fn -> + ["Failed to settle re-fetched blocks counters: ", Exception.format(:error, error, __STACKTRACE__)] + end) + + :ok + end + + defp settle_refetched_blocks(block_numbers) do + {:ok, updated_bytes} = + Repo.transaction( + fn -> + transactions = + Repo.all( + from(transaction in Transaction, + where: transaction.block_number in ^block_numbers, + select: struct(transaction, [:block_number, :from_address_hash, :to_address_hash, :gas_used]) + ), + timeout: query_timeout() + ) + + token_transfers = + Repo.all( + from(token_transfer in TokenTransfer, + where: token_transfer.block_number in ^block_numbers, + select: struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash]) + ), + timeout: query_timeout() + ) + + updated_bytes = apply_covered_deltas(transactions, token_transfers, :positive) + + AddressCountersRefetchBlock.delete_by_block_numbers(Enum.sort(block_numbers)) + + updated_bytes + end, + timeout: :infinity + ) + + AddressCounters.invalidate(updated_bytes) + end + + @doc """ + Applies the per-address counter deltas of the given transactions and token + transfers directly to the `addresses` columns, restricted per address to the + blocks already covered by its `counters_updated_at` watermark (contributions + above the watermark are left to regular range consolidation). `sign` selects + whether the contributions are added (`:positive` — re-imported content of + re-fetched blocks) or subtracted (`:negative` — old content of blocks queued + for re-fetch, see `Explorer.Chain.Import.Runner.Blocks`). + + Runs on `Explorer.Repo` unless another repo is given. Returns the hash bytes + of the updated addresses. + """ + @spec apply_covered_deltas([map()], [map()], :positive | :negative, Ecto.Repo.t()) :: [binary()] + def apply_covered_deltas(transactions, token_transfers, sign, repo \\ Repo) do + watermarks = + transactions + |> participant_hashes(token_transfers) + |> load_watermarks(repo) + + deltas = + AddressCounters.compute_deltas(transactions, token_transfers, fn bytes, block_number -> + case watermarks[bytes] do + nil -> false + watermark -> block_number <= watermark + end + end) + + if map_size(deltas) == 0 do + [] + else + apply_signed_deltas(deltas, watermarks, sign, repo) + end + end + + @doc """ + Resets the `counters_updated_at` watermark of the given addresses — forcing + a full counters recalculation on their next consolidation — when their + watermark already covers the given block number (a covered range changed + under it: deep reorg, or rows inserted below the watermark by an + out-of-band importer). Returns the hash bytes of the reset addresses; the + caller is responsible for invalidating the display cache and marking them + dirty. + """ + @spec reset_covered_watermarks(%{binary() => non_neg_integer()}, Ecto.Repo.t()) :: [binary()] + def reset_covered_watermarks(min_block_by_hash_bytes, repo \\ Repo) + + def reset_covered_watermarks(min_block_by_hash_bytes, _repo) when map_size(min_block_by_hash_bytes) == 0, do: [] + + def reset_covered_watermarks(min_block_by_hash_bytes, repo) do + {hashes, min_blocks} = min_block_by_hash_bytes |> Enum.sort() |> Enum.unzip() + + query = + from( + address in Address, + join: + affected in fragment( + "(SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS block_number)", + ^hashes, + ^min_blocks + ), + on: address.hash == affected.hash, + where: address.hash in subquery(addresses_lock_query(hashes)), + where: not is_nil(address.counters_updated_at) and address.counters_updated_at >= affected.block_number, + update: [set: [counters_updated_at: nil, updated_at: ^DateTime.utc_now()]], + select: address.hash + ) + + {_count, reset_hashes} = repo.update_all(query, [], timeout: query_timeout()) + + Enum.map(reset_hashes, & &1.bytes) + end + + defp apply_signed_deltas(deltas, watermarks, sign, repo) do + multiplier = if sign == :negative, do: -1, else: 1 + + entries = Enum.to_list(deltas) + hashes = Enum.map(entries, fn {bytes, _delta} -> bytes end) + expected_watermarks = Enum.map(entries, fn {bytes, _delta} -> watermarks[bytes] end) + transactions_deltas = Enum.map(entries, fn {_bytes, delta} -> multiplier * delta.transactions_count end) + token_transfers_deltas = Enum.map(entries, fn {_bytes, delta} -> multiplier * delta.token_transfers_count end) + gas_in_deltas = Enum.map(entries, fn {_bytes, delta} -> multiplier * delta.gas_in end) + gas_out_deltas = Enum.map(entries, fn {_bytes, delta} -> multiplier * delta.gas_out end) + + query = + from( + address in Address, + join: + deltas in fragment( + """ + (SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS expected_watermark, + unnest(?::bigint[]) AS transactions_delta, unnest(?::bigint[]) AS token_transfers_delta, + unnest(?::bigint[]) AS gas_in_delta, unnest(?::bigint[]) AS gas_out_delta) + """, + ^hashes, + ^expected_watermarks, + ^transactions_deltas, + ^token_transfers_deltas, + ^gas_in_deltas, + ^gas_out_deltas + ), + on: address.hash == deltas.hash, + where: address.hash in subquery(addresses_lock_query(hashes)), + # only apply when the watermark is unchanged since the deltas were + # computed; changed addresses are reset below for a full recalculation + where: address.counters_updated_at == deltas.expected_watermark, + update: [ + set: [ + transactions_count: + fragment( + "LEAST(GREATEST(COALESCE(?, 0) + ?, 0), ?)", + address.transactions_count, + deltas.transactions_delta, + @int4_max + ), + token_transfers_count: + fragment( + "LEAST(GREATEST(COALESCE(?, 0) + ?, 0), ?)", + address.token_transfers_count, + deltas.token_transfers_delta, + @int4_max + ), + # incoming gas for smart contracts, outgoing for EOAs — including + # EIP-7702 EOAs with delegated code (code starts with 0xef0100) + gas_used: + fragment( + """ + GREATEST(COALESCE(?, 0) + CASE + WHEN ? IS NULL OR substring(? from 1 for 3) = decode('ef0100', 'hex') THEN ? + ELSE ? + END, 0) + """, + address.gas_used, + address.contract_code, + address.contract_code, + deltas.gas_out_delta, + deltas.gas_in_delta + ), + updated_at: ^DateTime.utc_now() + ] + ], + select: address.hash + ) + + {_count, updated_hashes} = repo.update_all(query, [], timeout: query_timeout()) + + updated_bytes = Enum.map(updated_hashes, & &1.bytes) + + # addresses whose watermark moved between the read and the guarded update + # cannot be corrected by a delta anymore — force a full recalculation + missed_reset_bytes = + (hashes -- updated_bytes) + |> Map.new(&{&1, 0}) + |> reset_covered_watermarks(repo) + + updated_bytes ++ missed_reset_bytes + end + + defp participant_hashes(transactions, token_transfers) do + (transactions ++ token_transfers) + |> Enum.flat_map(fn row -> [row.from_address_hash, row.to_address_hash] end) + |> Enum.reject(&is_nil/1) + |> Enum.map(&AddressCounters.hash_bytes/1) + |> Enum.uniq() + end + + defp load_watermarks(participant_bytes, repo) do + hashes = Enum.map(participant_bytes, &%Hash{byte_count: 20, bytes: &1}) + + from(address in Address, + where: address.hash in ^hashes and not is_nil(address.counters_updated_at), + select: {address.hash, address.counters_updated_at} + ) + |> repo.all(timeout: query_timeout()) + |> Map.new(fn {hash, watermark} -> {hash.bytes, watermark} end) + end + + # Enforce Address ShareLocks order (see docs: sharelocks.md) + defp addresses_lock_query(hashes_bytes) do + hashes = Enum.map(hashes_bytes, &%Hash{byte_count: 20, bytes: &1}) + + from( + address in Address, + where: address.hash in ^hashes, + select: address.hash, + order_by: address.hash, + lock: "FOR NO KEY UPDATE" + ) + end + + defp schedule_next_consolidation(timeout) do + Process.send_after(self(), :consolidate, timeout) + end + + defp interval do + Application.get_env(:explorer, __MODULE__)[:interval] || :timer.minutes(10) + end + + defp batch_size do + Application.get_env(:explorer, __MODULE__)[:batch_size] || 100 + end + + defp concurrency do + Application.get_env(:explorer, __MODULE__)[:concurrency] || 4 + end + + defp safe_block_lag do + Application.get_env(:explorer, __MODULE__)[:safe_block_lag] || 12 + end + + defp query_timeout do + Application.get_env(:explorer, __MODULE__)[:query_timeout] || :timer.minutes(5) + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex deleted file mode 100644 index 225b4922886..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.AddressTokenTransfersCount do - @moduledoc """ - Caches Address token transfers count. - """ - use GenServer - use Utils.CompileTimeEnvHelper, enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]] - - alias Ecto.Changeset - alias Explorer.Chain.Address.Counters - alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Repo - - @cache_name :address_token_transfers_counter - @last_update_key "last_update" - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - Helper.create_cache_table(@cache_name) - - {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} - end - - @impl true - def handle_continue(:ok, %{consolidate?: true} = state) do - {:noreply, state} - end - - @impl true - def handle_continue(:ok, state) do - {:noreply, state} - end - - @impl true - def handle_info(:consolidate, state) do - {:noreply, state} - end - - def fetch(address) do - if cache_expired?(address) do - update_cache(address) - end - - address_hash_string = to_string(address.hash) - fetch_from_cache("hash_#{address_hash_string}") - end - - def cache_name, do: @cache_name - - defp cache_expired?(address) do - cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - address_hash_string = to_string(address.hash) - updated_at = fetch_from_cache("hash_#{address_hash_string}_#{@last_update_key}") - - cond do - is_nil(updated_at) -> true - Helper.current_time() - updated_at > cache_period -> true - true -> false - end - end - - defp update_cache(address) do - address_hash_string = to_string(address.hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@last_update_key}", Helper.current_time()) - new_data = Counters.address_to_token_transfer_count(address) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) - put_into_db(address, new_data) - end - - defp fetch_from_cache(key) do - Helper.fetch_from_ets_cache(@cache_name, key) - end - - defp put_into_db(address, value) do - address - |> Changeset.change(%{token_transfers_count: value}) - |> Repo.update() - end - - defp enable_consolidation?, do: @enable_consolidation -end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex deleted file mode 100644 index 7ff8aece4e1..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.AddressTransactionsCount do - @moduledoc """ - Caches Address transactions count. - """ - use GenServer - use Utils.CompileTimeEnvHelper, enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]] - - alias Ecto.Changeset - alias Explorer.Chain.Address.Counters - alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Repo - - @cache_name :address_transactions_counter - @last_update_key "last_update" - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - Helper.create_cache_table(@cache_name) - - {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} - end - - @impl true - def handle_continue(:ok, %{consolidate?: true} = state) do - {:noreply, state} - end - - @impl true - def handle_continue(:ok, state) do - {:noreply, state} - end - - @impl true - def handle_info(:consolidate, state) do - {:noreply, state} - end - - def fetch(address) do - if cache_expired?(address) do - update_cache(address) - end - - address_hash_string = to_string(address.hash) - fetch_from_cache("hash_#{address_hash_string}") - end - - def cache_name, do: @cache_name - - defp cache_expired?(address) do - cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - address_hash_string = to_string(address.hash) - updated_at = fetch_from_cache("hash_#{address_hash_string}_#{@last_update_key}") - - cond do - is_nil(updated_at) -> true - Helper.current_time() - updated_at > cache_period -> true - true -> false - end - end - - defp update_cache(address) do - address_hash_string = to_string(address.hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@last_update_key}", Helper.current_time()) - new_data = Counters.address_to_transaction_count(address) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) - put_into_db(address, new_data) - end - - defp fetch_from_cache(key) do - Helper.fetch_from_ets_cache(@cache_name, key) - end - - defp put_into_db(address, value) do - address - |> Changeset.change(%{transactions_count: value}) - |> Repo.update() - end - - defp enable_consolidation?, do: @enable_consolidation -end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex deleted file mode 100644 index fa9bda0ee3e..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum do - @moduledoc """ - Caches Address transactions gas usage count. - """ - use GenServer - use Utils.CompileTimeEnvHelper, enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]] - - alias Ecto.Changeset - alias Explorer.Chain.Address.Counters - alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Repo - - @cache_name :address_transactions_gas_usage_counter - @last_update_key "last_update" - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - Helper.create_cache_table(@cache_name) - - {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} - end - - @impl true - def handle_continue(:ok, %{consolidate?: true} = state) do - {:noreply, state} - end - - @impl true - def handle_continue(:ok, state) do - {:noreply, state} - end - - @impl true - def handle_info(:consolidate, state) do - {:noreply, state} - end - - def fetch(address) do - if cache_expired?(address) do - update_cache(address) - end - - address_hash_string = to_string(address.hash) - fetch_from_cache("hash_#{address_hash_string}") - end - - def cache_name, do: @cache_name - - defp cache_expired?(address) do - cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - address_hash_string = to_string(address.hash) - updated_at = fetch_from_cache("hash_#{address_hash_string}_#{@last_update_key}") - - cond do - is_nil(updated_at) -> true - Helper.current_time() - updated_at > cache_period -> true - true -> false - end - end - - defp update_cache(address) do - address_hash_string = to_string(address.hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@last_update_key}", Helper.current_time()) - new_data = Counters.address_to_gas_usage_count(address) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) - put_into_db(address, new_data) - end - - defp fetch_from_cache(key) do - Helper.fetch_from_ets_cache(@cache_name, key) - end - - defp put_into_db(_address, value) when is_nil(value), do: :ignore - - defp put_into_db(address, value) do - address - |> Changeset.change(%{gas_used: Decimal.to_integer(value)}) - |> Repo.update() - end - - defp enable_consolidation?, do: @enable_consolidation -end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/helper.ex b/apps/explorer/lib/explorer/chain/cache/counters/helper.ex index 9a74b74b78d..7fee87a4960 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/helper.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/helper.ex @@ -35,6 +35,14 @@ defmodule Explorer.Chain.Cache.Counters.Helper do DateTime.to_unix(utc_now, :millisecond) end + @doc """ + Normalizes a gas amount (`nil`, `Decimal` or integer) to an integer. + """ + @spec gas_to_integer(Decimal.t() | non_neg_integer() | nil) :: non_neg_integer() + def gas_to_integer(nil), do: 0 + def gas_to_integer(%Decimal{} = gas), do: Decimal.to_integer(gas) + def gas_to_integer(gas) when is_integer(gas), do: gas + @doc """ Fetches a value from the ETS cache. @@ -86,15 +94,17 @@ defmodule Explorer.Chain.Cache.Counters.Helper do ## Parameters - cache_name: The name of the cache table to be created. + - extra_opts: Additional `:ets.new/2` options appended to the default ones + (e.g. `[write_concurrency: true]`). ## Returns - The table identifier if the table is created. - `nil` if the table already exists. """ - @spec create_cache_table(atom()) :: any() - def create_cache_table(cache_name) do + @spec create_cache_table(atom(), list()) :: any() + def create_cache_table(cache_name, extra_opts \\ []) do if :ets.whereis(cache_name) == :undefined do - :ets.new(cache_name, @ets_opts) + :ets.new(cache_name, @ets_opts ++ extra_opts) end end diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index 4fbd82ce206..00658705d91 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -7,6 +7,8 @@ defmodule Explorer.Chain.Import do alias Ecto.{Changeset, Multi} alias Explorer.Account.Notify alias Explorer.Chain.{Block, Import} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Import.Stage alias Explorer.Repo @@ -155,11 +157,64 @@ defmodule Explorer.Chain.Import do {:ok, runner_to_changes_list} <- runner_to_changes_list(valid_runner_option_pairs), {:ok, data} <- insert_runner_to_changes_list(runner_to_changes_list, options) do Notify.async(data[:transactions]) + update_address_counters(data, Map.get(options, :broadcast, false)) Publisher.broadcast(Map.drop(data, @not_broadcasted_runners), Map.get(options, :broadcast, false)) {:ok, data} end end + # Registers imported transactions and token transfers in the incremental + # address counters, whatever the import source (block fetcher, on-demand and + # async fetchers, migrators): marks the involved addresses dirty for + # `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` and live-bumps + # the display cache for realtime imports. Token transfers inserted below an + # address's consolidation watermark (derived asynchronously — e.g. from + # internal transactions — or backfilled by migrators) cannot be covered by + # incremental range aggregates anymore, so such addresses get their + # watermark reset for a full recalculation. + # + # Runs after the import transaction committed, so failures here (e.g. a + # transient DB error in the watermark reset) must not fail the import or + # prevent the subsequent event broadcast — they only cost counter freshness. + defp update_address_counters(data, broadcast_type) do + transactions = Map.get(data, :transactions, []) + token_transfers = Map.get(data, :token_transfers, []) + + AddressCounters.handle_new_data(transactions, token_transfers, broadcast_type == :realtime) + + if token_transfers != [] and Explorer.mode() in [:indexer, :all] do + reset_watermarks_covering_token_transfers(token_transfers) + end + + :ok + rescue + error -> + Logger.error(fn -> + ["Could not update address counters for imported data: ", Exception.format(:error, error, __STACKTRACE__)] + end) + + :ok + end + + defp reset_watermarks_covering_token_transfers(token_transfers) do + reset_bytes = + token_transfers + |> Enum.flat_map(&token_transfer_participants/1) + |> Enum.reduce(%{}, fn {bytes, block_number}, acc -> + Map.update(acc, bytes, block_number, &min(&1, block_number)) + end) + |> AddressCountersConsolidator.reset_covered_watermarks() + + AddressCounters.invalidate(reset_bytes) + AddressCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + end + + defp token_transfer_participants(token_transfer) do + for address_hash <- [token_transfer.from_address_hash, token_transfer.to_address_hash], + not is_nil(address_hash) and not is_nil(token_transfer.block_number), + do: {address_hash.bytes, token_transfer.block_number} + end + @doc """ Prepares a bulk import transaction without executing it. diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index e05ab9dcff2..375f88feef3 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -30,6 +30,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do alias Explorer.Chain.Block.Reward alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} alias Explorer.Chain.Import.Runner alias Explorer.Chain.Import.Runner.Address.CurrentTokenBalances alias Explorer.Chain.Import.Runner.{Addresses, TokenInstances, Tokens} @@ -37,7 +38,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer alias Explorer.Prometheus.Instrumenter alias Explorer.Repo, as: ExplorerRepo - alias Explorer.Utility.MissingBlockRange + alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} alias Explorer.Chain.Celo.AggregatedElectionReward, as: CeloAggregatedElectionReward alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward @@ -95,6 +96,17 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :lose_consensus ) end) + |> Multi.run(:address_counters_refetched_block_numbers, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> + # Note, needs to be executed before `blocks` which resets `refetch_needed` + address_counters_refetched_block_numbers(repo, changes_list, insert_options) + end, + :address_referencing, + :blocks, + :address_counters_refetched_block_numbers + ) + end) |> Multi.run(:blocks, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> @@ -159,6 +171,18 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :derive_transaction_forks ) end) + |> Multi.run(:address_counters_corrections, fn repo, + %{ + address_counters_refetched_block_numbers: refetched_block_numbers, + fork_transactions: forked_transactions + } -> + Instrumenter.block_import_stage_runner( + fn -> address_counters_corrections(repo, refetched_block_numbers, forked_transactions, insert_options) end, + :address_referencing, + :blocks, + :address_counters_corrections + ) + end) |> Multi.run(:delete_address_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> Instrumenter.block_import_stage_runner( fn -> delete_address_token_balances(repo, non_consensus_blocks, insert_options) end, @@ -279,6 +303,137 @@ defmodule Explorer.Chain.Import.Runner.Blocks do @impl Runner def timeout, do: @timeout + # Detects which of the incoming blocks are re-imports of blocks queued for a + # re-fetch: their stored row still has `refetch_needed` set (the upcoming + # `blocks` upsert resets it). Blocks already present in the + # `address_counters_refetch_blocks` queue are skipped — their current content + # was never added to the counters, so there is nothing to subtract. + defp address_counters_refetched_block_numbers(repo, blocks_changes, %{timeout: timeout}) do + hashes = blocks_changes |> Enum.map(& &1.hash) |> Enum.uniq() + + refetched_numbers = + repo.all( + from(block in Block, + where: block.hash in ^hashes and block.refetch_needed == true, + select: block.number + ), + timeout: timeout + ) + + case refetched_numbers do + [] -> + {:ok, []} + + _ -> + pending_numbers = + repo.all( + from(refetch_block in AddressCountersRefetchBlock, + where: refetch_block.block_number in ^refetched_numbers, + select: refetch_block.block_number + ), + timeout: timeout + ) + + {:ok, refetched_numbers -- pending_numbers} + end + rescue + postgrex_error in Postgrex.Error -> + {:error, %{exception: postgrex_error}} + end + + # Corrects the incremental address counters for content changing under + # already-consolidated ranges: + # + # * deep reorgs — participants of forked transactions and of their token + # transfers whose `counters_updated_at` watermark covers the forked block + # get their watermark reset for a full recalculation (reorgs within the + # consolidation safety lag never satisfy the condition); + # * block re-fetches — the old content of the re-imported blocks is + # subtracted from the already-covered counters while it is still in the + # DB, and the block numbers are queued in `address_counters_refetch_blocks` + # so that `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` adds + # the re-imported content back once the re-fetch completes. Content of + # forked transactions is excluded (`block_number` already nulled) — those + # addresses are covered by the watermark reset. + defp address_counters_corrections(repo, refetched_block_numbers, forked_transactions, %{ + timeout: timeout, + timestamps: timestamps + }) do + reset_bytes = reset_watermarks_for_forked(repo, forked_transactions, timeout) + + subtracted_bytes = subtract_refetched_blocks_content(repo, refetched_block_numbers, timeout, timestamps) + + AddressCounters.invalidate(reset_bytes ++ subtracted_bytes) + AddressCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + + {:ok, %{reset: reset_bytes, refetched_block_numbers: refetched_block_numbers}} + rescue + postgrex_error in Postgrex.Error -> + {:error, %{exception: postgrex_error}} + end + + defp reset_watermarks_for_forked(_repo, [], _timeout), do: [] + + defp reset_watermarks_for_forked(repo, forked_transactions, timeout) do + forked_hashes = Enum.map(forked_transactions, & &1.hash) + + forked_token_transfers = + repo.all( + from(token_transfer in TokenTransfer, + where: token_transfer.transaction_hash in ^forked_hashes, + select: struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash]) + ), + timeout: timeout + ) + + (forked_transactions ++ forked_token_transfers) + |> Enum.flat_map(fn row -> + for address_hash <- [row.from_address_hash, row.to_address_hash], + not is_nil(address_hash) and not is_nil(row.block_number), + do: {address_hash.bytes, row.block_number} + end) + |> Enum.reduce(%{}, fn {bytes, block_number}, acc -> + Map.update(acc, bytes, block_number, &min(&1, block_number)) + end) + |> AddressCountersConsolidator.reset_covered_watermarks(repo) + end + + defp subtract_refetched_blocks_content(_repo, [], _timeout, _timestamps), do: [] + + defp subtract_refetched_blocks_content(repo, block_numbers, timeout, timestamps) do + transactions = + repo.all( + from(transaction in Transaction, + where: transaction.block_number in ^block_numbers, + select: struct(transaction, [:block_number, :from_address_hash, :to_address_hash, :gas_used]) + ), + timeout: timeout + ) + + token_transfers = + repo.all( + from(token_transfer in TokenTransfer, + where: token_transfer.block_number in ^block_numbers, + select: struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash]) + ), + timeout: timeout + ) + + updated_bytes = AddressCountersConsolidator.apply_covered_deltas(transactions, token_transfers, :negative, repo) + + # Enforce AddressCountersRefetchBlock ShareLocks order (see docs: sharelocks.md) + repo.insert_all( + AddressCountersRefetchBlock, + block_numbers + |> Enum.sort() + |> Enum.map(&%{block_number: &1, inserted_at: timestamps.inserted_at, updated_at: timestamps.updated_at}), + on_conflict: :nothing, + timeout: timeout + ) + + updated_bytes + end + defp fork_transactions(%{ repo: repo, timeout: timeout, diff --git a/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex b/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex new file mode 100644 index 00000000000..f1ff5a7b586 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillAddressCounters do + @moduledoc """ + Backfills the incremental address counters: computes + `addresses.transactions_count`, `addresses.token_transfers_count` and + `addresses.gas_used` from scratch for every address that was never + consolidated (`counters_updated_at IS NULL`) and stamps its consolidation + watermark. + + Pre-existing column values were written by the legacy on-view counters at + unknown block heights, so they cannot seed incremental updates and are + recalculated. The migration walks the `addresses` primary key with a keyset + cursor stored in the migration `meta`, so it resumes where it left off after + a restart. Addresses consolidated concurrently (via the dirty-marker path of + `Explorer.Chain.Cache.Counters.AddressCountersConsolidator`) are skipped by + the `counters_updated_at IS NULL` guard. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + require Logger + + alias Explorer.Chain.Address + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.Cache.Counters.AddressCountersConsolidator + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "backfill_address_counters" + + # extra cursor sweeps picking up addresses whose aggregates failed on a + # previous pass (they keep a NULL watermark but the cursor already passed + # them) + @max_retry_passes 3 + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + query = + from(address in Address, + where: is_nil(address.counters_updated_at), + order_by: [asc: address.hash], + limit: ^limit, + select: address.hash + ) + + hashes = + state + |> Map.get("max_processed_hash") + |> case do + nil -> query + cursor -> where(query, [address], address.hash > ^cursor) + end + |> Repo.all(timeout: :infinity) + + case List.last(hashes) do + nil -> maybe_retry_pass(state) + last_hash -> {hashes, Map.put(state, "max_processed_hash", to_string(last_hash))} + end + end + + defp maybe_retry_pass(state) do + retry_passes = Map.get(state, "retry_passes", 0) + + if retry_passes < @max_retry_passes and Map.has_key?(state, "max_processed_hash") and + Repo.exists?(from(address in Address, where: is_nil(address.counters_updated_at))) do + last_unprocessed_identifiers(%{"retry_passes" => retry_passes + 1}) + else + {[], state} + end + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(address_hashes) do + AddressCountersConsolidator.consolidate_addresses(address_hashes, await_safe_block()) + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_backfill_address_counters_finished(true) + end + + # Consolidation is not possible while the chain head is unknown or blocks + # below it are still missing or pending a re-fetch counter correction. Wait + # instead of skipping: the cursor must not advance past unprocessed + # addresses. + defp await_safe_block do + case AddressCountersConsolidator.safe_block() do + nil -> + Logger.info("#{@migration_name} migration is waiting for a safe block to consolidate up to") + Process.sleep(:timer.seconds(30)) + await_safe_block() + + safe_block -> + safe_block + end + end +end diff --git a/apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex b/apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex new file mode 100644 index 00000000000..893cc10cbdc --- /dev/null +++ b/apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.AddressCountersRefetchBlock do + @moduledoc """ + Keeps block numbers whose old content was already subtracted from the + incremental address counters (`addresses.transactions_count`, + `addresses.token_transfers_count`, `addresses.gas_used`) because the blocks + were queued for re-fetch (see `Explorer.Chain.Block.full_refetch/1`). + + A row means "the negative counters delta for this block was applied, the + positive delta from its re-imported content is still pending". Rows are + consumed by `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` once + the corresponding block no longer requires a re-fetch. While a row exists, + the consolidator does not advance any address consolidation watermark past + this block number. + """ + + use Explorer.Schema + + alias Explorer.Repo + + @primary_key false + typed_schema "address_counters_refetch_blocks" do + field(:block_number, :integer, primary_key: true) + + timestamps() + end + + @doc false + def changeset(refetch_block \\ %__MODULE__{}, params) do + cast(refetch_block, params, [:block_number]) + end + + @doc """ + Returns the minimum pending block number or `nil` when the table is empty. + """ + @spec min_block_number() :: non_neg_integer() | nil + def min_block_number do + __MODULE__ + |> select([r], min(r.block_number)) + |> Repo.one() + end + + @doc """ + Deletes the rows for the given block numbers. + """ + @spec delete_by_block_numbers([non_neg_integer()]) :: {non_neg_integer(), nil} + def delete_by_block_numbers(block_numbers) do + __MODULE__ + |> where([r], r.block_number in ^block_numbers) + |> Repo.delete_all() + end +end diff --git a/apps/explorer/priv/repo/migrations/20260831000000_add_counters_updated_at_to_addresses.exs b/apps/explorer/priv/repo/migrations/20260831000000_add_counters_updated_at_to_addresses.exs new file mode 100644 index 00000000000..6c1f72c7040 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260831000000_add_counters_updated_at_to_addresses.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCountersUpdatedAtToAddresses do + use Ecto.Migration + + def change do + alter table(:addresses) do + add(:counters_updated_at, :bigint, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260831000001_create_address_counters_refetch_blocks.exs b/apps/explorer/priv/repo/migrations/20260831000001_create_address_counters_refetch_blocks.exs new file mode 100644 index 00000000000..794293ef3ee --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260831000001_create_address_counters_refetch_blocks.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateAddressCountersRefetchBlocks do + use Ecto.Migration + + def change do + create table(:address_counters_refetch_blocks, primary_key: false) do + add(:block_number, :bigint, primary_key: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + end +end diff --git a/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs new file mode 100644 index 00000000000..389e8c35549 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs @@ -0,0 +1,290 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Address + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} + alias Explorer.Repo + alias Explorer.Utility.AddressCountersRefetchBlock + + describe "consolidate_addresses/2" do + test "incrementally consolidates an address with a watermark" do + address = + insert(:address, transactions_count: 7, token_transfers_count: 2, gas_used: 10, counters_updated_at: 100) + + in_range_block = insert(:block, number: 150) + above_range_block = insert(:block, number: 300) + + in_range_transaction = :transaction |> insert(from_address: address) |> with_block(in_range_block) + :transaction |> insert(to_address: address) |> with_block(above_range_block) + + another_transaction = :transaction |> insert() |> with_block(in_range_block) + + insert(:token_transfer, + from_address: address, + transaction: another_transaction, + block: in_range_block, + block_number: in_range_block.number + ) + + AddressCountersConsolidator.consolidate_addresses([address.hash], 200) + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 8 + assert reloaded.token_transfers_count == 3 + assert reloaded.gas_used == 10 + Decimal.to_integer(in_range_transaction.gas_used) + assert reloaded.counters_updated_at == 200 + end + + test "recalculates an address without a watermark from scratch, replacing stale values" do + address = insert(:address, transactions_count: 1000, token_transfers_count: 1000, gas_used: 1000) + + block = insert(:block, number: 50) + transaction = :transaction |> insert(from_address: address) |> with_block(block) + + AddressCountersConsolidator.consolidate_addresses([address.hash], 200) + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 1 + assert reloaded.token_transfers_count == 0 + assert reloaded.gas_used == Decimal.to_integer(transaction.gas_used) + assert reloaded.counters_updated_at == 200 + end + + test "skips addresses already consolidated at or above the safe block" do + address = insert(:address, transactions_count: 7, counters_updated_at: 300) + + block = insert(:block, number: 150) + :transaction |> insert(from_address: address) |> with_block(block) + + AddressCountersConsolidator.consolidate_addresses([address.hash], 200) + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 7 + assert reloaded.counters_updated_at == 300 + end + + test "accumulates incoming gas for contracts and outgoing gas for EOAs with delegated code" do + contract = insert(:contract_address, counters_updated_at: 100) + + eoa_with_code = + insert(:address, + contract_code: "0xef01001111111111111111111111111111111111111111", + counters_updated_at: 100 + ) + + block = insert(:block, number: 150) + + incoming_to_contract = :transaction |> insert(to_address: contract) |> with_block(block) + :transaction |> insert(from_address: contract) |> with_block(insert(:block, number: 151)) + + incoming_to_eoa = :transaction |> insert(to_address: eoa_with_code) |> with_block(insert(:block, number: 152)) + outgoing_from_eoa = :transaction |> insert(from_address: eoa_with_code) |> with_block(insert(:block, number: 153)) + + _ = incoming_to_eoa + + AddressCountersConsolidator.consolidate_addresses([contract.hash, eoa_with_code.hash], 200) + + assert Repo.get(Address, contract.hash).gas_used == Decimal.to_integer(incoming_to_contract.gas_used) + assert Repo.get(Address, eoa_with_code.hash).gas_used == Decimal.to_integer(outgoing_from_eoa.gas_used) + end + end + + describe "safe_block/0" do + setup do + initial_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + + Application.put_env( + :explorer, + AddressCountersConsolidator, + Keyword.merge(initial_env, safe_block_lag: 0) + ) + + on_exit(fn -> Application.put_env(:explorer, AddressCountersConsolidator, initial_env) end) + + :ok + end + + test "returns the lagged chain head" do + insert(:block, number: 0) + insert(:block, number: 100) + + assert AddressCountersConsolidator.safe_block() == 100 + end + + test "is nil while the chain is not indexed down to the first block" do + insert(:block, number: 100) + + assert AddressCountersConsolidator.safe_block() == nil + end + + test "is capped below the lowest missing block range" do + insert(:block, number: 0) + insert(:block, number: 100) + insert(:missing_block_range, from_number: 60, to_number: 50) + + assert AddressCountersConsolidator.safe_block() == 49 + end + + test "is capped below the lowest block pending a re-fetch counter correction" do + insert(:block, number: 0) + insert(:block, number: 100) + Repo.insert!(%AddressCountersRefetchBlock{block_number: 30}) + + assert AddressCountersConsolidator.safe_block() == 29 + end + end + + describe "consolidation cycle process" do + test "the process stays responsive while a cycle runs and reports it in its state" do + pid = start_supervised!(AddressCountersConsolidator) + + send(pid, :consolidate) + + # the cycle runs in a task, so the process answers immediately + assert %{cycle_task: _, cycle_started_at: _, last_cycle_finished_at: _} = :sys.get_state(pid, 1_000) + + wait_for_results(fn -> + case :sys.get_state(pid, 1_000) do + %{cycle_task: nil, last_cycle_finished_at: %DateTime{}} -> :ok + _ -> raise Ecto.NoResultsError, queryable: "cycle completion" + end + end) + end + end + + describe "consolidate/0" do + setup do + initial_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + + Application.put_env( + :explorer, + AddressCountersConsolidator, + Keyword.merge(initial_env, safe_block_lag: 0) + ) + + on_exit(fn -> Application.put_env(:explorer, AddressCountersConsolidator, initial_env) end) + + start_supervised!(AddressCounters) + + :ok + end + + test "consolidates dirty addresses and clears their markers" do + address = insert(:address, transactions_count: 1, token_transfers_count: 0, gas_used: 0, counters_updated_at: 10) + + insert(:block, number: 0) + block = insert(:block, number: 100) + :transaction |> insert(from_address: address) |> with_block(block) + + AddressCounters.mark_dirty([{address.hash.bytes, 100}]) + :sys.get_state(AddressCounters) + + AddressCountersConsolidator.consolidate() + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 2 + assert reloaded.counters_updated_at == 100 + assert AddressCounters.dirty_empty?() + end + + test "keeps markers above the safe block" do + address = insert(:address, transactions_count: 1, token_transfers_count: 0, gas_used: 0, counters_updated_at: 10) + + insert(:block, number: 0) + insert(:block, number: 100) + + AddressCounters.mark_dirty([{address.hash.bytes, 500}]) + :sys.get_state(AddressCounters) + + AddressCountersConsolidator.consolidate() + + assert Repo.get(Address, address.hash).counters_updated_at == 100 + refute AddressCounters.dirty_empty?() + end + + test "settles re-fetched blocks: adds the re-imported content below the watermark and drops the queue rows" do + address = insert(:address, transactions_count: 5, token_transfers_count: 0, gas_used: 0, counters_updated_at: 100) + + reimported_block = insert(:block, number: 50, refetch_needed: false) + transaction = :transaction |> insert(from_address: address) |> with_block(reimported_block) + + insert(:block, number: 100) + Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + + AddressCountersConsolidator.consolidate() + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 6 + assert reloaded.gas_used == Decimal.to_integer(transaction.gas_used) + # settling a covered block does not move the watermark + assert reloaded.counters_updated_at == 100 + assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 0 + end + + test "does not settle blocks still awaiting their re-fetch" do + insert(:block, number: 50, refetch_needed: true) + insert(:block, number: 100) + Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + + AddressCountersConsolidator.consolidate() + + assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + end + + test "does not settle blocks still covered by a missing range" do + # refetch_needed already flipped, but the re-import has not fully + # completed yet (the missing range row is only removed afterwards) + insert(:block, number: 50, refetch_needed: false) + insert(:block, number: 100) + insert(:missing_block_range, from_number: 50, to_number: 50) + Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + + AddressCountersConsolidator.consolidate() + + assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + end + end + + describe "apply_covered_deltas/4" do + test "applies negative deltas only for blocks covered by the address watermark" do + address = + insert(:address, transactions_count: 5, token_transfers_count: 3, gas_used: 100, counters_updated_at: 100) + + transactions = [ + %{block_number: 50, from_address_hash: address.hash, to_address_hash: nil, gas_used: 30}, + # above the watermark: never counted, must not be subtracted + %{block_number: 150, from_address_hash: address.hash, to_address_hash: nil, gas_used: 30} + ] + + token_transfers = [ + %{block_number: 50, from_address_hash: address.hash, to_address_hash: nil} + ] + + [updated_bytes] = AddressCountersConsolidator.apply_covered_deltas(transactions, token_transfers, :negative) + + assert updated_bytes == address.hash.bytes + + reloaded = Repo.get(Address, address.hash) + + assert reloaded.transactions_count == 4 + assert reloaded.token_transfers_count == 2 + assert reloaded.gas_used == 70 + assert reloaded.counters_updated_at == 100 + end + + test "skips addresses without a watermark" do + address = insert(:address, transactions_count: 5) + + transactions = [%{block_number: 50, from_address_hash: address.hash, to_address_hash: nil, gas_used: 30}] + + assert AddressCountersConsolidator.apply_covered_deltas(transactions, [], :positive) == [] + assert Repo.get(Address, address.hash).transactions_count == 5 + end + end +end diff --git a/apps/explorer/test/explorer/chain/cache/counters/address_counters_test.exs b/apps/explorer/test/explorer/chain/cache/counters/address_counters_test.exs new file mode 100644 index 00000000000..259d3b6c2ef --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/counters/address_counters_test.exs @@ -0,0 +1,244 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.AddressCountersTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.Counters.AddressCounters + + setup do + start_supervised!(AddressCounters) + + :ok + end + + describe "fetch/1" do + test "returns DB column values and seeds the cache" do + address = insert(:address, transactions_count: 5, token_transfers_count: 3, gas_used: 100) + + assert %{transactions_count: 5, token_transfers_count: 3, gas_used: 100} = AddressCounters.fetch(address) + + # the second read is served from ETS: a live bump is visible on top of the seed + AddressCounters.bump_live(address.hash.bytes, delta(transactions_count: 2)) + + assert %{transactions_count: 7, token_transfers_count: 3, gas_used: 100} = AddressCounters.fetch(address) + end + + test "returns zeros and marks the address dirty when the counters were never calculated" do + address = insert(:address) + + assert %{transactions_count: 0, token_transfers_count: 0, gas_used: 0} = AddressCounters.fetch(address) + + :sys.get_state(AddressCounters) + + assert {[{bytes, _block_number}], _continuation} = AddressCounters.select_dirty(10) + assert bytes == address.hash.bytes + end + end + + describe "compute_deltas/3" do + test "computes per-address contributions of transactions and token transfers" do + address_a = build(:address) + address_b = build(:address) + + transactions = [ + %{ + block_number: 100, + from_address_hash: address_a.hash, + to_address_hash: address_b.hash, + gas_used: Decimal.new(21_000) + }, + # pending transactions are skipped + %{block_number: nil, from_address_hash: address_a.hash, to_address_hash: address_b.hash, gas_used: nil} + ] + + token_transfers = [ + %{block_number: 101, from_address_hash: address_b.hash, to_address_hash: address_a.hash} + ] + + deltas = AddressCounters.compute_deltas(transactions, token_transfers) + + assert %{ + transactions_count: 1, + token_transfers_count: 1, + gas_in: 0, + gas_out: 21_000, + max_block_number: 101 + } = deltas[address_a.hash.bytes] + + assert %{ + transactions_count: 1, + token_transfers_count: 1, + gas_in: 21_000, + gas_out: 0, + max_block_number: 101 + } = deltas[address_b.hash.bytes] + end + + test "counts a self-send once and fills both gas buckets" do + address = build(:address) + + deltas = + AddressCounters.compute_deltas( + [%{block_number: 5, from_address_hash: address.hash, to_address_hash: address.hash, gas_used: 100}], + [] + ) + + assert %{transactions_count: 1, gas_in: 100, gas_out: 100} = deltas[address.hash.bytes] + end + + test "the include? predicate excludes single contributions" do + address_a = build(:address) + address_b = build(:address) + + transactions = [ + %{block_number: 10, from_address_hash: address_a.hash, to_address_hash: address_b.hash, gas_used: 1}, + %{block_number: 20, from_address_hash: address_a.hash, to_address_hash: address_b.hash, gas_used: 1} + ] + + watermarks = %{address_a.hash.bytes => 15} + + deltas = + AddressCounters.compute_deltas(transactions, [], fn bytes, block_number -> + case watermarks[bytes] do + nil -> false + watermark -> block_number <= watermark + end + end) + + assert %{transactions_count: 1, max_block_number: 10} = deltas[address_a.hash.bytes] + refute Map.has_key?(deltas, address_b.hash.bytes) + end + end + + describe "bump_live/2" do + test "never creates entries for addresses that are not cached" do + address = insert(:address, transactions_count: 1, token_transfers_count: 1, gas_used: 1) + + AddressCounters.bump_live(address.hash.bytes, delta(transactions_count: 5)) + + # the first read seeds from the DB columns: the bump was not applied anywhere + assert %{transactions_count: 1} = AddressCounters.fetch(address) + end + + test "applies the gas bucket matching the cached gas direction" do + contract = insert(:contract_address, transactions_count: 0, token_transfers_count: 0, gas_used: 0) + AddressCounters.fetch(contract) + + AddressCounters.bump_live(contract.hash.bytes, delta(gas_in: 30, gas_out: 7)) + + assert %{gas_used: 30} = AddressCounters.fetch(contract) + end + end + + describe "handle_new_data/3" do + test "bumps cached entries for live data and marks addresses dirty" do + address = insert(:address, transactions_count: 10, token_transfers_count: 0, gas_used: 0) + AddressCounters.fetch(address) + + transactions = [ + %{block_number: 300, from_address_hash: address.hash, to_address_hash: nil, gas_used: 55} + ] + + AddressCounters.handle_new_data(transactions, [], true) + :sys.get_state(AddressCounters) + + assert %{transactions_count: 11, gas_used: 55} = AddressCounters.fetch(address) + assert {[{bytes, 300}], _continuation} = AddressCounters.select_dirty(10) + assert bytes == address.hash.bytes + end + + test "only marks addresses dirty for catchup data" do + address = insert(:address, transactions_count: 10, token_transfers_count: 0, gas_used: 0) + AddressCounters.fetch(address) + + transactions = [ + %{block_number: 300, from_address_hash: address.hash, to_address_hash: nil, gas_used: 55} + ] + + AddressCounters.handle_new_data(transactions, [], false) + :sys.get_state(AddressCounters) + + assert %{transactions_count: 10, gas_used: 0} = AddressCounters.fetch(address) + assert {[{_bytes, 300}], _continuation} = AddressCounters.select_dirty(10) + end + end + + describe "mark_dirty/1" do + test "is a no-op on api-only nodes" do + initial_mode = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, :api) + on_exit(fn -> Application.put_env(:explorer, :mode, initial_mode) end) + + AddressCounters.mark_dirty([{build(:address).hash.bytes, 10}]) + :sys.get_state(AddressCounters) + + assert AddressCounters.dirty_empty?() + end + + test "does not create new markers above the configured cap" do + initial_env = Application.get_env(:explorer, AddressCounters) || [] + Application.put_env(:explorer, AddressCounters, Keyword.merge(initial_env, max_dirty_markers: 1)) + on_exit(fn -> Application.put_env(:explorer, AddressCounters, initial_env) end) + + address_a = build(:address) + address_b = build(:address) + + AddressCounters.mark_dirty([{address_a.hash.bytes, 10}]) + AddressCounters.mark_dirty([{address_b.hash.bytes, 20}]) + # already-marked addresses can still be bumped + AddressCounters.mark_dirty([{address_a.hash.bytes, 30}]) + :sys.get_state(AddressCounters) + + assert {[{bytes, 30}], _continuation} = AddressCounters.select_dirty(10) + assert bytes == address_a.hash.bytes + end + end + + describe "stats/0" do + test "reports cached addresses and dirty markers" do + address = insert(:address, transactions_count: 5, token_transfers_count: 0, gas_used: 0) + AddressCounters.fetch(address) + + AddressCounters.mark_dirty([{build(:address).hash.bytes, 10}]) + :sys.get_state(AddressCounters) + + assert %{cached_addresses: 1, dirty_markers: 1, memory_bytes: memory_bytes} = AddressCounters.stats() + assert memory_bytes > 0 + end + end + + describe "delete_dirty_markers/2" do + test "keeps markers above the safe block" do + address_a = build(:address) + address_b = build(:address) + + AddressCounters.mark_dirty([{address_a.hash.bytes, 100}, {address_b.hash.bytes, 200}]) + :sys.get_state(AddressCounters) + + AddressCounters.delete_dirty_markers([address_a.hash.bytes, address_b.hash.bytes], 150) + + assert {[{bytes, 200}], _continuation} = AddressCounters.select_dirty(10) + assert bytes == address_b.hash.bytes + end + end + + describe "eviction" do + test "drops entries not touched within the TTL" do + initial_env = Application.get_env(:explorer, AddressCounters) || [] + Application.put_env(:explorer, AddressCounters, Keyword.merge(initial_env, ttl: 0)) + on_exit(fn -> Application.put_env(:explorer, AddressCounters, initial_env) end) + + address = insert(:address, transactions_count: 5, token_transfers_count: 0, gas_used: 0) + AddressCounters.fetch(address) + + Process.sleep(1) + send(Process.whereis(AddressCounters), :evict) + :sys.get_state(AddressCounters) + + assert is_nil(AddressCounters.snapshot(address.hash.bytes)) + end + end + + defp delta(overrides) do + Enum.into(overrides, %{transactions_count: 0, token_transfers_count: 0, gas_in: 0, gas_out: 0, max_block_number: 0}) + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index f316123d7c7..dd03ca2ce8e 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -15,7 +15,7 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do alias Explorer.Chain.{Address, Block, Transaction, PendingBlockOperation} alias Explorer.Chain.Cache.BlockNumber alias Explorer.{Chain, Repo} - alias Explorer.Utility.MissingBlockRange + alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} describe "run/1" do setup do @@ -89,6 +89,98 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do "Tuple was written even though it is not distinct" end + test "re-import of a block queued for re-fetch subtracts its old content from the address counters", %{ + consensus_block: %{hash: block_hash, miner_hash: miner_hash, number: block_number}, + options: options + } do + consensus_block = insert(:block, %{hash: block_hash, number: block_number, refetch_needed: true}) + + transaction = + :transaction + |> insert() + |> with_block(consensus_block) + + gas_used = Decimal.to_integer(transaction.gas_used) + + from_address = Repo.get(Address, transaction.from_address_hash) + + from_address + |> Ecto.Changeset.change(%{ + transactions_count: 5, + token_transfers_count: 2, + gas_used: gas_used + 100, + counters_updated_at: block_number + 10 + }) + |> Repo.update!() + + block_params = + params_for(:block, hash: block_hash, miner_hash: miner_hash, number: block_number, consensus: true) + + %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, block_params) + + assert {:ok, %{address_counters_refetched_block_numbers: [^block_number]}} = + Multi.new() + |> Blocks.run([block_changes], options) + |> Repo.transaction() + + reloaded = Repo.get(Address, transaction.from_address_hash) + + assert reloaded.transactions_count == 4 + assert reloaded.token_transfers_count == 2 + assert reloaded.gas_used == 100 + assert reloaded.counters_updated_at == block_number + 10 + + assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + + # a second import of the same block applies no further deltas: the + # re-fetch was already settled into the queue + assert {:ok, %{address_counters_refetched_block_numbers: []}} = + Multi.new() + |> Blocks.run([block_changes], options) + |> Repo.transaction() + + assert Repo.get(Address, transaction.from_address_hash).transactions_count == 4 + end + + test "a reorg below the consolidation watermark resets it to force a full recalculation", %{ + consensus_block: %{hash: block_hash, miner_hash: miner_hash, number: block_number}, + options: options + } do + consensus_block = insert(:block, %{hash: block_hash, number: block_number}) + + transaction = + :transaction + |> insert() + |> with_block(consensus_block) + + consolidated_address = + Address + |> Repo.get(transaction.from_address_hash) + |> Ecto.Changeset.change(%{counters_updated_at: block_number + 10}) + |> Repo.update!() + + fresh_address = + Address + |> Repo.get(transaction.to_address_hash) + |> Ecto.Changeset.change(%{counters_updated_at: block_number - 1}) + |> Repo.update!() + + block_params = + params_for(:block, hash: block_hash, miner_hash: miner_hash, number: block_number, consensus: false) + + %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, block_params) + + assert {:ok, %{address_counters_corrections: %{reset: reset_bytes}}} = + Multi.new() + |> Blocks.run([block_changes], options) + |> Repo.transaction() + + assert reset_bytes == [consolidated_address.hash.bytes] + assert is_nil(Repo.get(Address, consolidated_address.hash).counters_updated_at) + # the forked transaction was above this watermark, so it stays intact + assert Repo.get(Address, fresh_address.hash).counters_updated_at == block_number - 1 + end + test "coin balances are deleted and new balances are derived if some blocks lost consensus", %{consensus_block: %{number: block_number} = block, options: options} do %{hash: address_hash} = address = insert(:address, fetched_coin_balance_block_number: block_number) diff --git a/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs b/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs new file mode 100644 index 00000000000..5f0a3e7f8f5 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillAddressCountersTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.Address + alias Explorer.Chain.Cache.Counters.AddressCountersConsolidator + alias Explorer.Migrator.{BackfillAddressCounters, MigrationStatus} + alias Explorer.Repo + + setup do + initial_consolidator_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + + Application.put_env( + :explorer, + AddressCountersConsolidator, + Keyword.merge(initial_consolidator_env, safe_block_lag: 0) + ) + + initial_migrator_env = Application.get_env(:explorer, BackfillAddressCounters) || [] + + Application.put_env( + :explorer, + BackfillAddressCounters, + Keyword.merge(initial_migrator_env, batch_size: 2, concurrency: 2, timeout: 0) + ) + + on_exit(fn -> + Application.put_env(:explorer, AddressCountersConsolidator, initial_consolidator_env) + Application.put_env(:explorer, BackfillAddressCounters, initial_migrator_env) + end) + + :ok + end + + test "backfills counters for addresses that were never consolidated" do + insert(:block, number: 0) + block = insert(:block, number: 100) + + active_address = insert(:address, transactions_count: 999, token_transfers_count: 999, gas_used: 999) + transaction = :transaction |> insert(from_address: active_address) |> with_block(block) + + dormant_address = insert(:address) + + already_consolidated_address = + insert(:address, transactions_count: 42, token_transfers_count: 42, gas_used: 42, counters_updated_at: 77) + + assert MigrationStatus.get_status("backfill_address_counters") == nil + + BackfillAddressCounters.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"backfill_address_counters" and ms.status == "completed" + ) + ) + end) + + safe_block = AddressCountersConsolidator.safe_block() + + active_reloaded = Repo.get(Address, active_address.hash) + assert active_reloaded.transactions_count == 1 + assert active_reloaded.token_transfers_count == 0 + assert active_reloaded.gas_used == Decimal.to_integer(transaction.gas_used) + assert active_reloaded.counters_updated_at == safe_block + + dormant_reloaded = Repo.get(Address, dormant_address.hash) + assert dormant_reloaded.transactions_count == 0 + assert dormant_reloaded.token_transfers_count == 0 + assert dormant_reloaded.gas_used == 0 + assert dormant_reloaded.counters_updated_at == safe_block + + # addresses already consolidated are left untouched + consolidated_reloaded = Repo.get(Address, already_consolidated_address.hash) + assert consolidated_reloaded.transactions_count == 42 + assert consolidated_reloaded.counters_updated_at == 77 + + # nothing is left to process + assert Repo.one(from(a in Address, where: is_nil(a.counters_updated_at), select: count())) == 0 + end + + test "resumes from the cursor stored in the migration meta" do + insert(:block, number: 0) + insert(:block, number: 100) + + addresses = Enum.map(1..5, fn _ -> insert(:address) end) + max_hash = addresses |> Enum.map(&to_string(&1.hash)) |> Enum.max() + + BackfillAddressCounters.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"backfill_address_counters" and ms.status == "completed" + ) + ) + end) + + %{meta: meta} = MigrationStatus.fetch("backfill_address_counters") + + assert meta["max_processed_hash"] >= max_hash + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 2181b9bc333..31c37f6b987 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -430,8 +430,17 @@ config :explorer, Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCount, config :explorer, Explorer.Chain.Cache.OptimismFinalizationPeriod, enabled: ConfigHelper.chain_type() == :optimism -config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum, - cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TRANSACTIONS_GAS_USAGE_COUNTER_PERIOD", "30m") +config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, + ttl: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_TTL", "2h"), + max_dirty_markers: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_MAX_DIRTY_MARKERS", 1_000_000, min: 1) + +config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, + enabled: !ConfigHelper.parse_bool_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_DISABLED"), + interval: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_INTERVAL", "10m"), + batch_size: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_BATCH_SIZE", 100, min: 1), + concurrency: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_CONCURRENCY", 4, min: 1), + safe_block_lag: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG", 12, min: 0), + query_timeout: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT", "5m") config :explorer, Explorer.Chain.Cache.Counters.TokenHoldersCount, cache_period: ConfigHelper.parse_time_env_var("CACHE_TOKEN_HOLDERS_COUNTER_PERIOD", "1h") @@ -454,15 +463,9 @@ config :explorer, Explorer.Stats.HotSmartContractsCache, %{ "3h" => ConfigHelper.parse_time_env_var("CACHE_HOT_SMART_CONTRACTS_3H_PERIOD", "18m") } -config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsCount, - cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD", "1h") - config :explorer, Explorer.Chain.Cache.Counters.AddressTokensUsdSum, cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TOKENS_USD_SUM_PERIOD", "1h") -config :explorer, Explorer.Chain.Cache.Counters.AddressTokenTransfersCount, - cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TOKEN_TRANSFERS_COUNTER_PERIOD", "1h") - config :explorer, Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount, enabled: ConfigHelper.chain_type() == :optimism, enable_consolidation: ConfigHelper.chain_type() == :optimism, @@ -948,6 +951,12 @@ config :explorer, Explorer.Migrator.FillInternalTransactionsAddressIds, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY", 10), timeout: ConfigHelper.parse_time_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_TIMEOUT", "5s") +config :explorer, Explorer.Migrator.BackfillAddressCounters, + enabled: !ConfigHelper.parse_bool_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED"), + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE", 10), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY", 2), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT", "500ms") + config :explorer, Explorer.Chain.BridgedToken, eth_omni_bridge_mediator: System.get_env("BRIDGED_TOKENS_ETH_OMNI_BRIDGE_MEDIATOR"), bsc_omni_bridge_mediator: System.get_env("BRIDGED_TOKENS_BSC_OMNI_BRIDGE_MEDIATOR"), diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 3284431f5d9..9608cc3522b 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -121,15 +121,24 @@ RELEASE_LINK= # CACHE_BLOCK_COUNT_PERIOD=7200 # CACHE_TXS_COUNT_PERIOD=7200 # CACHE_ADDRESS_SUM_PERIOD=3600 -# CACHE_ADDRESS_TRANSACTIONS_GAS_USAGE_COUNTER_PERIOD=1800 # CACHE_TOKEN_HOLDERS_COUNTER_PERIOD=3600 # CACHE_TOKEN_TRANSFERS_COUNTER_PERIOD=3600 # CACHE_ADDRESS_COUNT_PERIOD=1800 # CACHE_AVERAGE_BLOCK_PERIOD=1800 # CACHE_MARKET_HISTORY_PERIOD=21600 -# CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD=3600 # CACHE_ADDRESS_TOKENS_USD_SUM_PERIOD=3600 -# CACHE_ADDRESS_TOKEN_TRANSFERS_COUNTER_PERIOD=3600 +# CACHE_ADDRESS_COUNTERS_TTL=2h +# CACHE_ADDRESS_COUNTERS_MAX_DIRTY_MARKERS=1000000 +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_DISABLED=false +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_INTERVAL=10m +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_BATCH_SIZE=100 +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_CONCURRENCY=4 +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG=12 +# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT=5m +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED=false +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE=10 +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY=2 +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT=500ms # CACHE_HOT_SMART_CONTRACTS_5M_PERIOD=30s # CACHE_HOT_SMART_CONTRACTS_1H_PERIOD=6m # CACHE_HOT_SMART_CONTRACTS_3H_PERIOD=18m From 42b94e6e244b7ee1d0a1c4a62b59bd7a26783e41 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 4 Sep 2026 14:52:02 +0300 Subject: [PATCH 15/82] perf: Switch token counters to incremental consolidation (#14773) --- .../controllers/api/v2/token_controller.ex | 4 +- .../controllers/tokens/token_controller.ex | 10 +- .../api/v2/token_controller_test.exs | 10 +- apps/explorer/config/config.exs | 11 +- apps/explorer/config/runtime/test.exs | 3 + apps/explorer/lib/explorer/application.ex | 5 +- .../chain/cache/background_migrations.ex | 9 + .../counters/address_counters_consolidator.ex | 175 +---- .../chain/cache/counters/consolidation.ex | 224 +++++++ .../chain/cache/counters/token_counters.ex | 411 ++++++++++++ .../counters/token_counters_consolidator.ex | 602 ++++++++++++++++++ .../cache/counters/token_holders_count.ex | 101 --- .../cache/counters/token_transfers_count.ex | 101 --- apps/explorer/lib/explorer/chain/import.ex | 68 +- .../runner/address/current_token_balances.ex | 267 ++++---- .../explorer/chain/import/runner/blocks.ex | 87 +-- .../explorer/chain/import/runner/tokens.ex | 10 +- apps/explorer/lib/explorer/chain/token.ex | 149 ++--- .../lib/explorer/chain/token_transfer.ex | 22 + .../migrator/backfill_token_counters.ex | 105 +++ ...tch_block.ex => counters_refetch_block.ex} | 25 +- ...0002_add_counters_updated_at_to_tokens.exs | 10 + ...rename_address_counters_refetch_blocks.exs | 15 + .../address_counters_consolidator_test.exs | 59 +- .../token_counters_consolidator_test.exs | 272 ++++++++ .../cache/counters/token_counters_test.exs | 187 ++++++ .../address/current_token_balances_test.exs | 92 ++- .../chain/import/runner/blocks_test.exs | 10 +- .../backfill_address_counters_test.exs | 10 +- .../migrator/backfill_token_counters_test.exs | 114 ++++ .../indexer/fetcher/token_counters_updater.ex | 38 +- config/runtime.exs | 23 +- cspell.json | 1 + docker-compose/envs/common-blockscout.env | 15 +- 34 files changed, 2532 insertions(+), 713 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/consolidation.ex create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/token_counters.ex create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex delete mode 100644 apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex create mode 100644 apps/explorer/lib/explorer/migrator/backfill_token_counters.ex rename apps/explorer/lib/explorer/utility/{address_counters_refetch_block.ex => counters_refetch_block.ex} (55%) create mode 100644 apps/explorer/priv/repo/migrations/20260831000002_add_counters_updated_at_to_tokens.exs create mode 100644 apps/explorer/priv/repo/migrations/20260831000003_rename_address_counters_refetch_blocks.exs create mode 100644 apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs create mode 100644 apps/explorer/test/explorer/chain/cache/counters/token_counters_test.exs create mode 100644 apps/explorer/test/explorer/migrator/backfill_token_counters_test.exs 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 42d4716f8ea..09c0a1f3447 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 @@ -148,8 +148,8 @@ defmodule BlockScoutWeb.API.V2.TokenController do def counters(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do - {transfers_count, holders_count} = Token.fetch_token_counters(address_hash, 5_000) + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do + {transfers_count, holders_count} = Token.fetch_token_counters(token) json(conn, %{transfers_count: to_string(transfers_count), token_holders_count: to_string(holders_count)}) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex index c7661debd32..e8e8af5c5b3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex @@ -13,12 +13,12 @@ defmodule BlockScoutWeb.Tokens.TokenController do end def token_counters(conn, %{"id" => address_hash_string}) do - case Chain.string_to_address_hash(address_hash_string) do - {:ok, address_hash} -> - {transfers_count, holders_count} = Token.fetch_token_counters(address_hash, 30_000) - - json(conn, %{transfer_count: transfers_count, token_holder_count: holders_count}) + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + %Token{} = token <- Token.get_by_contract_address_hash(address_hash, []) do + {transfers_count, holders_count} = Token.fetch_token_counters(token) + json(conn, %{transfer_count: transfers_count, token_holder_count: holders_count}) + else _ -> not_found(conn) end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs index ecb3a77c381..6162c188542 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs @@ -10,8 +10,9 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do alias Explorer.{Repo, TestHelper} - alias Explorer.Chain.{Address, Token, Token.Instance, TokenTransfer} + alias Explorer.Chain.{Address, Block, Token, Token.Instance, TokenTransfer} alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Cache.Counters.TokenCountersConsolidator alias Explorer.Chain.Events.Subscriber alias Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, as: TokenInstanceMetadataRefetchOnDemand @@ -98,6 +99,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do 3, :token_transfer, transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, token_contract_address: contract_token_address ) @@ -111,10 +114,9 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do ) ) - request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/counters") - assert json_response(request, 200) + safe_block = Repo.aggregate(Block, :max, :number) + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], safe_block) - Process.sleep(500) request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/counters") assert response = json_response(request, 200) diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 11dfd949dc5..15a2236a0a5 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -84,13 +84,9 @@ config :explorer, Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount, config :explorer, Explorer.Market.Fetcher.Token, enabled: true -config :explorer, Explorer.Chain.Cache.Counters.TokenHoldersCount, - enabled: true, - enable_consolidation: true +config :explorer, Explorer.Chain.Cache.Counters.TokenCounters, enabled: true -config :explorer, Explorer.Chain.Cache.Counters.TokenTransfersCount, - enabled: true, - enable_consolidation: true +config :explorer, Explorer.Chain.Cache.Counters.TokenCountersConsolidator, enabled: true config :explorer, Explorer.Chain.Cache.Counters.BlockBurntFeeCount, enabled: true, @@ -139,7 +135,8 @@ for migrator <- [ Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, - Explorer.Migrator.BackfillAddressCounters + Explorer.Migrator.BackfillAddressCounters, + Explorer.Migrator.BackfillTokenCounters ] do config :explorer, migrator, enabled: true end diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index 84e068f714e..264a1f12e1e 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -17,6 +17,8 @@ config :explorer, Explorer.Chain.Transaction.History.Historian, enabled: false config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, enabled: false config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, enabled: false +config :explorer, Explorer.Chain.Cache.Counters.TokenCounters, enabled: false +config :explorer, Explorer.Chain.Cache.Counters.TokenCountersConsolidator, enabled: false for counter <- [ Explorer.Chain.Cache.Counters.AddressesCount, @@ -85,6 +87,7 @@ for migrator <- [ Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, Explorer.Migrator.BackfillAddressCounters, + Explorer.Migrator.BackfillTokenCounters, # Heavy DB index operations Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex, diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index ee94f58bba9..528b161cc3f 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -155,8 +155,8 @@ defmodule Explorer.Application do configure(Explorer.Chain.Cache.Counters.AddressCounters), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressCountersConsolidator, :indexer), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTokensUsdSum, :api), - configure(Explorer.Chain.Cache.Counters.TokenHoldersCount), - configure(Explorer.Chain.Cache.Counters.TokenTransfersCount), + configure(Explorer.Chain.Cache.Counters.TokenCounters), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.TokenCountersConsolidator, :indexer), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.BlockBurntFeeCount, :api), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.BlockPriorityFeeCount, :api), configure(Explorer.Chain.Cache.Counters.AverageBlockTime), @@ -190,6 +190,7 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.RestoreOmittedWETHTransfers, :indexer), configure_mode_dependent_process(Explorer.Migrator.FilecoinPendingAddressOperations, :indexer), configure_mode_dependent_process(Explorer.Migrator.BackfillAddressCounters, :indexer), + configure_mode_dependent_process(Explorer.Migrator.BackfillTokenCounters, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloL2Epochs, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloAccounts, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloAggregatedElectionRewards, :indexer), diff --git a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex index 8b3339ff0fe..7b70f201d45 100644 --- a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex +++ b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex @@ -71,6 +71,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :heavy_indexes_create_address_ids_internal_transactions_indexes_finished, key: :fill_internal_transactions_address_ids_finished, key: :backfill_address_counters_finished, + key: :backfill_token_counters_finished, key: :heavy_indexes_create_logs_address_hash_first_topic_second_topic_block_number_index_finished, key: :heavy_indexes_create_address_current_token_balances_address_hash_block_number_index_finished @@ -82,6 +83,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ArbitrumDaRecordsNormalization, BackfillAddressCounters, BackfillMultichainSearchDB, + BackfillTokenCounters, EmptyInternalTransactionsData, FillInternalTransactionsAddressIds, SanitizeDuplicatedLogIndexLogs, @@ -435,6 +437,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:backfill_token_counters_finished) do + set_and_return_migration_status( + BackfillTokenCounters, + &set_backfill_token_counters_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_create_logs_address_hash_first_topic_second_topic_block_number_index_finished) do set_and_return_migration_status( CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex index e2f1b1d80b9..76d2b7f35bc 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex @@ -22,11 +22,12 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do that catchup and block re-fetch cannot make consolidated ranges lose or double-count rows. - Each cycle starts by settling pending block re-fetch corrections: for every - block whose old content was subtracted when it was queued for re-fetch (see - `Explorer.Utility.AddressCountersRefetchBlock`) and which has been fully - re-imported since, the new content is added back to the counters of the - addresses whose watermark already covers the block. + Each cycle starts by settling pending block re-fetch corrections via + `Explorer.Chain.Cache.Counters.Consolidation.settle_pending_refetch_blocks/0`: + for every block whose old content was subtracted when it was queued for + re-fetch (see `Explorer.Utility.CountersRefetchBlock`) and which has been + fully re-imported since, the new content is added back to the counters + covered by the consolidation watermarks. Known accepted limitations: @@ -45,15 +46,13 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do require Logger - alias Explorer.Chain.{Address, Block, Hash, TokenTransfer, Transaction} + alias Explorer.Chain.{Address, Hash} alias Explorer.Chain.Address.Counters alias Explorer.Chain.Cache.BlockNumber - alias Explorer.Chain.Cache.Counters.{AddressCounters, Helper} + alias Explorer.Chain.Cache.Counters.{AddressCounters, Consolidation, Helper} alias Explorer.Repo - alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} @int4_max 2_147_483_647 - @refetch_blocks_chunk_size 100 @spec start_link(term()) :: GenServer.on_start() def start_link(_) do @@ -110,7 +109,7 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do """ @spec consolidate() :: :ok def consolidate do - apply_pending_refetch_deltas() + Consolidation.settle_pending_refetch_blocks() unless AddressCounters.dirty_empty?() do case safe_block() do @@ -123,59 +122,9 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do end @doc """ - Returns the highest block number consolidated ranges may currently cover or - `nil` when consolidation is not possible yet: the chain head minus the - configured safety lag, additionally capped below the lowest missing block - range and the lowest block pending a re-fetch counter correction. - - While the chain is not yet indexed down to the configured first block — - initial backward sync, before the missing ranges collector has recorded the - unindexed tail — no safe block exists at all: consolidating past blocks that - are absent but not yet registered in `missing_block_ranges` would lose their - contributions forever. + See `Explorer.Chain.Cache.Counters.Consolidation.safe_block/0`. """ - @spec safe_block() :: non_neg_integer() | nil - def safe_block do - if lower_chain_indexed?() do - [lagged_head(), min_missing_block_cap(), min_pending_refetch_cap()] - |> Enum.reject(&is_nil/1) - |> Enum.min(fn -> nil end) - |> case do - safe_block when is_integer(safe_block) and safe_block > 0 -> safe_block - _ -> nil - end - end - end - - defp lower_chain_indexed? do - first_block = Application.get_env(:indexer, :first_block) - - case BlockNumber.get_min() do - min_block_number when is_integer(min_block_number) -> min_block_number <= first_block - _ -> false - end - end - - defp lagged_head do - case BlockNumber.get_max() do - max_block_number when is_integer(max_block_number) -> max_block_number - safe_block_lag() - _ -> nil - end - end - - defp min_missing_block_cap do - case MissingBlockRange.fetch_min_max() do - %{min: min} when is_integer(min) -> min - 1 - _ -> nil - end - end - - defp min_pending_refetch_cap do - case AddressCountersRefetchBlock.min_block_number() do - block_number when is_integer(block_number) -> block_number - 1 - _ -> nil - end - end + defdelegate safe_block, to: Consolidation @doc """ Consolidates the given addresses up to `safe_block` synchronously: full @@ -422,90 +371,6 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do Repo.all(query, timeout: query_timeout()) end - ## Block re-fetch corrections (positive side) - - defp apply_pending_refetch_deltas do - # a block is ready to settle once its re-fetch completed: the consensus - # block no longer requires a re-fetch AND the block is no longer covered - # by a missing range (the range row survives until the whole re-import — - # all its stages — succeeded, so old content can no longer be observed) - ready_query = - from(refetch_block in AddressCountersRefetchBlock, - as: :refetch_block, - where: - not exists( - from(block in Block, - where: - block.number == parent_as(:refetch_block).block_number and block.consensus == true and - block.refetch_needed == true, - select: 1 - ) - ), - where: - not exists( - from(range in MissingBlockRange, - where: - range.from_number >= parent_as(:refetch_block).block_number and - range.to_number <= parent_as(:refetch_block).block_number, - select: 1 - ) - ), - select: refetch_block.block_number, - limit: @refetch_blocks_chunk_size - ) - - case Repo.all(ready_query, timeout: query_timeout()) do - [] -> - :ok - - block_numbers -> - settle_refetched_blocks(block_numbers) - # more rows may be ready - apply_pending_refetch_deltas() - end - rescue - error -> - Logger.error(fn -> - ["Failed to settle re-fetched blocks counters: ", Exception.format(:error, error, __STACKTRACE__)] - end) - - :ok - end - - defp settle_refetched_blocks(block_numbers) do - {:ok, updated_bytes} = - Repo.transaction( - fn -> - transactions = - Repo.all( - from(transaction in Transaction, - where: transaction.block_number in ^block_numbers, - select: struct(transaction, [:block_number, :from_address_hash, :to_address_hash, :gas_used]) - ), - timeout: query_timeout() - ) - - token_transfers = - Repo.all( - from(token_transfer in TokenTransfer, - where: token_transfer.block_number in ^block_numbers, - select: struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash]) - ), - timeout: query_timeout() - ) - - updated_bytes = apply_covered_deltas(transactions, token_transfers, :positive) - - AddressCountersRefetchBlock.delete_by_block_numbers(Enum.sort(block_numbers)) - - updated_bytes - end, - timeout: :infinity - ) - - AddressCounters.invalidate(updated_bytes) - end - @doc """ Applies the per-address counter deltas of the given transactions and token transfers directly to the `addresses` columns, restricted per address to the @@ -545,9 +410,9 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do a full counters recalculation on their next consolidation — when their watermark already covers the given block number (a covered range changed under it: deep reorg, or rows inserted below the watermark by an - out-of-band importer). Returns the hash bytes of the reset addresses; the - caller is responsible for invalidating the display cache and marking them - dirty. + out-of-band importer). Reset addresses are marked dirty so the recalculation + is actually scheduled. Returns the hash bytes of the reset addresses; the + caller is responsible for invalidating the display cache. """ @spec reset_covered_watermarks(%{binary() => non_neg_integer()}, Ecto.Repo.t()) :: [binary()] def reset_covered_watermarks(min_block_by_hash_bytes, repo \\ Repo) @@ -575,7 +440,13 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do {_count, reset_hashes} = repo.update_all(query, [], timeout: query_timeout()) - Enum.map(reset_hashes, & &1.bytes) + reset_bytes = Enum.map(reset_hashes, & &1.bytes) + + # a spurious marker on transaction rollback is harmless (the guarded + # consolidation of a non-reset address is a no-op) + AddressCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + + reset_bytes end defp apply_signed_deltas(deltas, watermarks, sign, repo) do @@ -711,10 +582,6 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do Application.get_env(:explorer, __MODULE__)[:concurrency] || 4 end - defp safe_block_lag do - Application.get_env(:explorer, __MODULE__)[:safe_block_lag] || 12 - end - defp query_timeout do Application.get_env(:explorer, __MODULE__)[:query_timeout] || :timer.minutes(5) end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/consolidation.ex b/apps/explorer/lib/explorer/chain/cache/counters/consolidation.ex new file mode 100644 index 00000000000..155f8c55d44 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/consolidation.ex @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.Consolidation do + @moduledoc """ + Plumbing shared by the incremental counter consolidators + (`Explorer.Chain.Cache.Counters.AddressCountersConsolidator` and + `Explorer.Chain.Cache.Counters.TokenCountersConsolidator`): + + * `safe_block/0` — the highest block number consolidated ranges may cover; + * `settle_pending_refetch_blocks/0` — applies the pending positive counter + corrections for re-fetched blocks (see `Explorer.Utility.CountersRefetchBlock`) + to both the address and the token counters. + + Both consolidators call the settle at the start of their cycles; a + transaction-scoped advisory lock guarantees the corrections are applied + exactly once even when the cycles overlap. + """ + + import Ecto.Query + + require Logger + + alias Explorer.Chain.{Block, TokenTransfer, Transaction} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator, TokenCounters} + alias Explorer.Chain.Cache.Counters.TokenCountersConsolidator + alias Explorer.Repo + alias Explorer.Utility.{CountersRefetchBlock, MissingBlockRange} + + @refetch_blocks_chunk_size 100 + + # arbitrary constant identifying the settle critical section among + # `pg_advisory_xact_lock` users + @settle_advisory_lock_key 7_235_622_386_001 + + @doc """ + Returns the highest block number consolidated ranges may currently cover or + `nil` when consolidation is not possible yet: the chain head minus the + configured safety lag, additionally capped below the lowest missing block + range and the lowest block pending a re-fetch counter correction. + + While the chain is not yet indexed down to the configured first block — + initial backward sync, before the missing ranges collector has recorded the + unindexed tail — no safe block exists at all: consolidating past blocks that + are absent but not yet registered in `missing_block_ranges` would lose their + contributions forever. + """ + @spec safe_block() :: non_neg_integer() | nil + def safe_block do + if lower_chain_indexed?() do + [lagged_head(), min_missing_block_cap(), min_pending_refetch_cap()] + |> Enum.reject(&is_nil/1) + |> Enum.min(fn -> nil end) + |> case do + safe_block when is_integer(safe_block) and safe_block > 0 -> safe_block + _ -> nil + end + end + end + + @doc """ + Returns the configured number of blocks the consolidation watermarks stay + behind the chain head. + """ + @spec safe_block_lag() :: non_neg_integer() + def safe_block_lag do + Application.get_env(:explorer, __MODULE__)[:safe_block_lag] || 12 + end + + @doc """ + Settles the pending block re-fetch corrections: for every queued block whose + re-fetch fully completed, adds the counter contributions of the re-imported + content back to the addresses and tokens whose watermarks already cover the + block, then drops the queue rows. Blocks whose re-import is still in flight + (the consensus block still requires a re-fetch, or the block is still + covered by a missing range) are left queued. + """ + @spec settle_pending_refetch_blocks() :: :ok + def settle_pending_refetch_blocks do + case Repo.all(ready_to_settle_query(), timeout: query_timeout()) do + [] -> + :ok + + block_numbers -> + case settle_refetched_blocks(block_numbers) do + # another settle is in progress on this or another node + :skipped -> :ok + # more rows may be ready + :ok -> settle_pending_refetch_blocks() + end + end + rescue + error -> + Logger.error(fn -> + ["Failed to settle re-fetched blocks counters: ", Exception.format(:error, error, __STACKTRACE__)] + end) + + :ok + end + + # a block is ready to settle once its re-fetch completed: the consensus + # block no longer requires a re-fetch AND the block is no longer covered + # by a missing range (the range row survives until the whole re-import — + # all its stages — succeeded, so old content can no longer be observed) + defp ready_to_settle_query do + from(refetch_block in CountersRefetchBlock, + as: :refetch_block, + where: + not exists( + from(block in Block, + where: + block.number == parent_as(:refetch_block).block_number and block.consensus == true and + block.refetch_needed == true, + select: 1 + ) + ), + where: + not exists( + from(range in MissingBlockRange, + where: + range.from_number >= parent_as(:refetch_block).block_number and + range.to_number <= parent_as(:refetch_block).block_number, + select: 1 + ) + ), + select: refetch_block.block_number, + limit: @refetch_blocks_chunk_size + ) + end + + defp settle_refetched_blocks(block_numbers) do + {:ok, result} = + Repo.transaction( + fn -> + if advisory_lock_acquired?() do + transactions = + Repo.all( + from(transaction in Transaction, + where: transaction.block_number in ^block_numbers, + select: struct(transaction, [:block_number, :from_address_hash, :to_address_hash, :gas_used]) + ), + timeout: query_timeout() + ) + + token_transfers = + Repo.all( + from(token_transfer in TokenTransfer, + where: token_transfer.block_number in ^block_numbers, + select: + struct(token_transfer, [ + :block_number, + :from_address_hash, + :to_address_hash, + :token_contract_address_hash + ]) + ), + timeout: query_timeout() + ) + + updated_address_bytes = + AddressCountersConsolidator.apply_covered_deltas(transactions, token_transfers, :positive) + + updated_token_bytes = TokenCountersConsolidator.apply_covered_transfer_deltas(token_transfers, :positive) + + CountersRefetchBlock.delete_by_block_numbers(Enum.sort(block_numbers)) + + {updated_address_bytes, updated_token_bytes} + else + :skipped + end + end, + timeout: :infinity + ) + + case result do + :skipped -> + :skipped + + {updated_address_bytes, updated_token_bytes} -> + AddressCounters.invalidate(updated_address_bytes) + TokenCounters.invalidate(updated_token_bytes) + :ok + end + end + + defp advisory_lock_acquired? do + %{rows: [[acquired]]} = Repo.query!("SELECT pg_try_advisory_xact_lock($1)", [@settle_advisory_lock_key]) + + acquired + end + + defp lower_chain_indexed? do + first_block = Application.get_env(:indexer, :first_block) + + case BlockNumber.get_min() do + min_block_number when is_integer(min_block_number) -> min_block_number <= first_block + _ -> false + end + end + + defp lagged_head do + case BlockNumber.get_max() do + max_block_number when is_integer(max_block_number) -> max_block_number - safe_block_lag() + _ -> nil + end + end + + defp min_missing_block_cap do + case MissingBlockRange.fetch_min_max() do + %{min: min} when is_integer(min) -> min - 1 + _ -> nil + end + end + + defp min_pending_refetch_cap do + case CountersRefetchBlock.min_block_number() do + block_number when is_integer(block_number) -> block_number - 1 + _ -> nil + end + end + + defp query_timeout do + Application.get_env(:explorer, __MODULE__)[:query_timeout] || :timer.minutes(5) + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_counters.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_counters.ex new file mode 100644 index 00000000000..a18f16943f5 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/token_counters.ex @@ -0,0 +1,411 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.TokenCounters do + @moduledoc """ + Shared ETS cache for the per-token transfers counter. + + The durable value lives in the `tokens.transfer_count` column and is + advanced incrementally by + `Explorer.Chain.Cache.Counters.TokenCountersConsolidator` up to the block + number stored in `tokens.counters_updated_at`. The per-token holders counter + (`tokens.holder_count`) is intentionally NOT cached here: it is maintained + live by the import-time deltas of the current token balances runner, so the + column itself is always current. + + This module keeps two ETS tables: + + * `:token_counters` — the display cache consumed by the API. Entries are + seeded from the `transfer_count` column on read (`fetch/1`), incremented + in place with the deltas of newly imported token transfers + (`handle_new_data/2`), re-based from the DB by the consolidator + (`refresh_from_consolidation/3`) and evicted a configurable TTL after + their last authoritative base, so accumulated live-bump drift is bounded + by the TTL. Live bumps never create entries. + + * `:token_counters_dirty` — the consolidator work list: token contract + address hash bytes mapped to the maximum block number seen for the token + since its last consolidation. Markers are written on nodes where the + consolidator runs (`:indexer`/`:all` modes) and are capped at a + configurable size — a dropped marker only delays consolidation until the + token's next activity. + + On pure API nodes of split deployments the display cache is kept warm by the + realtime `:token_transfers` chain events (dirty markers are not written + there); on indexer and all-mode nodes `Explorer.Chain.Import` calls + `handle_new_data/2` directly, so chain events are ignored there to avoid + double counting. + """ + + use GenServer + + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.Helper + alias Explorer.Chain.Events.Subscriber + alias Explorer.Chain.Hash + + @cache_name :token_counters + @dirty_cache_name :token_counters_dirty + + @typedoc """ + Per-token contribution of a set of imported (or deleted) token transfers. + """ + @type delta :: %{ + transfer_count: non_neg_integer(), + max_block_number: non_neg_integer() + } + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_args) do + Helper.create_cache_table(@cache_name, write_concurrency: true) + Helper.create_cache_table(@dirty_cache_name, write_concurrency: true) + + if Explorer.mode() == :api do + Subscriber.to(:token_transfers, :realtime) + end + + schedule_eviction() + + {:ok, %{}} + end + + @doc """ + Returns the transfers counter for the given token: the cached value when + present, the `tokens.transfer_count` column of the given struct otherwise + (seeding the cache along the way). A token whose counter was never + calculated (`NULL` column) is reported as zero and marked dirty so that the + consolidator computes its value ahead of the background backfill (on nodes + with a local consolidator). + """ + @spec fetch(Explorer.Chain.Token.t()) :: non_neg_integer() + def fetch(token) do + if cache_table_exists?() do + bytes = hash_bytes(token.contract_address_hash) + + case snapshot(bytes) do + nil -> seed_from_db(token, bytes) + transfer_count -> transfer_count + end + else + token.transfer_count || 0 + end + end + + @doc """ + Registers newly imported `token_transfers` in the cache: marks the involved + tokens dirty for the consolidator and, when `live?` is `true` (realtime + imports), increments the already-cached display values. + """ + @spec handle_new_data([map()], boolean()) :: :ok + def handle_new_data(token_transfers, live?) do + if cache_table_exists?() do + token_transfers + |> compute_transfer_deltas() + |> apply_new_deltas(live?) + end + + :ok + end + + defp apply_new_deltas(deltas, _live?) when map_size(deltas) == 0, do: :ok + + defp apply_new_deltas(deltas, live?) do + if live? do + Enum.each(deltas, fn {bytes, delta} -> bump_live(bytes, delta) end) + end + + deltas + |> Enum.map(fn {bytes, delta} -> {bytes, delta.max_block_number} end) + |> mark_dirty() + end + + @doc """ + Computes per-token transfer-count deltas for the given token transfers. Rows + without a block number are skipped. The optional `include?` predicate + receives the token contract hash bytes and the row block number and can + exclude single contributions (used to restrict deltas to blocks at or below + a token consolidation watermark). + """ + @spec compute_transfer_deltas([map()], (binary(), non_neg_integer() -> boolean())) :: %{binary() => delta()} + def compute_transfer_deltas(token_transfers, include? \\ fn _hash_bytes, _block_number -> true end) do + Enum.reduce(token_transfers, %{}, &add_transfer_delta(&2, &1, include?)) + end + + defp add_transfer_delta(acc, token_transfer, include?) do + bytes = hash_bytes(token_transfer.token_contract_address_hash) + block_number = token_transfer.block_number + + if is_nil(bytes) or is_nil(block_number) or not include?.(bytes, block_number) do + acc + else + acc + |> Map.put_new(bytes, %{transfer_count: 0, max_block_number: 0}) + |> Map.update!(bytes, fn delta -> + %{ + transfer_count: delta.transfer_count + 1, + max_block_number: max(delta.max_block_number, block_number) + } + end) + end + end + + @doc """ + Increments the cached display value of the given token by the given delta. + Only already-cached tokens are updated — live bumps never create entries. + """ + @spec bump_live(binary(), delta()) :: :ok + def bump_live(hash_bytes, delta) do + if :ets.member(@cache_name, {hash_bytes, :transfer_count}) do + :ets.update_counter(@cache_name, {hash_bytes, :transfer_count}, delta.transfer_count) + end + + :ok + rescue + # the entry was evicted between the membership check and the update + ArgumentError -> :ok + end + + @doc """ + Returns the currently cached transfers counter of the given token or `nil` + when the token is not cached. + """ + @spec snapshot(binary()) :: non_neg_integer() | nil + def snapshot(hash_bytes) do + if cache_table_exists?() do + Helper.fetch_from_ets_cache(@cache_name, {hash_bytes, :transfer_count}) + end + end + + @doc """ + Re-bases the cached display value of the given token on the value just + written to the DB by the consolidator. The write is an adjustment + (`new value - snapshot`) rather than an overwrite, so live bumps applied + concurrently — always for blocks above the consolidated range — survive on + top of the new base. + """ + @spec refresh_from_consolidation(binary(), non_neg_integer(), non_neg_integer() | nil) :: :ok + def refresh_from_consolidation(hash_bytes, new_transfer_count, snapshot) do + if not is_nil(snapshot) and :ets.member(@cache_name, {hash_bytes, :transfer_count}) do + :ets.update_counter(@cache_name, {hash_bytes, :transfer_count}, new_transfer_count - snapshot) + :ets.insert(@cache_name, {{hash_bytes, :ts}, Helper.current_time()}) + end + + :ok + rescue + # the entry was evicted between the membership check and the update + ArgumentError -> :ok + end + + @doc """ + Drops the cached display values of the given tokens so that the next read + re-seeds them from the DB. Used after out-of-band DB counter corrections + (block re-fetch deltas, watermark resets). + """ + @spec invalidate([binary()]) :: :ok + def invalidate(hash_bytes_list) do + if cache_table_exists?() do + Enum.each(hash_bytes_list, &delete_display_entries/1) + end + + :ok + end + + @doc """ + Marks tokens dirty for the consolidator. Accepts a list of + `{hash_bytes, block_number}` pairs; for every token the maximum seen block + number is kept. The update is serialized through the cache process to avoid + losing the maximum on concurrent writes. + + A no-op on nodes without a local consolidator (pure API mode). + """ + @spec mark_dirty([{binary(), non_neg_integer()}]) :: :ok + def mark_dirty(pairs) when is_list(pairs) do + if consolidator_local?() do + GenServer.cast(__MODULE__, {:mark_dirty, pairs}) + end + + :ok + end + + @doc """ + Marks the given token dirty at the current maximum block number, asking the + consolidator to (re)calculate its counters from scratch when + `counters_updated_at` is `NULL` or incrementally otherwise. + """ + @spec mark_dirty_at_max_block(Hash.Address.t()) :: :ok + def mark_dirty_at_max_block(contract_address_hash) do + mark_dirty([{hash_bytes(contract_address_hash), BlockNumber.get_max()}]) + end + + @doc """ + Returns `true` when there are no dirty markers (or the cache is not started). + """ + @spec dirty_empty?() :: boolean() + def dirty_empty? do + :ets.whereis(@dirty_cache_name) == :undefined or :ets.info(@dirty_cache_name, :size) == 0 + end + + @doc """ + Returns cache statistics for inspection (the process state itself is empty — + all data lives in the ETS tables): the number of tokens in the display + cache, the number of dirty markers awaiting consolidation, and the memory + held by both tables in bytes. + """ + @spec stats() :: %{ + cached_tokens: non_neg_integer(), + dirty_markers: non_neg_integer(), + memory_bytes: non_neg_integer() + } + def stats do + if cache_table_exists?() do + word_size = :erlang.system_info(:wordsize) + + %{ + cached_tokens: :ets.select_count(@cache_name, [{{{:_, :ts}, :_}, [], [true]}]), + dirty_markers: :ets.info(@dirty_cache_name, :size), + memory_bytes: (:ets.info(@cache_name, :memory) + :ets.info(@dirty_cache_name, :memory)) * word_size + } + else + %{cached_tokens: 0, dirty_markers: 0, memory_bytes: 0} + end + end + + @typedoc """ + Opaque `:ets.select/1` continuation of a dirty-markers traversal. + """ + @type dirty_markers_continuation :: term() + + @doc """ + Starts (with a limit) or continues (with a previously returned continuation) + a traversal of the dirty markers. Returns `{[{hash_bytes, block_number}], continuation}` + or `:"$end_of_table"`. + """ + @spec select_dirty(pos_integer() | dirty_markers_continuation()) :: + {[{binary(), non_neg_integer()}], dirty_markers_continuation()} | :"$end_of_table" + def select_dirty(limit) when is_integer(limit) do + :ets.select(@dirty_cache_name, [{{:"$1", :"$2"}, [], [{{:"$1", :"$2"}}]}], limit) + end + + def select_dirty(continuation) do + :ets.select(continuation) + end + + @doc """ + Deletes the dirty markers of the given tokens unless a concurrent import + bumped them above `safe_block` in the meantime (such markers stay for the + next consolidation cycle). + """ + @spec delete_dirty_markers([binary()], non_neg_integer()) :: :ok + def delete_dirty_markers(hash_bytes_list, safe_block) do + if :ets.whereis(@dirty_cache_name) != :undefined do + Enum.each(hash_bytes_list, fn bytes -> + :ets.select_delete(@dirty_cache_name, [{{bytes, :"$1"}, [{:"=<", :"$1", safe_block}], [true]}]) + end) + end + + :ok + end + + @doc """ + Converts a token contract address hash (or already-extracted hash bytes) to + the binary key used in the cache tables. + """ + @spec hash_bytes(Hash.Address.t() | binary() | nil) :: binary() | nil + def hash_bytes(nil), do: nil + def hash_bytes(%Hash{bytes: bytes}), do: bytes + def hash_bytes(bytes) when is_binary(bytes), do: bytes + + def cache_name, do: @cache_name + def dirty_cache_name, do: @dirty_cache_name + + @impl true + def handle_cast({:mark_dirty, pairs}, state) do + Enum.each(pairs, fn {bytes, block_number} -> insert_dirty_marker(bytes, block_number) end) + + {:noreply, state} + end + + defp insert_dirty_marker(bytes, block_number) do + case :ets.lookup(@dirty_cache_name, bytes) do + [{_, existing}] when existing >= block_number -> + :ok + + [{_, _existing}] -> + :ets.insert(@dirty_cache_name, {bytes, block_number}) + + [] -> + # cap the work list: a dropped marker only delays consolidation until + # the token's next activity (during initial sync all tokens are + # covered by the backfill migration anyway) + if :ets.info(@dirty_cache_name, :size) < max_dirty_markers() do + :ets.insert(@dirty_cache_name, {bytes, block_number}) + end + end + end + + @impl true + def handle_info({:chain_event, :token_transfers, :realtime, token_transfers}, state) do + handle_new_data(token_transfers, true) + {:noreply, state} + end + + def handle_info(:evict, state) do + evict_stale_entries() + schedule_eviction() + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp seed_from_db(token, bytes) do + if is_nil(token.transfer_count) do + mark_dirty([{bytes, BlockNumber.get_max()}]) + + 0 + else + :ets.insert_new(@cache_name, [ + {{bytes, :transfer_count}, token.transfer_count}, + {{bytes, :ts}, Helper.current_time()} + ]) + + token.transfer_count + end + end + + defp delete_display_entries(bytes) do + Enum.each([:transfer_count, :ts], fn key -> + :ets.delete(@cache_name, {bytes, key}) + end) + end + + defp evict_stale_entries do + threshold = Helper.current_time() - ttl() + + @cache_name + |> :ets.select([{{{:"$1", :ts}, :"$2"}, [{:<, :"$2", threshold}], [:"$1"]}]) + |> Enum.each(&delete_display_entries/1) + end + + defp cache_table_exists? do + :ets.whereis(@cache_name) != :undefined + end + + defp consolidator_local? do + Explorer.mode() in [:indexer, :all] + end + + defp max_dirty_markers do + Application.get_env(:explorer, __MODULE__)[:max_dirty_markers] || 200_000 + end + + defp schedule_eviction do + Process.send_after(self(), :evict, max(div(ttl(), 4), :timer.minutes(1))) + end + + defp ttl do + Application.get_env(:explorer, __MODULE__)[:ttl] || :timer.hours(2) + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex new file mode 100644 index 00000000000..4d94e0fb6d2 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex @@ -0,0 +1,602 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.TokenCountersConsolidator do + @moduledoc """ + The single writer of the per-token counters columns (`tokens.transfer_count` + and — on the initialization path only — `tokens.holder_count`). + + Periodically consolidates the counters of the tokens marked dirty in + `Explorer.Chain.Cache.Counters.TokenCounters` (a cycle is skipped entirely + when no token was marked since the last one): + + * a token with a `counters_updated_at` watermark gets a cheap range-bounded + `COUNT(*)` of its token transfers over `(counters_updated_at, safe_block]` + added to `transfer_count`; + * a token without a watermark (`NULL` — not yet backfilled, or reset because + covered content changed) gets a full transfer count bounded by + `safe_block`, and its `holder_count` re-established from a full recount. + + `holder_count` is otherwise maintained live by the import-time deltas of the + current token balances runner (state count — it cannot be recomputed from a + block range), so the recount here is written as a snapshot-relative + adjustment: the token is first "armed" (`holder_count` set to `0` when + `NULL`, enabling the delta guard), then the current count and the pre-count + column value are measured in one statement (one snapshot), and finally + `holder_count = holder_count - measured_old + recount` is applied — deltas + committed between the measurement and the guarded update survive on top of + the recount (READ COMMITTED re-reads the locked row, while the measured + values keep the measurement snapshot). + + `safe_block` (shared with the address counters consolidation — see + `Explorer.Chain.Cache.Counters.Consolidation`) keeps watermarks a + configurable lag behind the chain head and never advances past missing block + ranges or blocks pending re-fetch corrections. Token transfers of forked + blocks keep their physical rows (only `block_consensus` flips), and the + counter counts physical rows — legacy parity — so forks need no token + watermark resets; re-imported rows landing below a watermark are handled by + the reset valve in `Explorer.Chain.Import`. + + Known accepted limitations mirror the address consolidator: int4 clamping, + and content changes below a watermark landing while the very same token is + being consolidated can leave stale counts until the next watermark reset. + A one-off migrator deleting token transfer rows invalidates consolidated + transfer counts — reset `tokens.counters_updated_at` to `NULL` afterwards. + """ + + use GenServer + + import Ecto.Query + import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + + require Logger + + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{Consolidation, TokenCounters} + alias Explorer.Chain.{Hash, Token, TokenTransfer} + alias Explorer.Repo + + @int4_max 2_147_483_647 + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_args) do + schedule_next_consolidation(:timer.seconds(10)) + + {:ok, %{cycle_task: nil, cycle_started_at: nil, last_cycle_finished_at: nil}} + end + + # A cycle can run for a long time (draining a large dirty backlog, full + # recalculations of heavy tokens), so it runs in a separate supervised task: + # the process itself stays responsive to system messages (`:sys.get_state/1`, + # observer) and its state reports the running cycle. The next cycle is + # scheduled only once the previous one finished, so cycles never overlap. + @impl true + def handle_info(:consolidate, %{cycle_task: nil} = state) do + task = Task.Supervisor.async_nolink(Explorer.TaskSupervisor, &consolidate/0) + + {:noreply, %{state | cycle_task: task, cycle_started_at: DateTime.utc_now()}} + end + + # a stray tick while a cycle is still running — the next one is scheduled on + # its completion + def handle_info(:consolidate, state), do: {:noreply, state} + + def handle_info({ref, _result}, %{cycle_task: %Task{ref: ref}} = state) do + Process.demonitor(ref, [:flush]) + + {:noreply, finish_cycle(state)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{cycle_task: %Task{ref: ref}} = state) do + Logger.error(fn -> ["Token counters consolidation cycle crashed: ", inspect(reason)] end) + + {:noreply, finish_cycle(state)} + end + + def handle_info(_, state), do: {:noreply, state} + + defp finish_cycle(state) do + schedule_next_consolidation(interval()) + + %{state | cycle_task: nil, cycle_started_at: nil, last_cycle_finished_at: DateTime.utc_now()} + end + + @doc """ + Runs one consolidation cycle synchronously. Exposed for tests and manual + runs; the supervised process runs it in a task on every `:consolidate` tick. + """ + @spec consolidate() :: :ok + def consolidate do + Consolidation.settle_pending_refetch_blocks() + + unless TokenCounters.dirty_empty?() do + case Consolidation.safe_block() do + nil -> :ok + safe_block -> consolidate_dirty(TokenCounters.select_dirty(batch_size() * concurrency()), safe_block) + end + end + + :ok + end + + @doc """ + Consolidates the given tokens up to `safe_block` synchronously: full + recalculation (transfer count and holder recount) for tokens without a + `counters_updated_at` watermark, incremental transfer-count range aggregates + for the rest. Used by `Explorer.Migrator.BackfillTokenCounters` and + available for on-demand recalculation. + """ + @spec consolidate_tokens([Hash.Address.t()], non_neg_integer()) :: :ok + def consolidate_tokens(contract_address_hashes, safe_block) do + contract_address_hashes + |> Enum.map(& &1.bytes) + |> load_tokens() + |> Enum.reject(&(&1.counters_updated_at && &1.counters_updated_at >= safe_block)) + |> case do + [] -> :ok + tokens -> consolidate_chunk(tokens, safe_block) + end + + :ok + end + + defp consolidate_dirty(:"$end_of_table", _safe_block), do: :ok + + defp consolidate_dirty({entries, continuation}, safe_block) do + process_entries(entries, safe_block) + consolidate_dirty(TokenCounters.select_dirty(continuation), safe_block) + end + + defp process_entries(entries, safe_block) do + entry_bytes = Enum.map(entries, fn {bytes, _marker_block} -> bytes end) + tokens_by_bytes = Map.new(load_tokens(entry_bytes), &{&1.contract_address_hash.bytes, &1}) + + {found_bytes, missing_bytes} = Enum.split_with(entry_bytes, &Map.has_key?(tokens_by_bytes, &1)) + + # markers without a corresponding token row are garbage + TokenCounters.delete_dirty_markers(missing_bytes, safe_block) + + found_bytes + |> Enum.map(&Map.fetch!(tokens_by_bytes, &1)) + |> Enum.reject(&(&1.counters_updated_at && &1.counters_updated_at >= safe_block)) + |> Enum.chunk_every(batch_size()) + |> Task.async_stream(&consolidate_chunk(&1, safe_block), max_concurrency: concurrency(), timeout: :infinity) + |> Stream.run() + end + + defp consolidate_chunk(tokens, safe_block) do + {incremental_tokens, inits} = Enum.split_with(tokens, & &1.counters_updated_at) + + updated_rows = + consolidate_incremental_tokens(incremental_tokens, safe_block) ++ consolidate_inits(inits, safe_block) + + Enum.each(updated_rows, fn %{token: token, transfer_count: new_transfer_count, snapshot: snapshot} -> + TokenCounters.refresh_from_consolidation(token.contract_address_hash.bytes, new_transfer_count || 0, snapshot) + end) + + updated_rows + |> Enum.map(& &1.token.contract_address_hash.bytes) + |> TokenCounters.delete_dirty_markers(safe_block) + end + + ## Incremental path + + defp consolidate_incremental_tokens([], _safe_block), do: [] + + defp consolidate_incremental_tokens(tokens, safe_block) do + results = + tokens + |> Enum.map(&compute_transfer_range_count(&1, safe_block)) + |> Enum.reject(&is_nil/1) + + apply_increments(results, safe_block) + end + + defp compute_transfer_range_count(token, safe_block) do + snapshot = TokenCounters.snapshot(token.contract_address_hash.bytes) + + transfer_count_delta = + token.contract_address_hash + |> TokenTransfer.count_token_transfers_from_token_hash_query(token.counters_updated_at, safe_block) + |> Repo.aggregate(:count, timeout: query_timeout()) + + %{token: token, snapshot: snapshot, transfer_count_delta: transfer_count_delta} + rescue + error -> + Logger.warning(fn -> + [ + "Failed to consolidate counters for token #{to_string(token.contract_address_hash)}: ", + Exception.format(:error, error, __STACKTRACE__) + ] + end) + + nil + end + + defp apply_increments([], _safe_block), do: [] + + defp apply_increments(results, safe_block) do + hashes = Enum.map(results, & &1.token.contract_address_hash.bytes) + expected_froms = Enum.map(results, & &1.token.counters_updated_at) + transfer_deltas = Enum.map(results, & &1.transfer_count_delta) + + query = + from( + token in Token, + join: + deltas in fragment( + "(SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS expected_from, unnest(?::bigint[]) AS transfer_delta)", + ^hashes, + ^expected_froms, + ^transfer_deltas + ), + on: token.contract_address_hash == deltas.hash, + where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + where: token.counters_updated_at == deltas.expected_from, + update: [ + set: [ + transfer_count: + fragment("LEAST(COALESCE(?, 0) + ?, ?)", token.transfer_count, deltas.transfer_delta, @int4_max), + counters_updated_at: ^safe_block, + updated_at: ^DateTime.utc_now() + ] + ], + select: %{hash: token.contract_address_hash, transfer_count: token.transfer_count} + ) + + {_count, rows} = Repo.update_all(query, [], timeout: query_timeout()) + + rows_by_bytes = Map.new(rows, &{&1.hash.bytes, &1}) + + results + |> Enum.filter(&Map.has_key?(rows_by_bytes, &1.token.contract_address_hash.bytes)) + |> Enum.map(fn result -> + %{ + token: result.token, + snapshot: result.snapshot, + transfer_count: Map.fetch!(rows_by_bytes, result.token.contract_address_hash.bytes).transfer_count + } + end) + end + + ## Initialization path (NULL watermark): arm -> measure -> apply + + defp consolidate_inits([], _safe_block), do: [] + + defp consolidate_inits(tokens, safe_block) do + hashes = Enum.map(tokens, & &1.contract_address_hash.bytes) + + arm_holder_counts(hashes) + + results = + tokens + |> Enum.map(&measure_token(&1, safe_block)) + |> Enum.reject(&is_nil/1) + + apply_inits(results, safe_block) + end + + # Sets `holder_count` to 0 where it is NULL so that the import-time delta + # machinery (guarded by `not is_nil(holder_count)`) accumulates every + # transition committed after this point; the measured recount then lands as + # an adjustment on top (see the moduledoc). + defp arm_holder_counts(hashes) do + query = + from(token in Token, + where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + where: is_nil(token.holder_count), + update: [set: [holder_count: 0, updated_at: ^DateTime.utc_now()]] + ) + + Repo.update_all(query, [], timeout: query_timeout()) + end + + defp measure_token(token, safe_block) do + # one statement = one snapshot: the pre-recount column value and the + # recount are consistent with each other + {old_holder_count, holder_recount} = + Repo.one!( + from(t in Token, + where: t.contract_address_hash == ^token.contract_address_hash, + select: { + coalesce(t.holder_count, 0), + fragment( + """ + (SELECT COUNT(DISTINCT ctb.address_hash) + FROM address_current_token_balances ctb + WHERE ctb.token_contract_address_hash = ? + AND ctb.address_hash <> ? + AND (ctb.value > 0 OR ctb.token_type = 'ERC-7984')) + """, + t.contract_address_hash, + ^burn_address_bytes() + ) + } + ), + timeout: query_timeout() + ) + + transfer_count = + token.contract_address_hash + |> TokenTransfer.count_token_transfers_from_token_hash_query(nil, safe_block) + |> Repo.aggregate(:count, timeout: query_timeout()) + + snapshot = TokenCounters.snapshot(token.contract_address_hash.bytes) + + %{ + token: token, + snapshot: snapshot, + old_holder_count: old_holder_count, + holder_recount: holder_recount, + transfer_count: transfer_count + } + rescue + error -> + Logger.warning(fn -> + [ + "Failed to recalculate counters for token #{to_string(token.contract_address_hash)}: ", + Exception.format(:error, error, __STACKTRACE__) + ] + end) + + nil + end + + defp apply_inits([], _safe_block), do: [] + + defp apply_inits(results, safe_block) do + hashes = Enum.map(results, & &1.token.contract_address_hash.bytes) + old_holder_counts = Enum.map(results, & &1.old_holder_count) + holder_recounts = Enum.map(results, & &1.holder_recount) + transfer_counts = Enum.map(results, & &1.transfer_count) + + query = + from( + token in Token, + join: + values in fragment( + """ + (SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS old_holder_count, + unnest(?::bigint[]) AS holder_recount, unnest(?::bigint[]) AS transfer_count) + """, + ^hashes, + ^old_holder_counts, + ^holder_recounts, + ^transfer_counts + ), + on: token.contract_address_hash == values.hash, + where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + where: is_nil(token.counters_updated_at), + update: [ + set: [ + # snapshot-relative: deltas committed since the measurement stay + holder_count: + fragment( + "LEAST(GREATEST(COALESCE(?, 0) - ? + ?, 0), ?)", + token.holder_count, + values.old_holder_count, + values.holder_recount, + @int4_max + ), + transfer_count: fragment("LEAST(?, ?)", values.transfer_count, @int4_max), + counters_updated_at: ^safe_block, + updated_at: ^DateTime.utc_now() + ] + ], + select: %{hash: token.contract_address_hash, transfer_count: token.transfer_count} + ) + + {_count, rows} = Repo.update_all(query, [], timeout: query_timeout()) + + rows_by_bytes = Map.new(rows, &{&1.hash.bytes, &1}) + + results + |> Enum.filter(&Map.has_key?(rows_by_bytes, &1.token.contract_address_hash.bytes)) + |> Enum.map(fn result -> + %{ + token: result.token, + snapshot: result.snapshot, + transfer_count: Map.fetch!(rows_by_bytes, result.token.contract_address_hash.bytes).transfer_count + } + end) + end + + ## Covered deltas (block re-fetch corrections) + + @doc """ + Applies the per-token transfer-count deltas of the given token transfers + directly to `tokens.transfer_count`, restricted per token to the blocks + already covered by its `counters_updated_at` watermark (contributions above + the watermark are left to regular range consolidation). `sign` selects + whether the contributions are added (`:positive` — re-imported content of + re-fetched blocks) or subtracted (`:negative` — old content of blocks queued + for re-fetch, see `Explorer.Chain.Import.Runner.Blocks`). + + Runs on `Explorer.Repo` unless another repo is given. Returns the hash bytes + of the updated tokens. + """ + @spec apply_covered_transfer_deltas([map()], :positive | :negative, Ecto.Repo.t()) :: [binary()] + def apply_covered_transfer_deltas(token_transfers, sign, repo \\ Repo) do + watermarks = + token_transfers + |> Enum.map(&TokenCounters.hash_bytes(&1.token_contract_address_hash)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> load_watermarks(repo) + + deltas = + TokenCounters.compute_transfer_deltas(token_transfers, fn bytes, block_number -> + case watermarks[bytes] do + nil -> false + watermark -> block_number <= watermark + end + end) + + if map_size(deltas) == 0 do + [] + else + apply_signed_transfer_deltas(deltas, watermarks, sign, repo) + end + end + + @doc """ + Resets the `counters_updated_at` watermark of the given tokens — forcing a + full counters recalculation on their next consolidation — when their + watermark already covers the given block number (a covered range changed + under it: rows inserted below the watermark, or a missed correction guard). + Reset tokens are marked dirty so the recalculation is actually scheduled. + Returns the hash bytes of the reset tokens; the caller is responsible for + invalidating the display cache. + """ + @spec reset_covered_watermarks(%{binary() => non_neg_integer()}, Ecto.Repo.t()) :: [binary()] + def reset_covered_watermarks(min_block_by_hash_bytes, repo \\ Repo) + + def reset_covered_watermarks(min_block_by_hash_bytes, _repo) when map_size(min_block_by_hash_bytes) == 0, do: [] + + def reset_covered_watermarks(min_block_by_hash_bytes, repo) do + {hashes, min_blocks} = min_block_by_hash_bytes |> Enum.sort() |> Enum.unzip() + + query = + from( + token in Token, + join: + affected in fragment( + "(SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS block_number)", + ^hashes, + ^min_blocks + ), + on: token.contract_address_hash == affected.hash, + where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + where: not is_nil(token.counters_updated_at) and token.counters_updated_at >= affected.block_number, + update: [set: [counters_updated_at: nil, updated_at: ^DateTime.utc_now()]], + select: token.contract_address_hash + ) + + {_count, reset_hashes} = repo.update_all(query, [], timeout: query_timeout()) + + reset_bytes = Enum.map(reset_hashes, & &1.bytes) + + # a spurious marker on transaction rollback is harmless (the guarded + # consolidation of a non-reset token is a no-op) + TokenCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + + reset_bytes + end + + defp apply_signed_transfer_deltas(deltas, watermarks, sign, repo) do + multiplier = if sign == :negative, do: -1, else: 1 + + entries = Enum.to_list(deltas) + hashes = Enum.map(entries, fn {bytes, _delta} -> bytes end) + expected_watermarks = Enum.map(entries, fn {bytes, _delta} -> watermarks[bytes] end) + transfer_deltas = Enum.map(entries, fn {_bytes, delta} -> multiplier * delta.transfer_count end) + + query = + from( + token in Token, + join: + deltas in fragment( + "(SELECT unnest(?::bytea[]) AS hash, unnest(?::bigint[]) AS expected_watermark, unnest(?::bigint[]) AS transfer_delta)", + ^hashes, + ^expected_watermarks, + ^transfer_deltas + ), + on: token.contract_address_hash == deltas.hash, + where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + # only apply when the watermark is unchanged since the deltas were + # computed; changed tokens are reset below for a full recalculation + where: token.counters_updated_at == deltas.expected_watermark, + update: [ + set: [ + transfer_count: + fragment( + "LEAST(GREATEST(COALESCE(?, 0) + ?, 0), ?)", + token.transfer_count, + deltas.transfer_delta, + @int4_max + ), + updated_at: ^DateTime.utc_now() + ] + ], + select: token.contract_address_hash + ) + + {_count, updated_hashes} = repo.update_all(query, [], timeout: query_timeout()) + + updated_bytes = Enum.map(updated_hashes, & &1.bytes) + + # tokens whose watermark moved between the read and the guarded update + # cannot be corrected by a delta anymore — force a full recalculation + missed_reset_bytes = + (hashes -- updated_bytes) + |> Map.new(&{&1, 0}) + |> reset_covered_watermarks(repo) + + updated_bytes ++ missed_reset_bytes + end + + defp load_watermarks(token_bytes_list, repo) do + hashes = Enum.map(token_bytes_list, &%Hash{byte_count: 20, bytes: &1}) + + from(token in Token, + where: token.contract_address_hash in ^hashes and not is_nil(token.counters_updated_at), + select: {token.contract_address_hash, token.counters_updated_at} + ) + |> repo.all(timeout: query_timeout()) + |> Map.new(fn {hash, watermark} -> {hash.bytes, watermark} end) + end + + defp load_tokens(hash_bytes_list) do + hashes = Enum.map(hash_bytes_list, &%Hash{byte_count: 20, bytes: &1}) + + query = + from(token in Token, + where: token.contract_address_hash in ^hashes, + select: struct(token, [:contract_address_hash, :holder_count, :transfer_count, :counters_updated_at]) + ) + + Repo.all(query, timeout: query_timeout()) + end + + # Enforce Token ShareLocks order (see docs: sharelocks.md) + defp tokens_lock_query(hashes_bytes) do + hashes = Enum.map(hashes_bytes, &%Hash{byte_count: 20, bytes: &1}) + + from( + token in Token, + where: token.contract_address_hash in ^hashes, + select: token.contract_address_hash, + order_by: token.contract_address_hash, + lock: "FOR NO KEY UPDATE" + ) + end + + defp burn_address_bytes do + {:ok, burn_address_hash} = Hash.Address.cast(burn_address_hash_string()) + + burn_address_hash.bytes + end + + defp schedule_next_consolidation(timeout) do + Process.send_after(self(), :consolidate, timeout) + end + + defp interval do + Application.get_env(:explorer, __MODULE__)[:interval] || :timer.minutes(10) + end + + defp batch_size do + Application.get_env(:explorer, __MODULE__)[:batch_size] || 100 + end + + defp concurrency do + Application.get_env(:explorer, __MODULE__)[:concurrency] || 2 + end + + defp query_timeout do + Application.get_env(:explorer, __MODULE__)[:query_timeout] || :timer.minutes(5) + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex deleted file mode 100644 index 75860cef8d6..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex +++ /dev/null @@ -1,101 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do - @moduledoc """ - Caches Token holders count. - """ - use GenServer - use Utils.CompileTimeEnvHelper, enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]] - - alias Explorer.Chain.Address.CurrentTokenBalance - alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Chain.{Hash, Token} - - @api_true [api?: true] - @cache_name :token_holders_count - @ets_last_update_key "last_update" - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - Helper.create_cache_table(@cache_name) - - {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} - end - - @impl true - def handle_continue(:ok, %{consolidate?: true} = state) do - {:noreply, state} - end - - @impl true - def handle_continue(:ok, state) do - {:noreply, state} - end - - @impl true - def handle_info(:consolidate, state) do - {:noreply, state} - end - - def fetch(address_hash) do - if cache_expired?(address_hash) do - update_cache(address_hash) - end - - fetch_count_from_cache(address_hash) - end - - def cache_name, do: @cache_name - - defp cache_expired?(address_hash) do - cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - updated_at = fetch_updated_at_from_cache(address_hash, @cache_name) - - cond do - is_nil(updated_at) -> true - Helper.current_time() - updated_at > cache_period -> true - true -> false - end - end - - defp update_cache(address_hash) do - address_hash_string = to_string(address_hash) - new_data = CurrentTokenBalance.count_token_holders_from_token_hash(address_hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@ets_last_update_key}", Helper.current_time()) - put_into_db_cache(address_hash, new_data) - end - - @doc """ - Fetches the token holders count from the cache or database. - """ - @spec fetch_count_from_cache(Hash.Address.t()) :: integer() - def fetch_count_from_cache(address_hash) do - address_hash_string = to_string(address_hash) - key = "hash_#{address_hash_string}" - - Helper.fetch_from_ets_cache(@cache_name, key) || fetch_from_db_cache(address_hash) - end - - defp fetch_updated_at_from_cache(address_hash, cache_name) do - address_hash_string = to_string(address_hash) - key = "hash_#{address_hash_string}_#{@ets_last_update_key}" - - Helper.fetch_from_ets_cache(cache_name, key) - end - - defp fetch_from_db_cache(address_hash) do - token = Token.get_by_contract_address_hash(address_hash, @api_true) - (token && token.holder_count) || 0 - end - - defp put_into_db_cache(address_hash, count) do - Token.update_token_holder_count(address_hash, count) - end - - defp enable_consolidation?, do: @enable_consolidation -end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex deleted file mode 100644 index c70d6a38fa8..00000000000 --- a/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex +++ /dev/null @@ -1,101 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Cache.Counters.TokenTransfersCount do - @moduledoc """ - Caches Token transfers counter. - """ - use GenServer - use Utils.CompileTimeEnvHelper, enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]] - - alias Explorer.Chain - alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Chain.{Hash, Token} - - @api_true [api?: true] - @cache_name :token_transfers_counter - @ets_last_update_key "last_update" - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - Helper.create_cache_table(@cache_name) - - {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} - end - - @impl true - def handle_continue(:ok, %{consolidate?: true} = state) do - {:noreply, state} - end - - @impl true - def handle_continue(:ok, state) do - {:noreply, state} - end - - @impl true - def handle_info(:consolidate, state) do - {:noreply, state} - end - - def fetch(address_hash) do - if cache_expired?(address_hash) do - update_cache(address_hash) - end - - fetch_count_from_cache(address_hash) - end - - def cache_name, do: @cache_name - - defp cache_expired?(address_hash) do - cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - updated_at = fetch_updated_at_from_cache(address_hash, @cache_name) - - cond do - is_nil(updated_at) -> true - Helper.current_time() - updated_at > cache_period -> true - true -> false - end - end - - defp update_cache(address_hash) do - address_hash_string = to_string(address_hash) - new_data = Chain.count_token_transfers_from_token_hash(address_hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@ets_last_update_key}", Helper.current_time()) - put_into_db_cache(address_hash, new_data) - end - - @doc """ - Fetches the token transfers count from the cache or database. - """ - @spec fetch_count_from_cache(Hash.Address.t()) :: integer() - def fetch_count_from_cache(address_hash) do - address_hash_string = to_string(address_hash) - key = "hash_#{address_hash_string}" - - Helper.fetch_from_ets_cache(@cache_name, key) || fetch_from_db_cache(address_hash) - end - - defp fetch_updated_at_from_cache(address_hash, cache_name) do - address_hash_string = to_string(address_hash) - key = "hash_#{address_hash_string}_#{@ets_last_update_key}" - - Helper.fetch_from_ets_cache(cache_name, key) - end - - defp fetch_from_db_cache(address_hash) do - token = Token.get_by_contract_address_hash(address_hash, @api_true) - (token && token.transfer_count) || 0 - end - - defp put_into_db_cache(address_hash, count) do - Token.update_token_transfer_count(address_hash, count) - end - - defp enable_consolidation?, do: @enable_consolidation -end diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index 00658705d91..ffae635ab3f 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -8,7 +8,15 @@ defmodule Explorer.Chain.Import do alias Explorer.Account.Notify alias Explorer.Chain.{Block, Import} alias Explorer.Chain.Cache.BlockNumber - alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} + + alias Explorer.Chain.Cache.Counters.{ + AddressCounters, + AddressCountersConsolidator, + Consolidation, + TokenCounters, + TokenCountersConsolidator + } + alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Import.Stage alias Explorer.Repo @@ -157,30 +165,31 @@ defmodule Explorer.Chain.Import do {:ok, runner_to_changes_list} <- runner_to_changes_list(valid_runner_option_pairs), {:ok, data} <- insert_runner_to_changes_list(runner_to_changes_list, options) do Notify.async(data[:transactions]) - update_address_counters(data, Map.get(options, :broadcast, false)) + update_counters(data, Map.get(options, :broadcast, false)) Publisher.broadcast(Map.drop(data, @not_broadcasted_runners), Map.get(options, :broadcast, false)) {:ok, data} end end # Registers imported transactions and token transfers in the incremental - # address counters, whatever the import source (block fetcher, on-demand and - # async fetchers, migrators): marks the involved addresses dirty for - # `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` and live-bumps - # the display cache for realtime imports. Token transfers inserted below an - # address's consolidation watermark (derived asynchronously — e.g. from - # internal transactions — or backfilled by migrators) cannot be covered by - # incremental range aggregates anymore, so such addresses get their - # watermark reset for a full recalculation. + # address and token counters, whatever the import source (block fetcher, + # on-demand and async fetchers, migrators): marks the involved addresses and + # tokens dirty for their consolidators and live-bumps the display caches for + # realtime imports. Token transfers inserted below a consolidation watermark + # (derived asynchronously — e.g. from internal transactions — or backfilled + # by migrators) cannot be covered by incremental range aggregates anymore, + # so the affected addresses and tokens get their watermark reset for a full + # recalculation. # # Runs after the import transaction committed, so failures here (e.g. a # transient DB error in the watermark reset) must not fail the import or # prevent the subsequent event broadcast — they only cost counter freshness. - defp update_address_counters(data, broadcast_type) do + defp update_counters(data, broadcast_type) do transactions = Map.get(data, :transactions, []) token_transfers = Map.get(data, :token_transfers, []) AddressCounters.handle_new_data(transactions, token_transfers, broadcast_type == :realtime) + TokenCounters.handle_new_data(token_transfers, broadcast_type == :realtime) if token_transfers != [] and Explorer.mode() in [:indexer, :all] do reset_watermarks_covering_token_transfers(token_transfers) @@ -190,13 +199,30 @@ defmodule Explorer.Chain.Import do rescue error -> Logger.error(fn -> - ["Could not update address counters for imported data: ", Exception.format(:error, error, __STACKTRACE__)] + ["Could not update counters for imported data: ", Exception.format(:error, error, __STACKTRACE__)] end) :ok end defp reset_watermarks_covering_token_transfers(token_transfers) do + # a covered watermark is at most the lagged head, so transfers above it can + # never land below any watermark — pure-realtime imports skip the DB round + # trips entirely + lagged_head = BlockNumber.get_max() - Consolidation.safe_block_lag() + + covered_candidates = + Enum.filter(token_transfers, &(not is_nil(&1.block_number) and &1.block_number <= lagged_head)) + + if covered_candidates != [] do + reset_address_watermarks(covered_candidates) + reset_token_watermarks(covered_candidates) + end + + :ok + end + + defp reset_address_watermarks(token_transfers) do reset_bytes = token_transfers |> Enum.flat_map(&token_transfer_participants/1) @@ -206,7 +232,23 @@ defmodule Explorer.Chain.Import do |> AddressCountersConsolidator.reset_covered_watermarks() AddressCounters.invalidate(reset_bytes) - AddressCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + end + + defp reset_token_watermarks(token_transfers) do + reset_bytes = + token_transfers + |> Enum.reduce(%{}, fn token_transfer, acc -> + case token_transfer.token_contract_address_hash do + nil -> + acc + + token_hash -> + Map.update(acc, token_hash.bytes, token_transfer.block_number, &min(&1, token_transfer.block_number)) + end + end) + |> TokenCountersConsolidator.reset_covered_watermarks() + + TokenCounters.invalidate(reset_bytes) end defp token_transfer_participants(token_transfer) do diff --git a/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex b/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex index e2d69283ed4..f4737da3229 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex @@ -22,68 +22,52 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do @type imported :: [CurrentTokenBalance.t()] - @spec to_holder_address_hash_set_by_token_contract_address_hash([CurrentTokenBalance.t()]) :: %{ - token_contract_address_hash => MapSet.t(holder_address_hash) + @typedoc """ + A current token balance row shape carrying enough fields to decide whether + it makes its address a holder of its token: `value > 0`, or any row of an + `ERC-7984` token (confidential balances), except for the burn address — + matching `Explorer.Chain.Address.CurrentTokenBalance.token_holders_query_for_count/1`. + """ + @type holder_row :: %{ + required(:address_hash) => Hash.Address.t(), + required(:token_contract_address_hash) => Hash.Address.t(), + required(:token_id) => Decimal.t() | nil, + required(:token_type) => String.t() | nil, + required(:value) => Decimal.t() | nil, + optional(atom()) => any() } - when token_contract_address_hash: Hash.Address.t(), holder_address_hash: Hash.Address.t() - def to_holder_address_hash_set_by_token_contract_address_hash(address_current_token_balances) - when is_list(address_current_token_balances) do - address_current_token_balances - |> Stream.filter(fn %{value: value} -> valid_holder?(value) end) - |> Enum.reduce(%{}, fn %{token_contract_address_hash: token_contract_address_hash, address_hash: address_hash}, - acc_holder_address_hash_set_by_token_contract_address_hash -> - updated_holder_address_hash_set = - acc_holder_address_hash_set_by_token_contract_address_hash - |> Map.get_lazy(token_contract_address_hash, &MapSet.new/0) - |> MapSet.put(address_hash) - - Map.put( - acc_holder_address_hash_set_by_token_contract_address_hash, - token_contract_address_hash, - updated_holder_address_hash_set - ) - end) - end - @spec token_holder_count_deltas(%{deleted: [current_token_balance], inserted: [current_token_balance]}) :: [ + @doc """ + Computes the holder-count deltas produced by a reorg: `deleted` are the + current token balance rows removed with the non-consensus blocks, `inserted` + the rows re-derived from the historical token balances. A `{token, address}` + pair contributes ±1 only when its holder-ness actually flipped, taking the + pair's untouched rows (other `token_id`s still in the DB) into account. + """ + @spec token_holder_count_deltas(Repo.t(), %{deleted: [holder_row()], inserted: [holder_row()]}, timeout()) :: [ Tokens.token_holder_count_delta() ] - when current_token_balance: %{ - address_hash: Hash.Address.t(), - token_contract_address_hash: Hash.Address.t(), - value: Decimal.t() - } - def token_holder_count_deltas(%{deleted: deleted, inserted: inserted}) when is_list(deleted) and is_list(inserted) do - deleted_holder_address_hash_set_by_token_contract_address_hash = - to_holder_address_hash_set_by_token_contract_address_hash(deleted) - - inserted_holder_address_hash_set_by_token_contract_address_hash = - to_holder_address_hash_set_by_token_contract_address_hash(inserted) - - ordered_token_contract_address_hashes = - ordered_token_contract_address_hashes([ - deleted_holder_address_hash_set_by_token_contract_address_hash, - inserted_holder_address_hash_set_by_token_contract_address_hash - ]) - - Enum.flat_map(ordered_token_contract_address_hashes, fn token_contract_address_hash -> - holder_count_delta = - holder_count_delta(%{ - deleted_holder_address_hash_set_by_token_contract_address_hash: - deleted_holder_address_hash_set_by_token_contract_address_hash, - inserted_holder_address_hash_set_by_token_contract_address_hash: - inserted_holder_address_hash_set_by_token_contract_address_hash, - token_contract_address_hash: token_contract_address_hash - }) - - case holder_count_delta do - 0 -> - [] - - _ -> - [%{contract_address_hash: token_contract_address_hash, delta: holder_count_delta}] - end - end) + def token_holder_count_deltas(repo, %{deleted: deleted, inserted: inserted}, timeout \\ @timeout) + when is_list(deleted) and is_list(inserted) do + deleted = Enum.reject(deleted, &burn_address?(&1.address_hash)) + inserted = Enum.reject(inserted, &burn_address?(&1.address_hash)) + + before_by_pair = rows_qualification_by_pair(deleted) + after_by_pair = rows_qualification_by_pair(inserted) + + pair_states = + (Map.keys(before_by_pair) ++ Map.keys(after_by_pair)) + |> Enum.uniq() + |> Map.new(fn pair -> + {pair, %{before?: Map.get(before_by_pair, pair, false), after?: Map.get(after_by_pair, pair, false)}} + end) + + # the re-inserted rows are the only reorg-touched rows still present in + # the DB (deleted-but-not-reinserted rows are gone), so excluding the + # inserted triples leaves exactly the pair rows untouched by the reorg + batch_triples = MapSet.new(inserted, &row_triple/1) + + pair_transition_deltas(repo, pair_states, batch_triples, timeout) end @impl Import.Runner @@ -126,7 +110,7 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do :filter_params ) end) - |> Multi.run(:address_current_token_balances, fn repo, %{filter_params: filtered_changes_list} -> + |> Multi.run(:address_current_token_balances, fn repo, %{filter_params: {filtered_changes_list, _existing_keys}} -> Instrumenter.block_import_stage_runner( fn -> insert(repo, filtered_changes_list, insert_options) end, :block_following, @@ -137,11 +121,12 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do |> Multi.run(:address_current_token_balances_update_token_holder_counts, fn repo, %{ address_current_token_balances: - upserted_balances + upserted_balances, + filter_params: {_, existing_keys} } -> Instrumenter.block_import_stage_runner( fn -> - token_holder_count_deltas = upserted_balances_to_holder_count_deltas(upserted_balances) + token_holder_count_deltas = upserted_balances_to_holder_count_deltas(repo, upserted_balances, existing_keys) # ShareLocks order already enforced by `acquire_contract_address_tokens` (see docs: sharelocks.md) Tokens.update_holder_counts_with_deltas( @@ -164,18 +149,78 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do not is_nil(value) and Decimal.compare(value, 0) == :gt end - # Assumes existence of old_value field with previous value or nil - defp upserted_balances_to_holder_count_deltas(upserted_balances) do - upserted_balances - |> Enum.map(fn %{token_contract_address_hash: contract_address_hash, value: value, old_value: old_value} -> - delta = - cond do - not valid_holder?(old_value) and valid_holder?(value) -> 1 - valid_holder?(old_value) and not valid_holder?(value) -> -1 - true -> 0 - end + # matches the semantics of `CurrentTokenBalance.token_holders_query_for_count/1`: + # any row of an ERC-7984 token counts (confidential balances), otherwise value > 0 + defp row_qualifies?(%{token_type: "ERC-7984"}), do: true + defp row_qualifies?(%{value: value}), do: valid_holder?(value) + + # whether the row made its address a holder BEFORE this upsert: freshly + # inserted rows did not exist; updated ERC-7984 rows existed (any row + # counts); otherwise the previous value decides (`old_value` is set to the + # pre-upsert value by the on_conflict clause) + defp old_row_qualifies?(row, existing_keys) do + if MapSet.member?(existing_keys, row_triple(row)) do + case row.token_type do + "ERC-7984" -> true + _ -> valid_holder?(row.old_value) + end + else + false + end + end + + defp rows_qualification_by_pair(rows) do + rows + |> Enum.group_by(&{&1.token_contract_address_hash, &1.address_hash}) + |> Map.new(fn {pair, pair_rows} -> {pair, Enum.any?(pair_rows, &row_qualifies?/1)} end) + end + + defp burn_address?(%Hash{bytes: bytes}), do: bytes == <<0::160>> + + defp row_triple(row) do + {row.address_hash.bytes, row.token_contract_address_hash.bytes, normalize_token_id(row.token_id)} + end + + defp normalize_token_id(nil), do: nil + defp normalize_token_id(%Decimal{} = token_id), do: Decimal.to_integer(token_id) + defp normalize_token_id(token_id) when is_integer(token_id), do: token_id + + # Computes the holder-count deltas of the freshly upserted current token + # balance rows: a {token, address} pair contributes ±1 only when its + # holder-ness actually flipped with this batch, taking the pair's other rows + # (untouched token_ids of ERC-1155/404 balances) into account. + defp upserted_balances_to_holder_count_deltas(repo, upserted_balances, existing_keys) do + pair_states = + upserted_balances + |> Enum.reject(&burn_address?(&1.address_hash)) + |> Enum.group_by(&{&1.token_contract_address_hash, &1.address_hash}) + |> Map.new(fn {pair, rows} -> + {pair, + %{ + before?: Enum.any?(rows, &old_row_qualifies?(&1, existing_keys)), + after?: Enum.any?(rows, &row_qualifies?/1) + }} + end) - %{contract_address_hash: contract_address_hash, delta: delta} + batch_triples = MapSet.new(upserted_balances, &row_triple/1) + + pair_transition_deltas(repo, pair_states, batch_triples, @timeout) + end + + # For every {token, address} pair whose holder-ness flipped within the + # batch, checks whether the pair has other qualifying rows outside the batch + # (then its holder-ness did not actually change) and folds the surviving + # flips into per-token deltas. + defp pair_transition_deltas(repo, pair_states, batch_triples, timeout) do + flipped = for {pair, %{before?: before?, after?: after?}} <- pair_states, before? != after?, do: {pair, after?} + + pairs_with_other_rows = + pairs_with_other_qualifying_rows(repo, Enum.map(flipped, fn {pair, _after?} -> pair end), batch_triples, timeout) + + flipped + |> Enum.reject(fn {pair, _after?} -> MapSet.member?(pairs_with_other_rows, pair) end) + |> Enum.map(fn {{token_contract_address_hash, _address_hash}, after?} -> + %{contract_address_hash: token_contract_address_hash, delta: if(after?, do: 1, else: -1)} end) |> Enum.group_by(& &1.contract_address_hash, & &1.delta) |> Enum.map(fn {contract_address_hash, deltas} -> @@ -185,34 +230,32 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do |> Enum.sort_by(& &1.contract_address_hash) end - defp holder_count_delta(%{ - deleted_holder_address_hash_set_by_token_contract_address_hash: - deleted_holder_address_hash_set_by_token_contract_address_hash, - inserted_holder_address_hash_set_by_token_contract_address_hash: - inserted_holder_address_hash_set_by_token_contract_address_hash, - token_contract_address_hash: token_contract_address_hash - }) do - case {deleted_holder_address_hash_set_by_token_contract_address_hash[token_contract_address_hash], - inserted_holder_address_hash_set_by_token_contract_address_hash[token_contract_address_hash]} do - {deleted_holder_address_hash_set, nil} -> - -1 * Enum.count(deleted_holder_address_hash_set) - - {nil, inserted_holder_address_hash_set} -> - Enum.count(inserted_holder_address_hash_set) - - {deleted_holder_address_hash_set, inserted_holder_address_hash_set} -> - inserted_holder_address_hash_count = - inserted_holder_address_hash_set - |> MapSet.difference(deleted_holder_address_hash_set) - |> Enum.count() - - deleted_holder_address_hash_count = - deleted_holder_address_hash_set - |> MapSet.difference(inserted_holder_address_hash_set) - |> Enum.count() - - inserted_holder_address_hash_count - deleted_holder_address_hash_count - end + defp pairs_with_other_qualifying_rows(_repo, [], _batch_triples, _timeout), do: MapSet.new() + + defp pairs_with_other_qualifying_rows(repo, pairs, batch_triples, timeout) do + ids = + Enum.map(pairs, fn {token_contract_address_hash, address_hash} -> + {address_hash.bytes, token_contract_address_hash.bytes} + end) + + query = + from(ctb in CurrentTokenBalance, + where: ^QueryHelper.tuple_in([:address_hash, :token_contract_address_hash], ids), + where: ctb.value > 0 or ctb.token_type == "ERC-7984", + select: {ctb.token_contract_address_hash, ctb.address_hash, ctb.token_id} + ) + + query + |> repo.all(timeout: timeout) + |> Enum.reduce(MapSet.new(), fn {token_contract_address_hash, address_hash, token_id}, acc -> + triple = {address_hash.bytes, token_contract_address_hash.bytes, normalize_token_id(token_id)} + + if MapSet.member?(batch_triples, triple) do + acc + else + MapSet.put(acc, {token_contract_address_hash, address_hash}) + end + end) end defp filter_params(repo, changes_list) do @@ -229,15 +272,25 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do %{block_number: ctb.block_number, value: ctb.value, value_fetched_at: ctb.value_fetched_at}} end) + existing_keys = + existing_ctb_without_token_id + |> Enum.concat(existing_ctb_with_token_id) + |> MapSet.new(fn ctb -> + {hash_bytes(ctb.address_hash), hash_bytes(ctb.token_contract_address_hash), normalize_token_id(ctb.token_id)} + end) + filtered_ctbs = Enum.filter(changes_list, fn ctb -> existing_ctb = existing_ctb_map[{ctb[:address_hash], ctb[:token_contract_address_hash], ctb[:token_id]}] should_update?(Map.put_new(ctb, :value_fetched_at, nil), existing_ctb) end) - {:ok, filtered_ctbs} + {:ok, {filtered_ctbs, existing_keys}} end + defp hash_bytes(%Hash{bytes: bytes}), do: bytes + defp hash_bytes(bytes) when is_binary(bytes), do: bytes + defp select_existing_current_token_balances(_repo, [], _with_token_id?), do: [] defp select_existing_current_token_balances(repo, params, false) do @@ -396,16 +449,4 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do fragment("? < EXCLUDED.value_fetched_at", current_token_balance.value_fetched_at))) ) end - - defp ordered_token_contract_address_hashes(holder_address_hash_set_by_token_contract_address_hash_list) - when is_list(holder_address_hash_set_by_token_contract_address_hash_list) do - holder_address_hash_set_by_token_contract_address_hash_list - |> Enum.reduce(MapSet.new(), fn holder_address_hash_set_by_token_contract_address_hash, acc -> - holder_address_hash_set_by_token_contract_address_hash - |> Map.keys() - |> MapSet.new() - |> MapSet.union(acc) - end) - |> Enum.sort() - end end diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index 375f88feef3..b91b23ae20e 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -30,7 +30,8 @@ defmodule Explorer.Chain.Import.Runner.Blocks do alias Explorer.Chain.Block.Reward alias Explorer.Chain.Cache.BlockNumber - alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator, TokenCounters} + alias Explorer.Chain.Cache.Counters.TokenCountersConsolidator alias Explorer.Chain.Import.Runner alias Explorer.Chain.Import.Runner.Address.CurrentTokenBalances alias Explorer.Chain.Import.Runner.{Addresses, TokenInstances, Tokens} @@ -38,7 +39,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer alias Explorer.Prometheus.Instrumenter alias Explorer.Repo, as: ExplorerRepo - alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} + alias Explorer.Utility.{CountersRefetchBlock, MissingBlockRange} alias Explorer.Chain.Celo.AggregatedElectionReward, as: CeloAggregatedElectionReward alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward @@ -96,15 +97,15 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :lose_consensus ) end) - |> Multi.run(:address_counters_refetched_block_numbers, fn repo, _ -> + |> Multi.run(:counters_refetched_block_numbers, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> # Note, needs to be executed before `blocks` which resets `refetch_needed` - address_counters_refetched_block_numbers(repo, changes_list, insert_options) + counters_refetched_block_numbers(repo, changes_list, insert_options) end, :address_referencing, :blocks, - :address_counters_refetched_block_numbers + :counters_refetched_block_numbers ) end) |> Multi.run(:blocks, fn repo, _ -> @@ -171,16 +172,16 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :derive_transaction_forks ) end) - |> Multi.run(:address_counters_corrections, fn repo, - %{ - address_counters_refetched_block_numbers: refetched_block_numbers, - fork_transactions: forked_transactions - } -> + |> Multi.run(:counters_corrections, fn repo, + %{ + counters_refetched_block_numbers: refetched_block_numbers, + fork_transactions: forked_transactions + } -> Instrumenter.block_import_stage_runner( - fn -> address_counters_corrections(repo, refetched_block_numbers, forked_transactions, insert_options) end, + fn -> counters_corrections(repo, refetched_block_numbers, forked_transactions, insert_options) end, :address_referencing, :blocks, - :address_counters_corrections + :counters_corrections ) end) |> Multi.run(:delete_address_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> @@ -275,7 +276,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do } -> Instrumenter.block_import_stage_runner( fn -> - deltas = CurrentTokenBalances.token_holder_count_deltas(%{deleted: deleted, inserted: inserted}) + deltas = CurrentTokenBalances.token_holder_count_deltas(repo, %{deleted: deleted, inserted: inserted}) Tokens.update_holder_counts_with_deltas(repo, deltas, insert_options) end, :address_referencing, @@ -306,9 +307,9 @@ defmodule Explorer.Chain.Import.Runner.Blocks do # Detects which of the incoming blocks are re-imports of blocks queued for a # re-fetch: their stored row still has `refetch_needed` set (the upcoming # `blocks` upsert resets it). Blocks already present in the - # `address_counters_refetch_blocks` queue are skipped — their current content + # `counters_refetch_blocks` queue are skipped — their current content # was never added to the counters, so there is nothing to subtract. - defp address_counters_refetched_block_numbers(repo, blocks_changes, %{timeout: timeout}) do + defp counters_refetched_block_numbers(repo, blocks_changes, %{timeout: timeout}) do hashes = blocks_changes |> Enum.map(& &1.hash) |> Enum.uniq() refetched_numbers = @@ -327,7 +328,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do _ -> pending_numbers = repo.all( - from(refetch_block in AddressCountersRefetchBlock, + from(refetch_block in CountersRefetchBlock, where: refetch_block.block_number in ^refetched_numbers, select: refetch_block.block_number ), @@ -341,30 +342,35 @@ defmodule Explorer.Chain.Import.Runner.Blocks do {:error, %{exception: postgrex_error}} end - # Corrects the incremental address counters for content changing under - # already-consolidated ranges: + # Corrects the incremental address and token counters for content changing + # under already-consolidated ranges: # # * deep reorgs — participants of forked transactions and of their token # transfers whose `counters_updated_at` watermark covers the forked block # get their watermark reset for a full recalculation (reorgs within the - # consolidation safety lag never satisfy the condition); + # consolidation safety lag never satisfy the condition). Token watermarks + # need no fork handling: token transfer rows of forked blocks stay + # physically present (only `block_consensus` flips) and the token counter + # counts physical rows; # * block re-fetches — the old content of the re-imported blocks is - # subtracted from the already-covered counters while it is still in the - # DB, and the block numbers are queued in `address_counters_refetch_blocks` - # so that `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` adds - # the re-imported content back once the re-fetch completes. Content of - # forked transactions is excluded (`block_number` already nulled) — those - # addresses are covered by the watermark reset. - defp address_counters_corrections(repo, refetched_block_numbers, forked_transactions, %{ + # subtracted from the already-covered address and token counters while it + # is still in the DB, and the block numbers are queued in + # `counters_refetch_blocks` so that + # `Explorer.Chain.Cache.Counters.Consolidation` adds the re-imported + # content back once the re-fetch completes. Content of forked transactions + # is excluded (`block_number` already nulled) — those addresses are + # covered by the watermark reset. + defp counters_corrections(repo, refetched_block_numbers, forked_transactions, %{ timeout: timeout, timestamps: timestamps }) do reset_bytes = reset_watermarks_for_forked(repo, forked_transactions, timeout) - subtracted_bytes = subtract_refetched_blocks_content(repo, refetched_block_numbers, timeout, timestamps) + {subtracted_address_bytes, subtracted_token_bytes} = + subtract_refetched_blocks_content(repo, refetched_block_numbers, timeout, timestamps) - AddressCounters.invalidate(reset_bytes ++ subtracted_bytes) - AddressCounters.mark_dirty(Enum.map(reset_bytes, &{&1, BlockNumber.get_max()})) + AddressCounters.invalidate(reset_bytes ++ subtracted_address_bytes) + TokenCounters.invalidate(subtracted_token_bytes) {:ok, %{reset: reset_bytes, refetched_block_numbers: refetched_block_numbers}} rescue @@ -398,7 +404,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do |> AddressCountersConsolidator.reset_covered_watermarks(repo) end - defp subtract_refetched_blocks_content(_repo, [], _timeout, _timestamps), do: [] + defp subtract_refetched_blocks_content(_repo, [], _timeout, _timestamps), do: {[], []} defp subtract_refetched_blocks_content(repo, block_numbers, timeout, timestamps) do transactions = @@ -414,16 +420,20 @@ defmodule Explorer.Chain.Import.Runner.Blocks do repo.all( from(token_transfer in TokenTransfer, where: token_transfer.block_number in ^block_numbers, - select: struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash]) + select: + struct(token_transfer, [:block_number, :from_address_hash, :to_address_hash, :token_contract_address_hash]) ), timeout: timeout ) - updated_bytes = AddressCountersConsolidator.apply_covered_deltas(transactions, token_transfers, :negative, repo) + updated_address_bytes = + AddressCountersConsolidator.apply_covered_deltas(transactions, token_transfers, :negative, repo) + + updated_token_bytes = TokenCountersConsolidator.apply_covered_transfer_deltas(token_transfers, :negative, repo) - # Enforce AddressCountersRefetchBlock ShareLocks order (see docs: sharelocks.md) + # Enforce CountersRefetchBlock ShareLocks order (see docs: sharelocks.md) repo.insert_all( - AddressCountersRefetchBlock, + CountersRefetchBlock, block_numbers |> Enum.sort() |> Enum.map(&%{block_number: &1, inserted_at: timestamps.inserted_at, updated_at: timestamps.updated_at}), @@ -431,7 +441,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do timeout: timeout ) - updated_bytes + {updated_address_bytes, updated_token_bytes} end defp fork_transactions(%{ @@ -984,6 +994,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :address_hash, :token_contract_address_hash, :token_id, + :token_type, :value ]), where: ctb.block_number in ^non_consensus_block_numbers @@ -1017,6 +1028,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :address_hash, :token_contract_address_hash, :token_id, + :token_type, # Used to determine if `address_hash` was a holder of `token_contract_address_hash` before # `address_current_token_balance` is deleted in `update_tokens_holder_count`. @@ -1084,7 +1096,10 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ) derived_address_current_token_balances = - Enum.map(result, &Map.take(&1, [:address_hash, :token_contract_address_hash, :token_id, :block_number, :value])) + Enum.map( + result, + &Map.take(&1, [:address_hash, :token_contract_address_hash, :token_id, :token_type, :block_number, :value]) + ) {:ok, derived_address_current_token_balances} end diff --git a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex index ef77f04be35..ccec1b64b04 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex @@ -59,7 +59,7 @@ defmodule Explorer.Chain.Import.Runner.Tokens do where: not is_nil(token.holder_count), update: [ set: [ - holder_count: token.holder_count + deltas.delta, + holder_count: fragment("LEAST(GREATEST(? + ?, 0), 2147483647)", token.holder_count, deltas.delta), updated_at: ^updated_at ] ], @@ -199,8 +199,8 @@ defmodule Explorer.Chain.Import.Runner.Tokens do cataloged: fragment("COALESCE(EXCLUDED.cataloged, ?)", token.cataloged), bridged: fragment("COALESCE(EXCLUDED.bridged, ?)", token.bridged), skip_metadata: fragment("COALESCE(EXCLUDED.skip_metadata, ?)", token.skip_metadata), - # `holder_count` and `transfer_count` are not updated as a pre-existing token means these counts are already initialized OR - # need to be migrated with `priv/repo/migrations/scripts/update_new_tokens_holder_count_in_batches.sql.exs` + # `holder_count`, `transfer_count` and `counters_updated_at` are not updated: pre-existing tokens are maintained by + # the incremental counters machinery (import-time holder deltas and the token counters consolidator) # Don't update `contract_address_hash` as it is the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at) @@ -233,8 +233,8 @@ defmodule Explorer.Chain.Import.Runner.Tokens do type: fragment("COALESCE(EXCLUDED.type, ?)", token.type), cataloged: fragment("COALESCE(EXCLUDED.cataloged, ?)", token.cataloged), skip_metadata: fragment("COALESCE(EXCLUDED.skip_metadata, ?)", token.skip_metadata), - # `holder_count` is not updated as a pre-existing token means the `holder_count` is already initialized OR - # need to be migrated with `priv/repo/migrations/scripts/update_new_tokens_holder_count_in_batches.sql.exs` + # `holder_count`, `transfer_count` and `counters_updated_at` are not updated: pre-existing tokens are maintained by + # the incremental counters machinery (import-time holder deltas and the token counters consolidator) # Don't update `contract_address_hash` as it is the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at) diff --git a/apps/explorer/lib/explorer/chain/token.ex b/apps/explorer/lib/explorer/chain/token.ex index bd283659ae6..d290a3b4a93 100644 --- a/apps/explorer/lib/explorer/chain/token.ex +++ b/apps/explorer/lib/explorer/chain/token.ex @@ -37,6 +37,7 @@ defmodule Explorer.Chain.Token.Schema do field(:volume_24h, FiatValue) field(:circulating_supply, :decimal) field(:transfer_count, :integer) + field(:counters_updated_at, :integer) belongs_to( :contract_address, @@ -95,15 +96,12 @@ defmodule Explorer.Chain.Token do alias Explorer.{Chain, SortingHelper} alias Explorer.Chain.{Address, BridgedToken, Hash, Search, Token} alias Explorer.Chain.Cache.{BackgroundMigrations, BlockNumber} - alias Explorer.Chain.Cache.Counters.{TokenHoldersCount, TokenTransfersCount} + alias Explorer.Chain.Cache.Counters.TokenCounters alias Explorer.Chain.Import.Runner alias Explorer.Helper, as: ExplorerHelper alias Explorer.Repo alias Explorer.SmartContract.Helper - # milliseconds - @timeout 60_000 - @default_sorting [ desc_nulls_last: :circulating_market_cap, desc_nulls_last: :fiat_value, @@ -233,6 +231,27 @@ defmodule Explorer.Chain.Token do |> Repo.stream_reduce(initial, reducer) end + @doc """ + Streams cataloged tokens with their current counter values (holders and + transfers counts) for the periodic counters export. Tokens whose counters + were never consolidated (`counters_updated_at IS NULL`) are skipped rather + than exported as zeros. + """ + @spec stream_cataloged_tokens_for_counters( + initial :: accumulator, + reducer :: (entry :: __MODULE__.t(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_cataloged_tokens_for_counters(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + __MODULE__ + |> where([token], token.cataloged == true and not is_nil(token.counters_updated_at)) + |> select([token], struct(token, [:contract_address_hash, :holder_count, :transfer_count])) + |> Chain.add_fetcher_limit(limited?) + |> order_by(asc: :contract_address_hash) + |> Repo.stream_reduce(initial, reducer) + end + @doc """ Update a new `t:Token.t/0` record. @@ -483,49 +502,6 @@ defmodule Explorer.Chain.Token do (last_address_hash && where(query, [token], token.contract_address_hash > ^last_address_hash)) || query end - @doc """ - Updates token_holder_count for a given contract_address_hash. - It used by Explorer.Chain.Cache.Counters.TokenHoldersCount module. - """ - @spec update_token_holder_count(Hash.Address.t(), integer()) :: {non_neg_integer(), nil} - def update_token_holder_count(contract_address_hash, holders_count) when not is_nil(holders_count) do - now = DateTime.utc_now() - - Repo.update_all( - from(t in __MODULE__, - where: t.contract_address_hash == ^contract_address_hash, - update: [set: [holder_count: ^holders_count, updated_at: ^now]] - ), - [], - timeout: @timeout - ) - end - - @doc """ - Updates `transfer_count` field for a given `contract_address_hash`. - Used by the `Explorer.Chain.Cache.Counters.TokenTransfersCount` module. - - ## Parameters - - `contract_address_hash`: The address of the token contract. - - `transfer_count`: The updated counter value. - - ## Returns - - `{updated_count, nil}` tuple where `updated_count` is the number of updated rows in the db table. - """ - @spec update_token_transfer_count(Hash.Address.t(), non_neg_integer()) :: {non_neg_integer(), nil} - def update_token_transfer_count(contract_address_hash, transfer_count) when not is_nil(transfer_count) do - now = DateTime.utc_now() - - Repo.update_all( - from(t in __MODULE__, - where: t.contract_address_hash == ^contract_address_hash, - update: [set: [transfer_count: ^transfer_count, updated_at: ^now]] - ), - [], - timeout: @timeout - ) - end - @doc """ Drops token info for the given token: Sets is_verified_via_admin_panel to false, icon_url to nil, symbol to nil, name to nil. @@ -589,75 +565,32 @@ defmodule Explorer.Chain.Token do end @doc """ - Fetches token counters (transfers count and holders count) for a given token address. + Fetches the token counters (transfers count and holders count) for a given + token. - This function spawns two async tasks to fetch the token transfers count and - token holders count concurrently. If a task times out or exits, it falls back - to fetching the cached value. + The transfers count is served from the incremental counter cache + (`Explorer.Chain.Cache.Counters.TokenCounters`) falling back to the + `transfer_count` column; the holders count comes straight from the + `holder_count` column, which is maintained live by the import-time deltas. + No aggregates run in the calling process — a token whose counters were never + calculated reports zeros and is marked for consolidation. ## Parameters - - `address_hash`: The contract address hash of the token - - `timeout`: The timeout in milliseconds for the async tasks + - `token`: The token to fetch the counters for ## Returns - - A tuple `{transfers_count, holders_count}` where each value is an integer or nil + - A tuple `{transfers_count, holders_count}` of non-negative integers """ - @spec fetch_token_counters(Hash.Address.t(), timeout()) :: {integer() | nil, integer() | nil} - def fetch_token_counters(address_hash, timeout) do - total_token_transfers_task = - Task.async(fn -> - TokenTransfersCount.fetch(address_hash) - end) - - total_token_holders_task = - Task.async(fn -> - TokenHoldersCount.fetch(address_hash) - end) + @spec fetch_token_counters(t()) :: {non_neg_integer(), non_neg_integer()} + def fetch_token_counters(%__MODULE__{} = token) do + transfers_count = TokenCounters.fetch(token) + holders_count = token.holder_count || 0 - [total_token_transfers_task, total_token_holders_task] - |> Task.yield_many(timeout) - |> Enum.map(fn {task, res} -> - case res do - {:ok, result} -> - result - - {:exit, reason} -> - Logger.warning("Query fetching token counters terminated: #{inspect(reason)}") - - fallback_cached_value_based_on_async_task_pid( - task.pid, - total_token_transfers_task.pid, - total_token_holders_task.pid, - address_hash - ) - - nil -> - Logger.warning("Query fetching token counters timed out.") - - fallback_cached_value_based_on_async_task_pid( - task.pid, - total_token_transfers_task.pid, - total_token_holders_task.pid, - address_hash - ) - end - end) - |> List.to_tuple() - end - - defp fallback_cached_value_based_on_async_task_pid( - task_pid, - total_token_transfers_task_pid, - total_token_holders_task_pid, - address_hash - ) do - case task_pid do - ^total_token_transfers_task_pid -> - TokenTransfersCount.fetch_count_from_cache(address_hash) - - ^total_token_holders_task_pid -> - TokenHoldersCount.fetch_count_from_cache(address_hash) + if is_nil(token.counters_updated_at) or is_nil(token.holder_count) do + TokenCounters.mark_dirty_at_max_block(token.contract_address_hash) end + + {transfers_count, holders_count} end @doc """ diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index d29f6f394f8..fa1d785f00d 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -411,6 +411,28 @@ defmodule Explorer.Chain.TokenTransfer do Repo.one(query, timeout: :infinity) end + @doc """ + Builds a query for the token transfers of the given token, optionally + bounded by a `(from_block_number, to_block_number]` block range (either + bound may be `nil` to leave that side open). Used by the incremental token + counters consolidation. + """ + @spec count_token_transfers_from_token_hash_query( + Hash.t(), + Explorer.Chain.Block.block_number() | nil, + Explorer.Chain.Block.block_number() | nil + ) :: Ecto.Query.t() + def count_token_transfers_from_token_hash_query(token_address_hash, from_block_number \\ nil, to_block_number \\ nil) do + TokenTransfer + |> where([tt], tt.token_contract_address_hash == ^token_address_hash) + |> then(fn query -> + if is_nil(from_block_number), do: query, else: where(query, [tt], tt.block_number > ^from_block_number) + end) + |> then(fn query -> + if is_nil(to_block_number), do: query, else: where(query, [tt], tt.block_number <= ^to_block_number) + end) + end + @spec count_token_transfers_from_token_hash_and_token_id(Hash.t(), non_neg_integer(), [api?]) :: non_neg_integer() def count_token_transfers_from_token_hash_and_token_id(token_address_hash, token_id, options) do query = diff --git a/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex b/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex new file mode 100644 index 00000000000..31fb738418e --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillTokenCounters do + @moduledoc """ + Backfills the incremental token counters: recalculates + `tokens.transfer_count` (full count bounded by the consolidation safe block) + and `tokens.holder_count` (snapshot-relative full recount — normalizing the + drift accumulated by the legacy delta semantics) for every token that was + never consolidated (`counters_updated_at IS NULL`) and stamps its + consolidation watermark. + + The migration walks the `tokens` primary key with a keyset cursor stored in + the migration `meta`, so it resumes where it left off after a restart. + Tokens consolidated concurrently (via the dirty-marker path of + `Explorer.Chain.Cache.Counters.TokenCountersConsolidator`) are skipped by + the `counters_updated_at IS NULL` guard. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.Cache.Counters.{Consolidation, TokenCountersConsolidator} + alias Explorer.Chain.Token + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "backfill_token_counters" + + # extra cursor sweeps picking up tokens whose recalculation failed on a + # previous pass (they keep a NULL watermark but the cursor already passed + # them) + @max_retry_passes 3 + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + query = + from(token in Token, + where: is_nil(token.counters_updated_at), + order_by: [asc: token.contract_address_hash], + limit: ^limit, + select: token.contract_address_hash + ) + + hashes = + state + |> Map.get("max_processed_hash") + |> case do + nil -> query + cursor -> where(query, [token], token.contract_address_hash > ^cursor) + end + |> Repo.all(timeout: :infinity) + + case List.last(hashes) do + nil -> maybe_retry_pass(state) + last_hash -> {hashes, Map.put(state, "max_processed_hash", to_string(last_hash))} + end + end + + defp maybe_retry_pass(state) do + retry_passes = Map.get(state, "retry_passes", 0) + + if retry_passes < @max_retry_passes and Map.has_key?(state, "max_processed_hash") and + Repo.exists?(from(token in Token, where: is_nil(token.counters_updated_at))) do + last_unprocessed_identifiers(%{"retry_passes" => retry_passes + 1}) + else + {[], state} + end + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(contract_address_hashes) do + TokenCountersConsolidator.consolidate_tokens(contract_address_hashes, await_safe_block()) + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_backfill_token_counters_finished(true) + end + + # Consolidation is not possible while the chain head is unknown or blocks + # below it are still missing or pending a re-fetch counter correction. Wait + # instead of skipping: the cursor must not advance past unprocessed tokens. + defp await_safe_block do + case Consolidation.safe_block() do + nil -> + Logger.info("#{@migration_name} migration is waiting for a safe block to consolidate up to") + Process.sleep(:timer.seconds(30)) + await_safe_block() + + safe_block -> + safe_block + end + end +end diff --git a/apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex b/apps/explorer/lib/explorer/utility/counters_refetch_block.ex similarity index 55% rename from apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex rename to apps/explorer/lib/explorer/utility/counters_refetch_block.ex index 893cc10cbdc..45be2aebb96 100644 --- a/apps/explorer/lib/explorer/utility/address_counters_refetch_block.ex +++ b/apps/explorer/lib/explorer/utility/counters_refetch_block.ex @@ -1,17 +1,18 @@ # SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Utility.AddressCountersRefetchBlock do +defmodule Explorer.Utility.CountersRefetchBlock do @moduledoc """ Keeps block numbers whose old content was already subtracted from the - incremental address counters (`addresses.transactions_count`, - `addresses.token_transfers_count`, `addresses.gas_used`) because the blocks - were queued for re-fetch (see `Explorer.Chain.Block.full_refetch/1`). - - A row means "the negative counters delta for this block was applied, the - positive delta from its re-imported content is still pending". Rows are - consumed by `Explorer.Chain.Cache.Counters.AddressCountersConsolidator` once - the corresponding block no longer requires a re-fetch. While a row exists, - the consolidator does not advance any address consolidation watermark past - this block number. + incremental counters (the address counters `addresses.transactions_count`, + `addresses.token_transfers_count`, `addresses.gas_used` and the token + counter `tokens.transfer_count`) because the blocks were queued for re-fetch + (see `Explorer.Chain.Block.full_refetch/1`). + + A row means "the negative counters deltas for this block were applied, the + positive deltas from its re-imported content are still pending". Rows are + consumed by `Explorer.Chain.Cache.Counters.Consolidation.settle_pending_refetch_blocks/0` + once the corresponding block no longer requires a re-fetch. While a row + exists, no consolidation watermark (address or token) advances past this + block number. """ use Explorer.Schema @@ -19,7 +20,7 @@ defmodule Explorer.Utility.AddressCountersRefetchBlock do alias Explorer.Repo @primary_key false - typed_schema "address_counters_refetch_blocks" do + typed_schema "counters_refetch_blocks" do field(:block_number, :integer, primary_key: true) timestamps() diff --git a/apps/explorer/priv/repo/migrations/20260831000002_add_counters_updated_at_to_tokens.exs b/apps/explorer/priv/repo/migrations/20260831000002_add_counters_updated_at_to_tokens.exs new file mode 100644 index 00000000000..00118c6385f --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260831000002_add_counters_updated_at_to_tokens.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCountersUpdatedAtToTokens do + use Ecto.Migration + + def change do + alter table(:tokens) do + add(:counters_updated_at, :bigint, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260831000003_rename_address_counters_refetch_blocks.exs b/apps/explorer/priv/repo/migrations/20260831000003_rename_address_counters_refetch_blocks.exs new file mode 100644 index 00000000000..f065687fc03 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260831000003_rename_address_counters_refetch_blocks.exs @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.RenameAddressCountersRefetchBlocks do + use Ecto.Migration + + # the block re-fetch corrections queue is shared by the address AND token + # counters consolidation, so the address-scoped name is dropped + def change do + rename(table(:address_counters_refetch_blocks), to: table(:counters_refetch_blocks)) + + execute( + "ALTER INDEX address_counters_refetch_blocks_pkey RENAME TO counters_refetch_blocks_pkey", + "ALTER INDEX counters_refetch_blocks_pkey RENAME TO address_counters_refetch_blocks_pkey" + ) + end +end diff --git a/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs index 389e8c35549..acbf1d9cc1a 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs @@ -3,9 +3,9 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do use Explorer.DataCase, async: false alias Explorer.Chain.Address - alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator} + alias Explorer.Chain.Cache.Counters.{AddressCounters, AddressCountersConsolidator, Consolidation} alias Explorer.Repo - alias Explorer.Utility.AddressCountersRefetchBlock + alias Explorer.Utility.CountersRefetchBlock describe "consolidate_addresses/2" do test "incrementally consolidates an address with a watermark" do @@ -95,15 +95,15 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do describe "safe_block/0" do setup do - initial_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + initial_env = Application.get_env(:explorer, Consolidation) || [] Application.put_env( :explorer, - AddressCountersConsolidator, + Consolidation, Keyword.merge(initial_env, safe_block_lag: 0) ) - on_exit(fn -> Application.put_env(:explorer, AddressCountersConsolidator, initial_env) end) + on_exit(fn -> Application.put_env(:explorer, Consolidation, initial_env) end) :ok end @@ -132,7 +132,7 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do test "is capped below the lowest block pending a re-fetch counter correction" do insert(:block, number: 0) insert(:block, number: 100) - Repo.insert!(%AddressCountersRefetchBlock{block_number: 30}) + Repo.insert!(%CountersRefetchBlock{block_number: 30}) assert AddressCountersConsolidator.safe_block() == 29 end @@ -158,15 +158,15 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do describe "consolidate/0" do setup do - initial_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + initial_env = Application.get_env(:explorer, Consolidation) || [] Application.put_env( :explorer, - AddressCountersConsolidator, + Consolidation, Keyword.merge(initial_env, safe_block_lag: 0) ) - on_exit(fn -> Application.put_env(:explorer, AddressCountersConsolidator, initial_env) end) + on_exit(fn -> Application.put_env(:explorer, Consolidation, initial_env) end) start_supervised!(AddressCounters) @@ -214,7 +214,7 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do transaction = :transaction |> insert(from_address: address) |> with_block(reimported_block) insert(:block, number: 100) - Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + Repo.insert!(%CountersRefetchBlock{block_number: 50}) AddressCountersConsolidator.consolidate() @@ -224,30 +224,55 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do assert reloaded.gas_used == Decimal.to_integer(transaction.gas_used) # settling a covered block does not move the watermark assert reloaded.counters_updated_at == 100 - assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 0 + assert Repo.aggregate(CountersRefetchBlock, :count) == 0 end test "does not settle blocks still awaiting their re-fetch" do insert(:block, number: 50, refetch_needed: true) insert(:block, number: 100) - Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + Repo.insert!(%CountersRefetchBlock{block_number: 50}) AddressCountersConsolidator.consolidate() - assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + assert Repo.aggregate(CountersRefetchBlock, :count) == 1 end test "does not settle blocks still covered by a missing range" do # refetch_needed already flipped, but the re-import has not fully - # completed yet (the missing range row is only removed afterwards) + # completed yet (the missing range row is only removed afterwards); + # missing ranges are stored descending: from_number is the high end insert(:block, number: 50, refetch_needed: false) insert(:block, number: 100) - insert(:missing_block_range, from_number: 50, to_number: 50) - Repo.insert!(%AddressCountersRefetchBlock{block_number: 50}) + insert(:missing_block_range, from_number: 60, to_number: 40) + Repo.insert!(%CountersRefetchBlock{block_number: 50}) AddressCountersConsolidator.consolidate() - assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + assert Repo.aggregate(CountersRefetchBlock, :count) == 1 + end + end + + describe "reset_covered_watermarks/2" do + test "resets only addresses whose watermark covers the block and marks them dirty" do + start_supervised!(AddressCounters) + + covered_address = insert(:address, counters_updated_at: 100) + fresh_address = insert(:address, counters_updated_at: 30) + + reset_bytes = + AddressCountersConsolidator.reset_covered_watermarks(%{ + covered_address.hash.bytes => 50, + fresh_address.hash.bytes => 50 + }) + + assert reset_bytes == [covered_address.hash.bytes] + assert is_nil(Repo.get(Address, covered_address.hash).counters_updated_at) + assert Repo.get(Address, fresh_address.hash).counters_updated_at == 30 + + # the recalculation of the reset address is scheduled + :sys.get_state(AddressCounters) + assert {[{marked_bytes, _block_number}], _continuation} = AddressCounters.select_dirty(10) + assert marked_bytes == covered_address.hash.bytes end end diff --git a/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs b/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs new file mode 100644 index 00000000000..f80ea289eb5 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.TokenCountersConsolidatorTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.Counters.{Consolidation, TokenCounters, TokenCountersConsolidator} + alias Explorer.Chain.Token + alias Explorer.Repo + alias Explorer.Utility.CountersRefetchBlock + + describe "consolidate_tokens/2" do + test "incrementally consolidates a token with a watermark" do + token = insert(:token, transfer_count: 7, holder_count: 3, counters_updated_at: 100) + + in_range_block = insert(:block, number: 150) + above_range_block = insert(:block, number: 300) + + insert_transfer(token, in_range_block) + insert_transfer(token, above_range_block) + + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], 200) + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 8 + # holders are delta-maintained, the incremental path leaves them alone + assert reloaded.holder_count == 3 + assert reloaded.counters_updated_at == 200 + end + + test "recalculates a token without a watermark from scratch, replacing stale values" do + token = insert(:token, transfer_count: 1000, holder_count: 1000) + + block = insert(:block, number: 50) + insert_transfer(token, block) + + insert(:address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: 10) + insert(:address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: 20) + + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], 200) + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 1 + assert reloaded.holder_count == 2 + assert reloaded.counters_updated_at == 200 + end + + test "the holder recount excludes the burn address, counts any ERC-7984 row and distinct holders once" do + token = insert(:token, holder_count: nil) + + burn_address = insert(:address, hash: "0x0000000000000000000000000000000000000000") + + insert(:address_current_token_balance, + address: burn_address, + token_contract_address_hash: token.contract_address_hash, + value: 100 + ) + + confidential_holder = insert(:address) + + insert(:address_current_token_balance, + address: confidential_holder, + token_contract_address_hash: token.contract_address_hash, + value: 0, + token_type: "ERC-7984" + ) + + multi_row_holder = insert(:address) + + insert(:address_current_token_balance, + address: multi_row_holder, + token_contract_address_hash: token.contract_address_hash, + token_id: 1, + token_type: "ERC-1155", + value: 5 + ) + + insert(:address_current_token_balance, + address: multi_row_holder, + token_contract_address_hash: token.contract_address_hash, + token_id: 2, + token_type: "ERC-1155", + value: 7 + ) + + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], 200) + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + # confidential holder + the multi-row holder counted once; burn excluded + assert reloaded.holder_count == 2 + end + + test "a drifted holder_count is normalized to the recount" do + token = insert(:token, transfer_count: 0, holder_count: 100) + + insert(:address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: 10) + + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], 200) + + assert Repo.get_by(Token, contract_address_hash: token.contract_address_hash).holder_count == 1 + end + + test "skips tokens already consolidated at or above the safe block" do + token = insert(:token, transfer_count: 7, counters_updated_at: 300) + + block = insert(:block, number: 150) + insert_transfer(token, block) + + TokenCountersConsolidator.consolidate_tokens([token.contract_address_hash], 200) + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 7 + assert reloaded.counters_updated_at == 300 + end + end + + describe "consolidation cycle process" do + test "the process stays responsive while a cycle runs and reports it in its state" do + pid = start_supervised!(TokenCountersConsolidator) + + send(pid, :consolidate) + + # the cycle runs in a task, so the process answers immediately + assert %{cycle_task: _, cycle_started_at: _, last_cycle_finished_at: _} = :sys.get_state(pid, 1_000) + + wait_for_results(fn -> + case :sys.get_state(pid, 1_000) do + %{cycle_task: nil, last_cycle_finished_at: %DateTime{}} -> :ok + _ -> raise Ecto.NoResultsError, queryable: "cycle completion" + end + end) + end + end + + describe "consolidate/0" do + setup do + initial_env = Application.get_env(:explorer, Consolidation) || [] + + Application.put_env( + :explorer, + Consolidation, + Keyword.merge(initial_env, safe_block_lag: 0) + ) + + on_exit(fn -> Application.put_env(:explorer, Consolidation, initial_env) end) + + start_supervised!(TokenCounters) + + :ok + end + + test "consolidates dirty tokens and clears their markers" do + token = insert(:token, transfer_count: 1, counters_updated_at: 10) + + insert(:block, number: 0) + block = insert(:block, number: 100) + insert_transfer(token, block) + + TokenCounters.mark_dirty([{token.contract_address_hash.bytes, 100}]) + :sys.get_state(TokenCounters) + + TokenCountersConsolidator.consolidate() + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 2 + assert reloaded.counters_updated_at == 100 + assert TokenCounters.dirty_empty?() + end + + test "keeps markers above the safe block" do + token = insert(:token, transfer_count: 1, counters_updated_at: 10) + + insert(:block, number: 0) + insert(:block, number: 100) + + TokenCounters.mark_dirty([{token.contract_address_hash.bytes, 500}]) + :sys.get_state(TokenCounters) + + TokenCountersConsolidator.consolidate() + + assert Repo.get_by(Token, contract_address_hash: token.contract_address_hash).counters_updated_at == 100 + refute TokenCounters.dirty_empty?() + end + + test "settles re-fetched blocks: adds the re-imported transfers below the watermark and drops the queue rows" do + token = insert(:token, transfer_count: 5, counters_updated_at: 100) + + reimported_block = insert(:block, number: 50, refetch_needed: false) + insert_transfer(token, reimported_block) + + insert(:block, number: 100) + Repo.insert!(%CountersRefetchBlock{block_number: 50}) + + TokenCountersConsolidator.consolidate() + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 6 + # settling a covered block does not move the watermark + assert reloaded.counters_updated_at == 100 + assert Repo.aggregate(CountersRefetchBlock, :count) == 0 + end + end + + describe "apply_covered_transfer_deltas/3" do + test "applies negative deltas only for blocks covered by the token watermark" do + token = insert(:token, transfer_count: 5, counters_updated_at: 100) + + token_transfers = [ + %{token_contract_address_hash: token.contract_address_hash, block_number: 50}, + # above the watermark: never counted, must not be subtracted + %{token_contract_address_hash: token.contract_address_hash, block_number: 150} + ] + + [updated_bytes] = TokenCountersConsolidator.apply_covered_transfer_deltas(token_transfers, :negative) + + assert updated_bytes == token.contract_address_hash.bytes + + reloaded = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + + assert reloaded.transfer_count == 4 + assert reloaded.counters_updated_at == 100 + end + + test "skips tokens without a watermark" do + token = insert(:token, transfer_count: 5) + + token_transfers = [%{token_contract_address_hash: token.contract_address_hash, block_number: 50}] + + assert TokenCountersConsolidator.apply_covered_transfer_deltas(token_transfers, :positive) == [] + assert Repo.get_by(Token, contract_address_hash: token.contract_address_hash).transfer_count == 5 + end + end + + describe "reset_covered_watermarks/2" do + test "resets only tokens whose watermark covers the block and marks them dirty" do + start_supervised!(TokenCounters) + + covered_token = insert(:token, counters_updated_at: 100) + fresh_token = insert(:token, counters_updated_at: 30) + + reset_bytes = + TokenCountersConsolidator.reset_covered_watermarks(%{ + covered_token.contract_address_hash.bytes => 50, + fresh_token.contract_address_hash.bytes => 50 + }) + + assert reset_bytes == [covered_token.contract_address_hash.bytes] + assert is_nil(Repo.get_by(Token, contract_address_hash: covered_token.contract_address_hash).counters_updated_at) + assert Repo.get_by(Token, contract_address_hash: fresh_token.contract_address_hash).counters_updated_at == 30 + + # the recalculation of the reset token is scheduled + :sys.get_state(TokenCounters) + assert {[{marked_bytes, _block_number}], _continuation} = TokenCounters.select_dirty(10) + assert marked_bytes == covered_token.contract_address_hash.bytes + end + end + + defp insert_transfer(token, block) do + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address + ) + end +end diff --git a/apps/explorer/test/explorer/chain/cache/counters/token_counters_test.exs b/apps/explorer/test/explorer/chain/cache/counters/token_counters_test.exs new file mode 100644 index 00000000000..eb3d6f68bdd --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/counters/token_counters_test.exs @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.TokenCountersTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.Counters.TokenCounters + + setup do + start_supervised!(TokenCounters) + + :ok + end + + describe "fetch/1" do + test "returns the transfer_count column value and seeds the cache" do + token = insert(:token, transfer_count: 5, counters_updated_at: 100) + + assert TokenCounters.fetch(token) == 5 + + # the second read is served from ETS: a live bump is visible on top + TokenCounters.bump_live(token.contract_address_hash.bytes, %{transfer_count: 2, max_block_number: 101}) + + assert TokenCounters.fetch(token) == 7 + end + + test "returns zero and marks the token dirty when the counter was never calculated" do + token = insert(:token, transfer_count: nil) + + assert TokenCounters.fetch(token) == 0 + + :sys.get_state(TokenCounters) + + assert {[{bytes, _block_number}], _continuation} = TokenCounters.select_dirty(10) + assert bytes == token.contract_address_hash.bytes + end + end + + describe "compute_transfer_deltas/2" do + test "computes per-token deltas skipping rows without a block number" do + token_a = insert(:token) + token_b = insert(:token) + + token_transfers = [ + %{token_contract_address_hash: token_a.contract_address_hash, block_number: 100}, + %{token_contract_address_hash: token_a.contract_address_hash, block_number: 105}, + %{token_contract_address_hash: token_b.contract_address_hash, block_number: 101}, + %{token_contract_address_hash: token_a.contract_address_hash, block_number: nil} + ] + + deltas = TokenCounters.compute_transfer_deltas(token_transfers) + + assert %{transfer_count: 2, max_block_number: 105} = deltas[token_a.contract_address_hash.bytes] + assert %{transfer_count: 1, max_block_number: 101} = deltas[token_b.contract_address_hash.bytes] + end + + test "the include? predicate excludes single contributions" do + token = insert(:token) + + token_transfers = [ + %{token_contract_address_hash: token.contract_address_hash, block_number: 10}, + %{token_contract_address_hash: token.contract_address_hash, block_number: 20} + ] + + deltas = TokenCounters.compute_transfer_deltas(token_transfers, fn _bytes, block_number -> block_number <= 15 end) + + assert %{transfer_count: 1, max_block_number: 10} = deltas[token.contract_address_hash.bytes] + end + end + + describe "bump_live/2" do + test "never creates entries for tokens that are not cached" do + token = insert(:token, transfer_count: 1, counters_updated_at: 100) + + TokenCounters.bump_live(token.contract_address_hash.bytes, %{transfer_count: 5, max_block_number: 101}) + + # the first read seeds from the DB column: the bump was not applied anywhere + assert TokenCounters.fetch(token) == 1 + end + end + + describe "handle_new_data/2" do + test "bumps cached entries for live data and marks tokens dirty" do + token = insert(:token, transfer_count: 10, counters_updated_at: 100) + TokenCounters.fetch(token) + + token_transfers = [%{token_contract_address_hash: token.contract_address_hash, block_number: 300}] + + TokenCounters.handle_new_data(token_transfers, true) + :sys.get_state(TokenCounters) + + assert TokenCounters.fetch(token) == 11 + assert {[{bytes, 300}], _continuation} = TokenCounters.select_dirty(10) + assert bytes == token.contract_address_hash.bytes + end + + test "only marks tokens dirty for catchup data" do + token = insert(:token, transfer_count: 10, counters_updated_at: 100) + TokenCounters.fetch(token) + + token_transfers = [%{token_contract_address_hash: token.contract_address_hash, block_number: 300}] + + TokenCounters.handle_new_data(token_transfers, false) + :sys.get_state(TokenCounters) + + assert TokenCounters.fetch(token) == 10 + assert {[{_bytes, 300}], _continuation} = TokenCounters.select_dirty(10) + end + end + + describe "mark_dirty/1" do + test "is a no-op on api-only nodes" do + initial_mode = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, :api) + on_exit(fn -> Application.put_env(:explorer, :mode, initial_mode) end) + + TokenCounters.mark_dirty([{insert(:token).contract_address_hash.bytes, 10}]) + :sys.get_state(TokenCounters) + + assert TokenCounters.dirty_empty?() + end + + test "does not create new markers above the configured cap" do + initial_env = Application.get_env(:explorer, TokenCounters) || [] + Application.put_env(:explorer, TokenCounters, Keyword.merge(initial_env, max_dirty_markers: 1)) + on_exit(fn -> Application.put_env(:explorer, TokenCounters, initial_env) end) + + token_a = insert(:token) + token_b = insert(:token) + + TokenCounters.mark_dirty([{token_a.contract_address_hash.bytes, 10}]) + TokenCounters.mark_dirty([{token_b.contract_address_hash.bytes, 20}]) + # already-marked tokens can still be bumped + TokenCounters.mark_dirty([{token_a.contract_address_hash.bytes, 30}]) + :sys.get_state(TokenCounters) + + assert {[{bytes, 30}], _continuation} = TokenCounters.select_dirty(10) + assert bytes == token_a.contract_address_hash.bytes + end + end + + describe "stats/0" do + test "reports cached tokens and dirty markers" do + token = insert(:token, transfer_count: 5, counters_updated_at: 100) + TokenCounters.fetch(token) + + TokenCounters.mark_dirty([{insert(:token).contract_address_hash.bytes, 10}]) + :sys.get_state(TokenCounters) + + assert %{cached_tokens: 1, dirty_markers: 1, memory_bytes: memory_bytes} = TokenCounters.stats() + assert memory_bytes > 0 + end + end + + describe "delete_dirty_markers/2" do + test "keeps markers above the safe block" do + token_a = insert(:token) + token_b = insert(:token) + + TokenCounters.mark_dirty([{token_a.contract_address_hash.bytes, 100}, {token_b.contract_address_hash.bytes, 200}]) + :sys.get_state(TokenCounters) + + TokenCounters.delete_dirty_markers( + [token_a.contract_address_hash.bytes, token_b.contract_address_hash.bytes], + 150 + ) + + assert {[{bytes, 200}], _continuation} = TokenCounters.select_dirty(10) + assert bytes == token_b.contract_address_hash.bytes + end + end + + describe "eviction" do + test "drops entries not touched within the TTL" do + initial_env = Application.get_env(:explorer, TokenCounters) || [] + Application.put_env(:explorer, TokenCounters, Keyword.merge(initial_env, ttl: 0)) + on_exit(fn -> Application.put_env(:explorer, TokenCounters, initial_env) end) + + token = insert(:token, transfer_count: 5, counters_updated_at: 100) + TokenCounters.fetch(token) + + Process.sleep(1) + send(Process.whereis(TokenCounters), :evict) + :sys.get_state(TokenCounters) + + assert is_nil(TokenCounters.snapshot(token.contract_address_hash.bytes)) + end + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs b/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs index fc544223519..4e213346163 100644 --- a/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs @@ -145,10 +145,12 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do token_id: ^token_id_6 } ], + # one address holding multiple token_ids of the same token is + # a single holder address_current_token_balances_update_token_holder_counts: [ %{ contract_address_hash: ^token_contract_address_hash, - holder_count: 2 + holder_count: 1 }, %{ contract_address_hash: ^token_erc_20_contract_address_hash, @@ -160,7 +162,7 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do }, %{ contract_address_hash: ^token_erc_404_contract_address_hash, - holder_count: 2 + holder_count: 1 } ] }} = @@ -573,6 +575,92 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do options ) end + + test "burn address balances never affect the holder_count", %{ + token: %Token{contract_address_hash: token_contract_address_hash}, + options: options + } do + %Address{hash: burn_address_hash} = insert(:address, hash: "0x0000000000000000000000000000000000000000") + + assert {:ok, %{address_current_token_balances_update_token_holder_counts: []}} = + run_changes( + %{ + address_hash: burn_address_hash, + token_contract_address_hash: token_contract_address_hash, + block_number: 1, + value: Decimal.new(100), + value_fetched_at: DateTime.utc_now() + }, + options + ) + end + + test "an inserted ERC-7984 balance counts as a holder regardless of value, updates do not double count", %{ + address: %Address{hash: address_hash}, + options: options + } do + %Token{contract_address_hash: token_contract_address_hash} = insert(:token, type: "ERC-7984", holder_count: 0) + + changes = %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + block_number: 1, + value: Decimal.new(0), + value_fetched_at: DateTime.utc_now(), + token_type: "ERC-7984" + } + + assert {:ok, + %{ + address_current_token_balances_update_token_holder_counts: [ + %{contract_address_hash: ^token_contract_address_hash, holder_count: 1} + ] + }} = run_changes(changes, options) + + # updating an existing confidential balance is a 1 -> 1 transition + assert {:ok, %{address_current_token_balances_update_token_holder_counts: []}} = + run_changes(%{changes | block_number: 2, value_fetched_at: DateTime.utc_now()}, options) + end + + test "zeroing one token_id of a multi-token_id holder does not change the holder_count", %{ + address: %Address{hash: address_hash} = address, + options: options + } do + %Token{contract_address_hash: token_contract_address_hash} = insert(:token, type: "ERC-1155", holder_count: 1) + + insert(:address_current_token_balance, + address: address, + token_contract_address_hash: token_contract_address_hash, + token_id: 1, + token_type: "ERC-1155", + value: 5, + block_number: 1 + ) + + insert(:address_current_token_balance, + address: address, + token_contract_address_hash: token_contract_address_hash, + token_id: 2, + token_type: "ERC-1155", + value: 7, + block_number: 1 + ) + + # the address still holds token_id 2, so its holder-ness did not change + assert {:ok, %{address_current_token_balances_update_token_holder_counts: []}} = + run_changes( + %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + block_number: 2, + value: Decimal.new(0), + value_fetched_at: DateTime.utc_now(), + token_id: Decimal.new(1), + token_type: "ERC-1155" + }, + options + ) + end end defp run_changes(changes, options) when is_map(changes) do diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index dd03ca2ce8e..2e26a1b51f9 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -15,7 +15,7 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do alias Explorer.Chain.{Address, Block, Transaction, PendingBlockOperation} alias Explorer.Chain.Cache.BlockNumber alias Explorer.{Chain, Repo} - alias Explorer.Utility.{AddressCountersRefetchBlock, MissingBlockRange} + alias Explorer.Utility.{CountersRefetchBlock, MissingBlockRange} describe "run/1" do setup do @@ -118,7 +118,7 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, block_params) - assert {:ok, %{address_counters_refetched_block_numbers: [^block_number]}} = + assert {:ok, %{counters_refetched_block_numbers: [^block_number]}} = Multi.new() |> Blocks.run([block_changes], options) |> Repo.transaction() @@ -130,11 +130,11 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do assert reloaded.gas_used == 100 assert reloaded.counters_updated_at == block_number + 10 - assert Repo.aggregate(AddressCountersRefetchBlock, :count) == 1 + assert Repo.aggregate(CountersRefetchBlock, :count) == 1 # a second import of the same block applies no further deltas: the # re-fetch was already settled into the queue - assert {:ok, %{address_counters_refetched_block_numbers: []}} = + assert {:ok, %{counters_refetched_block_numbers: []}} = Multi.new() |> Blocks.run([block_changes], options) |> Repo.transaction() @@ -170,7 +170,7 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, block_params) - assert {:ok, %{address_counters_corrections: %{reset: reset_bytes}}} = + assert {:ok, %{counters_corrections: %{reset: reset_bytes}}} = Multi.new() |> Blocks.run([block_changes], options) |> Repo.transaction() diff --git a/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs b/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs index 5f0a3e7f8f5..a04629742ec 100644 --- a/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs +++ b/apps/explorer/test/explorer/migrator/backfill_address_counters_test.exs @@ -5,17 +5,17 @@ defmodule Explorer.Migrator.BackfillAddressCountersTest do import Ecto.Query alias Explorer.Chain.Address - alias Explorer.Chain.Cache.Counters.AddressCountersConsolidator + alias Explorer.Chain.Cache.Counters.{AddressCountersConsolidator, Consolidation} alias Explorer.Migrator.{BackfillAddressCounters, MigrationStatus} alias Explorer.Repo setup do - initial_consolidator_env = Application.get_env(:explorer, AddressCountersConsolidator) || [] + initial_consolidation_env = Application.get_env(:explorer, Consolidation) || [] Application.put_env( :explorer, - AddressCountersConsolidator, - Keyword.merge(initial_consolidator_env, safe_block_lag: 0) + Consolidation, + Keyword.merge(initial_consolidation_env, safe_block_lag: 0) ) initial_migrator_env = Application.get_env(:explorer, BackfillAddressCounters) || [] @@ -27,7 +27,7 @@ defmodule Explorer.Migrator.BackfillAddressCountersTest do ) on_exit(fn -> - Application.put_env(:explorer, AddressCountersConsolidator, initial_consolidator_env) + Application.put_env(:explorer, Consolidation, initial_consolidation_env) Application.put_env(:explorer, BackfillAddressCounters, initial_migrator_env) end) diff --git a/apps/explorer/test/explorer/migrator/backfill_token_counters_test.exs b/apps/explorer/test/explorer/migrator/backfill_token_counters_test.exs new file mode 100644 index 00000000000..d50e8167667 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/backfill_token_counters_test.exs @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillTokenCountersTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.Cache.Counters.Consolidation + alias Explorer.Chain.Token + alias Explorer.Migrator.{BackfillTokenCounters, MigrationStatus} + alias Explorer.Repo + + setup do + initial_consolidation_env = Application.get_env(:explorer, Consolidation) || [] + + Application.put_env( + :explorer, + Consolidation, + Keyword.merge(initial_consolidation_env, safe_block_lag: 0) + ) + + initial_migrator_env = Application.get_env(:explorer, BackfillTokenCounters) || [] + + Application.put_env( + :explorer, + BackfillTokenCounters, + Keyword.merge(initial_migrator_env, batch_size: 2, concurrency: 2, timeout: 0) + ) + + on_exit(fn -> + Application.put_env(:explorer, Consolidation, initial_consolidation_env) + Application.put_env(:explorer, BackfillTokenCounters, initial_migrator_env) + end) + + :ok + end + + test "backfills counters for tokens that were never consolidated" do + insert(:block, number: 0) + block = insert(:block, number: 100) + + # legacy values written at unknown heights get recalculated + active_token = insert(:token, transfer_count: 999, holder_count: 999) + + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: active_token.contract_address + ) + + insert(:address_current_token_balance, token_contract_address_hash: active_token.contract_address_hash, value: 10) + + dormant_token = insert(:token, transfer_count: nil, holder_count: nil) + + already_consolidated_token = insert(:token, transfer_count: 42, holder_count: 42, counters_updated_at: 77) + + assert MigrationStatus.get_status("backfill_token_counters") == nil + + BackfillTokenCounters.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"backfill_token_counters" and ms.status == "completed" + ) + ) + end) + + safe_block = Consolidation.safe_block() + + active_reloaded = Repo.get_by(Token, contract_address_hash: active_token.contract_address_hash) + assert active_reloaded.transfer_count == 1 + assert active_reloaded.holder_count == 1 + assert active_reloaded.counters_updated_at == safe_block + + dormant_reloaded = Repo.get_by(Token, contract_address_hash: dormant_token.contract_address_hash) + assert dormant_reloaded.transfer_count == 0 + assert dormant_reloaded.holder_count == 0 + assert dormant_reloaded.counters_updated_at == safe_block + + # tokens already consolidated are left untouched + consolidated_reloaded = Repo.get_by(Token, contract_address_hash: already_consolidated_token.contract_address_hash) + assert consolidated_reloaded.transfer_count == 42 + assert consolidated_reloaded.holder_count == 42 + assert consolidated_reloaded.counters_updated_at == 77 + + # nothing is left to process + assert Repo.one(from(token in Token, where: is_nil(token.counters_updated_at), select: count())) == 0 + end + + test "resumes from the cursor stored in the migration meta" do + insert(:block, number: 0) + insert(:block, number: 100) + + tokens = Enum.map(1..5, fn _ -> insert(:token) end) + max_hash = tokens |> Enum.map(&to_string(&1.contract_address_hash)) |> Enum.max() + + BackfillTokenCounters.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"backfill_token_counters" and ms.status == "completed" + ) + ) + end) + + %{meta: meta} = MigrationStatus.fetch("backfill_token_counters") + + assert meta["max_processed_hash"] >= max_hash + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex b/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex index 91960a0cdfa..1399be01d9b 100644 --- a/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex +++ b/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex @@ -1,7 +1,15 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenCountersUpdater do @moduledoc """ - Updates counters for cataloged tokens. + Periodically exports the counters of cataloged tokens to the Multichain + Search service. + + The counter values are read straight from the `tokens.holder_count` and + `tokens.transfer_count` columns — they are maintained by the incremental + counters machinery (import-time holder deltas and + `Explorer.Chain.Cache.Counters.TokenCountersConsolidator`), so no aggregates + run here. Tokens not yet consolidated are skipped until their first + consolidation. """ use Indexer.Fetcher, restart: :permanent @@ -10,7 +18,6 @@ defmodule Indexer.Fetcher.TokenCountersUpdater do alias Explorer.Chain.Token alias Explorer.MicroserviceInterfaces.MultichainSearch alias Indexer.BufferedTask - alias Timex.Duration @behaviour BufferedTask @@ -44,28 +51,33 @@ defmodule Indexer.Fetcher.TokenCountersUpdater do @impl BufferedTask def init(initial, reducer, _) do - counters_updater_milliseconds_interval = Application.get_env(:indexer, __MODULE__)[:milliseconds_interval] + # BufferedTask re-runs init whenever the queue drains; throttle the full + # export sweep to the configured interval (the legacy implementation was + # implicitly throttled by the metadata staleness predicate it reused) + interval = Application.get_env(:indexer, __MODULE__)[:milliseconds_interval] + last_stream_at = :persistent_term.get({__MODULE__, :last_stream_at}, 0) + now = System.system_time(:millisecond) - interval_in_minutes = - counters_updater_milliseconds_interval - |> Duration.from_milliseconds() - |> Duration.to_minutes() - |> trunc() + if now - last_stream_at >= interval do + :persistent_term.put({__MODULE__, :last_stream_at}, now) - {:ok, tokens} = Token.stream_cataloged_tokens(initial, reducer, interval_in_minutes, true) + {:ok, tokens} = Token.stream_cataloged_tokens_for_counters(initial, reducer, true) - tokens + tokens + else + initial + end end @impl BufferedTask def run(entries, _json_rpc_named_arguments) do - Logger.debug("updating token counters") + Logger.debug("exporting token counters") entries |> Enum.reduce(%{}, fn token, acc -> - {transfers_count, holders_count} = Token.fetch_token_counters(token.contract_address_hash, :infinity) + data_for_multichain = + MultichainSearch.prepare_token_counters_for_queue(token.transfer_count || 0, token.holder_count || 0) - data_for_multichain = MultichainSearch.prepare_token_counters_for_queue(transfers_count, holders_count) Map.put(acc, token.contract_address_hash.bytes, data_for_multichain) end) |> MultichainSearch.send_token_info_to_queue(:counters) diff --git a/config/runtime.exs b/config/runtime.exs index 31c37f6b987..2cde9a38271 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -439,14 +439,21 @@ config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, interval: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_INTERVAL", "10m"), batch_size: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_BATCH_SIZE", 100, min: 1), concurrency: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_CONCURRENCY", 4, min: 1), - safe_block_lag: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG", 12, min: 0), query_timeout: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT", "5m") -config :explorer, Explorer.Chain.Cache.Counters.TokenHoldersCount, - cache_period: ConfigHelper.parse_time_env_var("CACHE_TOKEN_HOLDERS_COUNTER_PERIOD", "1h") +config :explorer, Explorer.Chain.Cache.Counters.Consolidation, + safe_block_lag: ConfigHelper.parse_integer_env_var("CACHE_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG", 12, min: 0) -config :explorer, Explorer.Chain.Cache.Counters.TokenTransfersCount, - cache_period: ConfigHelper.parse_time_env_var("CACHE_TOKEN_TRANSFERS_COUNTER_PERIOD", "1h") +config :explorer, Explorer.Chain.Cache.Counters.TokenCounters, + ttl: ConfigHelper.parse_time_env_var("CACHE_TOKEN_COUNTERS_TTL", "2h"), + max_dirty_markers: ConfigHelper.parse_integer_env_var("CACHE_TOKEN_COUNTERS_MAX_DIRTY_MARKERS", 200_000, min: 1) + +config :explorer, Explorer.Chain.Cache.Counters.TokenCountersConsolidator, + enabled: !ConfigHelper.parse_bool_env_var("CACHE_TOKEN_COUNTERS_CONSOLIDATION_DISABLED"), + interval: ConfigHelper.parse_time_env_var("CACHE_TOKEN_COUNTERS_CONSOLIDATION_INTERVAL", "10m"), + batch_size: ConfigHelper.parse_integer_env_var("CACHE_TOKEN_COUNTERS_CONSOLIDATION_BATCH_SIZE", 100, min: 1), + concurrency: ConfigHelper.parse_integer_env_var("CACHE_TOKEN_COUNTERS_CONSOLIDATION_CONCURRENCY", 2, min: 1), + query_timeout: ConfigHelper.parse_time_env_var("CACHE_TOKEN_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT", "5m") config :explorer, Explorer.Chain.Cache.Counters.AverageBlockTime, enabled: true, @@ -957,6 +964,12 @@ config :explorer, Explorer.Migrator.BackfillAddressCounters, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY", 2), timeout: ConfigHelper.parse_time_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT", "500ms") +config :explorer, Explorer.Migrator.BackfillTokenCounters, + enabled: !ConfigHelper.parse_bool_env_var("MIGRATION_BACKFILL_TOKEN_COUNTERS_DISABLED"), + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_TOKEN_COUNTERS_BATCH_SIZE", 50), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_TOKEN_COUNTERS_CONCURRENCY", 4), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_BACKFILL_TOKEN_COUNTERS_TIMEOUT", "100ms") + config :explorer, Explorer.Chain.BridgedToken, eth_omni_bridge_mediator: System.get_env("BRIDGED_TOKENS_ETH_OMNI_BRIDGE_MEDIATOR"), bsc_omni_bridge_mediator: System.get_env("BRIDGED_TOKENS_BSC_OMNI_BRIDGE_MEDIATOR"), diff --git a/cspell.json b/cspell.json index 008c6164dd9..46f2fbd29d4 100644 --- a/cspell.json +++ b/cspell.json @@ -794,6 +794,7 @@ "wighawag", "WITHDRAWABILITY", "wobserver", + "wordsize", "wysdvjkizxonu", "x-ratelimit-limit", "x-ratelimit-remaining", diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 9608cc3522b..043126b6269 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -121,8 +121,6 @@ RELEASE_LINK= # CACHE_BLOCK_COUNT_PERIOD=7200 # CACHE_TXS_COUNT_PERIOD=7200 # CACHE_ADDRESS_SUM_PERIOD=3600 -# CACHE_TOKEN_HOLDERS_COUNTER_PERIOD=3600 -# CACHE_TOKEN_TRANSFERS_COUNTER_PERIOD=3600 # CACHE_ADDRESS_COUNT_PERIOD=1800 # CACHE_AVERAGE_BLOCK_PERIOD=1800 # CACHE_MARKET_HISTORY_PERIOD=21600 @@ -133,12 +131,23 @@ RELEASE_LINK= # CACHE_ADDRESS_COUNTERS_CONSOLIDATION_INTERVAL=10m # CACHE_ADDRESS_COUNTERS_CONSOLIDATION_BATCH_SIZE=100 # CACHE_ADDRESS_COUNTERS_CONSOLIDATION_CONCURRENCY=4 -# CACHE_ADDRESS_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG=12 # CACHE_ADDRESS_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT=5m +# CACHE_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG=12 +# CACHE_TOKEN_COUNTERS_TTL=2h +# CACHE_TOKEN_COUNTERS_MAX_DIRTY_MARKERS=200000 +# CACHE_TOKEN_COUNTERS_CONSOLIDATION_DISABLED=false +# CACHE_TOKEN_COUNTERS_CONSOLIDATION_INTERVAL=10m +# CACHE_TOKEN_COUNTERS_CONSOLIDATION_BATCH_SIZE=100 +# CACHE_TOKEN_COUNTERS_CONSOLIDATION_CONCURRENCY=2 +# CACHE_TOKEN_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT=5m # MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED=false # MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE=10 # MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY=2 # MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT=500ms +# MIGRATION_BACKFILL_TOKEN_COUNTERS_DISABLED=false +# MIGRATION_BACKFILL_TOKEN_COUNTERS_BATCH_SIZE=50 +# MIGRATION_BACKFILL_TOKEN_COUNTERS_CONCURRENCY=4 +# MIGRATION_BACKFILL_TOKEN_COUNTERS_TIMEOUT=100ms # CACHE_HOT_SMART_CONTRACTS_5M_PERIOD=30s # CACHE_HOT_SMART_CONTRACTS_1H_PERIOD=6m # CACHE_HOT_SMART_CONTRACTS_3H_PERIOD=18m From fbf8d898a26d1dab97e493bcf307765c2f97b0ed Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:46:54 +0400 Subject: [PATCH 16/82] fix: Disable old broadcast for token transfers without subscribers (#14777) --- .../lib/block_scout_web/notifier.ex | 99 ++++++++++++------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index 4851f8fbccc..2a6e2496349 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -321,15 +321,10 @@ defmodule BlockScoutWeb.Notifier do ) |> Instance.preload_nft(@api_true) - transfers_by_token = - Enum.group_by(all_token_transfers_full, fn tt -> to_string(tt.token_contract_address_hash) end) + broadcast_token_transfers_websocket_v2(all_token_transfers_full) - broadcast_token_transfers_websocket_v2(all_token_transfers_full, transfers_by_token) - - for {_token_contract_address_hash, token_transfers} <- transfers_by_token do - token_transfers - |> Enum.each(&broadcast_token_transfer/1) - end + # TODO: delete duplicated event when old UI becomes deprecated + broadcast_token_transfers_websocket_v1(all_token_transfers_full) end end @@ -865,26 +860,40 @@ defmodule BlockScoutWeb.Notifier do end end - defp broadcast_token_transfers_websocket_v2(tokens_transfers, transfers_by_token) do - for {token_contract_address_hash, token_transfers} <- transfers_by_token do - Endpoint.broadcast("tokens:#{token_contract_address_hash}", "token_transfer", %{ - token_transfer: Enum.count(token_transfers) - }) - end + defp broadcast_token_transfers_websocket_v2(tokens_transfers) do + case Enum.filter(tokens_transfers, &token_transfer_has_v2_subscribers?/1) do + [] -> + :ok - prepared_token_transfers = - TransactionView.render("token_transfers.json", %{ - token_transfers: tokens_transfers, - conn: nil - }) + relevant_token_transfers -> + for {token_contract_address_hash, token_transfers} <- + Enum.group_by(relevant_token_transfers, fn tt -> to_string(tt.token_contract_address_hash) end) do + Endpoint.broadcast("tokens:#{token_contract_address_hash}", "token_transfer", %{ + token_transfer: Enum.count(token_transfers) + }) + end + + prepared_token_transfers = + TransactionView.render("token_transfers.json", %{ + token_transfers: relevant_token_transfers, + conn: nil + }) + + relevant_token_transfers + |> Enum.zip(prepared_token_transfers) + |> group_by_address_hashes_and_broadcast( + "token_transfer", + :token_transfers, + &{&1["transaction_hash"], &1["block_hash"], &1["log_index"]} + ) + end + end + # TODO: delete this function when old UI becomes deprecated + defp broadcast_token_transfers_websocket_v1(tokens_transfers) do tokens_transfers - |> Enum.zip(prepared_token_transfers) - |> group_by_address_hashes_and_broadcast( - "token_transfer", - :token_transfers, - &{&1["transaction_hash"], &1["block_hash"], &1["log_index"]} - ) + |> Enum.filter(&token_transfer_has_old_subscribers?/1) + |> Enum.each(&broadcast_token_transfer/1) end defp broadcast_token_transfer(token_transfer) do @@ -948,10 +957,16 @@ defmodule BlockScoutWeb.Notifier do defp address_has_subscribers?(nil), do: false defp address_has_subscribers?(address_hash) do - has_subscribers?("addresses:" <> to_string(address_hash)) or + address_has_v2_subscribers?(address_hash) or address_has_old_subscribers?(address_hash) end + defp address_has_v2_subscribers?(nil), do: false + + defp address_has_v2_subscribers?(address_hash) do + has_subscribers?("addresses:" <> to_string(address_hash)) + end + # TODO: delete this function when old UI becomes deprecated defp address_has_old_subscribers?(nil), do: false @@ -960,18 +975,34 @@ defmodule BlockScoutWeb.Notifier do end defp token_transfer_has_subscribers?(tt) do - address_has_subscribers?(tt.from_address_hash) or - address_has_subscribers?(tt.to_address_hash) or - token_has_subscribers?(tt.token_contract_address_hash) + token_transfer_has_v2_subscribers?(tt) or + token_transfer_has_old_subscribers?(tt) end - defp token_has_subscribers?(nil), do: false + defp token_transfer_has_v2_subscribers?(tt) do + address_has_v2_subscribers?(tt.from_address_hash) or + address_has_v2_subscribers?(tt.to_address_hash) or + token_has_v2_subscribers?(tt.token_contract_address_hash) + end - defp token_has_subscribers?(token_address_hash) do - hash_string = to_string(token_address_hash) + # TODO: delete this function when old UI becomes deprecated + defp token_transfer_has_old_subscribers?(tt) do + address_has_old_subscribers?(tt.from_address_hash) or + address_has_old_subscribers?(tt.to_address_hash) or + token_has_old_subscribers?(tt.token_contract_address_hash) + end + + defp token_has_v2_subscribers?(nil), do: false + + defp token_has_v2_subscribers?(token_address_hash) do + has_subscribers?("tokens:" <> to_string(token_address_hash)) + end + + # TODO: delete this function when old UI becomes deprecated + defp token_has_old_subscribers?(nil), do: false - has_subscribers?("tokens:" <> hash_string) or - has_subscribers?("tokens_old:" <> hash_string) + defp token_has_old_subscribers?(token_address_hash) do + has_subscribers?("tokens_old:" <> to_string(token_address_hash)) end defp transaction_has_subscribers?(transaction) do From de8ed34c5e5c1fb4b9b4fef99fe50bd96dc7dd47 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 7 Sep 2026 17:30:46 +0300 Subject: [PATCH 17/82] perf: Preload only rendered transaction fields in v1 tokentx endpoints (#14782) --- apps/explorer/lib/explorer/etherscan.ex | 28 ++++++++++++++--- .../explorer/test/explorer/etherscan_test.exs | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index ea38e4cec84..6704e551270 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -776,13 +776,33 @@ defmodule Explorer.Etherscan do |> maybe_preload_entities() end + # Transaction fields needed to render the `tokentx`-family RPC responses (see + # `BlockScoutWeb.API.RPC.AddressView.prepare_common_token_transfer/3`) and to decode the method call. + # Token transfers are fetched in pages of up to `@default_options.page_size` items, so preloading full + # transaction rows for each of them was one of the top consumers of DB time on the API read replica. + # `hash` must stay in the list: Ecto uses it to match preloaded transactions to token transfers. + @token_transfer_transaction_fields [ + :hash, + :block_timestamp, + :nonce, + :index, + :gas, + :gas_price, + :gas_used, + :cumulative_gas_used, + :input, + :to_address_hash, + :created_contract_address_hash + ] + defp maybe_preload_entities(query) do + transaction_query = + from(transaction in Transaction, select: struct(transaction, ^@token_transfer_transaction_fields)) + if DenormalizationHelper.tt_denormalization_finished?() do - query - |> preload([:transaction, :token]) + preload(query, [:token, transaction: ^transaction_query]) else - query - |> preload([:block, :token, :transaction]) + preload(query, [:block, :token, transaction: ^transaction_query]) end end diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs index 0c9763bf259..04e7ab4a6e0 100644 --- a/apps/explorer/test/explorer/etherscan_test.exs +++ b/apps/explorer/test/explorer/etherscan_test.exs @@ -1277,6 +1277,37 @@ defmodule Explorer.EtherscanTest do assert token_transfer.to_address_hash == found_token_transfer.to_address_hash end + test "preloads only the transaction fields used by the RPC view" do + transaction = + :transaction + |> insert() + |> with_block() + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + [found_token_transfer] = Etherscan.list_token_transfers(:erc20, token_transfer.from_address_hash, nil, %{}) + + assert %Transaction{} = found_transaction = found_token_transfer.transaction + assert found_transaction.hash == transaction.hash + assert found_transaction.input == transaction.input + assert found_transaction.nonce == transaction.nonce + assert found_transaction.index == transaction.index + assert found_transaction.gas == transaction.gas + assert found_transaction.gas_price == transaction.gas_price + assert found_transaction.gas_used == transaction.gas_used + assert found_transaction.cumulative_gas_used == transaction.cumulative_gas_used + + # signature fields are not rendered by the RPC view, so they must not be fetched + assert is_nil(found_transaction.r) + assert is_nil(found_transaction.s) + assert is_nil(found_transaction.v) + end + test "with address with 0 token transfers" do address = insert(:address) From 8eca95261d35272e5613f5bb075eae246d86a7f8 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:26:23 +0400 Subject: [PATCH 18/82] perf: Scam addresses ETS cache (#14781) Co-authored-by: Victor Baranov --- apps/explorer/lib/explorer/application.ex | 1 + .../lib/explorer/chain/address/reputation.ex | 19 +- .../chain/address/scam_badge_to_address.ex | 13 +- .../explorer/chain/cache/scam_addresses.ex | 196 ++++++++++++++++++ .../chain/cache/scam_addresses_test.exs | 125 +++++++++++ config/runtime.exs | 5 + docker-compose/envs/common-blockscout.env | 18 +- 7 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/scam_addresses.ex create mode 100644 apps/explorer/test/explorer/chain/cache/scam_addresses_test.exs diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 528b161cc3f..aa74c875cf5 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -448,6 +448,7 @@ defmodule Explorer.Application do |> configure_mode_dependent_process(:indexer) |> configure_chain_type_dependent_process(:zksync), configure_mode_dependent_process(Explorer.Chain.Fetcher.AddressesBlacklist, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.ScamAddresses, :api), only_in_mode(Explorer.Migrator.SwitchPendingOperations, :indexer), configure_mode_dependent_process(Explorer.Utility.RateLimiter, :api), Hammer.child_for_supervisor() |> configure_mode_dependent_process(:api), diff --git a/apps/explorer/lib/explorer/chain/address/reputation.ex b/apps/explorer/lib/explorer/chain/address/reputation.ex index 1e74e5b1495..75717cf375b 100644 --- a/apps/explorer/lib/explorer/chain/address/reputation.ex +++ b/apps/explorer/lib/explorer/chain/address/reputation.ex @@ -5,9 +5,8 @@ defmodule Explorer.Chain.Address.Reputation do """ use Explorer.Schema - alias Explorer.Chain.Address.ScamBadgeToAddress + alias Explorer.Chain.Cache.ScamAddresses alias Explorer.Chain.Hash - alias Explorer.Repo @enum_values [:ok, :scam] def enum_values, do: @enum_values @@ -19,20 +18,18 @@ defmodule Explorer.Chain.Address.Reputation do end def preload_reputation(address_hashes) do - scam_badges = + scam_hashes = if Application.get_env(:block_scout_web, :hide_scam_addresses) do - ScamBadgeToAddress - |> where([sb], sb.address_hash in ^address_hashes) - |> Repo.replica().all() - |> Map.new(&{&1.address_hash, &1}) + ScamAddresses.scam_hashes(address_hashes) else - %{} + MapSet.new() end Enum.map(address_hashes, fn address_hash -> - case Map.get(scam_badges, address_hash) do - nil -> {address_hash, %__MODULE__{reputation: "ok"}} - _badge -> {address_hash, %__MODULE__{reputation: "scam"}} + if MapSet.member?(scam_hashes, address_hash) do + {address_hash, %__MODULE__{reputation: "scam"}} + else + {address_hash, %__MODULE__{reputation: "ok"}} end end) end diff --git a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex index 41d86b36e0f..5a9187d01d9 100644 --- a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex +++ b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex @@ -10,6 +10,7 @@ defmodule Explorer.Chain.Address.ScamBadgeToAddress do alias Explorer.{Chain, Repo} alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.Cache.ScamAddresses import Ecto.Query, only: [from: 2] @@ -51,7 +52,11 @@ defmodule Explorer.Chain.Address.ScamBadgeToAddress do end) |> Enum.filter(&(!is_nil(&1))) - safe_add(insert_params) + result = safe_add(insert_params) + + ScamAddresses.reload() + + result end defp safe_add(insert_params) do @@ -88,7 +93,11 @@ defmodule Explorer.Chain.Address.ScamBadgeToAddress do select: bta ) - Repo.delete_all(query) + result = Repo.delete_all(query) + + ScamAddresses.reload() + + result end @doc """ diff --git a/apps/explorer/lib/explorer/chain/cache/scam_addresses.ex b/apps/explorer/lib/explorer/chain/cache/scam_addresses.ex new file mode 100644 index 00000000000..02a194a5424 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/scam_addresses.ex @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.ScamAddresses do + @moduledoc """ + Keeps the set of addresses carrying a scam badge in an ETS table. + + `Explorer.Chain.Address.Reputation` needs nothing but a membership test, and + `scam_address_badge_mappings` changes only through the admin badge endpoints, + so the whole set fits in memory and the per-preload replica query becomes an + ETS lookup. + + The table is reloaded on an interval and whenever a badge is assigned or + revoked. Both happen in this process, one at a time, so a reload can never + install a set that predates the change that triggered it. + + That holds within an instance. A badge change reloads the cache of the instance + that served the admin request and of no other, so `:update_interval` is also the + window in which instances can disagree about a badge — lower it where that + matters. Nothing propagates the change sooner: libcluster runs only in the + separate `:api` and `:indexer` modes, and the chain event sender is in-process + in `:all` mode, so neither transport reaches every deployment topology. + + The cache is authoritative only once it has been loaded. Until the first + reload succeeds — and whenever the process is not running at all — reads fall + through to the database, so a disabled or not yet warmed up cache behaves + exactly as if it did not exist. That fallback is also what bounds the cache: + holding the whole table in memory pays off only while the table is small, and + past `:max_size` rows the reload — a full scan on every instance, every + interval — costs more than the queries it saves, so an oversized table is not + cached at all. + """ + + use GenServer + + require Logger + + import Ecto.Query, only: [select: 3, where: 3] + + alias Explorer.Chain.Address.ScamBadgeToAddress + alias Explorer.Chain.Cache.Counters.Helper, as: CountersHelper + alias Explorer.Chain.Hash + alias Explorer.Repo + + @table_name "scam_address_badge_mappings" + + @cache_name :scam_addresses + + # Tells a loaded table apart from an empty one. Cached keys are `Hash.Address` + # structs, so an atom can never collide with one. + @loaded_key :__loaded__ + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + @spec init(any()) :: {:ok, nil} + def init(_) do + :ets.new(@cache_name, [ + :set, + :named_table, + :public, + read_concurrency: true + ]) + + send(self(), :reload) + + {:ok, nil} + end + + @impl true + def handle_info(:reload, state) do + load() + Process.send_after(self(), :reload, update_interval()) + + {:noreply, state} + end + + @impl true + def handle_call(:reload, _from, state) do + load() + + {:reply, :ok, state} + end + + @doc """ + Returns the subset of `address_hashes` that carry a scam badge. + """ + @spec scam_hashes([Hash.Address.t()]) :: MapSet.t(Hash.Address.t()) + def scam_hashes([]), do: MapSet.new() + + def scam_hashes(address_hashes) do + if loaded?() do + for address_hash <- address_hashes, + :ets.member(@cache_name, address_hash), + into: MapSet.new(), + do: address_hash + else + db_scam_hashes(address_hashes) + end + end + + @doc """ + Checks whether the given address carries a scam badge. + """ + @spec scam?(Hash.Address.t()) :: boolean() + def scam?(address_hash) do + [address_hash] |> scam_hashes() |> MapSet.member?(address_hash) + end + + @doc """ + Reloads the cache, so that a badge just assigned or revoked shows up without + waiting for the next interval. + """ + @spec reload() :: :ok + def reload do + GenServer.call(__MODULE__, :reload) + catch + # The cache is not running — disabled, or on its way down — and the reads it + # would have served already fall through to the database. + :exit, _ -> :ok + end + + defp load do + entries = if oversized?(), do: :oversized, else: fetch_entries() + + :ets.delete_all_objects(@cache_name) + + # An oversized table is left without the marker, which is what sends reads to + # the database rather than letting an empty table report every address clean. + case entries do + :oversized -> :ok + entries -> :ets.insert(@cache_name, [{@loaded_key} | entries]) + end + + :ok + rescue + error -> + # Reads fall back to the database until the next interval. Letting this + # crash would take the table down with the process and restart it in a loop + # for as long as the database is unreachable. + Logger.error("Failed to load the scam addresses cache: #{inspect(error)}") + + :ok + end + + defp fetch_entries do + ScamBadgeToAddress + |> select([badge], {badge.address_hash}) + |> Repo.replica().all() + end + + # A `reltuples` estimate rather than a `count/0`: this guard exists to keep the + # reload cheap, so it cannot start with a full scan of its own. A table + # PostgreSQL has no statistics for reports `nil` and is let through — a table + # that new is not the one the guard is here for. + defp oversized? do + max_size = max_size() + estimated_size = CountersHelper.estimated_count_from(@table_name, api?: true) + + if estimated_size && estimated_size > max_size do + Logger.warning( + "Scam addresses cache is not used: #{@table_name} is estimated at #{estimated_size} rows, over the limit of #{max_size}. " <> + "Scam badge lookups fall back to the database. Raise CACHE_SCAM_ADDRESSES_MAX_SIZE to cache the table anyway." + ) + + true + else + false + end + end + + defp loaded? do + :ets.whereis(@cache_name) != :undefined and :ets.member(@cache_name, @loaded_key) + end + + defp db_scam_hashes(address_hashes) do + ScamBadgeToAddress + |> where([badge], badge.address_hash in ^address_hashes) + |> select([badge], badge.address_hash) + |> Repo.replica().all() + |> MapSet.new() + end + + defp config do + Application.get_env(:explorer, __MODULE__) + end + + defp update_interval do + config()[:update_interval] + end + + defp max_size do + config()[:max_size] + end +end diff --git a/apps/explorer/test/explorer/chain/cache/scam_addresses_test.exs b/apps/explorer/test/explorer/chain/cache/scam_addresses_test.exs new file mode 100644 index 00000000000..8269b160579 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/scam_addresses_test.exs @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.ScamAddressesTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Address.{Reputation, ScamBadgeToAddress} + alias Explorer.Chain.Cache.ScamAddresses + + describe "scam_hashes/1" do + test "falls back to the database while the cache is not running" do + %{address_hash: badged_hash} = insert(:scam_badge_to_address) + clean_hash = insert(:address).hash + + assert ScamAddresses.scam_hashes([badged_hash, clean_hash]) == MapSet.new([badged_hash]) + end + + test "answers from the cache once it is loaded" do + %{address_hash: badged_hash} = insert(:scam_badge_to_address) + clean_hash = insert(:address).hash + + start_cache() + + # only a cached answer can still see a badge that is no longer in the database + Repo.delete_all(ScamBadgeToAddress) + + assert ScamAddresses.scam_hashes([badged_hash, clean_hash]) == MapSet.new([badged_hash]) + end + + test "returns an empty set for no address hashes" do + assert ScamAddresses.scam_hashes([]) == MapSet.new() + end + + test "goes back to the database when the table outgrows the size limit" do + %{address_hash: badged_hash} = insert(:scam_badge_to_address) + + start_cache() + assert ScamAddresses.scam?(badged_hash) + + analyze_badges() + put_max_size(0) + ScamAddresses.reload() + + # nothing is cached any more, so the row going away is visible at once + Repo.delete_all(ScamBadgeToAddress) + + refute ScamAddresses.scam?(badged_hash) + end + end + + describe "badge changes" do + test "assigning a badge fills the cache" do + address_hash = insert(:address).hash + + start_cache() + refute ScamAddresses.scam?(address_hash) + + ScamBadgeToAddress.add([to_string(address_hash)]) + + assert ScamAddresses.scam?(address_hash) + end + + test "revoking a badge drops it from the cache" do + %{address_hash: address_hash} = insert(:scam_badge_to_address) + + start_cache() + assert ScamAddresses.scam?(address_hash) + + ScamBadgeToAddress.delete([address_hash]) + + refute ScamAddresses.scam?(address_hash) + end + end + + describe "preload_reputation/1" do + setup do + put_test_env(:block_scout_web, :hide_scam_addresses, true) + end + + test "marks cached addresses as scam" do + %{address_hash: badged_hash} = insert(:scam_badge_to_address) + clean_hash = insert(:address).hash + + start_cache() + Repo.delete_all(ScamBadgeToAddress) + + assert [{^badged_hash, %Reputation{reputation: "scam"}}, {^clean_hash, %Reputation{reputation: "ok"}}] = + Reputation.preload_reputation([badged_hash, clean_hash]) + end + end + + # The cache loads itself on start, and `reload/0` is synchronous, so waiting on + # it is enough to know the table is filled. + defp start_cache do + start_supervised!(ScamAddresses) + + ScamAddresses.reload() + end + + defp put_max_size(max_size) do + config = Application.get_env(:explorer, ScamAddresses, []) + + put_test_env(:explorer, ScamAddresses, Keyword.merge(config, max_size: max_size)) + end + + # A setting that was not configured has to be restored as not configured — + # putting back the `nil` that `get_env/2` reports would shadow the defaults + # every later `get_env/3` passes. + defp put_test_env(app, key, value) do + initial_config = Application.fetch_env(app, key) + + Application.put_env(app, key, value) + + on_exit(fn -> + case initial_config do + {:ok, initial_value} -> Application.put_env(app, key, initial_value) + :error -> Application.delete_env(app, key) + end + end) + end + + # The size guard reads `pg_class` statistics, which a table this fresh has none + # of — without an `ANALYZE` the estimate is `nil` and the guard lets it through. + defp analyze_badges do + Repo.query!("ANALYZE #{ScamBadgeToAddress.__schema__(:source)}") + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 2cde9a38271..4c6e9ccb675 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1002,6 +1002,11 @@ config :explorer, Explorer.Chain.Fetcher.AddressesBlacklist, retry_interval: ConfigHelper.parse_time_env_var("ADDRESSES_BLACKLIST_RETRY_INTERVAL", "5s"), provider: ConfigHelper.parse_catalog_value("ADDRESSES_BLACKLIST_PROVIDER", ["blockaid"], false, "blockaid") +config :explorer, Explorer.Chain.Cache.ScamAddresses, + enabled: ConfigHelper.parse_bool_env_var("HIDE_SCAM_ADDRESSES"), + update_interval: ConfigHelper.parse_time_env_var("CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL", "5m"), + max_size: ConfigHelper.parse_integer_env_var("CACHE_SCAM_ADDRESSES_MAX_SIZE", 200_000, min: 0) + rate_limiter_redis_url = ConfigHelper.parse_url_env_var("RATE_LIMITER_REDIS_URL") rate_limiter_redis_sentinel_urls = ConfigHelper.safe_get_env("RATE_LIMITER_REDIS_SENTINEL_URLS", "") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 043126b6269..8f32a6d2852 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -140,20 +140,14 @@ RELEASE_LINK= # CACHE_TOKEN_COUNTERS_CONSOLIDATION_BATCH_SIZE=100 # CACHE_TOKEN_COUNTERS_CONSOLIDATION_CONCURRENCY=2 # CACHE_TOKEN_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT=5m -# MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED=false -# MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE=10 -# MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY=2 -# MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT=500ms -# MIGRATION_BACKFILL_TOKEN_COUNTERS_DISABLED=false -# MIGRATION_BACKFILL_TOKEN_COUNTERS_BATCH_SIZE=50 -# MIGRATION_BACKFILL_TOKEN_COUNTERS_CONCURRENCY=4 -# MIGRATION_BACKFILL_TOKEN_COUNTERS_TIMEOUT=100ms # CACHE_HOT_SMART_CONTRACTS_5M_PERIOD=30s # CACHE_HOT_SMART_CONTRACTS_1H_PERIOD=6m # CACHE_HOT_SMART_CONTRACTS_3H_PERIOD=18m # CACHE_TRANSACTIONS_24H_STATS_PERIOD= # CACHE_FRESH_PENDING_TRANSACTIONS_COUNTER_PERIOD= # CACHE_PENDING_OPERATIONS_COUNT_PERIOD= +# CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL= +# CACHE_SCAM_ADDRESSES_MAX_SIZE= # PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE=1000 # PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE=10 # TOKEN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD= @@ -636,6 +630,14 @@ ACCOUNT_REDIS_URL=redis://redis-db:6379 # MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_BATCH_SIZE= # MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_CONCURRENCY= # MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_TIMEOUT= +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED=false +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE=10 +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY=2 +# MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT=500ms +# MIGRATION_BACKFILL_TOKEN_COUNTERS_DISABLED=false +# MIGRATION_BACKFILL_TOKEN_COUNTERS_BATCH_SIZE=50 +# MIGRATION_BACKFILL_TOKEN_COUNTERS_CONCURRENCY=4 +# MIGRATION_BACKFILL_TOKEN_COUNTERS_TIMEOUT=100ms # HEALTH_MONITOR_CHECK_INTERVAL=1m # HEALTH_MONITOR_BLOCKS_PERIOD=5m # HEALTH_MONITOR_BATCHES_PERIOD=4h From 9aeca252702613e304b94d6467c7d5849ff4ba6d Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 8 Sep 2026 12:35:04 +0300 Subject: [PATCH 19/82] perf: Limit logs before joining transactions in topic-only getLogs (#14780) --- apps/explorer/lib/explorer/etherscan/logs.ex | 212 +++++++-------- .../test/explorer/etherscan/logs_test.exs | 257 +++++++++++++++++- 2 files changed, 348 insertions(+), 121 deletions(-) diff --git a/apps/explorer/lib/explorer/etherscan/logs.ex b/apps/explorer/lib/explorer/etherscan/logs.ex index c6c3e575aaf..1393807b080 100644 --- a/apps/explorer/lib/explorer/etherscan/logs.ex +++ b/apps/explorer/lib/explorer/etherscan/logs.ex @@ -6,7 +6,8 @@ defmodule Explorer.Etherscan.Logs do """ - import Ecto.Query, only: [dynamic: 2, from: 2, where: 2, where: 3, subquery: 1, order_by: 3, union_all: 2] + import Ecto.Query, + only: [dynamic: 2, from: 2, join: 5, limit: 2, where: 2, where: 3, subquery: 1, order_by: 3, union_all: 2] alias Explorer.{Chain, Repo} alias Explorer.Chain.{DenormalizationHelper, Log, Transaction} @@ -87,62 +88,24 @@ defmodule Explorer.Etherscan.Logs do |> page_logs(paging_options) |> where_topic_match(prepared_filter, union_multiple_values: true) - if DenormalizationHelper.transactions_denormalization_finished?() do - all_transaction_logs_query = - from(log in subquery(logs_query), - join: transaction in Transaction, - on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, - where: transaction.block_consensus == true, - select: map(log, ^@log_fields), - select_merge: %{ - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_number: transaction.block_number, - block_timestamp: transaction.block_timestamp, - block_consensus: transaction.block_consensus - }, - order_by: [asc: log.block_number, asc: log.index], - limit: 1000 - ) - - all_transaction_logs_query - |> Chain.wrapped_union_subquery() - |> order_by([log], asc: log.block_number, asc: log.index) - |> Repo.replica().all() - else - all_transaction_logs_query = - from(log in subquery(logs_query), - join: transaction in Transaction, - on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, - inner_join: block in assoc(transaction, :block), - where: block.consensus == true, - select: map(log, ^@log_fields), - select_merge: %{ - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_number: transaction.block_number, - block_timestamp: block.timestamp, - block_consensus: block.consensus - }, - order_by: [asc: log.block_number, asc: log.index], - limit: 1000 - ) - - all_transaction_logs_query - |> Chain.wrapped_union_subquery() - |> order_by([log], asc: log.block_number, asc: log.index) - |> Repo.replica().all() - end + logs_query + |> join_transaction_data() + |> limit(1000) + |> fetch_ordered() end - # Since address_hash was not present, we know that a - # topic filter has been applied, so we use a different - # query that is optimized for a logs filter over an - # address_hash + # Since address_hash was not present, we know that a topic filter has been + # applied. Ordering, paging and the LIMIT are applied inside a subquery over + # `logs` alone, so the planner has to produce at most 1000 logs before + # joining `transactions`. Joining first and limiting afterwards lets the + # planner scan the whole `transactions` block range up front, which is + # prohibitively slow for wide ranges. + # + # Logs of non-consensus blocks are never deleted, so consensus has to be + # checked before the LIMIT: otherwise a page could come back short, or empty + # with no cursor to continue from, while later consensus logs still exist. + # The check uses the same predicate as `join_transaction_data/1`, so no row + # that makes it into the page is dropped by the outer join afterwards. def list_logs(filter, paging_options) do paging_options = if is_nil(paging_options), do: @default_paging_options, else: paging_options prepared_filter = Map.merge(@base_filter, filter) @@ -152,74 +115,83 @@ defmodule Explorer.Etherscan.Logs do |> where_topic_match(prepared_filter) |> where([log], log.block_number >= ^prepared_filter.from_block) |> where([log], log.block_number <= ^prepared_filter.to_block) + |> where_consensus() + |> page_logs(paging_options) + |> order_by([log], asc: log.block_number, asc: log.index) + |> limit(1000) + logs_query + |> join_transaction_data() + |> fetch_ordered() + end + + # Keeps only logs whose consensus predicate matches the one applied by + # `join_transaction_data/1` in the current denormalization state. Each check + # is a primary-key lookup per candidate log. `transactions.block_consensus` + # can diverge from `blocks.consensus` (see + # `Explorer.Migrator.TransactionBlockConsensus`), which is why the predicate + # is not simply `blocks.consensus` in both states. + defp where_consensus(logs_query) do if DenormalizationHelper.transactions_denormalization_finished?() do - block_transaction_query = - from(transaction in Transaction, - where: transaction.block_number >= ^prepared_filter.from_block, - where: transaction.block_number <= ^prepared_filter.to_block, - where: transaction.block_consensus == true, - select: %{ - transaction_hash: transaction.hash, - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_number: transaction.block_number, - block_timestamp: transaction.block_timestamp, - block_consensus: transaction.block_consensus - } - ) - - query_with_block_transaction_data = - from(log in logs_query, - join: block_transaction_data in subquery(block_transaction_query), - on: - block_transaction_data.transaction_hash == log.transaction_hash and - block_transaction_data.block_hash == log.block_hash, - order_by: log.block_number, - limit: 1000, - select: block_transaction_data, - select_merge: map(log, ^@log_fields) - ) - - query_with_block_transaction_data - |> order_by([log], asc: log.index) - |> page_logs(paging_options) - |> Repo.replica().all() + join(logs_query, :inner, [log], transaction in Transaction, + on: + log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash and + transaction.block_consensus == true + ) else - block_transaction_query = - from(transaction in Transaction, - join: block in assoc(transaction, :block), - where: block.number >= ^prepared_filter.from_block, - where: block.number <= ^prepared_filter.to_block, - where: block.consensus == true, - select: %{ - transaction_hash: transaction.hash, - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: block.hash, - block_number: block.number, - block_timestamp: block.timestamp, - block_consensus: block.consensus - } - ) - - query_with_block_transaction_data = - from(log in logs_query, - join: block_transaction_data in subquery(block_transaction_query), - on: block_transaction_data.transaction_hash == log.transaction_hash, - order_by: log.block_number, - limit: 1000, - select: block_transaction_data, - select_merge: map(log, ^@log_fields) - ) - - query_with_block_transaction_data - |> order_by([log], asc: log.index) - |> page_logs(paging_options) - |> Repo.replica().all() + join(logs_query, :inner, [log], block in assoc(log, :block), on: block.consensus == true) + end + end + + # Re-selects the joined query through an outer subquery so that fields + # taken from the `logs` subquery are loaded with their schema types (`Hash` + # structs instead of raw binaries), then applies the final ordering. + defp fetch_ordered(query) do + query + |> Chain.wrapped_union_subquery() + |> order_by([log], asc: log.block_number, asc: log.index) + |> Repo.replica().all() + end + + # Wraps `logs_query` in a subquery and joins each log to its consensus + # transaction by `(hash, block_hash)`, a primary-key lookup per log. The + # selected shape is identical in both denormalization states. + defp join_transaction_data(logs_query) do + if DenormalizationHelper.transactions_denormalization_finished?() do + from(log in subquery(logs_query), + join: transaction in Transaction, + on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, + where: transaction.block_consensus == true, + select: map(log, ^@log_fields), + select_merge: %{ + gas_price: transaction.gas_price, + gas_used: transaction.gas_used, + transaction_index: transaction.index, + block_hash: transaction.block_hash, + block_number: transaction.block_number, + block_timestamp: transaction.block_timestamp, + block_consensus: transaction.block_consensus + }, + order_by: [asc: log.block_number, asc: log.index] + ) + else + from(log in subquery(logs_query), + join: transaction in Transaction, + on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, + inner_join: block in assoc(transaction, :block), + where: block.consensus == true, + select: map(log, ^@log_fields), + select_merge: %{ + gas_price: transaction.gas_price, + gas_used: transaction.gas_used, + transaction_index: transaction.index, + block_hash: transaction.block_hash, + block_number: transaction.block_number, + block_timestamp: block.timestamp, + block_consensus: block.consensus + }, + order_by: [asc: log.block_number, asc: log.index] + ) end end diff --git a/apps/explorer/test/explorer/etherscan/logs_test.exs b/apps/explorer/test/explorer/etherscan/logs_test.exs index dc23b3c634e..07822172423 100644 --- a/apps/explorer/test/explorer/etherscan/logs_test.exs +++ b/apps/explorer/test/explorer/etherscan/logs_test.exs @@ -2,10 +2,13 @@ defmodule Explorer.Etherscan.LogsTest do use Explorer.DataCase + import Ecto.Query, only: [from: 2] import Explorer.Factory alias Explorer.Etherscan.Logs - alias Explorer.Chain.Transaction + alias Explorer.Chain.{Block, Transaction} + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Repo @first_topic_hex_string_1 "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65" @first_topic_hex_string_2 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" @@ -860,5 +863,257 @@ defmodule Explorer.Etherscan.LogsTest do assert block_number_order == Enum.sort(block_number_order) end + + test "paginates topic-only logs" do + contract_address = insert(:contract_address) + first_topic = topic(@first_topic_hex_string_1) + + transaction_a = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + transaction_b = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + transaction_c = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + inserted_records = + for {transaction, count} <- [{transaction_a, 700}, {transaction_b, 700}, {transaction_c, 600}], + i <- 1..count do + insert(:log, + address: contract_address, + transaction: transaction, + block_number: transaction.block.number, + block: transaction.block, + index: i, + first_topic: first_topic + ) + end + + # A log with a different topic in the same range must not be returned. + insert(:log, + address: contract_address, + transaction: transaction_a, + block_number: transaction_a.block.number, + block: transaction_a.block, + index: 701, + first_topic: topic(@first_topic_hex_string_2) + ) + + filter = %{ + from_block: transaction_a.block.number, + to_block: transaction_c.block.number, + first_topic: first_topic + } + + first_found_logs = Logs.list_logs(filter) + + assert Enum.count(first_found_logs) == 1_000 + + last_record = List.last(first_found_logs) + + next_page_params = %{ + log_index: last_record.index, + block_number: last_record.block_number + } + + second_found_logs = Logs.list_logs(filter, next_page_params) + + assert Enum.count(second_found_logs) == 1_000 + + all_found_logs = first_found_logs ++ second_found_logs + + assert Enum.all?(all_found_logs, &(&1.first_topic == first_topic)) + + found_keys = MapSet.new(all_found_logs, &{&1.block_number, &1.index}) + inserted_keys = MapSet.new(inserted_records, &{&1.block_number, &1.index}) + + assert MapSet.equal?(found_keys, inserted_keys) + end + + test "topic-only logs are sorted by block and index" do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + contract_address = insert(:contract_address) + first_topic = topic(@first_topic_hex_string_1) + + transaction_block1 = + %Transaction{} = + :transaction + |> insert(to_address: contract_address) + |> with_block(first_block) + + transaction_block2 = + %Transaction{} = + :transaction + |> insert(to_address: contract_address) + |> with_block(second_block) + + transaction_block3 = + %Transaction{} = + :transaction + |> insert(to_address: contract_address) + |> with_block(third_block) + + for {transaction, block, index} <- [ + {transaction_block3, third_block, 1}, + {transaction_block1, first_block, 2}, + {transaction_block2, second_block, 1}, + {transaction_block1, first_block, 1} + ] do + insert(:log, + address: contract_address, + transaction: transaction, + block: block, + block_number: block.number, + index: index, + first_topic: first_topic + ) + end + + filter = %{ + from_block: first_block.number, + to_block: third_block.number, + first_topic: first_topic + } + + found_logs = Logs.list_logs(filter) + + assert length(found_logs) == 4 + + order = Enum.map(found_logs, &{&1.block_number, &1.index}) + + assert order == Enum.sort(order) + end + + test "topic-only logs skip a full page of non-consensus logs and still return later consensus logs" do + contract_address = insert(:contract_address) + first_topic = topic(@first_topic_hex_string_1) + + non_consensus_block = insert(:block) + consensus_block = insert(:block, number: non_consensus_block.number + 1) + + non_consensus_transaction = + :transaction + |> insert(to_address: contract_address) + |> with_block(non_consensus_block) + + consensus_transaction = + :transaction + |> insert(to_address: contract_address) + |> with_block(consensus_block) + + for i <- 1..1_000 do + insert(:log, + address: contract_address, + transaction: non_consensus_transaction, + block: non_consensus_block, + block_number: non_consensus_block.number, + index: i, + first_topic: first_topic + ) + end + + consensus_log = + insert(:log, + address: contract_address, + transaction: consensus_transaction, + block: consensus_block, + block_number: consensus_block.number, + index: 1, + first_topic: first_topic + ) + + # The factory only attaches transactions to consensus blocks, so the + # reorg is simulated after the fact. + Repo.update_all(from(b in Block, where: b.hash == ^non_consensus_block.hash), set: [consensus: false]) + + Repo.update_all(from(t in Transaction, where: t.hash == ^non_consensus_transaction.hash), + set: [block_consensus: false] + ) + + filter = %{ + from_block: non_consensus_block.number, + to_block: consensus_block.number, + first_topic: first_topic + } + + [found_log] = Logs.list_logs(filter) + + assert found_log.block_number == consensus_log.block_number + assert found_log.index == consensus_log.index + assert found_log.block_consensus == true + end + + test "topic-only logs skip a full page of logs whose transactions lost consensus while their block did not" do + old_denormalization_finished = BackgroundMigrations.get_transactions_denormalization_finished() + BackgroundMigrations.set_transactions_denormalization_finished(true) + + on_exit(fn -> + BackgroundMigrations.set_transactions_denormalization_finished(old_denormalization_finished) + end) + + contract_address = insert(:contract_address) + first_topic = topic(@first_topic_hex_string_1) + + stale_block = insert(:block) + consensus_block = insert(:block, number: stale_block.number + 1) + + stale_transaction = + :transaction + |> insert(to_address: contract_address) + |> with_block(stale_block) + + consensus_transaction = + :transaction + |> insert(to_address: contract_address) + |> with_block(consensus_block) + + for i <- 1..1_000 do + insert(:log, + address: contract_address, + transaction: stale_transaction, + block: stale_block, + block_number: stale_block.number, + index: i, + first_topic: first_topic + ) + end + + consensus_log = + insert(:log, + address: contract_address, + transaction: consensus_transaction, + block: consensus_block, + block_number: consensus_block.number, + index: 1, + first_topic: first_topic + ) + + # `transactions.block_consensus` and `blocks.consensus` can diverge; the + # block keeps consensus here while the denormalized flag says otherwise. + Repo.update_all(from(t in Transaction, where: t.hash == ^stale_transaction.hash), + set: [block_consensus: false] + ) + + filter = %{ + from_block: stale_block.number, + to_block: consensus_block.number, + first_topic: first_topic + } + + [found_log] = Logs.list_logs(filter) + + assert found_log.block_number == consensus_log.block_number + assert found_log.index == consensus_log.index + end end end From ad84255422a11f684380198a6716817a1dd599a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:47:27 +0300 Subject: [PATCH 20/82] chore(deps): bump open_api_spex from 3.22.3 to 3.22.4 (#14790) 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 4ade0fedc17..1576f540db6 100644 --- a/mix.lock +++ b/mix.lock @@ -127,7 +127,7 @@ "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.24.0", "cd877a089ead66658c40864fc616e90c75ddba95559fffae3fccaf05da58a4e5", [: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", "ab497b2be51191a2d9d7a1b267714463891be711b20487b7b65b5c2269e4b312"}, - "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"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.4", "00d8f32676b459080a860b81d777a8010e61dfa333f54175dcf51dd6f35a0fac", [: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", "f658d73396e2277e584085a6146481eb085d815a716089defe4f94b26cf8b05f"}, "optimal": {:hex, :optimal, "0.3.6", "46bbf52fbbbd238cda81e02560caa84f93a53c75620f1fe19e81e4ae7b07d1dd", [:mix], [], "hexpm", "1a06ea6a653120226b35b283a1cd10039550f2c566edcdec22b29316d73640fd"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, From abd9e7b923bcd337fa06056957ac35269aae05ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:48:02 +0300 Subject: [PATCH 21/82] chore(deps): bump joken from 2.6.2 to 2.7.0 (#14792) 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 1576f540db6..5d7718b7108 100644 --- a/mix.lock +++ b/mix.lock @@ -93,9 +93,9 @@ "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "joken": {:hex, :joken, "2.7.0", "a9fd87805b1b58313435c04b950857d7557f019b157d503dc4783006e4e80ed1", [:mix], [{:jose, "~> 1.11.12", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "ffd0d92e12dbf497311386b75f9b5027d370e32c74f6e5576804d3a9eed668ab"}, "joken_jwks": {:hex, :joken_jwks, "1.7.0", "5db750732dddbfb51068c16da6e43439ad8a3932020bdd505e944dd918ec0771", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.6", [hex: :joken, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "c1dd2d5161f079f660bda773c338d3ff2b90bb79673a0852becc0433416797d3"}, - "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "junit_formatter": {:hex, :junit_formatter, "3.4.0", "d0e8db6c34dab6d3c4154c3b46b21540db1109ae709d6cf99ba7e7a2ce4b1ac2", [:mix], [], "hexpm", "bb36e2ae83f1ced6ab931c4ce51dd3dbef1ef61bb4932412e173b0cfa259dacd"}, From c62038fac076899f725e70e4d5faa078e5b27db7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:48:28 +0300 Subject: [PATCH 22/82] chore(deps): bump redix from 1.8.0 to 1.8.2 (#14786) 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 5d7718b7108..cebf9aaf6c8 100644 --- a/mix.lock +++ b/mix.lock @@ -156,7 +156,7 @@ "que": {:hex, :que, "0.12.0", "4629c156036b8a3bf9ffdd79aa4069ab414822144d3ef031b5cc61fc805942d3", [:mix], [{:ex_utils, "~> 0.1", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.6", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "906651d60b1036840f1afacc0036541583eda9ba4b0b03f322530a1c84100507"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "redix": {:hex, :redix, "1.8.0", "f0a2d1dc047a4a1b3004525d27e5df7a4e5b42768246bf161e44bb5e744f2a34", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24288583e0b08cde42aa781053cd14d992a02976fe06986fb664b8ebfcf0f853"}, + "redix": {:hex, :redix, "1.8.2", "7f0bb263c7c74b62ae5db3922146cbaa5d953993f8d678fce28ab9a24b12cd79", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6912716ae2f9aeb4f867383fc9b8522317528c6d183daefdc12c9cb7696dbd9"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "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"}, From b04e7e7f5be4b41c58ce5a3b011e633cf68f3531 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:49:05 +0300 Subject: [PATCH 23/82] chore(deps): bump mint from 1.9.3 to 1.10.0 (#14789) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 40a79271c0d..e96986bb480 100644 --- a/mix.exs +++ b/mix.exs @@ -107,7 +107,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.21.0"}, - {:mint, "~> 1.9.0"}, + {:mint, "~> 1.10.0"}, # Documentation {:ex_doc, "~> 0.40.1", only: :dev, runtime: false}, {:number, "~> 1.0.3"} diff --git a/mix.lock b/mix.lock index cebf9aaf6c8..da440477f75 100644 --- a/mix.lock +++ b/mix.lock @@ -112,7 +112,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm", "7d8f80c567807ce78cd93c938e7f4b0a20b1aaaaab914bf286f68457d9f7a852"}, - "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, "mnemoniac": {:hex, :mnemoniac, "0.1.5", "d10bf14abb94adc1a98f6cee4d81d197e1b3986cad8ec43b2e21d2cd9f4d99b6", [:mix], [], "hexpm", "518ae4b6ddda5285a5c02bf83e1eb6ce1af7e4d65d3f7f5bff3270689a00bcc3"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, From 607539648fd592bd2beb27f23f3e0161a30123d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:49:29 +0300 Subject: [PATCH 24/82] chore(deps): bump absinthe from 1.11.0 to 1.12.0 (#14784) 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 da440477f75..387c3138a78 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "absinthe": {:hex, :absinthe, "1.11.0", "dee6dfa57f86d52b25d8b0f925585b6b047d4921ac703c1075b6e7b8bc2fc83c", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "39b3b4b6e3eb405fa98b449feef0dacff81d89bf01c2866cfa513616c5530ba6"}, + "absinthe": {:hex, :absinthe, "1.12.0", "fe69ee9940e47a6a80ab506f2c1def96964ab52e7f52fe2cd642d0f574967931", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b0672939fa75c24b6286c784edc01bec1bedfbe58885acdda5d097c7053b03c6"}, "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.5", "1fdcfc59db241b1d5e45e4c8c9db162277e64ec5728e0fd6958a3fa491a05b82", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "086c6d4a1c32f7444713130d204c87b1b006169f5159026b73f02f7d38ccd05c"}, "absinthe_plug": {:git, "https://github.com/blockscout/absinthe_plug.git", "90a8188e94e2650f13259fb16462075a87f98e18", [tag: "1.5.8"]}, "absinthe_relay": {:hex, :absinthe_relay, "1.6.0", "73590bdb0dcc192622f39fa11da6fca9df4e339b603c45ec7a93493eb94f1829", [:mix], [{:absinthe, ">= 1.7.10", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "32d6397a7af3fd02678ef9bc8e2f574487f14593cb3e4f9110fb1c695d4d2ac0"}, From 2cbbe2d251041ac3bca4a9b1f38e7026b685ad5b Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 8 Sep 2026 16:07:35 +0300 Subject: [PATCH 25/82] perf: Make Postgrex prepared statements mode configurable per repo (#14799) --- config/runtime/prod.exs | 16 ++++++++++++++-- docker-compose/envs/common-blockscout.env | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/config/runtime/prod.exs b/config/runtime/prod.exs index 41b59300895..3ce0685efce 100644 --- a/config/runtime/prod.exs +++ b/config/runtime/prod.exs @@ -42,13 +42,24 @@ pool_size = ConfigHelper.parse_integer_env_var("POOL_SIZE", 50) queue_target = ConfigHelper.parse_integer_env_var("DATABASE_QUEUE_TARGET", 50) database_url = ConfigHelper.parse_url_env_var("DATABASE_URL") +# Postgrex prepared statements mode. With `named`, a query is a single round trip to the +# database (the parsed statement is cached per connection); `unnamed` needs two round trips +# per query (parse/describe, then bind/execute) but is required when connections go through a +# transaction-mode pooler (e.g. PgBouncer without `max_prepared_statements`). +prepare_modes = ["named", "unnamed"] +prepare_mode = ConfigHelper.parse_catalog_value("DATABASE_PREPARE_MODE", prepare_modes, true, "unnamed") + +api_prepare_mode = + ConfigHelper.parse_catalog_value("DATABASE_API_PREPARE_MODE", prepare_modes, true, Atom.to_string(prepare_mode)) + # Configures the database config :explorer, Explorer.Repo, [ url: database_url, pool_size: pool_size, - queue_target: queue_target + queue_target: queue_target, + prepare: prepare_mode ] |> Keyword.merge(ExplorerConfigHelper.ssl_options(database_url)) @@ -60,7 +71,8 @@ config :explorer, [ url: api_db_url, pool_size: ConfigHelper.parse_integer_env_var("POOL_SIZE_API", 50), - queue_target: queue_target + queue_target: queue_target, + prepare: api_prepare_mode ] |> Keyword.merge(ExplorerConfigHelper.ssl_options(api_db_url)) diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 8f32a6d2852..68498355654 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -6,6 +6,8 @@ DATABASE_URL=postgresql://blockscout:ceWb1MeLBEeOIfk65gU8EjF8@db:5432/blockscout # DATABASE_EVENT_POOL_SIZE # DATABASE_EVENT_URL= # DATABASE_QUEUE_TARGET +# DATABASE_PREPARE_MODE= +# DATABASE_API_PREPARE_MODE= # TEST_DATABASE_URL= # TEST_DATABASE_READ_ONLY_API_URL= From 1d0325b0776798bff72ffc2817c8d16a61befd24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:32:40 +0300 Subject: [PATCH 26/82] chore(deps-dev): bump ex_doc from 0.40.3 to 0.40.4 (#14787) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- mix.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.lock b/mix.lock index 387c3138a78..eb4a11bd8b0 100644 --- a/mix.lock +++ b/mix.lock @@ -43,7 +43,7 @@ "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, "dns": {:hex, :dns, "2.4.0", "44790a0375b28bdc7b59fc894460bfcb03ffeec4c5984e2c3e8b0797b1518327", [:mix], [], "hexpm", "e178e353c469820d02ba889d6a80d01c8c27b47dfcda4016a9cbc6218e3eed64"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, @@ -60,7 +60,7 @@ "ex_cldr_lists": {:hex, :ex_cldr_lists, "2.12.2", "731532a3c0a9bfd062acbe79fd39affa4d6e3e5aa5b08a7405524166ce1cd47a", [:mix], [{:ex_cldr_numbers, "~> 2.25", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "16e0b6dc63091c71a0c9a2c50e13db64798f261c3e121d7e3fb7d354872180e8"}, "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.3", "64a99531df09397174b7f477785c43016f223d01856725b541a19d36b8149523", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 1.0 or ~> 2.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3a0d87ef2747c66d78ae8967023d415d3d76aa310981047ad601e3287e8fe73c"}, "ex_cldr_units": {:hex, :ex_cldr_units, "3.20.5", "5f12206c41b76eb2baadfbc555826fc8e04c400ef6f42d8b1bf0959ab65dc25a", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.36", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "51e49f21d2c51127de7358367cc4fbc95eff04593b93ab07b20f12a076db6995"}, - "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "ex_eth_bls": {:hex, :ex_eth_bls, "0.1.0", "33c2bf424b360e4b64d7630dd72ec028dac63df56802d0a14ade54a23ad1c743", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25f6ffc36de4952e55adff1c712f0b9680850773678550f1da970d4d18329365"}, "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"}, @@ -102,7 +102,7 @@ "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, "logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"}, "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "math": {:hex, :math, "0.7.0", "12af548c3892abf939a2e242216c3e7cbfb65b9b2fe0d872d05c6fb609f8127b", [:mix], [], "hexpm", "7987af97a0c6b58ad9db43eb5252a49fc1dfe1f6d98f17da9282e297f594ebc2"}, From 3cdffdf75671568b41ce6e836ec34f08d6252a98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:32:55 +0300 Subject: [PATCH 27/82] chore(deps): bump hammer from 7.4.0 to 7.5.0 (#14785) 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 eb4a11bd8b0..0d45756cf7f 100644 --- a/mix.lock +++ b/mix.lock @@ -83,7 +83,7 @@ "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, - "hammer": {:hex, :hammer, "7.4.0", "7ec06643280583b73245d360c6c8797c080ad6cc45788206abc4358eadd70414", [:mix], [], "hexpm", "ae50e0cadd17c68e2379eb8bf06b63bc882a2f9bd6350f8a2c2727c56d082b3d"}, + "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.1.1", "979362db9e6f30b9b71f671b28550b5192cd5c2614e60da6ee9347d8085b962b", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "717bb15f14e709dcae67ba67f90229d2aaee2b6a9bfcc2ca96212f414cd474dc"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, From abeeda3ba0eac636907c96f855a3be33d969bf47 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:43:22 +0400 Subject: [PATCH 28/82] perf: Optimize join_associations (#14775) --- .../api/v2/block_controller_test.exs | 61 +++++ apps/explorer/lib/explorer/chain.ex | 141 +++++++++--- apps/explorer/lib/explorer/query_helper.ex | 70 ++++++ apps/explorer/test/explorer/chain_test.exs | 210 +++++++++++++++++- cspell.json | 3 + 5 files changed, 450 insertions(+), 35 deletions(-) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs index efd87ff59eb..38abc7f5ce7 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs @@ -7,6 +7,8 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Cache.{BlockNumber, Counters.AverageBlockTime} + @page_size 50 + setup do Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) @@ -224,6 +226,55 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do ] } = json_response(request, 422) end + + # Regression: `select_block_type/1` marks `:nephews` as `:required`, and + # `nephews` is a `has_many through`. Joining a to-many association repeats the + # parent row per child, so the `limit/2` in `Explorer.Chain.fetch_blocks/4` + # used to count joined rows rather than blocks and returned a short page. + test "type=uncle returns a full page when every uncle has a single nephew", %{conn: conn} do + for _ <- 1..(@page_size + 10), do: insert_uncle_with_nephews(1) + + response = json_response(get(conn, "/api/v2/blocks", %{"type" => "uncle"}), 200) + + assert length(response["items"]) == @page_size + refute is_nil(response["next_page_params"]) + end + + test "type=uncle returns a full page when uncles have several nephews", %{conn: conn} do + for _ <- 1..(@page_size + 10), do: insert_uncle_with_nephews(2) + + response = json_response(get(conn, "/api/v2/blocks", %{"type" => "uncle"}), 200) + + assert length(response["items"]) == @page_size + refute is_nil(response["next_page_params"]) + end + + test "type=uncle returns every uncle once regardless of nephew count", %{conn: conn} do + for _ <- 1..10, do: insert_uncle_with_nephews(3) + + response = json_response(get(conn, "/api/v2/blocks", %{"type" => "uncle"}), 200) + + hashes = Enum.map(response["items"], & &1["hash"]) + + assert length(hashes) == 10 + assert hashes == Enum.uniq(hashes) + end + + # The default listing marks the to-many `:transactions` and `:rewards` as + # `:optional`. They must stay preloaded rather than joined, otherwise the + # `limit/2` counts joined rows and the page comes back short — the same + # failure the `type=uncle` cases above cover for `:required`. + test "a full page is returned when blocks carry several transactions", %{conn: conn} do + for _ <- 1..(@page_size + 10) do + block = insert(:block) + for _ <- 1..3, do: :transaction |> insert() |> with_block(block) + end + + response = json_response(get(conn, "/api/v2/blocks"), 200) + + assert length(response["items"]) == @page_size + refute is_nil(response["next_page_params"]) + end end describe "/blocks/{block_number}/countdown" do @@ -1042,6 +1093,16 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do end end + defp insert_uncle_with_nephews(nephew_count) do + uncle = insert(:block, consensus: false) + + for index <- 0..(nephew_count - 1) do + insert(:block_second_degree_relation, uncle_hash: uncle.hash, nephew: insert(:block), index: index) + end + + uncle + end + defp compare_item(%Block{} = block, json) do assert to_string(block.hash) == json["hash"] assert block.number == json["height"] diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index f52edb9e5f6..48aa757f64c 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -78,7 +78,7 @@ defmodule Explorer.Chain do alias Explorer.Market.MarketHistoryCache alias Explorer.MicroserviceInterfaces.MultichainSearch - alias Explorer.{PagingOptions, Repo} + alias Explorer.{PagingOptions, QueryHelper, Repo} alias Dataloader.Ecto, as: DataloaderEcto @@ -1506,7 +1506,7 @@ defmodule Explorer.Chain do elements blocks -> - blocks + blocks |> select_repo(options).preload(Map.keys(necessity_by_association)) end end @@ -2267,47 +2267,123 @@ defmodule Explorer.Chain do @doc """ Dynamically joins and preloads associations in a query based on necessity. - This function adjusts the provided Ecto query to include joins for associations. It supports - both optional and required joins. Optional joins use the `preload` function to fetch associations - without enforcing their presence. Required joins ensure the association exists. + `:optional` returns the same rows as a plain preload; `:required` additionally + keeps only the entities that have the association. + + A to-one association is fetched through a join — `LEFT` for `:optional`, + `INNER` for `:required` — and preloaded from it, saving a round trip. A + to-many association cannot be fetched that way: the join repeats the parent + row once per child, and while Ecto collapses the duplicates when assembling + structs, a `limit/2` applied elsewhere counts joined rows rather than + entities, so the page silently comes back short. Those keep the preload path, + with `:required` adding an `INNER JOIN` purely to filter, made row-preserving + by `distinct/2`. + + The preload path is also taken whenever the join would be unsafe or + impossible: the source schema cannot be resolved, the association is a + `through` one or its related schema has no primary key (Ecto needs it to map + joined rows back onto parents), the query already binds the association, or + the spec is not a plain association name — `{name, query}` and `{name, fun}` + specs carry their own loading strategy that a join would drop. + + None of that weakens `:required`: whenever the spec names an association at + all, the filtering `INNER JOIN` is applied on the preload path too, unaliased + so that it cannot collide with a binding the query already has. ## Parameters - `query`: The initial Ecto query. - - `associations`: A single association or a tuple with nested association preloads. + - `preload_spec`: An association name, or a preload spec naming one. - `necessity`: Specifies if the association is `:optional` or `:required`. ## Returns - The modified query with the specified associations joined according to the defined necessity. """ - @spec join_association(atom() | Ecto.Query.t(), [{atom(), atom()}], :optional | :required) :: Ecto.Query.t() - def join_association(query, [{association, nested_preload}], necessity) - when is_atom(association) and is_atom(nested_preload) do - case necessity do - :optional -> - preload(query, [{^association, ^nested_preload}]) - - :required -> - from(q in query, - inner_join: a in assoc(q, ^association), - as: ^association, - left_join: b in assoc(a, ^nested_preload), - as: ^nested_preload, - preload: [{^association, {a, [{^nested_preload, b}]}}] - ) + @spec join_association(atom() | Ecto.Query.t(), term(), :optional | :required) :: Ecto.Query.t() + def join_association(query, preload_spec, necessity) do + with {association, nested_preloads} <- join_target(preload_spec), + true <- joinable?(query, association) do + join_and_preload(query, association, nested_preloads, necessity) + else + _ -> preload_association(query, preload_spec, necessity) end end - @spec join_association(atom() | Ecto.Query.t(), atom(), :optional | :required) :: Ecto.Query.t() - def join_association(query, association, necessity) do - case necessity do - :optional -> - preload(query, ^association) + # `:required` keeps its filtering even when the spec itself cannot be joined. + # The `INNER JOIN` is what drops the entities that do not have the association, + # and the caller depends on that regardless of how the data ends up loaded, so + # it is applied off the association's name alone. The join is left unaliased, + # which is also what makes this path safe for an association the query already + # binds. + defp preload_association(query, preload_spec, :required) do + case spec_association(preload_spec) do + nil -> + preload(query, ^List.wrap(preload_spec)) - :required -> - from(q in query, inner_join: a in assoc(q, ^association), as: ^association, preload: [{^association, a}]) + association -> + query + |> filter_by_association(association) + |> preload(^List.wrap(preload_spec)) end end + defp preload_association(query, preload_spec, :optional), do: preload(query, ^List.wrap(preload_spec)) + + defp join_and_preload(query, association, nested_preloads, :optional) do + from(q in query, + left_join: a in assoc(q, ^association), + as: ^association, + preload: [{^association, {a, ^nested_preloads}}] + ) + end + + defp join_and_preload(query, association, nested_preloads, :required) do + from(q in query, + inner_join: a in assoc(q, ^association), + as: ^association, + preload: [{^association, {a, ^nested_preloads}}] + ) + end + + # An `INNER JOIN` is the only way to express "entities having this association" + # without knowing its keys, but on a to-many association it duplicates the + # parent row; `distinct/2` restores one row per entity so a `limit/2` applied + # elsewhere still counts entities. + defp filter_by_association(query, association) do + if QueryHelper.association_cardinality(query, association) == :one do + from(q in query, inner_join: assoc(q, ^association)) + else + from(q in query, inner_join: assoc(q, ^association), distinct: true) + end + end + + # The association and nested preloads a join could supply, or `nil` when the + # spec has to be loaded as written: `{name, query}` and `{name, fun}` carry + # their own loading strategy that a join would silently drop. + defp join_target(association) when is_atom(association) and not is_nil(association), do: {association, []} + + defp join_target([{association, nested_preloads}]) + when is_atom(association) and (is_atom(nested_preloads) or is_list(nested_preloads)), + do: {association, List.wrap(nested_preloads)} + + defp join_target(_preload_spec), do: nil + + # The association a spec names, whatever shape it takes — including the shapes + # `join_target/1` refuses, since `:required` still has to filter on them. + defp spec_association(association) when is_atom(association) and not is_nil(association), do: association + defp spec_association([{association, _nested_preloads}]) when is_atom(association), do: association + defp spec_association({association, _custom_preload}) when is_atom(association), do: association + defp spec_association(_preload_spec), do: nil + + # Callers compose `join_associations/2` onto hand-written queries that may + # already bind or preload the very same association, and joining it again + # collides on the `as:` alias. Such associations fall back to the preload path, + # where `:required` still gets its filtering from an unaliased join. + defp joinable?(query, association) do + QueryHelper.join_preloadable?(query, association) and + QueryHelper.association_cardinality(query, association) == :one and + not QueryHelper.association_bound?(query, association) + end + @doc """ Applies dynamic joins to a query based on provided association necessities. @@ -2334,12 +2410,9 @@ defmodule Explorer.Chain do join_association(acc_query, association, :required) end) - optional_preloads = Enum.map(optional_associations, fn {association, _join} -> association end) - - case optional_preloads do - [] -> query_with_required_joins - _ -> preload(query_with_required_joins, ^optional_preloads) - end + Enum.reduce(optional_associations, query_with_required_joins, fn {association, _join}, acc_query -> + join_association(acc_query, association, :optional) + end) end def page_blocks(query, %PagingOptions{key: nil}), do: query diff --git a/apps/explorer/lib/explorer/query_helper.ex b/apps/explorer/lib/explorer/query_helper.ex index 8a687e05cf8..01971c6bf27 100644 --- a/apps/explorer/lib/explorer/query_helper.ex +++ b/apps/explorer/lib/explorer/query_helper.ex @@ -98,4 +98,74 @@ defmodule Explorer.QueryHelper do fragment(~s(?."ctid" = ?."ctid"), unquote(first_table_binding), unquote(second_table_binding)) end end + + @doc """ + Returns the cardinality of `association` on the schema `queryable` selects from. + + `:unknown` when the schema cannot be resolved — a query built on a subquery or + a raw source — or when it declares no such association. + """ + @spec association_cardinality(Ecto.Queryable.t(), atom()) :: :one | :many | :unknown + def association_cardinality(queryable, association) do + case association_reflection(queryable, association) do + %{cardinality: cardinality} -> cardinality + _ -> :unknown + end + end + + @doc """ + Whether `association` can be fetched through a join and preloaded from it. + + `Ecto.Repo.Assoc` maps joined rows back onto their parents by the related + schema's primary key and raises `Ecto.NoPrimaryKeyFieldError` without one, and + a `through` association's reflection does not even name a related schema. + Neither can come from a join. + """ + @spec join_preloadable?(Ecto.Queryable.t(), atom()) :: boolean() + def join_preloadable?(queryable, association) do + with %{related: related} <- association_reflection(queryable, association), + schema when not is_nil(schema) <- ecto_schema(related) do + schema.__schema__(:primary_key) != [] + else + _ -> false + end + end + + @doc """ + Whether `query` already carries `association` as a named binding or a + join-preload, in which case joining it again collides on the `as:` alias. + """ + @spec association_bound?(Ecto.Queryable.t(), atom()) :: boolean() + def association_bound?(%Ecto.Query{assocs: assocs} = query, association), + do: has_named_binding?(query, association) or List.keymember?(assocs, association, 0) + + def association_bound?(_queryable, _association), do: false + + @doc """ + Returns the association reflection `association` resolves to on `queryable`, + or `nil` when either the schema or the association cannot be resolved. + """ + @spec association_reflection(Ecto.Queryable.t(), atom()) :: struct() | nil + def association_reflection(queryable, association) do + with schema when not is_nil(schema) <- query_schema(queryable), + %{} = reflection <- schema.__schema__(:association, association) do + reflection + else + _ -> nil + end + end + + @doc """ + Returns the Ecto schema `queryable` selects from, or `nil` when it selects from + a subquery, a raw source, or anything that is not a schema. + """ + @spec query_schema(Ecto.Queryable.t()) :: module() | nil + def query_schema(%Ecto.Query{from: %{source: {_source, schema}}}), do: ecto_schema(schema) + def query_schema(queryable), do: ecto_schema(queryable) + + defp ecto_schema(module) when is_atom(module) and not is_nil(module) do + if Code.ensure_loaded?(module) and function_exported?(module, :__schema__, 2), do: module + end + + defp ecto_schema(_module), do: nil end diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 2d8a318b975..425c742b235 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -24,7 +24,7 @@ defmodule Explorer.ChainTest do } alias Explorer.{Chain, Etherscan} - alias Explorer.Chain.Cache.ChainId + alias Explorer.Chain.Cache.{ChainId, Uncles} alias Explorer.Chain.Cache.Counters.{ BlocksCount, @@ -2567,4 +2567,212 @@ defmodule Explorer.ChainTest do Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, init_config) end end + + # The uncles cache is warmed by the indexer with its own fixed preload list + # (`:transactions`, `[miner: :names]`, `:rewards`, `:nephews`), which is + # narrower than what a listing asks for. Serving those elements as they are + # would answer a request with associations left unloaded. + describe "list_blocks/1 served from a warm uncles cache" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, Uncles.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Uncles.child_id()) + + :ok + end + + test "loads the requested associations the cache does not preload itself" do + relations = + for _ <- 1..3 do + miner = insert(:address) + insert(:smart_contract, address_hash: miner.hash) + uncle = insert(:block, consensus: false, miner: miner) + insert(:block_second_degree_relation, uncle_hash: uncle.hash, nephew: insert(:block), index: 0) + end + + Uncles.update_from_second_degree_relations(relations) + + blocks = + Chain.list_blocks( + block_type: "Uncle", + necessity_by_association: %{[miner: [:names, :smart_contract]] => :optional}, + paging_options: %PagingOptions{page_size: 2, key: nil} + ) + + assert length(blocks) == 2 + + assert Enum.all?(blocks, fn block -> + not match?(%Ecto.Association.NotLoaded{}, block.miner.smart_contract) and + block.miner.smart_contract.address_hash == block.miner_hash + end) + end + end + + # `join_associations/2` fetches a to-one association through a join and + # everything else through a preload. A join against a to-many association + # repeats the parent row once per child, which makes a `limit/2` count joined + # rows rather than entities, so these cover the shapes where the distinction + # decides whether a page comes back whole. + describe "join_associations/2" do + test "a required to-one association loads its to-many nested preload" do + address = insert(:address) + insert(:address_name, address: address, primary: true, name: "first") + insert(:address_name, address: address, name: "second") + + block = insert(:block) + transaction = :transaction |> insert(from_address: address) |> with_block(block) + + assert {:ok, loaded} = + Chain.hash_to_transaction(transaction.hash, + necessity_by_association: %{[from_address: :names] => :required} + ) + + assert loaded.hash == transaction.hash + assert loaded.from_address.hash == address.hash + assert length(loaded.from_address.names) == 2 + end + + test "a required to-one association returns each entity once and fills a full page" do + block = insert(:block) + + transactions = + for _ <- 1..10 do + address = insert(:address) + insert(:address_name, address: address, primary: true, name: "first") + insert(:address_name, address: address, name: "second") + :transaction |> insert(from_address: address) |> with_block(block) + end + + loaded = + Chain.block_to_transactions(block.hash, + necessity_by_association: %{[from_address: :names] => :required}, + paging_options: %PagingOptions{page_size: 10, key: nil} + ) + + hashes = Enum.map(loaded, & &1.hash) + + assert length(hashes) == 10 + assert hashes == Enum.uniq(hashes) + assert MapSet.new(hashes) == MapSet.new(Enum.map(transactions, & &1.hash)) + assert Enum.all?(loaded, &(length(&1.from_address.names) == 2)) + end + + test "a required association drops the entities that do not have it" do + block = insert(:block) + with_from_address = :transaction |> insert(from_address: insert(:address)) |> with_block(block) + + loaded = + Chain.block_to_transactions(block.hash, + necessity_by_association: %{[from_address: :names] => :required}, + paging_options: %PagingOptions{page_size: 10, key: nil} + ) + + assert Enum.map(loaded, & &1.hash) == [with_from_address.hash] + end + + test "a required to-many association keeps one row per entity" do + uncle = insert(:block, consensus: false) + + for index <- 0..2 do + insert(:block_second_degree_relation, uncle_hash: uncle.hash, nephew: insert(:block), index: index) + end + + loaded = + Chain.list_blocks( + block_type: "Uncle", + necessity_by_association: %{:nephews => :required}, + paging_options: %PagingOptions{page_size: 10, key: nil} + ) + + assert Enum.map(loaded, & &1.hash) == [uncle.hash] + assert length(hd(loaded).nephews) == 3 + end + + test "an optional to-many association fills a full page" do + for _ <- 1..12 do + block = insert(:block) + for _ <- 1..3, do: :transaction |> insert() |> with_block(block) + end + + loaded = + Chain.list_blocks( + necessity_by_association: %{:transactions => :optional, :rewards => :optional}, + paging_options: %PagingOptions{page_size: 10, key: nil} + ) + + hashes = Enum.map(loaded, & &1.hash) + + assert length(hashes) == 10 + assert hashes == Enum.uniq(hashes) + assert Enum.all?(loaded, &(length(&1.transactions) == 3)) + end + + test "a has_one through and a primary-key-less schema still load" do + address = insert(:address) + insert(:address_name, address: address, primary: true, name: "named") + + token_transfer = insert(:token_transfer, from_address: address) + + [loaded] = + Chain.address_hash_to_token_transfers_new(address.hash, + necessity_by_association: %{ + :token => :optional, + [from_address: [:scam_badge, :names]] => :optional + } + ) + + assert loaded.transaction_hash == token_transfer.transaction_hash + assert loaded.token.contract_address_hash == token_transfer.token_contract_address_hash + assert length(loaded.from_address.names) == 1 + assert loaded.from_address.scam_badge == nil + end + + test "a required spec carrying its own query still filters" do + %Transaction{hash: collated_hash} = :transaction |> insert() |> with_block() + %Transaction{hash: pending_hash} = insert(:transaction) + + spec = [block: from(block in Block, select: struct(block, [:hash, :number]))] + + assert {:ok, %Transaction{hash: ^collated_hash} = loaded} = + Chain.hash_to_transaction(collated_hash, necessity_by_association: %{spec => :required}) + + assert loaded.block.number + + assert {:error, :not_found} = + Chain.hash_to_transaction(pending_hash, necessity_by_association: %{spec => :required}) + + assert {:ok, %Transaction{hash: ^pending_hash}} = + Chain.hash_to_transaction(pending_hash, necessity_by_association: %{spec => :optional}) + end + + test "a required association the query already binds filters without an alias collision" do + %Transaction{hash: collated_hash} = :transaction |> insert() |> with_block() + %Transaction{hash: pending_hash} = insert(:transaction) + + loaded = + from(transaction in Transaction, left_join: block in assoc(transaction, :block), as: :block) + |> Chain.join_associations(%{:block => :required}) + |> Repo.all() + + assert Enum.map(loaded, & &1.hash) == [collated_hash] + refute pending_hash in Enum.map(loaded, & &1.hash) + assert hd(loaded).block.number + end + + test "a required through association the query already binds stays on the preload path" do + transaction = :transaction |> insert() |> with_block() + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + [loaded] = + Chain.transaction_to_token_transfers(transaction.hash, necessity_by_association: %{:token => :required}) + + assert loaded.log_index == token_transfer.log_index + assert loaded.token.contract_address_hash == token_transfer.token_contract_address_hash + end + end end diff --git a/cspell.json b/cspell.json index 46f2fbd29d4..6fdccc2ddc9 100644 --- a/cspell.json +++ b/cspell.json @@ -383,6 +383,7 @@ "keccak", "Keepalive", "keyfind", + "keymember", "keyout", "kittencream", "KnxbUejwY", @@ -549,6 +550,7 @@ "predeploy", "prederive", "prederived", + "preloadable", "prewalk", "progressbar", "proxiable", @@ -732,6 +734,7 @@ "tzdata", "ueberauth", "ufixed", + "unaliased", "uncatalog", "unclosable", "unconfigured", From bbba374bef0ece5f9830c906c39e8af37f987801 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 9 Sep 2026 13:59:23 +0300 Subject: [PATCH 29/82] fix: support hexadecimal block number in eth_getBalance (#14804) --- .../api/rpc/eth_controller_test.exs | 26 +++++++++++++++++++ apps/explorer/lib/explorer/eth_rpc.ex | 7 +++++ 2 files changed, 33 insertions(+) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs index 42e62fd6a5a..9a727307a9a 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs @@ -710,6 +710,32 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do assert response["result"] == "0x2" end + test "with a hexadecimal block provided", %{conn: conn, api_params: api_params} do + address = insert(:address) + + insert(:fetched_balance, block_number: 1, address_hash: address.hash, value: 1) + insert(:fetched_balance, block_number: 2, address_hash: address.hash, value: 2) + insert(:fetched_balance, block_number: 3, address_hash: address.hash, value: 3) + + assert response = + conn + |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "0x2"])) + |> json_response(200) + + assert response["result"] == "0x2" + end + + test "with an invalid hexadecimal block provided", %{conn: conn, api_params: api_params} do + address = insert(:address) + + assert response = + conn + |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "0xnonsense"])) + |> json_response(200) + + assert response["error"] == "Query parameter 'block' is invalid" + end + test "with a block provided and no balance", %{conn: conn, api_params: api_params} do address = insert(:address) diff --git a/apps/explorer/lib/explorer/eth_rpc.ex b/apps/explorer/lib/explorer/eth_rpc.ex index 55b22253df0..1f5da9b0cb7 100644 --- a/apps/explorer/lib/explorer/eth_rpc.ex +++ b/apps/explorer/lib/explorer/eth_rpc.ex @@ -1334,6 +1334,13 @@ defmodule Explorer.EthRPC do defp block_param("earliest"), do: {:ok, :earliest} defp block_param("pending"), do: {:ok, :pending} + defp block_param("0x" <> hexadecimal_digits) do + case Integer.parse(hexadecimal_digits, 16) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + defp block_param(string_integer) when is_bitstring(string_integer) do case Integer.parse(string_integer) do {integer, ""} -> {:ok, integer} From e6788fff21e2c13ee90567608b6b70b22f1c127a Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 9 Sep 2026 14:04:54 +0300 Subject: [PATCH 30/82] fix: Decouple cache propagation to API nodes from block import (#14778) --- apps/explorer/lib/explorer/application.ex | 1 + .../lib/explorer/chain/cache/propagator.ex | 253 ++++++++++++++++++ apps/explorer/lib/explorer/chain/map_cache.ex | 75 ++++-- .../lib/explorer/chain/ordered_cache.ex | 92 ++++--- .../explorer/chain/cache/propagator_test.exs | 167 ++++++++++++ .../chain/cache/transactions_test.exs | 17 ++ apps/indexer/lib/indexer/block/fetcher.ex | 42 ++- config/runtime.exs | 4 + docker-compose/envs/common-blockscout.env | 2 + 9 files changed, 585 insertions(+), 68 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/propagator.ex create mode 100644 apps/explorer/test/explorer/chain/cache/propagator_test.exs diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index aa74c875cf5..9cc8c1f37ca 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -82,6 +82,7 @@ defmodule Explorer.Application do ), Supervisor.child_spec({Task.Supervisor, name: Explorer.WETHMigratorSupervisor}, id: WETHMigratorSupervisor), {Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents}, + Explorer.Chain.Cache.Propagator, Accounts, AddressesCoinBalanceSum, AddressesCoinBalanceSumMinusBurnt, diff --git a/apps/explorer/lib/explorer/chain/cache/propagator.ex b/apps/explorer/lib/explorer/chain/cache/propagator.ex new file mode 100644 index 00000000000..a18093931be --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/propagator.ex @@ -0,0 +1,253 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Propagator do + @moduledoc """ + Propagates cache writes from indexer nodes to the other nodes of the cluster. + + `Explorer.Chain.OrderedCache` and `Explorer.Chain.MapCache` write to their local + `ConCache` synchronously and then hand the written data to this process. The + process coalesces pending writes per cache and ships them to `Node.list/0` with + `:erpc.multicast/4` from a short-lived sender process, so that: + + - the local write never depends on the state of a remote node or of the + distribution link. Before this process existed the multicast ran inside the + block import task and, when the distribution buffer to an API node was full, + the VM suspended the task: tens of thousands of tasks piled up holding + preloaded blocks and transactions, and the indexer's own caches stalled; + - at most one batch is in flight at any time and the pending data is bounded. + Ordered caches keep at most `max_size` newest elements, map caches keep only + the set of dirty keys (the current local value is read when the batch is + sent), so a slow cluster only makes API nodes skip intermediate states; + - a sender that stays blocked (e.g. suspended on a busy distribution port) is + killed after `send_timeout` and its batch is dropped. + + Elements are sent exactly as they were written locally, preloads included, so + the receiving nodes never touch the database to apply a propagated write. A + warning `Cache propagation to [...] did not complete` in the logs means the + distribution link cannot keep up with the payload. + + ## Configuration + + - `:flush_interval` - how long (ms) writes are accumulated before a batch is + sent, defaults to 100. A batch is never sent while another one is in flight. + - `:send_timeout` - how long (ms) a sender may take before it is killed and its + batch dropped, defaults to 30 seconds. + """ + + use GenServer + + require Logger + + @default_flush_interval 100 + @default_send_timeout :timer.seconds(30) + + @typep pending_key :: {:ordered, module()} | {:map, module()} + @type pending :: %{optional(pending_key) => [{term(), struct()}] | MapSet.t(atom())} + + ## Client + + @doc """ + Starts the propagator. + + Besides `:name`, `:flush_interval` and `:send_timeout`, the following options + exist for tests: `:nodes_fun` (a zero-arity function returning the nodes to + propagate to, defaults to `Node.list/0`) and `:multicast_fun` (a 4-arity + function with the signature of `:erpc.multicast/4`). + """ + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + {name, opts} = Keyword.pop(opts, :name, __MODULE__) + gen_server_opts = if name, do: [name: name], else: [] + + GenServer.start_link(__MODULE__, opts, gen_server_opts) + end + + @doc """ + Queues prepared `{id, element}` pairs of an `Explorer.Chain.OrderedCache` for + propagation. Only the `max_size` most prevailing pending elements are kept. + """ + @spec enqueue_ordered(module(), [{term(), struct()}], GenServer.server()) :: :ok + def enqueue_ordered(cache_module, prepared_elements, server \\ __MODULE__) do + GenServer.cast(server, {:ordered, cache_module, prepared_elements}) + end + + @doc """ + Marks keys of an `Explorer.Chain.MapCache` as dirty. Their current local values + are read and propagated when the next batch is sent. + """ + @spec enqueue_map(module(), atom() | [atom()], GenServer.server()) :: :ok + def enqueue_map(cache_module, keys, server \\ __MODULE__) do + GenServer.cast(server, {:map, cache_module, List.wrap(keys)}) + end + + @doc """ + Returns the data queued but not yet sent, for introspection and tests. + """ + @spec pending(GenServer.server()) :: pending() + def pending(server \\ __MODULE__), do: GenServer.call(server, :pending) + + @doc """ + Sends the pending data right away instead of waiting for the flush interval. + Does nothing while a batch is already in flight. + """ + @spec flush(GenServer.server()) :: :ok + def flush(server \\ __MODULE__), do: GenServer.call(server, :flush) + + @doc false + # Merges new prepared elements into the pending ones: newest version of an id + # wins, the result is ordered by `prevails?/2` and capped to `max_size/0`. + @spec coalesce_ordered(module(), [{term(), struct()}], [{term(), struct()}]) :: [{term(), struct()}] + def coalesce_ordered(cache_module, existing, new) do + (new ++ existing) + |> Enum.uniq_by(&elem(&1, 0)) + |> Enum.sort_by(&elem(&1, 0), &cache_module.prevails?/2) + |> Enum.take(cache_module.max_size()) + end + + ## Server + + @impl GenServer + def init(opts) do + config = Application.get_env(:explorer, __MODULE__, []) + + state = %{ + pending: %{}, + in_flight: nil, + flush_timer: nil, + flush_interval: opts[:flush_interval] || config[:flush_interval] || @default_flush_interval, + send_timeout: opts[:send_timeout] || config[:send_timeout] || @default_send_timeout, + nodes_fun: opts[:nodes_fun] || (&Node.list/0), + multicast_fun: opts[:multicast_fun] || (&:erpc.multicast/4) + } + + {:ok, state} + end + + @impl GenServer + def handle_cast({:ordered, cache_module, prepared_elements}, state) do + pending = + Map.update( + state.pending, + {:ordered, cache_module}, + coalesce_ordered(cache_module, [], prepared_elements), + &coalesce_ordered(cache_module, &1, prepared_elements) + ) + + {:noreply, schedule_flush(%{state | pending: pending})} + end + + def handle_cast({:map, cache_module, keys}, state) do + new_keys = MapSet.new(keys) + pending = Map.update(state.pending, {:map, cache_module}, new_keys, &MapSet.union(&1, new_keys)) + + {:noreply, schedule_flush(%{state | pending: pending})} + end + + @impl GenServer + def handle_call(:pending, _from, state), do: {:reply, state.pending, state} + + def handle_call(:flush, _from, state) do + {:reply, :ok, state |> cancel_flush_timer() |> do_flush()} + end + + @impl GenServer + def handle_info(:flush, state) do + {:noreply, do_flush(%{state | flush_timer: nil})} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{in_flight: %{ref: ref} = in_flight} = state) do + Process.cancel_timer(in_flight.timer) + log_sender_exit(reason, in_flight) + + {:noreply, schedule_flush(%{state | in_flight: nil})} + end + + def handle_info({:sender_timeout, ref}, %{in_flight: %{ref: ref} = in_flight} = state) do + Logger.warning(fn -> + [ + "Cache propagation to ", + inspect(in_flight.nodes), + " did not complete in ", + to_string(state.send_timeout), + "ms, dropping the batch: ", + inspect(in_flight.summary) + ] + end) + + Process.exit(in_flight.pid, :kill) + + {:noreply, state} + end + + def handle_info(_message, state), do: {:noreply, state} + + ## Internals + + defp schedule_flush(%{pending: pending} = state) when map_size(pending) == 0, do: state + + defp schedule_flush(%{flush_timer: nil} = state) do + %{state | flush_timer: Process.send_after(self(), :flush, state.flush_interval)} + end + + defp schedule_flush(state), do: state + + defp cancel_flush_timer(%{flush_timer: nil} = state), do: state + + defp cancel_flush_timer(%{flush_timer: timer} = state) do + Process.cancel_timer(timer) + %{state | flush_timer: nil} + end + + defp do_flush(%{pending: pending} = state) when map_size(pending) == 0, do: state + + # A batch is being sent: pending data keeps coalescing and the sender's exit + # schedules the next flush. + defp do_flush(%{in_flight: %{}} = state), do: state + + defp do_flush(state) do + case state.nodes_fun.() do + [] -> + # Nothing to propagate to. Nodes joining later start from the database + # fallbacks of their caches and catch up with the following writes. + %{state | pending: %{}} + + nodes -> + batch = state.pending + multicast_fun = state.multicast_fun + + {pid, ref} = spawn_monitor(fn -> send_batch(batch, nodes, multicast_fun) end) + timer = Process.send_after(self(), {:sender_timeout, ref}, state.send_timeout) + + in_flight = %{pid: pid, ref: ref, timer: timer, nodes: nodes, summary: summarize(batch)} + + %{state | pending: %{}, in_flight: in_flight} + end + end + + defp send_batch(batch, nodes, multicast_fun) do + Enum.each(batch, fn + {{:ordered, cache_module}, prepared_elements} -> + multicast_fun.(nodes, cache_module, :do_raw_update, [prepared_elements, false]) + + {{:map, cache_module}, keys} -> + values = cache_module.current_values(MapSet.to_list(keys)) + multicast_fun.(nodes, cache_module, :apply_propagated, [values]) + end) + end + + defp summarize(batch) do + Map.new(batch, fn + {{:ordered, cache_module}, prepared_elements} -> {cache_module, length(prepared_elements)} + {{:map, cache_module}, keys} -> {cache_module, MapSet.to_list(keys)} + end) + end + + defp log_sender_exit(:normal, _in_flight), do: :ok + # already reported by the timeout handler + defp log_sender_exit(:killed, _in_flight), do: :ok + + defp log_sender_exit(reason, in_flight) do + Logger.error(fn -> + ["Cache propagation to ", inspect(in_flight.nodes), " failed: ", inspect(reason)] + end) + end +end diff --git a/apps/explorer/lib/explorer/chain/map_cache.ex b/apps/explorer/lib/explorer/chain/map_cache.ex index 7aa96a894ff..38ab88308c5 100644 --- a/apps/explorer/lib/explorer/chain/map_cache.ex +++ b/apps/explorer/lib/explorer/chain/map_cache.ex @@ -48,9 +48,12 @@ defmodule Explorer.Chain.MapCache do ## Distributed writes - In split API/indexer deployments, `set/2` and `update/2` use `do_raw/2`: the write runs on - the local `ConCache` first, then indexer nodes multicast the same operation to other cluster - nodes via `:erpc` (`propagate: false` on receivers). Reads are not distributed. + In split API/indexer deployments, `set/2` and `update/2` write to the local `ConCache` + first, then indexer nodes mark the key as dirty in `Explorer.Chain.Cache.Propagator`. The + propagator later reads the current local value with `current_values/1` and asynchronously + multicasts it to other cluster nodes via `:erpc`, which store it with `apply_propagated/1`. + The local write never waits for a remote node. Reads are not distributed, and neither is + the `:async_task` key since pids are node-local. """ @type key :: atom() @@ -163,7 +166,8 @@ defmodule Explorer.Chain.MapCache do @impl MapCache def set(key, value) do - do_raw(fn -> ConCache.put(cache_name(), key, value) end) + ConCache.put(cache_name(), key, value) + propagate(key) end @impl MapCache @@ -176,30 +180,63 @@ defmodule Explorer.Chain.MapCache do @impl MapCache def update(key, value) do - do_raw(fn -> ConCache.update(cache_name(), key, fn old_val -> handle_update(key, old_val, value) end) end) + ConCache.update(cache_name(), key, fn old_val -> handle_update(key, old_val, value) end) + propagate(key) end @doc """ - Applies a ConCache write locally, then propagates it from indexer nodes. + Applies a ConCache write locally. - With the default `propagate: true`, the given zero-arity function runs on this node first. - When `Explorer.mode/0` is `:indexer`, the same function is multicast to `Node.list/0` with - `propagate: false` so API nodes apply the write without re-propagating. + With the default `propagate: true`, every key of the cache is then marked dirty in + `Explorer.Chain.Cache.Propagator` on indexer nodes. `propagate: false` is the entry point + used by indexer nodes running a version that still multicasts closures; new versions + propagate values through `apply_propagated/1`. """ def do_raw(function, propagate \\ true) do function.() - case Explorer.mode() do - :indexer -> - if propagate do - Node.list() |> :erpc.multicast(__MODULE__, :do_raw, [function, false]) - else - Logger.error("[#{__MODULE__}] Indexer got unexpected propagation call to do_raw/2") - :ok - end + if propagate do + Enum.each(cache_keys(), &propagate/1) + else + log_unexpected_propagation("do_raw/2") + end + + :ok + end + + @doc """ + Receiving side of the propagation: stores the given `%{key => value}` map as is. + Values come from the indexer node, which is the source of truth for the cache. + """ + def apply_propagated(values) when is_map(values) do + log_unexpected_propagation("apply_propagated/1") + + Enum.each(values, fn {key, value} -> ConCache.put(cache_name(), key, value) end) + end + + @doc """ + Current local values of the given keys, read without triggering `c:handle_fallback/1`. + Used by `Explorer.Chain.Cache.Propagator` when a batch is sent. + """ + def current_values(keys) do + Map.new(keys, fn key -> {key, ConCache.get(cache_name(), key)} end) + end + + # pids are node-local, propagating them makes no sense + defp propagate(:async_task), do: :ok + + defp propagate(key) do + if Explorer.mode() == :indexer do + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + Explorer.Chain.Cache.Propagator.enqueue_map(__MODULE__, key) + end + + :ok + end - _ -> - :ok + defp log_unexpected_propagation(function) do + if Explorer.mode() == :indexer do + Logger.error("[#{__MODULE__}] Indexer got unexpected propagation call to #{function}") end end diff --git a/apps/explorer/lib/explorer/chain/ordered_cache.ex b/apps/explorer/lib/explorer/chain/ordered_cache.ex index ba37da1a2cb..ea3dbd62f9a 100644 --- a/apps/explorer/lib/explorer/chain/ordered_cache.ex +++ b/apps/explorer/lib/explorer/chain/ordered_cache.ex @@ -40,8 +40,11 @@ defmodule Explorer.Chain.OrderedCache do ## Distributed writes In split API/indexer deployments, `update/1` uses `do_raw_update/2`: the ids list and - elements are written to the local `ConCache` first, then indexer nodes multicast the prepared - update to other cluster nodes via `:erpc` (`propagate: false` on receivers). + elements are written to the local `ConCache` first, then indexer nodes hand the prepared + update to `Explorer.Chain.Cache.Propagator`. The propagator coalesces updates and + asynchronously multicasts them to other cluster nodes via `:erpc`, which call + `do_raw_update/2` with `propagate: false` and write locally without any database access. + The local write never waits for a remote node. """ @type element :: struct() @@ -265,27 +268,10 @@ defmodule Explorer.Chain.OrderedCache do def update(elements) when is_list(elements) do case Explorer.mode() do mode when mode in [:all, :api, :indexer] -> - elements_for_preload = - elements - |> Enum.sort_by(&element_to_id(&1), &prevails?(&1, &2)) - |> Enum.take(max_size()) - - preloaded_elements = - try do - do_preloads(elements_for_preload) - rescue - postgrex_error in Postgrex.Error -> - Logger.error(fn -> - [ - "Error while preloading elements for ordered cache: ", - Exception.format(:error, postgrex_error, __STACKTRACE__) - ] - end) - - elements_for_preload - end - - preloaded_elements + elements + |> Enum.sort_by(&element_to_id(&1), &prevails?(&1, &2)) + |> Enum.take(max_size()) + |> do_preloads() |> Enum.map(&{element_to_id(&1), sanitize_before_update(&1)}) |> do_raw_update(true) @@ -297,13 +283,36 @@ defmodule Explorer.Chain.OrderedCache do def update(element), do: update([element]) @doc """ - Merges prepared elements into the local ordered cache, then propagates from indexer nodes. + Merges prepared `{id, element}` pairs into the local ordered cache. + + With `propagate: true` (the writing side) the elements are written locally and, when + `Explorer.mode/0` is `:indexer`, handed to `Explorer.Chain.Cache.Propagator`, which + multicasts them to the other cluster nodes asynchronously. The local write never waits + for a remote node. - Always updates the local ids list and element entries first. When `Explorer.mode/0` is - `:indexer` and `propagate` is `true`, multicasts the same prepared elements to `Node.list/0` - with `propagate: false` so API nodes apply the write without re-propagating. + With `propagate: false` (the receiving side) the elements, already preloaded by the + sender, are written locally without any database access. """ - def do_raw_update(prepared_elements, propagate) do + def do_raw_update(prepared_elements, true) do + write_locally(prepared_elements) + + if Explorer.mode() == :indexer do + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + Explorer.Chain.Cache.Propagator.enqueue_ordered(__MODULE__, prepared_elements) + end + + :ok + end + + def do_raw_update(prepared_elements, false) do + if Explorer.mode() == :indexer do + Logger.error("Indexer got unexpected propagation call to do_raw_update/2") + end + + write_locally(prepared_elements) + end + + defp write_locally(prepared_elements) do ConCache.update(cache_name(), ids_list_key(), fn ids -> updated_list = prepared_elements @@ -312,26 +321,25 @@ defmodule Explorer.Chain.OrderedCache do # ids_list is set to never expire {:ok, %ConCache.Item{value: updated_list, ttl: :infinity}} end) - - case Explorer.mode() do - :indexer -> - if propagate do - Node.list() |> :erpc.multicast(__MODULE__, :do_raw_update, [prepared_elements, false]) - else - Logger.error("Indexer got unexpected propagation call to do_raw_update/2") - :ok - end - - _ -> - :ok - end end defp do_preloads(elements) do if Enum.empty?(preloads()) do elements else - Explorer.Repo.preload(elements, preloads()) + try do + Explorer.Repo.preload(elements, preloads()) + rescue + error in [Postgrex.Error, DBConnection.ConnectionError] -> + Logger.error(fn -> + [ + "Error while preloading elements for ordered cache: ", + Exception.format(:error, error, __STACKTRACE__) + ] + end) + + elements + end end end diff --git a/apps/explorer/test/explorer/chain/cache/propagator_test.exs b/apps/explorer/test/explorer/chain/cache/propagator_test.exs new file mode 100644 index 00000000000..ce7a92d1fb2 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/propagator_test.exs @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.PropagatorTest do + # `Explorer.mode/0` is global state + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + alias Explorer.Chain.Cache.{Blocks, Propagator, Transactions} + + defmodule TestMapCache do + use Explorer.Chain.MapCache, + name: :propagator_test_map_cache, + keys: [:foo, :bar, :async_task] + + # both return shapes are used so that the type checker does not flag the + # `{:update, _}` branch of `get/1` as unreachable + def handle_fallback(:bar), do: {:update, 0} + def handle_fallback(_key), do: {:return, nil} + end + + @nodes [:api1@test, :api2@test] + + defp start_propagator(opts) do + test_pid = self() + + default_opts = [ + name: nil, + flush_interval: 10, + send_timeout: 5_000, + nodes_fun: fn -> @nodes end, + multicast_fun: fn nodes, module, function, args -> + send(test_pid, {:multicast, self(), nodes, module, function, args}) + :ok + end + ] + + start_supervised!({Propagator, Keyword.merge(default_opts, opts)}) + end + + describe "enqueue_ordered/3" do + test "coalesces pending elements: newest version of an id wins, ordered by prevails?/2, capped to max_size/0" do + pid = start_propagator(flush_interval: :timer.minutes(1)) + + Propagator.enqueue_ordered(Blocks, [{1, :one}, {3, :three}], pid) + Propagator.enqueue_ordered(Blocks, [{2, :two}, {3, :three_v2}], pid) + + assert Propagator.pending(pid) == %{{:ordered, Blocks} => [{3, :three_v2}, {2, :two}, {1, :one}]} + + max_size = Blocks.max_size() + Propagator.enqueue_ordered(Blocks, Enum.map(1..(max_size + 10), &{&1, &1}), pid) + + %{{:ordered, Blocks} => pending} = Propagator.pending(pid) + assert length(pending) == max_size + assert hd(pending) == {max_size + 10, max_size + 10} + end + + test "sends the pending elements to the other nodes with propagate: false" do + pid = start_propagator([]) + + Propagator.enqueue_ordered(Transactions, [{{1, 0}, :transaction}], pid) + + assert_receive {:multicast, _sender, @nodes, Transactions, :do_raw_update, [[{{1, 0}, :transaction}], false]} + assert Propagator.pending(pid) == %{} + end + end + + describe "enqueue_map/3" do + setup do + start_supervised!(TestMapCache) + :ok + end + + test "deduplicates keys and sends their current local values" do + pid = start_propagator([]) + TestMapCache.set(:foo, 1) + + Propagator.enqueue_map(TestMapCache, [:foo, :foo, :bar], pid) + + assert_receive {:multicast, _sender, @nodes, TestMapCache, :apply_propagated, [%{foo: 1, bar: nil}]} + end + + test "apply_propagated/1 stores the received values" do + TestMapCache.apply_propagated(%{foo: 7, bar: 8}) + + assert TestMapCache.get_all() == %{foo: 7, bar: 8, async_task: nil} + end + + test "set/2 and update/2 mark the key dirty on indexer nodes, except for :async_task" do + old_mode = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, :indexer) + on_exit(fn -> Application.put_env(:explorer, :mode, old_mode) end) + + TestMapCache.set(:foo, 1) + TestMapCache.update(:bar, 2) + TestMapCache.set_async_task(self()) + + assert TestMapCache.get_all() == %{foo: 1, bar: 2, async_task: self()} + assert Propagator.pending()[{:map, TestMapCache}] == MapSet.new([:foo, :bar]) + end + end + + describe "in-flight batches" do + test "at most one batch is in flight, data queued meanwhile is coalesced and sent afterwards" do + test_pid = self() + + blocking_multicast = fn nodes, module, function, args -> + send(test_pid, {:multicast, self(), nodes, module, function, args}) + + receive do + :continue -> :ok + end + end + + pid = start_propagator(multicast_fun: blocking_multicast) + + Propagator.enqueue_ordered(Blocks, [{1, :one}], pid) + assert_receive {:multicast, sender, @nodes, Blocks, :do_raw_update, [[{1, :one}], false]} + + Propagator.enqueue_ordered(Blocks, [{2, :two}], pid) + Propagator.enqueue_ordered(Blocks, [{3, :three}], pid) + Propagator.flush(pid) + + refute_receive {:multicast, _, _, Blocks, _, _}, 50 + assert Propagator.pending(pid) == %{{:ordered, Blocks} => [{3, :three}, {2, :two}]} + + send(sender, :continue) + + assert_receive {:multicast, sender, @nodes, Blocks, :do_raw_update, [[{3, :three}, {2, :two}], false]} + send(sender, :continue) + end + + test "kills a sender exceeding send_timeout, drops its batch and keeps working" do + test_pid = self() + + stuck_multicast = fn nodes, module, function, args -> + send(test_pid, {:multicast, self(), nodes, module, function, args}) + Process.sleep(:infinity) + end + + pid = start_propagator(multicast_fun: stuck_multicast, send_timeout: 50) + + log = + capture_log(fn -> + Propagator.enqueue_ordered(Blocks, [{1, :one}], pid) + + assert_receive {:multicast, sender, @nodes, Blocks, _, _} + ref = Process.monitor(sender) + assert_receive {:DOWN, ^ref, :process, ^sender, :killed}, 1_000 + + Propagator.enqueue_ordered(Blocks, [{2, :two}], pid) + assert_receive {:multicast, _, @nodes, Blocks, :do_raw_update, [[{2, :two}], false]} + end) + + assert log =~ "did not complete" + end + + test "drops the pending data when there are no other nodes" do + pid = start_propagator(nodes_fun: fn -> [] end) + + Propagator.enqueue_ordered(Blocks, [{1, :one}], pid) + Propagator.flush(pid) + + assert Propagator.pending(pid) == %{} + refute_receive {:multicast, _, _, _, _, _}, 50 + end + end +end diff --git a/apps/explorer/test/explorer/chain/cache/transactions_test.exs b/apps/explorer/test/explorer/chain/cache/transactions_test.exs index 0ebd8dfb831..3832e9fc9ec 100644 --- a/apps/explorer/test/explorer/chain/cache/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/cache/transactions_test.exs @@ -112,6 +112,23 @@ defmodule Explorer.Chain.Cache.TransactionsTest do end end + describe "do_raw_update/2 with propagate: false" do + test "writes the received elements as is, without touching the database" do + Application.put_env(:explorer, :mode, :api) + + block = insert(:block) + transaction = insert(:transaction) |> with_block(block) |> preload_all() + prepared = [{Transactions.element_to_id(transaction), transaction}] + + # the rows are gone: the receiving node must not need them + Repo.delete!(transaction) + + Transactions.do_raw_update(prepared, false) + + assert Transactions.take(1) == [transaction] + end + end + defp preload_all(transactions) when is_list(transactions) do Enum.map(transactions, &preload_all(&1)) end diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index 783002787a2..a8326800ec7 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -278,13 +278,19 @@ defmodule Indexer.Block.Fetcher do Prometheus.Instrumenter.set_block_batch_fetch(fetch_time, callback_module) result = {:ok, %{inserted: inserted, errors: blocks_errors}} - Task.Supervisor.start_child(task_supervisor, fn -> - update_block_cache(inserted[:blocks], inserted) - update_transactions_cache(inserted[:transactions], inserted) - update_addresses_cache(inserted[:addresses]) - update_uncles_cache(inserted[:block_second_degree_relations]) - update_withdrawals_cache(inserted[:withdrawals]) - end) + # only what the caches need is captured by the task, not the whole import result + inserted_for_caches = + Map.take(inserted, [ + :blocks, + :transactions, + :block_rewards, + :token_transfers, + :addresses, + :block_second_degree_relations, + :withdrawals + ]) + + Task.Supervisor.start_child(task_supervisor, fn -> update_caches(inserted_for_caches) end) async_match_arbitrum_messages_to_l2(arbitrum_transactions_for_further_handling) @@ -485,6 +491,28 @@ defmodule Indexer.Block.Fetcher do defp enable_partial_async_import?, do: Application.get_env(:indexer, :enable_partial_async_import?) + # Every cache is refreshed independently: a failure while updating one of them + # must not leave the others stale. + defp update_caches(inserted) do + update_cache_safely("blocks", fn -> update_block_cache(inserted[:blocks], inserted) end) + update_cache_safely("transactions", fn -> update_transactions_cache(inserted[:transactions], inserted) end) + update_cache_safely("addresses", fn -> update_addresses_cache(inserted[:addresses]) end) + update_cache_safely("uncles", fn -> update_uncles_cache(inserted[:block_second_degree_relations]) end) + update_cache_safely("withdrawals", fn -> update_withdrawals_cache(inserted[:withdrawals]) end) + end + + defp update_cache_safely(cache_name, fun) do + fun.() + rescue + error -> + Logger.error(fn -> + ["Failed to update ", cache_name, " cache: ", Exception.format(:error, error, __STACKTRACE__)] + end) + catch + :exit, reason -> + Logger.error(fn -> ["Failed to update ", cache_name, " cache: ", inspect(reason)] end) + end + defp update_block_cache([], _), do: :ok defp update_block_cache(blocks, inserted) when is_list(blocks) do diff --git a/config/runtime.exs b/config/runtime.exs index 4c6e9ccb675..0c1217db415 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -679,6 +679,10 @@ config :explorer, Explorer.Chain.Cache.Uncles, ttl_check_interval: false, global_ttl: nil +config :explorer, Explorer.Chain.Cache.Propagator, + flush_interval: ConfigHelper.parse_time_env_var("CACHE_PROPAGATION_FLUSH_INTERVAL", "100ms"), + send_timeout: ConfigHelper.parse_time_env_var("CACHE_PROPAGATION_SEND_TIMEOUT", "30s") + celo_l2_migration_block = ConfigHelper.parse_integer_or_nil_env_var("CELO_L2_MIGRATION_BLOCK") celo_epoch_manager_contract_address = System.get_env("CELO_EPOCH_MANAGER_CONTRACT") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 68498355654..bed0c609861 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -167,6 +167,8 @@ RELEASE_LINK= # CONTRACT_CERTIFIED_LIST= # CONTRACT_ENABLE_PARTIAL_REVERIFICATION= # CONTRACT_PROXY_EMPTY_IMPLEMENTATION_DATA_CACHE_TTL=1d +# CACHE_PROPAGATION_FLUSH_INTERVAL=100ms +# CACHE_PROPAGATION_SEND_TIMEOUT=30s # UNCLES_IN_AVERAGE_BLOCK_TIME=false # DISABLE_BLOCK_BROADCAST_ENRICHMENT=false # BLOCK_BROADCAST_TYPE=block From cc165474168b943cb30a0abd6eff8afb505c1a89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:44:50 +0300 Subject: [PATCH 31/82] chore(deps): bump oban from 2.24.0 to 2.24.1 (#14791) 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 0d45756cf7f..2b0d5c12394 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.24.0", "cd877a089ead66658c40864fc616e90c75ddba95559fffae3fccaf05da58a4e5", [: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", "ab497b2be51191a2d9d7a1b267714463891be711b20487b7b65b5c2269e4b312"}, + "oban": {:hex, :oban, "2.24.1", "2a609c54697ad2c44ba339df30491df2a40eda0758c95b5b879426e4e478bd1f", [: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", "ef8482472cf198554400b7f8e36a0ffee75c3a64de425d5c7ee625d271925ac7"}, "open_api_spex": {:hex, :open_api_spex, "3.22.4", "00d8f32676b459080a860b81d777a8010e61dfa333f54175dcf51dd6f35a0fac", [: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", "f658d73396e2277e584085a6146481eb085d815a716089defe4f94b26cf8b05f"}, "optimal": {:hex, :optimal, "0.3.6", "46bbf52fbbbd238cda81e02560caa84f93a53c75620f1fe19e81e4ae7b07d1dd", [:mix], [], "hexpm", "1a06ea6a653120226b35b283a1cd10039550f2c566edcdec22b29316d73640fd"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, From d2a03c2b29ca19953e4b947a093304c2c13c700b Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:01:08 +0400 Subject: [PATCH 32/82] perf: Deduplicate preloads (#14797) --- .../api/v2/main_page_controller.ex | 43 ++++++++-- .../api/v2/transaction_controller.ex | 84 ++++++++++--------- .../lib/block_scout_web/notifier.ex | 74 ++++++++-------- .../api/v2/transaction_controller_test.exs | 17 ++++ .../chain/address/scam_badge_to_address.ex | 8 +- .../lib/explorer/chain/advanced_filter.ex | 23 ++++- .../lib/explorer/chain/token_transfer.ex | 55 ++++++++---- 7 files changed, 200 insertions(+), 104 deletions(-) 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 b79d0c2f9c3..bde0964e879 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 @@ -27,20 +27,31 @@ defmodule BlockScoutWeb.API.V2.MainPageController do @chain_type_transaction_necessity_by_association %{} end + # Address-info preloads for the `from`/`to`/`created_contract` participants are + # intentionally absent: `preload_transaction_participants/1` loads them for the + # whole page in a single deduplicated pass. @transactions_options [ necessity_by_association: - %{ - :block => :required, - [created_contract_address: [:scam_badge, :names, :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 - } + %{:block => :required} |> Map.merge(@chain_type_transaction_necessity_by_association), paging_options: %PagingOptions{page_size: 6}, api?: true ] + @transaction_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + + @transaction_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + proxy_implementations_association() => :optional + } + + @api_true [api?: true] + action_fallback(BlockScoutWeb.API.V2.FallbackController) plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) @@ -107,10 +118,24 @@ defmodule BlockScoutWeb.API.V2.MainPageController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: recent_transactions |> maybe_preload_ens_and_metadata(:transactions) + transactions: recent_transactions |> preload_transaction_participants() }) end + # Loads the address info of every participant on the page in one pass, + # deduplicating addresses shared between items and between roles of the same + # item. Runs before `maybe_preload_ens_and_metadata/2`, which writes ENS and + # metadata into the very address structs assigned here. + defp preload_transaction_participants(transactions) do + transactions + |> Chain.preload_address_participants( + @transaction_address_fields, + @transaction_participant_necessity_by_association, + @api_true + ) + |> maybe_preload_ens_and_metadata(:transactions) + end + operation :watchlist_transactions, summary: "Last 6 transactions from the current user's watchlist", description: "Retrieves a list of last 6 transactions from the current user's watchlist.", @@ -139,7 +164,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_participants(), watchlist_names: watchlist_names }) end 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 d25fb44e548..b24bfd79559 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 @@ -91,8 +91,8 @@ defmodule BlockScoutWeb.API.V2.TransactionController do # 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`. + # `Chain.preload_transaction_participants/3`, and the list actions via + # `Chain.preload_address_participants/4`. @transaction_necessity_by_association %{:block => :optional} |> Map.merge(@chain_type_transaction_necessity_by_association) @@ -104,6 +104,23 @@ defmodule BlockScoutWeb.API.V2.TransactionController do proxy_implementations_association() => :optional } + # The same associations for an item of a transaction *list*, where nothing + # renders the contract source, so the smart contract itself is not loaded. + # `:token` stays: `TransactionView.transaction_types/3` reports + # `token_creation` off `created_contract_address.token`. + @transaction_list_participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :token => :optional, + proxy_implementations_association() => :optional + } + + @transaction_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + @token_transfers_necessity_by_association %{ [token: reputation_association()] => :optional } @@ -255,7 +272,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do full_options = [ - necessity_by_association: transactions_necessity_by_association() + necessity_by_association: @transaction_necessity_by_association ] |> Keyword.merge(paging_options(params, filter_options)) |> Keyword.merge(method_filter_options(params)) @@ -271,38 +288,27 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_list_participants(), next_page_params: next_page_params }) end - defp transactions_necessity_by_association do - %{ - :block => :optional, - [ - created_contract_address: [ - :scam_badge, - :names, - :token, - proxy_implementations_association() - ] - ] => :optional, - [ - from_address: [ - :scam_badge, - :names, - proxy_implementations_association() - ] - ] => :optional, - [ - to_address: [ - :scam_badge, - :names, - proxy_implementations_association() - ] - ] => :optional - } - |> Map.merge(@chain_type_transaction_necessity_by_association) + # Loads the address info of every `from`/`to`/`created_contract` participant on + # the page in one pass, deduplicating addresses shared between items and + # between roles of the same item. Preloading it per role through + # `necessity_by_association` instead repeats the `address_names`, scam badge + # and proxy implementation queries once per role. + # + # Runs before `maybe_preload_ens_and_metadata/2`, which writes ENS and metadata + # into the very address structs assigned here. + defp preload_transaction_list_participants(transactions) do + transactions + |> Chain.preload_address_participants( + @transaction_address_fields, + @transaction_list_participant_necessity_by_association, + @api_true + ) + |> maybe_preload_ens_and_metadata(:transactions) end operation :zksync_batch, @@ -504,7 +510,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end query - |> Chain.join_associations(transactions_necessity_by_association()) + |> Chain.join_associations(@transaction_necessity_by_association) |> preload([{:token_transfers, [:token, :from_address, :to_address]}]) |> Repo.replica().all() end @@ -515,7 +521,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_list_participants(), next_page_params: next_page_params }) end @@ -538,7 +544,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do defp handle_batch_transactions(conn, %{batch_number_param: batch_number} = params, batch_transactions_fun) do full_options = [ - necessity_by_association: transactions_necessity_by_association() + necessity_by_association: @transaction_necessity_by_association ] |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) @@ -559,7 +565,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_list_participants(), next_page_params: next_page_params }) end @@ -591,7 +597,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do def execution_node(conn, %{execution_node_hash_param: execution_node_hash_string} = params) do with {:format, {:ok, execution_node_hash}} <- {:format, Chain.string_to_address_hash(execution_node_hash_string)} do full_options = - [necessity_by_association: transactions_necessity_by_association()] + [necessity_by_association: @transaction_necessity_by_association] |> Keyword.merge(put_key_value_to_paging_options(paging_options(params), :is_index_in_asc_order, true)) |> Keyword.merge(@api_true) @@ -606,7 +612,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_list_participants(), next_page_params: next_page_params }) end @@ -955,7 +961,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do with {:auth, %{watchlist_id: watchlist_id}} <- {:auth, current_user(conn)} do full_options = [ - necessity_by_association: transactions_necessity_by_association() + necessity_by_association: @transaction_necessity_by_association ] |> Keyword.merge(paging_options(params, [:validated])) |> Keyword.merge(@api_true) @@ -970,7 +976,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), + transactions: transactions |> preload_transaction_list_participants(), next_page_params: next_page_params, watchlist_names: watchlist_names }) diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index 2a6e2496349..d6840236b12 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -89,22 +89,26 @@ defmodule BlockScoutWeb.Notifier do @chain_type_transaction_associations [] end - @transaction_associations [ - from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], - to_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ], - created_contract_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ] - ] ++ - @chain_type_transaction_associations + # Address-info associations shared by every participant role of a broadcast + # item. Loaded once per broadcast batch by + # `Chain.preload_address_participants/4` rather than per role, which repeats + # each of these queries once per role. + @participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + proxy_implementations_association() => :optional + } + + @transaction_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + + @token_transfer_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] def handle_event({:chain_event, :addresses, type, addresses}) when type in [:realtime, :on_demand] do addresses_count = AddressesCount.fetch() @@ -304,21 +308,10 @@ defmodule BlockScoutWeb.Notifier do |> Repo.preload( DenormalizationHelper.extend_transaction_preload([ [token: Reputation.reputation_association()], - :transaction, - from_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ], - to_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ] + :transaction ]) ) + |> preload_participants(@token_transfer_address_fields) |> Instance.preload_nft(@api_true) broadcast_token_transfers_websocket_v2(all_token_transfers_full) @@ -796,7 +789,10 @@ defmodule BlockScoutWeb.Notifier do relevant_transactions -> prepared_transactions = TransactionView.render("transactions.json", %{ - transactions: Repo.preload(relevant_transactions, transaction_broadcast_associations()), + transactions: + relevant_transactions + |> Repo.preload(transaction_broadcast_associations()) + |> preload_participants(@transaction_address_fields), conn: nil }) @@ -818,7 +814,8 @@ defmodule BlockScoutWeb.Notifier do if relevant_transactions != [] do relevant_transactions - |> Repo.preload([:block | @transaction_associations]) + |> Repo.preload([:block | @chain_type_transaction_associations]) + |> preload_participants(@transaction_address_fields) |> Enum.map(fn transaction -> Map.put(transaction, :token_transfers, []) end) @@ -828,8 +825,19 @@ defmodule BlockScoutWeb.Notifier do defp transaction_broadcast_associations do if API_V2.enabled?(), - do: [:block, {:token_transfers, [token: Reputation.reputation_association()]} | @transaction_associations], - else: [:block | @transaction_associations] + do: [ + :block, + {:token_transfers, [token: Reputation.reputation_association()]} | @chain_type_transaction_associations + ], + else: [:block | @chain_type_transaction_associations] + end + + # Loads the address info of every participant of the broadcast batch in one + # pass, deduplicating addresses shared between items and between roles of the + # same item. Broadcasts run off the primary repo, as the preloads they replace + # did. + defp preload_participants(items, address_fields) do + Chain.preload_address_participants(items, address_fields, @participant_necessity_by_association, []) end defp broadcast_transaction(%Transaction{block_number: nil} = pending) 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 8c4338e39cf..4710a54773f 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 @@ -44,6 +44,23 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert response["next_page_params"] == nil end + # `token_creation` is reported off `created_contract_address.token`, which is + # only there while the participant preload keeps loading `:token`. + test "reports token_creation for a transaction that created a token", %{conn: conn} do + contract_address = insert(:contract_address) + insert(:token, contract_address: contract_address) + + :transaction + |> insert() + |> with_block() + |> with_contract_creation(contract_address) + + request = get(conn, "/api/v2/transactions") + + assert %{"items" => [item]} = json_response(request, 200) + assert "token_creation" in item["transaction_types"] + end + test "transactions with next_page_params", %{conn: conn} do transactions = 51 diff --git a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex index 5a9187d01d9..9031be23127 100644 --- a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex +++ b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex @@ -20,7 +20,13 @@ defmodule Explorer.Chain.Address.ScamBadgeToAddress do """ @primary_key false typed_schema "scam_address_badge_mappings" do - belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address, null: false) + belongs_to(:address, Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address, + null: false, + primary_key: true + ) timestamps() end diff --git a/apps/explorer/lib/explorer/chain/advanced_filter.ex b/apps/explorer/lib/explorer/chain/advanced_filter.ex index bab9581f309..37a9e9884b5 100644 --- a/apps/explorer/lib/explorer/chain/advanced_filter.ex +++ b/apps/explorer/lib/explorer/chain/advanced_filter.ex @@ -79,6 +79,21 @@ defmodule Explorer.Chain.AdvancedFilter do field(:token_transfer_batch_index, :integer, null: true) end + @address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ] + + # Address-info associations shared by every participant role of a filter row. + # Loaded once for the whole page by `Chain.preload_address_participants/4` + # rather than per role, which repeats each of these queries three times. + @participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + proxy_implementations_association() => :optional + } + @typep transaction_types :: {:transaction_types, [String.t()] | nil} @typep methods :: {:methods, [String.t()] | nil} @typep age :: {:age, [{:from, DateTime.t() | nil} | {:to, DateTime.t() | nil}] | nil} @@ -153,10 +168,10 @@ defmodule Explorer.Chain.AdvancedFilter do |> Enum.map(&to_advanced_filter/1) |> Enum.sort(&sort_function/2) |> take_page_size(paging_options) - |> Chain.select_repo(options).preload( - from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], - to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], - created_contract_address: [:names, :smart_contract, proxy_implementations_association()] + |> Chain.preload_address_participants( + @address_fields, + @participant_necessity_by_association, + options ) |> sanitize_fee() |> assign_type() diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index fa1d785f00d..2e34fb99a03 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -158,6 +158,17 @@ defmodule Explorer.Chain.TokenTransfer do @default_paging_options %PagingOptions{page_size: 50} + @participant_address_fields [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address} + ] + + @participant_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + Implementation.proxy_implementations_association() => :optional + } + @typep paging_options :: {:paging_options, PagingOptions.t()} @typep api? :: {:api?, true | false} @@ -301,23 +312,16 @@ defmodule Explorer.Chain.TokenTransfer do [] _ -> - preloads = - DenormalizationHelper.extend_transaction_preload([ - :transaction, - [token: reputation_association()], - [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], - [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] - ]) - only_consensus_transfers_query() |> where([tt], tt.token_contract_address_hash == ^token_address_hash) |> where([tt], fragment("? @> ARRAY[?::decimal]", tt.token_ids, ^Decimal.new(token_id))) |> where([tt], not is_nil(tt.block_number)) - |> preload(^preloads) + |> preload(^non_address_preloads()) |> order_by([tt], desc: tt.block_number, desc: tt.log_index) |> page_token_transfer(paging_options) |> limit(^paging_options.page_size) |> Chain.select_repo(options).all() + |> preload_participants(options) end end @@ -334,25 +338,40 @@ defmodule Explorer.Chain.TokenTransfer do [] _ -> - preloads = - DenormalizationHelper.extend_transaction_preload([ - :transaction, - [token: reputation_association()], - [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], - [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] - ]) - only_consensus_transfers_query() - |> preload(^preloads) + |> preload(^non_address_preloads()) |> order_by([tt], desc: tt.block_number, desc: tt.log_index) |> maybe_filter_by_token_type(token_type) |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> page_token_transfer(paging_options) |> limit(^paging_options.page_size) |> Chain.select_repo(options).all() + |> preload_participants(options) end end + # Everything but the `from`/`to` addresses, which `preload_participants/2` + # loads afterwards. + defp non_address_preloads do + DenormalizationHelper.extend_transaction_preload([ + :transaction, + [token: reputation_association()] + ]) + end + + # Loads the address info of every `from`/`to` participant of the page in one + # pass, deduplicating addresses shared between transfers and between the two + # roles of the same transfer. Preloading it per role instead repeats the + # `addresses` query and every one of these associations twice. + defp preload_participants(token_transfers, options) do + Chain.preload_address_participants( + token_transfers, + @participant_address_fields, + @participant_necessity_by_association, + options + ) + end + @doc """ Conditionally filters token transfers by token type based on denormalization status. From 86724dc6f83f65e2d801b6db9c54a868a50ac60c Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Thu, 10 Sep 2026 13:14:37 +0300 Subject: [PATCH 33/82] feat: support OP Stack post-exec transactions (#14734) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Victor Baranov --- .../views/api/v2/transaction_view.ex | 34 +++++++++- .../block_scout_web/views/transaction_view.ex | 10 +++ apps/block_scout_web/priv/gettext/default.pot | 10 +++ .../priv/gettext/en/LC_MESSAGES/default.po | 10 +++ .../views/api/v2/transaction_view_test.exs | 21 +++++- .../views/transaction_view_test.exs | 18 +++++ .../lib/ethereum_jsonrpc/transaction.ex | 29 ++++++++ .../test/ethereum_jsonrpc/receipt_test.exs | 26 +++++++ .../ethereum_jsonrpc/transaction_test.exs | 68 +++++++++++++++++++ 9 files changed, 224 insertions(+), 2 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 14418b66410..f61f9519ef7 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 @@ -805,7 +805,39 @@ defmodule BlockScoutWeb.API.V2.TransactionView do | :blob_transaction | :set_code_transaction | :sponsored_transaction - def transaction_types(transaction, types \\ [], stage \\ :sponsored_transaction) + | :op_stack_l1_attributes_transaction + | :op_stack_post_exec_transaction + def transaction_types(transaction, types \\ [], stage \\ :op_stack_l1_attributes_transaction) + + def transaction_types( + %Transaction{type: type, index: index} = transaction, + types, + :op_stack_l1_attributes_transaction + ) do + types = + if chain_type() == :optimism and type == 0x7E and index == 0 do + [:op_stack_l1_attributes_transaction | types] + else + types + end + + transaction_types(transaction, types, :op_stack_post_exec_transaction) + end + + def transaction_types( + %Transaction{type: type} = transaction, + types, + :op_stack_post_exec_transaction + ) do + types = + if chain_type() == :optimism and type == 0x7D do + [:op_stack_post_exec_transaction | types] + else + types + end + + transaction_types(transaction, types, :sponsored_transaction) + end def transaction_types(%Transaction{type: type} = transaction, types, :sponsored_transaction) do # Eden sponsored (batched) transaction type diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex index 158186b8f00..9d10b638fd4 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex @@ -2,6 +2,8 @@ defmodule BlockScoutWeb.TransactionView do use BlockScoutWeb, :view + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] + alias BlockScoutWeb.{AccessHelper, AddressView, BlockView, TabHelper} alias BlockScoutWeb.Account.AuthController alias BlockScoutWeb.Cldr.Number @@ -478,6 +480,14 @@ defmodule BlockScoutWeb.TransactionView do def to_address_hash(%Transaction{to_address_hash: address_hash}), do: address_hash def transaction_display_type(%Transaction{} = transaction) do + case {chain_type(), transaction.type, transaction.index} do + {:optimism, 0x7E, 0} -> gettext("L1 attr info tx") + {:optimism, 0x7D, _} -> gettext("Post exec tx") + _ -> default_transaction_display_type(transaction) + end + end + + defp default_transaction_display_type(transaction) do cond do involves_token_transfers?(transaction) -> token_transfer_type = get_transaction_type_from_token_transfers(transaction.token_transfers) diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index 67fde89acea..f7709985ed2 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -1635,6 +1635,11 @@ msgstr "" msgid "L1 Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:484 +#, elixir-autogen, elixir-format +msgid "L1 attr info tx" +msgstr "" + #: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format msgid "L2 Gas Limit" @@ -2149,6 +2154,11 @@ msgstr "" msgid "Position %{index}" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:485 +#, elixir-autogen, elixir-format +msgid "Post exec tx" +msgstr "" + #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #, elixir-autogen, elixir-format msgid "Potential matches from contract method database:" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index 2221ff8ea4f..aeb47bad4c5 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -1635,6 +1635,11 @@ msgstr "" msgid "L1 Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:484 +#, elixir-autogen, elixir-format +msgid "L1 attr info tx" +msgstr "" + #: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format, fuzzy msgid "L2 Gas Limit" @@ -2149,6 +2154,11 @@ msgstr "" msgid "Position %{index}" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:485 +#, elixir-autogen, elixir-format +msgid "Post exec tx" +msgstr "" + #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #, elixir-autogen, elixir-format msgid "Potential matches from contract method database:" diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs index 04109c83143..2b60994e1c6 100644 --- a/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs @@ -6,7 +6,7 @@ defmodule BlockScoutWeb.API.V2.TransactionViewTest do alias BlockScoutWeb.API.V2.TransactionView alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.Chain.Transaction + alias Explorer.Chain.{Transaction, Wei} alias Explorer.Market.MarketHistory alias Explorer.Repo @@ -25,6 +25,25 @@ defmodule BlockScoutWeb.API.V2.TransactionViewTest do @tuple_value {"", "", "", "", ""} @tuple_json ["", "", "", "", ""] + describe "OP Stack transaction types" do + test "labels L1 attributes and PostExec transactions only on OP Stack chains" do + value = %Wei{value: Decimal.new(0)} + l1_attributes = build(:transaction, type: 0x7E, index: 0, value: value) + post_exec = build(:transaction, type: 0x7D, index: 1, value: value) + + if Application.get_env(:explorer, :chain_type) == :optimism do + assert :op_stack_l1_attributes_transaction in TransactionView.transaction_types(l1_attributes) + assert :op_stack_post_exec_transaction in TransactionView.transaction_types(post_exec) + else + refute :op_stack_l1_attributes_transaction in TransactionView.transaction_types(l1_attributes) + refute :op_stack_post_exec_transaction in TransactionView.transaction_types(post_exec) + end + + refute :op_stack_l1_attributes_transaction in TransactionView.transaction_types(%{l1_attributes | index: 1}) + refute :op_stack_l1_attributes_transaction in TransactionView.transaction_types(%{l1_attributes | index: nil}) + end + end + test "loads historic exchange rates once for a transaction list", %{conn: conn} do first_date = ~D[2026-07-20] second_date = ~D[2026-07-21] diff --git a/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs index e1b5a1a10e4..978e31ca392 100644 --- a/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs @@ -8,6 +8,24 @@ defmodule BlockScoutWeb.TransactionViewTest do alias Explorer.Repo alias BlockScoutWeb.{BlockView, TransactionView} + describe "transaction_display_type/1" do + test "labels L1 attributes and PostExec transactions only on OP Stack chains" do + l1_attributes = build(:transaction, type: 0x7E, index: 0) + post_exec = build(:transaction, type: 0x7D, index: 1) + + if Application.get_env(:explorer, :chain_type) == :optimism do + assert TransactionView.transaction_display_type(l1_attributes) == "L1 attr info tx" + assert TransactionView.transaction_display_type(post_exec) == "Post exec tx" + else + refute TransactionView.transaction_display_type(l1_attributes) == "L1 attr info tx" + refute TransactionView.transaction_display_type(post_exec) == "Post exec tx" + end + + refute TransactionView.transaction_display_type(%{l1_attributes | index: 1}) == "L1 attr info tx" + refute TransactionView.transaction_display_type(%{l1_attributes | index: nil}) == "L1 attr info tx" + end + end + describe "block_number/1" do test "returns pending text for pending transaction" do pending = insert(:transaction) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex index 681d801e690..ccd8304b37c 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex @@ -30,6 +30,9 @@ defmodule EthereumJSONRPC.Transaction do # EIP-2718 type of the Eden sponsored (batched) transaction: `0x76`. @eden_sponsored_transaction_type 118 + @post_exec_tx_type 0x7D + @zero_address_hash_string "0x0000000000000000000000000000000000000000" + case @chain_type do :ethereum -> @chain_type_fields quote( @@ -571,10 +574,36 @@ defmodule EthereumJSONRPC.Transaction do defp chain_type_normalization(elixir) do case chain_type() do :eden -> eden_compatibility_fields(elixir) + :optimism -> optimism_compatibility_fields(elixir) _ -> elixir end end + # OP Stack post-exec transactions only carry post-execution metadata. Add compatibility values + # for fields required by the standard transaction parser while retaining the block context + # supplied by the execution client. + @spec optimism_compatibility_fields(%{String.t() => any()}) :: %{String.t() => any()} + defp optimism_compatibility_fields(%{"type" => @post_exec_tx_type} = elixir) do + Enum.reduce( + %{ + "from" => @zero_address_hash_string, + "gas" => 0, + "gasPrice" => 0, + "nonce" => 0, + "r" => 0, + "s" => 0, + "v" => 0, + "value" => 0 + }, + elixir, + fn {key, default}, transaction -> + if Map.get(transaction, key), do: transaction, else: Map.put(transaction, key, default) + end + ) + end + + defp optimism_compatibility_fields(elixir), do: elixir + # Eden sponsored transactions (EIP-2718 type `0x76`) carry `gasLimit` and an ordered `calls` # array instead of the standard `gas`, `to`, `input` and `value` fields. The first call provides # the compatibility values for `to` and `input`, while `value` is the sum across all the calls. diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs index 033f2b4f71a..7ed40b8ddfc 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs @@ -28,4 +28,30 @@ defmodule EthereumJSONRPC.ReceiptTest do "blockNumber" => nil } end + + test "converts an OP Stack PostExec receipt into collated transaction fields" do + receipt = + Receipt.to_elixir(%{ + "blockHash" => "0xab6dfcf5ad132602e939db5f84f56945b1be4e136daab002f9bf9ff0c7f9f7a5", + "blockNumber" => "0x1b", + "contractAddress" => nil, + "cumulativeGasUsed" => "0x5208", + "effectiveGasPrice" => "0x0", + "gasUsed" => "0x0", + "logs" => [], + "logsBloom" => "0x" <> String.duplicate("0", 512), + "status" => "0x1", + "transactionHash" => "0x39ee8fea8d4e719a75a5d64a4d5e34bdbd62f2543c88d00d0f00f91c8f1d8a20", + "transactionIndex" => "0x11", + "type" => "0x7d" + }) + + assert %{ + cumulative_gas_used: 21_000, + gas_used: 0, + status: :ok, + transaction_hash: "0x39ee8fea8d4e719a75a5d64a4d5e34bdbd62f2543c88d00d0f00f91c8f1d8a20", + transaction_index: 17 + } = Receipt.elixir_to_params(receipt) + end end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs index 7c75fa187e3..2e21e6bb5c0 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs @@ -13,4 +13,72 @@ defmodule EthereumJSONRPC.TransactionTest do assert %{ignore: :ignore} = Transaction.to_elixir(map) end end + + describe "elixir_to_params/1" do + test "adds compatibility defaults to minimal PostExec transactions only on OP Stack chains" do + transaction = %{ + "blockHash" => "0xab6dfcf5ad132602e939db5f84f56945b1be4e136daab002f9bf9ff0c7f9f7a5", + "blockNumber" => 27, + "gas" => 0, + "hash" => "0x39ee8fea8d4e719a75a5d64a4d5e34bdbd62f2543c88d00d0f00f91c8f1d8a20", + "input" => "0xc3011b80", + "transactionIndex" => 17, + "type" => 0x7D, + "value" => 0 + } + + if Application.get_env(:explorer, :chain_type) == :optimism do + assert Transaction.elixir_to_params(transaction) == %{ + block_hash: "0xab6dfcf5ad132602e939db5f84f56945b1be4e136daab002f9bf9ff0c7f9f7a5", + block_number: 27, + from_address_hash: "0x0000000000000000000000000000000000000000", + gas: 0, + gas_price: 0, + hash: "0x39ee8fea8d4e719a75a5d64a4d5e34bdbd62f2543c88d00d0f00f91c8f1d8a20", + index: 17, + input: "0xc3011b80", + nonce: 0, + r: 0, + s: 0, + to_address_hash: nil, + transaction_index: 17, + type: 0x7D, + v: 0, + value: 0 + } + else + assert_raise FunctionClauseError, fn -> Transaction.elixir_to_params(transaction) end + end + end + + test "preserves standard fields for type 0x7D transactions" do + authorization_list = [%{"address" => "0x0000000000000000000000000000000000000001", "nonce" => 1}] + + assert %{ + created_contract_address_hash: "0x0000000000000000000000000000000000000002", + from_address_hash: "0x0000000000000000000000000000000000000003", + max_fee_per_gas: 30, + max_priority_fee_per_gas: 2, + authorization_list: ^authorization_list, + type: 0x7D + } = + Transaction.elixir_to_params(%{ + "authorizationList" => authorization_list, + "blockHash" => "0xab6dfcf5ad132602e939db5f84f56945b1be4e136daab002f9bf9ff0c7f9f7a5", + "blockNumber" => 27, + "creates" => "0x0000000000000000000000000000000000000002", + "from" => "0x0000000000000000000000000000000000000003", + "gas" => 21_000, + "gasPrice" => 10, + "hash" => "0x39ee8fea8d4e719a75a5d64a4d5e34bdbd62f2543c88d00d0f00f91c8f1d8a20", + "input" => "0x", + "maxFeePerGas" => 30, + "maxPriorityFeePerGas" => 2, + "nonce" => 1, + "transactionIndex" => 17, + "type" => 0x7D, + "value" => 1 + }) + end + end end From e1adb829b388cfd6e9b16d4706fff42d3fc27d20 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:43:08 +0400 Subject: [PATCH 34/82] fix: Group JSON RPC requests by url type (#14806) --- .../lib/ethereum_jsonrpc/http.ex | 95 ++++++++++++++----- .../ethereum_jsonrpc/request_coordinator.ex | 39 +++++--- .../lib/ethereum_jsonrpc/transport.ex | 4 + .../test/ethereum_jsonrpc/http/mox_test.exs | 80 ++++++++++++++++ apps/explorer/lib/explorer/eth_rpc.ex | 30 +++--- apps/explorer/test/explorer/eth_rpc_test.exs | 77 +++++++++++++++ cspell.json | 1 + 7 files changed, 276 insertions(+), 50 deletions(-) create mode 100644 apps/explorer/test/explorer/eth_rpc_test.exs diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex index f169aab08f9..8358bcb8c41 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex @@ -25,7 +25,8 @@ defmodule EthereumJSONRPC.HTTP do def json_rpc(%{method: method} = request, options) when is_map(request) do json = encode_json(request) http = Keyword.fetch!(options, :http) - {url_type, url} = url(options, method) + url_type = url_type(options, method) + url = CommonHelper.get_available_url(options, url_type) http_options = Keyword.fetch!(options, :http_options) with {:ok, %{body: body, status_code: code}} <- http.json_rpc(url, json, headers(), http_options), @@ -41,25 +42,75 @@ defmodule EthereumJSONRPC.HTTP do end def json_rpc([batch | _] = chunked_batch_request, options) when is_list(batch) do - chunked_json_rpc(chunked_batch_request, options, []) + chunked_batch_request + |> Enum.flat_map(&group_by_url_type(&1, options)) + |> chunked_json_rpc(options, []) end def json_rpc(batch_request, options) when is_list(batch_request) do batch_size = Application.get_env(:ethereum_jsonrpc, __MODULE__)[:batch_size] - chunked_batch_request = Enum.chunk_every(batch_request, batch_size) - maybe_log_big_batch(chunked_batch_request) + maybe_log_big_batch(batch_request, batch_size) + + chunked_batch_request = + batch_request + |> group_by_url_type(options) + |> Enum.flat_map(fn {url_type, requests} -> + requests |> Enum.chunk_every(batch_size) |> Enum.map(&{url_type, &1}) + end) + chunked_json_rpc(chunked_batch_request, options, []) end - defp maybe_log_big_batch([]), do: :ok - defp maybe_log_big_batch([_]), do: :ok + # Requests within a single batch can be mapped to different url types (e.g. `eth_call` + # requests are sent to `eth_call_urls`), so the batch has to be split by the url type + # its methods are mapped to. Url types that are configured with the same urls are kept + # together in order to not send redundant requests. + # + # Note that this reorders the requests, so the responses of a batch are not returned in + # the order of the requests and have to be matched by their id (see + # `t:EthereumJSONRPC.Transport.batch_response/0`). + @spec group_by_url_type([Transport.request()], Keyword.t()) :: [{atom(), [Transport.request()]}] + defp group_by_url_type(requests, options) do + requests + |> Enum.group_by(& &1[:method]) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.reduce([], fn {method, method_requests}, acc -> + merge_url_type_requests(url_type(options, method), method_requests, acc, options) + end) + |> Enum.map(fn {_urls, url_type, grouped_requests} -> {url_type, grouped_requests} end) + end + + defp merge_url_type_requests(url_type, requests, acc, options) do + urls = { + CommonHelper.url_type_to_urls(url_type, options), + CommonHelper.url_type_to_urls(url_type, options, :fallback) + } + + case List.keyfind(acc, urls, 0) do + nil -> + acc ++ [{urls, url_type, requests}] - defp maybe_log_big_batch([first_chunk | _] = batch) do - Logger.warning( - "Big amount of node requests in batch: #{batch |> Enum.map(&length/1) |> Enum.sum()}, 1st_chunk_1st_request: #{inspect(List.first(first_chunk))}" - ) + {_urls, existing_url_type, existing_requests} -> + # `:http` is preferred as the representative of a merged group, so that endpoint + # availability is tracked under the type these urls are primarily configured for + merged_url_type = if :http in [existing_url_type, url_type], do: :http, else: existing_url_type + + List.keyreplace(acc, urls, 0, {urls, merged_url_type, existing_requests ++ requests}) + end end + defp maybe_log_big_batch(batch_request, batch_size) do + count = Enum.count(batch_request) + + if count > batch_size do + Logger.warning( + "Big amount of node requests in batch: #{count}, 1st_chunk_1st_request: #{inspect(List.first(batch_request))}" + ) + end + end + + # An empty batch produces no chunks, which matches the JSONRPC 2.0 standard saying that an empty batch (`[]`) returns + # an empty response (`""`): an empty response isn't valid JSON, so instead act like it returns an empty list (`[]`) defp chunked_json_rpc([], _options, decoded_response_bodies) when is_list(decoded_response_bodies) do list = decoded_response_bodies @@ -70,16 +121,10 @@ defmodule EthereumJSONRPC.HTTP do {:ok, list} end - # JSONRPC 2.0 standard says that an empty batch (`[]`) returns an empty response (`""`), but an empty response isn't - # valid JSON, so instead act like it returns an empty list (`[]`) - defp chunked_json_rpc([[] | tail], options, decoded_response_bodies) do - chunked_json_rpc(tail, options, decoded_response_bodies) - end - - defp chunked_json_rpc([[%{method: method} | _] = batch | tail] = chunks, options, decoded_response_bodies) + defp chunked_json_rpc([{url_type, batch} | tail] = chunks, options, decoded_response_bodies) when is_list(tail) and is_list(decoded_response_bodies) do http = Keyword.fetch!(options, :http) - {url_type, url} = url(options, method) + url = CommonHelper.get_available_url(options, url_type) http_options = Keyword.fetch!(options, :http_options) json = encode_json(batch) @@ -110,7 +155,7 @@ defmodule EthereumJSONRPC.HTTP do end end - defp rechunk_json_rpc([batch | tail], options, response, decoded_response_bodies) do + defp rechunk_json_rpc([{url_type, batch} | tail], options, response, decoded_response_bodies) do case length(batch) do # it can't be made any smaller 1 -> @@ -134,7 +179,7 @@ defmodule EthereumJSONRPC.HTTP do batch_size -> split_size = div(batch_size, 2) {first_chunk, second_chunk} = Enum.split(batch, split_size) - new_chunks = [first_chunk, second_chunk | tail] + new_chunks = [{url_type, first_chunk}, {url_type, second_chunk} | tail] chunked_json_rpc(new_chunks, options, decoded_response_bodies) end end @@ -260,17 +305,19 @@ defmodule EthereumJSONRPC.HTTP do end end - defp url(options, method) when is_list(options) and is_binary(method) do + @spec url_type(Keyword.t(), String.t() | nil) :: atom() + defp url_type(options, method) when is_list(options) and is_binary(method) do with {:ok, method_to_url} <- Keyword.fetch(options, :method_to_url), {:ok, method_atom} <- to_existing_atom(method), {:ok, url_type} <- Keyword.fetch(method_to_url, method_atom) do - {url_type, CommonHelper.get_available_url(options, url_type)} + url_type else - _ -> - {:http, CommonHelper.get_available_url(options, :http)} + _ -> :http end end + defp url_type(_options, _method), do: :http + defp to_existing_atom(string) do {:ok, String.to_existing_atom(string)} rescue diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex index aac43018a75..56da6eb3c1d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex @@ -74,9 +74,9 @@ defmodule EthereumJSONRPC.RequestCoordinator do @spec perform(Transport.batch_request(), Transport.t(), Transport.options(), non_neg_integer()) :: {:ok, Transport.batch_response()} | {:error, term()} def perform(request, transport, transport_options, throttle_timeout) do - request_method = request_method(request) + request_methods = request_methods(request) - sleep_time = sleep_time(request_method) + sleep_time = sleep_time(request_methods) if sleep_time <= throttle_timeout do :timer.sleep(sleep_time) @@ -88,7 +88,7 @@ defmodule EthereumJSONRPC.RequestCoordinator do trace_request(request, fn -> request |> transport.json_rpc(transport_options) - |> handle_transport_response(request_method) + |> handle_transport_response(request_methods) end) :error -> @@ -113,24 +113,29 @@ defmodule EthereumJSONRPC.RequestCoordinator do defp trace_request(_, fun), do: fun.() - defp request_method([request | _]), do: request_method(request) - defp request_method(%{method: method}), do: method - defp request_method(_), do: nil + defp request_methods(requests) when is_list(requests) do + requests + |> Enum.flat_map(&request_methods/1) + |> Enum.uniq() + end + + defp request_methods(%{method: method}), do: [method] + defp request_methods(_), do: [] - defp handle_transport_response({:error, {error_type, _}} = error, method) + defp handle_transport_response({:error, {error_type, _}} = error, methods) when error_type in [:bad_gateway, :bad_response] do - RollingWindow.inc(table(), method_error_key(method)) + inc_methods_error_count(methods) inc_throttle_table() error end - defp handle_transport_response({:error, :timeout} = error, method) do - RollingWindow.inc(table(), method_error_key(method)) + defp handle_transport_response({:error, :timeout} = error, methods) do + inc_methods_error_count(methods) inc_throttle_table() error end - defp handle_transport_response(response, _method) do + defp handle_transport_response(response, _methods) do inc_throttle_table() response end @@ -162,8 +167,16 @@ defmodule EthereumJSONRPC.RequestCoordinator do end end - defp sleep_time(request_method) do - wait_coefficient = RollingWindow.count(table(), method_error_key(request_method)) + defp inc_methods_error_count(methods) do + Enum.each(methods, &RollingWindow.inc(table(), method_error_key(&1))) + end + + defp sleep_time(request_methods) do + wait_coefficient = + Enum.reduce(request_methods, 0, fn request_method, acc -> + max(acc, RollingWindow.count(table(), method_error_key(request_method))) + end) + jitter = :rand.uniform(config!(:max_jitter)) wait_per_timeout = config!(:wait_per_timeout) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex index 3f8e38b75d5..28357c3b4fe 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex @@ -69,6 +69,10 @@ defmodule EthereumJSONRPC.Transport do @typedoc """ A batch of `t:response/0`. Each `t:response/0` will have an `"id"` corresponding to the `"id"` in the `t:request/0`. + + The order of the responses is **not** guaranteed to match the order of the requests: the + [JSONRPC specification](https://www.jsonrpc.org/specification#batch) allows a server to respond in any order and + `EthereumJSONRPC.HTTP` can split a batch into several requests. Callers must match responses to requests by `"id"`. """ @type batch_response :: [response] diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs index ed0a2dc39a0..bdf832ab7ef 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs @@ -344,6 +344,86 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do assert log =~ "Big amount of node requests in batch: 10, 1st_chunk_1st_request: %{" end + + test "sends requests of a batch to the urls their methods are mapped to" do + json_rpc_named_arguments = method_to_url_named_arguments(["http://storage.url"], ["http://eth-call.url"]) + + expect(EthereumJSONRPC.HTTP.Mox, :json_rpc, 2, fn url, json, _headers, _options -> + requests = decode_requests(json) + + case url do + "http://eth-call.url" -> assert Enum.map(requests, & &1["method"]) == ["eth_call"] + "http://storage.url" -> assert Enum.map(requests, & &1["method"]) == ["eth_getStorageAt", "eth_getStorageAt"] + end + + {:ok, %{body: encode_results(requests), status_code: 200}} + end) + + assert {:ok, responses} = EthereumJSONRPC.json_rpc(mixed_batch_request(), json_rpc_named_arguments) + assert_results_match_ids(responses) + end + + test "keeps requests in a single batch when their url types are configured with the same urls" do + json_rpc_named_arguments = method_to_url_named_arguments([url()], [url()]) + + expect(EthereumJSONRPC.HTTP.Mox, :json_rpc, 1, fn _url, json, _headers, _options -> + requests = decode_requests(json) + + assert Enum.map(requests, & &1["method"]) == ["eth_call", "eth_getStorageAt", "eth_getStorageAt"] + + {:ok, %{body: encode_results(requests), status_code: 200}} + end) + + assert {:ok, responses} = EthereumJSONRPC.json_rpc(mixed_batch_request(), json_rpc_named_arguments) + assert_results_match_ids(responses) + end + end + + # Batch responses are not returned in the order of the requests, so the only guarantee + # is that every request gets its own result back under its own id + defp assert_results_match_ids(responses) do + assert Map.new(responses, &{&1.id, &1.result}) == %{ + 0 => "0x0", + 1 => "0x1", + 2 => "0x2" + } + end + + defp method_to_url_named_arguments(urls, eth_call_urls) do + [ + transport: EthereumJSONRPC.HTTP, + transport_options: [ + http: EthereumJSONRPC.HTTP.Mox, + urls: urls, + eth_call_urls: eth_call_urls, + fallback_urls: nil, + fallback_eth_call_urls: nil, + method_to_url: [eth_call: :eth_call], + http_options: http_options() + ], + # Which one does not matter, so pick one + variant: EthereumJSONRPC.Nethermind + ] + end + + defp mixed_batch_request do + [ + request(%{id: 0, method: "eth_getStorageAt", params: ["0x0", "0x0", "latest"]}), + request(%{id: 1, method: "eth_call", params: [%{to: "0x0", data: "0x0"}, "latest"]}), + request(%{id: 2, method: "eth_getStorageAt", params: ["0x0", "0x1", "latest"]}) + ] + end + + defp decode_requests(json) do + json |> IO.iodata_to_binary() |> Jason.decode!() + end + + # the result is derived from the request id, so that a response can be traced back to the + # request it belongs to + defp encode_results(requests) do + requests + |> Enum.map(&%{jsonrpc: "2.0", id: &1["id"], result: "0x#{&1["id"]}"}) + |> Jason.encode!() end defp assert_payload_too_large(payload, json_rpc_named_arguments) do diff --git a/apps/explorer/lib/explorer/eth_rpc.ex b/apps/explorer/lib/explorer/eth_rpc.ex index 1f5da9b0cb7..ad222a21ae1 100644 --- a/apps/explorer/lib/explorer/eth_rpc.ex +++ b/apps/explorer/lib/explorer/eth_rpc.ex @@ -659,6 +659,7 @@ defmodule Explorer.EthRPC do } @incorrect_number_of_params "Incorrect number of params." + @no_result "No result" @spec responses([map()]) :: [map()] def responses(requests) do @@ -729,29 +730,32 @@ defmodule Explorer.EthRPC do end) end + # Responses of a batch request are not guaranteed to be returned in the order of the + # requests, so they are matched by id. The index of the request is used as the id sent + # to the node, since the id provided by the user can be duplicated or nil. The user's + # id is attached to the response by `responses/1` anyway. defp json_rpc(map) when is_map(map) do to_request = - Enum.flat_map(Map.values(map), fn - {:error, _} -> + Enum.flat_map(map, fn + {_index, {:error, _}} -> [] - map when is_map(map) -> - [request_to_elixir(map)] + {index, request} when is_map(request) -> + [request |> request_to_elixir() |> Map.put(:id, index)] end) - with [_ | _] = to_request <- to_request, + with [_ | _] <- to_request, {:ok, responses} <- EthereumJSONRPC.json_rpc(to_request, Application.get_env(:explorer, :json_rpc_named_arguments)) do - {map, []} = - Enum.map_reduce(map, responses, fn - {_index, {:error, _}} = elem, responses -> - {elem, responses} + index_to_response = Map.new(responses, &{&1.id, &1}) - {index, _request}, [response | other_responses] -> - {{index, response}, other_responses} - end) + Map.new(map, fn + {_index, {:error, _}} = elem -> + elem - Enum.into(map, %{}) + {index, _request} -> + {index, Map.get(index_to_response, index, {:error, @no_result})} + end) else [] -> map diff --git a/apps/explorer/test/explorer/eth_rpc_test.exs b/apps/explorer/test/explorer/eth_rpc_test.exs new file mode 100644 index 00000000000..52973c170f5 --- /dev/null +++ b/apps/explorer/test/explorer/eth_rpc_test.exs @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.EthRPCTest do + # the tested requests are proxied to the node, so no database is involved + use ExUnit.Case, async: false + + import Mox + + alias Explorer.EthRPC + + setup :verify_on_exit! + setup :set_mox_global + + @address_hash "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + + describe "responses/1" do + test "matches responses of a batch to the requests by id, not by their order" do + requests = [ + %{ + "jsonrpc" => "2.0", + "id" => "storage", + "method" => "eth_getStorageAt", + "params" => [@address_hash, "0x0", "latest"] + }, + %{ + "jsonrpc" => "2.0", + "id" => "call", + "method" => "eth_call", + "params" => [%{"to" => @address_hash, "input" => "0xd4aae0c4"}, "latest"] + } + ] + + # the node is free to respond in any order, and `EthereumJSONRPC.HTTP` splits a batch + # by the url type its methods are mapped to, so the responses are reversed here + expect(EthereumJSONRPC.Mox, :json_rpc, fn batch, _options -> + assert [%{method: "eth_getStorageAt"}, %{method: "eth_call"}] = batch + + responses = + Enum.map(batch, fn + %{id: id, method: "eth_getStorageAt"} -> %{jsonrpc: "2.0", id: id, result: "0x123"} + %{id: id, method: "eth_call"} -> %{jsonrpc: "2.0", id: id, result: "0x234"} + end) + + {:ok, Enum.reverse(responses)} + end) + + assert [ + %{id: "storage", result: "0x123"}, + %{id: "call", result: "0x234"} + ] = EthRPC.responses(requests) + end + + test "matches responses of a batch with duplicated user ids" do + request = fn id, storage_pointer -> + %{ + "jsonrpc" => "2.0", + "id" => id, + "method" => "eth_getStorageAt", + "params" => [@address_hash, storage_pointer, "latest"] + } + end + + expect(EthereumJSONRPC.Mox, :json_rpc, fn batch, _options -> + responses = + Enum.map(batch, fn %{id: id, params: [_, storage_pointer, _]} -> + %{jsonrpc: "2.0", id: id, result: storage_pointer} + end) + + {:ok, Enum.reverse(responses)} + end) + + assert [ + %{id: 1, result: "0x0"}, + %{id: 1, result: "0x1"} + ] = EthRPC.responses([request.(1, "0x0"), request.(1, "0x1")]) + end + end +end diff --git a/cspell.json b/cspell.json index 6fdccc2ddc9..e336bd03db6 100644 --- a/cspell.json +++ b/cspell.json @@ -385,6 +385,7 @@ "keyfind", "keymember", "keyout", + "keyreplace", "kittencream", "KnxbUejwY", "labelledby", From 5eef09c7e9cdd3df6002a17a33d7386b639a9b97 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 10 Sep 2026 15:47:15 +0300 Subject: [PATCH 35/82] v11.3.0 --- .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 +- .../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 +- ...ocker-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 | 33 +++++++++++++++++++ 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, 89 insertions(+), 56 deletions(-) diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index 867b724072c..a606a14d9c9 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.8 + RELEASE_VERSION: 11.3.0 jobs: matrix-builder: diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index c634c0a9c9e..6a9af42927d 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.8 + RELEASE_VERSION: 11.3.0 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 7c367402fb4..1d1ab8ef114 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.8 + RELEASE_VERSION: 11.3.0 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 dbe00a95596..bd31991cec1 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.8 + RELEASE_VERSION: 11.3.0 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 e2dfa5c79b9..1cf3e9dcbad 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.8 + RELEASE_VERSION: 11.3.0 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 87a39303e5f..dda754e3bbc 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.8 + RELEASE_VERSION: 11.3.0 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 9c9b9a28599..a54d79fe0bf 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.8 + RELEASE_VERSION: 11.3.0 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 c79c0c0f405..792aa183b16 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.8 + RELEASE_VERSION: 11.3.0 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 3cf6e91c009..547c239a13e 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.8 + RELEASE_VERSION: 11.3.0 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 aaf92953cbc..a713602d9e0 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.8 + RELEASE_VERSION: 11.3.0 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 f0dec4d685a..a1e2a40d0f2 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.8 + RELEASE_VERSION: 11.3.0 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 f553925c55f..204364ac65f 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.8 + RELEASE_VERSION: 11.3.0 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 a07fd848e32..ef057c79c82 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index c88c8e00ed6..770af881b8d 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.8 + RELEASE_VERSION: 11.3.0 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 1b28ab91041..2658e567c01 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.8 + RELEASE_VERSION: 11.3.0 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 afe5d319374..6e92217ef68 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.8 + RELEASE_VERSION: 11.3.0 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index 8aa2cc5d23a..3e67e81020b 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.8 + RELEASE_VERSION: 11.3.0 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 a6d8a19611c..f8e1e4cbb6f 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.8 + RELEASE_VERSION: 11.3.0 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 ba1c3ed409e..ff16b3f30aa 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.8 + RELEASE_VERSION: 11.3.0 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 85558f950e1..1e7a28c5138 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.8 + RELEASE_VERSION: 11.3.0 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 6188d8cfd06..4084d4db8bf 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.8 + RELEASE_VERSION: 11.3.0 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 29797dfda88..5df2e635404 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.8 + RELEASE_VERSION: 11.3.0 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 514ad5f9b50..d4ea1a103cf 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.8 + RELEASE_VERSION: 11.3.0 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 3040fd63d46..e18cd5b4588 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.8 + RELEASE_VERSION: 11.3.0 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 9c8a78e99e8..d78b6bdbfa6 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.8 + RELEASE_VERSION: 11.3.0 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 e47d6fd16c2..723abc91016 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.8 + RELEASE_VERSION: 11.3.0 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 2fb971c5301..2d7fc653378 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.8 + RELEASE_VERSION: 11.3.0 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 fa76dc51acd..5f7ad99671d 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.8 + RELEASE_VERSION: 11.3.0 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 b295199a5c3..cdac7272132 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.8 + RELEASE_VERSION: 11.3.0 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 baaa69fa14a..b2fa1c14524 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.8 + RELEASE_VERSION: 11.3.0 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 c92d2705adc..6b6b03498a6 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.8 + RELEASE_VERSION: 11.3.0 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 4287847298a..936716fe496 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index 972c1e5342d..36a47383139 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index 09b5f1d6064..f1ec3b3e261 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.8 + RELEASE_VERSION: 11.3.0 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 e3ba72ae5e4..d19d5cd9b19 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index 702fba733ca..ee832c06205 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 86e0d5fff43..f7678ab9963 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index c8e8d092144..0839eb590a4 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index 0dc36bba601..c5077ed3a0e 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index e6b993fd217..161e81b2f44 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index 5950116ce3b..44dabf542f2 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index 3ea8fc688b9..0497c1239b8 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index e45e92b1b15..76d67bca855 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index 5af2996935d..fca42686e85 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index 8680c3469a6..caef5f2b4f6 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index 0d4166997ac..f375ba5e254 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.8 + RELEASE_VERSION: 11.3.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/CHANGELOG.md b/CHANGELOG.md index ff58a4687cc..7156055dfd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 11.3.0 + +### 🚀 Features + +- Support OP Stack post-exec transactions ([#14734](https://github.com/blockscout/blockscout/issues/14734)) +- Support API key for CryptoCompare market source ([#14746](https://github.com/blockscout/blockscout/issues/14746)) + +### 🐛 Bug Fixes + +- Group JSON RPC requests by url type ([#14806](https://github.com/blockscout/blockscout/issues/14806)) +- Decouple cache propagation to API nodes from block import ([#14778](https://github.com/blockscout/blockscout/issues/14778)) +- Support hexadecimal block number in eth_getBalance ([#14804](https://github.com/blockscout/blockscout/issues/14804)) +- Disable old broadcast for token transfers without subscribers ([#14777](https://github.com/blockscout/blockscout/issues/14777)) +- Forward [api?: true] where missed ([#14740](https://github.com/blockscout/blockscout/issues/14740)) + +### ⚡ Performance + +- Deduplicate preloads ([#14797](https://github.com/blockscout/blockscout/issues/14797)) +- Optimize join_associations ([#14775](https://github.com/blockscout/blockscout/issues/14775)) +- Make Postgrex prepared statements mode configurable per repo ([#14799](https://github.com/blockscout/blockscout/issues/14799)) +- Limit logs before joining transactions in topic-only getLogs ([#14780](https://github.com/blockscout/blockscout/issues/14780)) +- Scam addresses ETS cache ([#14781](https://github.com/blockscout/blockscout/issues/14781)) +- Preload only rendered transaction fields in v1 tokentx endpoints ([#14782](https://github.com/blockscout/blockscout/issues/14782)) +- Switch token counters to incremental consolidation ([#14773](https://github.com/blockscout/blockscout/issues/14773)) +- Switch address counters to incremental consolidation ([#14759](https://github.com/blockscout/blockscout/issues/14759)) +- Batch uncataloged token transfers scan via Migrator framework ([#14774](https://github.com/blockscout/blockscout/issues/14774)) +- Remove unused GasUsageSum cache with heavy DB query ([#14771](https://github.com/blockscout/blockscout/issues/14771)) +- Deduplicate addresses preloads ([#14758](https://github.com/blockscout/blockscout/issues/14758)) +- Reduce addresses preload queries count ([#14755](https://github.com/blockscout/blockscout/issues/14755)) +- Remove redundant preloads in api/v2/addresses/* ([#14751](https://github.com/blockscout/blockscout/issues/14751)) +- Remove unconditional contract_address preload for tokens ([#14750](https://github.com/blockscout/blockscout/issues/14750)) + + ## 11.2.8 ### 🚀 Features diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index 02eb9583afc..8e84ff28c10 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.8", + version: "11.3.0", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 9012839b1bf..9408864f38d 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.8" + version: "11.3.0" ] end diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 41cc04a1811..4f1a250fb5c 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.8"}] + @default_headers [{"User-Agent", "blockscout-11.3.0"}] # 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 f29eebbf3e9..0d73ae29c1e 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.8", + version: "11.3.0", 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 dcebfe8e14c..9d7b8454939 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.8", + version: "11.3.0", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 4f555517dfb..2ca0a375f6c 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.8", + version: "11.3.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index 28791ffd11f..35bc091e48c 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.8", + version: "11.3.0", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/docker/Makefile b/docker/Makefile index fb2c95989c8..01c60d54b12 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.8' +RELEASE_VERSION ?= '11.3.0' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/mix.exs b/mix.exs index e96986bb480..628ab10b711 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.8", + version: "11.3.0", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/rel/config.exs b/rel/config.exs index 58d2d5fe9c9..5446ef721ed 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.8" + set version: "11.3.0" set applications: [ :runtime_tools, block_scout_web: :permanent, From 43af7ea84797e2f3a55ac1191d9cbe67436eb3e8 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 10 Sep 2026 17:00:00 +0300 Subject: [PATCH 36/82] Update CHANGELOG --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7156055dfd5..15dfe241e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,58 @@ - Remove redundant preloads in api/v2/addresses/* ([#14751](https://github.com/blockscout/blockscout/issues/14751)) - Remove unconditional contract_address preload for tokens ([#14750](https://github.com/blockscout/blockscout/issues/14750)) +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DATABASE_PREPARE_MODE` | Postgrex prepared statements mode for the main database connection. `named` uses one round trip per query and caches parsed statements per connection; `unnamed` is required behind a transaction-mode pooler without prepared statements support. | Version: v11.3.0\+
Default: `unnamed`
Applications: API, Indexer | +| `DATABASE_API_PREPARE_MODE` | Same as `DATABASE_PREPARE_MODE`, but for the API read-only database connection (`DATABASE_READ_ONLY_API_URL`). | Version: v11.3.0\+
Default: value of `DATABASE_PREPARE_MODE`
Applications: API | +| `MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED` | If `true`, the backfill migration that recomputes address counters from scratch is disabled. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE` | Number of addresses to process in each batch during address counters backfill migration. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `10`
Applications: Indexer | +| `MIGRATION_BACKFILL_ADDRESS_COUNTERS_CONCURRENCY` | Number of parallel processes for address counters backfill migration. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `2`
Applications: Indexer | +| `MIGRATION_BACKFILL_ADDRESS_COUNTERS_TIMEOUT` | Timeout between address counters backfill batches processing. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `0s`
Applications: Indexer | +| `MIGRATION_BACKFILL_TOKEN_COUNTERS_DISABLED` | If `true`, the backfill migration that recalculates token counters from scratch is disabled. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_BACKFILL_TOKEN_COUNTERS_BATCH_SIZE` | Number of tokens to process in each batch during token counters backfill migration. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `50`
Applications: Indexer | +| `MIGRATION_BACKFILL_TOKEN_COUNTERS_CONCURRENCY` | Number of parallel processes for token counters backfill migration. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `4`
Applications: Indexer | +| `MIGRATION_BACKFILL_TOKEN_COUNTERS_TIMEOUT` | Timeout between token counters backfill batches processing. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `0s`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_BATCH_SIZE` | Number of blocks to reindex in the batch. Implemented in [#14774](https://github.com/blockscout/blockscout/pull/14774). | Version: v11.3.0\+
Default: `1000`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_CONCURRENCY` | Number of parallel reindexing block batches processing. Implemented in [#14774](https://github.com/blockscout/blockscout/pull/14774). | Version: v11.3.0\+
Default: `1`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_TIMEOUT` | Timeout between reindexing block batches processing. Implemented in [#14774](https://github.com/blockscout/blockscout/pull/14774). | Version: v11.3.0\+
Default: `0s`
Applications: Indexer | +| `MARKET_CRYPTOCOMPARE_API_KEY` | CryptoCompare API key. If set to a non-empty value, it is sent in the `Authorization: Apikey {key}` request header. If unset or empty, no authorization header is added. Implemented in [#14746](https://github.com/blockscout/blockscout/pull/14746). | Version: v11.3.0\+
Default: (empty)
Applications: API, Indexer | +| `CACHE_PROPAGATION_FLUSH_INTERVAL` | Interval between cache propagation flushes. Lower values reduce propagation latency, while higher values allow more cache writes to be coalesced into a batch. Implemented in [#14778](https://github.com/blockscout/blockscout/pull/14778). | Version: v11.3.0\+
Default: `100ms`
Applications: API, Indexer | +| `CACHE_PROPAGATION_SEND_TIMEOUT` | Maximum time allowed for a cache propagation batch to be sent to remote nodes before the sender is stopped and the batch is dropped. Implemented in [#14778](https://github.com/blockscout/blockscout/pull/14778). | Version: v11.3.0\+
Default: `30s`
Applications: API, Indexer | +| `CACHE_COUNTERS_CONSOLIDATION_SAFE_BLOCK_LAG` | Number of blocks behind the chain head to use as safe block for consolidation (prevents corrupting ranges during reorgs). Used by both address and token consolidators. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759), extended in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `10`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_TTL` | Time to live of address counters cache (transactions count, token transfers count, gas used). [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `1h`
Applications: API | +| `CACHE_ADDRESS_COUNTERS_MAX_DIRTY_MARKERS` | Maximum number of dirty address markers to accumulate before forcing a consolidation cycle. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `10000`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_CONSOLIDATION_DISABLED` | If `true`, disables background consolidation of address counters. Useful during rolling deploys when old and new API pods are running together. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `false`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_CONSOLIDATION_INTERVAL` | Interval between address counters consolidation cycles. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `10s`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_CONSOLIDATION_BATCH_SIZE` | Number of addresses to consolidate counters for in a single batch. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `100`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_CONSOLIDATION_CONCURRENCY` | Number of parallel consolidation workers. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `4`
Applications: API, Indexer | +| `CACHE_ADDRESS_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT` | Query timeout for address counters consolidation. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | Version: v11.3.0\+
Default: `30s`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_TTL` | Time to live of token counters cache (transfer count and holder count). [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `1h`
Applications: API | +| `CACHE_TOKEN_COUNTERS_MAX_DIRTY_MARKERS` | Maximum number of dirty token markers to accumulate before forcing a consolidation cycle. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `10000`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_CONSOLIDATION_DISABLED` | If `true`, disables background consolidation of token counters. Useful during rolling deploys when old and new API pods are running together. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `false`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_CONSOLIDATION_INTERVAL` | Interval between token counters consolidation cycles. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `10s`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_CONSOLIDATION_BATCH_SIZE` | Number of tokens to consolidate counters for in a single batch. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `50`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_CONSOLIDATION_CONCURRENCY` | Number of parallel token consolidation workers. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `4`
Applications: API, Indexer | +| `CACHE_TOKEN_COUNTERS_CONSOLIDATION_QUERY_TIMEOUT` | Query timeout for token counters consolidation. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | Version: v11.3.0\+
Default: `30s`
Applications: API, Indexer | +| `CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL` | Interval between updating scam addresses cache. Implemented in [#14781](https://github.com/blockscout/blockscout/pull/14781). | Version: v11.3.0\+
Default: `5m`
Applications: API | +| `CACHE_SCAM_ADDRESSES_MAX_SIZE` | Max number of records in `scam_address_badge_mappings` table after which cache will be turned off. Implemented in [#14781](https://github.com/blockscout/blockscout/pull/14781). | Version: v11.3.0\+
Default: `200000`
Applications: API | + + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Need recompile | Deprecated in Version | +| -------- | ----------- | ------- | ------- | -------------- | --------------------- | +| Deprecated `CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD` | Time to live of address' transaction counter. Replaced with incremental consolidation via `CACHE_ADDRESS_COUNTERS_TTL` and related env vars. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | `1h` | v3.4.0\+ | | v11.3.0+ | +| Deprecated `CACHE_ADDRESS_TOKEN_TRANSFERS_COUNTER_PERIOD` | Interval to restart the task, which calculates the number of token transfers at the address. Replaced with incremental consolidation via `CACHE_ADDRESS_COUNTERS_TTL` and related env vars. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | `1h` | v4.0.0\+ | | v11.3.0+ | +| Deprecated `CACHE_ADDRESS_TRANSACTIONS_GAS_USAGE_COUNTER_PERIOD` | Interval to restart the task which calculates gas usage at the address. Replaced with incremental consolidation via `CACHE_ADDRESS_COUNTERS_TTL` and related env vars. Implemented in [#14759](https://github.com/blockscout/blockscout/pull/14759). | `30m` | v4.1.3\+ | | v11.3.0+ | +| Deprecated `CACHE_TOKEN_HOLDERS_COUNTER_PERIOD` | Interval to restart the task which calculates holders count of the token. Replaced with delta-exact consolidation via `CACHE_TOKEN_COUNTERS_TTL` and related env vars. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | `1h` | v4.1.3\+ | | v11.3.0+ | +| Deprecated `CACHE_TOKEN_TRANSFERS_COUNTER_PERIOD` | Interval to restart the task which calculates transfers count of the token. Replaced with incremental consolidation via `CACHE_TOKEN_COUNTERS_TTL` and related env vars. Implemented in [#14773](https://github.com/blockscout/blockscout/pull/14773). | `1h` | v4.1.3\+ | | v11.3.0+ | +| Deprecated `CACHE_TOTAL_GAS_USAGE_PERIOD` | Interval to restart the task which calculates the total gas usage. Starting from release v6.8.0+, if the value is not set, ttl value gradually increases until the default value with growth of the block numbers. Removed in [#14771](https://github.com/blockscout/blockscout/pull/14771). | `2h` | v4.1.3+ | | v11.3.0+ | +| Deprecated `CACHE_TOTAL_GAS_USAGE_COUNTER_ENABLED` | If `true`, enables cache for total gas usage counter. Removed in [#14771](https://github.com/blockscout/blockscout/pull/14771). | `false` | v5.1.3+ | | v11.3.0+ | + + ## 11.2.8 From 1e61e6e885e145b032841dfef0fd4a03e9cde31d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:23:39 +0300 Subject: [PATCH 37/82] chore(deps): bump tesla from 1.21.2 to 1.21.3 (#14783) 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 2b0d5c12394..d22532e172d 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.21.2", "71b3a8a000802fa82f0b87c22acbca1e8f6a03f23d727e01b66687111681debc", [: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.5.0", [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", "296bbafd1a328533c57e6b0e93078983cf40a827546e58a20464ce75485d5254"}, + "tesla": {:hex, :tesla, "1.21.3", "377e74491164595d89ad4e24b738a448c743f830a491a6c638b9e46995bca23d", [: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.5.0", [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", "558aca0d763aaed4cba418b6078c290ffd55ddd1b03e58d6163cb7438851855e"}, "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.5.0", "cf0e387727dd898b6a67c80c8528d54bdb96e2318ee07dfbd65848fec2cde91f", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "061c500aa5ce03ed4defd85a2fa8ba9a53d036fc096459d570e7a4de05171ed1"}, From 069104c20eaba3e40dabfd63ceaa4965f3fd5586 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:47 +0400 Subject: [PATCH 38/82] perf: Optimize realtime events broadcast for legacy topics (#14810) --- .../lib/block_scout_web/notifier.ex | 138 ++++++++++++------ .../notifier_subscriber_filter_test.exs | 134 +++++++++++++++++ 2 files changed, 227 insertions(+), 45 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index d6840236b12..fe1a8fd3f08 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -119,10 +119,19 @@ defmodule BlockScoutWeb.Notifier do exchange_rate = Market.get_coin_exchange_rate() - addresses - |> Stream.reject(fn %Address{fetched_coin_balance: fetched_coin_balance} -> is_nil(fetched_coin_balance) end) - |> Stream.filter(fn %Address{hash: hash} -> address_has_subscribers?(hash) end) - |> Enum.each(&broadcast_balance(&1, exchange_rate)) + addresses_with_balance = + Enum.reject(addresses, fn %Address{fetched_coin_balance: fetched_coin_balance} -> + is_nil(fetched_coin_balance) + end) + + addresses_with_balance + |> Enum.filter(fn %Address{hash: hash} -> address_has_v2_subscribers?(hash) end) + |> Enum.each(&broadcast_balance_v2(&1, exchange_rate)) + + # TODO: delete duplicated event when old UI becomes deprecated + addresses_with_balance + |> Enum.filter(fn %Address{hash: hash} -> address_has_old_subscribers?(hash) end) + |> Enum.each(&broadcast_balance_v1(&1, exchange_rate)) end def handle_event({:chain_event, :address_coin_balances, type, address_coin_balances}) @@ -131,7 +140,6 @@ defmodule BlockScoutWeb.Notifier do address_coin_balances |> Enum.reject(fn balance -> is_nil(balance[:value]) end) - |> Enum.filter(fn balance -> address_has_subscribers?(balance[:address_hash]) end) # Only the most recent balance of an address is worth a query and a message, # the earlier ones are superseded by it right away. |> Enum.sort_by(& &1[:block_number], :desc) @@ -142,8 +150,13 @@ defmodule BlockScoutWeb.Notifier do def handle_event({:chain_event, :address_token_balances, type, address_token_balances}) when type in [:realtime, :on_demand] do address_token_balances - |> Enum.filter(fn balance -> address_has_subscribers?(balance.address_hash) end) - |> Enum.each(&broadcast_address_token_balance/1) + |> Enum.filter(fn balance -> address_has_v2_subscribers?(balance.address_hash) end) + |> Enum.each(&broadcast_address_token_balance_v2/1) + + # TODO: delete duplicated event when old UI becomes deprecated + address_token_balances + |> Enum.filter(fn balance -> address_has_old_subscribers?(balance.address_hash) end) + |> Enum.each(&broadcast_address_token_balance_v1/1) end def handle_event( @@ -590,51 +603,69 @@ defmodule BlockScoutWeb.Notifier do end end + # Subscribers of both channels are checked here rather than filtered upstream: + # the coin balance query below feeds both payloads, so it has to run once for + # an address subscribed to either of them. defp broadcast_address_coin_balance(%{address_hash: address_hash, block_number: block_number}, exchange_rate) do - coin_balance = CoinBalance.get_coin_balance(address_hash, block_number, @api_true) + old_subscribers? = address_has_old_subscribers?(address_hash) + v2_subscribers? = address_has_v2_subscribers?(address_hash) + + coin_balance = + if old_subscribers? or v2_subscribers?, + do: CoinBalance.get_coin_balance(address_hash, block_number, @api_true) if coin_balance && coin_balance.delta && !Decimal.eq?(coin_balance.delta, Decimal.new(0)) do # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{address_hash}", "coin_balance", %{ - block_number: block_number, - coin_balance: coin_balance - }) + if old_subscribers? do + Endpoint.broadcast("addresses_old:#{address_hash}", "coin_balance", %{ + block_number: block_number, + coin_balance: coin_balance + }) + end + + if v2_subscribers? and coin_balance.value do + broadcast_address_coin_balance_v2(address_hash, block_number, coin_balance, exchange_rate) + end end + end - if coin_balance && coin_balance.value && coin_balance.delta && !Decimal.eq?(coin_balance.delta, Decimal.new(0)) do - rendered_coin_balance = AddressView.render("coin_balance.json", %{coin_balance: coin_balance}) + defp broadcast_address_coin_balance_v2(address_hash, block_number, coin_balance, exchange_rate) do + rendered_coin_balance = AddressView.render("coin_balance.json", %{coin_balance: coin_balance}) - Endpoint.broadcast("addresses:#{address_hash}", "coin_balance", %{ - coin_balance: rendered_coin_balance - }) + Endpoint.broadcast("addresses:#{address_hash}", "coin_balance", %{ + coin_balance: rendered_coin_balance + }) - Endpoint.broadcast("addresses:#{address_hash}", "current_coin_balance", %{ - coin_balance: coin_balance.value, - exchange_rate: exchange_rate.fiat_value, - block_number: block_number - }) - end + Endpoint.broadcast("addresses:#{address_hash}", "current_coin_balance", %{ + coin_balance: coin_balance.value, + exchange_rate: exchange_rate.fiat_value, + block_number: block_number + }) end - defp broadcast_address_token_balance(%{address_hash: address_hash, block_number: block_number}) do - # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{address_hash}", "token_balance", %{ + defp broadcast_address_token_balance_v2(%{address_hash: address_hash, block_number: block_number}) do + Endpoint.broadcast("addresses:#{address_hash}", "token_balance", %{ block_number: block_number }) + end - Endpoint.broadcast("addresses:#{address_hash}", "token_balance", %{ + # TODO: delete this function when old UI becomes deprecated + defp broadcast_address_token_balance_v1(%{address_hash: address_hash, block_number: block_number}) do + Endpoint.broadcast("addresses_old:#{address_hash}", "token_balance", %{ block_number: block_number }) end - defp broadcast_balance(%Address{hash: address_hash} = address, exchange_rate) do - v2_params = %{ + defp broadcast_balance_v2(%Address{hash: address_hash} = address, exchange_rate) do + Endpoint.broadcast("addresses:#{address_hash}", "balance", %{ balance: address.fetched_coin_balance.value, block_number: address.fetched_coin_balance_block_number, exchange_rate: exchange_rate.fiat_value - } + }) + end - # TODO: delete duplicated event when old UI becomes deprecated + # TODO: delete this function when old UI becomes deprecated + defp broadcast_balance_v1(%Address{hash: address_hash} = address, exchange_rate) do Endpoint.broadcast( "addresses_old:#{address_hash}", "balance_update", @@ -643,8 +674,6 @@ defmodule BlockScoutWeb.Notifier do exchange_rate: exchange_rate } ) - - Endpoint.broadcast("addresses:#{address_hash}", "balance", v2_params) end defp broadcast_block(block) do @@ -728,21 +757,35 @@ defmodule BlockScoutWeb.Notifier do defp merge_enriched_miner_field(block, _enriched_block, _field), do: block defp broadcast_rewards(rewards) do - preloaded_rewards = Repo.preload(rewards, [:address, :block]) - emission_reward = Enum.find(preloaded_rewards, fn reward -> reward.address_type == :emission_funds end) - - preloaded_rewards_except_emission = - Enum.reject(preloaded_rewards, fn reward -> reward.address_type == :emission_funds end) - - Enum.each(preloaded_rewards_except_emission, fn reward -> - # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("rewards_old:#{to_string(reward.address_hash)}", "new_reward", %{ - emission_funds: emission_reward, - validator: reward - }) + {emission_rewards, validator_rewards} = + Enum.split_with(rewards, fn reward -> reward.address_type == :emission_funds end) + Enum.each(validator_rewards, fn reward -> Endpoint.broadcast("rewards:#{to_string(reward.address_hash)}", "new_reward", %{reward: 1}) end) + + # TODO: delete duplicated event when old UI becomes deprecated + broadcast_rewards_v1(validator_rewards, emission_rewards) + end + + # TODO: delete this function when old UI becomes deprecated + defp broadcast_rewards_v1(validator_rewards, emission_rewards) do + case Enum.filter(validator_rewards, &reward_has_old_subscribers?/1) do + [] -> + :ok + + relevant_rewards -> + emission_reward = emission_rewards |> Repo.preload([:address, :block]) |> List.first() + + relevant_rewards + |> Repo.preload([:address, :block]) + |> Enum.each(fn reward -> + Endpoint.broadcast("rewards_old:#{to_string(reward.address_hash)}", "new_reward", %{ + emission_funds: emission_reward, + validator: reward + }) + end) + end end defp broadcast_internal_transaction(internal_transaction) do @@ -982,6 +1025,11 @@ defmodule BlockScoutWeb.Notifier do has_subscribers?("addresses_old:" <> to_string(address_hash)) end + # TODO: delete this function when old UI becomes deprecated + defp reward_has_old_subscribers?(reward) do + has_subscribers?("rewards_old:" <> to_string(reward.address_hash)) + end + defp token_transfer_has_subscribers?(tt) do token_transfer_has_v2_subscribers?(tt) or token_transfer_has_old_subscribers?(tt) diff --git a/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs b/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs index 54c18fc2fd0..a407b998b48 100644 --- a/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs +++ b/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs @@ -67,6 +67,72 @@ defmodule BlockScoutWeb.NotifierSubscriberFilterTest do Notifier.handle_event({:chain_event, :addresses, :realtime, [address]}) end + + # TODO: delete this test when old UI becomes deprecated + test "broadcasts balance to the old UI channel when subscribed to it only" do + {:ok, balance} = Wei.cast(1) + address = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + + topic = "addresses_old:#{address.hash}" + @endpoint.subscribe(topic) + + start_supervised!(AddressesCount) + AddressesCount.consolidate() + + Notifier.handle_event({:chain_event, :addresses, :realtime, [address]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "balance_update", + payload: %{address: %{hash: hash}} + }, + :timer.seconds(5) + + assert hash == address.hash + end + + # TODO: delete this test when old UI becomes deprecated + test "broadcasts balance only to the old UI channel of the subscribed address in a batch" do + {:ok, balance} = Wei.cast(1) + + subscribed = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + unsubscribed = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + + subscribed_topic = "addresses_old:#{subscribed.hash}" + unsubscribed_topic = "addresses_old:#{unsubscribed.hash}" + @endpoint.subscribe(subscribed_topic) + @endpoint.subscribe(unsubscribed_topic) + + start_supervised!(AddressesCount) + AddressesCount.consolidate() + + Phoenix.PubSub.unsubscribe(BlockScoutWeb.PubSub, unsubscribed_topic) + + Notifier.handle_event({:chain_event, :addresses, :realtime, [subscribed, unsubscribed]}) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^subscribed_topic, event: "balance_update"}, :timer.seconds(5) + refute_receive %Phoenix.Socket.Broadcast{topic: ^unsubscribed_topic, event: "balance_update"}, 100 + end + + # TODO: delete this test when old UI becomes deprecated + test "broadcasts the old UI address count when subscribed to that channel" do + {:ok, balance} = Wei.cast(1) + address = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + + @endpoint.subscribe("addresses_old:new_address") + + start_supervised!(AddressesCount) + AddressesCount.consolidate() + + Notifier.handle_event({:chain_event, :addresses, :realtime, [address]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: "addresses_old:new_address", + event: "count", + payload: %{count: _} + }, + :timer.seconds(5) + end end describe "address_coin_balances event: subscriber filtering" do @@ -104,6 +170,33 @@ defmodule BlockScoutWeb.NotifierSubscriberFilterTest do assert_receive %Phoenix.Socket.Broadcast{topic: ^subscribed_topic, event: "coin_balance"}, :timer.seconds(5) end + + # TODO: delete this test when old UI becomes deprecated + test "broadcasts to the old UI channel when subscribed to it only" do + address = insert(:address) + + coin_balance = + insert(:address_coin_balance, + address: address, + address_hash: address.hash, + delta: 500 + ) + + topic = "addresses_old:#{address.hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event( + {:chain_event, :address_coin_balances, :realtime, + [%{address_hash: address.hash, block_number: coin_balance.block_number, value: 1}]} + ) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "coin_balance", + payload: %{coin_balance: _, block_number: _} + }, + :timer.seconds(5) + end end describe "address_token_balances event: subscriber filtering" do @@ -115,6 +208,47 @@ defmodule BlockScoutWeb.NotifierSubscriberFilterTest do {:chain_event, :address_token_balances, :realtime, [%{address_hash: address.hash, block_number: block.number}]} ) end + + test "broadcasts only to subscribed addresses in a batch" do + subscribed_address = insert(:address) + unsubscribed_address = insert(:address) + block = insert(:block) + + subscribed_topic = "addresses:#{subscribed_address.hash}" + unsubscribed_topic = "addresses:#{unsubscribed_address.hash}" + @endpoint.subscribe(subscribed_topic) + + Notifier.handle_event( + {:chain_event, :address_token_balances, :realtime, + [ + %{address_hash: subscribed_address.hash, block_number: block.number}, + %{address_hash: unsubscribed_address.hash, block_number: block.number} + ]} + ) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^subscribed_topic, event: "token_balance"}, :timer.seconds(5) + refute_receive %Phoenix.Socket.Broadcast{topic: ^unsubscribed_topic}, 100 + end + + # TODO: delete this test when old UI becomes deprecated + test "broadcasts to the old UI channel when subscribed to it only" do + address = insert(:address) + block = insert(:block) + + topic = "addresses_old:#{address.hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event( + {:chain_event, :address_token_balances, :realtime, [%{address_hash: address.hash, block_number: block.number}]} + ) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "token_balance", + payload: %{block_number: _} + }, + :timer.seconds(5) + end end describe "internal_transactions event: subscriber filtering" do From dca0feed1792d34c87b1f858007aafdee311a7a0 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 14 Sep 2026 19:31:14 +0300 Subject: [PATCH 39/82] fix: support OP Stack Upgrade 20 (Super Root games, SystemConfig v4) (#14818) --- .../schemas/api/v2/optimism/game.ex | 8 +- .../views/api/v2/optimism_view.ex | 11 +- .../explorer/chain/optimism/dispute_game.ex | 90 +++++++++++++- .../lib/explorer/chain/optimism/withdrawal.ex | 40 +++++- .../chain/optimism/dispute_game_test.exs | 114 ++++++++++++++++++ .../fetcher/optimism/transaction_batch.ex | 89 +++++++------- cspell.json | 3 + 7 files changed, 300 insertions(+), 55 deletions(-) create mode 100644 apps/explorer/test/explorer/chain/optimism/dispute_game_test.exs diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex index 2aa97fc3adf..a7d43450e29 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex @@ -16,7 +16,12 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Game do created_at: General.Timestamp, game_type: %Schema{type: :integer}, index: %Schema{type: :integer}, - l2_block_number: %Schema{type: :integer}, + l2_block_number: %Schema{ + type: :integer, + description: "L2 block number the game is bound to. `null` for the games with Super Root claim.", + nullable: true + }, + l2_timestamp: General.TimestampNullable, resolved_at: General.TimestampNullable, status: %Schema{ type: :string, @@ -30,6 +35,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Game do :game_type, :index, :l2_block_number, + :l2_timestamp, :resolved_at, :status ], diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex index 6faebc9b279..9bf545c7493 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex @@ -122,13 +122,22 @@ defmodule BlockScoutWeb.API.V2.OptimismView do 2 -> "Defender wins" end - l2_block_number = DisputeGame.l2_block_number_from_extra_data(g.extra_data) + # Games with Output Root claim are bound to L2 block number, whereas games with Super Root claim + # (OP Stack Upgrade 20) are bound to L2 timestamp + {l2_block_number, l2_timestamp} = + case DisputeGame.l2_sequence_number(g) do + {:block_number, block_number} -> {block_number, nil} + # zero timestamp means the extra data is unknown or malformed + {:timestamp, 0} -> {nil, nil} + {:timestamp, timestamp} -> {nil, DateTime.from_unix!(timestamp)} + end %{ "index" => g.index, "game_type" => g.game_type, "contract_address_hash" => g.address_hash, "l2_block_number" => l2_block_number, + "l2_timestamp" => l2_timestamp, "created_at" => g.created_at, "status" => status, "resolved_at" => g.resolved_at diff --git a/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex b/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex index 25666afb677..ca6ab27c6d4 100644 --- a/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex +++ b/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex @@ -19,11 +19,24 @@ defmodule Explorer.Chain.Optimism.DisputeGame do @chain_id_megaeth_mainnet 4326 @chain_id_megaeth_testnet_v2 6343 + # Game types whose root claim is a Super Root rather than an Output Root (introduced by OP Stack Upgrade 20). + # Mirrors `GameTypes.isSuperGame` from the OP Stack contracts: + # SUPER_CANNON (4), SUPER_PERMISSIONED (5), SUPER_ASTERISC_KONA (7), SUPER_CANNON_KONA (9), ZK_DISPUTE_GAME (10). + @super_game_types [4, 5, 7, 9, 10] + + # The only supported version of the Super Root proof encoding stored in the `extraData` of a Super Root game + @super_root_proof_version 1 + + # The size (in bytes) of one output root entry in the Super Root proof: 32 bytes of chain ID + 32 bytes of output root + @super_root_output_root_size 64 + @typedoc """ * `index` - A unique index of the dispute game. * `game_type` - A number encoding a type of the dispute game. * `address_hash` - The dispute game contract address. - * `extra_data` - An extra data of the dispute game (contains L2 block number). + * `extra_data` - An extra data of the dispute game. For the games with Output Root claim it contains L2 block number. + For the games with Super Root claim (see `super_game_type?/1`) it contains the Super Root proof + (version byte, L2 timestamp, and the list of output roots with their chain IDs). Equals to `nil` when the game is written to database but the rest data is not known yet. * `created_at` - UTC timestamp of when the dispute game was created. * `resolved_at` - UTC timestamp of when the dispute game was resolved. @@ -89,14 +102,52 @@ defmodule Explorer.Chain.Optimism.DisputeGame do end @doc """ - Retrieves L2 block number from the `extraData` field of the dispute game. The L2 block number can be encoded in - different ways depending on the chain. + Checks whether the given game type is a Super Root game type, i.e. the game's root claim is a Super Root + (a commitment to the output roots of several chains at the same L2 timestamp) rather than an Output Root + of a single chain. Such games were introduced by OP Stack Upgrade 20. + + ## Parameters + - `game_type`: The game type number. + + ## Returns + - `true` if the game type is a Super Root game type, `false` otherwise. + """ + @spec super_game_type?(non_neg_integer() | nil) :: boolean() + def super_game_type?(game_type), do: game_type in @super_game_types + + @doc """ + Retrieves the L2 sequence number of the dispute game from its `extraData` field. + + For the games with Output Root claim the sequence number is the L2 block number. + For the games with Super Root claim (see `super_game_type?/1`) the sequence number is the L2 timestamp + of the Super Root. + + ## Parameters + - `game`: A map (or `t:Explorer.Chain.Optimism.DisputeGame.t/0`) with `game_type` and `extra_data` fields. + + ## Returns + - `{:block_number, l2_block_number}` tuple for the games with Output Root claim. + - `{:timestamp, l2_timestamp}` tuple for the games with Super Root claim (the timestamp is in UNIX seconds). + """ + @spec l2_sequence_number(%{:game_type => non_neg_integer() | nil, :extra_data => Data.t() | nil, any() => any()}) :: + {:block_number, non_neg_integer()} | {:timestamp, non_neg_integer()} + def l2_sequence_number(%{game_type: game_type, extra_data: extra_data}) do + if super_game_type?(game_type) do + {:timestamp, l2_timestamp_from_extra_data(extra_data)} + else + {:block_number, l2_block_number_from_extra_data(extra_data)} + end + end + + @doc """ + Retrieves L2 block number from the `extraData` field of the dispute game with Output Root claim. + The L2 block number can be encoded in different ways depending on the chain. ## Parameters - `extra_data`: The byte sequence of the extra data to retrieve L2 block number from. ## Returns - - L2 block number of the dispute game. + - L2 block number of the dispute game. Zero if the extra data is unknown or has unexpected format. """ @spec l2_block_number_from_extra_data(Data.t() | nil) :: non_neg_integer() def l2_block_number_from_extra_data(nil), do: 0 @@ -114,10 +165,37 @@ defmodule Explorer.Chain.Optimism.DisputeGame do 256 end - <> = extra_data - l2_block_number + case extra_data do + <> -> l2_block_number + _ -> 0 + end end + @doc """ + Retrieves L2 timestamp from the `extraData` field of the dispute game with Super Root claim. + + The extra data of such a game contains the Super Root proof encoded as follows: + 1 byte of version (must be equal to 1), 8 bytes of the L2 timestamp (big-endian uint64), + and 64 bytes for each output root (32 bytes of chain ID and 32 bytes of the output root). + See `Encoding.decodeSuperRootProof` in the OP Stack contracts. + + ## Parameters + - `extra_data`: The byte sequence of the extra data to retrieve L2 timestamp from. + + ## Returns + - L2 timestamp (in UNIX seconds) of the Super Root. Zero if the extra data is unknown or has unexpected format. + """ + @spec l2_timestamp_from_extra_data(Data.t() | nil) :: non_neg_integer() + def l2_timestamp_from_extra_data(nil), do: 0 + + def l2_timestamp_from_extra_data(%Data{ + bytes: <<@super_root_proof_version, l2_timestamp::size(64), output_roots::binary>> + }) + when byte_size(output_roots) > 0 and rem(byte_size(output_roots), @super_root_output_root_size) == 0, + do: l2_timestamp + + def l2_timestamp_from_extra_data(%Data{}), do: 0 + defp page_dispute_games(query, %PagingOptions{key: nil}), do: query defp page_dispute_games(query, %PagingOptions{key: {index}}) do diff --git a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex index 5980f9ed4bb..d98df84e9b2 100644 --- a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex +++ b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex @@ -169,6 +169,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do select: %{ hash: w.hash, l2_block_number: w.l2_block_number, + l2_timestamp: l2_block.timestamp, l1_transaction_hash: we.l1_transaction_hash, msg_nonce: w.msg_nonce, msg_log_sender_address_hash: log.third_topic, @@ -226,7 +227,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do if proven_events == [] do cond do - appropriate_games_found(w.l2_block_number, respected_games) -> + appropriate_games_found(w, respected_games) -> {@withdrawal_status_ready_to_prove, nil} appropriate_root_found(w.l2_block_number) -> @@ -282,14 +283,41 @@ defmodule Explorer.Chain.Optimism.Withdrawal do @proof_maturity_delay_seconds end - defp appropriate_games_found(withdrawal_l2_block_number, respected_games) do - respected_games - |> Enum.any?(fn game -> - l2_block_number = DisputeGame.l2_block_number_from_extra_data(game.extra_data) - withdrawal_l2_block_number <= l2_block_number + # Checks whether there is a respected dispute game the given withdrawal can be proven against. + # + # For the games with Output Root claim the withdrawal's L2 block number is compared with the L2 block number + # of the game. For the games with Super Root claim (OP Stack Upgrade 20) the game's `extraData` contains + # the L2 timestamp of the Super Root instead of L2 block number, so the timestamp of the withdrawal's L2 block + # is compared with it. + # + # ## Parameters + # - `w`: A map with the withdrawal info. Must contain `l2_block_number` and (for Super Root games) `l2_timestamp`. + # - `respected_games`: A list of games returned by the `respected_games(options)` function. + # + # ## Returns + # - `true` if an appropriate game is found, `false` otherwise. + @spec appropriate_games_found(map(), list()) :: boolean() + defp appropriate_games_found(w, respected_games) do + Enum.any?(respected_games, fn game -> + case DisputeGame.l2_sequence_number(game) do + {:block_number, game_l2_block_number} -> + w.l2_block_number <= game_l2_block_number + + {:timestamp, game_l2_timestamp} -> + withdrawal_covered_by_timestamp?(Map.get(w, :l2_timestamp), game_l2_timestamp) + end end) end + # Checks whether the withdrawal's L2 block timestamp is covered by the Super Root game bound to the given L2 timestamp. + # Returns `false` if the withdrawal's L2 block timestamp is unknown (e.g. the L2 block is not indexed yet). + @spec withdrawal_covered_by_timestamp?(DateTime.t() | nil, non_neg_integer()) :: boolean() + defp withdrawal_covered_by_timestamp?(%DateTime{} = withdrawal_l2_timestamp, game_l2_timestamp) do + DateTime.to_unix(withdrawal_l2_timestamp) <= game_l2_timestamp + end + + defp withdrawal_covered_by_timestamp?(_withdrawal_l2_timestamp, _game_l2_timestamp), do: false + defp appropriate_root_found(withdrawal_l2_block_number) do last_root_l2_block_number = Repo.replica().one( diff --git a/apps/explorer/test/explorer/chain/optimism/dispute_game_test.exs b/apps/explorer/test/explorer/chain/optimism/dispute_game_test.exs new file mode 100644 index 00000000000..358aae7f49a --- /dev/null +++ b/apps/explorer/test/explorer/chain/optimism/dispute_game_test.exs @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Optimism.DisputeGameTest do + use Explorer.DataCase + + alias Explorer.Chain.Cache.ChainId + alias Explorer.Chain.Data + alias Explorer.Chain.Optimism.DisputeGame + + if Application.compile_env(:explorer, :chain_type) == :optimism do + # game types are taken from `GameTypes` library of the OP Stack contracts + @cannon_game_type 0 + @permissioned_cannon_game_type 1 + @super_cannon_game_type 4 + @super_permissioned_game_type 5 + @super_asterisc_kona_game_type 7 + @super_cannon_kona_game_type 9 + @zk_dispute_game_type 10 + + @l2_block_number 123_456_789 + @l2_timestamp 1_758_000_000 + + # `extraData` of a game with Output Root claim: abi.encode(uint256 l2BlockNumber) + @output_root_extra_data %Data{bytes: <<@l2_block_number::size(256)>>} + + # `extraData` of a game with Super Root claim (see `Encoding.decodeSuperRootProof` in the OP Stack contracts): + # 1 byte of version, 8 bytes of timestamp, (32 bytes of chain ID + 32 bytes of output root) per chain + @super_root_extra_data %Data{ + bytes: <<1, @l2_timestamp::size(64), 10::size(256), 0xAA::size(256), 8453::size(256), 0xBB::size(256)>> + } + + setup do + ChainId.set_id(10) + :ok + end + + describe "super_game_type?/1" do + test "returns true only for Super Root game types" do + assert DisputeGame.super_game_type?(@super_cannon_game_type) + assert DisputeGame.super_game_type?(@super_permissioned_game_type) + assert DisputeGame.super_game_type?(@super_asterisc_kona_game_type) + assert DisputeGame.super_game_type?(@super_cannon_kona_game_type) + assert DisputeGame.super_game_type?(@zk_dispute_game_type) + + refute DisputeGame.super_game_type?(@cannon_game_type) + refute DisputeGame.super_game_type?(@permissioned_cannon_game_type) + refute DisputeGame.super_game_type?(nil) + end + end + + describe "l2_sequence_number/1" do + test "returns L2 block number for a game with Output Root claim" do + game = %{game_type: @cannon_game_type, extra_data: @output_root_extra_data} + assert DisputeGame.l2_sequence_number(game) == {:block_number, @l2_block_number} + end + + test "returns L2 timestamp for a game with Super Root claim" do + for game_type <- [@super_permissioned_game_type, @super_cannon_kona_game_type] do + game = %{game_type: game_type, extra_data: @super_root_extra_data} + assert DisputeGame.l2_sequence_number(game) == {:timestamp, @l2_timestamp} + end + end + + test "returns zero when extra data is unknown" do + assert DisputeGame.l2_sequence_number(%{game_type: @cannon_game_type, extra_data: nil}) == {:block_number, 0} + + assert DisputeGame.l2_sequence_number(%{game_type: @super_cannon_kona_game_type, extra_data: nil}) == + {:timestamp, 0} + end + end + + describe "l2_block_number_from_extra_data/1" do + test "returns zero when extra data is shorter than expected" do + assert DisputeGame.l2_block_number_from_extra_data(%Data{bytes: <<1, 2, 3>>}) == 0 + end + end + + describe "l2_timestamp_from_extra_data/1" do + test "parses the timestamp of the Super Root proof" do + assert DisputeGame.l2_timestamp_from_extra_data(@super_root_extra_data) == @l2_timestamp + end + + test "returns zero for unsupported Super Root proof version" do + extra_data = %Data{bytes: <<2, @l2_timestamp::size(64), 10::size(256), 0xAA::size(256)>>} + assert DisputeGame.l2_timestamp_from_extra_data(extra_data) == 0 + end + + test "returns zero when extra data is shorter than expected" do + assert DisputeGame.l2_timestamp_from_extra_data(%Data{bytes: <<1, 2, 3>>}) == 0 + end + + test "returns zero when the list of output roots is empty" do + assert DisputeGame.l2_timestamp_from_extra_data(%Data{bytes: <<1, @l2_timestamp::size(64)>>}) == 0 + end + + test "returns zero when an output root entry is truncated" do + # 32 bytes of chain ID followed by only 16 bytes of the output root + extra_data = %Data{bytes: <<1, @l2_timestamp::size(64), 10::size(256), 0xAA::size(128)>>} + assert DisputeGame.l2_timestamp_from_extra_data(extra_data) == 0 + + # one complete entry followed by a truncated one + extra_data = %Data{ + bytes: <<1, @l2_timestamp::size(64), 10::size(256), 0xAA::size(256), 8453::size(256), 0xBB::size(64)>> + } + + assert DisputeGame.l2_timestamp_from_extra_data(extra_data) == 0 + end + + test "accepts a single complete output root entry" do + extra_data = %Data{bytes: <<1, @l2_timestamp::size(64), 10::size(256), 0xAA::size(256)>>} + assert DisputeGame.l2_timestamp_from_extra_data(extra_data) == @l2_timestamp + end + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex index 7d1e476a89b..0b0b3976d48 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex @@ -1562,8 +1562,12 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # Gets the number of a start block (from which this fetcher should start), # the inbox address, and the batcher (batch submitter) address. # - # If SystemConfig has obsolete implementation, the values are fallen back from the corresponding - # env variables (INDEXER_OPTIMISM_L1_START_BLOCK, INDEXER_OPTIMISM_L1_BATCH_INBOX, INDEXER_OPTIMISM_L1_BATCH_SUBMITTER). + # Each getter is handled independently: if a getter is unavailable (the call reverts or the whole request fails), + # only that value is fallen back from the corresponding env variable (INDEXER_OPTIMISM_L1_START_BLOCK, + # INDEXER_OPTIMISM_L1_BATCH_INBOX, INDEXER_OPTIMISM_L1_BATCH_SUBMITTER) while the others are still taken + # from the contract. In particular, SystemConfig v4.0.0 (OP Stack Upgrade 20) removed the `batchInbox()` getter, + # so the inbox address must be defined through INDEXER_OPTIMISM_L1_BATCH_INBOX for such chains, whereas + # `startBlock()` and `batcherHash()` are still readable. # # Moreover, if INDEXER_OPTIMISM_L1_BATCH_INBOX and/or INDEXER_OPTIMISM_L1_BATCH_SUBMITTER are explicitly set, # they take precedence over the corresponding values read from the SystemConfig contract. This is needed when @@ -1579,15 +1583,18 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # - `nil` in case of error. @spec read_system_config(String.t(), EthereumJSONRPC.json_rpc_named_arguments()) :: {non_neg_integer(), String.t(), String.t()} | nil - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp read_system_config(contract_address, json_rpc_named_arguments) do + start_block_request_id = 0 + batch_inbox_request_id = 1 + batch_submitter_request_id = 2 + requests = [ # startBlock() public getter - Contract.eth_call_request("0x48cd4cb1", contract_address, 0, nil, nil), - # batchInbox() public getter - Contract.eth_call_request("0xdac6e63a", contract_address, 1, nil, nil), + Contract.eth_call_request("0x48cd4cb1", contract_address, start_block_request_id, nil, nil), + # batchInbox() public getter (removed in SystemConfig v4.0.0, so the call can revert) + Contract.eth_call_request("0xdac6e63a", contract_address, batch_inbox_request_id, nil, nil), # batcherHash() public getter - Contract.eth_call_request("0xe81b2c6d", contract_address, 2, nil, nil) + Contract.eth_call_request("0xe81b2c6d", contract_address, batch_submitter_request_id, nil, nil) ] error_message = &"Cannot call public getters of SystemConfig. Error: #{inspect(&1)}" @@ -1595,57 +1602,57 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do env = Application.get_all_env(:indexer)[__MODULE__] fallback_start_block = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism][:start_block_l1] - {start_block, batch_inbox, batch_submitter} = + result_by_id = case Helper.repeated_call( &json_rpc/2, [requests, json_rpc_named_arguments], error_message, Helper.finite_retries_number() ) do - {:ok, responses} -> - start_block = - responses - |> Enum.at(0) - |> Map.get(:result, fallback_start_block) - |> quantity_to_integer() - - inbox_result = Map.get(Enum.at(responses, 1), :result) - submitter_result = Map.get(Enum.at(responses, 2), :result) - - {batch_inbox, batch_submitter} = - with {:nil_result, true, _, _} <- - {:nil_result, is_nil(inbox_result) or is_nil(submitter_result), inbox_result, submitter_result}, - {:fallback_defined, true} <- - {:fallback_defined, - Helper.address_correct?(env[:inbox]) and Helper.address_correct?(env[:submitter])} do - {env[:inbox], env[:submitter]} - else - {:nil_result, false, inbox, submitter} -> - "0x000000000000000000000000" <> batch_inbox = inbox - "0x000000000000000000000000" <> batch_submitter = submitter - {"0x" <> batch_inbox, "0x" <> batch_submitter} - - {:fallback_defined, false} -> - {nil, nil} - end - - {start_block, batch_inbox, batch_submitter} - - _ -> - {fallback_start_block, env[:inbox], env[:submitter]} + {:ok, responses} -> Map.new(responses, &{&1.id, Map.get(&1, :result)}) + _ -> %{} end + start_block = + result_by_id + |> Map.get(start_block_request_id) + |> Kernel.||(fallback_start_block) + |> quantity_to_integer() + # An explicitly configured inbox/submitter overrides the value read from the SystemConfig contract. # Only kicks in when the corresponding env variable holds a correct address, so the on-chain value # is still used by default. - batch_inbox = if Helper.address_correct?(env[:inbox]), do: env[:inbox], else: batch_inbox - batch_submitter = if Helper.address_correct?(env[:submitter]), do: env[:submitter], else: batch_submitter + batch_inbox = system_config_address(result_by_id[batch_inbox_request_id], env[:inbox]) + batch_submitter = system_config_address(result_by_id[batch_submitter_request_id], env[:submitter]) + + if is_nil(batch_inbox) and Map.has_key?(result_by_id, batch_submitter_request_id) do + Logger.error( + "The batchInbox() getter of SystemConfig is not available (it was removed in SystemConfig v4.0.0 by OP Stack Upgrade 20). Please, define the batch inbox address via INDEXER_OPTIMISM_L1_BATCH_INBOX env variable." + ) + end if !is_nil(start_block) and Helper.address_correct?(batch_inbox) and Helper.address_correct?(batch_submitter) do {start_block, String.downcase(batch_inbox), String.downcase(batch_submitter)} end end + # Returns an address read from a SystemConfig getter unless the corresponding env variable + # defines a correct address (which takes precedence). Returns `nil` if the getter result + # is absent (e.g. the call reverted) and the env variable is not defined. + @spec system_config_address(String.t() | nil, String.t() | nil) :: String.t() | nil + defp system_config_address(getter_result, env_address) do + cond do + Helper.address_correct?(env_address) -> + env_address + + is_binary(getter_result) and String.starts_with?(getter_result, "0x000000000000000000000000") -> + "0x" <> String.slice(getter_result, 26..-1//1) + + true -> + nil + end + end + # Retrieves Alt-DA signature from L1 transaction input as described in # https://github.com/ethereum-optimism/specs/blob/main/specs/experimental/alt-da.md#example-commitments # diff --git a/cspell.json b/cspell.json index e336bd03db6..c14c328058d 100644 --- a/cspell.json +++ b/cspell.json @@ -58,6 +58,7 @@ "Asearch", "Asfpp", "Astar", + "asterisc", "asyncable", "atoken", "audix", @@ -387,6 +388,7 @@ "keyout", "keyreplace", "kittencream", + "kona", "KnxbUejwY", "labelledby", "lastmod", @@ -522,6 +524,7 @@ "peekers", "pendingtxlist", "perc", + "permissioned", "permissionless", "persistable", "PGDATABASE", From 6037429b93685d7aa9dec4cbe4cc8ec9c6b424b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:34:54 +0300 Subject: [PATCH 40/82] chore(deps-dev): bump dialyxir from 1.4.7 to 1.4.8 (#14822) 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 d22532e172d..764c84f7276 100644 --- a/mix.lock +++ b/mix.lock @@ -40,14 +40,14 @@ "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, - "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, "dns": {:hex, :dns, "2.4.0", "44790a0375b28bdc7b59fc894460bfcb03ffeec4c5984e2c3e8b0797b1518327", [:mix], [], "hexpm", "e178e353c469820d02ba889d6a80d01c8c27b47dfcda4016a9cbc6218e3eed64"}, "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "evil_crc32c": {:hex, :evil_crc32c, "0.2.9", "9e4082418c4cc27c88869c1773f114f15a9257121ebb019037bea60c75c601d2", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "86ba14f8aad0fe55eda0b57e79e3d8dffe39ea326b753eac12463ae2bec757bf"}, "evision": {:hex, :evision, "0.2.17", "0d5aed4fdb6945f100642cfd41531d187963af984c60ec367719106dda7ed864", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "565bc8700d23cd2429444387a1cf0e53fe7a85892e9276ce62a9cef23350c11f"}, From 051fbf19b957053b9a278d5d468e0f438ef696aa Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:50:18 +0400 Subject: [PATCH 41/82] perf: Use Endpoint.local_broadcast instead of Endpoint.broadcast (#14819) --- .../lib/block_scout_web/notifier.ex | 107 ++++++++++-------- .../lib/block_scout_web/notifiers/arbitrum.ex | 4 +- .../lib/block_scout_web/notifiers/optimism.ex | 4 +- .../notifier_broadcast_test.exs | 81 +++++++++++++ 4 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 apps/block_scout_web/test/block_scout_web/notifier_broadcast_test.exs diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index fe1a8fd3f08..d58223e9d0b 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -2,6 +2,12 @@ defmodule BlockScoutWeb.Notifier do @moduledoc """ Responds to events by sending appropriate channel updates to front-end. + + Every node handles the chain events itself (API nodes receive them from the + database through `Explorer.Chain.Events.Listener`), so the updates are sent to + the subscribers of the current node only. Forwarding them to the other nodes + of the cluster would duplicate the messages there and suspend the handling + process whenever the distribution buffer to one of those nodes is full. """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type], @@ -114,8 +120,8 @@ defmodule BlockScoutWeb.Notifier do addresses_count = AddressesCount.fetch() # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:new_address", "count", %{count: addresses_count}) - Endpoint.broadcast("addresses:new_address", "count", %{count: addresses_count}) + Endpoint.local_broadcast("addresses_old:new_address", "count", %{count: addresses_count}) + Endpoint.local_broadcast("addresses:new_address", "count", %{count: addresses_count}) exchange_rate = Market.get_coin_exchange_rate() @@ -166,7 +172,7 @@ defmodule BlockScoutWeb.Notifier do v2_params = verification_result_params_v2(contract_verification_result) # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast( + Endpoint.local_broadcast( "addresses_old:#{address_hash}", "verification_result", %{ @@ -174,7 +180,7 @@ defmodule BlockScoutWeb.Notifier do } ) - Endpoint.broadcast("addresses:#{address_hash}", "verification_result", v2_params) + Endpoint.local_broadcast("addresses:#{address_hash}", "verification_result", v2_params) end def handle_event( @@ -207,7 +213,7 @@ defmodule BlockScoutWeb.Notifier do end # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast( + Endpoint.local_broadcast( "addresses_old:#{address_hash}", "verification_result", %{ @@ -215,7 +221,7 @@ defmodule BlockScoutWeb.Notifier do } ) - Endpoint.broadcast("addresses:#{address_hash}", "verification_result", v2_params) + Endpoint.local_broadcast("addresses:#{address_hash}", "verification_result", v2_params) end def handle_event({:chain_event, :block_rewards, :realtime, rewards}) do @@ -252,12 +258,12 @@ defmodule BlockScoutWeb.Notifier do end # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("exchange_rate_old:new_rate", "new_rate", %{ + Endpoint.local_broadcast("exchange_rate_old:new_rate", "new_rate", %{ exchange_rate: exchange_rate_with_available_supply, market_history_data: market_history_data }) - Endpoint.broadcast("exchange_rate:new_rate", "new_rate", %{ + Endpoint.local_broadcast("exchange_rate:new_rate", "new_rate", %{ exchange_rate: exchange_rate_with_available_supply.fiat_value, available_supply: exchange_rate_with_available_supply.available_supply, chart_data: market_history_data @@ -273,7 +279,7 @@ defmodule BlockScoutWeb.Notifier do |> Map.get(:transaction_hash) # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("transactions_old:#{transaction_hash}", "raw_trace", %{raw_trace_origin: transaction_hash}) + Endpoint.local_broadcast("transactions_old:#{transaction_hash}", "raw_trace", %{raw_trace_origin: transaction_hash}) internal_transactions = InternalTransaction.all_transaction_to_internal_transactions(transaction_hash) @@ -281,7 +287,7 @@ defmodule BlockScoutWeb.Notifier do raw_trace: TransactionView.render("raw_trace.json", %{internal_transactions: internal_transactions}) } - Endpoint.broadcast("transactions:#{transaction_hash}", "raw_trace", v2_params) + Endpoint.local_broadcast("transactions:#{transaction_hash}", "raw_trace", v2_params) end # internal transactions broadcast disabled on the indexer level, therefore it out of scope of the refactoring within https://github.com/blockscout/blockscout/pull/7474 @@ -306,7 +312,8 @@ defmodule BlockScoutWeb.Notifier do Enum.group_by(all_token_transfers, fn tt -> to_string(tt.token_contract_address_hash) end) for {token_contract_address_hash, token_transfers} <- all_transfers_by_token do - Subscription.publish( + # Local only, like the channel updates (see the moduledoc) + Subscription.Local.publish_mutation( Endpoint, token_transfers, token_transfers: token_contract_address_hash @@ -352,7 +359,7 @@ defmodule BlockScoutWeb.Notifier do date_range = TransactionStats.by_date_range(x_days_back, today) stats = Enum.map(date_range, fn item -> Map.drop(item, [:__meta__]) end) - Endpoint.broadcast("transactions_old:stats", "update", %{stats: stats}) + Endpoint.local_broadcast("transactions_old:stats", "update", %{stats: stats}) end def handle_event( @@ -361,20 +368,20 @@ defmodule BlockScoutWeb.Notifier do ) when not is_nil(total_supply) do # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("tokens_old:#{to_string(contract_address_hash)}", "token_total_supply", %{token: token}) + Endpoint.local_broadcast("tokens_old:#{to_string(contract_address_hash)}", "token_total_supply", %{token: token}) - Endpoint.broadcast("tokens:#{to_string(contract_address_hash)}", "total_supply", %{ + Endpoint.local_broadcast("tokens:#{to_string(contract_address_hash)}", "total_supply", %{ total_supply: to_string(total_supply) }) end def handle_event({:chain_event, :fetched_bytecode, :on_demand, [address_hash, fetched_bytecode]}) do # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{to_string(address_hash)}", "fetched_bytecode", %{ + Endpoint.local_broadcast("addresses_old:#{to_string(address_hash)}", "fetched_bytecode", %{ fetched_bytecode: fetched_bytecode }) - Endpoint.broadcast("addresses:#{to_string(address_hash)}", "fetched_bytecode", %{ + Endpoint.local_broadcast("addresses:#{to_string(address_hash)}", "fetched_bytecode", %{ fetched_bytecode: fetched_bytecode }) end @@ -383,7 +390,7 @@ defmodule BlockScoutWeb.Notifier do {:chain_event, :fetched_token_instance_metadata, :on_demand, [token_contract_address_hash_string, token_id, fetched_token_instance_metadata]} ) do - Endpoint.broadcast( + Endpoint.local_broadcast( "token_instances:#{token_contract_address_hash_string}", "fetched_token_instance_metadata", %{token_id: to_string(token_id), fetched_metadata: fetched_token_instance_metadata} @@ -394,7 +401,7 @@ defmodule BlockScoutWeb.Notifier do {:chain_event, :not_fetched_token_instance_metadata, :on_demand, [token_contract_address_hash_string, token_id, reason]} ) do - Endpoint.broadcast( + Endpoint.local_broadcast( "token_instances:#{token_contract_address_hash_string}", "not_fetched_token_instance_metadata", %{token_id: to_string(token_id), reason: reason} @@ -403,8 +410,8 @@ defmodule BlockScoutWeb.Notifier do def handle_event({:chain_event, :changed_bytecode, :on_demand, [address_hash]}) do # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{to_string(address_hash)}", "changed_bytecode", %{}) - Endpoint.broadcast("addresses:#{to_string(address_hash)}", "changed_bytecode", %{}) + Endpoint.local_broadcast("addresses_old:#{to_string(address_hash)}", "changed_bytecode", %{}) + Endpoint.local_broadcast("addresses:#{to_string(address_hash)}", "changed_bytecode", %{}) end def handle_event({:chain_event, :smart_contract_was_verified = event, :on_demand, [address_hash]}) do @@ -459,7 +466,7 @@ defmodule BlockScoutWeb.Notifier do blocks |> Enum.group_by(&(&1 |> BlockScoutWeb.BlockView.block_type() |> String.downcase())) |> Enum.each(fn {type, blocks_list} -> - Endpoint.broadcast("blocks:new_block", "new_blocks_count", %{count: Enum.count(blocks_list), type: type}) + Endpoint.local_broadcast("blocks:new_block", "new_blocks_count", %{count: Enum.count(blocks_list), type: type}) end) end @@ -538,7 +545,7 @@ defmodule BlockScoutWeb.Notifier do event = "updated_token_balances_" <> event_postfix - Endpoint.broadcast("addresses:#{address_hash}", event, %{ + Endpoint.local_broadcast("addresses:#{address_hash}", event, %{ token_balances: AddressView.render("token_balances.json", %{ token_balances: Enum.take(sorted, @current_token_balances_limit) @@ -567,7 +574,7 @@ defmodule BlockScoutWeb.Notifier do @spec broadcast_indexed_ratio(String.t(), Decimal.t()) :: :ok | {:error, term()} def broadcast_indexed_ratio(msg, ratio) do - Endpoint.broadcast(msg, "index_status", %{ + Endpoint.local_broadcast(msg, "index_status", %{ ratio: Decimal.to_string(ratio), finished: Chain.finished_indexing_from_ratio?(ratio) }) @@ -617,7 +624,7 @@ defmodule BlockScoutWeb.Notifier do if coin_balance && coin_balance.delta && !Decimal.eq?(coin_balance.delta, Decimal.new(0)) do # TODO: delete duplicated event when old UI becomes deprecated if old_subscribers? do - Endpoint.broadcast("addresses_old:#{address_hash}", "coin_balance", %{ + Endpoint.local_broadcast("addresses_old:#{address_hash}", "coin_balance", %{ block_number: block_number, coin_balance: coin_balance }) @@ -632,11 +639,11 @@ defmodule BlockScoutWeb.Notifier do defp broadcast_address_coin_balance_v2(address_hash, block_number, coin_balance, exchange_rate) do rendered_coin_balance = AddressView.render("coin_balance.json", %{coin_balance: coin_balance}) - Endpoint.broadcast("addresses:#{address_hash}", "coin_balance", %{ + Endpoint.local_broadcast("addresses:#{address_hash}", "coin_balance", %{ coin_balance: rendered_coin_balance }) - Endpoint.broadcast("addresses:#{address_hash}", "current_coin_balance", %{ + Endpoint.local_broadcast("addresses:#{address_hash}", "current_coin_balance", %{ coin_balance: coin_balance.value, exchange_rate: exchange_rate.fiat_value, block_number: block_number @@ -644,20 +651,20 @@ defmodule BlockScoutWeb.Notifier do end defp broadcast_address_token_balance_v2(%{address_hash: address_hash, block_number: block_number}) do - Endpoint.broadcast("addresses:#{address_hash}", "token_balance", %{ + Endpoint.local_broadcast("addresses:#{address_hash}", "token_balance", %{ block_number: block_number }) end # TODO: delete this function when old UI becomes deprecated defp broadcast_address_token_balance_v1(%{address_hash: address_hash, block_number: block_number}) do - Endpoint.broadcast("addresses_old:#{address_hash}", "token_balance", %{ + Endpoint.local_broadcast("addresses_old:#{address_hash}", "token_balance", %{ block_number: block_number }) end defp broadcast_balance_v2(%Address{hash: address_hash} = address, exchange_rate) do - Endpoint.broadcast("addresses:#{address_hash}", "balance", %{ + Endpoint.local_broadcast("addresses:#{address_hash}", "balance", %{ balance: address.fetched_coin_balance.value, block_number: address.fetched_coin_balance_block_number, exchange_rate: exchange_rate.fiat_value @@ -666,7 +673,7 @@ defmodule BlockScoutWeb.Notifier do # TODO: delete this function when old UI becomes deprecated defp broadcast_balance_v1(%Address{hash: address_hash} = address, exchange_rate) do - Endpoint.broadcast( + Endpoint.local_broadcast( "addresses_old:#{address_hash}", "balance_update", %{ @@ -691,12 +698,12 @@ defmodule BlockScoutWeb.Notifier do average_block_time = AverageBlockTime.average_block_time() # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("blocks_old:new_block", "new_block", %{ + Endpoint.local_broadcast("blocks_old:new_block", "new_block", %{ block: preloaded_block, average_block_time: average_block_time }) - Endpoint.broadcast("blocks_old:#{to_string(block.miner_hash)}", "new_block", %{ + Endpoint.local_broadcast("blocks_old:#{to_string(block.miner_hash)}", "new_block", %{ block: preloaded_block, average_block_time: average_block_time }) @@ -710,8 +717,8 @@ defmodule BlockScoutWeb.Notifier do }) } - Endpoint.broadcast("blocks:new_block", "new_block", block_params_v2) - Endpoint.broadcast("blocks:#{to_string(block.miner_hash)}", "new_block", block_params_v2) + Endpoint.local_broadcast("blocks:new_block", "new_block", block_params_v2) + Endpoint.local_broadcast("blocks:#{to_string(block.miner_hash)}", "new_block", block_params_v2) end defp maybe_preload_enrichment_for_broadcast(block) do @@ -761,7 +768,7 @@ defmodule BlockScoutWeb.Notifier do Enum.split_with(rewards, fn reward -> reward.address_type == :emission_funds end) Enum.each(validator_rewards, fn reward -> - Endpoint.broadcast("rewards:#{to_string(reward.address_hash)}", "new_reward", %{reward: 1}) + Endpoint.local_broadcast("rewards:#{to_string(reward.address_hash)}", "new_reward", %{reward: 1}) end) # TODO: delete duplicated event when old UI becomes deprecated @@ -780,7 +787,7 @@ defmodule BlockScoutWeb.Notifier do relevant_rewards |> Repo.preload([:address, :block]) |> Enum.each(fn reward -> - Endpoint.broadcast("rewards_old:#{to_string(reward.address_hash)}", "new_reward", %{ + Endpoint.local_broadcast("rewards_old:#{to_string(reward.address_hash)}", "new_reward", %{ emission_funds: emission_reward, validator: reward }) @@ -789,13 +796,13 @@ defmodule BlockScoutWeb.Notifier do end defp broadcast_internal_transaction(internal_transaction) do - Endpoint.broadcast("addresses_old:#{internal_transaction.from_address_hash}", "internal_transaction", %{ + Endpoint.local_broadcast("addresses_old:#{internal_transaction.from_address_hash}", "internal_transaction", %{ address: internal_transaction.from_address, internal_transaction: internal_transaction }) if internal_transaction.to_address_hash != internal_transaction.from_address_hash do - Endpoint.broadcast("addresses_old:#{internal_transaction.to_address_hash}", "internal_transaction", %{ + Endpoint.local_broadcast("addresses_old:#{internal_transaction.to_address_hash}", "internal_transaction", %{ address: internal_transaction.to_address, internal_transaction: internal_transaction }) @@ -820,7 +827,7 @@ defmodule BlockScoutWeb.Notifier do defp broadcast_transactions_websocket_v2_inner(transactions, default_channel, event) do if not Enum.empty?(transactions) do - Endpoint.broadcast(default_channel, event, %{ + Endpoint.local_broadcast(default_channel, event, %{ String.to_existing_atom(event) => Enum.count(transactions) }) end @@ -892,19 +899,19 @@ defmodule BlockScoutWeb.Notifier do end defp broadcast_transaction(transaction, transaction_channel, event) do - Endpoint.broadcast("transactions_old:#{transaction.hash}", "collated", %{}) + Endpoint.local_broadcast("transactions_old:#{transaction.hash}", "collated", %{}) - Endpoint.broadcast(transaction_channel, event, %{ + Endpoint.local_broadcast(transaction_channel, event, %{ transaction: transaction }) - Endpoint.broadcast("addresses_old:#{transaction.from_address_hash}", event, %{ + Endpoint.local_broadcast("addresses_old:#{transaction.from_address_hash}", event, %{ address: transaction.from_address, transaction: transaction }) if transaction.to_address_hash != transaction.from_address_hash do - Endpoint.broadcast("addresses_old:#{transaction.to_address_hash}", event, %{ + Endpoint.local_broadcast("addresses_old:#{transaction.to_address_hash}", event, %{ address: transaction.to_address, transaction: transaction }) @@ -919,7 +926,7 @@ defmodule BlockScoutWeb.Notifier do relevant_token_transfers -> for {token_contract_address_hash, token_transfers} <- Enum.group_by(relevant_token_transfers, fn tt -> to_string(tt.token_contract_address_hash) end) do - Endpoint.broadcast("tokens:#{token_contract_address_hash}", "token_transfer", %{ + Endpoint.local_broadcast("tokens:#{token_contract_address_hash}", "token_transfer", %{ token_transfer: Enum.count(token_transfers) }) end @@ -952,18 +959,18 @@ defmodule BlockScoutWeb.Notifier do end defp broadcast_token_transfer(token_transfer, event) do - Endpoint.broadcast("addresses_old:#{token_transfer.from_address_hash}", event, %{ + Endpoint.local_broadcast("addresses_old:#{token_transfer.from_address_hash}", event, %{ address: token_transfer.from_address, token_transfer: token_transfer }) - Endpoint.broadcast("tokens_old:#{token_transfer.token_contract_address_hash}", event, %{ + Endpoint.local_broadcast("tokens_old:#{token_transfer.token_contract_address_hash}", event, %{ address: token_transfer.token_contract_address_hash, token_transfer: token_transfer }) if token_transfer.to_address_hash != token_transfer.from_address_hash do - Endpoint.broadcast("addresses_old:#{token_transfer.to_address_hash}", event, %{ + Endpoint.local_broadcast("addresses_old:#{token_transfer.to_address_hash}", event, %{ address: token_transfer.to_address, token_transfer: token_transfer }) @@ -982,7 +989,7 @@ defmodule BlockScoutWeb.Notifier do grouped = Map.merge(grouped_by_to, grouped_by_from, fn _k, v1, v2 -> Enum.uniq_by(v1 ++ v2, uniq_function) end) for {address_hash, elements} <- grouped do - Endpoint.broadcast("addresses:#{address_hash}", event, %{map_key => elements}) + Endpoint.local_broadcast("addresses:#{address_hash}", event, %{map_key => elements}) end end @@ -997,8 +1004,8 @@ defmodule BlockScoutWeb.Notifier do defp broadcast_automatic_verification_events(event, address_hash) do log_broadcast_smart_contract_event(address_hash, event) # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{to_string(address_hash)}", to_string(event), %{}) - Endpoint.broadcast("addresses:#{to_string(address_hash)}", to_string(event), %{}) + Endpoint.local_broadcast("addresses_old:#{to_string(address_hash)}", to_string(event), %{}) + Endpoint.local_broadcast("addresses:#{to_string(address_hash)}", to_string(event), %{}) end defp has_subscribers?(topic) when is_binary(topic) do diff --git a/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex b/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex index b6bf0149aa1..42d135d4674 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex @@ -13,14 +13,14 @@ defmodule BlockScoutWeb.Notifiers.Arbitrum do batches |> Enum.sort_by(& &1.number, :asc) |> Enum.each(fn batch -> - Endpoint.broadcast("arbitrum:new_batch", "new_arbitrum_batch", %{ + Endpoint.local_broadcast("arbitrum:new_batch", "new_arbitrum_batch", %{ batch: ArbitrumView.render_base_info_for_batch(batch) }) end) end def handle_event({:chain_event, :new_messages_to_arbitrum_amount, :realtime, new_messages_amount}) do - Endpoint.broadcast("arbitrum:new_messages_to_rollup_amount", "new_messages_to_rollup_amount", %{ + Endpoint.local_broadcast("arbitrum:new_messages_to_rollup_amount", "new_messages_to_rollup_amount", %{ new_messages_to_rollup_amount: new_messages_amount }) end diff --git a/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex b/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex index 0e9648bff51..9043a8b1cce 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex @@ -12,7 +12,7 @@ defmodule BlockScoutWeb.Notifiers.Optimism do batches |> Enum.sort_by(& &1.number, :asc) |> Enum.each(fn batch -> - Endpoint.broadcast("optimism:new_batch", "new_optimism_batch", %{ + Endpoint.local_broadcast("optimism:new_batch", "new_optimism_batch", %{ batch: batch }) end) @@ -22,7 +22,7 @@ defmodule BlockScoutWeb.Notifiers.Optimism do deposits_count = Enum.count(deposits) if deposits_count > 0 do - Endpoint.broadcast("optimism:new_deposits", "new_optimism_deposits", %{ + Endpoint.local_broadcast("optimism:new_deposits", "new_optimism_deposits", %{ deposits: deposits_count }) end diff --git a/apps/block_scout_web/test/block_scout_web/notifier_broadcast_test.exs b/apps/block_scout_web/test/block_scout_web/notifier_broadcast_test.exs new file mode 100644 index 00000000000..3589a3fe26e --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/notifier_broadcast_test.exs @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.NotifierBroadcastTest do + use BlockScoutWeb.SubscriptionCase, + async: false + + alias BlockScoutWeb.Notifier + + defmodule ClusterBroadcastSpy do + @moduledoc false + + # Takes the place of the PubSub adapter, whose `broadcast/4` sends a broadcast + # to the other nodes of the cluster, and reports such broadcasts to the test + # process given as the adapter name. + def broadcast(test_pid, topic, message, _dispatcher) do + send(test_pid, {:cluster_broadcast, topic, message}) + :ok + end + + def node_name(_test_pid), do: node() + end + + setup do + {:ok, {adapter, adapter_name, dispatcher}} = Registry.meta(BlockScoutWeb.PubSub, :pubsub) + Registry.put_meta(BlockScoutWeb.PubSub, :pubsub, {ClusterBroadcastSpy, self(), dispatcher}) + on_exit(fn -> Registry.put_meta(BlockScoutWeb.PubSub, :pubsub, {adapter, adapter_name, dispatcher}) end) + + :ok + end + + # Makes sure that the refutations in the other tests cannot pass vacuously. + test "the spy detects a broadcast sent to the cluster" do + topic = "test:#{System.unique_integer([:positive])}" + + @endpoint.broadcast(topic, "event", %{}) + + assert_received {:cluster_broadcast, ^topic, _} + end + + test "sends channel updates to the subscribers of the current node only" do + address = insert(:address) + topic = "addresses:#{address.hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event({:chain_event, :changed_bytecode, :on_demand, [address.hash]}) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "changed_bytecode"} + refute_received {:cluster_broadcast, ^topic, _} + end + + test "publishes GraphQL subscription updates on the current node only", %{socket: socket} do + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction) + token_contract_address_hash = to_string(token_transfer.token_contract_address_hash) + + subscription = """ + subscription ($hash: AddressHash!) { + token_transfers(token_contract_address_hash: $hash) { + transaction_hash + } + } + """ + + ref = push_doc(socket, subscription, variables: %{"hash" => token_contract_address_hash}) + assert_reply(ref, :ok, %{subscriptionId: subscription_id}) + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [token_transfer]}) + + assert_push("subscription:data", push) + + assert push == %{ + result: %{ + data: %{ + "token_transfers" => [%{"transaction_hash" => to_string(token_transfer.transaction_hash)}] + } + }, + subscriptionId: subscription_id + } + + refute_received {:cluster_broadcast, "__absinthe__:proxy:" <> _, %{mutation_result: [^token_transfer]}} + end +end From 982fb44d95b6171a1f4d347c00ac7a72040a47be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:50:42 +0300 Subject: [PATCH 42/82] chore(deps): bump redix from 1.8.2 to 1.9.1 (#14823) 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 764c84f7276..222323e7649 100644 --- a/mix.lock +++ b/mix.lock @@ -156,7 +156,7 @@ "que": {:hex, :que, "0.12.0", "4629c156036b8a3bf9ffdd79aa4069ab414822144d3ef031b5cc61fc805942d3", [:mix], [{:ex_utils, "~> 0.1", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.6", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "906651d60b1036840f1afacc0036541583eda9ba4b0b03f322530a1c84100507"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "redix": {:hex, :redix, "1.8.2", "7f0bb263c7c74b62ae5db3922146cbaa5d953993f8d678fce28ab9a24b12cd79", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6912716ae2f9aeb4f867383fc9b8522317528c6d183daefdc12c9cb7696dbd9"}, + "redix": {:hex, :redix, "1.9.1", "99473ccc538b97eb169beea133e788c236850e84ec3bcc785ef414ffca9fb2e2", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b873953d06f893f88bb92a515ff9f9c0b65662576de27fb810e76cff3609ebab"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "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"}, From 4b99cc9416979261c0109853532bc348d1d58260 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:51:04 +0300 Subject: [PATCH 43/82] chore(deps): bump mox from 1.1.0 to 1.3.2 (#14825) 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/indexer/mix.exs | 2 +- mix.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 9408864f38d..ed0e59b226e 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -70,7 +70,7 @@ defmodule EthereumJSONRPC.MixProject do {:logger_file_backend, "~> 0.0.10"}, {:logger_json, "~> 7.0"}, # Mocking `EthereumJSONRPC.Transport` and `EthereumJSONRPC.HTTP` so we avoid hitting real chains for local testing - {:mox, "~> 1.1.0", only: [:test]}, + {:mox, "~> 1.3.2", only: [:test]}, {:prometheus_ex, "~> 5.1.0", override: true}, # Tracing {:spandex, "~> 3.0"}, diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 0d73ae29c1e..eef800239da 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -93,7 +93,7 @@ defmodule Explorer.Mixfile do {:logger_json, "~> 7.0"}, {:math, "~> 0.7.0"}, {:mock, "~> 0.3.0", only: [:test], runtime: false}, - {:mox, "~> 1.1.0"}, + {:mox, "~> 1.3.2"}, {:poison, "~> 5.0.0"}, {:nimble_csv, "~> 1.1"}, {:postgrex, ">= 0.0.0"}, diff --git a/apps/indexer/mix.exs b/apps/indexer/mix.exs index 9d7b8454939..8d2fb44980a 100644 --- a/apps/indexer/mix.exs +++ b/apps/indexer/mix.exs @@ -66,7 +66,7 @@ defmodule Indexer.MixProject do {:logger_file_backend, "~> 0.0.10"}, {:logger_json, "~> 7.0"}, # Mocking `EthereumJSONRPC.Transport`, so we avoid hitting real chains for local testing - {:mox, "~> 1.1.0"}, + {:mox, "~> 1.3.2"}, {:prometheus_ex, "~> 5.1.0", override: true}, # Tracing {:spandex, "~> 3.0"}, diff --git a/mix.lock b/mix.lock index 222323e7649..5802ec8ebfe 100644 --- a/mix.lock +++ b/mix.lock @@ -115,7 +115,7 @@ "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, "mnemoniac": {:hex, :mnemoniac, "0.1.5", "d10bf14abb94adc1a98f6cee4d81d197e1b3986cad8ec43b2e21d2cd9f4d99b6", [:mix], [], "hexpm", "518ae4b6ddda5285a5c02bf83e1eb6ce1af7e4d65d3f7f5bff3270689a00bcc3"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, - "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "mox": {:hex, :mox, "1.3.2", "f34ca4331b1cce3125609c6de674739fe5f3df0d68df25510d64b6a94b2246de", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "97918a185e727f3128a826f01827b5b8168e1b815bda19eb4192c3ffdb6a8054"}, "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, From 5221aff987b01e4929dcf1baee95b44584ac71fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:51:29 +0300 Subject: [PATCH 44/82] chore(deps): bump hammer_backend_redis from 7.1.1 to 7.2.0 (#14826) 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 5802ec8ebfe..eacff3db9de 100644 --- a/mix.lock +++ b/mix.lock @@ -84,7 +84,7 @@ "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, - "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.1.1", "979362db9e6f30b9b71f671b28550b5192cd5c2614e60da6ee9347d8085b962b", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "717bb15f14e709dcae67ba67f90229d2aaee2b6a9bfcc2ca96212f414cd474dc"}, + "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, From bcc8d173899c4eba307976561657081f05bd70fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:19 +0300 Subject: [PATCH 45/82] chore(deps-dev): bump cowboy from 2.18.0 to 2.19.0 (#14824) 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 eacff3db9de..3465bcad2c2 100644 --- a/mix.lock +++ b/mix.lock @@ -29,9 +29,9 @@ "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, - "cowboy": {:hex, :cowboy, "2.18.0", "bff388eb4d6356cb3f88c26e65b515976bf04b401b805d31550c3e60eba8fe18", [:make, :rebar3], [{:cowlib, ">= 2.19.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "62d0b26abcf455054972b0da242389c69d5982ce5914afb8c344517f667b9600"}, + "cowboy": {:hex, :cowboy, "2.19.0", "78b9d92d25a23e56d7341040b67946c04067cd77de966e2372865f673a8e6f59", [:make, :rebar3], [{:cowlib, ">= 2.20.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "986dae81f99fcb78ef2d8efc21d738cee189410840eeaf32eca84ada81dcf6d4"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.19.0", "c9d11c9d035472e27a740c9f327786c61ed209269b4be0260d59d3ec07b8949f", [:make, :rebar3], [], "hexpm", "6dc66e3135b229193ea4dcb14294e79520c923d391315c9c962ef0b4bea72356"}, + "cowlib": {:hex, :cowlib, "2.20.0", "bb525377ba634cd6d68bac7bff5d98571f738806139d335daca827717c5dc172", [:make, :rebar3], [], "hexpm", "7d41a0dd2c093041ff3779ac5fe8a1585a68ec7cb2dd1de0536bdd2452fd7ba1"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "csv": {:hex, :csv, "2.5.0", "c47b5a5221bf2e56d6e8eb79e77884046d7fd516280dc7d9b674251e0ae46246", [:mix], [{:parallel_stream, "~> 1.0.4 or ~> 1.1.0", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "e821f541487045c7591a1963eeb42afff0dfa99bdcdbeb3410795a2f59c77d34"}, "dataloader": {:hex, :dataloader, "2.0.2", "c45075e0692e68638a315e14f747bd8d7065fb5f38705cf980f62d4cd344401f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c6cabc0b55e96e7de74d14bf37f4a5786f0ab69aa06764a1f39dda40079b098"}, From d1c51cfdfaf10c90ab09820ab1750687d5ae5742 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 13:54:57 +0300 Subject: [PATCH 46/82] perf: reuse preloaded proxy and ABI associations in input decoding (#14827) --- .../lib/explorer/chain/smart_contract.ex | 32 +++++ .../lib/explorer/chain/transaction.ex | 127 +++++++++++++----- .../test/explorer/chain/transaction_test.exs | 109 ++++++++++++++- 3 files changed, 234 insertions(+), 34 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/smart_contract.ex b/apps/explorer/lib/explorer/chain/smart_contract.ex index 590a414530e..9f48cebfa27 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract.ex @@ -1295,6 +1295,38 @@ defmodule Explorer.Chain.SmartContract do [] end + @doc """ + Gets ABIs of the verified smart contracts among the given address hashes in a + single query. + + Only the `abi` column is selected. Address hashes without a verified smart + contract are absent from the result. + + ## Parameters + - `address_hashes`: The address hashes to look up. + - `options`: An optional keyword list of options, such as selecting a specific repository. + + ## Returns + - A map of address hash to ABI. + """ + @spec abis_by_address_hashes([Hash.Address.t()], Keyword.t()) :: %{Hash.Address.t() => abi() | nil} + def abis_by_address_hashes(address_hashes, options \\ []) + + def abis_by_address_hashes([], _options), do: %{} + + def abis_by_address_hashes(address_hashes, options) do + query = + from( + smart_contract in __MODULE__, + where: smart_contract.address_hash in ^address_hashes, + select: {smart_contract.address_hash, smart_contract.abi} + ) + + query + |> Chain.select_repo(options).all() + |> Map.new() + end + @doc """ Composes a query for fetching a smart contract by its address hash. diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 012fca533bb..3970dfdfd61 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -338,6 +338,7 @@ defmodule Explorer.Chain.Transaction do Hash, InternalTransaction, MethodIdentifier, + SmartContract, SmartContract.Proxy, TokenTransfer, Wei @@ -2485,52 +2486,112 @@ defmodule Explorer.Chain.Transaction do defp decode_remaining_transaction({decoded, _}, _, _, _, _), do: decoded + # Builds %{target address hash => combined proxy + implementations ABI} for the + # to_address / created_contract_address of every transaction. + # + # Associations already present on the address structs (`smart_contract` with + # its `abi`, `proxy_implementations`, and the implementations' + # `smart_contracts`) are reused, so a fully preloaded target address costs no + # query at all. Whatever is missing is fetched in at most two queries: one for + # proxy implementations, one for all outstanding ABIs. defp combine_smart_contract_full_abi_map(transactions, opts) do - # parse unique address hashes of smart-contracts from to_address and created_contract_address properties of the transactions list - unique_to_address_hashes = + # unique target addresses of the transactions list: to_address, or + # created_contract_address for contract creations + target_addresses = transactions |> Enum.flat_map(fn - %__MODULE__{to_address: %Address{hash: hash}} -> [hash] - %__MODULE__{created_contract_address: %Address{hash: hash}} -> [hash] + %__MODULE__{to_address: %Address{} = address} -> [address] + %__MODULE__{created_contract_address: %Address{} = address} -> [address] _ -> [] end) - |> Enum.uniq() + |> Enum.uniq_by(& &1.hash) - # query from the DB proxy implementation objects for those address hashes - multiple_proxy_implementations = - Implementation.get_proxy_implementations_for_multiple_proxies(unique_to_address_hashes, opts) - - # query from the DB address objects with smart_contract preload for all found above proxy and implementation addresses - addresses_with_smart_contracts = - multiple_proxy_implementations - |> Enum.flat_map(fn proxy_implementations -> proxy_implementations.address_hashes end) - |> Enum.concat(unique_to_address_hashes) - |> Chain.hashes_to_addresses( - Keyword.merge(Keyword.take(opts, [:api?]), necessity_by_association: %{smart_contract: :optional}) - ) - |> Enum.into(%{}, &{&1.hash, &1}) + # %{target address hash => implementation address hashes} + implementation_hashes_map = implementation_hashes_by_target_address(target_addresses, opts) - # combine map %{proxy_address_hash => implementation address hashes} - proxy_implementations_map = - multiple_proxy_implementations - |> Enum.into(%{}, &{&1.proxy_address_hash, &1.address_hashes}) + # %{address hash => abi} for every target address and its implementations + abis_map = abis_by_address_hash(target_addresses, implementation_hashes_map, opts) - # combine map %{proxy_address_hash => combined proxy abi} - unique_to_address_hashes - |> Enum.into(%{}, fn to_address_hash -> + Map.new(target_addresses, fn %Address{hash: target_hash} -> full_abi = - [to_address_hash | Map.get(proxy_implementations_map, to_address_hash, [])] - |> Enum.map(&Map.get(addresses_with_smart_contracts, &1)) - |> Enum.flat_map(fn - %{smart_contract: %{abi: abi}} when is_list(abi) -> abi - _ -> [] - end) - |> Enum.filter(&(!is_nil(&1))) + [target_hash | Map.get(implementation_hashes_map, target_hash, [])] + |> Enum.flat_map(&abi_from_map(abis_map, &1)) + |> Enum.reject(&is_nil/1) - {to_address_hash, full_abi} + {target_hash, full_abi} end) end + defp abi_from_map(abis_map, address_hash) do + case Map.get(abis_map, address_hash) do + abi when is_list(abi) -> abi + _ -> [] + end + end + + # Takes implementation address hashes from the preloaded `proxy_implementations` + # association where present and queries them in one go for the rest. + defp implementation_hashes_by_target_address(target_addresses, opts) do + {preloaded_map, not_loaded_hashes} = + Enum.reduce(target_addresses, {%{}, []}, fn + %Address{hash: hash, proxy_implementations: %Implementation{address_hashes: address_hashes}}, + {preloaded_map, not_loaded_hashes} -> + {Map.put(preloaded_map, hash, address_hashes), not_loaded_hashes} + + # a loaded `has_one` without a row: the address is not a proxy + %Address{proxy_implementations: nil}, acc -> + acc + + %Address{hash: hash}, {preloaded_map, not_loaded_hashes} -> + {preloaded_map, [hash | not_loaded_hashes]} + end) + + not_loaded_hashes + |> Implementation.get_proxy_implementations_for_multiple_proxies(opts) + |> Enum.reduce(preloaded_map, &Map.put(&2, &1.proxy_address_hash, &1.address_hashes)) + end + + # Takes ABIs from the preloaded `smart_contract` and + # `proxy_implementations.smart_contracts` associations where present and + # fetches the outstanding ones in a single query. + defp abis_by_address_hash(target_addresses, implementation_hashes_map, opts) do + preloaded_abis_map = + Enum.reduce(target_addresses, %{}, fn %Address{hash: hash} = address, acc -> + acc + |> put_preloaded_abi(hash, address.smart_contract) + |> put_preloaded_implementation_abis(address.proxy_implementations) + end) + + target_addresses + |> Enum.flat_map(fn %Address{hash: hash} -> [hash | Map.get(implementation_hashes_map, hash, [])] end) + |> Enum.uniq() + |> Enum.reject(&Map.has_key?(preloaded_abis_map, &1)) + |> SmartContract.abis_by_address_hashes(opts) + |> Map.merge(preloaded_abis_map) + end + + # a loaded `has_one` without a row: the address is not a verified contract + defp put_preloaded_abi(acc, hash, nil), do: Map.put(acc, hash, []) + + # `abi` is a list only when the smart contract was loaded with its ABI; the + # ABI-less preload (`SmartContract.association_without_abi/0`) leaves it `nil` + # and falls through to the fetch below + defp put_preloaded_abi(acc, hash, %SmartContract{abi: abi}) when is_list(abi), do: Map.put(acc, hash, abi) + + defp put_preloaded_abi(acc, _hash, _not_loaded_or_without_abi), do: acc + + defp put_preloaded_implementation_abis( + acc, + %Implementation{address_hashes: address_hashes, smart_contracts: smart_contracts} + ) + when is_list(smart_contracts) do + smart_contracts_map = Map.new(smart_contracts, &{&1.address_hash, &1}) + + Enum.reduce(address_hashes, acc, &put_preloaded_abi(&2, &1, Map.get(smart_contracts_map, &1))) + end + + defp put_preloaded_implementation_abis(acc, _not_loaded), do: acc + @doc """ Receives as input result of decoded_input_data/5, returns either nil or decoded input in format: {:ok, _identifier, _text, _mapping} """ diff --git a/apps/explorer/test/explorer/chain/transaction_test.exs b/apps/explorer/test/explorer/chain/transaction_test.exs index 22e4faacae5..01c2b8cd5ce 100644 --- a/apps/explorer/test/explorer/chain/transaction_test.exs +++ b/apps/explorer/test/explorer/chain/transaction_test.exs @@ -5,7 +5,8 @@ defmodule Explorer.Chain.TransactionTest do import Mox alias Ecto.Changeset - alias Explorer.Chain.{Address, InternalTransaction, Transaction} + alias Explorer.Chain.{Address, InternalTransaction, SmartContract, Transaction} + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.{PagingOptions, TestHelper} doctest Transaction @@ -392,6 +393,112 @@ defmodule Explorer.Chain.TransactionTest do end end + describe "decode_transactions/3" do + @decoded_set_10 {:ok, "60fe47b1", "set(uint256 x)", [{"x", "uint256", 10}]} + @abi_sources ["proxy_implementations", "smart_contracts", "addresses"] + + test "decodes input with the implementation ABI of a proxy whose associations are not preloaded" do + transaction = + insert_transaction_to_verified_proxy() + |> Repo.preload(to_address: :smart_contract) + + assert [@decoded_set_10] = Transaction.decode_transactions([transaction], true, api?: true) + end + + test "reuses preloaded proxy implementations and ABIs without querying them again" do + transaction = + insert_transaction_to_verified_proxy() + |> Repo.preload( + to_address: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()] + ) + + {result, sources} = + with_query_sources(fn -> Transaction.decode_transactions([transaction], true, api?: true) end) + + assert [@decoded_set_10] = result + assert Enum.filter(sources, &(&1 in @abi_sources)) == [] + end + + test "skips the proxy implementations query when the association is preloaded and empty" do + smart_contract = insert(:smart_contract) |> Repo.preload(:address) + input_data = "set(uint)" |> ABI.encode([10]) |> Base.encode16(case: :lower) + + transaction = + :transaction + |> insert(to_address: smart_contract.address, input: "0x" <> input_data) + |> Repo.preload(to_address: [:smart_contract, :proxy_implementations]) + + {result, sources} = + with_query_sources(fn -> Transaction.decode_transactions([transaction], true, api?: true) end) + + assert [@decoded_set_10] = result + assert Enum.filter(sources, &(&1 in @abi_sources)) == [] + end + + test "fetches the ABI when the smart contract was preloaded without it" do + transaction = + insert_transaction_to_verified_proxy() + |> Repo.preload(to_address: [SmartContract.association_without_abi(), :proxy_implementations]) + + {result, sources} = + with_query_sources(fn -> Transaction.decode_transactions([transaction], true, api?: true) end) + + assert [@decoded_set_10] = result + assert "smart_contracts" in sources + refute "proxy_implementations" in sources + end + end + + # a verified proxy whose own ABI lacks `set(uint256)`, delegating to a + # verified implementation whose ABI has it + defp insert_transaction_to_verified_proxy do + proxy_smart_contract = :smart_contract |> insert(abi: []) |> Repo.preload(:address) + implementation_smart_contract = insert(:smart_contract) + + insert(:proxy_implementation, + proxy_address_hash: proxy_smart_contract.address_hash, + proxy_type: :eip1967, + address_hashes: [implementation_smart_contract.address_hash], + names: [implementation_smart_contract.name] + ) + + input_data = "set(uint)" |> ABI.encode([10]) |> Base.encode16(case: :lower) + + insert(:transaction, to_address: proxy_smart_contract.address, input: "0x" <> input_data) + end + + # runs `fun` and returns its result with the `source` tables of every + # query issued by the calling process meanwhile + defp with_query_sources(fun) do + handler_id = {__MODULE__, :query_sources, make_ref()} + + :telemetry.attach_many( + handler_id, + [[:explorer, :repo, :query], [:explorer, :repo, :replica1, :query]], + &__MODULE__.handle_query_event/4, + self() + ) + + try do + {fun.(), collect_query_sources([])} + after + :telemetry.detach(handler_id) + end + end + + defp collect_query_sources(acc) do + receive do + {:query_source, source} -> collect_query_sources([source | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + @doc false + def handle_query_event(_event, _measurements, %{source: source}, test_pid) do + if self() == test_pid, do: send(test_pid, {:query_source, source}) + end + describe "Poison.encode!/1" do test "encodes transaction input" do assert %{ From 075ca1cacc5bd67c34e31191a01518b51e53a5a4 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 13:56:16 +0300 Subject: [PATCH 47/82] perf: preload signed_authorizations only for EIP-7702 transactions (#14828) --- .../api/v2/transaction_controller.ex | 20 +++++------ .../api/v2/transaction_controller_test.exs | 31 +++++++++++++++++ .../lib/explorer/chain/transaction.ex | 33 ++++++++++++++++++ apps/explorer/lib/explorer/eth_rpc.ex | 34 ++++++++++++------- 4 files changed, 95 insertions(+), 23 deletions(-) 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 b24bfd79559..fa45a54e3a8 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 @@ -169,21 +169,20 @@ defmodule BlockScoutWeb.API.V2.TransactionController do """ @spec transaction(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def transaction(conn, %{transaction_hash_param: transaction_hash_string} = params) do - necessity_by_association_with_actions = - @transaction_necessity_by_association - |> Map.put(:signed_authorizations, :optional) - + # `signed_authorizations` are not part of the necessity map on purpose: only + # EIP-7702 (type 4) transactions have any, so they are preloaded below via + # `Transaction.preload_signed_authorizations/2` once the type is known. necessity_by_association = case Application.get_env(:explorer, :chain_type) do :zksync -> - necessity_by_association_with_actions + @transaction_necessity_by_association |> Map.put(:zksync_batch, :optional) |> Map.put(:zksync_commit_transaction, :optional) |> Map.put(:zksync_prove_transaction, :optional) |> Map.put(:zksync_execute_transaction, :optional) :arbitrum -> - necessity_by_association_with_actions + @transaction_necessity_by_association |> Map.put(:arbitrum_batch, :optional) |> Map.put(:arbitrum_commitment_transaction, :optional) |> Map.put(:arbitrum_confirmation_transaction, :optional) @@ -191,14 +190,14 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Map.put(:arbitrum_message_from_l2, :optional) :suave -> - necessity_by_association_with_actions + @transaction_necessity_by_association |> Map.put(:logs, :optional) |> Map.put([execution_node: :names], :optional) |> Map.put([wrapped_to_address: :names], :optional) :eden -> Map.put( - necessity_by_association_with_actions, + @transaction_necessity_by_association, [ fee_payer_address: [ :scam_badge, @@ -211,7 +210,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do ) _ -> - necessity_by_association_with_actions + @transaction_necessity_by_association end options = @@ -219,9 +218,10 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Keyword.merge(@api_true) with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params, options), + preloaded <- Transaction.preload_signed_authorizations(transaction, @api_true), preloaded <- Chain.preload_token_transfers( - transaction, + preloaded, @token_transfers_in_transaction_necessity_by_association, @api_true |> fetch_scam_token_toggle(conn) ), 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 4710a54773f..be79004eb44 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 @@ -224,6 +224,37 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end describe "/transactions/{transaction_hash}" do + test "returns authorization list for EIP-7702 (type 4) transaction", %{conn: conn} do + transaction = + :transaction + |> insert(type: 4) + |> with_block() + + authorization = insert(:signed_authorization, transaction: transaction, index: 0) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert [%{"address_hash" => address_hash, "authority" => authority, "nonce" => "0"}] = + response["authorization_list"] + + assert address_hash == Address.checksum(authorization.address) + assert authority == Address.checksum(authorization.authority) + assert "set_code_transaction" in response["transaction_types"] + end + + test "returns empty authorization list for non-EIP-7702 transaction", %{conn: conn} do + transaction = + :transaction + |> insert(type: 2) + |> with_block() + + request = get(conn, "/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert response["authorization_list"] == [] + end + test "get token-transfers with ok reputation", %{conn: conn} do init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) Application.put_env(:block_scout_web, :hide_scam_addresses, true) diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 3970dfdfd61..7b6ccdbc0a0 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -2398,6 +2398,39 @@ defmodule Explorer.Chain.Transaction do ) end + # EIP-7702 set code transaction type: the only type carrying an authorization list. + @set_code_transaction_type 4 + + @doc """ + Preloads `signed_authorizations` for a transaction, but only when it can have + any: EIP-7702 set code transactions (type #{@set_code_transaction_type}). + + Every other transaction type gets an empty list without touching the DB, + which saves one query per transaction on the single-transaction endpoints. + A transaction whose `signed_authorizations` are already loaded is returned + as is. + + ## Parameters + - `transaction`: The transaction to preload. + - `options`: Keyword list with `api?` used to pick the replica repo. + + ## Returns + - The transaction with `signed_authorizations` loaded (or set to `[]`). + """ + @spec preload_signed_authorizations(t(), Keyword.t()) :: t() + def preload_signed_authorizations(%__MODULE__{signed_authorizations: signed_authorizations} = transaction, _options) + when is_list(signed_authorizations) do + transaction + end + + def preload_signed_authorizations(%__MODULE__{type: @set_code_transaction_type} = transaction, options) do + Chain.select_repo(options).preload(transaction, :signed_authorizations) + end + + def preload_signed_authorizations(%__MODULE__{} = transaction, _options) do + %{transaction | signed_authorizations: []} + end + @doc """ Receives as input list of transactions and returns decoded_input_data Where diff --git a/apps/explorer/lib/explorer/eth_rpc.ex b/apps/explorer/lib/explorer/eth_rpc.ex index ad222a21ae1..80d71ae2650 100644 --- a/apps/explorer/lib/explorer/eth_rpc.ex +++ b/apps/explorer/lib/explorer/eth_rpc.ex @@ -853,13 +853,17 @@ defmodule Explorer.EthRPC do """ @spec eth_get_transaction_by_hash(String.t()) :: {:ok, map() | nil} | {:error, String.t()} def eth_get_transaction_by_hash(transaction_hash_string) do - necessity_by_association = - %{signed_authorizations: :optional} - |> Map.merge(chain_type_transaction_necessity_by_association()) + # `signed_authorizations` are preloaded separately, only for EIP-7702 + # (type 4) transactions, to skip the query for every other type. + render_func = fn transaction -> + transaction + |> Transaction.preload_signed_authorizations(api?: true) + |> render_transaction() + end - validate_and_render_transaction(transaction_hash_string, &render_transaction/1, + validate_and_render_transaction(transaction_hash_string, render_func, api?: true, - necessity_by_association: necessity_by_association + necessity_by_association: chain_type_transaction_necessity_by_association() ) end @@ -995,25 +999,29 @@ defmodule Explorer.EthRPC do defp maybe_add_access_list(props, _transaction), do: props - defp maybe_add_chain_type_extra_transaction_info_properties(props, %{beacon_blob_transaction: beacon_blob_transaction}) do - if Application.get_env(:explorer, :chain_type) == :ethereum && beacon_blob_transaction do + # The `beacon_blob_transaction` association exists on the `Transaction` struct + # only when the chain type is `:ethereum` at compile time, so these clauses + # are compiled in under the same condition; otherwise dialyzer reports a + # pattern that can never match. + if @chain_type == :ethereum do + defp maybe_add_chain_type_extra_transaction_info_properties(props, %{ + beacon_blob_transaction: beacon_blob_transaction + }) + when not is_nil(beacon_blob_transaction) do props |> Map.put("maxFeePerBlobGas", Helper.decimal_to_hex(beacon_blob_transaction.max_fee_per_blob_gas)) |> Map.put("blobVersionedHashes", beacon_blob_transaction.blob_versioned_hashes) - else - props end end defp maybe_add_chain_type_extra_transaction_info_properties(props, _transaction), do: props - defp maybe_add_chain_type_extra_receipt_properties(props, %{beacon_blob_transaction: beacon_blob_transaction}) do - if Application.get_env(:explorer, :chain_type) == :ethereum && beacon_blob_transaction do + if @chain_type == :ethereum do + defp maybe_add_chain_type_extra_receipt_properties(props, %{beacon_blob_transaction: beacon_blob_transaction}) + when not is_nil(beacon_blob_transaction) do props |> Map.put("blobGasPrice", Helper.decimal_to_hex(beacon_blob_transaction.blob_gas_price)) |> Map.put("blobGasUsed", Helper.decimal_to_hex(beacon_blob_transaction.blob_gas_used)) - else - props end end From 80113f9c596c3f035fe7bcd619bf9f33c32eb15f Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 14:06:46 +0300 Subject: [PATCH 48/82] perf: Bound and deduplicate on-demand token total supply fetcher (#14811) --- .../controllers/api/v2/token_controller.ex | 2 +- .../controllers/tokens/transfer_controller.ex | 3 +- .../fetcher/on_demand/token_total_supply.ex | 242 ++++++++++++++-- .../on_demand/token_total_supply_test.exs | 267 ++++++++++++++++++ config/runtime.exs | 7 + cspell.json | 1 + docker-compose/envs/common-blockscout.env | 3 + 7 files changed, 499 insertions(+), 26 deletions(-) create mode 100644 apps/indexer/test/indexer/fetcher/on_demand/token_total_supply_test.exs 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 09c0a1f3447..b6fa0396407 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 @@ -101,7 +101,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)} do - TokenTotalSupplyOnDemand.trigger_fetch(ip, address_hash) + TokenTotalSupplyOnDemand.trigger_fetch(ip, token) conn |> token_response(token, address_hash) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex index 941835a8624..ab4f7b7ec8b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex @@ -92,13 +92,14 @@ defmodule BlockScoutWeb.Tokens.TransferController do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, token} <- Chain.token_from_address_hash(address_hash, options), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do + TokenTotalSupplyOnDemand.trigger_fetch(ip, token) + render( conn, "index.html", counters_path: token_path(conn, :token_counters, %{"id" => Address.checksum(address_hash)}), current_path: Controller.current_full_path(conn), token: token, - token_total_supply_status: TokenTotalSupplyOnDemand.trigger_fetch(ip, address_hash), tags: get_address_tags(address_hash, current_user(conn)) ) else diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex b/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex index 80dc1e4b539..4b19ecaf524 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex @@ -1,28 +1,63 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenTotalSupply do @moduledoc """ - Ensures that we have a reasonably up to date token supply. + Ensures that we have a reasonably up to date token supply. + + The fetch is triggered from API request handlers. To keep the request path + cheap and the GenServer mailbox bounded: + + * the staleness check runs in the caller, using the already loaded + `t:Explorer.Chain.Token.t/0`, before anything is enqueued; + * an ETS table records tokens that are in flight or failed recently, so a + token is enqueued at most once per attempt and failing tokens back off; + * the JSON RPC call runs in a task under `__MODULE__.TaskSupervisor`, + never in the GenServer itself, and at most `max_concurrency` tasks run at + a time. Requests arriving above the cap are dropped and can be retried + by a later request. """ use GenServer use Indexer.Fetcher, restart: :permanent + require Logger + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Events.Publisher - alias Explorer.Chain.{Hash, Token} - alias Explorer.Repo + alias Explorer.Chain.Token alias Explorer.Token.MetadataRetriever alias Explorer.Utility.RateLimiter + @table_name :token_total_supply_on_demand @ttl_in_blocks 1 + @default_max_concurrency 5 + @default_threshold :timer.minutes(5) + @sweep_interval :timer.minutes(1) + + # Non-token entries kept in the same ETS table. + @slots_key :slots + @max_concurrency_key :max_concurrency + + @typep key :: binary() ## Interface - @spec trigger_fetch(String.t() | nil, Hash.Address.t()) :: :ok - def trigger_fetch(caller \\ nil, address_hash) do - case RateLimiter.check_rate(caller, :on_demand) do - :allow -> GenServer.cast(__MODULE__, {:fetch_and_update, address_hash}) - :deny -> :ok + @doc """ + Schedules a total supply refresh for `token` when it is stale. + + Returns `:ok` in all cases. Does nothing when the fetcher is disabled or not + running, when the token is fresh, when the caller is rate limited, or when + the token is already in flight or failed recently. + """ + @spec trigger_fetch(String.t() | nil, Token.t()) :: :ok + def trigger_fetch(caller \\ nil, %Token{} = token) do + with false <- __MODULE__.Supervisor.disabled?(), + true <- table_exists?(), + true <- stale?(token, BlockNumber.get_max()), + :allow <- RateLimiter.check_rate(caller, :on_demand), + true <- claim_and_reserve_slot(key(token)) do + GenServer.cast(__MODULE__, {:fetch, token}) + else + _ -> :ok end end @@ -33,36 +68,195 @@ defmodule Indexer.Fetcher.OnDemand.TokenTotalSupply do end @impl true - def init(init_arg) do - {:ok, init_arg} + def init(init_opts) do + ensure_table() + schedule_sweep() + + max_concurrency = + Keyword.get(init_opts, :max_concurrency) || + Application.get_env(:indexer, __MODULE__, [])[:max_concurrency] || + @default_max_concurrency + + :ets.insert(@table_name, [{@slots_key, 0}, {@max_concurrency_key, max_concurrency}]) + + {:ok, %{running: %{}}} + end + + @impl true + def handle_cast({:fetch, %Token{} = token}, %{running: running} = state) do + key = key(token) + + if key in Map.values(running) do + release_slot() + {:noreply, state} + else + %Task{ref: ref} = Task.Supervisor.async_nolink(__MODULE__.TaskSupervisor, fn -> do_fetch(token) end) + {:noreply, %{state | running: Map.put(running, ref, key)}} + end end @impl true - def handle_cast({:fetch_and_update, address_hash}, state) do - do_fetch(address_hash) + def handle_info({ref, result}, %{running: running} = state) when is_map_key(running, ref) do + Process.demonitor(ref, [:flush]) + {key, running} = Map.pop(running, ref) + finish(key, result) + release_slot() + + {:noreply, %{state | running: running}} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{running: running} = state) when is_map_key(running, ref) do + {key, running} = Map.pop(running, ref) + Logger.error("On-demand token total supply fetch crashed: #{inspect(reason)}") + mark_failed(key) + release_slot() + + {:noreply, %{state | running: running}} + end + + def handle_info(:sweep, %{running: running} = state) do + sweep(Map.values(running)) + schedule_sweep() {:noreply, state} end + def handle_info(_message, state), do: {:noreply, state} + ## Implementation - defp do_fetch(address_hash) when not is_nil(address_hash) do - token = Repo.replica().get_by(Token, contract_address_hash: address_hash) + @spec do_fetch(Token.t()) :: :ok | :error | {:error, Ecto.Changeset.t()} + defp do_fetch(%Token{contract_address_hash: contract_address_hash} = token) do + case MetadataRetriever.get_total_supply_of(to_string(contract_address_hash)) do + %{total_supply: _} = token_params -> + case Token.update(token, token_params) do + {:ok, updated_token} -> + Publisher.broadcast(%{token_total_supply: [updated_token]}, :on_demand) + :ok + + {:error, _changeset} = error -> + error + end + + _ -> + :error + end + end + + defp finish(key, :ok), do: :ets.delete(@table_name, key) + + defp finish(key, error) do + Logger.debug(fn -> "On-demand token total supply fetch failed: #{inspect(error)}" end) + mark_failed(key) + end + + defp mark_failed(key), do: :ets.insert(@table_name, {key, :failed, now_ms()}) + + @spec stale?(Token.t(), non_neg_integer()) :: boolean() + defp stale?(%Token{skip_metadata: true, total_supply_updated_at_block: nil}, _max_block_number), do: false + defp stale?(%Token{total_supply_updated_at_block: nil}, _max_block_number), do: true + + defp stale?(%Token{total_supply_updated_at_block: updated_at_block}, max_block_number), + do: max_block_number - updated_at_block > @ttl_in_blocks + + # Claims the token and a fetch slot. The ETS table is owned by the GenServer, + # so it can disappear between `table_exists?/0` and these calls when the + # fetcher is terminating; treat that as "nothing to do" instead of crashing + # the request process. + @spec claim_and_reserve_slot(key()) :: boolean() + defp claim_and_reserve_slot(key) do + claim(key) and reserve_slot(key) + rescue + ArgumentError -> false + end + + # Marks the token as in flight. Returns `false` when it is already in flight + # or failed less than `threshold` ago. + @spec claim(key()) :: boolean() + defp claim(key) do + now = now_ms() + + case :ets.lookup(@table_name, key) do + [] -> + :ets.insert_new(@table_name, {key, :in_flight, now}) + + [{_key, :in_flight, _at}] -> + false + + [{_key, :failed, _failed_at}] -> + # Atomically replace the failure record only if it is old enough, so + # concurrent callers cannot both claim the same expired token. + expired_before = now - threshold_ms() + + match_spec = [ + {{key, :failed, :"$1"}, [{:"=<", :"$1", expired_before}], [{{{:const, key}, :in_flight, {:const, now}}}]} + ] + + :ets.select_replace(@table_name, match_spec) == 1 + end + end - if (token && !token.skip_metadata && is_nil(token.total_supply_updated_at_block)) or - (token && !is_nil(token.total_supply_updated_at_block) && - BlockNumber.get_max() - token.total_supply_updated_at_block > @ttl_in_blocks) do - token_address_hash_string = to_string(address_hash) + # Reserves one of `max_concurrency` fetch slots. On failure, releases the + # token claim so a later request can retry. + @spec reserve_slot(key()) :: boolean() + defp reserve_slot(key) do + if :ets.update_counter(@table_name, @slots_key, {2, 1}) > max_concurrency() do + release_slot() + :ets.delete(@table_name, key) + false + else + true + end + end + + defp release_slot, do: :ets.update_counter(@table_name, @slots_key, {2, -1}) + + defp max_concurrency do + case :ets.lookup(@table_name, @max_concurrency_key) do + [{_key, max_concurrency}] -> max_concurrency + [] -> @default_max_concurrency + end + end + + # Removes expired failure records and in-flight markers that are not backed + # by a running task (for example, casts lost while the server restarted). + @spec sweep([key()]) :: :ok + defp sweep(running_keys) do + now = now_ms() + threshold = threshold_ms() - token_params = MetadataRetriever.get_total_supply_of(token_address_hash_string) + :ets.foldl( + fn + {key, :failed, failed_at}, acc when now - failed_at >= threshold -> + [key | acc] - if token_params !== %{} do - {:ok, token} = Token.update(token, token_params) + {key, :in_flight, started_at}, acc when now - started_at >= 2 * threshold -> + if key in running_keys, do: acc, else: [key | acc] - Publisher.broadcast(%{token_total_supply: [token]}, :on_demand) - end + _entry, acc -> + acc + end, + [], + @table_name + ) + |> Enum.each(&:ets.delete(@table_name, &1)) + end - :ok + defp ensure_table do + if :ets.whereis(@table_name) == :undefined do + :ets.new(@table_name, [:set, :named_table, :public, read_concurrency: true, write_concurrency: true]) end end + + defp table_exists?, do: :ets.whereis(@table_name) != :undefined + + defp schedule_sweep, do: Process.send_after(self(), :sweep, @sweep_interval) + + defp key(%Token{contract_address_hash: %{bytes: bytes}}), do: bytes + + defp now_ms, do: System.monotonic_time(:millisecond) + + defp threshold_ms do + Application.get_env(:indexer, __MODULE__, [])[:threshold] || @default_threshold + end end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/token_total_supply_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/token_total_supply_test.exs new file mode 100644 index 00000000000..281ec2c4d3e --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/on_demand/token_total_supply_test.exs @@ -0,0 +1,267 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.OnDemand.TokenTotalSupplyTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Events.Subscriber + alias Explorer.Chain.Token + alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: Fetcher + + @moduletag :capture_log + + @table :token_total_supply_on_demand + @total_supply_selector "0x18160ddd" + @total_supply_hex "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + + setup :set_mox_global + setup :verify_on_exit! + + setup do + initial_explorer_retries = Application.get_env(:explorer, :token_functions_reader_max_retries) + initial_fetcher_config = Application.get_env(:indexer, Fetcher) || [] + + Application.put_env(:explorer, :token_functions_reader_max_retries, 1) + Application.put_env(:indexer, Fetcher, Keyword.merge(initial_fetcher_config, threshold: :timer.minutes(5))) + + on_exit(fn -> + Application.put_env(:explorer, :token_functions_reader_max_retries, initial_explorer_retries) + Application.put_env(:indexer, Fetcher, initial_fetcher_config) + end) + + start_supervised!(Fetcher.Supervisor.child_spec([[max_concurrency: 1]])) + Subscriber.to(:token_total_supply, :on_demand) + + %{max_block: BlockNumber.get_max()} + end + + describe "trigger_fetch/2" do + test "fetches, stores and broadcasts total supply of a stale token" do + token = stale_token() + hash = token.contract_address_hash + expect_total_supply(to_string(hash), fn id -> {:ok, [%{id: id, result: @total_supply_hex}]} end) + + assert :ok = Fetcher.trigger_fetch(token) + + assert_receive {:chain_event, :token_total_supply, :on_demand, [%Token{contract_address_hash: ^hash} = updated]}, + 1_000 + + assert Decimal.equal?(updated.total_supply, Decimal.new(1_000_000_000_000_000_000)) + + reloaded = Repo.get_by!(Token, contract_address_hash: hash) + assert Decimal.equal?(reloaded.total_supply, Decimal.new(1_000_000_000_000_000_000)) + assert reloaded.total_supply_updated_at_block == BlockNumber.get_max() + + wait_until(fn -> :ets.lookup(@table, key(token)) == [] end) + assert %{running: running} = :sys.get_state(Fetcher) + assert running == %{} + assert :ets.lookup(@table, :slots) == [{:slots, 0}] + end + + test "does not enqueue a token that is already in flight" do + token = stale_token() + expect_blocking_total_supply(to_string(token.contract_address_hash)) + + assert :ok = Fetcher.trigger_fetch(token) + assert_receive {:rpc, _}, 1_000 + assert_receive {:blocked, task_pid}, 1_000 + assert [{_key, :in_flight, _at}] = :ets.lookup(@table, key(token)) + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + refute_receive {:rpc, _}, 100 + assert %{running: running} = :sys.get_state(Fetcher) + assert map_size(running) == 1 + + send(task_pid, :continue) + assert_receive {:chain_event, :token_total_supply, :on_demand, [%Token{}]}, 1_000 + end + + test "does nothing for a fresh token", %{max_block: max_block} do + token = insert(:token, total_supply_updated_at_block: max_block) + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + assert :ets.lookup(@table, key(token)) == [] + assert %{running: running} = :sys.get_state(Fetcher) + assert running == %{} + refute_receive {:rpc, _}, 100 + end + + test "does nothing for a never fetched token with skip_metadata" do + token = insert(:token, total_supply_updated_at_block: nil, skip_metadata: true) + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + assert :ets.lookup(@table, key(token)) == [] + refute_receive {:rpc, _}, 100 + end + + test "records a failed attempt and backs off until the threshold passes" do + token = stale_token() + hash_string = to_string(token.contract_address_hash) + + expect_total_supply(hash_string, fn id -> + {:ok, [%{id: id, error: %{code: -32015, message: "VM execution error"}}]} + end) + + assert :ok = Fetcher.trigger_fetch(token) + assert_receive {:rpc, ^hash_string}, 1_000 + + wait_until(fn -> match?([{_key, :failed, _at}], :ets.lookup(@table, key(token))) end) + [{_key, :failed, failed_at}] = :ets.lookup(@table, key(token)) + refute_receive {:chain_event, :token_total_supply, :on_demand, _}, 100 + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + assert [{_key, :failed, ^failed_at}] = :ets.lookup(@table, key(token)) + refute_receive {:rpc, _}, 100 + + # Expire the failure record and make sure the token is fetched again. + :ets.insert(@table, {key(token), :failed, failed_at - :timer.minutes(5) - 1}) + expect_total_supply(hash_string, fn id -> {:ok, [%{id: id, result: @total_supply_hex}]} end) + + assert :ok = Fetcher.trigger_fetch(token) + assert_receive {:chain_event, :token_total_supply, :on_demand, [%Token{}]}, 1_000 + wait_until(fn -> :ets.lookup(@table, key(token)) == [] end) + end + + test "drops requests above the concurrency cap and lets them retry later" do + token_a = stale_token() + token_b = stale_token() + hash_b_string = to_string(token_b.contract_address_hash) + expect_blocking_total_supply(to_string(token_a.contract_address_hash)) + + assert :ok = Fetcher.trigger_fetch(token_a) + assert_receive {:blocked, task_pid}, 1_000 + + assert :ok = Fetcher.trigger_fetch(token_b) + sync() + + assert :ets.lookup(@table, key(token_b)) == [] + assert :ets.lookup(@table, :slots) == [{:slots, 1}] + assert %{running: running} = :sys.get_state(Fetcher) + assert map_size(running) == 1 + refute_receive {:rpc, ^hash_b_string}, 100 + + send(task_pid, :continue) + assert_receive {:chain_event, :token_total_supply, :on_demand, [%Token{}]}, 1_000 + wait_until(fn -> map_size(:sys.get_state(Fetcher).running) == 0 end) + assert :ets.lookup(@table, :slots) == [{:slots, 0}] + + expect_total_supply(hash_b_string, fn id -> {:ok, [%{id: id, result: @total_supply_hex}]} end) + assert :ok = Fetcher.trigger_fetch(token_b) + assert_receive {:rpc, ^hash_b_string}, 1_000 + assert_receive {:chain_event, :token_total_supply, :on_demand, [%Token{}]}, 1_000 + end + + test "does nothing when the fetcher is disabled" do + initial = Application.get_env(:indexer, Fetcher.Supervisor) || [] + Application.put_env(:indexer, Fetcher.Supervisor, Keyword.merge(initial, disabled?: true)) + on_exit(fn -> Application.put_env(:indexer, Fetcher.Supervisor, initial) end) + + token = stale_token() + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + assert :ets.lookup(@table, key(token)) == [] + assert %{running: running} = :sys.get_state(Fetcher) + assert running == %{} + end + + test "does not raise when the fetcher is not running" do + token = stale_token() + stop_supervised!(Fetcher.Supervisor) + + assert :ets.whereis(@table) == :undefined + assert :ok = Fetcher.trigger_fetch(token) + end + + test "does not raise when ETS operations fail after the table check" do + token = stale_token() + # Simulates the table being torn down between `table_exists?/0` and the + # ETS calls: the slot counter is gone, so `:ets.update_counter/3` raises. + :ets.delete(@table, :slots) + + assert :ok = Fetcher.trigger_fetch(token) + sync() + + assert %{running: running} = :sys.get_state(Fetcher) + assert running == %{} + end + end + + describe "sweep" do + test "removes expired failures and orphaned in-flight markers" do + now = System.monotonic_time(:millisecond) + threshold = :timer.minutes(5) + + :ets.insert(@table, {"expired_failed", :failed, now - threshold - 1}) + :ets.insert(@table, {"fresh_failed", :failed, now}) + :ets.insert(@table, {"orphaned_in_flight", :in_flight, now - 2 * threshold - 1}) + :ets.insert(@table, {"fresh_in_flight", :in_flight, now}) + + send(Fetcher, :sweep) + sync() + + assert :ets.lookup(@table, "expired_failed") == [] + assert :ets.lookup(@table, "orphaned_in_flight") == [] + assert [{"fresh_failed", :failed, _}] = :ets.lookup(@table, "fresh_failed") + assert [{"fresh_in_flight", :in_flight, _}] = :ets.lookup(@table, "fresh_in_flight") + end + end + + defp stale_token, do: insert(:token, total_supply_updated_at_block: nil) + + defp key(%Token{contract_address_hash: %{bytes: bytes}}), do: bytes + + # Casts are processed in order, so a synchronous call afterwards guarantees + # that all previously sent casts have been handled. + defp sync, do: :sys.get_state(Fetcher) + + defp wait_until(fun, attempts \\ 50) do + cond do + fun.() -> + :ok + + attempts == 0 -> + flunk("condition was not met in time") + + true -> + Process.sleep(20) + wait_until(fun, attempts - 1) + end + end + + defp expect_total_supply(hash_string, reply) do + test_pid = self() + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_call", params: [params, "latest"]}], _opts -> + assert params.data == @total_supply_selector + assert params.to == hash_string + send(test_pid, {:rpc, hash_string}) + reply.(id) + end) + end + + defp expect_blocking_total_supply(hash_string) do + test_pid = self() + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_call", params: [params, "latest"]}], _opts -> + assert params.to == hash_string + send(test_pid, {:rpc, hash_string}) + send(test_pid, {:blocked, self()}) + + receive do + :continue -> {:ok, [%{id: id, result: @total_supply_hex}]} + end + end) + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 0c1217db415..4eeff9818fc 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1217,6 +1217,13 @@ config :indexer, Indexer.Fetcher.OnDemand.TokenBalance, config :indexer, Indexer.Fetcher.OnDemand.TokenBalance.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_TOKEN_BALANCE_ON_DEMAND_FETCHER") +config :indexer, Indexer.Fetcher.OnDemand.TokenTotalSupply, + max_concurrency: ConfigHelper.parse_integer_env_var("TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_CONCURRENCY", 5, min: 1), + threshold: ConfigHelper.parse_time_env_var("TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_THRESHOLD", "5m") + +config :indexer, Indexer.Fetcher.OnDemand.TokenTotalSupply.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER") + config :indexer, Indexer.Fetcher.OnDemand.CoinBalance, threshold: ConfigHelper.parse_time_env_var("COIN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD", "1h"), fallback_threshold_in_blocks: 500 diff --git a/cspell.json b/cspell.json index c14c328058d..09edb46dc75 100644 --- a/cspell.json +++ b/cspell.json @@ -291,6 +291,7 @@ "fixidity", "fkey", "Floki", + "foldl", "fontawesome", "fortawesome", "froms", diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index bed0c609861..e19dfed6a6b 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -156,6 +156,9 @@ RELEASE_LINK= # COIN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD= # CONTRACT_CODE_ON_DEMAND_FETCHER_THRESHOLD= # TOKEN_INSTANCE_METADATA_REFETCH_ON_DEMAND_FETCHER_THRESHOLD= +# TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_THRESHOLD= +# TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_CONCURRENCY= +# INDEXER_DISABLE_TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER=false # ADDRESSES_TABS_COUNTERS_TTL=10m # TOKEN_METADATA_UPDATE_INTERVAL=48h # TOKEN_COUNTERS_UPDATE_INTERVAL=3h From fea802fd545ed3c7b9dfd26fc5d86c2fdacd4453 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 18:35:18 +0300 Subject: [PATCH 49/82] perf: cache public address tags and index-driven tags lookup (#14833) --- .../models/get_address_tags.ex | 22 ++- .../models/get_address_tags_test.exs | 42 ++++++ apps/explorer/config/config.exs | 2 + apps/explorer/config/test.exs | 2 + apps/explorer/lib/explorer/application.ex | 142 ++++++++++-------- .../lib/explorer/chain/cache/address_tags.ex | 82 ++++++++++ .../chain/cache/address_tags_test.exs | 83 ++++++++++ config/runtime.exs | 2 + docker-compose/envs/common-blockscout.env | 1 + 9 files changed, 310 insertions(+), 68 deletions(-) create mode 100644 apps/block_scout_web/test/block_scout_web/models/get_address_tags_test.exs create mode 100644 apps/explorer/lib/explorer/chain/cache/address_tags.ex create mode 100644 apps/explorer/test/explorer/chain/cache/address_tags_test.exs 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 d97fa17cd61..2a4ad915cfc 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 @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Models.GetAddressTags do import Explorer.Chain, only: [select_repo: 1] alias Explorer.Account.{TagAddress, WatchlistAddress} + alias Explorer.Chain.Cache.AddressTags, as: AddressTagsCache alias Explorer.Repo alias Explorer.Tags.{AddressTag, AddressToTag} @@ -67,23 +68,28 @@ 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 - common_tags_base_query() - |> where([tt, att], att.address_hash == ^address_hash) - |> select_repo(opts).all() + get_tags_on_addresses([address_hash], opts) end def get_tags_on_address(_, _), do: [] + # Served from `Explorer.Chain.Cache.AddressTags`; only addresses missing from + # the cache reach the database, in one query. 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() + AddressTagsCache.fetch(address_hashes, fn missing_address_hashes -> + common_tags_base_query() + |> where([att], att.address_hash in ^missing_address_hashes) + |> select_repo(opts).all() + end) end + # Starts from `address_to_tags` so the `address_hash` filter is applied + # through its index before joining the tags, rather than scanning + # `address_tags` and filtering the joined rows afterwards. defp common_tags_base_query do from( - tt in AddressTag, - left_join: att in AddressToTag, + att in AddressToTag, + join: tt in AddressTag, on: tt.id == att.tag_id, where: tt.label != ^"validator", select: %{label: tt.label, display_name: tt.display_name, address_hash: att.address_hash} diff --git a/apps/block_scout_web/test/block_scout_web/models/get_address_tags_test.exs b/apps/block_scout_web/test/block_scout_web/models/get_address_tags_test.exs new file mode 100644 index 00000000000..072e5f2a282 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/models/get_address_tags_test.exs @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Models.GetAddressTagsTest do + use BlockScoutWeb.ConnCase, async: false + + alias BlockScoutWeb.Models.GetAddressTags + + describe "get_tags_on_address/2" do + test "returns the public tags of the address except the validator one" do + address = insert(:address) + tag = insert(:address_tag, label: "exchange", display_name: "Exchange") + insert(:address_to_tag, address: address, tag: tag) + validator_tag = insert(:address_tag, label: "validator", display_name: "Validator") + insert(:address_to_tag, address: address, tag: validator_tag) + + assert GetAddressTags.get_tags_on_address(address.hash) == [ + %{label: "exchange", display_name: "Exchange", address_hash: address.hash} + ] + end + + test "returns an empty list for an address without tags" do + assert GetAddressTags.get_tags_on_address(insert(:address).hash) == [] + end + end + + describe "get_address_tags_batch/3" do + test "groups public tags by address and fills in addresses without tags" do + tagged = insert(:address) + untagged = insert(:address) + tag = insert(:address_tag, label: "bridge", display_name: "Bridge") + insert(:address_to_tag, address: tagged, tag: tag) + + assert GetAddressTags.get_address_tags_batch([tagged.hash, untagged.hash], nil) == %{ + tagged.hash => %{ + common_tags: [%{label: "bridge", display_name: "Bridge", address_hash: tagged.hash}], + personal_tags: [], + watchlist_names: [] + }, + untagged.hash => %{common_tags: [], personal_tags: [], watchlist_names: []} + } + end + end +end diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 15a2236a0a5..2dcaaa14556 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -25,6 +25,8 @@ config :explorer, Explorer.Chain.Cache.BlockNumber, enabled: true config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: true +config :explorer, Explorer.Chain.Cache.AddressTags, 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 1a6aaa300a2..1551e68471b 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -104,6 +104,8 @@ end config :logger, :explorer, path: Path.absname("logs/test/explorer.log") config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: false + +config :explorer, Explorer.Chain.Cache.AddressTags, enabled: false, ttl: :timer.minutes(5) 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 9cc8c1f37ca..584438d184a 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -31,6 +31,7 @@ defmodule Explorer.Application do TransactionsCount } + alias Explorer.Chain.Cache.AddressTags, as: AddressTagsCache alias Explorer.Chain.Cache.ContractMethods, as: ContractMethodsCache alias Explorer.Chain.Optimism.InteropMessage, as: OptimismInteropMessage alias Explorer.Chain.Supply.RSK @@ -60,66 +61,7 @@ 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}, - Explorer.Chain.Cache.Propagator, - Accounts, - AddressesCoinBalanceSum, - AddressesCoinBalanceSumMinusBurnt, - BackgroundMigrations, - BlocksCount, - BlockNumber, - Blocks, - ChainId, - GasPriceOracle, - 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)), - 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, - 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() + children = children() opts = [strategy: :one_for_one, name: Explorer.Supervisor, max_restarts: 1_000] @@ -130,6 +72,77 @@ defmodule Explorer.Application do end end + # The child list of the supervision tree for the current mode, exposed so a + # test can check that every entry is something a supervisor accepts. The + # mode- and chain-type-dependent helpers return `[]` for children that do not + # apply, and a bare `[]` crashes the supervisor unless the list holding it is + # flattened first — so those helpers belong in `configurable_children/0`. + @doc false + @spec children() :: [Supervisor.child_spec() | {module(), term()} | module()] + def children do + base_children() ++ configurable_children() + end + + # Children to start in all environments + defp base_children do + [ + 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}, + Explorer.Chain.Cache.Propagator, + Accounts, + AddressesCoinBalanceSum, + AddressesCoinBalanceSumMinusBurnt, + BackgroundMigrations, + BlocksCount, + BlockNumber, + Blocks, + ChainId, + GasPriceOracle, + 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)), + 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, + recv_timeout: 60_000, + timeout: 60_000, + max_connections: Application.get_env(:explorer, :hackney_default_pool_size) + ), + Explorer.Promo.Autoscout + ] ++ HttpClient.pool_child_specs() + end + defp configurable_children do configurable_children_set = [ @@ -137,6 +150,15 @@ defmodule Explorer.Application do only_in_mode(Explorer.SmartContract.SolcDownloader, :api), only_in_mode(Explorer.SmartContract.VyperDownloader, :api), only_in_mode({Admin.Recovery, [[], [name: Admin.Recovery]]}, :api), + # only the web layer reads public address tags; this list is flattened, + # so the `[]` returned outside API mode is dropped rather than passed + # to the supervisor + AddressTagsCache.cache_name() + |> con_cache_child_spec( + ttl_check_interval: :timer.minutes(1), + global_ttl: :infinity + ) + |> only_in_mode(:api), configure(Explorer.Utility.VersionUpgrade), configure_mode_dependent_process(Explorer.Utility.VersionConstantsUpdater, :indexer), configure_mode_dependent_process(Explorer.Market.Fetcher.Coin, :api), diff --git a/apps/explorer/lib/explorer/chain/cache/address_tags.ex b/apps/explorer/lib/explorer/chain/cache/address_tags.ex new file mode 100644 index 00000000000..860f47d6f2f --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/address_tags.ex @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.AddressTags do + @moduledoc """ + Per-address cache for public address tags (`address_tags` joined with + `address_to_tags`). + + Public tags are read for every address in every transaction, address and + token-transfer response, but change only through the tag cataloger and the + admin endpoints, so each address's tag list is cached with a short TTL. + Addresses without tags are cached too: an empty result is by far the most + common one, and it is exactly the lookup worth skipping. + + The cache stores whatever list the fallback returns for an address and knows + nothing about the query itself, so the filtering that the API applies (for + example hiding the `validator` tag) stays with the caller. + """ + + alias Explorer.Chain.Hash + + @cache_name :address_tags + + @type tag :: %{required(:address_hash) => Hash.Address.t(), optional(atom()) => any()} + + @spec cache_name() :: atom() + def cache_name, do: @cache_name + + @doc """ + Returns the tags of `address_hashes`, serving addresses already in the cache + from it and fetching the rest with `fallback_fn` in a single call. + + `fallback_fn` receives the list of address hashes missing from the cache and + must return the tags of those addresses as maps carrying an `:address_hash` + key. Every missing address is then cached, including the ones the fallback + returned no tags for. + + With the cache disabled the fallback is called with all `address_hashes`. + """ + @spec fetch([Hash.Address.t()], ([Hash.Address.t()] -> [tag()])) :: [tag()] + def fetch([], _fallback_fn), do: [] + + def fetch(address_hashes, fallback_fn) when is_function(fallback_fn, 1) do + if enabled?() do + fetch_with_cache(address_hashes, fallback_fn) + else + fallback_fn.(address_hashes) + end + end + + defp fetch_with_cache(address_hashes, fallback_fn) do + {cached_tags, missing_hashes} = + Enum.reduce(address_hashes, {[], []}, fn address_hash, {cached, missing} -> + case ConCache.get(@cache_name, address_hash) do + nil -> {cached, [address_hash | missing]} + tags -> {tags ++ cached, missing} + end + end) + + fetched_tags = if missing_hashes == [], do: [], else: fallback_fn.(missing_hashes) + fetched_by_hash = Enum.group_by(fetched_tags, & &1.address_hash) + + Enum.each(missing_hashes, fn address_hash -> + ConCache.put(@cache_name, address_hash, %ConCache.Item{ + value: Map.get(fetched_by_hash, address_hash, []), + ttl: ttl() + }) + end) + + cached_tags ++ fetched_tags + end + + defp enabled? do + config()[:enabled] + end + + defp ttl do + config()[:ttl] + end + + defp config do + Application.get_env(:explorer, __MODULE__) + end +end diff --git a/apps/explorer/test/explorer/chain/cache/address_tags_test.exs b/apps/explorer/test/explorer/chain/cache/address_tags_test.exs new file mode 100644 index 00000000000..98680d11442 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/address_tags_test.exs @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.AddressTagsTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.AddressTags + + setup do + config = Application.get_env(:explorer, AddressTags) || [] + Application.put_env(:explorer, AddressTags, Keyword.merge(config, enabled: true)) + on_exit(fn -> Application.put_env(:explorer, AddressTags, config) end) + + :ok + end + + describe "fetch/2" do + test "returns an empty list for no address hashes without calling the fallback" do + assert AddressTags.fetch([], fn hashes -> + send(self(), {:fallback, hashes}) + [] + end) == [] + + refute_received {:fallback, _} + end + + test "calls the fallback only for addresses missing from the cache" do + tagged_hash = build(:address).hash + untagged_hash = build(:address).hash + new_hash = build(:address).hash + tag = %{label: "label", display_name: "Label", address_hash: tagged_hash} + + fallback = fn hashes -> + send(self(), {:fallback, Enum.sort(hashes)}) + for hash <- hashes, hash == tagged_hash, do: tag + end + + assert AddressTags.fetch([tagged_hash, untagged_hash], fallback) == [tag] + assert_received {:fallback, hashes} + assert hashes == Enum.sort([tagged_hash, untagged_hash]) + + # the tagged address and the address without tags are both cached now + assert AddressTags.fetch([tagged_hash, untagged_hash, new_hash], fallback) == [tag] + assert_received {:fallback, [^new_hash]} + end + + test "serves a cached address without touching the fallback" do + hash = build(:address).hash + tag = %{label: "label", display_name: "Label", address_hash: hash} + + assert AddressTags.fetch([hash], fn _ -> [tag] end) == [tag] + + assert AddressTags.fetch([hash], fn hashes -> + send(self(), {:fallback, hashes}) + [] + end) == [tag] + + refute_received {:fallback, _} + end + + test "passes every address to the fallback when disabled" do + Application.put_env( + :explorer, + AddressTags, + Keyword.merge(Application.get_env(:explorer, AddressTags), enabled: false) + ) + + hash = build(:address).hash + + assert AddressTags.fetch([hash], fn hashes -> + send(self(), {:fallback, hashes}) + [] + end) == [] + + assert_received {:fallback, [^hash]} + + assert AddressTags.fetch([hash], fn hashes -> + send(self(), {:fallback, hashes}) + [] + end) == [] + + assert_received {:fallback, [^hash]} + end + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 4eeff9818fc..685d0012e16 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1011,6 +1011,8 @@ config :explorer, Explorer.Chain.Cache.ScamAddresses, update_interval: ConfigHelper.parse_time_env_var("CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL", "5m"), max_size: ConfigHelper.parse_integer_env_var("CACHE_SCAM_ADDRESSES_MAX_SIZE", 200_000, min: 0) +config :explorer, Explorer.Chain.Cache.AddressTags, ttl: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TAGS_TTL", "5m") + rate_limiter_redis_url = ConfigHelper.parse_url_env_var("RATE_LIMITER_REDIS_URL") rate_limiter_redis_sentinel_urls = ConfigHelper.safe_get_env("RATE_LIMITER_REDIS_SENTINEL_URLS", "") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index e19dfed6a6b..1b16cc9b153 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -150,6 +150,7 @@ RELEASE_LINK= # CACHE_PENDING_OPERATIONS_COUNT_PERIOD= # CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL= # CACHE_SCAM_ADDRESSES_MAX_SIZE= +# CACHE_ADDRESS_TAGS_TTL= # PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE=1000 # PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE=10 # TOKEN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD= From 50cbcf4ea6855ff8bb46cdce67e7cacfe012a6c9 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 18:36:39 +0300 Subject: [PATCH 50/82] Add missing test --- .../test/explorer/application_test.exs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/explorer/test/explorer/application_test.exs diff --git a/apps/explorer/test/explorer/application_test.exs b/apps/explorer/test/explorer/application_test.exs new file mode 100644 index 00000000000..69a064fd947 --- /dev/null +++ b/apps/explorer/test/explorer/application_test.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ApplicationTest do + use ExUnit.Case, async: false + + describe "children/0" do + # Regression test: a mode-gated child spec placed in a list that is not + # flattened reaches the supervisor as a bare `[]` outside its mode and + # crashes the application on start. `Supervisor.init/2` runs the same + # child validation as `Supervisor.start_link/2` without starting anything. + for mode <- [:indexer, :api, :all] do + test "every child is accepted by the supervisor in #{mode} mode" do + initial_mode = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, unquote(mode)) + on_exit(fn -> Application.put_env(:explorer, :mode, initial_mode) end) + + children = Explorer.Application.children() + refute Enum.empty?(children) + + assert {:ok, {_flags, specs}} = Supervisor.init(children, strategy: :one_for_one) + assert length(specs) == length(children) + end + end + end +end From 5efb2aac32f7aa7388d08c0ee1e6610040572bbd Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 15 Sep 2026 19:23:16 +0300 Subject: [PATCH 51/82] docs: document OP Stack transaction types in Transaction schema (#14835) --- .../schemas/api/v2/transaction.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 1322be3f59d..55ce62bb716 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 @@ -109,6 +109,14 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do additionalProperties: false } + # OP Stack-specific transaction types appended to the `transaction_types` enum + # for both `optimism` and `optimism-celo` chain types (the latter resolves to + # the `:optimism` chain type with a `{:optimism, :celo}` chain identity). + @optimism_transaction_types [ + "op_stack_l1_attributes_transaction", + "op_stack_post_exec_transaction" + ] + @scroll_schema %Schema{ type: :object, nullable: false, @@ -196,6 +204,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do operator_fee: General.IntegerString } ) + |> extend_transaction_types_enum(@optimism_transaction_types) :scroll -> schema |> Helper.extend_schema(properties: %{scroll: @scroll_schema}) @@ -295,6 +304,16 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do |> chain_identity_fields() end + # Appends chain-specific values to the `transaction_types` items enum of the given schema. + @spec extend_transaction_types_enum(map(), [String.t()]) :: map() + defp extend_transaction_types_enum(schema, extra_types) do + update_in( + schema, + [:properties, :transaction_types, Access.key(:items), Access.key(:enum)], + &(&1 ++ extra_types) + ) + end + defp chain_identity_fields(schema) do case chain_identity() do {:optimism, :celo} -> From 07c097c6d4dc00ebc8ac8d38c9a26af09c8372d8 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 16 Sep 2026 14:01:49 +0300 Subject: [PATCH 52/82] v11.3.1 --- .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 +- .../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 +- ...ocker-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 | 27 +++++++++++++++++++ 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, 83 insertions(+), 56 deletions(-) diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index a606a14d9c9..d056ef0c3b4 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.3.0 + RELEASE_VERSION: 11.3.1 jobs: matrix-builder: diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index 6a9af42927d..876921a291b 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.3.0 + RELEASE_VERSION: 11.3.1 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 1d1ab8ef114..da8c0d8b659 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.3.0 + RELEASE_VERSION: 11.3.1 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 bd31991cec1..f2711dca7de 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.3.0 + RELEASE_VERSION: 11.3.1 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 1cf3e9dcbad..65f2a618d43 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.3.0 + RELEASE_VERSION: 11.3.1 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 dda754e3bbc..11ca00a1867 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.3.0 + RELEASE_VERSION: 11.3.1 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 a54d79fe0bf..07009665612 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.3.0 + RELEASE_VERSION: 11.3.1 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 792aa183b16..8a2f3057fa2 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.3.0 + RELEASE_VERSION: 11.3.1 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 547c239a13e..ad8e69e7c1b 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.3.0 + RELEASE_VERSION: 11.3.1 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 a713602d9e0..2d90904f43e 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.3.0 + RELEASE_VERSION: 11.3.1 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 a1e2a40d0f2..38e10622d9b 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.3.0 + RELEASE_VERSION: 11.3.1 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 204364ac65f..54d0ee35e60 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.3.0 + RELEASE_VERSION: 11.3.1 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 ef057c79c82..c8495e3396f 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 770af881b8d..d147955ed1b 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.3.0 + RELEASE_VERSION: 11.3.1 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 2658e567c01..dae72763762 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.3.0 + RELEASE_VERSION: 11.3.1 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 6e92217ef68..9e748196480 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.3.0 + RELEASE_VERSION: 11.3.1 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index 3e67e81020b..b77bf6708bf 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.3.0 + RELEASE_VERSION: 11.3.1 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 f8e1e4cbb6f..9061d4b788e 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.3.0 + RELEASE_VERSION: 11.3.1 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 ff16b3f30aa..6265bf2dc19 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.3.0 + RELEASE_VERSION: 11.3.1 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 1e7a28c5138..27949d4ead7 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.3.0 + RELEASE_VERSION: 11.3.1 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 4084d4db8bf..0f61699052d 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.3.0 + RELEASE_VERSION: 11.3.1 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 5df2e635404..590dff13ccf 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.3.0 + RELEASE_VERSION: 11.3.1 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 d4ea1a103cf..28756341ac1 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.3.0 + RELEASE_VERSION: 11.3.1 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 e18cd5b4588..5310e3145a4 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.3.0 + RELEASE_VERSION: 11.3.1 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 d78b6bdbfa6..da5e1e6c921 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.3.0 + RELEASE_VERSION: 11.3.1 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 723abc91016..45672e08973 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.3.0 + RELEASE_VERSION: 11.3.1 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 2d7fc653378..d191cccce82 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.3.0 + RELEASE_VERSION: 11.3.1 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 5f7ad99671d..5d81133f35d 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.3.0 + RELEASE_VERSION: 11.3.1 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 cdac7272132..c814ada2c90 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.3.0 + RELEASE_VERSION: 11.3.1 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 b2fa1c14524..972b8e0ab1c 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.3.0 + RELEASE_VERSION: 11.3.1 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 6b6b03498a6..5e71470072e 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.3.0 + RELEASE_VERSION: 11.3.1 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 936716fe496..ec555808176 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index 36a47383139..88bebcb20e3 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index f1ec3b3e261..5a78fc5081c 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.3.0 + RELEASE_VERSION: 11.3.1 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 d19d5cd9b19..1e5c6f37e21 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index ee832c06205..009feb84887 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index f7678ab9963..4e3126f59a2 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index 0839eb590a4..86a8eefe4a8 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index c5077ed3a0e..c7dfc722b64 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index 161e81b2f44..b6982ddc2de 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index 44dabf542f2..92ca52a73a5 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index 0497c1239b8..0d6501924e7 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index 76d67bca855..0e9c43ab61c 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index fca42686e85..3c4cdef5ccd 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index caef5f2b4f6..e18644025dc 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index f375ba5e254..467ded817c4 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.3.0 + RELEASE_VERSION: 11.3.1 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dfe241e25..ebf6f3beade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 11.3.1 + +### 🚀 Features + +- Support OP Stack Upgrade 20 (Super Root games, SystemConfig v4) ([#14818](https://github.com/blockscout/blockscout/issues/14818)) + +### 📚 Documentation + +- Document OP Stack transaction types in Transaction schema ([#14835](https://github.com/blockscout/blockscout/issues/14835)) + +### ⚡ Performance + +- Cache public address tags and index-driven tags lookup ([#14833](https://github.com/blockscout/blockscout/issues/14833)) +- Bound and deduplicate on-demand token total supply fetcher ([#14811](https://github.com/blockscout/blockscout/issues/14811)) +- Preload signed_authorizations only for EIP-7702 transactions ([#14828](https://github.com/blockscout/blockscout/issues/14828)) +- Reuse preloaded proxy and ABI associations in input decoding ([#14827](https://github.com/blockscout/blockscout/issues/14827)) +- Use Endpoint.local_broadcast instead of Endpoint.broadcast ([#14819](https://github.com/blockscout/blockscout/issues/14819)) +- Optimize realtime events broadcast for legacy topics ([#14810](https://github.com/blockscout/blockscout/issues/14810)) + +### New ENV variables +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `INDEXER_DISABLE_TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER` | If `true`, `Indexer.Fetcher.OnDemand.TokenTotalSupply` fetcher is disabled at runtime. Implemented in [#14811](https://github.com/blockscout/blockscout/pull/14811). | Version: v11.3.1\+
Default: `false`
Applications: Indexer | +| `TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_CONCURRENCY` | Concurrency for `Indexer.Fetcher.OnDemand.TokenTotalSupply`. Implemented in [#14811](https://github.com/blockscout/blockscout/pull/14811). | Version: v11.3.1\+
Default: `5`
Applications: Indexer | +| `TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER_THRESHOLD` | Backoff threshold after a failed token total supply fetch. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14811](https://github.com/blockscout/blockscout/pull/14811). | Version: v11.3.1\+
Default: `5m`
Applications: Indexer | + + ## 11.3.0 ### 🚀 Features diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index 8e84ff28c10..214dbc9cd09 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.3.0", + version: "11.3.1", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index ed0e59b226e..6aa7cab4dc9 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.3.0" + version: "11.3.1" ] end diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 4f1a250fb5c..6a712ee4f3b 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.3.0"}] + @default_headers [{"User-Agent", "blockscout-11.3.1"}] # 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 eef800239da..578ee08ade3 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.3.0", + version: "11.3.1", 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 8d2fb44980a..25ac2b804b0 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.3.0", + version: "11.3.1", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 2ca0a375f6c..aa5528b6382 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.3.0", + version: "11.3.1", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index 35bc091e48c..e1d6d7bd2f7 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.3.0", + version: "11.3.1", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/docker/Makefile b/docker/Makefile index 01c60d54b12..b3c81e4a26e 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.3.0' +RELEASE_VERSION ?= '11.3.1' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/mix.exs b/mix.exs index 628ab10b711..e15a85ff597 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "11.3.0", + version: "11.3.1", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/rel/config.exs b/rel/config.exs index 5446ef721ed..43d438d9cb7 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.3.0" + set version: "11.3.1" set applications: [ :runtime_tools, block_scout_web: :permanent, From 43a8e72aa8eab254e2fa70a145d736812acc11dd Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:46:57 +0400 Subject: [PATCH 53/82] fix: Move counters_corrections after ctb deriving (#14837) --- .../counters/address_counters_consolidator.ex | 8 +++++- .../counters/token_counters_consolidator.ex | 8 +++++- .../explorer/chain/import/runner/blocks.ex | 26 ++++++++++--------- .../address_counters_consolidator_test.exs | 7 ++--- .../token_counters_consolidator_test.exs | 7 ++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex index 76d2b7f35bc..515ee3474e6 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_counters_consolidator.ex @@ -432,7 +432,9 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do ^min_blocks ), on: address.hash == affected.hash, - where: address.hash in subquery(addresses_lock_query(hashes)), + # addresses whose watermark is below every given block can't be reset, so + # they aren't locked: a reorg within the safety lag locks nothing + where: address.hash in subquery(covered_addresses_lock_query(hashes, Enum.min(min_blocks))), where: not is_nil(address.counters_updated_at) and address.counters_updated_at >= affected.block_number, update: [set: [counters_updated_at: nil, updated_at: ^DateTime.utc_now()]], select: address.hash @@ -566,6 +568,10 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidator do ) end + defp covered_addresses_lock_query(hashes_bytes, min_block_number) do + where(addresses_lock_query(hashes_bytes), [address], address.counters_updated_at >= ^min_block_number) + end + defp schedule_next_consolidation(timeout) do Process.send_after(self(), :consolidate, timeout) end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex index 4d94e0fb6d2..4a1dc34b2fd 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/token_counters_consolidator.ex @@ -469,7 +469,9 @@ defmodule Explorer.Chain.Cache.Counters.TokenCountersConsolidator do ^min_blocks ), on: token.contract_address_hash == affected.hash, - where: token.contract_address_hash in subquery(tokens_lock_query(hashes)), + # tokens whose watermark is below every given block can't be reset, so + # they aren't locked + where: token.contract_address_hash in subquery(covered_tokens_lock_query(hashes, Enum.min(min_blocks))), where: not is_nil(token.counters_updated_at) and token.counters_updated_at >= affected.block_number, update: [set: [counters_updated_at: nil, updated_at: ^DateTime.utc_now()]], select: token.contract_address_hash @@ -574,6 +576,10 @@ defmodule Explorer.Chain.Cache.Counters.TokenCountersConsolidator do ) end + defp covered_tokens_lock_query(hashes_bytes, min_block_number) do + where(tokens_lock_query(hashes_bytes), [token], token.counters_updated_at >= ^min_block_number) + end + defp burn_address_bytes do {:ok, burn_address_hash} = Hash.Address.cast(burn_address_hash_string()) diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index b91b23ae20e..0d79d642142 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -172,18 +172,6 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :derive_transaction_forks ) end) - |> Multi.run(:counters_corrections, fn repo, - %{ - counters_refetched_block_numbers: refetched_block_numbers, - fork_transactions: forked_transactions - } -> - Instrumenter.block_import_stage_runner( - fn -> counters_corrections(repo, refetched_block_numbers, forked_transactions, insert_options) end, - :address_referencing, - :blocks, - :counters_corrections - ) - end) |> Multi.run(:delete_address_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> Instrumenter.block_import_stage_runner( fn -> delete_address_token_balances(repo, non_consensus_blocks, insert_options) end, @@ -269,6 +257,20 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :update_token_instances_owner ) end) + |> Multi.run(:counters_corrections, fn repo, + %{ + counters_refetched_block_numbers: refetched_block_numbers, + fork_transactions: forked_transactions + } -> + Instrumenter.block_import_stage_runner( + fn -> + counters_corrections(repo, refetched_block_numbers, forked_transactions, insert_options) + end, + :address_referencing, + :blocks, + :counters_corrections + ) + end) |> Multi.run(:blocks_update_token_holder_counts, fn repo, %{ delete_address_current_token_balances: deleted, diff --git a/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs index acbf1d9cc1a..055df0cda71 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/address_counters_consolidator_test.exs @@ -257,17 +257,18 @@ defmodule Explorer.Chain.Cache.Counters.AddressCountersConsolidatorTest do start_supervised!(AddressCounters) covered_address = insert(:address, counters_updated_at: 100) - fresh_address = insert(:address, counters_updated_at: 30) + # covers the lowest given block, but not its own one + fresh_address = insert(:address, counters_updated_at: 60) reset_bytes = AddressCountersConsolidator.reset_covered_watermarks(%{ covered_address.hash.bytes => 50, - fresh_address.hash.bytes => 50 + fresh_address.hash.bytes => 70 }) assert reset_bytes == [covered_address.hash.bytes] assert is_nil(Repo.get(Address, covered_address.hash).counters_updated_at) - assert Repo.get(Address, fresh_address.hash).counters_updated_at == 30 + assert Repo.get(Address, fresh_address.hash).counters_updated_at == 60 # the recalculation of the reset address is scheduled :sys.get_state(AddressCounters) diff --git a/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs b/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs index f80ea289eb5..cd77f417991 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/token_counters_consolidator_test.exs @@ -240,17 +240,18 @@ defmodule Explorer.Chain.Cache.Counters.TokenCountersConsolidatorTest do start_supervised!(TokenCounters) covered_token = insert(:token, counters_updated_at: 100) - fresh_token = insert(:token, counters_updated_at: 30) + # covers the lowest given block, but not its own one + fresh_token = insert(:token, counters_updated_at: 60) reset_bytes = TokenCountersConsolidator.reset_covered_watermarks(%{ covered_token.contract_address_hash.bytes => 50, - fresh_token.contract_address_hash.bytes => 50 + fresh_token.contract_address_hash.bytes => 70 }) assert reset_bytes == [covered_token.contract_address_hash.bytes] assert is_nil(Repo.get_by(Token, contract_address_hash: covered_token.contract_address_hash).counters_updated_at) - assert Repo.get_by(Token, contract_address_hash: fresh_token.contract_address_hash).counters_updated_at == 30 + assert Repo.get_by(Token, contract_address_hash: fresh_token.contract_address_hash).counters_updated_at == 60 # the recalculation of the reset token is scheduled :sys.get_state(TokenCounters) From 9a55cc1a80622698fff55e02f51185c4a5b12f9f Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 17 Sep 2026 14:55:28 +0300 Subject: [PATCH 54/82] fix: tolerate non-string NFT metadata values in NFTHelper (#14838) --- .../lib/block_scout_web/views/nft_helper.ex | 46 ++++++++--- .../block_scout_web/views/nft_helper_test.exs | 79 +++++++++++++++++++ apps/explorer/test/support/factory.ex | 2 +- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex index d19df7d939b..0ad82eef088 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex @@ -27,20 +27,34 @@ defmodule BlockScoutWeb.NFTHelper do nil end - if result && String.trim(result) == "", do: nil, else: result + normalize_url(result) end + @doc """ + Returns the `external_url` from the token instance metadata, or `nil` when it is + missing, blank, not a string, or not an `http(s)` URL. Metadata is user-controlled, + so the value may be of any JSON type; a list is unwrapped to its first element, + anything else that is not a string is treated as absent. The value is rendered as + a link `href`, so non-web schemes such as `javascript:` or `data:` are rejected. + """ + @spec external_url(Explorer.Chain.Token.Instance.t() | nil) :: String.t() | nil def external_url(nil), do: nil - def external_url(instance) do - result = - if instance.metadata && instance.metadata["external_url"] do - instance.metadata["external_url"] - else - external_url(nil) - end + def external_url(%{metadata: %{"external_url" => external_url}}) do + external_url + |> normalize_url() + |> web_url() + end + + def external_url(_instance), do: nil + + defp web_url(nil), do: nil - if !result || (result && String.trim(result)) == "", do: external_url(nil), else: result + defp web_url(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) and host != "" -> url + _ -> nil + end end def retrieve_image(image) when is_nil(image), do: nil @@ -54,13 +68,25 @@ defmodule BlockScoutWeb.NFTHelper do retrieve_image(image_url) end - def retrieve_image(image_url) do + def retrieve_image(image_url) when is_binary(image_url) do image_url |> URI.decode() |> URI.encode() |> compose_resource_url() end + def retrieve_image(_image), do: nil + + # Metadata values come from arbitrary JSON, so a URL field may hold a list, + # a map, a number, etc. Only a non-blank string is a usable URL. + defp normalize_url(url) when is_binary(url) do + if String.trim(url) == "", do: nil, else: url + end + + defp normalize_url([first | _]), do: normalize_url(first) + + defp normalize_url(_), do: nil + @doc """ Composes a full IPFS URL from the given image URL. diff --git a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs index 71affd17f4e..aa31f0696df 100644 --- a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs @@ -82,6 +82,85 @@ defmodule BlockScoutWeb.NFTHelperTest do metadata = %{"properties" => []} assert NFTHelper.get_media_src(metadata, true) == nil end + + test "unwraps image when it is a list of urls" do + metadata = %{"image" => ["https://example.com/first.png", "https://example.com/second.png"]} + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/first.png" + end + + test "returns nil when properties.image is a non-string value" do + assert NFTHelper.get_media_src(%{"properties" => %{"image" => ["https://example.com/a.png"]}}, true) == + "https://example.com/a.png" + + assert NFTHelper.get_media_src(%{"properties" => %{"image" => 42}}, true) == nil + assert NFTHelper.get_media_src(%{"properties" => %{"image" => %{"description" => %{}}}}, true) == nil + end + end + + describe "external_url/1" do + test "returns nil for nil instance" do + assert NFTHelper.external_url(nil) == nil + end + + test "returns nil when metadata is nil or has no external_url" do + assert NFTHelper.external_url(%{metadata: nil}) == nil + assert NFTHelper.external_url(%{metadata: %{"name" => "NFT"}}) == nil + end + + test "returns external_url when it is a string" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => "https://example.com"}}) == + "https://example.com" + end + + test "returns nil when external_url is blank" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => ""}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => " "}}) == nil + end + + test "unwraps external_url when it is a list" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => ["https://cryptoversecomic.com"]}}) == + "https://cryptoversecomic.com" + + assert NFTHelper.external_url(%{metadata: %{"external_url" => []}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => [nil, "https://example.com"]}}) == nil + end + + test "returns nil when external_url is not a string" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => 123}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => %{"url" => "https://example.com"}}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => true}}) == nil + end + + test "accepts http and https URLs only" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => "http://example.com/path?q=1"}}) == + "http://example.com/path?q=1" + + assert NFTHelper.external_url(%{metadata: %{"external_url" => "HTTPS://Example.com"}}) == + "HTTPS://Example.com" + end + + test "returns nil for non-web schemes and scheme-less values" do + assert NFTHelper.external_url(%{metadata: %{"external_url" => "javascript:alert(1)"}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => ["javascript:alert(1)"]}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => "data:text/html,"}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => "ipfs://QmHash"}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => "example.com"}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => "/relative/path"}}) == nil + assert NFTHelper.external_url(%{metadata: %{"external_url" => "http://"}}) == nil + end + end + + describe "retrieve_image/1" do + test "returns nil for non-string, non-map, non-list values" do + assert NFTHelper.retrieve_image(nil) == nil + assert NFTHelper.retrieve_image(42) == nil + assert NFTHelper.retrieve_image(true) == nil + end + + test "unwraps list and returns nil for empty list" do + assert NFTHelper.retrieve_image(["https://example.com/a.png"]) == "https://example.com/a.png" + assert NFTHelper.retrieve_image([]) == nil + end end describe "compose_resource_url/1" do diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index f88567275e7..b3d3880ab2f 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -1276,7 +1276,7 @@ defmodule Explorer.Factory do "key" => sequence("value"), "image_url" => sequence("image_url"), "animation_url" => sequence("image_url"), - "external_url" => sequence("external_url") + "external_url" => sequence("https://example.com/external_url") }, error: nil, owner_address_hash: insert(:address).hash From 21515c696f49af865061c51226a861cfcc248db0 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 17 Sep 2026 16:03:28 +0300 Subject: [PATCH 55/82] fix: dedupe multichain export queue rows to avoid cardinality violation (#14839) --- .../multichain_search.ex | 26 ++++++--- .../migrator/backfill_multichain_search_db.ex | 7 ++- .../multichain_search_test.exs | 54 ++++++++++++++++++- cspell.json | 1 + 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex index 7b7ad55d3fa..e58c5d39a6c 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex @@ -909,7 +909,12 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do } end) - main_queue = Enum.sort_by(hashes_to_queue ++ addresses_to_queue, &{&1.hash, &1.hash_type}) + # `ON CONFLICT DO UPDATE` fails with a cardinality violation if one `insert_all` + # statement carries two rows with the same conflict key, so dedupe on it here. + main_queue = + (hashes_to_queue ++ addresses_to_queue) + |> Enum.sort_by(&{&1.hash, &1.hash_type}) + |> Enum.uniq_by(&{&1.hash, &1.hash_type}) balances_queue = compose_balances_queue(address_coin_balances, address_token_balances) @@ -954,10 +959,12 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do } end) - Enum.sort_by( - coin_balances_queue ++ token_balances_queue, - &{&1.address_hash, &1.token_contract_address_hash_or_native, &1[:token_id]} - ) + # Conflict target of the balances queue is + # `(address_hash, token_contract_address_hash_or_native, COALESCE(token_id, -1))`; + # duplicates within one `insert_all` would raise a cardinality violation. + (coin_balances_queue ++ token_balances_queue) + |> Enum.sort_by(&{&1.address_hash, &1.token_contract_address_hash_or_native, &1[:token_id]}) + |> Enum.uniq_by(&{&1.address_hash, &1.token_contract_address_hash_or_native, &1[:token_id]}) end @spec http_post_request(String.t(), map()) :: {:ok, any()} | {:error, String.t()} @@ -1056,18 +1063,21 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do block_transaction_hashes = block_hashes ++ transaction_hashes + # Dedupe by hash only: the same address can arrive several times with different + # non-key fields (e.g. coin balance read at different moments), and duplicates would + # later violate the export queues' unique constraints within a single `insert_all`. indexed_addresses_chunks = addresses |> Enum.sort_by(& &1.hash) - |> Enum.uniq() + |> Enum.uniq_by(& &1.hash) |> Enum.chunk_every(addresses_chunk_size()) |> Enum.with_index() indexed_address_coin_balances_chunks = address_coin_balances - |> Enum.sort_by(& &1.address_hash) - |> Enum.uniq() |> Enum.reject(&is_nil(&1.value)) + |> Enum.sort_by(& &1.address_hash) + |> Enum.uniq_by(& &1.address_hash) |> Enum.chunk_every(addresses_chunk_size()) |> Enum.with_index() diff --git a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex index 7014767189d..edd15c6c1e0 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex @@ -127,8 +127,13 @@ defmodule Explorer.Migrator.BackfillMultichainSearchDB do # credo:disable-for-next-line Credo.Check.Refactor.Nesting extract_address_from_result(result) ++ addresses_acc end) - |> Enum.uniq() |> Enum.reject(&is_nil/1) + # The same address may be preloaded by several of the concurrent queries above + # while the indexer keeps updating `addresses` (e.g. `fetched_coin_balance`), so + # the structs for one hash can differ. Dedupe by hash, keeping the snapshot with + # the most recently fetched coin balance. + |> Enum.sort_by(&(&1.fetched_coin_balance_block_number || -1), :desc) + |> Enum.uniq_by(& &1.hash) to_import = %{ addresses: addresses, diff --git a/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs index 1ce2643cbe6..cc4ea1484a3 100644 --- a/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs +++ b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs @@ -5,7 +5,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do import Mox alias Explorer.Chain.Cache.ChainId - alias Explorer.Chain.MultichainSearchDb.{MainExportQueue, TokenInfoExportQueue} + alias Explorer.Chain.MultichainSearchDb.{BalancesExportQueue, MainExportQueue, TokenInfoExportQueue} alias Explorer.Chain.{Hash, Token, Wei} alias Explorer.MicroserviceInterfaces.MultichainSearch alias Explorer.{Repo, TestHelper} @@ -158,6 +158,58 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do end) end + test "dedupes the same address passed with different coin balances and populates queues on error without crashing" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 0 + + address = insert(:address, fetched_coin_balance: 100) + # the same address row read at a different moment, after the indexer updated its balance + stale_address = %{address | fetched_coin_balance: Wei.from(Decimal.new(50), :wei)} + + params = %{ + addresses: [address, stale_address], + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + expected_address = address_export_data(address) + + assert {:error, + %{ + addresses: [^expected_address], + address_coin_balances: [%{address_hash: _, value: _}] + }} = MultichainSearch.batch_import(params) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 1 + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 1 + end + test "returns {:error, data_to_retry} when at least one chunk is failed" do Application.put_env(:explorer, MultichainSearch, service_url: "http://localhost:1234", diff --git a/cspell.json b/cspell.json index 09edb46dc75..94452903c4e 100644 --- a/cspell.json +++ b/cspell.json @@ -206,6 +206,7 @@ "decompiler", "Decompiler", "dedup", + "dedupe", "DefiLlama", "defmock", "defsupervisor", From b7671f6cef934eb6940c20bc8c0be336fee45f9c Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 17 Sep 2026 18:07:22 +0300 Subject: [PATCH 56/82] fix: increase beacon RPC client response timeout to 30s (#14840) --- apps/indexer/lib/indexer/fetcher/beacon/client.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/indexer/lib/indexer/fetcher/beacon/client.ex b/apps/indexer/lib/indexer/fetcher/beacon/client.ex index c4a42cfcb74..49306a2f93c 100644 --- a/apps/indexer/lib/indexer/fetcher/beacon/client.ex +++ b/apps/indexer/lib/indexer/fetcher/beacon/client.ex @@ -10,7 +10,7 @@ defmodule Indexer.Fetcher.Beacon.Client do @request_error_msg "Error while sending request to beacon rpc" defp http_get_request(url) do - case HttpClient.get(url) do + case HttpClient.get(url, [], recv_timeout: 30_000) do {:ok, %{body: body, status_code: 200}} -> Jason.decode(body) From 4670d6d2bb84cdf740296760aefa88fd5bab98bc Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:47:51 +0400 Subject: [PATCH 57/82] perf: Preload data for realtime block events by batch (#14842) --- .../lib/block_scout_web/notifier.ex | 78 ++--- .../channels/v2/block_channel_test.exs | 275 +++++++++++------- .../chain/address/metadata_preloader.ex | 74 +++-- .../explorer/microservice_interfaces/bens.ex | 10 +- .../microservice_interfaces/metadata.ex | 10 +- .../chain/address/metadata_preloader_test.exs | 43 +++ .../test/explorer/chain/transaction_test.exs | 33 +-- apps/explorer/test/support/query_sources.ex | 39 +++ 8 files changed, 312 insertions(+), 250 deletions(-) create mode 100644 apps/explorer/test/support/query_sources.ex diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index d58223e9d0b..fbcc3847dda 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -62,13 +62,11 @@ defmodule BlockScoutWeb.Notifier do alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, Helper} alias Explorer.Chain.Supply.RSK alias Explorer.Chain.Transaction.History.TransactionStats - alias Explorer.MicroserviceInterfaces.{BENS, Metadata} alias Explorer.SmartContract.{CompilerVersion, Solidity.CodeCompiler} alias Phoenix.View alias Timex.Duration - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens_to_block: 1] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata_to_block: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 3] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] @check_broadcast_sequence_period 500 @@ -471,11 +469,11 @@ defmodule BlockScoutWeb.Notifier do end defp do_handle_blocks(blocks) do - last_broadcasted_block_number = Helper.fetch_from_ets_cache(:last_broadcasted_block, :number) - blocks |> Enum.sort_by(& &1.number, :asc) + |> preload_blocks_for_broadcast() |> Enum.each(fn block -> + last_broadcasted_block_number = Helper.fetch_from_ets_cache(:last_broadcasted_block, :number) broadcast_latest_block?(block, last_broadcasted_block_number) end) end @@ -683,18 +681,19 @@ defmodule BlockScoutWeb.Notifier do ) end - defp broadcast_block(block) do - preloaded_block = - block - |> Repo.preload([ - [miner: [:names, :smart_contract, proxy_implementations_association()]], - :transactions, - :rewards - ]) - # TODO: theoretically might introduce performance issues, - # consider async broadcast of enrichment data - |> maybe_preload_enrichment_for_broadcast() + defp preload_blocks_for_broadcast(blocks) do + blocks + |> Repo.preload([ + [miner: [:names, :smart_contract, proxy_implementations_association()]], + :transactions, + :rewards + ]) + # TODO: the enrichment holds the broadcast of the whole batch back for up to + # its timeout, consider broadcasting the enrichment data separately + |> maybe_preload_enrichment_for_broadcast() + end + defp broadcast_block(preloaded_block) do average_block_time = AverageBlockTime.average_block_time() # TODO: delete duplicated event when old UI becomes deprecated @@ -703,7 +702,7 @@ defmodule BlockScoutWeb.Notifier do average_block_time: average_block_time }) - Endpoint.local_broadcast("blocks_old:#{to_string(block.miner_hash)}", "new_block", %{ + Endpoint.local_broadcast("blocks_old:#{to_string(preloaded_block.miner_hash)}", "new_block", %{ block: preloaded_block, average_block_time: average_block_time }) @@ -718,51 +717,20 @@ defmodule BlockScoutWeb.Notifier do } Endpoint.local_broadcast("blocks:new_block", "new_block", block_params_v2) - Endpoint.local_broadcast("blocks:#{to_string(block.miner_hash)}", "new_block", block_params_v2) + Endpoint.local_broadcast("blocks:#{to_string(preloaded_block.miner_hash)}", "new_block", block_params_v2) end - defp maybe_preload_enrichment_for_broadcast(block) do - if !block_broadcast_enrichment_disabled?() and (BENS.enabled?() or Metadata.enabled?()) do - preload_enrichment_for_broadcast(block) + defp maybe_preload_enrichment_for_broadcast(blocks) do + if block_broadcast_enrichment_disabled?() do + blocks else - block - end - end - - defp preload_enrichment_for_broadcast(block) do - timeout = block_broadcast_enrichment_timeout() + # The timeout is per miner, and a single request asks about all miners of the batch + miners_count = blocks |> Enum.uniq_by(& &1.miner_hash) |> Enum.count() - results = - Task.Supervisor.async_stream_nolink( - Explorer.TaskSupervisor, - [ - {:ens_domain_name, &maybe_preload_ens_to_block/1}, - {:metadata, &maybe_preload_metadata_to_block/1} - ], - fn {field, preload_fun} -> - {field, preload_fun.(block)} - end, - timeout: timeout, - on_timeout: :kill_task, - ordered: false - ) - - Enum.reduce(results, block, fn - {:ok, {field, enriched_block}}, acc -> merge_enriched_miner_field(acc, enriched_block, field) - _, acc -> acc - end) - end - - defp merge_enriched_miner_field(%{miner: %{} = miner} = block, %{miner: %{} = enriched_miner}, field) do - case Map.fetch(enriched_miner, field) do - {:ok, nil} -> block - {:ok, value} -> %{block | miner: Map.replace(miner, field, value)} - :error -> block + maybe_preload_ens_and_metadata(blocks, :blocks, timeout: block_broadcast_enrichment_timeout() * miners_count) end end - defp merge_enriched_miner_field(block, _enriched_block, _field), do: block - defp broadcast_rewards(rewards) do {emission_rewards, validator_rewards} = Enum.split_with(rewards, fn reward -> reward.address_type == :emission_funds end) diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs index 0afa7f4b0b0..87361d3c607 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs @@ -2,18 +2,28 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do use BlockScoutWeb.ChannelCase + import Explorer.QuerySources, only: [with_query_sources: 1] + alias BlockScoutWeb.Notifier - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, Block} alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Explorer.MicroserviceInterfaces.{BENS, Metadata} + alias Explorer.Repo alias Plug.Conn + @chain_id 1 + setup do old_notifier = Application.get_env(:block_scout_web, Notifier, []) topic = "blocks:new_block" @endpoint.subscribe(topic) + start_supervised!(AverageBlockTime) + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + on_exit(fn -> Application.put_env(:block_scout_web, Notifier, old_notifier) + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) Phoenix.PubSub.unsubscribe(BlockScoutWeb.PubSub, topic) end) @@ -23,13 +33,6 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do test "subscribed user is notified of new_block event", %{topic: topic} do block = insert(:block, number: 1) - start_supervised!(AverageBlockTime) - Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) - - on_exit(fn -> - Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) - end) - Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) receive do @@ -54,39 +57,13 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do test "new_block payload includes miner ENS and metadata when microservices are enabled", %{topic: topic} do bypass = Bypass.open() - - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) - old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) - old_tesla_adapter = Application.get_env(:tesla, :adapter) - - Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) - - chain_id = 1 - Application.put_env(:block_scout_web, :chain_id, chain_id) - - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - protocols: [] - ) - - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, - service_url: "http://localhost:#{bypass.port}", - enabled: true - ) - - on_exit(fn -> - Bypass.down(bypass) - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) - Application.put_env(:tesla, :adapter, old_tesla_adapter) - end) + on_exit(fn -> Bypass.down(bypass) end) + enable_enrichment_microservices("http://localhost:#{bypass.port}") + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_timeout: :timer.seconds(5)) miner = insert(:address) - Bypass.expect_once(bypass, "POST", "/api/v1/#{chain_id}/addresses:batch_resolve_names", fn conn -> + Bypass.expect_once(bypass, "POST", "/api/v1/#{@chain_id}/addresses:batch_resolve_names", fn conn -> Conn.resp( conn, 200, @@ -114,13 +91,6 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do block = insert(:block, number: 1, miner: miner) - start_supervised!(AverageBlockTime) - Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) - - on_exit(fn -> - Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) - end) - Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) receive do @@ -133,48 +103,152 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do end end - test "new_block broadcast skips enrichment when DISABLE_BLOCK_BROADCAST_ENRICHMENT is set", %{topic: topic} do + test "new_block payloads of a batch are enriched with a single request per microservice", %{topic: topic} do bypass = Bypass.open() + on_exit(fn -> Bypass.down(bypass) end) + enable_enrichment_microservices("http://localhost:#{bypass.port}") + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_timeout: :timer.seconds(5)) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) - old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + first_miner = insert(:address) + second_miner = insert(:address) - Application.put_env(:block_scout_web, :chain_id, 1) + # A second request to either microservice fails the test + Bypass.expect_once(bypass, "POST", "/api/v1/#{@chain_id}/addresses:batch_resolve_names", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "names" => %{ + Address.checksum(first_miner.hash) => "first.eth", + Address.checksum(second_miner.hash) => "second.eth" + } + }) + ) + end) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - protocols: [] - ) + Bypass.expect_once(bypass, "GET", "/api/v1/metadata", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "addresses" => %{ + Address.checksum(first_miner.hash) => %{"tags" => []}, + Address.checksum(second_miner.hash) => %{"tags" => []} + } + }) + ) + end) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, - service_url: "http://localhost:#{bypass.port}", - enabled: true - ) + first_block = insert(:block, number: 1, miner: first_miner) + second_block = insert(:block, number: 2, miner: second_miner) + third_block = insert(:block, number: 3, miner: first_miner) - Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_disabled: true) + Notifier.handle_event({:chain_event, :blocks, :realtime, [second_block, third_block, first_block]}) - on_exit(fn -> - Bypass.down(bypass) - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) + miners_info = + for _ <- 1..3 do + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: block_payload}}, + :timer.seconds(5) + + {block_payload["height"], block_payload["miner"]["ens_domain_name"], block_payload["miner"]["metadata"]} + end + + assert Enum.sort(miners_info) == [ + {1, "first.eth", %{"tags" => []}}, + {2, "second.eth", %{"tags" => []}}, + {3, "first.eth", %{"tags" => []}} + ] + end + + test "new_block payload skips miner ENS when DISABLE_BLOCKS_BENS_PRELOAD is set", %{topic: topic} do + bypass = Bypass.open() + on_exit(fn -> Bypass.down(bypass) end) + enable_enrichment_microservices("http://localhost:#{bypass.port}", disable_blocks_bens_preload: true) + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_timeout: :timer.seconds(5)) + + miner = insert(:address) + + # Would put the name into the payload, were BENS asked + Bypass.stub(bypass, "POST", "/api/v1/#{@chain_id}/addresses:batch_resolve_names", fn conn -> + Conn.resp(conn, 200, Jason.encode!(%{"names" => %{Address.checksum(miner.hash) => "miner.eth"}})) + end) + + Bypass.expect_once(bypass, "GET", "/api/v1/metadata", fn conn -> + Conn.resp(conn, 200, Jason.encode!(%{"addresses" => %{Address.checksum(miner.hash) => %{"tags" => []}}})) end) + block = insert(:block, number: 1, miner: miner) + + Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: block_payload}}, + :timer.seconds(5) + + assert is_nil(block_payload["miner"]["ens_domain_name"]) + assert block_payload["miner"]["metadata"] == %{"tags" => []} + end + + test "new_block broadcast of a batch makes as many queries as of a single block", %{topic: topic} do + [block | batch] = + for number <- 1..4 do + block = insert(:block, number: number) + :transaction |> insert() |> with_block(block) + insert(:reward, address_hash: block.miner_hash, block_hash: block.hash) + + # The blocks of a chain event come without loaded associations + Repo.get!(Block, block.hash) + end + + {_, block_query_sources} = + with_query_sources(fn -> Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) end) + + {_, batch_query_sources} = + with_query_sources(fn -> Notifier.handle_event({:chain_event, :blocks, :realtime, batch}) end) + + for number <- 1..4 do + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "new_block", + payload: %{block: %{"height" => ^number, "transactions_count" => 1, "rewards" => [_]}} + }, + :timer.seconds(5) + end + + assert "transactions" in block_query_sources + assert "block_rewards" in block_query_sources + assert Enum.frequencies(batch_query_sources) == Enum.frequencies(block_query_sources) + end + + test "new_block broadcast sends the consecutive blocks of a batch without waiting", %{topic: topic} do + blocks = for number <- 11..13, do: insert(:block, number: number) + + :ets.insert(:last_broadcasted_block, {:number, 10}) + on_exit(fn -> :ets.delete(:last_broadcasted_block, :number) end) + + Notifier.handle_event({:chain_event, :blocks, :realtime, Enum.reverse(blocks)}) + + # A block waiting for its predecessor would be broadcast from a task later on + for number <- 11..13 do + assert_received %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "new_block", + payload: %{block: %{"height" => ^number}} + } + end + end + + test "new_block broadcast skips enrichment when DISABLE_BLOCK_BROADCAST_ENRICHMENT is set", %{topic: topic} do + bypass = Bypass.open() + on_exit(fn -> Bypass.down(bypass) end) + enable_enrichment_microservices("http://localhost:#{bypass.port}") + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_disabled: true) + # No Bypass.expect calls — any HTTP call to the microservices would cause Bypass to raise Bypass.pass(bypass) miner = insert(:address) block = insert(:block, number: 1, miner: miner) - start_supervised!(AverageBlockTime) - Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) - - on_exit(fn -> - Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) - end) - Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) receive do @@ -187,46 +261,13 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do end test "new_block broadcast falls back quickly when enrichment services are unavailable", %{topic: topic} do - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) - old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) - old_tesla_adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) - - chain_id = 1 - Application.put_env(:block_scout_web, :chain_id, chain_id) - + enable_enrichment_microservices("http://127.0.0.1:9") Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_timeout: 50) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, - service_url: "http://127.0.0.1:9", - enabled: true, - protocols: [] - ) - - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, - service_url: "http://127.0.0.1:9", - enabled: true - ) - - on_exit(fn -> - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) - Application.put_env(:tesla, :adapter, old_tesla_adapter) - end) - miner = insert(:address) block = insert(:block, number: 1, miner: miner) - start_supervised!(AverageBlockTime) - Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) - - on_exit(fn -> - Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) - end) - timeout = Application.get_env(:block_scout_web, Notifier, []) |> Keyword.get(:block_broadcast_enrichment_timeout, 200) @@ -235,4 +276,26 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: _}}, timeout + 200 end + + defp enable_enrichment_microservices(service_url, bens_options \\ []) do + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + old_bens = Application.get_env(:explorer, BENS) + old_metadata = Application.get_env(:explorer, Metadata) + + Application.put_env(:block_scout_web, :chain_id, @chain_id) + + Application.put_env( + :explorer, + BENS, + Keyword.merge(old_bens || [], [service_url: service_url, enabled: true, protocols: []] ++ bens_options) + ) + + Application.put_env(:explorer, Metadata, Keyword.merge(old_metadata || [], service_url: service_url, enabled: true)) + + on_exit(fn -> + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, BENS, old_bens) + Application.put_env(:explorer, Metadata, old_metadata) + end) + end end diff --git a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex index 85dad4aead6..f4d2f929c2d 100644 --- a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex +++ b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex @@ -45,6 +45,17 @@ defmodule Explorer.Chain.Address.MetadataPreloader do """ @type meta_field :: :ens_domain_name | :metadata + @typedoc """ + Option of a microservice preload: + + * `:timeout` - milliseconds the microservice requests may take. A request + that does not complete in time is dropped, and the entities are returned + without its data. Without it, concurrent requests are cut off by a 10 + seconds backstop, and a single request is bounded by its own receive + timeout only. + """ + @type preload_option :: {:timeout, non_neg_integer()} + @all_meta_fields [:ens_domain_name, :metadata] # Backstop for the concurrent microservice requests. Both BENS and Metadata @@ -62,16 +73,17 @@ defmodule Explorer.Chain.Address.MetadataPreloader do 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. + input. The metadata preload is not flag-gated. See `t:preload_option/0` for + the `options`. """ - @spec maybe_preload_ens_and_metadata(supported_input(), entity_kind()) :: supported_input() - def maybe_preload_ens_and_metadata(input, entity_kind \\ :any) do + @spec maybe_preload_ens_and_metadata(supported_input(), entity_kind(), [preload_option()]) :: supported_input() + def maybe_preload_ens_and_metadata(input, entity_kind \\ :any, options \\ []) 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) + maybe_preload_selected_meta(input, fields, options) end @doc """ @@ -83,21 +95,22 @@ defmodule Explorer.Chain.Address.MetadataPreloader do 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. + requested unless the caller asks for it. See `t:preload_option/0` for the + `options`. """ - @spec maybe_preload_selected_meta(supported_input(), [meta_field()]) :: supported_input() - def maybe_preload_selected_meta(input, fields) + @spec maybe_preload_selected_meta(supported_input(), [meta_field()], [preload_option()]) :: supported_input() + def maybe_preload_selected_meta(input, fields, options \\ []) - def maybe_preload_selected_meta(input, []), do: input + def maybe_preload_selected_meta(input, [], _options), do: input - def maybe_preload_selected_meta(nil, _fields), do: nil + def maybe_preload_selected_meta(nil, _fields, _options), do: nil - def maybe_preload_selected_meta(items, fields) when is_list(items) do - preload_selected_meta(items, fields) + def maybe_preload_selected_meta(items, fields, options) when is_list(items) do + preload_selected_meta(items, fields, options) end - def maybe_preload_selected_meta(item, fields) do - [item_with_meta] = preload_selected_meta([item], fields) + def maybe_preload_selected_meta(item, fields, options) do + [item_with_meta] = preload_selected_meta([item], fields, options) item_with_meta end @@ -131,15 +144,6 @@ defmodule Explorer.Chain.Address.MetadataPreloader do address_with_ens end - @doc """ - Preloads ENS name to Block.t() - """ - @spec preload_ens_to_block(Block.t()) :: Block.t() - def preload_ens_to_block(block) do - [block_with_ens] = preload_ens_to_list([block]) - block_with_ens - end - @doc """ Preloads ENS names to list of supported entities """ @@ -181,15 +185,6 @@ defmodule Explorer.Chain.Address.MetadataPreloader do transaction_with_metadata end - @doc """ - Preloads metadata to Block.t() - """ - @spec preload_metadata_to_block(Block.t()) :: Block.t() - def preload_metadata_to_block(block) do - [block_with_metadata] = preload_metadata_to_list([block]) - block_with_metadata - end - @doc """ Preload ENS info to search result, using get_address/1 """ @@ -208,7 +203,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do end) end - defp preload_selected_meta(items, fields) do + defp preload_selected_meta(items, fields, options) do case address_hash_strings(items) do [] -> items @@ -216,7 +211,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do address_hash_strings -> address_hash_strings |> meta_fetchers(fields) - |> fetch_meta() + |> fetch_meta(options[:timeout]) |> Enum.reduce(items, fn {field, meta}, acc -> put_meta_to_items(acc, meta, field) end) end end @@ -236,18 +231,19 @@ defmodule Explorer.Chain.Address.MetadataPreloader do |> Enum.filter(fn {field, enabled?, _fetcher} -> enabled? and field in fields end) end - defp fetch_meta([]), do: [] + defp fetch_meta([], _timeout), 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.()) + # A single request needs no task unless it has a timeout to keep: running it + # in the caller keeps the logger metadata and the stacktrace of the calling + # process. + defp fetch_meta([{field, _enabled?, fetcher}], nil), do: fetched_meta(field, fetcher.()) - defp fetch_meta(fetchers) do + defp fetch_meta(fetchers, timeout) do Explorer.TaskSupervisor |> Task.Supervisor.async_stream_nolink( fetchers, fn {field, _enabled?, fetcher} -> {field, fetcher.()} end, - timeout: @concurrent_preload_timeout, + timeout: timeout || @concurrent_preload_timeout, on_timeout: :kill_task, ordered: false ) diff --git a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex index 90f0c5913af..4f414c56a03 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex @@ -7,7 +7,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do alias Explorer.Chain alias Explorer.Chain.Address.MetadataPreloader - alias Explorer.Chain.{Address, Block, Transaction} + alias Explorer.Chain.{Address, Transaction} alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice @@ -336,14 +336,6 @@ defmodule Explorer.MicroserviceInterfaces.BENS do maybe_preload_meta(address, __MODULE__, &MetadataPreloader.preload_ens_to_address/1) end - @doc """ - Preloads ENS data to the block if BENS is enabled - """ - @spec maybe_preload_ens_to_block(Block.t()) :: Block.t() - def maybe_preload_ens_to_block(block) do - maybe_preload_meta(block, __MODULE__, &MetadataPreloader.preload_ens_to_block/1) - end - @doc """ Preloads ENS data to the list of blocks unless disabled via DISABLE_BLOCKS_BENS_PRELOAD. diff --git a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex index c613c7ea65c..f92c52859d7 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex @@ -5,7 +5,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do """ alias Explorer.Chain - alias Explorer.Chain.{Address.MetadataPreloader, Block, Transaction} + alias Explorer.Chain.{Address.MetadataPreloader, Transaction} alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice @@ -186,14 +186,6 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do maybe_preload_meta(transaction, __MODULE__, &MetadataPreloader.preload_metadata_to_transaction/1) end - @doc """ - Preloads metadata to block if Metadata microservice is enabled - """ - @spec maybe_preload_metadata_to_block(Block.t()) :: Block.t() - def maybe_preload_metadata_to_block(block) do - maybe_preload_meta(block, __MODULE__, &MetadataPreloader.preload_metadata_to_block/1) - end - defp decode_meta({:ok, %{"addresses" => addresses} = result}) do prepared_address = Enum.reduce(addresses, %{}, fn {address, meta}, acc -> diff --git a/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs b/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs index 5bbdf43b602..567c66c4350 100644 --- a/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs +++ b/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs @@ -151,6 +151,49 @@ defmodule Explorer.Chain.Address.MetadataPreloaderTest do end end + describe "the :timeout option" do + setup do + # Accepts connections, but never answers them + {:ok, socket} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(socket) + on_exit(fn -> :gen_tcp.close(socket) end) + + for microservice <- [BENS, Metadata] do + Application.put_env( + :explorer, + microservice, + Keyword.put(Application.get_env(:explorer, microservice), :service_url, "http://127.0.0.1:#{port}") + ) + end + + :ok + end + + # Otherwise the requests would last until their receive timeout of 1 second + test "cuts the concurrent requests off" do + {elapsed, [address]} = + measure(fn -> MetadataPreloader.maybe_preload_ens_and_metadata([address()], :any, timeout: 100) end) + + assert elapsed < 1_000 + assert address.ens_domain_name == nil + assert address.metadata == nil + end + + test "cuts a single request off" do + {elapsed, address} = + measure(fn -> MetadataPreloader.maybe_preload_selected_meta(address(), [:ens_domain_name], timeout: 100) end) + + assert elapsed < 1_000 + assert address.ens_domain_name == nil + end + end + + defp measure(fun) do + started_at = System.monotonic_time(:millisecond) + result = fun.() + {System.monotonic_time(:millisecond) - started_at, result} + end + defp address do {:ok, hash} = Chain.string_to_address_hash(@address_hash_string) %Address{hash: hash} diff --git a/apps/explorer/test/explorer/chain/transaction_test.exs b/apps/explorer/test/explorer/chain/transaction_test.exs index 01c2b8cd5ce..e7f1a16bdbd 100644 --- a/apps/explorer/test/explorer/chain/transaction_test.exs +++ b/apps/explorer/test/explorer/chain/transaction_test.exs @@ -3,6 +3,7 @@ defmodule Explorer.Chain.TransactionTest do use Explorer.DataCase import Mox + import Explorer.QuerySources, only: [with_query_sources: 1] alias Ecto.Changeset alias Explorer.Chain.{Address, InternalTransaction, SmartContract, Transaction} @@ -467,38 +468,6 @@ defmodule Explorer.Chain.TransactionTest do insert(:transaction, to_address: proxy_smart_contract.address, input: "0x" <> input_data) end - # runs `fun` and returns its result with the `source` tables of every - # query issued by the calling process meanwhile - defp with_query_sources(fun) do - handler_id = {__MODULE__, :query_sources, make_ref()} - - :telemetry.attach_many( - handler_id, - [[:explorer, :repo, :query], [:explorer, :repo, :replica1, :query]], - &__MODULE__.handle_query_event/4, - self() - ) - - try do - {fun.(), collect_query_sources([])} - after - :telemetry.detach(handler_id) - end - end - - defp collect_query_sources(acc) do - receive do - {:query_source, source} -> collect_query_sources([source | acc]) - after - 0 -> Enum.reverse(acc) - end - end - - @doc false - def handle_query_event(_event, _measurements, %{source: source}, test_pid) do - if self() == test_pid, do: send(test_pid, {:query_source, source}) - end - describe "Poison.encode!/1" do test "encodes transaction input" do assert %{ diff --git a/apps/explorer/test/support/query_sources.ex b/apps/explorer/test/support/query_sources.ex new file mode 100644 index 00000000000..e246286b118 --- /dev/null +++ b/apps/explorer/test/support/query_sources.ex @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.QuerySources do + @moduledoc """ + Records the `source` tables of the queries issued by a piece of code. + """ + + @query_events [[:explorer, :repo, :query], [:explorer, :repo, :replica1, :query]] + + @doc """ + Runs `fun` and returns its result with the `source` tables of every query + issued meanwhile by the calling process or on its behalf, such as by the tasks + Ecto runs the preloads of the same level in. + """ + @spec with_query_sources((-> result)) :: {result, [String.t() | nil]} when result: any() + def with_query_sources(fun) do + handler_id = {__MODULE__, make_ref()} + + :telemetry.attach_many(handler_id, @query_events, &__MODULE__.handle_query_event/4, self()) + + try do + {fun.(), collect_query_sources([])} + after + :telemetry.detach(handler_id) + end + end + + @doc false + def handle_query_event(_event, _measurements, %{source: source}, caller) do + if caller in [self() | Process.get(:"$callers", [])], do: send(caller, {:query_source, source}) + end + + defp collect_query_sources(acc) do + receive do + {:query_source, source} -> collect_query_sources([source | acc]) + after + 0 -> Enum.reverse(acc) + end + end +end From 346d9994d9c1fec97b5bb9441eb8074554adf2d9 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 21 Sep 2026 15:48:10 +0300 Subject: [PATCH 58/82] perf: refill top addresses cache in the background on API nodes (#14843) --- apps/explorer/config/config.exs | 2 + apps/explorer/config/test.exs | 1 + apps/explorer/lib/explorer/application.ex | 1 + apps/explorer/lib/explorer/chain/address.ex | 31 ++- .../lib/explorer/chain/cache/accounts.ex | 6 + .../chain/cache/accounts/refresher.ex | 183 ++++++++++++++++++ .../lib/explorer/chain/ordered_cache.ex | 72 ++++++- .../chain/cache/accounts/refresher_test.exs | 120 ++++++++++++ config/config_helper.exs | 16 +- config/runtime.exs | 13 +- docker-compose/envs/common-blockscout.env | 2 + 11 files changed, 430 insertions(+), 17 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/accounts/refresher.ex create mode 100644 apps/explorer/test/explorer/chain/cache/accounts/refresher_test.exs diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 2dcaaa14556..d61325261be 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -27,6 +27,8 @@ config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: true config :explorer, Explorer.Chain.Cache.AddressTags, enabled: true +config :explorer, Explorer.Chain.Cache.Accounts.Refresher, 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 1551e68471b..7cdfe5e4ec4 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -106,6 +106,7 @@ config :logger, :explorer, path: Path.absname("logs/test/explorer.log") config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: false config :explorer, Explorer.Chain.Cache.AddressTags, enabled: false, ttl: :timer.minutes(5) +config :explorer, Explorer.Chain.Cache.Accounts.Refresher, 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 584438d184a..58d4168c3a1 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -472,6 +472,7 @@ defmodule Explorer.Application do |> configure_chain_type_dependent_process(:zksync), configure_mode_dependent_process(Explorer.Chain.Fetcher.AddressesBlacklist, :api), configure_mode_dependent_process(Explorer.Chain.Cache.ScamAddresses, :api), + configure_mode_dependent_process(Accounts.Refresher, :api), only_in_mode(Explorer.Migrator.SwitchPendingOperations, :indexer), configure_mode_dependent_process(Explorer.Utility.RateLimiter, :api), Hammer.child_for_supervisor() |> configure_mode_dependent_process(:api), diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index fc115c80cac..eea836d4032 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -530,6 +530,10 @@ defmodule Explorer.Chain.Address do @doc """ Lists the top `t:Explorer.Chain.Address.t/0`'s' in descending order based on coin balance and address hash. + The first page in the default order is served from `Explorer.Chain.Cache.Accounts` + when it holds enough entries; otherwise the cache is refilled through + `Explorer.Chain.Cache.Accounts.Refresher`, which runs the query once for all + requests that miss at the same time. """ @spec list_top_addresses :: [{__MODULE__.t(), non_neg_integer()}] def list_top_addresses(options \\ []) do @@ -541,7 +545,7 @@ defmodule Explorer.Chain.Address do |> Accounts.atomic_take_enough() |> case do nil -> - get_addresses(options) + Accounts.Refresher.fetch_top_addresses(paging_options.page_size) accounts -> accounts @@ -551,6 +555,22 @@ defmodule Explorer.Chain.Address do end end + @doc """ + Fetches as many top addresses as `Explorer.Chain.Cache.Accounts` holds from + the database and replaces the content of the cache with them. + + This is the cache refill behind `list_top_addresses/1`; request handlers + should call that function instead, so concurrent misses share one query. + """ + @spec fetch_and_cache_top_addresses() :: [__MODULE__.t()] + def fetch_and_cache_top_addresses do + addresses = fetch_top_addresses(paging_options: %PagingOptions{page_size: Accounts.max_size()}, api?: true) + + Accounts.replace(addresses) + + addresses + end + @doc """ Fetches addresses based on the provided list of address hashes. @@ -672,15 +692,6 @@ defmodule Explorer.Chain.Address do def eoa_with_code?(%NotLoaded{}), do: nil def eoa_with_code?(_), do: false - defp get_addresses(options) do - addresses = fetch_top_addresses(options) - - addresses - |> Accounts.update() - - addresses - end - @default_sorting [desc: :fetched_coin_balance, asc: :hash] defp fetch_top_addresses(options) do diff --git a/apps/explorer/lib/explorer/chain/cache/accounts.ex b/apps/explorer/lib/explorer/chain/cache/accounts.ex index cfee59fbe24..4d0ff04a787 100644 --- a/apps/explorer/lib/explorer/chain/cache/accounts.ex +++ b/apps/explorer/lib/explorer/chain/cache/accounts.ex @@ -2,6 +2,12 @@ defmodule Explorer.Chain.Cache.Accounts do @moduledoc """ Caches the top Addresses + + Holds the first page of `/api/v2/addresses`. In `:all` mode the indexer + invalidates it through `drop/1` when a cached address changes its balance; in + `:api` mode the entries expire on `CACHE_TOP_ADDRESSES_TTL` instead. In both + modes `Explorer.Chain.Cache.Accounts.Refresher` refills it in the background, + so requests only refill it when they miss. """ alias Explorer.Chain.Address diff --git a/apps/explorer/lib/explorer/chain/cache/accounts/refresher.ex b/apps/explorer/lib/explorer/chain/cache/accounts/refresher.ex new file mode 100644 index 00000000000..27449502d4b --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/accounts/refresher.ex @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Accounts.Refresher do + @moduledoc """ + Keeps `Explorer.Chain.Cache.Accounts`, the top-addresses cache behind + `/api/v2/addresses`, filled without a request having to pay for it. + + In `:all` mode the indexer empties the cache whenever a cached address changes + its balance; in `:api` mode nothing tells the node about balance changes, so + the entries expire on a TTL instead. Either way the cache used to be refilled + by the next request, which ran the top-addresses query itself — hundreds of + milliseconds on a large `addresses` table — and so did every other request + arriving while that query was running. + + This process refills the cache on an interval (`:update_interval`, + `CACHE_TOP_ADDRESSES_UPDATE_INTERVAL`). A refill replaces the whole content + of the cache, which renews the TTL of the entries, so as long as the interval + is shorter than the TTL (`CACHE_TOP_ADDRESSES_TTL`) the cache never expires on + an API node, and an entry whose balance changed does not linger next to its + replacement. + + It also serializes the on-demand refills that + `Explorer.Chain.Address.list_top_addresses/1` falls back to when the cache + cannot serve a request: right after boot, after the indexer dropped the + entries, or once refreshes have failed for longer than the TTL. At most one + refill runs at a time, in a task, always for the whole cache; requests + arriving while it runs wait for it and all receive its result — their page of + the addresses, or the same failure — so the query runs once per miss instead + of once per request, whether it succeeds or not. The interval is measured + from the last refill, whatever started it. + + When the process is not running, `fetch_top_addresses/1` runs the query in + the calling process, exactly as the request path did before. + """ + + use GenServer + + require Logger + + alias Explorer.Chain.Address + alias Explorer.Chain.Cache.Accounts + + @typep result :: {:ok, [Address.t()]} | {:error, {atom(), term(), Exception.stacktrace()}} + + # `refill` is the monitor reference of the running task, or `nil`; `waiters` + # are the callers to answer when it completes, with the page size each asked + # for; `timer` is the reference of the next scheduled periodic refill. + @typep state :: %{ + refill: reference() | nil, + waiters: [{GenServer.from(), non_neg_integer()}], + timer: reference() | nil + } + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_) do + send(self(), :refresh) + + {:ok, %{refill: nil, waiters: [], timer: nil}} + end + + @impl true + def handle_info(:refresh, state) do + # A refill already running fills the cache just as well. + {:noreply, maybe_start_refill(state)} + end + + # The task finished: hand its result to everyone who waited for it. + def handle_info({ref, result}, %{refill: ref} = state) do + Process.demonitor(ref, [:flush]) + + {:noreply, complete_refill(state, result)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{refill: ref} = state) do + {:noreply, complete_refill(state, {:error, {:exit, reason, []}})} + end + + def handle_info(_message, state), do: {:noreply, state} + + @impl true + def handle_call({:fetch, page_size}, from, state) do + # A refill that finished while this request was queued already serves it. + case Accounts.atomic_take_enough(page_size) do + nil -> {:noreply, state |> add_waiter(from, page_size) |> maybe_start_refill()} + addresses -> {:reply, {:ok, addresses}, state} + end + end + + @doc """ + Returns the `page_size` top addresses, refilling the cache from the database + when it cannot serve them. + + Concurrent calls share one database query and its outcome: a failed query + raises in every caller, as a direct query would. When the process is not + running, the caller refills the cache itself. When the refill does not + complete within twice the repo's query timeout the call exits, so that the + requests waiting on a slow database do not each start a query of their own. + """ + @spec fetch_top_addresses(non_neg_integer()) :: [Address.t()] + def fetch_top_addresses(page_size) do + case GenServer.call(__MODULE__, {:fetch, page_size}, call_timeout()) do + {:ok, addresses} -> addresses + {:error, {kind, reason, stacktrace}} -> :erlang.raise(kind, reason, stacktrace) + end + catch + # The process is not running: disabled, or on its way down. + :exit, {:noproc, _} -> Address.fetch_and_cache_top_addresses() |> Enum.take(page_size) + end + + @spec add_waiter(state(), GenServer.from(), non_neg_integer()) :: state() + defp add_waiter(state, from, page_size), do: %{state | waiters: [{from, page_size} | state.waiters]} + + @spec maybe_start_refill(state()) :: state() + defp maybe_start_refill(%{refill: nil} = state) do + %Task{ref: ref} = Task.Supervisor.async_nolink(Explorer.TaskSupervisor, &fetch/0) + + %{state | refill: ref} + end + + defp maybe_start_refill(state), do: state + + @spec complete_refill(state(), result()) :: state() + defp complete_refill(%{waiters: waiters} = state, result) do + case result do + {:ok, _addresses} -> + :ok + + {:error, {kind, reason, _stacktrace}} -> + # Reads keep the previous entries until the next interval. Letting this + # crash would restart the process in a loop for as long as the database + # is unreachable. + Logger.error("Failed to refresh the top addresses cache: #{Exception.format_banner(kind, reason)}") + end + + Enum.each(waiters, fn {from, page_size} -> + GenServer.reply(from, with({:ok, addresses} <- result, do: {:ok, Enum.take(addresses, page_size)})) + end) + + state + |> schedule_refresh() + |> Map.merge(%{refill: nil, waiters: []}) + end + + # The next periodic refill is due one interval after the one that just + # completed, so a refill started by a request does not get followed by a + # periodic one right away. + @spec schedule_refresh(state()) :: state() + defp schedule_refresh(%{timer: timer} = state) do + if timer do + Process.cancel_timer(timer) + + # The timer may have fired while the refill was running. + receive do + :refresh -> :ok + after + 0 -> :ok + end + end + + %{state | timer: Process.send_after(self(), :refresh, update_interval())} + end + + @spec fetch() :: result() + defp fetch do + {:ok, Address.fetch_and_cache_top_addresses()} + catch + kind, reason -> {:error, {kind, reason, __STACKTRACE__}} + end + + # A refill runs the top-addresses query and then the cache's preload, each + # bounded by the repo's query timeout, and a request may join it at any point. + defp call_timeout do + 2 * (Application.get_env(:explorer, Explorer.Repo)[:timeout] || :timer.seconds(15)) + end + + defp update_interval do + Application.get_env(:explorer, __MODULE__)[:update_interval] + end +end diff --git a/apps/explorer/lib/explorer/chain/ordered_cache.ex b/apps/explorer/lib/explorer/chain/ordered_cache.ex index ea3dbd62f9a..1a3db1f0ec3 100644 --- a/apps/explorer/lib/explorer/chain/ordered_cache.ex +++ b/apps/explorer/lib/explorer/chain/ordered_cache.ex @@ -143,6 +143,13 @@ defmodule Explorer.Chain.OrderedCache do """ @callback update([element] | element | nil) :: :ok + @doc """ + Replaces the stored elements with `elements`: those are stored (which renews + their TTL, if any) and every other stored element is removed. + Unlike `update/1`, this is a local write that is not propagated to other nodes. + """ + @callback replace([element]) :: :ok + defmacro __using__(name) when is_atom(name), do: do_using(name, []) defmacro __using__(opts) when is_list(opts) do @@ -238,12 +245,22 @@ defmodule Explorer.Chain.OrderedCache do |> ConCache.ets() |> :ets.tab2list() - if amount <= Enum.count(items) - 1 do - items - |> Enum.reject(fn {key, _value} -> key == ids_list_key() end) - |> Enum.sort(&prevails?/2) + # Elements that `update/1` or `replace/1` dropped from the ids list stay + # in the table for a moment before their delayed deletion, so only the + # elements the ids list of the same snapshot still references count. + ids = + case List.keyfind(items, ids_list_key(), 0) do + {_key, ids} -> MapSet.new(ids) + nil -> MapSet.new() + end + + elements = Enum.filter(items, fn {id, _value} -> MapSet.member?(ids, id) end) + + if amount <= Enum.count(elements) do + elements + |> Enum.sort_by(fn {id, _value} -> id end, &prevails?/2) |> Enum.take(amount) - |> Enum.map(fn {_key, value} -> value end) + |> Enum.map(fn {_id, value} -> value end) end end @@ -282,6 +299,32 @@ defmodule Explorer.Chain.OrderedCache do def update(element), do: update([element]) + @impl OrderedCache + def replace(elements) when is_list(elements) do + prepared_elements = + elements + |> Enum.sort_by(&element_to_id(&1), &prevails?(&1, &2)) + |> Enum.take(max_size()) + |> do_preloads() + |> Enum.map(&{element_to_id(&1), sanitize_before_update(&1)}) + + ConCache.update(cache_name(), ids_list_key(), fn ids -> + new_ids = + Enum.map(prepared_elements, fn {element_id, element} -> + put_element(element_id, element) + element_id + end) + + case Enum.reject(ids || [], &(&1 in new_ids)) do + [] -> :ok + to_remove -> remove_if_absent(to_remove) + end + + # ids_list is set to never expire + {:ok, %ConCache.Item{value: new_ids, ttl: :infinity}} + end) + end + @doc """ Merges prepared `{id, element}` pairs into the local ordered cache. @@ -407,6 +450,25 @@ defmodule Explorer.Chain.OrderedCache do end) end + # Like `remove/1`, but once the delay is over only deletes the ids that + # are still absent from the ids list, checked under its lock: unlike an + # update, a replace can be followed by one that stores an id it removed, + # and deleting that id would leave the ids list pointing at a missing + # element. + defp remove_if_absent(ids_to_remove) do + Task.start_link(fn -> + Process.sleep(100) + + ConCache.isolated(cache_name(), ids_list_key(), fn -> + current_ids = ids_list() + + ids_to_remove + |> Enum.reject(&(&1 in current_ids)) + |> Enum.each(&ConCache.delete(cache_name(), &1)) + end) + end) + end + defp put_element(element_id, element) do # dirty puts are a little faster than puts with locks. # this is not a problem because this is the only function modifying rows diff --git a/apps/explorer/test/explorer/chain/cache/accounts/refresher_test.exs b/apps/explorer/test/explorer/chain/cache/accounts/refresher_test.exs new file mode 100644 index 00000000000..46ef4c2469f --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/accounts/refresher_test.exs @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Accounts.RefresherTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Address + alias Explorer.Chain.Cache.Accounts + alias Explorer.Chain.Cache.Accounts.Refresher + alias Explorer.PagingOptions + + describe "start_link/1" do + test "fills the cache from the database" do + hashes = insert_top_addresses(3) + + start_refresher() + + assert cached_hashes() == hashes + end + end + + describe "fetch_top_addresses/1" do + test "runs one query for concurrent misses and serves all of them from it" do + hashes = insert_top_addresses(3) + + start_refresher() + empty_cache() + count_addresses_queries() + + results = + 1..5 + |> Enum.map(fn _ -> + Task.async(fn -> Address.list_top_addresses(paging_options: %PagingOptions{page_size: 2}) end) + end) + |> Task.await_many() + + expected_hashes = Enum.take(hashes, 2) + + assert Enum.all?(results, fn addresses -> Enum.map(addresses, & &1.hash) == expected_hashes end) + # the refill fills the whole cache, not only the page that missed + assert cached_hashes() == hashes + + assert_received :addresses_query + refute_received :addresses_query + end + + test "shares a failed refill with every concurrent caller instead of retrying it" do + insert_top_addresses(2) + + start_refresher() + empty_cache() + count_addresses_queries() + + # Without the shared sandbox the refill task, which does not run on behalf + # of the test process, fails to check out a connection. The callers below + # do run on its behalf, so a retry in a caller would succeed and be counted. + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual) + + errors = + 1..3 + |> Enum.map(fn _ -> Task.async(fn -> catch_error(Refresher.fetch_top_addresses(2)) end) end) + |> Task.await_many() + + assert Enum.all?(errors, &match?(%DBConnection.OwnershipError{}, &1)) + + # the failed attempt is reported as a query too; a retry in a caller would + # add a successful one + assert_received :addresses_query + refute_received :addresses_query + end + + test "runs the query in the caller while the refresher is not running" do + hashes = insert_top_addresses(2) + + assert Address.list_top_addresses() |> Enum.map(& &1.hash) == hashes + assert cached_hashes() == hashes + end + end + + # Balances descend with insertion order, so the returned hashes are in the + # order the top list reports them. + defp insert_top_addresses(count) do + count..1//-1 + |> Enum.map(&insert(:address, fetched_coin_balance: &1)) + |> Enum.map(& &1.hash) + end + + defp cached_hashes do + Accounts.all() |> Enum.map(& &1.hash) + end + + # The process starts a refill on start; a fetch through it is served from that + # refill, so returning from it means the cache is filled. + defp start_refresher do + start_supervised!(Refresher) + + Refresher.fetch_top_addresses(1) + end + + defp empty_cache do + Supervisor.terminate_child(Explorer.Supervisor, Accounts.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Accounts.child_id()) + end + + # Sends `:addresses_query` to the test process for every query on the + # `addresses` table, wherever in the node it runs. + defp count_addresses_queries do + test_pid = self() + handler_id = {__MODULE__, make_ref()} + + :telemetry.attach( + handler_id, + [:explorer, :repo, :query], + fn _event, _measurements, %{source: source}, _config -> + if source == "addresses", do: send(test_pid, :addresses_query) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + end +end diff --git a/config/config_helper.exs b/config/config_helper.exs index 7540d5ffbd9..67773a951c2 100644 --- a/config/config_helper.exs +++ b/config/config_helper.exs @@ -264,11 +264,25 @@ defmodule ConfigHelper do if(disable_indexer?, do: :timer.seconds(1), else: false) end - @spec cache_global_ttl(boolean()) :: non_neg_integer() + @doc """ + TTL of a cache that the indexer keeps up to date: `nil` (no expiration) while + the indexer runs, a fixed 5 seconds when it is disabled. + """ + @spec cache_global_ttl(boolean()) :: non_neg_integer() | nil def cache_global_ttl(disable_indexer?) do if(disable_indexer?, do: :timer.seconds(5)) end + @doc """ + Same as `cache_global_ttl/1`, but when the indexer is disabled the TTL is read + from the `env_var` environment variable (in the time format accepted by + `parse_time_env_var/2`), falling back to `default_value` when it is not set. + """ + @spec cache_global_ttl(boolean(), String.t(), String.t()) :: non_neg_integer() | nil + def cache_global_ttl(disable_indexer?, env_var, default_value) do + if(disable_indexer?, do: parse_time_env_var(env_var, default_value)) + end + @spec indexer_memory_limit() :: integer() def indexer_memory_limit do "INDEXER_MEMORY_LIMIT" diff --git a/config/runtime.exs b/config/runtime.exs index 685d0012e16..d8b373877e3 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -671,9 +671,20 @@ config :explorer, Explorer.Chain.Cache.Transactions, ttl_check_interval: false, global_ttl: nil +# API nodes are not told about balance changes, so their entries expire on a TTL; +# the refresher below rewrites them every interval, which renews the TTL, so +# the cache only expires when refreshes have been failing for the whole TTL. config :explorer, Explorer.Chain.Cache.Accounts, ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) + global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?, "CACHE_TOP_ADDRESSES_TTL", "5m") + +top_addresses_update_interval = ConfigHelper.parse_time_env_var("CACHE_TOP_ADDRESSES_UPDATE_INTERVAL", "1m") + +if top_addresses_update_interval <= 0 do + raise "CACHE_TOP_ADDRESSES_UPDATE_INTERVAL must be a positive duration" +end + +config :explorer, Explorer.Chain.Cache.Accounts.Refresher, update_interval: top_addresses_update_interval config :explorer, Explorer.Chain.Cache.Uncles, ttl_check_interval: false, diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 1b16cc9b153..9a066cd5acd 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -150,6 +150,8 @@ RELEASE_LINK= # CACHE_PENDING_OPERATIONS_COUNT_PERIOD= # CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL= # CACHE_SCAM_ADDRESSES_MAX_SIZE= +# CACHE_TOP_ADDRESSES_TTL= +# CACHE_TOP_ADDRESSES_UPDATE_INTERVAL= # CACHE_ADDRESS_TAGS_TTL= # PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE=1000 # PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE=10 From ae478d711fe1f8de9bf5030019ef423f3bc1f69f Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 21 Sep 2026 19:01:47 +0300 Subject: [PATCH 59/82] perf: batch chain-specific lookups in realtime block broadcast (#14849) --- .tool-versions | 4 +- .../lib/block_scout_web/notifier.ex | 31 +++++--- apps/explorer/lib/explorer/chain/block.ex | 69 ++++++++++++---- .../lib/explorer/chain/block_number_helper.ex | 22 ++++++ .../lib/explorer/chain/null_round_height.ex | 74 +++++++++++++++-- .../chain/optimism/eip1559_config_update.ex | 43 ++++++++++ .../chain/block_number_helper_test.exs | 31 ++++++++ .../test/explorer/chain/block_test.exs | 51 ++++++++++++ .../explorer/chain/null_round_height_test.exs | 79 +++++++++++++++++++ .../optimism/eip1559_config_update_test.exs | 66 ++++++++++++++++ 10 files changed, 436 insertions(+), 34 deletions(-) create mode 100644 apps/explorer/test/explorer/chain/block_number_helper_test.exs create mode 100644 apps/explorer/test/explorer/chain/null_round_height_test.exs create mode 100644 apps/explorer/test/explorer/chain/optimism/eip1559_config_update_test.exs diff --git a/.tool-versions b/.tool-versions index 45ce6d515c6..fd5a44639d6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.19.4-otp-27 -erlang 27.3.4.6 +elixir 1.19.6-otp-27 +erlang 27.3.4.17 nodejs 20.17.0 diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index fbcc3847dda..9cb5bba9b84 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -51,6 +51,7 @@ defmodule BlockScoutWeb.Notifier do Address, Address.CoinBalance, Address.Reputation, + Block, BlockNumberHelper, DenormalizationHelper, InternalTransaction, @@ -469,12 +470,17 @@ defmodule BlockScoutWeb.Notifier do end defp do_handle_blocks(blocks) do - blocks - |> Enum.sort_by(& &1.number, :asc) - |> preload_blocks_for_broadcast() - |> Enum.each(fn block -> + blocks = + blocks + |> Enum.sort_by(& &1.number, :asc) + |> preload_blocks_for_broadcast() + + # The previous block number is a lookup of null rounds on Filecoin, so it is resolved for the whole batch at once + previous_block_numbers = blocks |> Enum.map(& &1.number) |> BlockNumberHelper.previous_block_numbers() + + Enum.each(blocks, fn block -> last_broadcasted_block_number = Helper.fetch_from_ets_cache(:last_broadcasted_block, :number) - broadcast_latest_block?(block, last_broadcasted_block_number) + broadcast_latest_block?(block, last_broadcasted_block_number, previous_block_numbers[block.number]) end) end @@ -578,33 +584,33 @@ defmodule BlockScoutWeb.Notifier do }) end - defp broadcast_latest_block?(block, last_broadcasted_block_number) do + defp broadcast_latest_block?(block, last_broadcasted_block_number, previous_block_number) do cond do last_broadcasted_block_number == 0 || - last_broadcasted_block_number == BlockNumberHelper.previous_block_number(block.number) || + last_broadcasted_block_number == previous_block_number || last_broadcasted_block_number < block.number - 4 -> broadcast_block(block) :ets.insert(:last_broadcasted_block, {:number, block.number}) - last_broadcasted_block_number > BlockNumberHelper.previous_block_number(block.number) -> + last_broadcasted_block_number > previous_block_number -> broadcast_block(block) true -> Task.start_link(fn -> - schedule_broadcasting(block) + schedule_broadcasting(block, previous_block_number) end) end end - defp schedule_broadcasting(block) do + defp schedule_broadcasting(block, previous_block_number) do :timer.sleep(@check_broadcast_sequence_period) last_broadcasted_block_number = Helper.fetch_from_ets_cache(:last_broadcasted_block, :number) - if last_broadcasted_block_number == BlockNumberHelper.previous_block_number(block.number) do + if last_broadcasted_block_number == previous_block_number do broadcast_block(block) :ets.insert(:last_broadcasted_block, {:number, block.number}) else - schedule_broadcasting(block) + schedule_broadcasting(block, previous_block_number) end end @@ -688,6 +694,7 @@ defmodule BlockScoutWeb.Notifier do :transactions, :rewards ]) + |> Block.preload_eip1559_config() # TODO: the enrichment holds the broadcast of the whole batch back for up to # its timeout, consider broadcasting the enrichment data separately |> maybe_preload_enrichment_for_broadcast() diff --git a/apps/explorer/lib/explorer/chain/block.ex b/apps/explorer/lib/explorer/chain/block.ex index 98c92fec5d6..53b4cc58138 100644 --- a/apps/explorer/lib/explorer/chain/block.ex +++ b/apps/explorer/lib/explorer/chain/block.ex @@ -175,6 +175,8 @@ defmodule Explorer.Chain.Block.Schema do field(:transactions_fees, :decimal, virtual: true) field(:burnt_fees, :decimal, virtual: true) field(:priority_fees, :decimal, virtual: true) + # `{base_fee_max_change_denominator, elasticity_multiplier}` resolved by `preload_eip1559_config/1` + field(:eip1559_config, :any, virtual: true) timestamps() @@ -497,29 +499,66 @@ defmodule Explorer.Chain.Block do def uncle_reward_coef, do: @uncle_reward_coef - # Gets EIP-1559 config actual for the given block number. + @doc """ + Resolves the EIP-1559 config actual for each of the given blocks with a single query. + + The config is put into the `eip1559_config` virtual field, so that `gas_target/1` and + `next_block_base_fee_per_gas/1` don't query the config per block. Does nothing (and makes no queries) + unless the chain type is `optimism`, where the config is dynamic and read from the + `op_eip1559_config_updates` table. + + ## Parameters + - `blocks`: The blocks to resolve the config for. + + ## Returns + - The same blocks with the `eip1559_config` field set. + """ + @spec preload_eip1559_config([t()]) :: [t()] + def preload_eip1559_config(blocks) do + if Application.get_env(:explorer, :chain_type) == :optimism do + configs = + blocks + |> Enum.map(& &1.number) + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_configs_for_blocks() + + Enum.map(blocks, fn block -> + %{block | eip1559_config: eip1559_config_or_default(Map.fetch!(configs, block.number))} + end) + else + blocks + end + end + + # Gets EIP-1559 config actual for the given block, preferring the one resolved by `preload_eip1559_config/1`. # If not found, returns EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR and EIP_1559_ELASTICITY_MULTIPLIER env values. # # ## Parameters - # - `block_number`: The given block number. + # - `block`: The given block. # # ## Returns # - `{denominator, multiplier}` tuple. - @spec get_eip1559_config(non_neg_integer()) :: {non_neg_integer(), non_neg_integer()} - defp get_eip1559_config(block_number) do - with true <- Application.get_env(:explorer, :chain_type) == :optimism, - # credo:disable-for-next-line Credo.Check.Design.AliasUsage - config = Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_config_for_block(block_number), - false <- is_nil(config) do - {denominator, multiplier, _min_base_fee} = config - {denominator, multiplier} + @spec get_eip1559_config(t()) :: {non_neg_integer(), non_neg_integer()} + defp get_eip1559_config(%{eip1559_config: {_denominator, _multiplier} = config}), do: config + + defp get_eip1559_config(block) do + if Application.get_env(:explorer, :chain_type) == :optimism do + block.number + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_config_for_block() + |> eip1559_config_or_default() else - _ -> - {Application.get_env(:explorer, :base_fee_max_change_denominator), - Application.get_env(:explorer, :elasticity_multiplier)} + eip1559_config_or_default(nil) end end + defp eip1559_config_or_default({denominator, multiplier, _min_base_fee}), do: {denominator, multiplier} + + defp eip1559_config_or_default(nil) do + {Application.get_env(:explorer, :base_fee_max_change_denominator), + Application.get_env(:explorer, :elasticity_multiplier)} + end + @doc """ Calculates the gas target for a given block. @@ -538,7 +577,7 @@ defmodule Explorer.Chain.Block do @spec gas_target(t()) :: float() def gas_target(block) do if Decimal.compare(block.gas_limit, 0) == :gt do - {_, elasticity_multiplier} = get_eip1559_config(block.number) + {_, elasticity_multiplier} = get_eip1559_config(block) ratio = Decimal.div(block.gas_used, Decimal.div(block.gas_limit, elasticity_multiplier)) ratio |> Decimal.sub(1) |> Decimal.mult(100) |> Decimal.to_float() @@ -592,7 +631,7 @@ defmodule Explorer.Chain.Block do @spec next_block_base_fee_per_gas(t()) :: Decimal.t() | nil def next_block_base_fee_per_gas(block) do - {base_fee_max_change_denominator, elasticity_multiplier} = get_eip1559_config(block.number) + {base_fee_max_change_denominator, elasticity_multiplier} = get_eip1559_config(block) gas_target = Decimal.div_int(block.gas_limit, elasticity_multiplier) diff --git a/apps/explorer/lib/explorer/chain/block_number_helper.ex b/apps/explorer/lib/explorer/chain/block_number_helper.ex index ca97f7eebdb..87b90dcd7f3 100644 --- a/apps/explorer/lib/explorer/chain/block_number_helper.ex +++ b/apps/explorer/lib/explorer/chain/block_number_helper.ex @@ -48,6 +48,21 @@ defmodule Explorer.Chain.BlockNumberHelper do @spec next_block_number(non_neg_integer()) :: non_neg_integer() def next_block_number(number), do: neighbor_block_number(number, :next) + @doc """ + Returns the previous block numbers of several blocks in the blockchain sequence. + + A batch counterpart of `previous_block_number/1`: for Filecoin chain type, the null rounds + are looked up for all the blocks at once. + + ## Parameters + - `numbers`: The reference block heights + + ## Returns + - A map from each reference block height to its previous block number + """ + @spec previous_block_numbers([non_neg_integer()]) :: %{non_neg_integer() => non_neg_integer()} + def previous_block_numbers(numbers), do: neighbor_block_numbers(numbers, :previous) + @doc """ Returns the total count of null rounds in the blockchain. @@ -62,6 +77,7 @@ defmodule Explorer.Chain.BlockNumberHelper do @spec get_null_rounds_count() :: non_neg_integer() @spec neighbor_block_number(non_neg_integer(), :previous | :next) :: non_neg_integer() + @spec neighbor_block_numbers([non_neg_integer()], :previous | :next) :: %{non_neg_integer() => non_neg_integer()} case @chain_type do :filecoin -> @@ -72,6 +88,10 @@ defmodule Explorer.Chain.BlockNumberHelper do defp neighbor_block_number(number, direction), do: Explorer.Chain.NullRoundHeight.neighbor_block_number(number, direction) + # Determines the actual neighboring block numbers of several blocks taking into account null rounds. + defp neighbor_block_numbers(numbers, direction), + do: Explorer.Chain.NullRoundHeight.neighbor_block_numbers(numbers, direction) + _ -> defp get_null_rounds_count, do: 0 @@ -80,6 +100,8 @@ defmodule Explorer.Chain.BlockNumberHelper do # this simple approach differs from Filecoin which handles null rounds. Looks like # only blocks with consensus `true` must be taken into account here as well. defp neighbor_block_number(number, direction), do: move_by_one(number, direction) + + defp neighbor_block_numbers(numbers, direction), do: Map.new(numbers, &{&1, move_by_one(&1, direction)}) end @doc """ diff --git a/apps/explorer/lib/explorer/chain/null_round_height.ex b/apps/explorer/lib/explorer/chain/null_round_height.ex index 0325f2a5207..0474b4bfe24 100644 --- a/apps/explorer/lib/explorer/chain/null_round_height.ex +++ b/apps/explorer/lib/explorer/chain/null_round_height.ex @@ -133,14 +133,78 @@ defmodule Explorer.Chain.NullRoundHeight do end end + @doc """ + Determines the actual neighboring block numbers of several blocks + considering null rounds. + + A batch counterpart of `neighbor_block_number/2`: a batch of null rounds + per given block is fetched with one query in the direction, starting from + the farthest given block. For a run of consecutive blocks, such as + a realtime batch, this covers the null rounds between them and beyond. + Further queries are made only for the blocks whose neighbors lie beyond + the fetched null rounds. + + ## Parameters + - `numbers`: The reference block heights + - `direction`: Either `:previous` or `:next` to indicate search direction + + ## Returns + - A map from each reference block height to its actual neighboring block + number. + """ + @spec neighbor_block_numbers([non_neg_integer()], :previous | :next) :: %{non_neg_integer() => non_neg_integer()} + def neighbor_block_numbers([], _direction), do: %{} + + def neighbor_block_numbers(numbers, direction) do + {min_number, max_number} = Enum.min_max(numbers) + + # The fetched null rounds are bounded by the number of blocks rather than by their span + batch_size = length(numbers) * @null_rounds_batch_size + + # One null round more than the batch size tells whether there are any beyond the batch + null_rounds = + case direction do + :previous -> max_number + :next -> min_number + end + |> neighboring_null_rounds_query(direction, batch_size + 1) + |> select([nrh], nrh.height) + |> Repo.all() + + # No more null rounds than the batch size mean there are none beyond the fetched ones + farthest_fetched = if length(null_rounds) <= batch_size, do: nil, else: List.last(null_rounds) + + null_rounds_set = MapSet.new(null_rounds) + + Map.new(numbers, &{&1, skip_null_rounds(&1, direction, null_rounds_set, farthest_fetched)}) + end + + # Moves from the number in the direction through the fetched null rounds. The null rounds beyond + # the farthest fetched one are unknown, so they are looked up once the fetched ones are exhausted. + defp skip_null_rounds(number, direction, null_rounds, farthest_fetched) do + neighbor = BlockNumberHelper.move_by_one(number, direction) + + cond do + MapSet.member?(null_rounds, neighbor) -> skip_null_rounds(neighbor, direction, null_rounds, farthest_fetched) + beyond?(neighbor, farthest_fetched, direction) -> neighbor_block_number(number, direction) + true -> neighbor + end + end + + defp beyond?(_height, nil, _direction), do: false + defp beyond?(height, farthest_fetched, :previous), do: height < farthest_fetched + defp beyond?(height, farthest_fetched, :next), do: height > farthest_fetched + # Constructs a query to fetch neighboring null round heights in batches. - @spec neighboring_null_rounds_query(non_neg_integer(), :previous | :next) :: Ecto.Query.t() - defp neighboring_null_rounds_query(number, :previous) do - from(nrh in __MODULE__, where: nrh.height < ^number, order_by: [desc: :height], limit: @null_rounds_batch_size) + @spec neighboring_null_rounds_query(non_neg_integer(), :previous | :next, pos_integer()) :: Ecto.Query.t() + defp neighboring_null_rounds_query(number, direction, batch_size \\ @null_rounds_batch_size) + + defp neighboring_null_rounds_query(number, :previous, batch_size) do + from(nrh in __MODULE__, where: nrh.height < ^number, order_by: [desc: :height], limit: ^batch_size) end - defp neighboring_null_rounds_query(number, :next) do - from(nrh in __MODULE__, where: nrh.height > ^number, order_by: [asc: :height], limit: @null_rounds_batch_size) + defp neighboring_null_rounds_query(number, :next, batch_size) do + from(nrh in __MODULE__, where: nrh.height > ^number, order_by: [asc: :height], limit: ^batch_size) end # Fetches the next batch of null round heights from the database diff --git a/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex b/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex index e2f7e6d020c..b59ec3d43c0 100644 --- a/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex +++ b/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex @@ -60,6 +60,49 @@ defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do Repo.one(query) end + @doc """ + Reads the configs actual before each of the specified blocks from the `op_eip1559_config_updates` table with a single query. + + A batch counterpart of `actual_config_for_block/1`. + + ## Parameters + - `block_numbers`: The block numbers for which we need to read the actual configs. + + ## Returns + - A map from a block number to its `{denominator, multiplier, min_base_fee}` tuple, + or to `nil` if the config for the block is unknown. + """ + @spec actual_configs_for_blocks([non_neg_integer()]) :: %{ + non_neg_integer() => {non_neg_integer(), non_neg_integer(), non_neg_integer()} | nil + } + def actual_configs_for_blocks([]), do: %{} + + def actual_configs_for_blocks(block_numbers) do + {min_block_number, max_block_number} = Enum.min_max(block_numbers) + + # The update actual for the smallest block is the latest one registered before it. All the later updates + # registered before the largest block are relevant to the other blocks of the batch. + latest_update_before_min_block = + from(u in __MODULE__, + select: coalesce(max(u.l2_block_number), 0), + where: u.l2_block_number < ^min_block_number + ) + + updates = + from(u in __MODULE__, + select: {u.l2_block_number, {u.base_fee_max_change_denominator, u.elasticity_multiplier, u.min_base_fee}}, + where: u.l2_block_number < ^max_block_number, + where: u.l2_block_number >= subquery(latest_update_before_min_block), + order_by: [desc: u.l2_block_number] + ) + |> Repo.all() + + Map.new(block_numbers, fn block_number -> + config = Enum.find_value(updates, fn {l2_block_number, config} -> l2_block_number < block_number && config end) + {block_number, config} + end) + end + @doc """ Reads the last row from the `op_eip1559_config_updates` table. diff --git a/apps/explorer/test/explorer/chain/block_number_helper_test.exs b/apps/explorer/test/explorer/chain/block_number_helper_test.exs new file mode 100644 index 00000000000..f4d6c60d9af --- /dev/null +++ b/apps/explorer/test/explorer/chain/block_number_helper_test.exs @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.BlockNumberHelperTest do + use Explorer.DataCase + + import Explorer.QuerySources, only: [with_query_sources: 1] + + alias Explorer.Chain.BlockNumberHelper + + describe "previous_block_numbers/1" do + test "matches previous_block_number/1 for every block" do + numbers = [0, 1, 5, 100] + + assert BlockNumberHelper.previous_block_numbers(numbers) == + Map.new(numbers, &{&1, BlockNumberHelper.previous_block_number(&1)}) + end + + if Application.compile_env(:explorer, :chain_type) == :filecoin do + test "looks the null rounds up for the whole batch with a single query" do + Explorer.Chain.NullRoundHeight.insert_heights([99, 102]) + + assert with_query_sources(fn -> BlockNumberHelper.previous_block_numbers([100, 101, 103]) end) == + {%{100 => 98, 101 => 100, 103 => 101}, ["null_round_heights"]} + end + else + test "makes no queries" do + assert with_query_sources(fn -> BlockNumberHelper.previous_block_numbers([100, 101, 103]) end) == + {%{100 => 99, 101 => 100, 103 => 102}, []} + end + end + end +end diff --git a/apps/explorer/test/explorer/chain/block_test.exs b/apps/explorer/test/explorer/chain/block_test.exs index 0575e65e6f3..1d2771e5ba3 100644 --- a/apps/explorer/test/explorer/chain/block_test.exs +++ b/apps/explorer/test/explorer/chain/block_test.exs @@ -2,6 +2,7 @@ defmodule Explorer.Chain.BlockTest do use Explorer.DataCase + import Explorer.QuerySources, only: [with_query_sources: 1] import Mox alias Ecto.Changeset @@ -153,6 +154,56 @@ defmodule Explorer.Chain.BlockTest do end end + describe "preload_eip1559_config/1" do + if Application.compile_env(:explorer, :chain_type) == :optimism do + test "resolves the config of a batch of blocks with a single query" do + %Explorer.Chain.Optimism.EIP1559ConfigUpdate{} + |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.changeset(%{ + l2_block_number: 10, + l2_block_hash: block_hash(), + base_fee_max_change_denominator: 50, + elasticity_multiplier: 4 + }) + |> Repo.insert!() + + blocks = + for number <- [5, 15] do + insert(:block, number: number, gas_limit: Decimal.new(30_000_000), gas_used: Decimal.new(15_000_000)) + end + + {[before_update, after_update], preload_query_sources} = + with_query_sources(fn -> Block.preload_eip1559_config(blocks) end) + + assert preload_query_sources == ["op_eip1559_config_updates"] + + # The env defaults apply before the first update + assert before_update.eip1559_config == + {Application.get_env(:explorer, :base_fee_max_change_denominator), + Application.get_env(:explorer, :elasticity_multiplier)} + + assert after_update.eip1559_config == {50, 4} + + {gas_targets, gas_target_query_sources} = + with_query_sources(fn -> Enum.map([before_update, after_update], &Block.gas_target/1) end) + + assert gas_targets == [0.0, 100.0] + assert gas_target_query_sources == [] + + # Not preloaded blocks still resolve the config on their own + {gas_targets, gas_target_query_sources} = with_query_sources(fn -> Enum.map(blocks, &Block.gas_target/1) end) + + assert gas_targets == [0.0, 100.0] + assert gas_target_query_sources == ["op_eip1559_config_updates", "op_eip1559_config_updates"] + end + else + test "leaves the blocks as is without queries" do + blocks = insert_list(2, :block) + + assert with_query_sources(fn -> Block.preload_eip1559_config(blocks) end) == {blocks, []} + end + end + end + describe "block_combined_rewards/1" do test "sums the block_rewards values" do block = insert(:block) diff --git a/apps/explorer/test/explorer/chain/null_round_height_test.exs b/apps/explorer/test/explorer/chain/null_round_height_test.exs new file mode 100644 index 00000000000..4061a00be6a --- /dev/null +++ b/apps/explorer/test/explorer/chain/null_round_height_test.exs @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.NullRoundHeightTest do + use Explorer.DataCase + + import Explorer.QuerySources, only: [with_query_sources: 1] + + alias Explorer.Chain.NullRoundHeight + + if Application.compile_env(:explorer, :chain_type) == :filecoin do + describe "neighbor_block_numbers/2" do + test "skips the null rounds of the batch with a single query" do + NullRoundHeight.insert_heights([99, 102, 103, 106]) + + {previous, query_sources} = + with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([100, 101, 104, 105, 107], :previous) end) + + assert previous == %{100 => 98, 101 => 100, 104 => 101, 105 => 104, 107 => 105} + assert query_sources == ["null_round_heights"] + + {next, query_sources} = + with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([98, 100, 101, 104, 105], :next) end) + + assert next == %{98 => 100, 100 => 101, 101 => 104, 104 => 105, 105 => 107} + assert query_sources == ["null_round_heights"] + end + + test "matches neighbor_block_number/2 for every block" do + NullRoundHeight.insert_heights([99, 102, 103, 106]) + numbers = [90, 100, 101, 104, 105, 107, 120] + + for direction <- [:previous, :next] do + neighbors = NullRoundHeight.neighbor_block_numbers(numbers, direction) + + for number <- numbers do + assert neighbors[number] == NullRoundHeight.neighbor_block_number(number, direction) + end + end + end + + test "looks further when a run of null rounds beyond the farthest block exceeds the fetched batch" do + NullRoundHeight.insert_heights(Enum.to_list(80..99)) + + {previous, query_sources} = + with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([100, 101], :previous) end) + + assert previous == %{100 => 79, 101 => 100} + assert length(query_sources) > 1 + + assert NullRoundHeight.neighbor_block_numbers([78, 79], :next) == %{78 => 79, 79 => 100} + end + + test "skips a run of null rounds as long as the fetched batch with a single query" do + # A batch of 5 null rounds is fetched for a single block + NullRoundHeight.insert_heights(Enum.to_list(95..99)) + + assert with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([100], :previous) end) == + {%{100 => 94}, ["null_round_heights"]} + + assert with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([94], :next) end) == + {%{94 => 100}, ["null_round_heights"]} + end + + test "looks the neighbors of sparse blocks up separately rather than fetching their whole span" do + NullRoundHeight.insert_heights(Enum.to_list(1_000..1_100)) + + {previous, query_sources} = + with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([999, 5_000], :previous) end) + + # The batch fetched below the farthest block doesn't reach the lower one, which is looked up separately + assert previous == %{999 => 998, 5_000 => 4_999} + assert length(query_sources) == 2 + end + + test "returns an empty map for no blocks" do + assert with_query_sources(fn -> NullRoundHeight.neighbor_block_numbers([], :previous) end) == {%{}, []} + end + end + end +end diff --git a/apps/explorer/test/explorer/chain/optimism/eip1559_config_update_test.exs b/apps/explorer/test/explorer/chain/optimism/eip1559_config_update_test.exs new file mode 100644 index 00000000000..0319a2e2159 --- /dev/null +++ b/apps/explorer/test/explorer/chain/optimism/eip1559_config_update_test.exs @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdateTest do + use Explorer.DataCase + + import Explorer.QuerySources, only: [with_query_sources: 1] + + alias Explorer.Chain.Optimism.EIP1559ConfigUpdate + + if Application.compile_env(:explorer, :chain_type) == :optimism do + describe "actual_configs_for_blocks/1" do + test "returns the config actual before each block with a single query" do + insert_update(10, 50, 6) + insert_update(20, 100, 8) + insert_update(30, 150, 10) + + {configs, query_sources} = + with_query_sources(fn -> EIP1559ConfigUpdate.actual_configs_for_blocks([5, 10, 11, 25, 30, 31]) end) + + assert configs == %{ + 5 => nil, + 10 => nil, + 11 => {50, 6, nil}, + 25 => {100, 8, nil}, + 30 => {100, 8, nil}, + 31 => {150, 10, nil} + } + + assert query_sources == ["op_eip1559_config_updates"] + end + + test "matches actual_config_for_block/1 for every block" do + insert_update(10, 50, 6) + insert_update(20, 100, 8) + + block_numbers = [1, 10, 11, 20, 21, 100] + configs = EIP1559ConfigUpdate.actual_configs_for_blocks(block_numbers) + + for block_number <- block_numbers do + assert configs[block_number] == EIP1559ConfigUpdate.actual_config_for_block(block_number) + end + end + + test "finds the update registered long before the batch" do + insert_update(10, 50, 6) + insert_update(200, 100, 8) + + assert EIP1559ConfigUpdate.actual_configs_for_blocks([100, 101]) == %{100 => {50, 6, nil}, 101 => {50, 6, nil}} + end + + test "returns an empty map for no blocks" do + assert EIP1559ConfigUpdate.actual_configs_for_blocks([]) == %{} + end + end + + defp insert_update(l2_block_number, denominator, multiplier) do + %EIP1559ConfigUpdate{} + |> EIP1559ConfigUpdate.changeset(%{ + l2_block_number: l2_block_number, + l2_block_hash: block_hash(), + base_fee_max_change_denominator: denominator, + elasticity_multiplier: multiplier + }) + |> Repo.insert!() + end + end +end From 2da78cd2b659991c28f592bed620834388c67f93 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 21 Sep 2026 19:33:33 +0300 Subject: [PATCH 60/82] v11.3.2 --- .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 +- .../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 +- ...ocker-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 | 24 +++++++++++++++++++ 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, 80 insertions(+), 56 deletions(-) diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index d056ef0c3b4..bd132b4d9d8 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.3.1 + RELEASE_VERSION: 11.3.2 jobs: matrix-builder: diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index 876921a291b..6e7f4fd4ece 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.3.1 + RELEASE_VERSION: 11.3.2 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 da8c0d8b659..3dff7d9086d 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.3.1 + RELEASE_VERSION: 11.3.2 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 f2711dca7de..7b750119590 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.3.1 + RELEASE_VERSION: 11.3.2 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 65f2a618d43..2ad9a2157fb 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.3.1 + RELEASE_VERSION: 11.3.2 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 11ca00a1867..52de48a7707 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.3.1 + RELEASE_VERSION: 11.3.2 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 07009665612..777fb2e5314 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.3.1 + RELEASE_VERSION: 11.3.2 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 8a2f3057fa2..ebecde4886d 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.3.1 + RELEASE_VERSION: 11.3.2 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 ad8e69e7c1b..9f4c39aecf4 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.3.1 + RELEASE_VERSION: 11.3.2 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 2d90904f43e..565ef88acf4 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.3.1 + RELEASE_VERSION: 11.3.2 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 38e10622d9b..ee6810cf19e 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.3.1 + RELEASE_VERSION: 11.3.2 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 54d0ee35e60..37cfc768856 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.3.1 + RELEASE_VERSION: 11.3.2 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 c8495e3396f..701cb0625a9 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index d147955ed1b..333db5a4627 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.3.1 + RELEASE_VERSION: 11.3.2 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 dae72763762..7e6c5ae1fce 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.3.1 + RELEASE_VERSION: 11.3.2 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 9e748196480..595600db805 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.3.1 + RELEASE_VERSION: 11.3.2 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index b77bf6708bf..387cf76c8d2 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.3.1 + RELEASE_VERSION: 11.3.2 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 9061d4b788e..21f13f35f34 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.3.1 + RELEASE_VERSION: 11.3.2 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 6265bf2dc19..b0007eeaba7 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.3.1 + RELEASE_VERSION: 11.3.2 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 27949d4ead7..c7a4f580b5d 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.3.1 + RELEASE_VERSION: 11.3.2 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 0f61699052d..345ad2e9571 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.3.1 + RELEASE_VERSION: 11.3.2 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 590dff13ccf..535adcb68e1 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.3.1 + RELEASE_VERSION: 11.3.2 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 28756341ac1..a85875874fa 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.3.1 + RELEASE_VERSION: 11.3.2 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 5310e3145a4..8dd099ba4c9 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.3.1 + RELEASE_VERSION: 11.3.2 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 da5e1e6c921..bdda44e6c66 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.3.1 + RELEASE_VERSION: 11.3.2 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 45672e08973..b3b21c159d4 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.3.1 + RELEASE_VERSION: 11.3.2 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 d191cccce82..463c44d5ba1 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.3.1 + RELEASE_VERSION: 11.3.2 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 5d81133f35d..7b6c5ea4cb4 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.3.1 + RELEASE_VERSION: 11.3.2 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 c814ada2c90..c0b9945f231 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.3.1 + RELEASE_VERSION: 11.3.2 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 972b8e0ab1c..e0aae6a0138 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.3.1 + RELEASE_VERSION: 11.3.2 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 5e71470072e..9d37ba8a042 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.3.1 + RELEASE_VERSION: 11.3.2 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 ec555808176..201ad03dc7a 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index 88bebcb20e3..01193deae4f 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index 5a78fc5081c..dbd95cbaa46 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.3.1 + RELEASE_VERSION: 11.3.2 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 1e5c6f37e21..925c36adf3e 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index 009feb84887..054bc1611a6 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 4e3126f59a2..29917b65ae7 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index 86a8eefe4a8..2cd216cb0ce 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index c7dfc722b64..4083778d527 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index b6982ddc2de..3038d0da4a5 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index 92ca52a73a5..a2c7b1019f2 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index 0d6501924e7..60b2a216c1f 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index 0e9c43ab61c..a47c869d6fe 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index 3c4cdef5ccd..c701cab1dd8 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index e18644025dc..97d60a36ea7 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index 467ded817c4..59f32316d3d 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.3.1 + RELEASE_VERSION: 11.3.2 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf6f3beade..38e8866cf78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 11.3.2 + +### 🐛 Bug Fixes + +- Increase beacon RPC client response timeout to 30s ([#14840](https://github.com/blockscout/blockscout/issues/14840)) +- Dedupe multichain export queue rows to avoid cardinality violation ([#14839](https://github.com/blockscout/blockscout/issues/14839)) +- Tolerate non-string NFT metadata values in NFTHelper ([#14838](https://github.com/blockscout/blockscout/issues/14838)) +- Move counters_corrections after ctb deriving ([#14837](https://github.com/blockscout/blockscout/issues/14837)) + +### ⚡ Performance + +- Batch chain-specific lookups in realtime block broadcast ([#14849](https://github.com/blockscout/blockscout/issues/14849)) +- Refill top addresses cache in the background on API nodes ([#14843](https://github.com/blockscout/blockscout/issues/14843)) +- Preload data for realtime block events by batch ([#14842](https://github.com/blockscout/blockscout/issues/14842)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `CACHE_TOP_ADDRESSES_TTL` | TTL of the top addresses cache (`/api/v2/addresses` first page) on API nodes (`APPLICATION_MODE=api` or `DISABLE_INDEXER=true`). The background refresher renews it on every refill, so the cache only expires when refills have been failing for the whole TTL. [Time format](/setup/env-variables/backend-env-variables#time-format). | Version: v11.3.2\+
Default: `5m`
Applications: API | +| `CACHE_TOP_ADDRESSES_UPDATE_INTERVAL` | Interval at which `Explorer.Chain.Cache.Accounts.Refresher` refills the top addresses cache from the database. Should be lower than `CACHE_TOP_ADDRESSES_TTL`. [Time format](/setup/env-variables/backend-env-variables#time-format). | Version: v11.3.2\+
Default: `1m`
Applications: API | + + ## 11.3.1 ### 🚀 Features @@ -20,6 +43,7 @@ - Optimize realtime events broadcast for legacy topics ([#14810](https://github.com/blockscout/blockscout/issues/14810)) ### New ENV variables + | Variable | Description | Parameters | |-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| | `INDEXER_DISABLE_TOKEN_TOTAL_SUPPLY_ON_DEMAND_FETCHER` | If `true`, `Indexer.Fetcher.OnDemand.TokenTotalSupply` fetcher is disabled at runtime. Implemented in [#14811](https://github.com/blockscout/blockscout/pull/14811). | Version: v11.3.1\+
Default: `false`
Applications: Indexer | diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index 214dbc9cd09..6cb2e43aeb5 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.3.1", + version: "11.3.2", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 6aa7cab4dc9..867895f76aa 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.3.1" + version: "11.3.2" ] end diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 6a712ee4f3b..1b2e80fdc1e 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.3.1"}] + @default_headers [{"User-Agent", "blockscout-11.3.2"}] # 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 578ee08ade3..d3f6e46dd60 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.3.1", + version: "11.3.2", 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 25ac2b804b0..d545f7f0bcb 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.3.1", + version: "11.3.2", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index aa5528b6382..212b1c8d8d1 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.3.1", + version: "11.3.2", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index e1d6d7bd2f7..419e2de5cb9 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.3.1", + version: "11.3.2", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/docker/Makefile b/docker/Makefile index b3c81e4a26e..29ca94ff0a5 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.3.1' +RELEASE_VERSION ?= '11.3.2' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/mix.exs b/mix.exs index e15a85ff597..6a49020f4ec 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "11.3.1", + version: "11.3.2", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/rel/config.exs b/rel/config.exs index 43d438d9cb7..2803b313f83 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.3.1" + set version: "11.3.2" set applications: [ :runtime_tools, block_scout_web: :permanent, From ba08ac5045c671675fabfb59f680c0620eb6b4fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:01:41 +0300 Subject: [PATCH 61/82] chore(deps): bump redix from 1.9.1 to 1.9.2 (#14853) 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 3465bcad2c2..d933ad758c4 100644 --- a/mix.lock +++ b/mix.lock @@ -156,7 +156,7 @@ "que": {:hex, :que, "0.12.0", "4629c156036b8a3bf9ffdd79aa4069ab414822144d3ef031b5cc61fc805942d3", [:mix], [{:ex_utils, "~> 0.1", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.6", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "906651d60b1036840f1afacc0036541583eda9ba4b0b03f322530a1c84100507"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "redix": {:hex, :redix, "1.9.1", "99473ccc538b97eb169beea133e788c236850e84ec3bcc785ef414ffca9fb2e2", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b873953d06f893f88bb92a515ff9f9c0b65662576de27fb810e76cff3609ebab"}, + "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "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"}, From 7f94d9b65329084fd2d7b0e14715383e2ab7a026 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:02:10 +0300 Subject: [PATCH 62/82] chore(deps): bump phoenix_live_view from 1.2.11 to 1.2.12 (#14850) 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 d933ad758c4..2bfaad40582 100644 --- a/mix.lock +++ b/mix.lock @@ -136,9 +136,9 @@ "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.11", "1c83fdfd007e8d554076baee00ee6f55bb9c87ad9439f33ee520e47ae4a983bb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4087267cbd4dc4ed7ac36512a97830af8e9c857ca7cb22557f8640fc5f9d306"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, From 5487b16df712f2608be3573dc33cf8dd56d2eb70 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 23 Sep 2026 12:35:42 +0300 Subject: [PATCH 63/82] fix: Bound pending operations PTO->PBO migration batches by transaction_hash (#14856) --- .../chain/pending_operations_helper.ex | 46 +++++++++---- .../switch_pending_operations_test.exs | 66 +++++++++++++++++++ cspell.json | 1 + 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex index 268f76039e3..56d465a109a 100644 --- a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex +++ b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex @@ -75,35 +75,53 @@ defmodule Explorer.Chain.PendingOperationsHelper do end end + # Moves a batch of pending transaction operations into pending block operations. + # + # Every batch is bounded by `transactions_batch_size` rows of `pending_transaction_operations` + # and touches only those rows: pending block operations are derived from them and then exactly + # those rows are deleted by primary key. Operations whose transaction has no block yet + # (`block_hash IS NULL`) produce no pending block operation and are simply dropped, the pending + # block operation will be created when the block is imported. defp from_transactions_to_blocks_function do batch_size = transactions_batch_size() - pbo_params_query = + batch_query = from( pto in PendingTransactionOperation, join: t in assoc(pto, :transaction), - select: %{block_hash: t.block_hash, block_number: t.block_number, priority: pto.priority}, + select: %{ + transaction_hash: pto.transaction_hash, + block_hash: t.block_hash, + block_number: t.block_number, + priority: pto.priority + }, limit: ^batch_size ) - case Repo.all(pbo_params_query) do + case Repo.all(batch_query) do [] -> :finish - pbo_params -> - filtered_pbo_params = Enum.reject(pbo_params, &is_nil(&1.block_hash)) - Repo.insert_all(PendingBlockOperation, Helper.add_timestamps(filtered_pbo_params), on_conflict: :nothing) + batch -> + pbo_params = + batch + |> Enum.reject(&is_nil(&1.block_hash)) + |> Enum.group_by(& &1.block_hash) + |> Enum.map(fn {block_hash, [%{block_number: block_number} | _] = rows} -> + %{block_hash: block_hash, block_number: block_number, priority: Enum.find_value(rows, & &1.priority)} + end) + |> Helper.add_timestamps() - block_numbers_to_delete = Enum.map(pbo_params, & &1.block_number) + Repo.insert_all(PendingBlockOperation, pbo_params, + on_conflict: default_pbo_on_conflict(), + conflict_target: [:block_hash] + ) - delete_query = - from( - pto in PendingTransactionOperation, - join: t in assoc(pto, :transaction), - where: is_nil(t.block_number) or t.block_number in ^block_numbers_to_delete - ) + transaction_hashes = Enum.map(batch, & &1.transaction_hash) - Repo.delete_all(delete_query) + PendingTransactionOperation + |> where([pto], pto.transaction_hash in ^transaction_hashes) + |> Repo.delete_all() :continue end diff --git a/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs b/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs index 47ae88ddc32..71c4fd27f8b 100644 --- a/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs +++ b/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs @@ -136,5 +136,71 @@ defmodule Explorer.Migrator.SwitchPendingOperationsTest do assert [] = Repo.all(PendingTransactionOperation) assert [_, _] = Repo.all(PendingBlockOperation) end + + test "from pto to pbo keeps priority and processes in small batches" do + initial_helper_config = Application.get_env(:explorer, Explorer.Chain.PendingOperationsHelper) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.PendingOperationsHelper, initial_helper_config) + end) + + Application.put_env( + :explorer, + Explorer.Chain.PendingOperationsHelper, + Keyword.put(initial_helper_config || [], :transactions_batch_size, 2) + ) + + prioritized_block = insert(:block) + regular_block = insert(:block) + + [first_prioritized, second_prioritized, third_prioritized] = + 3 + |> insert_list(:transaction) + |> with_block(prioritized_block) + + regular_transactions = + 2 + |> insert_list(:transaction) + |> with_block(regular_block) + + pending_transaction = insert(:transaction) + + insert(:pending_transaction_operation, transaction_hash: first_prioritized.hash) + insert(:pending_transaction_operation, transaction_hash: second_prioritized.hash, priority: 1) + insert(:pending_transaction_operation, transaction_hash: third_prioritized.hash) + + Enum.each(regular_transactions ++ [pending_transaction], fn %{hash: transaction_hash} -> + insert(:pending_transaction_operation, transaction_hash: transaction_hash) + end) + + json_rpc_config = Application.get_env(:explorer, :json_rpc_named_arguments) + + Application.put_env( + :explorer, + :json_rpc_named_arguments, + Keyword.put(json_rpc_config, :variant, EthereumJSONRPC.Geth) + ) + + geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(geth_config, :block_traceable?, true)) + + SwitchPendingOperations.start_link([]) + Process.sleep(200) + + assert [] = Repo.all(PendingTransactionOperation) + + pbos = Repo.all(PendingBlockOperation) + assert length(pbos) == 2 + + assert %PendingBlockOperation{block_number: prioritized_number, priority: 1} = + Enum.find(pbos, &(&1.block_hash == prioritized_block.hash)) + + assert prioritized_number == prioritized_block.number + + assert %PendingBlockOperation{block_number: regular_number, priority: nil} = + Enum.find(pbos, &(&1.block_hash == regular_block.hash)) + + assert regular_number == regular_block.number + end end end diff --git a/cspell.json b/cspell.json index 94452903c4e..c23d269e3a0 100644 --- a/cspell.json +++ b/cspell.json @@ -522,6 +522,7 @@ "paymentchannel", "pbcopy", "PBFT", + "pbos", "peeker", "peekers", "pendingtxlist", From 3ea5c7ac7eea9a748f1fe7f932c4358194b0309e Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 23 Sep 2026 16:22:32 +0300 Subject: [PATCH 64/82] fix: recognize null round errors with epoch suffix in catchup fetcher for Filecoin (#14859) --- .../lib/indexer/block/catchup/fetcher.ex | 4 +- .../indexer/block/catchup/fetcher_test.exs | 65 +++++++++++++++++++ bin/install_chrome_headless.sh | 2 +- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/indexer/lib/indexer/block/catchup/fetcher.ex b/apps/indexer/lib/indexer/block/catchup/fetcher.ex index 3c2453f5bb8..c24da994cbf 100644 --- a/apps/indexer/lib/indexer/block/catchup/fetcher.ex +++ b/apps/indexer/lib/indexer/block/catchup/fetcher.ex @@ -245,10 +245,12 @@ defmodule Indexer.Block.Catchup.Fetcher do |> MissingBlockRange.clear_batch() end + # Lotus reports a null round either with the bare message (older versions) or with + # the epoch appended, e.g. "requested epoch was a null round (6394248)" (newer versions). defp handle_null_rounds(errors) do {null_rounds, other_errors} = Enum.split_with(errors, fn - %{message: "requested epoch was a null round"} -> true + %{message: "requested epoch was a null round" <> _} -> true _ -> false end) diff --git a/apps/indexer/test/indexer/block/catchup/fetcher_test.exs b/apps/indexer/test/indexer/block/catchup/fetcher_test.exs index 983f8a29875..7948176bd06 100644 --- a/apps/indexer/test/indexer/block/catchup/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/catchup/fetcher_test.exs @@ -703,6 +703,71 @@ defmodule Indexer.Block.Catchup.FetcherTest do assert %{from_number: 1, to_number: 0} = Repo.one(MissingBlockRange) end + if Application.compile_env(:explorer, :chain_type) == :filecoin do + test "records null rounds reported with and without the epoch suffix and clears them from missing ranges", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + Application.put_env(:indexer, Indexer.Block.Catchup.Fetcher, batch_size: 2, concurrency: 10) + Application.put_env(:indexer, :block_ranges, "0..1") + start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) + MissingRangesCollector.start_link([]) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn + [ + %{ + id: id_1, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x1", true] + }, + %{ + id: id_2, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x0", true] + } + ], + _options -> + {:ok, + [ + # Newer Lotus: typed error with the epoch appended to the message + %{ + id: id_1, + jsonrpc: "2.0", + error: %{code: 12, message: "requested epoch was a null round (1)"} + }, + # Older Lotus: bare message + %{ + id: id_2, + jsonrpc: "2.0", + error: %{message: "requested epoch was a null round"} + } + ]} + + # No blocks were fetched, so the beneficiaries request is an empty batch + [], _options -> + {:ok, []} + end) + + Process.sleep(50) + + assert %{first_block_number: 1, last_block_number: 0, missing_block_count: 2, shrunk: false} = + Fetcher.task(%Fetcher{ + block_fetcher: %Block.Fetcher{ + callback_module: Fetcher, + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } + }) + + Process.sleep(1000) + + assert [0, 1] == Explorer.Chain.NullRoundHeight |> Repo.all() |> Enum.map(& &1.height) |> Enum.sort() + assert [] == Repo.all(MissingBlockRange) + end + end + if Application.compile_env(:explorer, :chain_type) == :stability do test "update stability validator counter", %{ json_rpc_named_arguments: json_rpc_named_arguments diff --git a/bin/install_chrome_headless.sh b/bin/install_chrome_headless.sh index 5944e2f2abf..be6fe6b1fb9 100755 --- a/bin/install_chrome_headless.sh +++ b/bin/install_chrome_headless.sh @@ -2,7 +2,7 @@ export DISPLAY=:99.0 sh -e /etc/init.d/xvfb start #export CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json" | jq -r '.channels' | jq -r '.Stable' | jq -r '.version') -export CHROMEDRIVER_VERSION=152.0.7977.65 +export CHROMEDRIVER_VERSION=154.0.8037.58 curl -L -O "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip" unzip -j chromedriver-linux64.zip sudo chmod +x chromedriver From cb602b7d542db0b2d05a623b5c419dd38b169e09 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 28 Sep 2026 16:57:24 +0300 Subject: [PATCH 65/82] fix: tolerate pending internal transactions at chain head in txlistinternal (#14866) --- .../controllers/api/rpc/address_controller.ex | 48 ++- .../api/rpc/address_controller_test.exs | 287 ++++++++++++++++++ .../chain/pending_operations_helper.ex | 2 +- config/runtime.exs | 4 + docker-compose/envs/common-blockscout.env | 1 + 5 files changed, 340 insertions(+), 2 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex index 56d1e9d4e3e..e6227022c6f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex @@ -7,6 +7,7 @@ defmodule BlockScoutWeb.API.RPC.AddressController do alias BlockScoutWeb.Chain, as: BlockScoutWebChain alias Explorer.{Chain, Etherscan} alias Explorer.Chain.{Address, PendingOperationsHelper, Wei} + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Etherscan.{Addresses, Blocks} alias Explorer.Helper, as: ExplorerHelper alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand @@ -155,6 +156,9 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end + # The pending check here is deliberately strict (no chain head tolerance, see + # `blocks_pending_below_head_tolerance?/2`): the response is all-or-nothing for a single + # transaction, so a pending block means the whole result is missing. def txlistinternal(conn, params, transaction_param, :transaction) do with {:params, {:ok, options}} <- {:params, optional_params(params)}, {:format, {:ok, transaction_hash}} <- to_transaction_hash(transaction_param), @@ -218,6 +222,9 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end + # With an empty result the pending status is the only useful information the response + # carries, so the strict check is kept here: pending blocks at the chain head are not + # tolerated (see `blocks_pending_below_head_tolerance?/2`). defp render_internal_transactions(conn, [], start_block_number, end_block_number) do if PendingOperationsHelper.blocks_pending?(start_block_number, end_block_number) do render(conn, :pending_internal_transaction, @@ -230,7 +237,7 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end defp render_internal_transactions(conn, internal_transactions, start_block_number, end_block_number) do - if PendingOperationsHelper.blocks_pending?(start_block_number, end_block_number) do + if blocks_pending_below_head_tolerance?(start_block_number, end_block_number) do render(conn, :pending_internal_transaction, message: @block_range_not_yet_processed_message, data: internal_transactions @@ -240,6 +247,45 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end + # Checks whether internal transactions of any block in the requested range are still + # being fetched, ignoring pending blocks at the chain head for ranges reaching the head. + # + # Internal transactions are fetched asynchronously, so on chains with a short block time + # the most recent blocks are practically always pending. When the requested range reaches + # the chain head (no `endblock`, or an `endblock` at or above the head such as the + # `99999999` sent by common Etherscan clients), pending blocks within the last + # `internal_transactions_pending_head_tolerance` blocks are therefore ignored; otherwise + # every such request would permanently return status `2`. An `endblock` below the head + # keeps the strict check, because the requested range is then really bounded and the + # response really is incomplete. + # + # The tolerance only applies to responses that return internal transactions: an empty + # result and the single-transaction `txlistinternal` variant keep the strict check, since + # for them the pending status is the only information about the missing data. + defp blocks_pending_below_head_tolerance?(start_block_number, end_block_number) do + PendingOperationsHelper.blocks_pending?(start_block_number, tolerated_end_block_number(end_block_number)) + end + + defp tolerated_end_block_number(end_block_number) do + tolerance = pending_head_tolerance() + + if tolerance == 0 do + end_block_number + else + head_block_number = BlockNumber.get_max() + + if is_nil(end_block_number) or end_block_number >= head_block_number do + max(head_block_number - tolerance, 0) + else + end_block_number + end + end + end + + defp pending_head_tolerance do + Application.get_env(:block_scout_web, __MODULE__)[:internal_transactions_pending_head_tolerance] || 0 + end + def tokentx(conn, params) do do_tokentx(conn, params, :erc20) end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs index fc85d15d00f..26390a19be8 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs @@ -1886,6 +1886,8 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do block = insert(:block) insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + # the pending block is exactly `tolerance` blocks behind the head, so it is not ignored + insert(:block, number: block.number + pending_head_tolerance()) transaction = :transaction @@ -1946,6 +1948,197 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end + test "returns status = 1 when only blocks within the chain head tolerance are pending", %{ + conn: conn, + params: params + } do + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + internal_transaction = insert_internal_transaction(block, address, address_2) + + # the pending block is one block inside the tolerance window, so it is ignored + pending_block = insert(:block, number: block.number + 1) + insert(:pending_block_operation, block_hash: pending_block.hash, block_number: pending_block.number) + head_block = insert(:block, number: pending_block.number + pending_head_tolerance() - 1) + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert [%{"transactionHash" => transaction_hash}] = response["result"] + assert transaction_hash == to_string(internal_transaction.transaction.hash) + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + + # an explicit endblock at or above the head reaches the head as well, so it is tolerated too + for endblock <- [head_block.number, 99_999_999] do + assert response = + conn + |> get("/api/v1", Map.merge(params, %{"startblock" => "#{block.number}", "endblock" => "#{endblock}"})) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "1" + assert response["message"] == "OK" + end + + # an explicit endblock below the head is a really bounded range, so the strict check applies + assert response = + conn + |> get("/api/v1", Map.put(params, "endblock", "#{head_block.number - 1}")) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + end + + test "returns status = 2 when the requested endblock is below the chain head tolerance", %{ + conn: conn, + params: params + } do + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + insert_internal_transaction(block, address, address_2) + + pending_block = insert(:block, number: block.number + 1) + insert(:pending_block_operation, block_hash: pending_block.hash, block_number: pending_block.number) + insert(:block, number: pending_block.number + pending_head_tolerance() + 1) + + assert response = + conn + |> get("/api/v1", Map.put(params, "endblock", "#{pending_block.number}")) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + + # an endblock below the pending block excludes it from the checked range + assert response = + conn + |> get("/api/v1", Map.put(params, "endblock", "#{block.number}")) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "returns status = 2 when a pending block is exactly tolerance blocks behind the chain head", %{ + conn: conn, + params: params + } do + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + insert_internal_transaction(block, address, address_2) + + pending_block = insert(:block, number: block.number + 1) + insert(:pending_block_operation, block_hash: pending_block.hash, block_number: pending_block.number) + insert(:block, number: pending_block.number + pending_head_tolerance()) + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + end + + test "returns status = 2 with an empty result when blocks within the chain head tolerance are pending", %{ + conn: conn, + params: params + } do + head_block = insert(:block) + insert(:pending_block_operation, block_hash: head_block.hash, block_number: head_block.number) + + for request_params <- [params, Map.put(params, "startblock", "#{head_block.number}")] do + assert response = + conn + |> get("/api/v1", request_params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + end + + test "returns status = 2 for pending block 0 when the chain head is below the tolerance", %{ + conn: conn, + params: params + } do + address = insert(:address) + address_2 = insert(:address) + + genesis_block = insert(:block, number: 0) + + block = insert(:block, number: 1) + insert_internal_transaction(block, address, address_2) + + # the head block is within the tolerance, so it is ignored + head_block = insert(:block, number: 2) + insert(:pending_block_operation, block_hash: head_block.hash, block_number: head_block.number) + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "1" + assert response["message"] == "OK" + + # block 0 is the only block outside the tolerance, so it is still reported + insert(:pending_block_operation, block_hash: genesis_block.hash, block_number: genesis_block.number) + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + end + + test "returns status = 2 for pending blocks at the chain head when tolerance is disabled", %{ + conn: conn, + params: params + } do + configuration = Application.get_env(:block_scout_web, AddressController) + Application.put_env(:block_scout_web, AddressController, internal_transactions_pending_head_tolerance: 0) + on_exit(fn -> Application.put_env(:block_scout_web, AddressController, configuration) end) + + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + insert_internal_transaction(block, address, address_2) + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + end + test "returns only non zero value internal transactions by default", %{conn: conn, params: params} do address = insert(:address) address_2 = insert(:address) @@ -2599,6 +2792,79 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end + test "returns status = 1 when only blocks within the chain head tolerance are pending", %{ + conn: conn + } do + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + internal_transaction = insert_internal_transaction(block, address, address_2) + + pending_block = insert(:block, number: block.number + 1) + insert(:pending_block_operation, block_hash: pending_block.hash, block_number: pending_block.number) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}" + } + + # the pending block is the head, so it is ignored with and without an endblock reaching the head + for request_params <- [params, Map.put(params, "endblock", "99999999")] do + assert response = + conn + |> get("/api", request_params) + |> json_response(200) + + assert [%{"transactionHash" => transaction_hash}] = response["result"] + assert transaction_hash == to_string(internal_transaction.transaction.hash) + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + # once the chain moves on, the pending block leaves the tolerance window + insert(:block, number: pending_block.number + pending_head_tolerance()) + + for request_params <- [params, Map.put(params, "endblock", "#{pending_block.number}")] do + assert response = + conn + |> get("/api", request_params) + |> json_response(200) + + assert [_] = response["result"] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + end + end + + test "returns status = 2 with an empty result when blocks within the chain head tolerance are pending", %{ + conn: conn + } do + address = insert(:address) + + head_block = insert(:block) + insert(:pending_block_operation, block_hash: head_block.hash, block_number: head_block.number) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}", + "startblock" => "#{head_block.number}" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + test "with a address that doesn't exist", %{conn: conn} do params = %{ "module" => "account", @@ -5066,6 +5332,27 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do end end + defp pending_head_tolerance do + Application.get_env(:block_scout_web, AddressController)[:internal_transactions_pending_head_tolerance] + end + + defp insert_internal_transaction(block, from_address, to_address) do + transaction = + :transaction + |> insert(from_address: from_address, to_address: to_address) + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 1, + from_address: from_address, + to_address: to_address, + block_number: block.number + ) + end + defp listaccounts_schema do resolve_schema(%{ "type" => "array", diff --git a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex index 56d465a109a..1bb6cd4222e 100644 --- a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex +++ b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex @@ -226,7 +226,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do end # Generates a query to find pending block operations within a specified range of block numbers. - @spec block_range_in_query(non_neg_integer(), non_neg_integer()) :: Ecto.Query.t() + @spec block_range_in_query(non_neg_integer() | nil, non_neg_integer() | nil) :: Ecto.Query.t() defp block_range_in_query(min_block_number, max_block_number) when is_integer(min_block_number) and is_integer(max_block_number) do from( diff --git a/config/runtime.exs b/config/runtime.exs index d8b373877e3..d859b36b3e9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -247,6 +247,10 @@ config :block_scout_web, BlockScoutWeb.Chain.Address.CoinBalance, config :block_scout_web, BlockScoutWeb.API.V2, enabled: ConfigHelper.parse_bool_env_var("API_V2_ENABLED", "true") +config :block_scout_web, BlockScoutWeb.API.RPC.AddressController, + internal_transactions_pending_head_tolerance: + ConfigHelper.parse_integer_env_var("API_INTERNAL_TRANSACTIONS_PENDING_HEAD_TOLERANCE", 200, min: 0) + config :block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_TRANSACTION_INTERPRETATION_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_TRANSACTION_INTERPRETATION_ENABLED") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 9a066cd5acd..44bcf07e5fe 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -187,6 +187,7 @@ ADMIN_PANEL_ENABLED=false API_V1_READ_METHODS_DISABLED=false API_V1_WRITE_METHODS_DISABLED=false # API_RATE_LIMIT_DISABLED=true +# API_INTERNAL_TRANSACTIONS_PENDING_HEAD_TOLERANCE=200 # API_SENSITIVE_ENDPOINTS_KEY= # API_RATE_LIMIT_BY_KEY_TIME_INTERVAL=1s # API_RATE_LIMIT_BY_WHITELISTED_IP_TIME_INTERVAL=1s From e1b902fcc2840b32e84023591e14f93face10ded Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:29 +0300 Subject: [PATCH 66/82] chore(deps): bump mint from 1.10.0 to 1.10.1 (#14876) 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 2bfaad40582..4fdd0293ae1 100644 --- a/mix.lock +++ b/mix.lock @@ -85,7 +85,7 @@ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hammer": {:hex, :hammer, "7.5.0", "7f9621ebb137f94d1bce60175cf2dd1be26e6e5c40f561124307fdd053fe8693", [:mix], [{:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "cb6fb12e5b5a7f1631d5753e9c45493b225bc11f4f09f11ec7f28140c9f0905f"}, "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.2.0", "90cec559a78af3128c7f73788b19d5a1040b1eba7fabaf82ac7a23383ce68c6a", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "dd33cc70ae0678602f363e80b302d7454722d12d6bef8f9db5cbb858bb319248"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, @@ -112,7 +112,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm", "7d8f80c567807ce78cd93c938e7f4b0a20b1aaaaab914bf286f68457d9f7a852"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "mnemoniac": {:hex, :mnemoniac, "0.1.5", "d10bf14abb94adc1a98f6cee4d81d197e1b3986cad8ec43b2e21d2cd9f4d99b6", [:mix], [], "hexpm", "518ae4b6ddda5285a5c02bf83e1eb6ce1af7e4d65d3f7f5bff3270689a00bcc3"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, "mox": {:hex, :mox, "1.3.2", "f34ca4331b1cce3125609c6de674739fe5f3df0d68df25510d64b6a94b2246de", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "97918a185e727f3128a826f01827b5b8168e1b815bda19eb4192c3ffdb6a8054"}, From f8d521525552250fa23c78719843a13b1785c6de Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 29 Sep 2026 11:58:14 +0300 Subject: [PATCH 67/82] fix: apply a finite timeout to the internal transactions import (#14864) --- .../indexer/fetcher/internal_transaction.ex | 54 +++++++++++++++++-- .../fetcher/internal_transaction_test.exs | 30 +++++++++++ config/config_helper.exs | 20 +++++-- config/runtime.exs | 1 + docker-compose/envs/common-blockscout.env | 1 + 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex index a27c6baa9b8..bb9d119feba 100644 --- a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex @@ -33,6 +33,7 @@ defmodule Indexer.Fetcher.InternalTransaction do @default_max_batch_size 10 @default_max_concurrency 4 + @default_import_timeout :timer.minutes(4) @doc """ Asynchronously fetches internal transactions. @@ -341,17 +342,57 @@ defmodule Indexer.Fetcher.InternalTransaction do end end + @doc """ + Timeout of the DB transaction importing a batch of internal transactions. + + Configured by `INDEXER_INTERNAL_TRANSACTIONS_IMPORT_TIMEOUT` (`import_timeout` key of the fetcher config). + It is applied both as the Ecto transaction timeout and as the server-side `statement_timeout`, so a batch + stuck on row locks or slow I/O is rolled back and re-queued instead of holding a fetcher worker + indefinitely. `0` disables the timeout. + """ + @spec import_timeout() :: timeout() + def import_timeout do + case Application.get_env(:indexer, __MODULE__)[:import_timeout] do + nil -> @default_import_timeout + 0 -> :infinity + timeout when is_integer(timeout) and timeout > 0 -> timeout + end + end + defp safe_import_internal_transaction(internal_transactions_params, block_numbers, data_type) do import_internal_transaction(internal_transactions_params, block_numbers, data_type) rescue exception in Postgrex.Error -> - Logger.error( - "Error on internal transactions import: #{inspect(exception)}, block numbers: #{inspect(block_numbers)}" - ) + case exception do + %Postgrex.Error{postgres: %{code: :query_canceled}} -> + log_import_timeout(exception, block_numbers) + + _ -> + Logger.error( + "Error on internal transactions import: #{inspect(exception)}, block numbers: #{inspect(block_numbers)}" + ) + end + + {:retry, block_numbers} + + exception in DBConnection.ConnectionError -> + log_import_timeout(exception, block_numbers) {:retry, block_numbers} end + # The cancel may come from the import transaction itself (`import_timeout/0`, in which case it was rolled back) + # or from the post-commit export queue insert in `Chain.import/1` (the Repo default timeout, the internal + # transactions are already committed). Either way the batch is re-queued; re-importing a committed block is a no-op. + defp log_import_timeout(reason, block_numbers) do + Logger.warning(fn -> + [ + "Internal transactions import was interrupted by a DB timeout (import timeout: #{inspect(import_timeout())} ms), ", + "retrying block numbers: #{inspect(block_numbers)}. Reason: #{inspect(reason)}" + ] + end) + end + defp import_internal_transaction(internal_transactions_params, transactions_params_or_unique_numbers, data_type) do internal_transactions_params_marked = mark_failed_transactions(internal_transactions_params) @@ -404,7 +445,7 @@ defmodule Indexer.Fetcher.InternalTransaction do addresses: %{params: addresses_params}, address_coin_balances: %{params: address_coin_balances_params_set}, internal_transactions: %{params: internal_transactions_and_empty_block_numbers, with: :blockless_changeset}, - timeout: :infinity + timeout: import_timeout() }) case imports do @@ -416,6 +457,11 @@ defmodule Indexer.Fetcher.InternalTransaction do async_import_celo_token_balances(celo_token_transfers_params) + {:error, :timeout} -> + log_import_timeout(:timeout, transactions_params_or_unique_numbers) + + {:retry, transactions_params_or_unique_numbers} + {:error, step, reason, _changes_so_far} -> Logger.error( fn -> diff --git a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs index 8fe3802871b..975e3e59a89 100644 --- a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs @@ -33,6 +33,36 @@ defmodule Indexer.Fetcher.InternalTransactionTest do @moduletag [capture_log: true, no_geth: true] + describe "import_timeout/0" do + setup do + initial_env = Application.get_env(:indexer, InternalTransaction) + + on_exit(fn -> Application.put_env(:indexer, InternalTransaction, initial_env) end) + + %{initial_env: initial_env || []} + end + + test "defaults to a finite timeout when not configured", %{initial_env: initial_env} do + Application.put_env(:indexer, InternalTransaction, Keyword.delete(initial_env, :import_timeout)) + + timeout = InternalTransaction.import_timeout() + + assert is_integer(timeout) and timeout > 0 + end + + test "returns the configured timeout", %{initial_env: initial_env} do + Application.put_env(:indexer, InternalTransaction, Keyword.put(initial_env, :import_timeout, 90_000)) + + assert InternalTransaction.import_timeout() == 90_000 + end + + test "0 disables the timeout", %{initial_env: initial_env} do + Application.put_env(:indexer, InternalTransaction, Keyword.put(initial_env, :import_timeout, 0)) + + assert InternalTransaction.import_timeout() == :infinity + end + end + test "does not try to fetch pending transactions from Indexer.Fetcher.PendingTransaction", %{ json_rpc_named_arguments: json_rpc_named_arguments } do diff --git a/config/config_helper.exs b/config/config_helper.exs index 67773a951c2..ce98d6df0ee 100644 --- a/config/config_helper.exs +++ b/config/config_helper.exs @@ -169,8 +169,14 @@ defmodule ConfigHelper do end end - @spec parse_time_env_var(String.t(), String.t() | nil) :: non_neg_integer() | nil - def parse_time_env_var(env_var, default_value \\ nil) do + @doc """ + Parses a time value (e.g. `10s`, `5m`, `1h`) from the env var into milliseconds. + + Options: + * `:min` - the minimum allowed value in milliseconds; a smaller value (e.g. a negative duration) raises at startup. + """ + @spec parse_time_env_var(String.t(), String.t() | nil, keyword()) :: integer() | nil + def parse_time_env_var(env_var, default_value \\ nil, opts \\ []) do case safe_get_env(env_var, default_value) do "" -> nil @@ -181,11 +187,19 @@ defmodule ConfigHelper do raise "Invalid time format in environment variable #{env_var}: #{value}" time -> - time + validate_min(env_var, time, value, Keyword.get(opts, :min)) end end end + defp validate_min(_env_var, time, _raw_value, nil), do: time + + defp validate_min(env_var, time, raw_value, min) when time < min do + raise "#{env_var} must be >= #{min} ms, got: #{raw_value}" + end + + defp validate_min(_env_var, time, _raw_value, _min), do: time + @doc """ Parses value of env var through catalogued values list. If a value is not in the list, nil is returned. Also, the application shutdown option is supported, if a value is wrong. diff --git a/config/runtime.exs b/config/runtime.exs index d859b36b3e9..6715e97e606 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1407,6 +1407,7 @@ config :indexer, Indexer.Fetcher.TokenInstance.SanitizeERC721, config :indexer, Indexer.Fetcher.InternalTransaction, batch_size: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_BATCH_SIZE", 10), concurrency: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_CONCURRENCY", 4), + import_timeout: ConfigHelper.parse_time_env_var("INDEXER_INTERNAL_TRANSACTIONS_IMPORT_TIMEOUT", "4m", min: 0), indexing_finished_threshold: ConfigHelper.parse_integer_env_var("API_INTERNAL_TRANSACTIONS_INDEXING_FINISHED_THRESHOLD", 1_000) diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 44bcf07e5fe..4b074787c51 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -257,6 +257,7 @@ API_V1_WRITE_METHODS_DISABLED=false # INDEXER_EMPTY_BLOCKS_SANITIZER_HEAD_OFFSET=1000 # INDEXER_INTERNAL_TRANSACTIONS_BATCH_SIZE= # INDEXER_INTERNAL_TRANSACTIONS_CONCURRENCY= +# INDEXER_INTERNAL_TRANSACTIONS_IMPORT_TIMEOUT= # INDEXER_REPLACED_TRANSACTIONS_BATCH_SIZE= # INDEXER_REPLACED_TRANSACTIONS_CONCURRENCY= # INDEXER_BLOCK_REWARD_BATCH_SIZE= From f09cc3bda95ec7bcf658a8711c67654e6a1eece9 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 29 Sep 2026 13:59:52 +0300 Subject: [PATCH 68/82] feat: add env var to disable transactions count consolidation (#14877) --- apps/explorer/config/config.exs | 2 ++ .../cache/counters/transactions_count.ex | 28 ++++++++++++++-- .../cache/counters/transactions_test.exs | 32 +++++++++++++++++++ config/runtime.exs | 3 +- docker-compose/envs/common-blockscout.env | 1 + 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index d61325261be..a91f0470e05 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -41,6 +41,8 @@ config :explorer, Explorer.Chain.Cache.Counters.AddressesCount, enabled: true, enable_consolidation: true +config :explorer, Explorer.Chain.Cache.Counters.TransactionsCount, enable_consolidation: true + config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, enabled: true config :explorer, Explorer.Chain.Cache.Counters.AddressCountersConsolidator, enabled: true diff --git a/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex index 3553ce2c5e9..72879f7d1c1 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex @@ -2,6 +2,13 @@ defmodule Explorer.Chain.Cache.Counters.TransactionsCount do @moduledoc """ Cache for total transactions count. + + The count is consolidated by an async task running `COUNT(*)` over the + `transactions` table and refreshed every `CACHE_TXS_COUNT_PERIOD`. On large + chains this scan is expensive, and the only consumer of the exact value is the + REST API output, so the consolidation can be turned off with + `CACHE_TXS_COUNT_CONSOLIDATION_DISABLED=true`. In that case `get/0` returns the + cheap `pg_class` estimate and never starts the counting task. """ use Explorer.Chain.MapCache, @@ -28,16 +35,31 @@ defmodule Explorer.Chain.Cache.Counters.TransactionsCount do """ @spec get() :: non_neg_integer() def get do - cached_value_from_ets = __MODULE__.get_count() + if enable_consolidation?() do + cached_value_from_ets = __MODULE__.get_count() + + CacheCountersHelper.evaluate_count(@cache_key, cached_value_from_ets, :estimated_transactions_count) + else + CacheCountersHelper.estimated_transactions_count() + end + end - CacheCountersHelper.evaluate_count(@cache_key, cached_value_from_ets, :estimated_transactions_count) + @doc """ + Returns whether the exact count consolidation is enabled. + + Controlled by `CACHE_TXS_COUNT_CONSOLIDATION_DISABLED`. When disabled, no + counting task is started and `get/0` falls back to the `pg_class` estimate. + """ + @spec enable_consolidation?() :: boolean() + def enable_consolidation? do + Application.get_env(:explorer, __MODULE__)[:enable_consolidation] end defp handle_fallback(:count) do # This will get the task PID if one exists, check if it's running and launch # a new task if task doesn't exist or it's not running. # See next `handle_fallback` definition - safe_get_async_task() + if enable_consolidation?(), do: safe_get_async_task() {:return, nil} end diff --git a/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs b/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs index 0bc082af09b..06c5430f9b8 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs @@ -56,4 +56,36 @@ defmodule Explorer.Chain.Cache.Counters.TransactionsTest do test "returns 0 on empty table" do assert 0 == TransactionsCount.get() end + + describe "with consolidation disabled" do + setup do + initial_config = Application.get_env(:explorer, TransactionsCount) + Application.put_env(:explorer, TransactionsCount, Keyword.put(initial_config, :enable_consolidation, false)) + on_exit(fn -> Application.put_env(:explorer, TransactionsCount, initial_config) end) + :ok + end + + test "get/0 does not start the counting task and does not populate the cache" do + insert(:transaction) + insert(:transaction) + + result = TransactionsCount.get() + + assert is_integer(result) + + Process.sleep(1000) + + assert %{count: nil, async_task: nil} == TransactionsCount.current_values([:count, :async_task]) + end + + test "get_count/0 fallback does not start the counting task" do + insert(:transaction) + + assert is_nil(TransactionsCount.get_count()) + + Process.sleep(1000) + + assert %{count: nil, async_task: nil} == TransactionsCount.current_values([:count, :async_task]) + end + end end diff --git a/config/runtime.exs b/config/runtime.exs index 6715e97e606..6f9a5e1dac0 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -408,7 +408,8 @@ config :explorer, Explorer.Chain.Cache.Counters.AddressesCount, update_interval_in_milliseconds: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNT_PERIOD", "30m") config :explorer, Explorer.Chain.Cache.Counters.TransactionsCount, - global_ttl: ConfigHelper.parse_time_env_var("CACHE_TXS_COUNT_PERIOD", "2h") + global_ttl: ConfigHelper.parse_time_env_var("CACHE_TXS_COUNT_PERIOD", "2h"), + enable_consolidation: !ConfigHelper.parse_bool_env_var("CACHE_TXS_COUNT_CONSOLIDATION_DISABLED") config :explorer, Explorer.Chain.Cache.Counters.PendingBlockOperationCount, global_ttl: ConfigHelper.parse_time_env_var("CACHE_PENDING_OPERATIONS_COUNT_PERIOD", "5m") diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 4b074787c51..111a9c6b657 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -122,6 +122,7 @@ RELEASE_LINK= # TRACE_LAST_BLOCK= # CACHE_BLOCK_COUNT_PERIOD=7200 # CACHE_TXS_COUNT_PERIOD=7200 +# CACHE_TXS_COUNT_CONSOLIDATION_DISABLED # CACHE_ADDRESS_SUM_PERIOD=3600 # CACHE_ADDRESS_COUNT_PERIOD=1800 # CACHE_AVERAGE_BLOCK_PERIOD=1800 From 3b1b7a6c34cfae449c83dd044414d91520b60511 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 29 Sep 2026 21:07:35 +0300 Subject: [PATCH 69/82] fix: handle multi-id historical transfers when reverting token instance owner on reorg (#14879) --- .../explorer/chain/import/runner/blocks.ex | 92 ++++++++++++------- .../chain/import/runner/blocks_test.exs | 80 ++++++++++++++++ 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index 0d79d642142..8466eb6fc47 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -1153,21 +1153,12 @@ defmodule Explorer.Chain.Import.Runner.Blocks do end) non_consensus_block_numbers = token_transfers |> Enum.map(fn tt -> tt.block_number end) |> Enum.uniq() + forked_token_ids = token_transfers |> Enum.map(fn tt -> tt.token_id end) |> Enum.uniq() filtered_query = TokenTransfer.only_consensus_transfers_query() - base_query = - from(token_transfer in subquery(filtered_query), - select: %{ - token_contract_address_hash: token_transfer.token_contract_address_hash, - token_id: fragment("(?)[1]", token_transfer.token_ids), - block_number: max(token_transfer.block_number) - }, - group_by: [token_transfer.token_contract_address_hash, fragment("(?)[1]", token_transfer.token_ids)] - ) - - historical_token_transfers_query = - Enum.reduce(token_transfers, base_query, fn tt, acc -> + matching_token_transfers_query = + Enum.reduce(token_transfers, subquery(filtered_query), fn tt, acc -> from(token_transfer in acc, or_where: token_transfer.token_contract_address_hash == ^tt.token_contract_address_hash and @@ -1177,6 +1168,23 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ) end) + # A historical transfer may carry several token ids (e.g. an ERC-1155 batch + # transfer emitted by the same contract), so `token_ids` is unnested to find + # the latest block per forked token id instead of relying on the first + # element of the array. + historical_token_transfers_query = + from(token_transfer in subquery(matching_token_transfers_query), + inner_join: unnested in fragment("LATERAL (SELECT unnest(?) AS token_id)", token_transfer.token_ids), + on: true, + where: fragment("? = ANY(?)", unnested.token_id, type(^forked_token_ids, {:array, :decimal})), + select: %{ + token_contract_address_hash: token_transfer.token_contract_address_hash, + token_id: fragment("?::numeric", unnested.token_id), + block_number: max(token_transfer.block_number) + }, + group_by: [token_transfer.token_contract_address_hash, unnested.token_id] + ) + refs_to_token_transfers = refs_to_token_transfers_query(historical_token_transfers_query, filtered_query) derived_token_transfers_query = derived_token_transfers_query(refs_to_token_transfers, filtered_query) @@ -1185,27 +1193,33 @@ defmodule Explorer.Chain.Import.Runner.Blocks do derived_token_transfers_query |> repo.all(timeout: options[:timeout]) |> Enum.reduce(changes_initial, fn tt, acc -> - token_id = List.first(tt.token_ids) - current_key = {tt.token_contract_address_hash, token_id} - - params = %{ - token_contract_address_hash: tt.token_contract_address_hash, - token_id: token_id, - owner_address_hash: tt.to_address_hash, - owner_updated_at_block: tt.block_number, - owner_updated_at_log_index: tt.log_index - } - - Map.put( - acc, - current_key, - Enum.max_by([acc[current_key], params], fn %{ - owner_updated_at_block: block_number, - owner_updated_at_log_index: log_index - } -> - {block_number, log_index} - end) - ) + current_key = {tt.token_contract_address_hash, tt.token_id} + + case acc do + %{^current_key => current} -> + params = %{ + token_contract_address_hash: tt.token_contract_address_hash, + token_id: tt.token_id, + owner_address_hash: tt.to_address_hash, + owner_updated_at_block: tt.block_number, + owner_updated_at_log_index: tt.log_index + } + + Map.put( + acc, + current_key, + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + Enum.max_by([current, params], fn %{ + owner_updated_at_block: block_number, + owner_updated_at_log_index: log_index + } -> + {block_number, log_index} + end) + ) + + _ -> + acc + end end) |> Map.values() @@ -1286,7 +1300,17 @@ defmodule Explorer.Chain.Import.Runner.Blocks do defp derived_token_transfers_query(refs_to_token_transfers, filtered_query) do from(tt in filtered_query, inner_join: tt_1 in subquery(refs_to_token_transfers), - on: tt_1.log_index == tt.log_index and tt_1.block_number == tt.block_number + on: + tt_1.log_index == tt.log_index and tt_1.block_number == tt.block_number and + tt_1.token_contract_address_hash == tt.token_contract_address_hash and + fragment("? @> ARRAY[?::decimal]", tt.token_ids, tt_1.token_id), + select: %{ + token_contract_address_hash: tt.token_contract_address_hash, + token_id: tt_1.token_id, + to_address_hash: tt.to_address_hash, + block_number: tt.block_number, + log_index: tt.log_index + } ) end diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index 2e26a1b51f9..12c446076bf 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -797,6 +797,86 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do }} = Multi.new() |> Blocks.run(changes_list, options) |> Repo.transaction() end + test "change instance owner when the previous transfer is a batch transfer with several token ids", + %{consensus_block: %{hash: block_hash, miner_hash: miner_hash, number: block_number}, options: options} do + block_number = block_number + 1 + consensus_block = insert(:block, %{hash: block_hash, number: block_number}) + + transaction = + :transaction + |> insert() + |> with_block(consensus_block) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address, type: "ERC-721") + id = Decimal.new(7) + other_id = Decimal.new(3) + + tt = + insert(:token_transfer, + token_ids: [id], + token_type: "ERC-721", + transaction: transaction, + token_contract_address: token_address, + block_number: block_number, + block: consensus_block + ) + + %{hash: hash_1} = params_for(:block, consensus: true, miner_hash: miner_hash) + consensus_block_1 = insert(:block, %{hash: hash_1, number: block_number - 1}) + + transaction_1 = + :transaction + |> insert() + |> with_block(consensus_block_1) + + # the forked token id is not the first element of the historical transfer's `token_ids` + batch_tt = + insert(:token_transfer, + token_ids: [other_id, id], + amounts: [Decimal.new(1), Decimal.new(1)], + token_type: "ERC-1155", + transaction: transaction_1, + token_contract_address: token_address, + block_number: consensus_block_1.number, + block: consensus_block_1 + ) + + instance = + insert(:token_instance, + token_contract_address_hash: token_address.hash, + token_id: id, + owner_updated_at_block: tt.block_number, + owner_updated_at_log_index: tt.log_index, + owner_address_hash: insert(:address).hash + ) + + block_params = + params_for(:block, hash: block_hash, miner_hash: miner_hash, number: block_number, consensus: false) + + %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, block_params) + changes_list = [block_changes] + error = instance.error + batch_block_number = batch_tt.block_number + batch_log_index = batch_tt.log_index + owner_address_hash = batch_tt.to_address_hash + token_address_hash = token_address.hash + + assert {:ok, + %{ + update_token_instances_owner: [ + %Explorer.Chain.Token.Instance{ + token_id: ^id, + error: ^error, + owner_updated_at_block: ^batch_block_number, + owner_updated_at_log_index: ^batch_log_index, + owner_address_hash: ^owner_address_hash, + token_contract_address_hash: ^token_address_hash + } + ] + }} = Multi.new() |> Blocks.run(changes_list, options) |> Repo.transaction() + end + if @chain_identity == {:optimism, :celo} do test "removes celo epoch rewards and sets fetched? = false when starting block loses consensus", %{ consensus_block: %{miner_hash: miner_hash} = parent_block, From 2f6b468e75dd8eec6e012e931b93a9a075ac1b0e Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 29 Sep 2026 22:00:45 +0300 Subject: [PATCH 70/82] fix: handle null nonce/mixHash returned by Nethermind for AuRa blocks (#14880) --- .../lib/ethereum_jsonrpc/block.ex | 38 +++++++++++------ .../lib/ethereum_jsonrpc/blocks.ex | 4 +- .../test/ethereum_jsonrpc/block_test.exs | 41 +++++++++++++++++++ .../test/ethereum_jsonrpc_test.exs | 4 +- 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex index 3e983f9effc..17b6edd531f 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex @@ -18,6 +18,11 @@ defmodule EthereumJSONRPC.Block do # (sha3Uncles) is the RLP-encoded hash of an empty list. @sha3_uncles_empty_list "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + # Defaults for PoW-only header fields that PoA/PoS nodes may omit or return as `null`. + # Kept full-width so they stay hex-decodable for header hashing (e.g. `Indexer.Transform.Blocks.Clique`). + @nonce_default "0x0000000000000000" + @mix_hash_default "0x0000000000000000000000000000000000000000000000000000000000000000" + case @chain_type do :rsk -> @chain_type_fields quote( @@ -103,8 +108,10 @@ defmodule EthereumJSONRPC.Block do * `"miner"` - `t:EthereumJSONRPC.address/0` of the beneficiary to whom the mining rewards were given. Aliased by `"author"`. * `"mixHash"` - Generated from [DAG](https://ethereum.stackexchange.com/a/10353) as part of Proof-of-Work for EthHash - algorithm. **[Geth](https://github.com/ethereum/go-ethereum/wiki/geth) + Proof-of-Work-only** - * `"nonce"` - `t:EthereumJSONRPC.nonce/0`. `nil` when its pending block. + algorithm. **[Geth](https://github.com/ethereum/go-ethereum/wiki/geth) + Proof-of-Work-only**. May be absent or + `nil` on PoA/PoS chains (e.g. Nethermind returns `nil` for AuRa blocks). + * `"nonce"` - `t:EthereumJSONRPC.nonce/0`. `nil` when its pending block. May also be absent or `nil` on PoA/PoS + chains (e.g. Nethermind returns `nil` for AuRa blocks). * `"number"` - the block number `t:EthereumJSONRPC.quantity/0`. `nil` when block is pending. * `"parentHash" - the `t:EthereumJSONRPC.hash/0` of the parent block. * `"receiptsRoot"` - `t:EthereumJSONRPC.hash/0` of the root of the receipts. @@ -246,8 +253,8 @@ defmodule EthereumJSONRPC.Block do hash: "0x52c867bc0a91e573dc39300143c3bead7408d09d45bdb686749f02684ece72f3", logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", miner_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", - mix_hash: "0x0", - nonce: 0, + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", number: 1, parent_hash: "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", receipts_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", @@ -389,8 +396,8 @@ defmodule EthereumJSONRPC.Block do hash: hash, logs_bloom: logs_bloom, miner_hash: miner_hash, - mix_hash: Map.get(elixir, "mixHash", "0x0"), - nonce: Map.get(elixir, "nonce", 0), + mix_hash: Map.get(elixir, "mixHash", @mix_hash_default), + nonce: Map.get(elixir, "nonce", @nonce_default), number: number, parent_hash: parent_hash, receipts_root: receipts_root, @@ -434,8 +441,8 @@ defmodule EthereumJSONRPC.Block do hash: hash, logs_bloom: logs_bloom, miner_hash: miner_hash, - mix_hash: Map.get(elixir, "mixHash", "0x0"), - nonce: Map.get(elixir, "nonce", 0), + mix_hash: Map.get(elixir, "mixHash", @mix_hash_default), + nonce: Map.get(elixir, "nonce", @nonce_default), number: number, parent_hash: parent_hash, receipts_root: receipts_root, @@ -478,8 +485,8 @@ defmodule EthereumJSONRPC.Block do hash: hash, logs_bloom: logs_bloom, miner_hash: miner_hash, - mix_hash: Map.get(elixir, "mixHash", "0x0"), - nonce: Map.get(elixir, "nonce", 0), + mix_hash: Map.get(elixir, "mixHash", @mix_hash_default), + nonce: Map.get(elixir, "nonce", @nonce_default), number: number, parent_hash: parent_hash, receipts_root: receipts_root, @@ -522,8 +529,8 @@ defmodule EthereumJSONRPC.Block do hash: hash, logs_bloom: logs_bloom, miner_hash: miner_hash, - mix_hash: Map.get(elixir, "mixHash", "0x0"), - nonce: Map.get(elixir, "nonce", 0), + mix_hash: Map.get(elixir, "mixHash", @mix_hash_default), + nonce: Map.get(elixir, "nonce", @nonce_default), number: number, parent_hash: parent_hash, receipts_root: receipts_root, @@ -926,6 +933,13 @@ defmodule EthereumJSONRPC.Block do {key, nil} end + # PoW-only header fields may be omitted or returned as `null` by PoA/PoS nodes (e.g. Nethermind returns `null` + # `nonce`/`mixHash` for AuRa blocks). Drop `nil` values so `elixir_to_params/1` applies the same defaults as + # for absent keys. + defp entry_to_elixir({key, nil}, _block) when key in ~w(nonce mixHash sha3Uncles uncles) do + {:ignore, :ignore} + end + # double check that no new keys are being missed by requiring explicit match for passthrough # `t:EthereumJSONRPC.address/0` and `t:EthereumJSONRPC.hash/0` pass through as `Explorer.Chain` can verify correct # hash format diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex index 592194ea113..82a4c822ca3 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex @@ -191,8 +191,8 @@ defmodule EthereumJSONRPC.Blocks do hash: "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", miner_hash: "0x0000000000000000000000000000000000000000", - mix_hash: "0x0", - nonce: 0, + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", number: 0, parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", receipts_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs index 834d7680baf..0455d7801b6 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs @@ -95,6 +95,47 @@ defmodule EthereumJSONRPC.BlockTest do end end + describe "to_elixir/1" do + # Nethermind returns `nonce` and `mixHash` as `null` for AuRa blocks (e.g. pre-merge Gnosis) + test "drops nil PoW-only fields so elixir_to_params/1 falls back to defaults" do + elixir = + Block.to_elixir(%{ + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0x", + "gasLimit" => "0x1c9c380", + "gasUsed" => "0x1f1e57", + "hash" => "0x3be18f3167d097104e5256ca2eb04a8148241b0d7270c55a5547f1f4dc90307d", + "logsBloom" => "0x", + "miner" => "0x0000999dc55126ca626c20377f0045946db69b6e", + "mixHash" => nil, + "nonce" => nil, + "number" => "0x13b09fd", + "parentHash" => "0x39558c126fdbbb412271b6dc5b34922f16ae786a883f33ba3a55b5f0f5f0f5f0", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sha3Uncles" => nil, + "size" => "0x92c", + "stateRoot" => "0x6fd0a5d82ca77d9f38c3ebbde11b11d304a5fcf3854f291df64395ab38ed43ba", + "timestamp" => "0x620b2ff9", + "totalDifficulty" => nil, + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => nil, + "baseFeePerGas" => "0x8" + }) + + for key <- ~w(nonce mixHash sha3Uncles uncles) do + refute Map.has_key?(elixir, key), "expected #{key} to be dropped" + end + + assert %{ + nonce: "0x0000000000000000", + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + uncles: [] + } = Block.elixir_to_params(elixir) + end + end + describe "elixir_to_transactions/1" do test "converts to empty list if there is not transaction key" do assert Block.elixir_to_transactions(%{}) == [] diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs index 27a09f53179..2be03484561 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs @@ -488,8 +488,8 @@ defmodule EthereumJSONRPCTest do hash: "0x0", logs_bloom: "0x", miner_hash: "0x0", - mix_hash: "0x0", - nonce: 0, + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", number: 0, parent_hash: "0x0", receipts_root: "0x0", From e23fe2f5c7ed2bc1e72356f9b49a94949b652455 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Wed, 30 Sep 2026 12:39:05 +0300 Subject: [PATCH 71/82] fix: do not move the beacon deposit fetcher cursor forward on reorgs (#14883) --- apps/explorer/lib/explorer/chain/import.ex | 3 + .../explorer/chain/import/runner/blocks.ex | 106 ++++++++++++++---- .../chain/import/runner/blocks_test.exs | 71 ++++++++++++ .../test/explorer/chain/import_test.exs | 42 +++++++ .../lib/indexer/fetcher/beacon/deposit.ex | 35 ++++-- .../indexer/fetcher/beacon/deposit_test.exs | 84 ++++++++++++++ 6 files changed, 307 insertions(+), 34 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index ffae635ab3f..ef063143302 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -164,6 +164,9 @@ defmodule Explorer.Chain.Import do {:ok, valid_runner_option_pairs} <- validate_runner_options_pairs(runner_options_pairs), {:ok, runner_to_changes_list} <- runner_to_changes_list(valid_runner_option_pairs), {:ok, data} <- insert_runner_to_changes_list(runner_to_changes_list, options) do + # Only after the commit: the fetcher deletes deposits and rewinds its + # cursor in response, which must not happen for a rolled back import. + Import.Runner.Blocks.notify_beacon_deposit_fetcher(data[:blocks_consensus]) Notify.async(data[:transactions]) update_counters(data, Map.get(options, :broadcast, false)) Publisher.broadcast(Map.drop(data, @not_broadcasted_runners), Map.get(options, :broadcast, false)) diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index 8466eb6fc47..6de85f66eaf 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -80,16 +80,21 @@ defmodule Explorer.Chain.Import.Runner.Blocks do # Enforce ShareLocks tables order (see docs: sharelocks.md) run_func = fn repo -> - {:ok, nonconsensus_items} = process_blocks_consensus(changes_list, repo, insert_options) + {:ok, %{nonconsensus_blocks: nonconsensus_items} = consensus_result} = + lose_consensus(repo, changes_list, insert_options) {:ok, - RangesHelper.filter_by_height_range(nonconsensus_items, fn {number, _hash} -> - RangesHelper.traceable_block_number?(number) - end)} + %{ + consensus_result + | nonconsensus_blocks: + RangesHelper.filter_by_height_range(nonconsensus_items, fn {number, _hash} -> + RangesHelper.traceable_block_number?(number) + end) + }} end multi - |> Multi.run(:lose_consensus, fn repo, _ -> + |> Multi.run(:blocks_consensus, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> run_func.(repo) end, :address_referencing, @@ -97,6 +102,12 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :lose_consensus ) end) + # The beacon deposit fetcher is notified by `Explorer.Chain.Import.all/1` + # from `:blocks_consensus` once the transaction has committed, see + # `notify_beacon_deposit_fetcher/1`. + |> Multi.run(:lose_consensus, fn _repo, %{blocks_consensus: %{nonconsensus_blocks: nonconsensus_blocks}} -> + {:ok, nonconsensus_blocks} + end) |> Multi.run(:counters_refetched_block_numbers, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> @@ -718,8 +729,11 @@ defmodule Explorer.Chain.Import.Runner.Blocks do # - `_opts`: The options containing timeout and `updated_at` timestamp for db operations. # # ## Returns - # - `{:ok, removed_consensus_blocks}` tuple with the list of `{block_number, block_hash}` - # tuples for the blocks that lost consensus. + # - `{:ok, %{nonconsensus_blocks: [{block_number, block_hash}], beacon_deposit_reorg_block_number: number | nil}}` + # where `nonconsensus_blocks` lists every block marked non-consensus here (including + # blocks that already were), and `beacon_deposit_reorg_block_number` is the lowest + # block number near the chain head that actually lost consensus, see + # `notify_beacon_deposit_fetcher/1`. # - `{:error, %{exception: postgrex_error}}` in case of database error. defp lose_consensus( repo, @@ -738,13 +752,17 @@ defmodule Explorer.Chain.Import.Runner.Blocks do or_where: block.number in ^consensus_block_numbers, # we also need to acquire blocks that will be upserted here, for ordering or_where: block.hash in ^hashes, - select: %{hash: block.hash, number: block.number}, + select: %{hash: block.hash, number: block.number, consensus: block.consensus}, # Enforce Block ShareLocks order (see docs: sharelocks.md) order_by: [asc: block.hash], lock: "FOR NO KEY UPDATE" ) - {_, removed_consensus_blocks} = + # `s.consensus` is read by the locked subquery before the update, so it + # tells whether the block actually lost consensus here or was already + # non-consensus (e.g. a fork block re-touched by a later import of its + # number). + {_, removed_consensus_blocks_with_previous_consensus} = repo.update_all( from( block in Block, @@ -752,26 +770,26 @@ defmodule Explorer.Chain.Import.Runner.Blocks do on: block.hash == s.hash, # we don't want to remove consensus from blocks that will be upserted where: block.hash not in ^hashes, - select: {block.number, block.hash} + select: {block.number, block.hash, s.consensus} ), [set: [consensus: false, updated_at: updated_at]], timeout: timeout ) + removed_consensus_blocks = + Enum.map(removed_consensus_blocks_with_previous_consensus, fn {number, hash, _previous_consensus} -> + {number, hash} + end) + removed_consensus_block_numbers = removed_consensus_blocks |> Enum.map(fn {number, _hash} -> number end) - maximum_block_number = BlockNumber.get_max() - - minimum_recent_block_number = - removed_consensus_block_numbers - |> Enum.filter(fn n -> n >= maximum_block_number - 64 end) - |> Enum.min(fn -> nil end) - - if minimum_recent_block_number do - GenServer.cast(Indexer.Fetcher.Beacon.Deposit, {:lost_consensus, minimum_recent_block_number}) - end + beacon_deposit_reorg_block_number = + removed_consensus_blocks_with_previous_consensus + |> Enum.filter(fn {_number, _hash, previous_consensus} -> previous_consensus end) + |> Enum.map(fn {number, _hash, _previous_consensus} -> number end) + |> recent_block_number() repo.update_all( from( @@ -844,17 +862,55 @@ defmodule Explorer.Chain.Import.Runner.Blocks do |> Enum.reject(&Enum.member?(consensus_block_numbers, &1)) |> MissingBlockRange.add_ranges_by_block_numbers() - {:ok, removed_consensus_blocks} + {:ok, + %{ + nonconsensus_blocks: removed_consensus_blocks, + beacon_deposit_reorg_block_number: beacon_deposit_reorg_block_number + }} rescue postgrex_error in Postgrex.Error -> {:error, %{exception: postgrex_error}} end + # The lowest of the given block numbers that lies within the reorg depth the + # beacon deposit fetcher cares about (64 blocks below the chain head), or + # `nil` when there is none. + defp recent_block_number([]), do: nil + + defp recent_block_number(block_numbers) do + maximum_block_number = BlockNumber.get_max() + + block_numbers + |> Enum.filter(fn n -> n >= maximum_block_number - 64 end) + |> Enum.min(fn -> nil end) + end + + @doc """ + Notifies the beacon deposit fetcher that blocks lost consensus, based on the + `:blocks_consensus` result of `run/3`. + + Must be called only after the import transaction has committed: the + fetcher reacts by deleting deposits above the reported block number and + rewinding its cursor, which would be wrong if the import was rolled back. + Does nothing when no block near the chain head actually lost consensus. + """ + @spec notify_beacon_deposit_fetcher( + %{:beacon_deposit_reorg_block_number => non_neg_integer() | nil, optional(atom()) => any()} + | nil + ) :: :ok + def notify_beacon_deposit_fetcher(%{beacon_deposit_reorg_block_number: block_number}) when is_integer(block_number) do + GenServer.cast(Indexer.Fetcher.Beacon.Deposit, {:lost_consensus, block_number}) + end + + def notify_beacon_deposit_fetcher(_), do: :ok + @doc """ Processes consensus for blocks that failed to import completely. This function handles the consistency updates needed when a block import fails, - ensuring that the chain's consensus state remains valid. + ensuring that the chain's consensus state remains valid. It runs outside of + the import transaction, so the beacon deposit fetcher is notified right away + when a block near the chain head lost consensus. ## Parameters - `blocks_changes`: List of block changes to process @@ -873,7 +929,11 @@ defmodule Explorer.Chain.Import.Runner.Blocks do timestamps: %{updated_at: DateTime.utc_now()} } - lose_consensus(repo, blocks_changes, opts) + with {:ok, %{nonconsensus_blocks: nonconsensus_blocks} = consensus_result} <- + lose_consensus(repo, blocks_changes, opts) do + notify_beacon_deposit_fetcher(consensus_result) + {:ok, nonconsensus_blocks} + end end defp delete_address_coin_balances(_repo, [], _options), do: {:ok, []} diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index 12c446076bf..f3c530959b8 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -1099,6 +1099,77 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do Blocks.process_blocks_consensus([new_block1_changes], Repo, opts) assert_received {:"$gen_cast", {:lost_consensus, _}} end + + test "does not trigger beacon deposit reorg handling for blocks that were already non-consensus" do + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: false) + end) + + BlockNumber.set_max(50) + + Process.register(self(), Indexer.Fetcher.Beacon.Deposit) + + block0 = insert(:block, consensus: true, number: 0) + insert(:block, consensus: false, number: 1) + + new_block1 = params_for(:block, miner_hash: insert(:address).hash, parent_hash: block0.hash, number: 1) + + %Ecto.Changeset{valid?: true, changes: new_block1_changes} = Block.changeset(%Block{}, new_block1) + + opts = %{ + timeout: 60_000, + timestamps: %{updated_at: DateTime.utc_now()} + } + + # the old fork block is still reported for the cleanup steps of the import + assert {:ok, [{1, _}]} = Blocks.process_blocks_consensus([new_block1_changes], Repo, opts) + refute_received {:"$gen_cast", {:lost_consensus, _}} + end + + test "run/3 reports the reorg block number instead of notifying the beacon deposit fetcher from the transaction" do + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: false) + end) + + BlockNumber.set_max(50) + + Process.register(self(), Indexer.Fetcher.Beacon.Deposit) + + insert(:block, consensus: true, number: 0) + insert(:block, consensus: true, number: 1) + insert(:block, consensus: false, number: 2) + + miner = insert(:address) + new_block0 = params_for(:block, miner_hash: miner.hash, number: 0) + new_block1 = params_for(:block, miner_hash: miner.hash, parent_hash: new_block0.hash, number: 1) + + %Ecto.Changeset{valid?: true, changes: new_block1_changes} = Block.changeset(%Block{}, new_block1) + + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + assert {:ok, + %{ + blocks_consensus: %{ + beacon_deposit_reorg_block_number: 0, + nonconsensus_blocks: nonconsensus_blocks + }, + lose_consensus: lose_consensus + }} = + Multi.new() + |> Blocks.run([new_block1_changes], options) + |> Repo.transaction() + + assert [{0, _}, {1, _}] = Enum.sort(nonconsensus_blocks) + assert Enum.sort(lose_consensus) == Enum.sort(nonconsensus_blocks) + + # the notification is sent by `Explorer.Chain.Import.all/1` after the commit + refute_received {:"$gen_cast", {:lost_consensus, _}} + end end defp insert_block(block_params, options) do diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index dc39d793f8b..fc1e1ae0d38 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -1371,6 +1371,48 @@ defmodule Explorer.Chain.ImportTest do assert DateTime.compare(timestamp, timestamp_before) == :eq end + test "notifies the beacon deposit fetcher about a reorg near the chain head after the import committed" do + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: false) + end) + + Explorer.Chain.Cache.BlockNumber.set_max(50) + + Process.register(self(), Indexer.Fetcher.Beacon.Deposit) + + %Block{hash: old_block_hash} = insert(:block, consensus: true, number: 10) + + miner_hash = address_hash() + + assert {:ok, %{blocks_consensus: %{beacon_deposit_reorg_block_number: 10}}} = + Import.all(%{ + addresses: %{params: [%{hash: miner_hash}]}, + blocks: %{ + params: [ + %{ + consensus: true, + difficulty: 1, + gas_limit: 1, + gas_used: 1, + hash: block_hash(), + miner_hash: miner_hash, + nonce: 1, + number: 10, + parent_hash: block_hash(), + size: 1, + timestamp: Timex.parse!("2019-01-01T02:00:00Z", "{ISO:Extended:Z}"), + total_difficulty: 1 + } + ] + } + }) + + assert %Block{consensus: false} = Repo.get(Block, old_block_hash) + assert_received {:"$gen_cast", {:lost_consensus, 10}} + end + test "reorganizations nils transaction receipt fields for transactions that end up in non-consensus blocks" do block_number = 0 diff --git a/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex b/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex index 35499715185..d145ceb19e7 100644 --- a/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex +++ b/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex @@ -104,27 +104,40 @@ defmodule Indexer.Fetcher.Beacon.Deposit do end end + # Handles a consensus loss reported by the blocks import runner. + # + # The cursor is always rebuilt from the deposits that survived in the + # database, never from `block_number` itself. The fetcher trails the chain + # head by however many blocks carry no deposit, so the reorged block is + # usually ahead of the cursor; moving the cursor to it would silently skip + # every deposit log in between, and the node fallback (which starts from the + # cursor) could never recover them. When nothing was deleted the state is + # left untouched, which also makes the notification idempotent: the runner + # may report the same block number several times. @impl GenServer def handle_cast({:lost_consensus, block_number}, %__MODULE__{} = state) do - {_deleted_deposits_count, deleted_deposits} = + {deleted_deposits_count, _} = Repo.delete_all( from( d in Deposit, - where: d.block_number > ^block_number, - select: d.index + where: d.block_number > ^block_number ), timeout: :infinity ) - deposit_index = Enum.min(deleted_deposits, fn -> state.deposit_index + 1 end) + if deleted_deposits_count > 0 do + last_processed_deposit = Deposit.get_latest_deposit() || %{index: -1, block_number: -1, log_index: -1} - {:noreply, - %{ - state - | deposit_index: deposit_index - 1, - last_processed_log_block_number: block_number, - last_processed_log_index: -1 - }} + {:noreply, + %{ + state + | deposit_index: last_processed_deposit.index, + last_processed_log_block_number: last_processed_deposit.block_number, + last_processed_log_index: last_processed_deposit.log_index + }} + else + {:noreply, state} + end rescue postgrex_error in Postgrex.Error -> Logger.error( diff --git a/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs b/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs index 75ee23b3753..6b5bf691866 100644 --- a/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs +++ b/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs @@ -907,5 +907,89 @@ defmodule Indexer.Fetcher.Beacon.DepositTest do Repo.all(from(d in Deposit, order_by: [asc: :index])) end end + + describe "handle_cast({:lost_consensus, block_number}, state)" do + test "keeps the cursor when the reorged block is ahead of the last processed deposit" do + insert(:beacon_deposit, index: 5, block_number: 100, log_index: 3) + + state = %{ + @state + | deposit_index: 5, + last_processed_log_block_number: 100, + last_processed_log_index: 3 + } + + # the fetcher trails the head: a reorg at block 200 must not move the + # cursor past the deposit logs of blocks 101..200 it has not seen yet + assert {:noreply, ^state} = DepositFetcher.handle_cast({:lost_consensus, 200}, state) + assert [5] = Repo.all(from(d in Deposit, select: d.index)) + + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + block = insert(:block, number: 150) + transaction = insert(:transaction) |> with_block(block) + + log = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 6, + transaction: transaction, + block: block, + block_number: block.number + ) + + log_index = log.index + + assert {:noreply, + %DepositFetcher{ + deposit_index: 6, + last_processed_log_block_number: 150, + last_processed_log_index: ^log_index + }} = DepositFetcher.handle_info(:process_logs, state) + + assert [5, 6] = Repo.all(from(d in Deposit, order_by: [asc: :index], select: d.index)) + end + + test "rewinds the cursor to the latest surviving deposit when deposits are deleted" do + insert(:beacon_deposit, index: 5, block_number: 100, log_index: 3) + insert(:beacon_deposit, index: 6, block_number: 150, log_index: 1) + insert(:beacon_deposit, index: 7, block_number: 160, log_index: 2) + + state = %{ + @state + | deposit_index: 7, + last_processed_log_block_number: 160, + last_processed_log_index: 2 + } + + assert {:noreply, + %DepositFetcher{ + deposit_index: 5, + last_processed_log_block_number: 100, + last_processed_log_index: 3 + }} = DepositFetcher.handle_cast({:lost_consensus, 120}, state) + + assert [5] = Repo.all(from(d in Deposit, select: d.index)) + end + + test "resets the cursor when all deposits are deleted" do + insert(:beacon_deposit, index: 5, block_number: 100, log_index: 3) + + state = %{ + @state + | deposit_index: 5, + last_processed_log_block_number: 100, + last_processed_log_index: 3 + } + + assert {:noreply, + %DepositFetcher{ + deposit_index: -1, + last_processed_log_block_number: -1, + last_processed_log_index: -1 + }} = DepositFetcher.handle_cast({:lost_consensus, 50}, state) + + assert [] = Repo.all(Deposit) + end + end end end From 49f6330d4d385f9293e2d1d77e68de28c6534f31 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:55:45 +0400 Subject: [PATCH 72/82] fix: ReindexBlocksWithStaleInternalTransactions migration (#14884) --- apps/explorer/config/config.exs | 1 + apps/explorer/config/runtime/test.exs | 1 + apps/explorer/lib/explorer/application.ex | 1 + ...blocks_with_stale_internal_transactions.ex | 126 ++++++++++++++++++ ..._with_stale_internal_transactions_test.exs | 126 ++++++++++++++++++ config/runtime.exs | 7 + docker-compose/envs/common-blockscout.env | 3 + 7 files changed, 265 insertions(+) create mode 100644 apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex create mode 100644 apps/explorer/test/explorer/migrator/reindex_blocks_with_stale_internal_transactions_test.exs diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index a91f0470e05..6225ba37de8 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -139,6 +139,7 @@ for migrator <- [ Explorer.Migrator.UnescapeAmpersandsInTokens, Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, + Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, Explorer.Migrator.BackfillAddressCounters, diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index 264a1f12e1e..ebec4ea9864 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -83,6 +83,7 @@ for migrator <- [ Explorer.Migrator.UnescapeAmpersandsInTokens, Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, + Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions, Explorer.Migrator.DeleteZeroValueInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 58d4168c3a1..94ce4674c03 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -253,6 +253,7 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.UnescapeAmpersandsInTokens, :indexer), configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithMissingTransactions, :indexer), configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, :indexer), + configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, :indexer), configure_mode_dependent_process(Explorer.Migrator.DeleteZeroValueInternalTransactions, :indexer), configure_mode_dependent_process(Explorer.Migrator.EmptyInternalTransactionsData, :indexer), diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex new file mode 100644 index 00000000000..b6c97f5c061 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions do + @moduledoc """ + Searches for blocks whose internal transactions were not refreshed after a + block with the same number lost consensus and sends them to a full refetch + (see `Explorer.Chain.Block.full_refetch/1`). + + Internal transactions of a block that lost consensus used to stay in the DB + until reorgs started to be handled via `internal_transactions_delete_queue`. + Since internal transactions are identified by + `(block_number, transaction_index, index)` only, such leftovers are + indistinguishable from the internal transactions of the consensus block. + + Every import of a consensus block bumps `updated_at` of all the other blocks + with the same number, and the internal transactions of that number are + deleted and refetched afterwards, so they get a later `inserted_at`. Thus a + block number is considered stale if it has a non-consensus block and an + internal transaction inserted between `inserted_at` and `updated_at` of that + block. The lower bound excludes uncle blocks fetched after the internal + transactions of the consensus block had been imported. + + The migration processes block numbers in batches from the maximum down to the + minimum number of non-consensus blocks and persists its progress, so it runs + to completion only once. To re-run it, delete the + "reindex_blocks_with_stale_internal_transactions" record from the + `migrations_status` table. + """ + + use Explorer.Migrator.FillingMigration + + require Logger + + import Ecto.Query + + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.{Block, InternalTransaction} + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "reindex_blocks_with_stale_internal_transactions" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => from_number, "min_block_number" => min_number} = state) + when from_number < min_number, + do: {[], state} + + def last_unprocessed_identifiers(%{"max_block_number" => from_number, "min_block_number" => min_number} = state) do + limit = batch_size() * concurrency() + to_number = max(from_number - limit + 1, min_number) + + {Enum.to_list(from_number..to_number//-1), %{state | "max_block_number" => to_number - 1}} + end + + def last_unprocessed_identifiers(state) do + {min_block_number, max_block_number} = + Repo.one( + from(block in Block, where: block.consensus == false, select: {min(block.number), max(block.number)}), + timeout: :infinity + ) + + state + |> Map.merge(%{"min_block_number" => min_block_number || 0, "max_block_number" => max_block_number || -1}) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + {min_block_number, max_block_number} = Enum.min_max(block_numbers) + + min_block_number + |> stale_block_numbers(max_block_number) + |> RangesHelper.filter_traceable_block_numbers() + |> full_refetch() + end + + @impl FillingMigration + def update_cache, do: :ok + + defp stale_block_numbers(from_block_number, to_block_number) do + query = + from( + block in Block, + as: :block, + where: block.number >= ^from_block_number and block.number <= ^to_block_number, + where: block.consensus == false, + where: block.updated_at > block.inserted_at, + where: exists(stale_internal_transactions_query()), + distinct: true, + select: block.number + ) + + Repo.all(query, timeout: :infinity) + end + + defp stale_internal_transactions_query do + from( + it in InternalTransaction, + where: it.block_number == parent_as(:block).number, + where: it.inserted_at >= parent_as(:block).inserted_at, + where: it.inserted_at < parent_as(:block).updated_at, + select: 1 + ) + end + + defp full_refetch([]), do: :ok + + defp full_refetch(block_numbers) do + case Block.full_refetch(block_numbers) do + :ok -> + Logger.info( + "Migration #{@migration_name} sent blocks to full refetch: #{inspect(block_numbers, limit: :infinity)}" + ) + + :ok + + {:error, reason} -> + raise "Migration #{@migration_name} failed to send blocks #{inspect(block_numbers, limit: :infinity)} to full refetch: #{inspect(reason)}" + end + end +end diff --git a/apps/explorer/test/explorer/migrator/reindex_blocks_with_stale_internal_transactions_test.exs b/apps/explorer/test/explorer/migrator/reindex_blocks_with_stale_internal_transactions_test.exs new file mode 100644 index 00000000000..209234aef50 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/reindex_blocks_with_stale_internal_transactions_test.exs @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithStaleInternalTransactionsTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Block + alias Explorer.Chain.InternalTransaction.DeleteQueue + alias Explorer.Migrator.{MigrationStatus, ReindexBlocksWithStaleInternalTransactions} + alias Explorer.Repo + + @migration_name "reindex_blocks_with_stale_internal_transactions" + + setup do + configuration = Application.get_env(:explorer, ReindexBlocksWithStaleInternalTransactions) + + Application.put_env( + :explorer, + ReindexBlocksWithStaleInternalTransactions, + Keyword.merge(configuration || [], batch_size: 1, concurrency: 1) + ) + + on_exit(fn -> + Application.put_env(:explorer, ReindexBlocksWithStaleInternalTransactions, configuration) + end) + end + + test "sends blocks with internal transactions left from non-consensus blocks to full refetch" do + now = Timex.now() + + # internal transactions of the block that lost consensus were not deleted + stale_block = insert(:block) + :transaction |> insert() |> with_block(stale_block) + + insert(:block, + number: stale_block.number, + consensus: false, + inserted_at: Timex.shift(now, hours: -3), + updated_at: Timex.shift(now, hours: -1) + ) + + insert(:internal_transaction, + block_number: stale_block.number, + transaction_index: 5, + index: 1, + inserted_at: Timex.shift(now, hours: -2) + ) + + # uncle block fetched after the internal transactions of the consensus block had been imported + uncle_block = insert(:block) + uncle_transaction = :transaction |> insert() |> with_block(uncle_block) + uncle_inserted_at = Timex.shift(now, hours: -1) + + insert(:block, + number: uncle_block.number, + consensus: false, + inserted_at: uncle_inserted_at, + updated_at: uncle_inserted_at + ) + + insert(:internal_transaction, + transaction: uncle_transaction, + block_number: uncle_block.number, + transaction_index: uncle_transaction.index, + index: 1, + inserted_at: Timex.shift(now, hours: -2) + ) + + # internal transactions were refetched after the block lost consensus + refetched_block = insert(:block) + refetched_transaction = :transaction |> insert() |> with_block(refetched_block) + + insert(:block, + number: refetched_block.number, + consensus: false, + inserted_at: Timex.shift(now, hours: -3), + updated_at: Timex.shift(now, hours: -2) + ) + + insert(:internal_transaction, + transaction: refetched_transaction, + block_number: refetched_block.number, + transaction_index: refetched_transaction.index, + index: 1, + inserted_at: Timex.shift(now, hours: -1) + ) + + assert MigrationStatus.get_status(@migration_name) == nil + + ReindexBlocksWithStaleInternalTransactions.start_link([]) + + wait_for_results(fn -> + Repo.one!(from(ms in MigrationStatus, where: ms.migration_name == ^@migration_name and ms.status == "completed")) + end) + + stale_block_number = stale_block.number + assert [%{block_number: ^stale_block_number}] = Repo.all(DeleteQueue) + + assert Block |> where([b], b.number == ^stale_block_number) |> Repo.all() |> Enum.all?(& &1.refetch_needed) + + refute Block + |> where([b], b.number in ^[uncle_block.number, refetched_block.number]) + |> Repo.all() + |> Enum.any?(& &1.refetch_needed) + end + + test "completes without non-consensus blocks" do + block = insert(:block) + transaction = :transaction |> insert() |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + block_number: block.number, + transaction_index: transaction.index, + index: 1, + inserted_at: Timex.shift(Timex.now(), hours: -1) + ) + + ReindexBlocksWithStaleInternalTransactions.start_link([]) + + wait_for_results(fn -> + Repo.one!(from(ms in MigrationStatus, where: ms.migration_name == ^@migration_name and ms.status == "completed")) + end) + + assert Repo.all(DeleteQueue) == [] + refute Repo.reload!(block).refetch_needed + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 6f9a5e1dac0..70d69714adc 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -888,6 +888,13 @@ config :explorer, Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers, ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_CONCURRENCY", 1), timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_TIMEOUT", "0s") +config :explorer, Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions, + batch_size: + ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_BATCH_SIZE", 1000), + concurrency: + ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_CONCURRENCY", 1), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_TIMEOUT", "0s") + config :explorer, Explorer.Migrator.RestoreOmittedWETHTransfers, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_CONCURRENCY", 5), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_BATCH_SIZE", 50), diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 111a9c6b657..1de9388a643 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -608,6 +608,9 @@ ACCOUNT_REDIS_URL=redis://redis-db:6379 # MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_BATCH_SIZE= # MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_CONCURRENCY= # MIGRATION_REINDEX_BLOCKS_WITH_UNCATALOGED_TOKEN_TRANSFERS_TIMEOUT= +# MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_BATCH_SIZE= +# MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_CONCURRENCY= +# MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_TIMEOUT= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_BATCH_SIZE= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_CONCURRENCY= # MIGRATION_ARBITRUM_DA_RECORDS_NORMALIZATION_BATCH_SIZE= From 796c680c6d9acddcbe3e4a130372fee58330069c Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 1 Oct 2026 14:02:37 +0300 Subject: [PATCH 73/82] ex_abi bump: 0.8.4 -> 0.8.5 --- apps/ethereum_jsonrpc/mix.exs | 2 +- mix.exs | 1 + mix.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 867895f76aa..164ae266ac2 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -80,7 +80,7 @@ defmodule EthereumJSONRPC.MixProject do # Convert unix timestamps in JSONRPC to DateTimes {:timex, "~> 3.7.1"}, # Encode/decode function names and arguments - {:ex_abi, "~> 0.8"}, + {:ex_abi, git: "https://github.com/poanetwork/ex_abi", tag: "0.8.5"}, # `:verify_fun` for `Socket.Web.connect` {:ssl_verify_fun, "~> 1.1"}, # `EthereumJSONRPC.WebSocket` diff --git a/mix.exs b/mix.exs index 6a49020f4ec..4403deac71a 100644 --- a/mix.exs +++ b/mix.exs @@ -104,6 +104,7 @@ defmodule BlockScout.Mixfile do # and cannot be accessed from applications inside the apps folder defp deps do [ + {:ex_abi, git: "https://github.com/poanetwork/ex_abi", tag: "0.8.5"}, {: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.21.0"}, diff --git a/mix.lock b/mix.lock index 4fdd0293ae1..16da432fbb4 100644 --- a/mix.lock +++ b/mix.lock @@ -51,7 +51,7 @@ "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "evil_crc32c": {:hex, :evil_crc32c, "0.2.9", "9e4082418c4cc27c88869c1773f114f15a9257121ebb019037bea60c75c601d2", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "86ba14f8aad0fe55eda0b57e79e3d8dffe39ea326b753eac12463ae2bec757bf"}, "evision": {:hex, :evision, "0.2.17", "0d5aed4fdb6945f100642cfd41531d187963af984c60ec367719106dda7ed864", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "565bc8700d23cd2429444387a1cf0e53fe7a85892e9276ce62a9cef23350c11f"}, - "ex_abi": {:hex, :ex_abi, "0.8.4", "32ca4212322d2ada9c0b6fa1ee3617c0dba3aa246f4e76039892bd04febfd725", [:mix], [{:ex_keccak, "~> 0.7.8", [hex: :ex_keccak, repo: "hexpm", optional: true]}], "hexpm", "69e5b73f48b0a4feca4dac70e0d220e3361f98f483345d2b2331ee79e72b460b"}, + "ex_abi": {:git, "https://github.com/poanetwork/ex_abi", "ef40110d78ca01dbad692b546493d5a58bf603a1", [tag: "0.8.5"]}, "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, "ex_brotli": {:hex, :ex_brotli, "0.5.0", "573645db5201317b6176b8858b668ea4ca89dc5c21852e84b9867579d483c220", [:mix], [{:phoenix, ">= 0.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8447d98d51f8f312629fd38619d4f564507dcf3a03d175c3f8f4ddf98e46dd92"}, From 435e8535ddda2488449ae91c0e9e4193e7633307 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 1 Oct 2026 16:40:48 +0300 Subject: [PATCH 74/82] Remove deploy_e2e step from docker image publishing --- .../publish-docker-image-every-push.yml | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/publish-docker-image-every-push.yml b/.github/workflows/publish-docker-image-every-push.yml index 595600db805..e8dc2bbf3e9 100644 --- a/.github/workflows/publish-docker-image-every-push.yml +++ b/.github/workflows/publish-docker-image-every-push.yml @@ -92,30 +92,3 @@ jobs: SESSION_COOKIE_DOMAIN=k8s-dev.blockscout.com BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - deploy_e2e: - needs: push_to_registry - runs-on: ubuntu-latest - permissions: write-all - steps: - - name: Get Vault credentials - id: retrieve-vault-secrets - uses: hashicorp/vault-action@v2.4.1 - with: - url: https://vault.k8s.blockscout.com - role: ci-dev - path: github-jwt - method: jwt - tlsSkipVerify: false - exportToken: true - secrets: | - ci/data/dev/github token | WORKFLOW_TRIGGER_TOKEN ; - - name: Trigger deploy - uses: convictional/trigger-workflow-and-wait@v1.6.1 - with: - owner: blockscout - repo: deployment-values - github_token: ${{env.WORKFLOW_TRIGGER_TOKEN}} - workflow_file_name: deploy_blockscout.yaml - ref: main - wait_interval: 30 - client_payload: '{ "instance": "dev", "globalEnv": "e2e"}' From 1d11ae89d43366dcdad5e325175ba1d453c78c2c Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 1 Oct 2026 17:40:20 +0300 Subject: [PATCH 75/82] fix: exclude non-traceable blocks from coin balance catchup init query (#14889) --- .../explorer/chain/address/coin_balance.ex | 7 ++- .../import/runner/internal_transactions.ex | 18 +------ apps/explorer/lib/explorer/helper.ex | 43 ++++++++++++++++ .../chain/address/coin_balance_test.exs | 50 +++++++++++++++++++ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/address/coin_balance.ex b/apps/explorer/lib/explorer/chain/address/coin_balance.ex index 6c4538c77e8..6933b82bb9a 100644 --- a/apps/explorer/lib/explorer/chain/address/coin_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/coin_balance.ex @@ -7,7 +7,7 @@ defmodule Explorer.Chain.Address.CoinBalance do use Explorer.Schema - alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.{Chain, Helper, PagingOptions, Repo} alias Explorer.Chain.{Address, Block, Hash, InternalTransaction, Transaction, Wei} alias Explorer.Chain.Address.CoinBalance @@ -129,10 +129,15 @@ defmodule Explorer.Chain.Address.CoinBalance do ) :: {:ok, accumulator} when accumulator: term() def stream_unfetched_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + # Balances at non-traceable block numbers are skipped by the catch-up fetcher + # (see `Indexer.Fetcher.CoinBalance.Helper.run/3`), so they must be excluded + # here as well. Otherwise they are never marked as fetched and, once their + # count reaches the init query limit, they shadow all fetchable balances. query = from( balance in CoinBalance, where: is_nil(balance.value_fetched_at), + where: ^Helper.traceable_block_numbers_dynamic(:block_number), select: %{address_hash: balance.address_hash, block_number: balance.block_number} ) diff --git a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex index 233bd2fddf9..05871720e7f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex @@ -9,7 +9,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do alias Ecto.Adapters.SQL alias Ecto.{Changeset, Multi, Repo} - alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain.{ Address, @@ -24,6 +23,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Import.Runner + alias Explorer.Helper alias Explorer.Migrator.DeleteZeroValueInternalTransactions alias Explorer.Prometheus.Instrumenter alias Explorer.Repo, as: ExplorerRepo @@ -1026,20 +1026,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end defp traceable_blocks_dynamic_query do - if RangesHelper.trace_ranges_present?() do - block_ranges = RangesHelper.get_trace_block_ranges() - - Enum.reduce(block_ranges, dynamic([_], false), fn - _from.._to//_ = range, acc -> - lower = min(range.first, range.last) - upper = max(range.first, range.last) - dynamic([block], ^acc or (block.number >= ^lower and block.number <= ^upper)) - - num_to_latest, acc -> - dynamic([block], ^acc or block.number >= ^num_to_latest) - end) - else - dynamic([_], true) - end + Helper.traceable_block_numbers_dynamic(:number) end end diff --git a/apps/explorer/lib/explorer/helper.ex b/apps/explorer/lib/explorer/helper.ex index 2ad1c6f6649..82ab76c54b5 100644 --- a/apps/explorer/lib/explorer/helper.ex +++ b/apps/explorer/lib/explorer/helper.ex @@ -8,6 +8,7 @@ defmodule Explorer.Helper do import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] alias ABI.TypeDecoder + alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain alias Explorer.Chain.{Address.Reputation, Address.ScamBadgeToAddress, Data, Hash, Wei} alias Redix.URI, as: RedixURI @@ -852,4 +853,46 @@ defmodule Explorer.Helper do [sentinel: [sentinels: sentinel_urls, group: sentinel_master_name]] |> Keyword.merge(ssl_opts) end end + + @doc """ + Builds an `Ecto.Query.dynamic/2` expression restricting the first query + binding's `block_number_field` to the traceable block ranges configured via + `TRACE_BLOCK_RANGES` (or `TRACE_FIRST_BLOCK`/`TRACE_LAST_BLOCK`). + + Rows outside the traceable ranges cannot be fetched by trace-dependent + fetchers (internal transactions, catch-up coin balances), so excluding them + on the SQL side prevents them from occupying the fetchers' limited init + queries forever. + + When no trace ranges are configured, the returned expression is always true. + + ## Parameters + - `block_number_field`: The name of the block number field on the first + binding of the query, e.g. `:number` or `:block_number`. + + ## Returns + - An `Ecto.Query.dynamic/2` expression to be used in a `where` clause. + """ + @spec traceable_block_numbers_dynamic(atom()) :: Ecto.Query.dynamic_expr() + def traceable_block_numbers_dynamic(block_number_field) when is_atom(block_number_field) do + if RangesHelper.trace_ranges_present?() do + RangesHelper.get_trace_block_ranges() + |> Enum.reduce(dynamic([_], false), fn + _from.._to//_ = range, acc -> + lower = min(range.first, range.last) + upper = max(range.first, range.last) + + dynamic( + [row], + ^acc or + (field(row, ^block_number_field) >= ^lower and field(row, ^block_number_field) <= ^upper) + ) + + num_to_latest, acc -> + dynamic([row], ^acc or field(row, ^block_number_field) >= ^num_to_latest) + end) + else + dynamic([_], true) + end + end end diff --git a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs index ab76e3fdb1d..4e0fda4d730 100644 --- a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs @@ -315,6 +315,56 @@ defmodule Explorer.Chain.Address.CoinBalanceTest do assert {:ok, []} = CoinBalance.stream_unfetched_balances([], &[&1 | &2]) end + test "does not return balances at block numbers below TRACE_FIRST_BLOCK" do + original_config = Application.get_env(:indexer, :trace_block_ranges) + on_exit(fn -> Application.put_env(:indexer, :trace_block_ranges, original_config) end) + + %Address{hash: genesis_address_hash} = insert(:address) + %Address{hash: traceable_address_hash} = insert(:address) + insert(:unfetched_balance, address_hash: genesis_address_hash, block_number: 0) + insert(:unfetched_balance, address_hash: traceable_address_hash, block_number: 1) + + Application.put_env(:indexer, :trace_block_ranges, "1..latest") + + assert {:ok, [%{address_hash: ^traceable_address_hash, block_number: 1}]} = + CoinBalance.stream_unfetched_balances([], &[&1 | &2]) + + assert {:ok, [%{address_hash: ^traceable_address_hash, block_number: 1}]} = + CoinBalance.stream_unfetched_balances([], &[&1 | &2], true) + end + + test "returns only balances within TRACE_BLOCK_RANGES" do + original_config = Application.get_env(:indexer, :trace_block_ranges) + on_exit(fn -> Application.put_env(:indexer, :trace_block_ranges, original_config) end) + + %Address{hash: address_hash} = insert(:address) + + for block_number <- [0, 5, 10, 15, 20, 25, 30] do + insert(:unfetched_balance, address_hash: address_hash, block_number: block_number) + end + + Application.put_env(:indexer, :trace_block_ranges, "5..10,20..latest") + + {:ok, balance_fields_list} = CoinBalance.stream_unfetched_balances([], &[&1 | &2]) + + assert balance_fields_list |> Enum.map(& &1.block_number) |> Enum.sort() == [5, 10, 20, 25, 30] + end + + test "returns all unfetched balances when trace ranges are not configured" do + original_config = Application.get_env(:indexer, :trace_block_ranges) + on_exit(fn -> Application.put_env(:indexer, :trace_block_ranges, original_config) end) + + %Address{hash: address_hash} = insert(:address) + insert(:unfetched_balance, address_hash: address_hash, block_number: 0) + insert(:unfetched_balance, address_hash: address_hash, block_number: 1) + + Application.put_env(:indexer, :trace_block_ranges, "0..latest") + + {:ok, balance_fields_list} = CoinBalance.stream_unfetched_balances([], &[&1 | &2]) + + assert balance_fields_list |> Enum.map(& &1.block_number) |> Enum.sort() == [0, 1] + end + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> "does not return `t:Explorer.Chain.Transaction.t/0` `from_address_hash`" do %Address{hash: from_address_hash} = from_address = insert(:address) From 86519531c030632c6090bd002e44e3e52911464c Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 1 Oct 2026 17:41:59 +0300 Subject: [PATCH 76/82] Pin ex_abi 0.8.5 from pm --- apps/ethereum_jsonrpc/mix.exs | 2 +- mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 164ae266ac2..867895f76aa 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -80,7 +80,7 @@ defmodule EthereumJSONRPC.MixProject do # Convert unix timestamps in JSONRPC to DateTimes {:timex, "~> 3.7.1"}, # Encode/decode function names and arguments - {:ex_abi, git: "https://github.com/poanetwork/ex_abi", tag: "0.8.5"}, + {:ex_abi, "~> 0.8"}, # `:verify_fun` for `Socket.Web.connect` {:ssl_verify_fun, "~> 1.1"}, # `EthereumJSONRPC.WebSocket` diff --git a/mix.exs b/mix.exs index 4403deac71a..398a3cac4d3 100644 --- a/mix.exs +++ b/mix.exs @@ -104,7 +104,7 @@ defmodule BlockScout.Mixfile do # and cannot be accessed from applications inside the apps folder defp deps do [ - {:ex_abi, git: "https://github.com/poanetwork/ex_abi", tag: "0.8.5"}, + {:ex_abi, "~> 0.8.5"}, {: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.21.0"}, diff --git a/mix.lock b/mix.lock index 16da432fbb4..8f8cf2bffd0 100644 --- a/mix.lock +++ b/mix.lock @@ -51,7 +51,7 @@ "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "evil_crc32c": {:hex, :evil_crc32c, "0.2.9", "9e4082418c4cc27c88869c1773f114f15a9257121ebb019037bea60c75c601d2", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "86ba14f8aad0fe55eda0b57e79e3d8dffe39ea326b753eac12463ae2bec757bf"}, "evision": {:hex, :evision, "0.2.17", "0d5aed4fdb6945f100642cfd41531d187963af984c60ec367719106dda7ed864", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "565bc8700d23cd2429444387a1cf0e53fe7a85892e9276ce62a9cef23350c11f"}, - "ex_abi": {:git, "https://github.com/poanetwork/ex_abi", "ef40110d78ca01dbad692b546493d5a58bf603a1", [tag: "0.8.5"]}, + "ex_abi": {:hex, :ex_abi, "0.8.5", "1f48b716769cf0443fd87de05ba70f4bc9f484a86de63bb139102fd241781158", [:mix], [{:ex_keccak, "~> 0.7.8", [hex: :ex_keccak, repo: "hexpm", optional: true]}], "hexpm", "bb8ca507d9cc8e96c06746f2bfec7e4d06d40f62fefc4e16a996e3a7de0a61d9"}, "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, "ex_brotli": {:hex, :ex_brotli, "0.5.0", "573645db5201317b6176b8858b668ea4ca89dc5c21852e84b9867579d483c220", [:mix], [{:phoenix, ">= 0.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8447d98d51f8f312629fd38619d4f564507dcf3a03d175c3f8f4ddf98e46dd92"}, From e78c9158ab22c8f20ee5351cb53d3f1b921c6f1e Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:51:46 +0400 Subject: [PATCH 77/82] fix: Missing blocks clearing on failed import (#14888) --- apps/explorer/lib/explorer/chain/block.ex | 38 ++++++-- .../lib/explorer/chain/block_number_helper.ex | 15 +++ apps/explorer/lib/explorer/chain/import.ex | 91 +++++++++++++------ .../explorer/utility/missing_block_range.ex | 89 ++++++++++++++++++ .../test/explorer/chain/block_test.exs | 16 ++++ .../test/explorer/chain/import_test.exs | 45 +++++++++ .../utility/missing_block_range_test.exs | 83 ++++++++++++++++- .../lib/indexer/block/catchup/fetcher.ex | 2 +- .../lib/indexer/block/realtime/fetcher.ex | 2 +- 9 files changed, 345 insertions(+), 36 deletions(-) diff --git a/apps/explorer/lib/explorer/chain/block.ex b/apps/explorer/lib/explorer/chain/block.ex index 53b4cc58138..cc46861395a 100644 --- a/apps/explorer/lib/explorer/chain/block.ex +++ b/apps/explorer/lib/explorer/chain/block.ex @@ -753,9 +753,35 @@ defmodule Explorer.Chain.Block do @spec set_refetch_needed(integer | [integer]) :: :ok def set_refetch_needed(block_numbers) when is_list(block_numbers) do - query = - from(block in Block, - where: block.number in ^block_numbers, + Block + |> where([block], block.number in ^block_numbers) + |> set_refetch_needed_by_query() + + :ok + end + + def set_refetch_needed(block_number), do: set_refetch_needed([block_number]) + + @doc """ + Marks the consensus blocks with the given numbers, except for the blocks with the + given hashes, with `refetch_needed` and adds their numbers to the missing block ranges. + + Used when an import of the blocks with these numbers and hashes has failed before + storing them: the consensus blocks stored at these numbers may belong to a stale fork. + + ## Returns + - The numbers of the marked blocks + """ + @spec set_refetch_needed_for_other_consensus_blocks([integer], [Hash.Full.t() | String.t()]) :: [integer] + def set_refetch_needed_for_other_consensus_blocks(block_numbers, hashes) do + Block + |> where([block], block.number in ^block_numbers and block.consensus and block.hash not in ^hashes) + |> set_refetch_needed_by_query() + end + + defp set_refetch_needed_by_query(query) do + locked_query = + from(block in query, # Enforce Block ShareLocks order (see docs: sharelocks.md) order_by: [asc: block.hash], lock: "FOR NO KEY UPDATE" @@ -763,17 +789,15 @@ defmodule Explorer.Chain.Block do {_count, updated_numbers} = Repo.update_all( - from(b in Block, join: s in subquery(query), on: b.hash == s.hash, select: b.number), + from(b in Block, join: s in subquery(locked_query), on: b.hash == s.hash, select: b.number), set: [refetch_needed: true, updated_at: Timex.now()] ) MissingBlockRange.add_ranges_by_block_numbers(updated_numbers) - :ok + updated_numbers end - def set_refetch_needed(block_number), do: set_refetch_needed([block_number]) - # Re-enqueues the blocks, their transactions, and the addresses they touched for export to # the Multichain Service database. # diff --git a/apps/explorer/lib/explorer/chain/block_number_helper.ex b/apps/explorer/lib/explorer/chain/block_number_helper.ex index 87b90dcd7f3..3a5605fb7cb 100644 --- a/apps/explorer/lib/explorer/chain/block_number_helper.ex +++ b/apps/explorer/lib/explorer/chain/block_number_helper.ex @@ -63,6 +63,21 @@ defmodule Explorer.Chain.BlockNumberHelper do @spec previous_block_numbers([non_neg_integer()]) :: %{non_neg_integer() => non_neg_integer()} def previous_block_numbers(numbers), do: neighbor_block_numbers(numbers, :previous) + @doc """ + Returns the next block numbers of several blocks in the blockchain sequence. + + A batch counterpart of `next_block_number/1`: for Filecoin chain type, the null rounds + are looked up for all the blocks at once. + + ## Parameters + - `numbers`: The reference block heights + + ## Returns + - A map from each reference block height to its next block number + """ + @spec next_block_numbers([non_neg_integer()]) :: %{non_neg_integer() => non_neg_integer()} + def next_block_numbers(numbers), do: neighbor_block_numbers(numbers, :next) + @doc """ Returns the total count of null rounds in the blockchain. diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index ef063143302..b39d3e9d6b6 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -497,18 +497,6 @@ defmodule Explorer.Chain.Import do runner_to_changes_list |> runner_to_changes_list_to_multis(options) |> logged_import(options) - |> case do - {:ok, result} -> - {:ok, result} - - error -> - handle_partially_imported_blocks(options) - error - end - rescue - exception -> - handle_partially_imported_blocks(options) - reraise exception, __STACKTRACE__ end defp logged_import(multis_batches, options) when is_list(multis_batches) and is_map(options) do @@ -520,11 +508,14 @@ defmodule Explorer.Chain.Import do defp import_batch_transactions(multis_batches, options) when is_list(multis_batches) and is_map(options) do Enum.reduce_while(multis_batches, {:ok, %{}}, fn multis, {:ok, acc_changes} -> multis - |> run_parallel_multis(options) - |> handle_task_results(acc_changes) + |> import_multis_batch(acc_changes, options) |> case do - {:ok, changes} -> {:cont, {:ok, changes}} - error -> {:halt, error} + {:ok, changes} -> + {:cont, {:ok, changes}} + + error -> + handle_failed_import(options, acc_changes) + {:halt, error} end end) rescue @@ -535,6 +526,16 @@ defmodule Explorer.Chain.Import do end end + defp import_multis_batch(multis, acc_changes, options) do + multis + |> run_parallel_multis(options) + |> handle_task_results(acc_changes) + rescue + exception -> + handle_failed_import(options, acc_changes) + reraise exception, __STACKTRACE__ + end + defp run_parallel_multis(multis, options) do Task.async_stream(multis, fn multi -> import_transaction(multi, options) end, timeout: :infinity, @@ -583,24 +584,62 @@ defmodule Explorer.Chain.Import do end) end - defp handle_partially_imported_blocks(%{blocks: %{params: blocks_params}} = options) do + defp handle_failed_import(options, %{blocks: _}), do: handle_partially_imported_blocks(options) + defp handle_failed_import(options, _acc_changes), do: handle_not_imported_blocks(options) + + defp handle_partially_imported_blocks(%{blocks: %{params: blocks_params}}) do + retry_on_exception( + fn -> + block_numbers = blocks_params |> Enum.map(& &1.number) |> Enum.uniq() + Block.set_refetch_needed(block_numbers) + Import.Runner.Blocks.process_blocks_consensus(blocks_params) + + Logger.warning("Set refetch_needed for partially imported block because of error: #{inspect(block_numbers)}") + end, + "Unable to set refetch_needed for partially imported block because of error" + ) + end + + defp handle_partially_imported_blocks(_options), do: :ok + + defp handle_not_imported_blocks(%{blocks: %{params: blocks_params}}) do + blocks_params + |> Enum.filter(& &1[:consensus]) + |> mark_possibly_stale_blocks() + end + + defp handle_not_imported_blocks(_options), do: :ok + + defp mark_possibly_stale_blocks([]), do: :ok + + defp mark_possibly_stale_blocks(blocks_params) do block_numbers = blocks_params |> Enum.map(& &1.number) |> Enum.uniq() - Block.set_refetch_needed(block_numbers) - Import.Runner.Blocks.process_blocks_consensus(blocks_params) + hashes = Enum.map(blocks_params, & &1.hash) - Logger.warning("Set refetch_needed for partially imported block because of error: #{inspect(block_numbers)}") + retry_on_exception( + fn -> + marked_numbers = Block.set_refetch_needed_for_other_consensus_blocks(block_numbers, hashes) + + if marked_numbers != [] do + Logger.warning( + "Set refetch_needed for possibly stale blocks because of failed import: #{inspect(marked_numbers)}" + ) + end + end, + "Unable to set refetch_needed for possibly stale blocks because of error" + ) + end + + defp retry_on_exception(fun, error_message) do + fun.() rescue exception -> - Logger.warning( - "Unable to set refetch_needed for partially imported block because of error: #{inspect(exception)}" - ) + Logger.warning("#{error_message}: #{inspect(exception)}") Process.sleep(Application.get_env(:indexer, :handle_partially_imported_block_interval) || 1000) - handle_partially_imported_blocks(options) + retry_on_exception(fun, error_message) end - defp handle_partially_imported_blocks(_options), do: :ok - @spec timestamps() :: timestamps def timestamps do now = DateTime.utc_now() diff --git a/apps/explorer/lib/explorer/utility/missing_block_range.ex b/apps/explorer/lib/explorer/utility/missing_block_range.ex index 724edcd66f3..c8517e919b6 100644 --- a/apps/explorer/lib/explorer/utility/missing_block_range.ex +++ b/apps/explorer/lib/explorer/utility/missing_block_range.ex @@ -7,6 +7,7 @@ defmodule Explorer.Utility.MissingBlockRange do use Explorer.Schema alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain alias Explorer.Chain.{Block, BlockNumberHelper} alias Explorer.Repo @@ -331,6 +332,94 @@ defmodule Explorer.Utility.MissingBlockRange do Enum.map(batch, &delete_range/1) end + @doc """ + Removes the numbers of the given ranges that are indexed, i.e. have a consensus + block that is not marked with `refetch_needed`, and keeps the rest of them. + + Must be used instead of `clear_batch/1` after a successful import: a block can be + invalidated while its import is still in flight (e.g. a concurrent import removes + its consensus, or it is marked with `refetch_needed`), and the invalidating side + adds its number to the missing ranges. Removing such a number unconditionally + leaves the block unindexed until `Indexer.Block.Catchup.MissingRangesCollector` + passes over it again. + + Only the ranges locked before the blocks are checked are modified, and the ranges + are not queried again after the check. So a range added concurrently by + `save_range/2` is never removed by mistake: if it was committed before the lock, + it is locked and its blocks are checked after that; otherwise it either waits for + this transaction (when it overlaps a locked range) or is a new row left intact. + + ## Parameters + - `batch`: A list of `Range` structs to clear + + ## Returns + - A list of transaction results, one per range + """ + @spec clear_batch_if_indexed([Range.t()]) :: [{:ok, :ok} | {:error, any()}] + def clear_batch_if_indexed(batch) do + Enum.map(batch, &delete_range_if_indexed/1) + end + + defp delete_range_if_indexed(from..to//_) do + min_number = min(from, to) + max_number = max(from, to) + + Repo.transaction(fn -> + {locked_ranges, _lower_range, _higher_range} = lock_related_ranges(max_number, min_number) + remove_indexed_numbers(locked_ranges, min_number..max_number//1) + end) + end + + defp remove_indexed_numbers([], _range), do: :ok + + defp remove_indexed_numbers(locked_ranges, range) do + missing_numbers = + range + |> Chain.missing_block_number_ranges() + |> Enum.flat_map(&Enum.to_list/1) + |> MapSet.new() + + indexed_ranges = + range + |> Enum.reject(&MapSet.member?(missing_numbers, &1)) + |> numbers_to_ranges() + + remove_from_locked_ranges(locked_ranges, indexed_ranges) + end + + defp remove_from_locked_ranges(_locked_ranges, []), do: :ok + + defp remove_from_locked_ranges(locked_ranges, removed_ranges) do + previous_numbers = removed_ranges |> Enum.map(& &1.first) |> BlockNumberHelper.previous_block_numbers() + next_numbers = removed_ranges |> Enum.map(& &1.last) |> BlockNumberHelper.next_block_numbers() + + Enum.each(locked_ranges, &remove_from_locked_range(&1, removed_ranges, previous_numbers, next_numbers)) + end + + defp remove_from_locked_range(range, removed_ranges, previous_numbers, next_numbers) do + overlapping_ranges = Enum.filter(removed_ranges, &(&1.first <= range.from_number and &1.last >= range.to_number)) + + if overlapping_ranges != [] do + Repo.delete(range) + + range + |> remaining_parts(overlapping_ranges, previous_numbers, next_numbers) + |> Enum.each(fn {lower, upper} -> + insert_range(%{from_number: upper, to_number: lower, priority: range.priority}) + end) + end + end + + defp remaining_parts(range, overlapping_ranges, previous_numbers, next_numbers) do + {parts, last_part_start} = + Enum.reduce(overlapping_ranges, {[], range.to_number}, fn first..last//_, {parts, part_start} -> + parts = if part_start < first, do: [{part_start, previous_numbers[first]} | parts], else: parts + {parts, next_numbers[last]} + end) + + Enum.filter([{last_part_start, range.from_number} | parts], fn {lower, upper} -> lower <= upper end) + end + @doc """ Saves multiple block ranges to the missing blocks tracking system. diff --git a/apps/explorer/test/explorer/chain/block_test.exs b/apps/explorer/test/explorer/chain/block_test.exs index 1d2771e5ba3..566ce0221c3 100644 --- a/apps/explorer/test/explorer/chain/block_test.exs +++ b/apps/explorer/test/explorer/chain/block_test.exs @@ -12,6 +12,7 @@ defmodule Explorer.Chain.BlockTest do alias Explorer.Chain.MultichainSearchDb.{BalancesExportQueue, MainExportQueue} alias Explorer.MicroserviceInterfaces.MultichainSearch alias Explorer.PagingOptions + alias Explorer.Utility.MissingBlockRange alias Explorer.TestHelper describe "changeset/2" do @@ -586,4 +587,19 @@ defmodule Explorer.Chain.BlockTest do assert Repo.aggregate(BalancesExportQueue, :count, :id) == 0 end end + + describe "set_refetch_needed_for_other_consensus_blocks/2" do + test "marks only the consensus blocks with other hashes and adds their numbers to the missing ranges" do + other_block = insert(:block, number: 10) + non_consensus_block = insert(:block, number: 10, consensus: false) + given_block = insert(:block, number: 11) + + assert [10] = Block.set_refetch_needed_for_other_consensus_blocks([10, 11], [given_block.hash]) + + assert %Block{refetch_needed: true} = Repo.get(Block, other_block.hash) + assert %Block{refetch_needed: false} = Repo.get(Block, non_consensus_block.hash) + assert %Block{refetch_needed: false} = Repo.get(Block, given_block.hash) + assert [%MissingBlockRange{from_number: 10, to_number: 10}] = Repo.all(MissingBlockRange) + end + end end diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index fc1e1ae0d38..c217011eed6 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -389,6 +389,51 @@ defmodule Explorer.Chain.ImportTest do assert %{consensus: true, refetch_needed: true} = Repo.one(Block) end + test "stored consensus blocks keep consensus and are refetched if the blocks transaction fails" do + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :auto) + + on_exit(fn -> + Repo.delete_all(MissingBlockRange) + Repo.delete_all(Block) + end) + + [block_params] = @import_data.blocks.params + %{hash: stored_block_hash} = stored_block_params = %{block_params | hash: block_hash()} + + assert {:ok, _} = Import.all(%{blocks: %{params: [stored_block_params]}}) + + # two consensus blocks at the same height make the blocks transaction fail + # on the `one_consensus_block_at_height` unique index + conflicting_blocks_params = [block_params, %{block_params | hash: block_hash(), parent_hash: block_hash()}] + + assert_raise(Postgrex.Error, fn -> Import.all(%{blocks: %{params: conflicting_blocks_params}}) end) + + assert [%Block{hash: ^stored_block_hash, consensus: true, refetch_needed: true}] = Repo.all(Block) + assert [%MissingBlockRange{from_number: 37, to_number: 37}] = Repo.all(MissingBlockRange) + end + + test "stored blocks from a failed blocks transaction are not refetched" do + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :auto) + + on_exit(fn -> + Repo.delete_all(MissingBlockRange) + Repo.delete_all(Block) + end) + + [block_params] = @import_data.blocks.params + {:ok, stored_block_hash} = Hash.Full.cast(block_params.hash) + + assert {:ok, _} = Import.all(%{blocks: %{params: [block_params]}}) + + # the stored block is imported again together with another consensus block at the same height + conflicting_blocks_params = [block_params, %{block_params | hash: block_hash(), parent_hash: block_hash()}] + + assert_raise(Postgrex.Error, fn -> Import.all(%{blocks: %{params: conflicting_blocks_params}}) end) + + assert [%Block{hash: ^stored_block_hash, consensus: true, refetch_needed: false}] = Repo.all(Block) + assert [] = Repo.all(MissingBlockRange) + end + test "inserts a token_balance" do params = %{ addresses: %{ diff --git a/apps/explorer/test/explorer/utility/missing_block_range_test.exs b/apps/explorer/test/explorer/utility/missing_block_range_test.exs index 2b6ddd503cb..5d180eaf065 100644 --- a/apps/explorer/test/explorer/utility/missing_block_range_test.exs +++ b/apps/explorer/test/explorer/utility/missing_block_range_test.exs @@ -2,8 +2,11 @@ defmodule Explorer.Utility.MissingBlockRangeTest do use ExUnit.Case, async: true - alias Explorer.Utility.MissingBlockRange + import Explorer.Factory + + alias Explorer.Chain.Block alias Explorer.Repo + alias Explorer.Utility.MissingBlockRange describe "add_ranges_by_block_numbers/2" do setup do @@ -482,4 +485,82 @@ defmodule Explorer.Utility.MissingBlockRangeTest do end) end end + + describe "clear_batch_if_indexed/1" do + setup do + # blocks are inserted here, so unlike the rest of the module these tests + # are run in a sandbox transaction instead of committing to the database + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Repo.delete_all(MissingBlockRange) + + :ok + end + + test "clears only the numbers of consensus blocks not marked with refetch_needed" do + Repo.insert!(%MissingBlockRange{from_number: 10, to_number: 1, priority: nil}) + + insert(:block, number: 3) + insert(:block, number: 4) + insert(:block, number: 5) + insert(:block, number: 6, refetch_needed: true) + insert(:block, number: 7, consensus: false) + + MissingBlockRange.clear_batch_if_indexed([8..3//-1]) + + assert [%{from_number: 10, to_number: 6}, %{from_number: 2, to_number: 1}] = sorted_ranges() + end + + test "keeps the not indexed numbers between the indexed ones" do + Repo.insert!(%MissingBlockRange{from_number: 10, to_number: 1, priority: nil}) + + insert(:block, number: 2) + insert(:block, number: 3) + insert(:block, number: 6) + insert(:block, number: 7) + + MissingBlockRange.clear_batch_if_indexed([1..10]) + + assert [%{from_number: 10, to_number: 8}, %{from_number: 5, to_number: 4}, %{from_number: 1, to_number: 1}] = + sorted_ranges() + end + + test "clears the numbers at the lower bound of the block ranges" do + Repo.insert!(%MissingBlockRange{from_number: 3, to_number: 0, priority: nil}) + + Enum.each(0..3, &insert(:block, number: &1)) + + MissingBlockRange.clear_batch_if_indexed([0..3]) + + assert [] = sorted_ranges() + end + + test "keeps the priority of the not indexed numbers" do + Repo.insert!(%MissingBlockRange{from_number: 5, to_number: 1, priority: 1}) + + insert(:block, number: 2) + insert(:block, number: 3) + + MissingBlockRange.clear_batch_if_indexed([1..5]) + + assert [%{from_number: 5, to_number: 4, priority: 1}, %{from_number: 1, to_number: 1, priority: 1}] = + sorted_ranges() + end + + test "keeps a number invalidated after its block has been imported" do + insert(:block, number: 42) + + # e.g. the failure handler of a concurrent import of the same height + Block.set_refetch_needed([42]) + + MissingBlockRange.clear_batch_if_indexed([42..42]) + + assert [%{from_number: 42, to_number: 42}] = sorted_ranges() + end + end + + defp sorted_ranges do + MissingBlockRange + |> Repo.all() + |> Enum.sort_by(& &1.from_number, :desc) + end end diff --git a/apps/indexer/lib/indexer/block/catchup/fetcher.ex b/apps/indexer/lib/indexer/block/catchup/fetcher.ex index c24da994cbf..36f861b45aa 100644 --- a/apps/indexer/lib/indexer/block/catchup/fetcher.ex +++ b/apps/indexer/lib/indexer/block/catchup/fetcher.ex @@ -242,7 +242,7 @@ defmodule Indexer.Block.Catchup.Fetcher do acc end) |> numbers_to_ranges() - |> MissingBlockRange.clear_batch() + |> MissingBlockRange.clear_batch_if_indexed() end # Lotus reports a null round either with the bare message (older versions) or with diff --git a/apps/indexer/lib/indexer/block/realtime/fetcher.ex b/apps/indexer/lib/indexer/block/realtime/fetcher.ex index 61b31fa6d32..68921edc9d0 100644 --- a/apps/indexer/lib/indexer/block/realtime/fetcher.ex +++ b/apps/indexer/lib/indexer/block/realtime/fetcher.ex @@ -418,7 +418,7 @@ defmodule Indexer.Block.Realtime.Fetcher do case result do {:ok, %{inserted: inserted, errors: []}} -> log_import_timings(inserted, fetch_duration, time_before) - MissingBlockRange.clear_batch([block_number_to_fetch..block_number_to_fetch]) + MissingBlockRange.clear_batch_if_indexed([block_number_to_fetch..block_number_to_fetch]) Logger.debug("Fetched and imported.") {:ok, %{inserted: _, errors: [_ | _] = errors}} -> From 69347f37d7135946206458678f9908e646e194d6 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 2 Oct 2026 11:48:37 +0300 Subject: [PATCH 78/82] fix: stop internal transactions import from upserting existing addresses (#14890) --- .../indexer/fetcher/internal_transaction.ex | 21 ++- .../fetcher/internal_transaction_test.exs | 171 +++++++++++++++++- 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex index bb9d119feba..4f7d5920402 100644 --- a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex @@ -397,9 +397,9 @@ defmodule Indexer.Fetcher.InternalTransaction do internal_transactions_params_marked = mark_failed_transactions(internal_transactions_params) addresses_params = - Addresses.extract_addresses(%{ - internal_transactions: internal_transactions_params_marked - }) + %{internal_transactions: internal_transactions_params_marked} + |> Addresses.extract_addresses() + |> drop_block_number_from_participant_addresses() address_coin_balances_params_set = AddressCoinBalances.params_set(%{internal_transactions_params: internal_transactions_params_marked}) @@ -479,6 +479,21 @@ defmodule Indexer.Fetcher.InternalTransaction do end end + # Internal transactions only reference their participants, they carry no balances. Bumping + # `fetched_coin_balance_block_number` for every touched address made the `addresses` import + # upsert (and row-lock) the same hot rows from every concurrent batch, serializing all importers + # on them. Without the block number the `Addresses` runner's pre-filter skips existing + # participants altogether, so only genuinely new addresses are inserted. Created contracts keep + # it because their `contract_code` must land on the row even when the address already exists + # (e.g. pre-funded CREATE2 wallets). Balance fetching is still driven by the + # `address_coin_balances` rows this import writes for every participant. + defp drop_block_number_from_participant_addresses(addresses_params) do + Enum.map(addresses_params, fn + %{contract_code: _} = created_contract_params -> created_contract_params + participant_params -> Map.delete(participant_params, :fetched_coin_balance_block_number) + end) + end + defp mark_failed_transactions(internal_transactions_params) do # we store reversed trace addresses for more efficient list head-tail decomposition in has_failed_parent? failed_parent_paths = diff --git a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs index 975e3e59a89..74ae809e0e9 100644 --- a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs @@ -28,7 +28,16 @@ defmodule Indexer.Fetcher.InternalTransactionTest do config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) - on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) + # The block factory sequence starts at 0, so whichever test inserts the first block of a run + # would hit the genesis check in `InternalTransaction.run/2` and issue an unexpected block + # fetch. Point the check at a block number no test uses. + trace_first_block = Application.get_env(:indexer, :trace_first_block) + Application.put_env(:indexer, :trace_first_block, -1) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) + Application.put_env(:indexer, :trace_first_block, trace_first_block) + end) end @moduletag [capture_log: true, no_geth: true] @@ -350,6 +359,166 @@ defmodule Indexer.Fetcher.InternalTransactionTest do assert Repo.exists?(from(i in Chain.InternalTransaction, where: i.block_number == ^block_number)) end + test "does not touch existing participant addresses but still stores created contracts", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block) + block_hash = block.hash + block_number = block.number + insert(:pending_block_operation, block_hash: block_hash, block_number: block_number) + + # a participant that already exists with an older balance snapshot must not be upserted + # (bumping its block number row-locks hot addresses from every concurrent batch) + %Chain.Address{hash: participant_hash} = + insert(:address, fetched_coin_balance: 100, fetched_coin_balance_block_number: block_number - 10) + + # a contract created in this block whose address already exists (e.g. a pre-funded CREATE2 + # wallet) must still receive its code + %Chain.Address{hash: created_contract_hash} = + insert(:address, fetched_coin_balance_block_number: block_number - 10) + + new_participant_hash_string = "0x1469b17ebf82fedf56f04109e5207bdc4554288c" + created_contract_code = "0x6080604052" + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + case Keyword.fetch!(json_rpc_named_arguments, :variant) do + EthereumJSONRPC.Nethermind -> + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [%{id: id, method: "trace_replayBlockTransactions"}], _options -> + {:ok, + [ + %{ + id: id, + result: [ + %{ + "output" => "0x", + "stateDiff" => nil, + "trace" => [ + %{ + "action" => %{ + "callType" => "call", + "from" => to_string(participant_hash), + "gas" => "0x8600", + "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", + "to" => new_participant_hash_string, + "value" => "0x174876e800" + }, + "result" => %{"gasUsed" => "0x7d37", "output" => "0x"}, + "subtraces" => 1, + "traceAddress" => [], + "type" => "call" + }, + %{ + "action" => %{ + "from" => to_string(participant_hash), + "gas" => "0x32dcf", + "init" => created_contract_code, + "value" => "0x0" + }, + "result" => %{ + "address" => to_string(created_contract_hash), + "code" => created_contract_code, + "gasUsed" => "0xb08" + }, + "subtraces" => 0, + "traceAddress" => [0], + "type" => "create" + } + ], + "transactionHash" => to_string(transaction.hash), + "vmTrace" => nil + } + ] + } + ]} + end) + + EthereumJSONRPC.Geth -> + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [%{id: id, method: "debug_traceTransaction"}], _options -> + {:ok, + [ + %{ + id: id, + result: [ + %{ + "blockNumber" => block.number, + "transactionIndex" => 0, + "transactionHash" => to_string(transaction.hash), + "index" => 0, + "traceAddress" => [], + "type" => "call", + "callType" => "call", + "from" => to_string(participant_hash), + "to" => new_participant_hash_string, + "gas" => "0x8600", + "gasUsed" => "0x7d37", + "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", + "output" => "0x", + "value" => "0x174876e800" + }, + %{ + "blockNumber" => block.number, + "transactionIndex" => 0, + "transactionHash" => to_string(transaction.hash), + "index" => 1, + "traceAddress" => [0], + "type" => "create", + "from" => to_string(participant_hash), + "createdContractAddressHash" => to_string(created_contract_hash), + "createdContractCode" => created_contract_code, + "gas" => "0x32dcf", + "gasUsed" => "0xb08", + "init" => created_contract_code, + "value" => "0x0" + } + ] + } + ]} + end) + + variant_name -> + raise ArgumentError, "Unsupported variant name (#{variant_name})" + end + end + + CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + start_token_balance_fetcher(json_rpc_named_arguments) + + assert :ok == InternalTransaction.run([block_number], json_rpc_named_arguments) + + assert nil == Repo.get(PendingBlockOperation, block_hash) + + # existing participant: untouched + assert %Chain.Address{fetched_coin_balance_block_number: participant_block_number} = + Repo.get(Chain.Address, participant_hash) + + assert participant_block_number == block_number - 10 + + # created contract: code and block number stored even though the row already existed + assert %Chain.Address{contract_code: %Chain.Data{}, fetched_coin_balance_block_number: ^block_number} = + Repo.get(Chain.Address, created_contract_hash) + + # new participant: inserted, its balance snapshot will be set by the coin balance fetcher + {:ok, new_participant_hash} = Chain.string_to_address_hash(new_participant_hash_string) + + assert %Chain.Address{fetched_coin_balance_block_number: nil} = Repo.get(Chain.Address, new_participant_hash) + + # balance fetching is still driven by the coin balance rows written for every participant + assert Repo.exists?( + from(cb in Chain.Address.CoinBalance, + where: cb.address_hash == ^participant_hash and cb.block_number == ^block_number + ) + ) + + assert Repo.exists?( + from(cb in Chain.Address.CoinBalance, + where: cb.address_hash == ^new_participant_hash and cb.block_number == ^block_number + ) + ) + end + test "retries fetching by transactions when fetching by block returns incorrect number of top-level calls", %{ json_rpc_named_arguments: json_rpc_named_arguments } do From cd77624874f987b2a36b151028f3e2faa359ddd8 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 2 Oct 2026 11:49:20 +0300 Subject: [PATCH 79/82] fix: export only supplied coin balances to the multichain balances queue (#14885) --- apps/explorer/lib/explorer/chain.ex | 57 +++++++++++++++---- .../multichain_search.ex | 4 ++ apps/explorer/test/explorer/chain_test.exs | 52 +++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 48aa757f64c..bfa82500c46 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1331,19 +1331,16 @@ defmodule Explorer.Chain do def import(options) do case Import.all(options) do {:ok, imported} = result -> - assets_to_import = %{ - addresses: imported[:addresses] || [], + addresses_to_import = + MultichainSearch.filter_addresses_to_multichain_import(imported[:addresses] || [], options[:broadcast]) + + MultichainSearch.send_data_to_queue(%{ + addresses: addresses_to_import, blocks: imported[:blocks] || [], transactions: imported[:transactions] || [], - address_current_token_balances: imported[:address_current_token_balances] || [] - } - - filtered_addresses_to_import = - MultichainSearch.filter_addresses_to_multichain_import(assets_to_import[:addresses], options[:broadcast]) - - assets_to_import = Map.put(assets_to_import, :addresses, filtered_addresses_to_import) - - MultichainSearch.send_data_to_queue(assets_to_import) + address_current_token_balances: imported[:address_current_token_balances] || [], + address_coin_balances: coin_balances_to_export(addresses_to_import, options) + }) result @@ -1352,6 +1349,44 @@ defmodule Explorer.Chain do end end + # Selects the coin balances to hand to the Multichain balances export queue after an import. + # + # Without an explicit `:address_coin_balances` list, `MultichainSearch.send_data_to_queue/1` + # derives a "current balance" row from every imported address. Most imports only touch + # addresses without changing their balance (internal transactions, token transfers, logs, + # ...) and would re-enqueue hundreds of unchanged balances per batch, which floods the queue + # and serializes all importers on the same hot rows. So only the addresses whose + # `fetched_coin_balance` this import supplied (realtime block imports, coin balance fetchers, + # on-demand fetches) are exported, with the balance stored after the upsert. + @spec coin_balances_to_export([Address.t()], Import.all_options()) :: [ + %{address_hash: Hash.Address.t(), value: Wei.t() | nil} + ] + defp coin_balances_to_export([], _options), do: [] + + defp coin_balances_to_export(imported_addresses, options) do + hashes_with_supplied_balance = + options + |> get_in([:addresses, :params]) + |> List.wrap() + |> Enum.reduce(MapSet.new(), fn address_params, acc -> + with %{hash: hash, fetched_coin_balance: fetched_coin_balance} when not is_nil(fetched_coin_balance) <- + address_params, + {:ok, address_hash} <- Hash.Address.cast(hash) do + MapSet.put(acc, address_hash) + else + _ -> acc + end + end) + + if MapSet.size(hashes_with_supplied_balance) == 0 do + [] + else + imported_addresses + |> Enum.filter(&MapSet.member?(hashes_with_supplied_balance, &1.hash)) + |> Enum.map(&%{address_hash: &1.hash, value: &1.fetched_coin_balance}) + end + end + @doc """ The percentage of indexed blocks on the chain. diff --git a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex index e58c5d39a6c..d57b6d992d0 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex @@ -563,6 +563,10 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do - `:blocks` - List of block data. - `:transactions` - List of transaction data. - `:address_current_token_balances` - List of address token balance data. + - `:address_coin_balances` (optional) - List of `%{address_hash: Hash.Address.t(), value: Wei.t()}` + coin balances to export. When the key is absent, a current coin balance row is derived from + every entry of `:addresses`, so callers whose addresses were merely touched (and whose + balances did not change) should pass the list explicitly, possibly empty. ## Returns diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 425c742b235..2508e0ee25b 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -1583,6 +1583,58 @@ defmodule Explorer.ChainTest do # token balance value has been updated assert token_balance_export_item.value == %Explorer.Chain.Wei{value: Decimal.new(500)} end + + test "populates balances multichain export queue only with coin balances supplied by the import, if the multichain service is enabled" do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + multichain_configuration = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, multichain_configuration) + end) + + bypass = Bypass.open() + + Application.put_env( + :explorer, + Explorer.MicroserviceInterfaces.MultichainSearch, + Keyword.merge(multichain_configuration || [], + service_url: "http://localhost:#{bypass.port}", + addresses_chunk_size: 7_000 + ) + ) + + %Address{hash: address_hash} = + insert(:address, fetched_coin_balance: Decimal.new(100), fetched_coin_balance_block_number: 10) + + address_hash_string = to_string(address_hash) + + TestHelper.get_chain_id_mock() + + # An import that only touches the address (as internal transactions or token transfers + # imports do) leaves its balance unchanged and must not re-enqueue it + {:ok, %{addresses: [%Address{hash: ^address_hash}]}} = + Chain.import(%{ + addresses: %{params: [%{hash: address_hash_string, fetched_coin_balance_block_number: 11}]} + }) + + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 0 + + # An import that supplies the current balance is exported with the stored value + {:ok, %{addresses: [%Address{hash: ^address_hash}]}} = + Chain.import(%{ + addresses: %{ + params: [ + %{hash: address_hash_string, fetched_coin_balance: 250, fetched_coin_balance_block_number: 12} + ] + } + }) + + assert [%BalancesExportQueue{address_hash: ^address_hash, value: %Wei{value: value}}] = + Repo.all(BalancesExportQueue) + + assert Decimal.equal?(value, 250) + end end describe "list_blocks/2" do From 3185f58c63876987d642098b0eaa8a4d73367741 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 2 Oct 2026 14:27:44 +0300 Subject: [PATCH 80/82] fix: fix internal transactions fetcher test on zksync chain type (#14894) --- .../fetcher/internal_transaction_test.exs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs index 74ae809e0e9..32ff5af9349 100644 --- a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs @@ -496,9 +496,20 @@ defmodule Indexer.Fetcher.InternalTransactionTest do assert participant_block_number == block_number - 10 - # created contract: code and block number stored even though the row already existed - assert %Chain.Address{contract_code: %Chain.Data{}, fetched_coin_balance_block_number: ^block_number} = - Repo.get(Chain.Address, created_contract_hash) + created_contract = Repo.get(Chain.Address, created_contract_hash) + + if elem(@chain_identity, 0) == :zksync do + # zksync traces carry no created contract code (`Indexer.Transform.Addresses` does not + # extract it there), so the created contract is a plain participant: untouched as well + assert %Chain.Address{contract_code: nil, fetched_coin_balance_block_number: created_contract_block_number} = + created_contract + + assert created_contract_block_number == block_number - 10 + else + # created contract: code and block number stored even though the row already existed + assert %Chain.Address{contract_code: %Chain.Data{}, fetched_coin_balance_block_number: ^block_number} = + created_contract + end # new participant: inserted, its balance snapshot will be set by the coin balance fetcher {:ok, new_participant_hash} = Chain.string_to_address_hash(new_participant_hash_string) From a031e30cc6a59f50780c4782e4c44704999d69bc Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Fri, 2 Oct 2026 15:03:39 +0300 Subject: [PATCH 81/82] 11.3.3 --- .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 +- .../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 +- ...ocker-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 | 35 +++++++++++++++++++ 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, 91 insertions(+), 56 deletions(-) diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index bd132b4d9d8..30171d27d5a 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.3.2 + RELEASE_VERSION: 11.3.3 jobs: matrix-builder: diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index 6e7f4fd4ece..ec5606313a6 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.3.2 + RELEASE_VERSION: 11.3.3 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 3dff7d9086d..57e6632ec67 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.3.2 + RELEASE_VERSION: 11.3.3 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 7b750119590..236a816e068 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.3.2 + RELEASE_VERSION: 11.3.3 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 2ad9a2157fb..60dd7d0b3d9 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.3.2 + RELEASE_VERSION: 11.3.3 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 52de48a7707..06fc0e79b51 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.3.2 + RELEASE_VERSION: 11.3.3 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 777fb2e5314..3c343ecfda3 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.3.2 + RELEASE_VERSION: 11.3.3 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 ebecde4886d..8b884a6aba0 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.3.2 + RELEASE_VERSION: 11.3.3 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 9f4c39aecf4..9bddf57c785 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.3.2 + RELEASE_VERSION: 11.3.3 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 565ef88acf4..33ae2115691 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.3.2 + RELEASE_VERSION: 11.3.3 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 ee6810cf19e..7e5bdb340c7 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.3.2 + RELEASE_VERSION: 11.3.3 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 37cfc768856..8809869e14f 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.3.2 + RELEASE_VERSION: 11.3.3 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 701cb0625a9..bc37cee7ceb 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 333db5a4627..7186fcbf5ef 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.3.2 + RELEASE_VERSION: 11.3.3 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 7e6c5ae1fce..7e7364bde3a 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.3.2 + RELEASE_VERSION: 11.3.3 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 e8dc2bbf3e9..fdb981e85b4 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.3.2 + RELEASE_VERSION: 11.3.3 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index 387cf76c8d2..ae42fe7163b 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.3.2 + RELEASE_VERSION: 11.3.3 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 21f13f35f34..c8848c853ca 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.3.2 + RELEASE_VERSION: 11.3.3 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 b0007eeaba7..5800cd92196 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.3.2 + RELEASE_VERSION: 11.3.3 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 c7a4f580b5d..fef72f13d6b 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.3.2 + RELEASE_VERSION: 11.3.3 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 345ad2e9571..263495c9a37 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.3.2 + RELEASE_VERSION: 11.3.3 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 535adcb68e1..10752942cd7 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.3.2 + RELEASE_VERSION: 11.3.3 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 a85875874fa..54f0234789a 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.3.2 + RELEASE_VERSION: 11.3.3 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 8dd099ba4c9..13b3215ac21 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.3.2 + RELEASE_VERSION: 11.3.3 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 bdda44e6c66..d756516e86d 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.3.2 + RELEASE_VERSION: 11.3.3 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 b3b21c159d4..d0d34be53c0 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.3.2 + RELEASE_VERSION: 11.3.3 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 463c44d5ba1..255c2f04b1b 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.3.2 + RELEASE_VERSION: 11.3.3 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 7b6c5ea4cb4..916d3ca8154 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.3.2 + RELEASE_VERSION: 11.3.3 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 c0b9945f231..527d6db1016 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.3.2 + RELEASE_VERSION: 11.3.3 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 e0aae6a0138..b35d53276c2 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.3.2 + RELEASE_VERSION: 11.3.3 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 9d37ba8a042..81eb56a876d 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.3.2 + RELEASE_VERSION: 11.3.3 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 201ad03dc7a..cc1f91c6ca9 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index 01193deae4f..58ae4b6d5ad 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index dbd95cbaa46..5775d7825b4 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.3.2 + RELEASE_VERSION: 11.3.3 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 925c36adf3e..51726b50931 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index 054bc1611a6..0b784aea780 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 29917b65ae7..064ee79bd7d 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index 2cd216cb0ce..775d27f45af 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index 4083778d527..e0a7310f06a 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index 3038d0da4a5..8b94a2898fa 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index a2c7b1019f2..1df57481158 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index 60b2a216c1f..bf07a3764b3 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index a47c869d6fe..fa371bffdcc 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index c701cab1dd8..7494055ab91 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index 97d60a36ea7..7bf65163865 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index 59f32316d3d..2083327dde7 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.3.2 + RELEASE_VERSION: 11.3.3 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e8866cf78..286bb2cacc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog + +## 11.3.3 + +### 🐛 Bug Fixes + +- Fix internal transactions fetcher test on zksync chain type ([#14894](https://github.com/blockscout/blockscout/issues/14894)) +- Export only supplied coin balances to the multichain balances queue ([#14885](https://github.com/blockscout/blockscout/issues/14885)) +- Stop internal transactions import from upserting existing addresses ([#14890](https://github.com/blockscout/blockscout/issues/14890)) +- Missing blocks clearing on failed import ([#14888](https://github.com/blockscout/blockscout/issues/14888)) +- Exclude non-traceable blocks from coin balance catchup init query ([#14889](https://github.com/blockscout/blockscout/issues/14889)) +- ReindexBlocksWithStaleInternalTransactions migration ([#14884](https://github.com/blockscout/blockscout/issues/14884)) +- Do not move the beacon deposit fetcher cursor forward on reorgs ([#14883](https://github.com/blockscout/blockscout/issues/14883)) +- Handle null nonce/mixHash returned by Nethermind for AuRa blocks ([#14880](https://github.com/blockscout/blockscout/issues/14880)) +- Handle multi-id historical transfers when reverting token instance owner on reorg ([#14879](https://github.com/blockscout/blockscout/issues/14879)) +- Apply a finite timeout to the internal transactions import ([#14864](https://github.com/blockscout/blockscout/issues/14864)) +- Tolerate pending internal transactions at chain head in txlistinternal ([#14866](https://github.com/blockscout/blockscout/issues/14866)) +- Recognize null round errors with epoch suffix in catchup fetcher for Filecoin ([#14859](https://github.com/blockscout/blockscout/issues/14859)) +- Bound pending operations PTO->PBO migration batches by transaction_hash ([#14856](https://github.com/blockscout/blockscout/issues/14856)) + +### ⚙️ Miscellaneous Tasks + +- Add env var to disable transactions count consolidation ([#14877](https://github.com/blockscout/blockscout/issues/14877)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `API_INTERNAL_TRANSACTIONS_PENDING_HEAD_TOLERANCE` | Number of most recent blocks whose internal transactions may still be pending without API endpoints returning internal transactions marking the response as pending (`status: "2"`), for requests whose range reaches the chain head (no `endblock`, or `endblock` at or above the latest block). Requests with `endblock` below the head, empty results, and requests by `txhash` keep the strict check. Set to `0` to disable the tolerance. Implemented in [#14866](https://github.com/blockscout/blockscout/pull/14866). | Version: v11.3.3\+
Default: `200`
Applications: API | +| `INDEXER_INTERNAL_TRANSACTIONS_IMPORT_TIMEOUT` | DB transaction timeout for internal transactions import. [Time format](/setup/env-variables/backend-env-variables#time-format). Set to `0` to disable the timeout. Implemented in [#14864](https://github.com/blockscout/blockscout/pull/14864). | Version: v11.3.3\+
Default: `4m`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_BATCH_SIZE` | Number of blocks to reindex in the batch. Implemented in [#14884](https://github.com/blockscout/blockscout/pull/14884). | Version: v11.3.3\+
Default: `1000`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_CONCURRENCY` | Number of parallel reindexing block batches processing. Implemented in [#14884](https://github.com/blockscout/blockscout/pull/14884). | Version: v11.3.3\+
Default: `1`
Applications: Indexer | +| `MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_TIMEOUT` | Timeout between reindexing block batches processing. Implemented in [#14884](https://github.com/blockscout/blockscout/pull/14884). | Version: v11.3.3\+
Default: `0s`
Applications: Indexer | +| `CACHE_TXS_COUNT_CONSOLIDATION_DISABLED` | If `true`, disables exact total transaction count consolidation and uses the PostgreSQL `pg_class` estimate directly. Implemented in [#14877](https://github.com/blockscout/blockscout/pull/14877). | Version: v11.3.3\+
Default: `false`
Applications: API | + + ## 11.3.2 ### 🐛 Bug Fixes diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index 6cb2e43aeb5..ab37a5deece 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.3.2", + version: "11.3.3", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 867895f76aa..34e3556dd9f 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.3.2" + version: "11.3.3" ] end diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 1b2e80fdc1e..a6610697f78 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.3.2"}] + @default_headers [{"User-Agent", "blockscout-11.3.3"}] # 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 d3f6e46dd60..d8217a55e42 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.3.2", + version: "11.3.3", 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 d545f7f0bcb..41466972912 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.3.2", + version: "11.3.3", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 212b1c8d8d1..7d2acbf0795 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.3.2", + version: "11.3.3", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index 419e2de5cb9..f679d96c033 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.3.2", + version: "11.3.3", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/docker/Makefile b/docker/Makefile index 29ca94ff0a5..0e64afc7d75 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.3.2' +RELEASE_VERSION ?= '11.3.3' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/mix.exs b/mix.exs index 398a3cac4d3..8b30f3c24cb 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "11.3.2", + version: "11.3.3", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/rel/config.exs b/rel/config.exs index 2803b313f83..f5a401740c1 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.3.2" + set version: "11.3.3" set applications: [ :runtime_tools, block_scout_web: :permanent, From 5b7c157dda36f528b65be6ef1780f656564d2a97 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 6 Oct 2026 16:46:16 +0300 Subject: [PATCH 82/82] v12.0.0 (#14908) Signed-off-by: dependabot[bot] Co-authored-by: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Co-authored-by: Alexander Kolotov Co-authored-by: Alexander Kolotov Co-authored-by: nikitosing <32202610+nikitosing@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kirill Fedoseev Co-authored-by: Maxim Filonov <53992153+sl1depengwyn@users.noreply.github.com> Co-authored-by: Nikita Pozdniakov Co-authored-by: Cursor Co-authored-by: Claude Co-authored-by: entin-hun <50300541+entin-hun@users.noreply.github.com> Co-authored-by: VIkions <99107287+vikions@users.noreply.github.com> Co-authored-by: serhat dolmaci Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: tom goriunov Co-authored-by: kewe63 Co-authored-by: Leoni Mella Co-authored-by: tom goriunov Co-authored-by: Crazy <53251494+crazywriter1@users.noreply.github.com> Co-authored-by: crazywriter1 --- .agents/agents/leaf-schemas-cataloger.md | 130 ++ .agents/agents/openapi-spec-inspector.md | 2 + .../alphabetically-ordered-aliases/SKILL.md | 31 + .../skills/elixir-clause-grouping/SKILL.md | 15 + .agents/skills/openapi-spec/SKILL.md | 13 +- .../openapi-spec/references/cache/.gitignore | 2 + .../references/inspection-checklist.md | 36 +- .../references/parameter-discovery.md | 2 - .../references/schema-conventions.md | 68 +- .../scripts/leaf-schemas-cache-check.sh | 68 ++ .../scripts/leaf-schemas-collect-facts.py | 189 +++ .devcontainer/Dockerfile | 3 +- .dialyzer_ignore.exs | 3 +- .github/workflows/config.yml | 5 + .github/workflows/generate-swagger.yml | 37 +- .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 +- .github/workflows/publish-api-types-npm.yml | 5 +- .../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 +- .../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 +- ...ocker-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 +- .gitignore | 2 + CHANGELOG.md | 179 ++- .../lib/block_scout_web/captcha_helper.ex | 23 +- .../lib/block_scout_web/chain.ex | 694 ++++++----- .../account/api/v2/email_controller.ex | 2 +- .../account/api/v2/user_controller.ex | 28 +- .../address_coin_balance_controller.ex | 3 +- .../controllers/address_controller.ex | 15 +- ...address_internal_transaction_controller.ex | 4 +- .../controllers/address_logs_controller.ex | 13 +- .../controllers/address_token_controller.ex | 6 +- .../address_token_transfer_controller.ex | 4 +- .../address_transaction_controller.ex | 4 +- .../address_validation_controller.ex | 4 +- .../address_withdrawal_controller.ex | 3 +- .../controllers/admin/tasks_controller.ex | 8 +- .../controllers/api/eth_rpc/eth_controller.ex | 2 +- .../controllers/api/health_controller.ex | 8 +- .../api/legacy/block_controller.ex | 28 +- .../controllers/api/legacy/eth_controller.ex | 186 +++ .../controllers/api/legacy/logs_controller.ex | 63 - .../api/rpc/transaction_controller.ex | 5 +- .../api/v1/gas_price_oracle_controller.ex | 4 +- .../v1/verified_smart_contract_controller.ex | 2 +- .../api/v2/address_badge_controller.ex | 2 +- .../controllers/api/v2/address_controller.ex | 252 ++-- .../api/v2/advanced_filter_controller.ex | 30 +- .../controllers/api/v2/arbitrum_controller.ex | 40 +- .../controllers/api/v2/block_controller.ex | 121 +- .../controllers/api/v2/celo_controller.ex | 96 +- .../controllers/api/v2/config_controller.ex | 13 +- .../api/v2/csv_export_controller.ex | 47 + .../api/v2/ethereum/deposit_controller.ex | 14 +- .../api/v2/internal_transaction_controller.ex | 59 +- ...rnal_transactions_pending_status_helper.ex | 125 ++ .../api/v2/main_page_controller.ex | 4 +- .../controllers/api/v2/mud_controller.ex | 475 -------- .../controllers/api/v2/optimism_controller.ex | 122 +- .../proxy/account_abstraction_controller.ex | 2 +- .../controllers/api/v2/scroll_controller.ex | 47 +- .../controllers/api/v2/search_controller.ex | 38 +- .../api/v2/shibarium_controller.ex | 122 +- .../api/v2/smart_contract_controller.ex | 22 +- .../controllers/api/v2/stats_controller.ex | 45 +- .../controllers/api/v2/token_controller.ex | 290 ++++- .../api/v2/token_transfer_controller.ex | 20 +- .../api/v2/transaction_controller.ex | 185 ++- .../api/v2/validator_controller.ex | 158 ++- .../api/v2/verification_controller.ex | 4 +- .../api/v2/withdrawal_controller.ex | 10 +- .../controllers/api/v2/zksync_controller.ex | 99 +- .../controllers/block_controller.ex | 3 +- .../block_transaction_controller.ex | 4 +- .../block_withdrawal_controller.ex | 4 +- .../chain/market_history_chart_controller.ex | 6 +- .../transaction_history_chart_controller.ex | 2 +- .../controllers/chain_controller.ex | 2 +- .../pending_transaction_controller.ex | 3 +- .../controllers/smart_contract_controller.ex | 6 +- .../controllers/tokens/holder_controller.ex | 8 +- .../tokens/instance/holder_controller.ex | 3 +- .../tokens/instance/transfer_controller.ex | 3 +- .../tokens/inventory_controller.ex | 3 +- .../controllers/tokens/tokens_controller.ex | 15 +- .../controllers/tokens/transfer_controller.ex | 3 +- ...saction_internal_transaction_controller.ex | 4 +- .../controllers/transaction_log_controller.ex | 7 +- .../transaction_state_controller.ex | 20 +- .../transaction_token_transfer_controller.ex | 3 +- .../verified_contracts_controller.ex | 6 +- .../controllers/withdrawal_controller.ex | 8 +- .../lib/block_scout_web/etherscan.ex | 118 +- .../block_scout_web/graphql/body_reader.ex | 4 +- .../graphql/celo/schema/types.ex | 4 +- .../graphql/resolvers/token_transfer.ex | 13 +- .../lib/block_scout_web/graphql/schema.ex | 4 +- .../graphql/schema/scalars/JSON.ex | 4 +- .../block_scout_web/graphql/schema/types.ex | 4 +- .../block_scout_web/legacy_paging_helper.ex | 58 + .../transaction_interpretation.ex | 62 +- .../models/transaction_state_helper.ex | 5 +- .../lib/block_scout_web/paging_helper.ex | 74 +- .../block_scout_web/plug/check_account_api.ex | 2 +- .../lib/block_scout_web/plug/check_api_v2.ex | 2 +- .../block_scout_web/plug/graphql_context.ex | 22 + .../plug/graphql_schema_introspection.ex | 2 +- .../lib/block_scout_web/plug/rate_limit.ex | 3 +- .../lib/block_scout_web/rate_limit.ex | 107 +- .../lib/block_scout_web/router.ex | 10 +- .../block_scout_web/routers/account_router.ex | 6 +- .../routers/address_badges_v2_router.ex | 4 +- .../block_scout_web/routers/admin_router.ex | 2 +- .../routers/api_key_v2_router.ex | 2 +- .../lib/block_scout_web/routers/api_router.ex | 108 +- .../routers/smart_contracts_api_v2_router.ex | 4 +- .../routers/tokens_api_v2_router.ex | 37 +- .../routers/utils_api_v2_router.ex | 2 +- .../lib/block_scout_web/routers/web_router.ex | 2 +- .../schemas/api/legacy/envelope.ex | 96 +- .../api/legacy/eth_block_number_result.ex | 4 +- .../schemas/api/legacy/eth_rpc/block_tag.ex | 17 + .../api/legacy/eth_rpc/eth_call_result.ex | 13 + .../legacy/eth_rpc/eth_get_balance_result.ex | 13 + .../api/legacy/eth_rpc/eth_get_logs_result.ex | 59 + .../eth_rpc/eth_get_storage_at_result.ex | 13 + .../eth_send_raw_transaction_result.ex | 14 + .../schemas/api/legacy/eth_rpc/requests.ex | 309 +++++ .../legacy/get_block_number_by_time_result.ex | 8 +- .../schemas/api/legacy/log_item.ex | 26 +- .../block_scout_web/schemas/api/v2/address.ex | 6 +- .../schemas/api/v2/address/tabs_counters.ex | 46 +- .../schemas/api/v2/advanced_filter.ex | 12 +- .../schemas/api/v2/arbitrum/batch.ex | 18 +- .../api/v2/arbitrum/batch_by_anytrust.ex | 2 +- .../api/v2/arbitrum/batch_by_celestia.ex | 2 +- .../api/v2/arbitrum/batch_by_eigenda.ex | 2 +- .../api/v2/arbitrum/batch_data_container.ex | 1 + .../schemas/api/v2/arbitrum/batch_for_list.ex | 1 + .../schemas/api/v2/arbitrum/claim_message.ex | 11 +- .../api/v2/arbitrum/commitment_transaction.ex | 1 + .../v2/arbitrum/data_availability/anytrust.ex | 1 + .../api/v2/arbitrum/data_availability/base.ex | 1 + .../v2/arbitrum/data_availability/celestia.ex | 1 + .../v2/arbitrum/data_availability/eigenda.ex | 1 + .../schemas/api/v2/arbitrum/message.ex | 11 +- .../api/v2/arbitrum/minimal_message.ex | 20 +- .../schemas/api/v2/arbitrum/withdrawal.ex | 65 +- .../schemas/api/v2/beacon/deposit.ex | 1 + .../schemas/api/v2/beacon/deposit/status.ex | 8 +- .../block_scout_web/schemas/api/v2/blob.ex | 6 +- .../block_scout_web/schemas/api/v2/block.ex | 95 +- .../schemas/api/v2/celo/epoch.ex | 8 +- .../celo/epoch/aggregated_election_rewards.ex | 47 + .../schemas/api/v2/celo/epoch/detailed.ex | 19 +- .../v2/celo/epoch/detailed_distribution.ex | 53 + .../schemas/api/v2/celo/epoch/distribution.ex | 44 + .../schemas/api/v2/csv_export/response.ex | 5 +- .../schemas/api/v2/eden/call.ex | 2 +- .../schemas/api/v2/fhe_operation.ex | 2 +- .../block_scout_web/schemas/api/v2/general.ex | 339 +++--- .../api/v2/general/decoded_log_input.ex | 1 + .../api/v2/general/filecoin_actor_type.ex | 29 + .../schemas/api/v2/general/hex_data.ex | 7 + .../api/v2/general/hex_data_nullable.ex | 7 + .../schemas/api/v2/general/hex_quantity.ex | 7 + .../schemas/api/v2/general/hex_string.ex | 7 - .../api/v2/general/hex_string_nullable.ex | 7 - .../schemas/api/v2/general/implementation.ex | 5 +- .../v2/general/non_negative_integer_string.ex | 7 + .../schemas/api/v2/internal_transaction.ex | 13 + .../lib/block_scout_web/schemas/api/v2/log.ex | 4 +- .../schemas/api/v2/mud/record.ex | 44 - .../schemas/api/v2/mud/system.ex | 25 - .../schemas/api/v2/mud/system_details.ex | 24 - .../schemas/api/v2/mud/table.ex | 31 - .../schemas/api/v2/mud/table_schema.ex | 28 - .../schemas/api/v2/mud/table_with_schema.ex | 24 - .../schemas/api/v2/mud/world.ex | 27 - .../schemas/api/v2/optimism/batch.ex | 8 +- .../schemas/api/v2/optimism/batch/blob.ex | 119 ++ .../api/v2/optimism/batch/data_container.ex | 20 + .../schemas/api/v2/optimism/batch/detailed.ex | 195 ++- .../schemas/api/v2/optimism/deposit.ex | 1 + .../api/v2/optimism/deposit/main_page.ex | 1 + .../schemas/api/v2/optimism/game.ex | 1 + .../api/v2/optimism/interop_message.ex | 60 + .../schemas/api/v2/optimism/output_root.ex | 1 + .../api/v2/optimism/transaction_withdrawal.ex | 45 + .../schemas/api/v2/optimism/withdrawal.ex | 7 +- .../v2/proxy/account_abstraction/account.ex | 1 + .../v2/proxy/account_abstraction/bundle.ex | 1 + .../v2/proxy/account_abstraction/bundler.ex | 1 + .../v2/proxy/account_abstraction/factory.ex | 1 + .../v2/proxy/account_abstraction/paymaster.ex | 1 + .../account_abstraction/user_operation.ex | 73 +- .../account_abstraction/user_operation/raw.ex | 82 ++ .../schemas/api/v2/scroll/batch.ex | 1 + .../schemas/api/v2/scroll/bridge.ex | 1 + .../schemas/api/v2/search/result.ex | 534 ++++++++ .../schemas/api/v2/search/result_item.ex | 23 - .../schemas/api/v2/search/results.ex | 6 +- .../schemas/api/v2/search/tac_operation.ex | 93 -- .../schemas/api/v2/shibarium/deposit.ex | 52 + .../schemas/api/v2/shibarium/withdrawal.ex | 52 + .../schemas/api/v2/smart_contract.ex | 60 +- .../schemas/api/v2/smart_contract/counters.ex | 1 + .../schemas/api/v2/stability/counters.ex | 56 + .../schemas/api/v2/stability/validator.ex | 42 + .../schemas/api/v2/stats/chart.ex | 69 ++ .../schemas/api/v2/stats/gas_prices.ex | 84 ++ .../schemas/api/v2/stats/response.ex | 27 +- .../block_scout_web/schemas/api/v2/token.ex | 46 +- .../schemas/api/v2/token/counters.ex | 14 +- .../api/v2/token/ui_multiplier_change.ex | 71 ++ .../schemas/api/v2/token_instance.ex | 14 +- .../token_instance_in_token_instances_list.ex | 27 + .../schemas/api/v2/token_transfer.ex | 32 +- .../schemas/api/v2/transaction.ex | 27 +- .../schemas/api/v2/transaction/preview.ex | 2 + .../schemas/api/v2/transaction/raw_trace.ex | 12 +- .../schemas/api/v2/transaction/response.ex | 40 +- .../api/v2/transaction/state_change.ex | 11 +- .../schemas/api/v2/withdrawal.ex | 1 + .../schemas/api/v2/withdrawal/counter.ex | 1 + .../schemas/api/v2/zilliqa/staker.ex | 3 +- .../schemas/api/v2/zilliqa/staker/detailed.ex | 4 +- .../schemas/api/v2/zksync/batch.ex | 72 ++ .../schemas/api/v2/zksync/batch_list_item.ex | 30 + .../v2/zksync/confirmed_batch_list_item.ex | 44 + .../schemas/api/v2/zksync/minimal_batch.ex | 90 ++ .../lib/block_scout_web/schemas/helper.ex | 43 + .../lib/block_scout_web/specs/private.ex | 4 +- .../lib/block_scout_web/specs/public.ex | 33 +- .../transaction_state/_state_change.html.eex | 8 +- .../utility/rate_limit_config_helper.ex | 29 +- .../views/account/custom_abi_view.ex | 2 +- .../views/address_contract_view.ex | 10 +- .../block_scout_web/views/api/eth_rpc/view.ex | 39 +- .../views/api/legacy/eth_view.ex | 5 + .../views/api/legacy/logs_view.ex | 5 - .../views/api/rpc/address_view.ex | 3 +- .../views/api/rpc/contract_view.ex | 6 +- .../views/api/v2/address_view.ex | 10 +- .../views/api/v2/advanced_filter_view.ex | 13 + .../views/api/v2/internal_transaction_view.ex | 21 +- .../block_scout_web/views/api/v2/mud_view.ex | 119 -- .../views/api/v2/shibarium_view.ex | 10 +- .../views/api/v2/stability_view.ex | 6 +- .../views/api/v2/token_transfer_view.ex | 8 +- .../views/api/v2/token_view.ex | 50 +- .../views/api/v2/transaction_view.ex | 130 +- .../views/api/v2/zksync_view.ex | 60 +- .../lib/block_scout_web/views/layout_view.ex | 7 +- .../lib/block_scout_web/views/nft_helper.ex | 21 +- .../block_scout_web/views/tokens/helper.ex | 45 +- .../views/tokens/holder_view.ex | 8 +- .../views/tokens/instance/metadata_view.ex | 2 +- .../views/tokens/overview_view.ex | 9 +- .../views/transaction_raw_trace_view.ex | 2 +- .../views/transaction_state_view.ex | 15 +- .../block_scout_web/views/transaction_view.ex | 3 + apps/block_scout_web/mix.exs | 7 +- apps/block_scout_web/priv/gettext/default.pot | 1079 +++++++++++++++++ .../priv/gettext/en/LC_MESSAGES/default.po | 1079 +++++++++++++++++ .../test/block_scout_web/chain_test.exs | 154 ++- .../channels/v2/block_channel_test.exs | 4 +- .../channels/v2/websocket_test.exs | 28 +- .../controllers/address_controller_test.exs | 6 +- ...s_internal_transaction_controller_test.exs | 3 +- .../address_token_controller_test.exs | 8 +- ...address_token_transfer_controller_test.exs | 3 +- .../api/legacy/block_controller_test.exs | 94 -- .../api/legacy/eth_controller_test.exs | 627 ++++++++++ .../api/legacy/logs_controller_test.exs | 263 ---- .../api/rpc/address_controller_test.exs | 192 +-- .../api/rpc/contract_controller_test.exs | 22 +- .../api/rpc/eth_controller_test.exs | 16 +- .../api/rpc/transaction_controller_test.exs | 41 + .../api/v1/health_controller_test.exs | 8 +- ...erified_smart_contract_controller_test.exs | 6 +- .../api/v2/address_controller_test.exs | 427 ++++++- .../v2/advanced_filter_controller_test.exs | 21 +- .../api/v2/block_controller_test.exs | 87 ++ .../api/v2/celo_controller_test.exs | 107 ++ .../api/v2/csv_export_controller_test.exs | 229 +++- .../api/v2/import_controller_test.exs | 9 + .../internal_transaction_controller_test.exs | 70 ++ .../api/v2/optimism_controller_test.exs | 21 + .../account_abstraction_controller_test.exs | 8 +- .../api/v2/search_controller_test.exs | 216 +++- .../api/v2/shibarium_controller_test.exs | 205 ++++ .../api/v2/smart_contract_controller_test.exs | 53 +- .../api/v2/token_controller_test.exs | 609 +++++++++- .../api/v2/transaction_controller_test.exs | 417 ++++++- .../api/v2/validator_controller_test.exs | 24 + .../api/v2/zksync_controller_test.exs | 295 +++++ .../controllers/block_controller_test.exs | 2 +- .../block_transaction_controller_test.exs | 14 +- .../block_withdrawal_controller_test.exs | 8 +- ...nsaction_history_chart_controller_test.exs | 4 +- .../pending_transaction_controller_test.exs | 2 +- .../tokens/inventory_controller_test.exs | 6 +- .../tokens/token_controller_test.exs | 2 +- ...n_internal_transaction_controller_test.exs | 8 +- .../transaction_log_controller_test.exs | 12 +- .../transaction_state_controller_test.exs | 10 +- ...saction_token_transfer_controller_test.exs | 8 +- .../verified_contracts_controller_test.exs | 6 +- .../withdrawal_controller_test.exs | 2 +- .../graphql/schema/query/address_test.exs | 2 +- .../graphql/schema/query/addresses_test.exs | 2 +- .../graphql/schema/query/node_test.exs | 8 +- .../schema/query/token_transfers_test.exs | 75 ++ .../graphql/schema/query/transaction_test.exs | 2 +- .../block_scout_web/paging_helper_test.exs | 94 ++ .../block_scout_web/plug/rate_limit_test.exs | 102 ++ .../routers/api_router_pipelines_test.exs | 80 ++ .../specs/public_legacy_tag_test.exs | 92 +- .../utility/rate_limit_config_helper_test.exs | 16 +- .../views/api/v2/token_view_test.exs | 70 ++ .../block_scout_web/views/nft_helper_test.exs | 21 + .../tokens/instance/overview_view_test.exs | 8 +- .../test/support/api_schema_assertions.ex | 7 +- apps/block_scout_web/test/test_helper.exs | 1 - .../lib/ethereum_jsonrpc/contract.ex | 6 +- .../lib/ethereum_jsonrpc/erc20.ex | 50 +- .../lib/ethereum_jsonrpc/http.ex | 15 +- .../lib/ethereum_jsonrpc/http/helper.ex | 53 +- .../lib/ethereum_jsonrpc/http/httpoison.ex | 2 +- .../lib/ethereum_jsonrpc/http/tesla.ex | 27 +- .../lib/ethereum_jsonrpc/ipc.ex | 6 +- .../lib/ethereum_jsonrpc/receipt.ex | 20 +- .../lib/ethereum_jsonrpc/receipts.ex | 4 +- .../utility/endpoint_availability_checker.ex | 19 + .../utility/endpoint_availability_observer.ex | 26 +- .../web_socket/web_socket_client.ex | 4 +- .../lib/ethereum_jsonrpc/zilliqa/helper.ex | 2 +- apps/ethereum_jsonrpc/mix.exs | 5 +- .../test/ethereum_jsonrpc/erc20_test.exs | 93 ++ .../ethereum_jsonrpc/geth/tracer_test.exs | 5 +- .../ethereum_jsonrpc/http/helper_test.exs | 56 + .../test/ethereum_jsonrpc/http/mox_test.exs | 18 +- .../test/ethereum_jsonrpc/http/tesla_test.exs | 125 ++ .../web_socket/web_socket_client_test.exs | 4 +- .../web_socket/cowboy/websocket_handler.ex | 8 +- apps/ethereum_jsonrpc/test/test_helper.exs | 2 + apps/explorer/config/config.exs | 30 +- apps/explorer/config/dev.exs | 1 - apps/explorer/config/prod.exs | 1 - apps/explorer/config/runtime/test.exs | 22 +- apps/explorer/config/test.exs | 1 - .../account/auth0_to_keycloak_migration.ex | 8 +- .../lib/explorer/account/custom_abi.ex | 4 +- .../explorer/lib/explorer/account/identity.ex | 1 - .../lib/explorer/account/notifier/email.ex | 3 + .../lib/explorer/account/notifier/notify.ex | 3 + .../lib/explorer/account/notifier/summary.ex | 20 +- .../lib/explorer/account/tag_transaction.ex | 6 +- .../lib/explorer/account/watchlist.ex | 2 +- apps/explorer/lib/explorer/application.ex | 98 +- apps/explorer/lib/explorer/chain.ex | 122 +- apps/explorer/lib/explorer/chain/address.ex | 12 +- .../explorer/chain/address/coin_balance.ex | 49 +- .../lib/explorer/chain/address/counters.ex | 106 +- .../chain/address/current_token_balance.ex | 47 +- .../chain/address/metadata_preloader.ex | 54 + .../lib/explorer/chain/address/reputation.ex | 6 +- .../explorer/chain/address/token_balance.ex | 4 +- .../lib/explorer/chain/advanced_filter.ex | 59 +- .../chain/arbitrum/reader/api/general.ex | 6 +- .../chain/arbitrum/reader/indexer/messages.ex | 22 +- .../lib/explorer/chain/beacon/deposit.ex | 46 +- .../lib/explorer/chain/blackfort/validator.ex | 2 +- apps/explorer/lib/explorer/chain/block.ex | 70 +- .../lib/explorer/chain/bridged_token.ex | 13 +- .../chain/cache/background_migrations.ex | 85 +- .../counters/address_tabs_elements_count.ex | 39 +- .../cache/counters/optimism/deposits_count.ex | 89 ++ .../explorer/lib/explorer/chain/celo/epoch.ex | 19 +- .../lib/explorer/chain/compressed_data.ex | 54 + .../explorer/chain/csv_export/address/logs.ex | 4 +- .../csv_export/address/token_transfers.ex | 7 + .../explorer/chain/csv_export/async_helper.ex | 2 +- .../lib/explorer/chain/csv_export/helper.ex | 12 +- .../chain/csv_export/token/holders.ex | 11 +- .../chain/csv_export/token/transfers.ex | 119 ++ apps/explorer/lib/explorer/chain/data.ex | 17 +- .../explorer/chain/denormalization_helper.ex | 16 + .../fetcher/addresses_blacklist/blockaid.ex | 2 +- .../lib/explorer/chain/filecoin/id.ex | 17 +- .../explorer/chain/filecoin/native_address.ex | 17 +- apps/explorer/lib/explorer/chain/hash.ex | 17 +- .../lib/explorer/chain/hash/trimmed.ex | 159 +++ .../explorer/chain/import/runner/blocks.ex | 186 ++- .../import/runner/internal_transactions.ex | 34 +- .../lib/explorer/chain/import/runner/logs.ex | 234 +++- .../explorer/chain/import/runner/tokens.ex | 19 +- .../chain/import/runner/transactions.ex | 59 +- .../explorer/chain/internal_transaction.ex | 238 +--- apps/explorer/lib/explorer/chain/log.ex | 853 +++++++++++-- apps/explorer/lib/explorer/chain/map_cache.ex | 1 + .../chain/metrics/queries/indexer_metrics.ex | 36 +- apps/explorer/lib/explorer/chain/mud.ex | 649 ---------- .../explorer/lib/explorer/chain/mud/schema.ex | 111 -- apps/explorer/lib/explorer/chain/mud/table.ex | 89 -- .../lib/explorer/chain/optimism/deposit.ex | 3 +- .../chain/optimism/interop_message.ex | 2 +- .../chain/optimism/superchain_config.ex | 431 +++++++ .../lib/explorer/chain/optimism/withdrawal.ex | 210 ++-- .../chain/pending_operations_helper.ex | 165 ++- apps/explorer/lib/explorer/chain/search.ex | 6 +- .../lib/explorer/chain/smart_contract.ex | 6 +- .../explorer/chain/smart_contract/proxy.ex | 37 +- .../lib/explorer/chain/stability/validator.ex | 2 +- apps/explorer/lib/explorer/chain/token.ex | 58 +- .../lib/explorer/chain/token/instance.ex | 105 +- .../explorer/chain/token/scaled_ui_amount.ex | 257 ++++ .../chain/token/ui_multiplier_change.ex | 494 ++++++++ .../lib/explorer/chain/token_transfer.ex | 47 +- .../lib/explorer/chain/transaction.ex | 131 +- .../transaction/history/transaction_stats.ex | 2 +- .../chain/transaction/state_change.ex | 82 +- apps/explorer/lib/explorer/chain/wei.ex | 6 +- .../chain/zilliqa/zrc2/token_transfer.ex | 39 +- .../lib/explorer/chain_spec/genesis_data.ex | 10 +- .../lib/explorer/env_var_translator.ex | 12 +- apps/explorer/lib/explorer/eth_rpc.ex | 399 +++++- apps/explorer/lib/explorer/eth_rpc_helper.ex | 49 + apps/explorer/lib/explorer/etherscan.ex | 43 +- apps/explorer/lib/explorer/etherscan/logs.ex | 191 ++- apps/explorer/lib/explorer/graphql.ex | 10 +- apps/explorer/lib/explorer/helper.ex | 2 +- .../lib/explorer/market/fetcher/token_list.ex | 2 +- .../lib/explorer/market/source/coin_gecko.ex | 41 +- apps/explorer/lib/explorer/market/token.ex | 2 +- .../lib/explorer/metadata_uri_validator.ex | 128 +- .../account_abstraction.ex | 4 +- .../explorer/microservice_interfaces/bens.ex | 22 +- .../microservice_interfaces/metadata.ex | 24 +- .../multichain_search.ex | 140 ++- .../tac_operation_lifecycle.ex | 4 +- ...ddress_current_token_balance_token_type.ex | 4 +- .../address_token_balance_token_type.ex | 4 +- .../arbitrum_da_records_normalization.ex | 4 +- .../migrator/backfill_address_counters.ex | 1 + .../migrator/backfill_metadata_url.ex | 13 + .../migrator/backfill_multichain_search_db.ex | 4 +- ...ichain_search_db_current_token_balances.ex | 124 ++ .../backfill_scaled_ui_amount_tokens.ex | 152 +++ .../migrator/backfill_token_counters.ex | 1 + .../lib/explorer/migrator/celo_accounts.ex | 22 +- .../celo_aggregated_election_rewards.ex | 2 + .../lib/explorer/migrator/celo_l2_epochs.ex | 42 +- .../migrator/delete_non_consensus_logs.ex | 51 + ...delete_zero_value_internal_transactions.ex | 32 +- ...code_for_selfdestructed_smart_contracts.ex | 13 +- .../empty_internal_transactions_data.ex | 158 +-- .../filecoin_pending_address_operations.ex | 2 + .../fill_internal_transactions_address_ids.ex | 157 +-- .../migrator/fill_logs_compressed_data.ex | 110 ++ .../migrator/fill_logs_optimized_fields.ex | 139 +++ .../explorer/migrator/filling_migration.ex | 19 +- .../migrator/heavy_db_index_operation.ex | 1 + ...rent_token_balances_token_holders_index.ex | 73 ++ ...s_id_block_number_desc_index_desc_index.ex | 64 + ...id_first_topic_block_number_index_index.ex | 66 + ...opic_id_second_topic_block_number_index.ex | 64 + ...er_transaction_index_index_unique_index.ex | 81 ++ ...osits_withdrawals_index_with_updated_pk.ex | 73 ++ .../create_logs_first_topic_id_index.ex | 63 + ..._token_transfer_method_id_ordered_index.ex | 64 + ...fetched_address_token_balances_v3_index.ex | 85 ++ ...token_contract_address_hash_value_index.ex | 134 ++ ...hash_block_number_desc_index_desc_index.ex | 56 + ...sh_first_topic_block_number_index_index.ex | 61 + ...t_topic_second_topic_block_number_index.ex | 75 ++ .../drop_logs_deposits_withdrawals_index.ex | 69 ++ ...fetched_address_token_balances_v2_index.ex | 70 ++ .../heavy_db_index_operation/helper.ex | 148 +++ ...ve_internal_transactions_address_hashes.ex | 104 ++ .../update_logs_primary_key.ex | 195 +++ ...block_number_transaction_index_not_null.ex | 97 +- ...block_number_transaction_index_not_null.ex | 66 + .../validate_logs_first_topic_id_fkey.ex | 105 ++ .../merge_adjacent_missing_block_ranges.ex | 2 + .../migrator/refetch_contract_codes.ex | 4 + ...eindex_blocks_with_missing_transactions.ex | 18 +- ...blocks_with_stale_internal_transactions.ex | 4 +- ...blocks_with_uncataloged_token_transfers.ex | 8 +- ...l_transactions_with_incompatible_status.ex | 4 +- .../restore_omitted_weth_transfers.ex | 7 + ...icate_smart_contract_additional_sources.ex | 9 +- .../sanitize_duplicated_log_index_logs.ex | 17 +- .../sanitize_empty_contract_code_addresses.ex | 4 +- ...c_1155_token_balances_without_token_ids.ex | 4 +- .../sanitize_incorrect_nft_token_transfers.ex | 16 +- ...sanitize_incorrect_weth_token_transfers.ex | 13 +- .../sanitize_missing_token_balances.ex | 23 +- .../sanitize_replaced_transactions.ex | 4 +- ...e_scaled_ui_amount_token_transfer_types.ex | 139 +++ .../migrator/sanitize_verified_addresses.ex | 4 +- .../migrator/shrink_internal_transactions.ex | 4 +- .../token_transfer_block_consensus.ex | 4 +- .../migrator/token_transfer_token_type.ex | 4 +- .../migrator/transaction_block_consensus.ex | 4 +- .../transaction_has_token_transfers.ex | 58 + .../migrator/transactions_denormalization.ex | 4 +- .../migrator/unescape_ampersands_in_tokens.ex | 5 +- .../migrator/unescape_quotes_in_tokens.ex | 5 +- apps/explorer/lib/explorer/paging_options.ex | 5 + apps/explorer/lib/explorer/repo.ex | 1 - .../lib/explorer/repo/config_helper.ex | 9 +- .../lib/explorer/repo/lock_timeout.ex | 96 ++ .../lib/explorer/repo/postgrex_types.ex | 4 + .../smart_contract/compiler_version.ex | 4 +- .../explorer/smart_contract/geas/publisher.ex | 4 +- .../lib/explorer/smart_contract/reader.ex | 141 +-- .../rust_verifier_interface_behaviour.ex | 4 +- .../smart_contract/sig_provider_interface.ex | 6 +- .../smart_contract/solidity/code_compiler.ex | 14 +- .../smart_contract/solidity/publish_helper.ex | 9 - .../smart_contract/solidity/publisher.ex | 27 +- .../smart_contract/solidity/verifier.ex | 52 +- .../smart_contract/stylus/publisher.ex | 2 +- .../stylus_verifier_interface.ex | 4 +- .../smart_contract/vyper/code_compiler.ex | 4 +- .../smart_contract/vyper/publisher.ex | 15 +- .../smart_contract/vyper_downloader.ex | 2 +- .../airtable_audit_report.ex | 4 +- .../third_party_integrations/auth0.ex | 4 +- .../third_party_integrations/keycloak.ex | 14 +- .../third_party_integrations/noves_fi.ex | 2 +- .../third_party_integrations/sourcify.ex | 384 +++--- .../universal_proxy.ex | 2 +- .../lib/explorer/token/metadata_retriever.ex | 518 ++++++-- apps/explorer/lib/explorer/tuple_encoder.ex | 9 +- .../lib/explorer/utility/log_first_topic.ex | 140 +++ .../lib/explorer/utility/log_helper.ex | 23 + .../lib/explorer/utility/massive_block.ex | 29 + .../lib/explorer/utility/version_upgrade.ex | 38 +- .../explorer/validator/metadata_retriever.ex | 2 +- .../lib/explorer/visualize/sol2uml.ex | 4 +- .../explorer/lib/fetch_celo_core_contracts.ex | 2 +- apps/explorer/lib/test_helper.ex | 6 +- apps/explorer/mix.exs | 9 +- ...on_index_not_null_constraint_from_logs.exs | 17 + ...5_transactions_add_has_token_transfers.exs | 9 + ...ts_add_constructor_arguments_hex_field.exs | 42 + ...gacy_optimism_portal_contract_constant.exs | 12 + ...0260511153825_reset_nfts_skip_metadata.exs | 1 + ...000_add_media_types_to_token_instances.exs | 11 + ...21103421_add_transaction_index_to_logs.exs | 9 + ...115803_add_logs_pk_not_null_constraint.exs | 19 + .../20260605121256_add_address_id_to_logs.exs | 9 + ...008_create_bytea_ltrim_zeroes_function.exs | 24 + ...0626154924_add_compressed_data_to_logs.exs | 10 + ...20260703093513_create_log_first_topics.exs | 81 ++ ...0817134921_add_ui_multiplier_to_tokens.exs | 11 + ...854_create_token_ui_multiplier_changes.exs | 17 + ...on_hash_to_token_ui_multiplier_changes.exs | 9 + .../chain/address/coin_balance_test.exs | 167 +++ .../explorer/chain/address/counters_test.exs | 120 ++ .../address/current_token_balance_test.exs | 65 + .../explorer/chain/beacon/deposit_test.exs | 113 +- .../test/explorer/chain/block_test.exs | 42 +- .../counters/optimism/deposits_count_test.exs | 29 + .../chain/csv_export/address/logs_test.exs | 2 + .../address/token_transfers_test.exs | 48 + .../explorer/chain/csv_export/helper_test.exs | 15 + .../chain/csv_export/token/holders_test.exs | 33 + .../chain/csv_export/token/transfers_test.exs | 244 ++++ .../explorer/chain/csv_export/worker_test.exs | 2 +- .../chain/import/runner/blocks_test.exs | 20 + .../runner/internal_transactions_test.exs | 86 +- .../test/explorer/chain/import_test.exs | 22 +- .../chain/internal_transaction_test.exs | 260 ++++ .../explorer/test/explorer/chain/log_test.exs | 2 +- .../chain/metrics/indexer_metrics_test.exs | 18 + .../chain/smart_contract/proxy_test.exs | 64 + .../explorer/chain/smart_contract_test.exs | 65 + .../explorer/chain/token/instance_test.exs | 209 ++++ .../chain/token/ui_multiplier_change_test.exs | 525 ++++++++ .../test/explorer/chain/token_test.exs | 43 + .../test/explorer/chain/transaction_test.exs | 4 +- .../explorer/chain_spec/genesis_data_test.exs | 2 +- .../chain_spec/geth/importer_test.exs | 8 +- .../chain_spec/parity/importer_test.exs | 4 +- apps/explorer/test/explorer/chain_test.exs | 207 +++- apps/explorer/test/explorer/eth_rpc_test.exs | 273 ++++- apps/explorer/test/explorer/graphql_test.exs | 61 +- .../market/fetcher/token_list_test.exs | 4 +- .../explorer/market/fetcher/token_test.exs | 130 +- .../market/source/coin_gecko_test.exs | 92 +- .../multichain_search_test.exs | 130 +- .../migrator/backfill_metadata_url_test.exs | 20 +- ..._search_db_current_token_balances_test.exs | 151 +++ .../backfill_scaled_ui_amount_tokens_test.exs | 186 +++ ..._zero_value_internal_transactions_test.exs | 12 +- ...or_selfdestructed_smart_contracts_test.exs | 22 +- .../fill_logs_compressed_data_test.exs | 46 + .../fill_logs_optimized_fields_test.exs | 106 ++ ...c_second_topic_block_number_index_test.exs | 73 ++ ...anitize_duplicated_log_index_logs_test.exs | 10 +- ...ize_incorrect_nft_token_transfers_test.exs | 24 +- ...ze_incorrect_weth_token_transfers_test.exs | 74 +- ...ed_ui_amount_token_transfer_types_test.exs | 103 ++ .../transaction_has_token_transfers_test.exs | 40 + .../test/explorer/repo/lock_timeout_test.exs | 46 + .../smart_contract/geas/publisher_test.exs | 2 +- .../solidity/code_compiler_test.exs | 2 +- .../solidity/publisher_test.exs | 6 +- .../smart_contract/solidity/verifier_test.exs | 47 +- .../smart_contract/vyper/publisher_test.exs | 25 + .../sourcify_test.exs | 249 ++++ .../universal_proxy_test.exs | 30 +- .../token/metadata_retriever/nft_url_test.exs | 102 ++ .../token/metadata_retriever_test.exs | 414 ++++++- apps/explorer/test/support/data_case.ex | 89 ++ apps/explorer/test/support/factory.ex | 139 ++- apps/explorer/test/test_helper.exs | 1 - apps/indexer/lib/indexer/application.ex | 9 +- .../block/catchup/missing_ranges_collector.ex | 15 +- apps/indexer/lib/indexer/block/fetcher.ex | 12 +- .../lib/indexer/block/fetcher/receipts.ex | 55 +- .../arbitrum/workers/new_messages_to_l2.ex | 121 +- .../lib/indexer/fetcher/beacon/client.ex | 2 +- ...or_and_group_payments_post_l2_migration.ex | 21 +- ...r_and_group_payments_prior_l2_migration.ex | 30 +- .../epoch_block_operations/voter_payments.ex | 15 +- .../lib/indexer/fetcher/celo/epoch_logs.ex | 2 +- .../fetcher/on_demand/internal_transaction.ex | 16 +- apps/indexer/lib/indexer/fetcher/optimism.ex | 23 +- .../indexer/fetcher/optimism/dispute_game.ex | 4 +- .../fetcher/optimism/eip1559_config_update.ex | 5 +- .../fetcher/optimism/interop/message_queue.ex | 4 +- .../optimism/interop/multichain_export.ex | 2 +- .../indexer/fetcher/optimism/operator_fee.ex | 3 +- .../optimism/superchain_config_updater.ex | 84 ++ .../fetcher/optimism/transaction_batch.ex | 22 +- .../indexer/fetcher/token_instance/helper.ex | 81 ++ .../fetcher/token_instance/media_type.ex | 82 ++ .../fetcher/token_ui_multiplier_updater.ex | 171 +++ .../indexer/fetcher/zilliqa/zrc2_tokens.ex | 2 +- apps/indexer/lib/indexer/helper.ex | 6 +- .../migrator/recovery_weth_token_transfers.ex | 25 +- apps/indexer/lib/indexer/supervisor.ex | 2 + .../validator_epoch_payment_distributions.ex | 2 +- .../lib/indexer/transform/token_transfers.ex | 56 +- apps/indexer/mix.exs | 2 +- .../catchup/missing_ranges_collector_test.exs | 40 +- .../indexer/block/fetcher/receipts_test.exs | 12 +- .../test/indexer/block/fetcher_test.exs | 9 +- .../workers/new_messages_to_l2_test.exs | 465 +++++++ .../balances_export_queue_test.exs | 12 +- .../counters_export_queue_test.exs | 10 +- .../main_export_queue_test.exs | 16 +- .../token_info_export_queue_test.exs | 18 +- .../on_demand/internal_transaction_test.exs | 56 +- .../token_instance_metadata_refetch_test.exs | 4 +- .../fetcher/token_instance/helper_test.exs | 145 ++- .../token_ui_multiplier_updater_test.exs | 256 ++++ .../indexer/fetcher/token_updater_test.exs | 60 + .../transform/address_coin_balances_test.exs | 5 +- .../transform/token_transfers_test.exs | 83 +- .../lib/nft_media_handler.ex | 52 +- .../lib/nft_media_handler/media/fetcher.ex | 25 +- apps/nft_media_handler/mix.exs | 5 +- .../utils/lib/utils/http_client/safe_fetch.ex | 125 ++ .../lib/utils/http_client/tesla_helper.ex | 9 +- apps/utils/lib/utils/json.ex | 282 +++++ apps/utils/lib/utils/runtime_env_helper.ex | 5 - apps/utils/lib/utils/token_instance_helper.ex | 97 +- apps/utils/lib/utils/url_validator.ex | 184 +++ apps/utils/mix.exs | 5 +- .../utils/http_client/safe_fetch_test.exs | 137 +++ apps/utils/test/utils/json_test.exs | 205 ++++ .../test/utils/token_instance_helper_test.exs | 69 ++ apps/utils/test/utils/url_validator_test.exs | 97 ++ config/config.exs | 12 +- config/config_helper.exs | 5 +- config/runtime.exs | 100 +- config/runtime/dev.exs | 11 - config/runtime/prod.exs | 12 - cspell.json | 19 +- docker-compose/anvil.yml | 3 + docker-compose/docker-compose.yml | 3 + docker-compose/envs/common-blockscout.env | 19 +- docker-compose/erigon.yml | 3 + docker-compose/external-db.yml | 3 + docker-compose/external-frontend.yml | 5 +- docker-compose/geth-clique-consensus.yml | 3 + docker-compose/geth.yml | 3 + docker-compose/hardhat-network.yml | 3 + docker-compose/no-services.yml | 3 + docker-compose/services/backend.yml | 2 +- docker/Dockerfile | 9 +- docker/Makefile | 2 +- docker/oldUI.Dockerfile | 4 - mix.exs | 2 +- mix.lock | 11 +- rel/config.exs | 2 +- types-package/README.md | 68 +- types-package/index.ts | 63 + types-package/package-lock.json | 24 +- types-package/scripts/chain-types.sh | 2 +- types-package/scripts/generate-spec.sh | 1 - 747 files changed, 30939 insertions(+), 7784 deletions(-) create mode 100644 .agents/agents/leaf-schemas-cataloger.md create mode 100644 .agents/skills/openapi-spec/references/cache/.gitignore create mode 100755 .agents/skills/openapi-spec/scripts/leaf-schemas-cache-check.sh create mode 100755 .agents/skills/openapi-spec/scripts/leaf-schemas-collect-facts.py create mode 100644 apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/eth_controller.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transactions_pending_status_helper.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/legacy_paging_helper.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/plug/graphql_context.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/block_tag.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_call_result.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_balance_result.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_logs_result.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_storage_at_result.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_send_raw_transaction_result.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/requests.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/aggregated_election_rewards.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed_distribution.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/distribution.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/filecoin_actor_type.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data_nullable.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_quantity.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/non_negative_integer_string.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/blob.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/data_container.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/interop_message.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/transaction_withdrawal.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation/raw.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result_item.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/tac_operation.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/deposit.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/withdrawal.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/counters.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/validator.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/chart.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/gas_prices.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/ui_multiplier_change.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_token_instances_list.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch_list_item.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/confirmed_batch_list_item.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/minimal_batch.ex create mode 100644 apps/block_scout_web/lib/block_scout_web/views/api/legacy/eth_view.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex delete mode 100644 apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex create mode 100644 apps/block_scout_web/test/block_scout_web/controllers/api/legacy/eth_controller_test.exs delete mode 100644 apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs create mode 100644 apps/block_scout_web/test/block_scout_web/controllers/api/v2/shibarium_controller_test.exs create mode 100644 apps/block_scout_web/test/block_scout_web/controllers/api/v2/zksync_controller_test.exs create mode 100644 apps/block_scout_web/test/block_scout_web/paging_helper_test.exs create mode 100644 apps/block_scout_web/test/block_scout_web/routers/api_router_pipelines_test.exs create mode 100644 apps/ethereum_jsonrpc/test/ethereum_jsonrpc/erc20_test.exs create mode 100644 apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/helper_test.exs create mode 100644 apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/tesla_test.exs create mode 100644 apps/explorer/lib/explorer/chain/cache/counters/optimism/deposits_count.ex create mode 100644 apps/explorer/lib/explorer/chain/compressed_data.ex create mode 100644 apps/explorer/lib/explorer/chain/csv_export/token/transfers.ex create mode 100644 apps/explorer/lib/explorer/chain/hash/trimmed.ex delete mode 100644 apps/explorer/lib/explorer/chain/mud.ex delete mode 100644 apps/explorer/lib/explorer/chain/mud/schema.ex delete mode 100644 apps/explorer/lib/explorer/chain/mud/table.ex create mode 100644 apps/explorer/lib/explorer/chain/optimism/superchain_config.ex create mode 100644 apps/explorer/lib/explorer/chain/token/scaled_ui_amount.ex create mode 100644 apps/explorer/lib/explorer/chain/token/ui_multiplier_change.ex create mode 100644 apps/explorer/lib/explorer/migrator/backfill_multichain_search_db_current_token_balances.ex create mode 100644 apps/explorer/lib/explorer/migrator/backfill_scaled_ui_amount_tokens.ex create mode 100644 apps/explorer/lib/explorer/migrator/delete_non_consensus_logs.ex create mode 100644 apps/explorer/lib/explorer/migrator/fill_logs_compressed_data.ex create mode 100644 apps/explorer/lib/explorer/migrator/fill_logs_optimized_fields.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_address_current_token_balances_token_holders_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_block_number_desc_index_desc_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_block_number_index_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_id_second_topic_block_number_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_number_transaction_index_index_unique_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index_with_updated_pk.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_first_topic_id_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_token_transfer_method_id_ordered_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_unfetched_address_token_balances_v3_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_address_current_token_balances_token_contract_address_hash_value_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_block_number_desc_index_desc_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_block_number_index_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_deposits_withdrawals_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_unfetched_address_token_balances_v2_index.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_address_hashes.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_logs_primary_key.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_block_number_transaction_index_not_null.ex create mode 100644 apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_first_topic_id_fkey.ex create mode 100644 apps/explorer/lib/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types.ex create mode 100644 apps/explorer/lib/explorer/migrator/transaction_has_token_transfers.ex create mode 100644 apps/explorer/lib/explorer/repo/lock_timeout.ex create mode 100644 apps/explorer/lib/explorer/repo/postgrex_types.ex create mode 100644 apps/explorer/lib/explorer/utility/log_first_topic.ex create mode 100644 apps/explorer/lib/explorer/utility/log_helper.ex create mode 100644 apps/explorer/priv/celo/migrations/20260805120000_remove_transaction_index_not_null_constraint_from_logs.exs create mode 100644 apps/explorer/priv/repo/migrations/20250220074005_transactions_add_has_token_transfers.exs create mode 100644 apps/explorer/priv/repo/migrations/20260413121113_smart_contracts_add_constructor_arguments_hex_field.exs create mode 100644 apps/explorer/priv/repo/migrations/20260416172533_remove_legacy_optimism_portal_contract_constant.exs create mode 100644 apps/explorer/priv/repo/migrations/20260513120000_add_media_types_to_token_instances.exs create mode 100644 apps/explorer/priv/repo/migrations/20260521103421_add_transaction_index_to_logs.exs create mode 100644 apps/explorer/priv/repo/migrations/20260521115803_add_logs_pk_not_null_constraint.exs create mode 100644 apps/explorer/priv/repo/migrations/20260605121256_add_address_id_to_logs.exs create mode 100644 apps/explorer/priv/repo/migrations/20260619133008_create_bytea_ltrim_zeroes_function.exs create mode 100644 apps/explorer/priv/repo/migrations/20260626154924_add_compressed_data_to_logs.exs create mode 100644 apps/explorer/priv/repo/migrations/20260703093513_create_log_first_topics.exs create mode 100644 apps/explorer/priv/repo/migrations/20260817134921_add_ui_multiplier_to_tokens.exs create mode 100644 apps/explorer/priv/repo/migrations/20260818090854_create_token_ui_multiplier_changes.exs create mode 100644 apps/explorer/priv/repo/migrations/20260911000000_add_transaction_hash_to_token_ui_multiplier_changes.exs create mode 100644 apps/explorer/test/explorer/chain/address/counters_test.exs create mode 100644 apps/explorer/test/explorer/chain/cache/counters/optimism/deposits_count_test.exs create mode 100644 apps/explorer/test/explorer/chain/csv_export/token/transfers_test.exs create mode 100644 apps/explorer/test/explorer/chain/token/ui_multiplier_change_test.exs create mode 100644 apps/explorer/test/explorer/migrator/backfill_multichain_search_db_current_token_balances_test.exs create mode 100644 apps/explorer/test/explorer/migrator/backfill_scaled_ui_amount_tokens_test.exs create mode 100644 apps/explorer/test/explorer/migrator/fill_logs_compressed_data_test.exs create mode 100644 apps/explorer/test/explorer/migrator/fill_logs_optimized_fields_test.exs create mode 100644 apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index_test.exs create mode 100644 apps/explorer/test/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types_test.exs create mode 100644 apps/explorer/test/explorer/migrator/transaction_has_token_transfers_test.exs create mode 100644 apps/explorer/test/explorer/repo/lock_timeout_test.exs create mode 100644 apps/explorer/test/explorer/third_party_integrations/sourcify_test.exs create mode 100644 apps/explorer/test/explorer/token/metadata_retriever/nft_url_test.exs create mode 100644 apps/indexer/lib/indexer/fetcher/optimism/superchain_config_updater.ex create mode 100644 apps/indexer/lib/indexer/fetcher/token_instance/media_type.ex create mode 100644 apps/indexer/lib/indexer/fetcher/token_ui_multiplier_updater.ex create mode 100644 apps/indexer/test/indexer/fetcher/arbitrum/workers/new_messages_to_l2_test.exs create mode 100644 apps/indexer/test/indexer/fetcher/token_ui_multiplier_updater_test.exs create mode 100644 apps/utils/lib/utils/http_client/safe_fetch.ex create mode 100644 apps/utils/lib/utils/json.ex create mode 100644 apps/utils/lib/utils/url_validator.ex create mode 100644 apps/utils/test/utils/http_client/safe_fetch_test.exs create mode 100644 apps/utils/test/utils/json_test.exs create mode 100644 apps/utils/test/utils/token_instance_helper_test.exs create mode 100644 apps/utils/test/utils/url_validator_test.exs diff --git a/.agents/agents/leaf-schemas-cataloger.md b/.agents/agents/leaf-schemas-cataloger.md new file mode 100644 index 00000000000..dd8c12b227a --- /dev/null +++ b/.agents/agents/leaf-schemas-cataloger.md @@ -0,0 +1,130 @@ +--- +name: leaf-schemas-cataloger +description: "Maintains a cached catalog of leaf type schemas under `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/`. Invoke at the start of any OpenAPI spec authoring task that involves selecting or referencing leaf-type schemas. The agent checks a content hash, regenerates the catalog only when leaf files have changed, and writes the result to `.claude/skills/openapi-spec/references/cache/leaf-schemas/catalog.md`. Self-contained — does not depend on the openapi-spec skill prompt." +permissionMode: auto +model: haiku +tools: Bash, Read, Write +--- + +You maintain a cached, human-readable catalog of leaf type schemas defined in the Blockscout codebase. The catalog is consumed by the `openapi-spec` skill to give the parent agent a fast, accurate vocabulary of available leaf schemas without re-reading every source file each session. + +## Workflow + +1. **Check freshness.** Run: + ``` + bash .claude/skills/openapi-spec/scripts/leaf-schemas-cache-check.sh + ``` + The script emits three `key=value` lines on stdout: + - `state=fresh` or `state=stale` + - `digest=` — the current content digest of `general/*.ex` + - `cache_dir=` — where the catalog and digest live + +2. **If `state=fresh`**, stop immediately. Report: `Catalog is fresh (digest=<...>). No work needed.` Do not regenerate. + +3. **If `state=stale`**, regenerate: + 1. Collect facts: + ``` + python3 .claude/skills/openapi-spec/scripts/leaf-schemas-collect-facts.py + ``` + Capture the stdout — it is a single JSON document with one entry per leaf, each containing `module`, `short_name`, `path`, `source`, and up to three `callsites` (file + line). + 2. Build the catalog (see "Catalog format" below). Use the source content for every leaf; use callsite locations as **input to interpretation** when a leaf's purpose is not obvious from its name and structure alone — open the referenced files via `Read` and inspect the lines around each callsite to see how the leaf is used in practice. Spend this effort only on leaves whose role is genuinely ambiguous (a name like `Tag`, `ProxyType`, or `Implementation` warrants a look; `AddressHash` or `Timestamp` does not). + 3. Write atomically: + - Write the catalog to `/catalog.md.tmp`, then move to `/catalog.md`. + - Write the new digest to `/digest.tmp`, then move to `/digest`. + - The two `mv` commands ensure that a half-written cache cannot be mistaken for fresh. + 4. Report: `Catalog regenerated (digest=, leaves=).` + +4. **If a script fails** (non-zero exit, missing file, etc.), report the error verbatim and stop. The parent skill is responsible for falling back to live source reads — your job is to keep the cache truthful or absent, never wrong. + +## Catalog format + +The catalog is markdown. It must contain exactly these sections in this order: + +```markdown +# Leaf Type Schemas Catalog + +Auto-generated by the `leaf-schemas-cataloger` agent. Do not edit by hand — +changes are overwritten on the next regeneration. + +Source directory: `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/` + +## Scalar leaves + +Leaves whose top-level shape is a JSON primitive (`string` with pattern / +enum / format, `integer`, `boolean`). The "Spec" column captures the full +constraint at a glance. + +| Module | Spec | Purpose | +|---|---|---| +| `General.` | `` | | +| ... | + +## Composite leaves + +Leaves whose top-level shape is `object` or `array`. The "Shape" column +lists top-level fields (or array element kind) only; refer to the source +file for nested structure. + +| Module | Shape | Purpose | Source | +|---|---|---|---| +| `General.` | `object { field1, field2[]? }` | | `general/.ex` | +| ... | +``` + +### Classification rule + +A leaf is **scalar** if its `OpenApiSpex.schema(%{...})` map has `type: :string`, `type: :integer`, `type: :boolean`, or similar primitive at the top level (with optional `pattern:`, `enum:`, `format:`, `nullable:`, `minimum:`, etc.). It is **composite** if its top level is `type: :object`, `type: :array`, or contains `anyOf:` / `oneOf:` at the top level. + +### Spec column conventions (scalar table) + +- Write the type plainly: `string`, `integer`, `boolean`. +- Append constraints as a comma-separated list: `pattern 0x[a-f0-9]{40}`, `enum: [eip1167, eip1967, ...]`, `format: date-time`, `minimum: 0`, `nullable`. +- For nullable variants, use `string \| null` rather than a separate column. Escape pipe characters (`\|`) inside table cells whenever they appear — GitHub-Flavored Markdown table cells treat raw `|` as a column separator. +- When `pattern:` references `General._pattern()`, expand it to the actual regex if you can locate the constant in the source; otherwise write `pattern: General._pattern()`. +- When `enum:` is sourced at runtime from `Ecto.Enum.values(SomeModule, :field)`, look up the actual value list by reading the referenced Ecto schema module under `apps/explorer/lib/explorer/chain/` and inline the literal values in the Spec cell. Do **not** leave `Ecto.Enum.values(...)` in the catalog — the reader needs the concrete vocabulary. + +### Shape column conventions (composite table) + +- Depth = 1 only. Do not descend into nested objects or arrays of objects. +- Use `object { a, b, c }` for objects; mark optional fields with `?`. +- Use `array` or `array` for arrays. +- Use `anyOf [string, object, ...]` for top-level `anyOf` / `oneOf`. + +### Purpose column rules — important + +The "Purpose" cell must describe **what the schema represents and the kind of context it appears in**, in *general* terms. It must remain valid even if every current callsite is renamed or restructured. + +**Do**: +- Describe the modelled entity ("Ethereum address hash, lowercase 20-byte hex"). +- Describe the typical role ("nullable variant for properties that may be absent in proxy contracts"). +- Describe the data shape's meaning ("decoded event log parameters with name/type/value triples"). + +**Do not**: +- Name specific consumer controllers, views, schema modules, or files in the **Purpose** column (`address_controller.ex`, `BlockScoutWeb.Schemas.API.V2.Transaction`, etc.). +- Reference specific actions or endpoints (`/v2/blocks/...`, `:show_address`, etc.). +- Quote line numbers or counts of callers. +- Use words like "used by X" or "called from Y". + +Note: the **Source** column of the composite table is *not* a callsite reference — it points at the leaf's own definition file (`general/.ex`), which is structural metadata, not consumer context. Always populate it. + +Callsite line context is a tool for *you* to understand the leaf's purpose. It must not leak into the catalog text. If a callsite is the only thing that disambiguates the leaf's purpose, abstract the pattern: instead of "used by `address_controller.ex` to render private tags", write "user-applied label with name and type, attached to account records". + +## Atomic write recipe + +Always use temp-file-and-rename so that an interrupted run cannot leave a half-written catalog matching a fresh digest: + +```bash +printf '%s' "$CATALOG_CONTENT" > "$CACHE_DIR/catalog.md.tmp" +mv "$CACHE_DIR/catalog.md.tmp" "$CACHE_DIR/catalog.md" +printf '%s' "$NEW_DIGEST" > "$CACHE_DIR/digest.tmp" +mv "$CACHE_DIR/digest.tmp" "$CACHE_DIR/digest" +``` + +The `Write` tool itself is fine — write to `catalog.md.tmp` first, then `mv` via `Bash`. Do **not** write directly to `catalog.md` without the rename step. + +## What you do NOT do + +- You do not modify source files under `schemas/api/v2/general/`. The catalog is downstream of the code, never upstream. +- You do not edit the `openapi-spec` skill or its references. You only own the cache directory. +- You do not invoke other agents. +- You do not commit changes. The cache directory is gitignored. diff --git a/.agents/agents/openapi-spec-inspector.md b/.agents/agents/openapi-spec-inspector.md index db9151ab461..b8a28e1d667 100644 --- a/.agents/agents/openapi-spec-inspector.md +++ b/.agents/agents/openapi-spec-inspector.md @@ -54,4 +54,6 @@ The report should include: 6. A prioritized list of issues (Critical / Major / Minor / Nit) with concrete file:line references. 7. Suggested fixes (described, not applied). +Apply the finding-quality gates in `references/inspection-checklist.md` §7 before finalizing items (6) and (7). + Keep the report focused and actionable. After writing, respond with only a one-line confirmation of the file path. diff --git a/.agents/skills/alphabetically-ordered-aliases/SKILL.md b/.agents/skills/alphabetically-ordered-aliases/SKILL.md index 35919a3804f..0bea1ee6278 100644 --- a/.agents/skills/alphabetically-ordered-aliases/SKILL.md +++ b/.agents/skills/alphabetically-ordered-aliases/SKILL.md @@ -13,6 +13,8 @@ Elixir code style conventions prefer that module aliases are alphabetically orde - When organizing module aliases at the top of a file - When multiple aliases from related modules are defined together - When refactoring code to improve consistency and readability +- When adding a new alias into an existing `alias ...{...}` grouped block +- When touching files that already have `Credo.Check.Readability.AliasOrder` warnings ## Anti-Patterns (Avoid These) @@ -75,6 +77,15 @@ For modules with the same prefix like: Sort by the last component: `Create...` < `Drop...` < `Helper` +For grouped aliases like: + +```elixir +alias BlockScoutWeb.API.V2.{ApiView, Helper, InternalTransactionView, TokenView} +``` + +sort entries exactly as Credo expects by module name order within the group. +When two names share a long prefix (for example `InternalTransaction...`), compare the next character and keep strict lexical order. + ### Step 3: Reorder in code Rearrange the alias statements to match the alphabetical order determined in Step 2. @@ -87,6 +98,26 @@ Run Credo to ensure no warnings remain: mix credo --strict ``` +If you changed only a few files, prefer targeted checks first: + +```bash +mix credo --strict --files path/to/file1.ex,path/to/file2.ex +``` + +## Trigger Cues + +- Credo output includes: `The alias ... is not alphabetically ordered among its group` +- A diff adds/reorders aliases near module top +- A grouped alias block contains names that are visually close (`InternalTransaction...` vs `InternalTransactions...`) + +## Reliable Checklist + +1. Re-read every alias group in the touched file after edits. +2. Re-sort grouped aliases (inside `{...}`) and standalone aliases. +3. Ensure new aliases are inserted in-place, not appended. +4. Run `mix format` after edits. +5. Run Credo (targeted or full) and confirm no alias-order warnings remain. + ## Example Violations and Fixes ### Violation 1: Helper before DropTransactions diff --git a/.agents/skills/elixir-clause-grouping/SKILL.md b/.agents/skills/elixir-clause-grouping/SKILL.md index 3042f6d60a2..82d4d1a0ad2 100644 --- a/.agents/skills/elixir-clause-grouping/SKILL.md +++ b/.agents/skills/elixir-clause-grouping/SKILL.md @@ -7,6 +7,8 @@ description: Use when refactoring Elixir multi-clause functions, extracting help In Elixir modules, all clauses of the same function should stay together. Inserting a `defp` helper between clauses of a `def` or `defp` makes the function harder to read and can trigger Credo readability warnings. When shared logic needs to be extracted, keep the original clause group contiguous and place the helper after the full group. +This also applies to Phoenix view modules that define many `render/2` clauses with different templates: all `render/2` clauses still belong to the same function and must be contiguous. + ## When to Use - When refactoring a multi-clause `def` or `defp` @@ -14,12 +16,17 @@ In Elixir modules, all clauses of the same function should stay together. Insert - When addressing Credo warnings about clause grouping or readability - When editing controller, view, or context modules with several clauses of the same function - During review when a helper was added in the middle of another function's clauses +- When editing Phoenix `render/2` clauses and introducing helper functions nearby +- When adding any `defp` between two definitions that share the same function name/arity +- When you see compiler output like: `clauses with the same name and arity ... should be grouped together` ## Core Rule - Keep all clauses of the same function contiguous - Do not place `defp` helpers between clauses of another function - Extract shared logic into a helper placed after the full clause group +- Before finishing edits, scan up/down around each new helper and verify no same-name/same-arity clauses are split +- In view modules, keep all `render/2` clauses contiguous even if clause heads match different template strings ## Anti-Pattern @@ -58,6 +65,14 @@ end 3. Extract shared logic only after the full clause group. 4. Re-check that no unrelated `def` or `defp` appears inside the group. 5. Run formatting after the refactor. +6. For Phoenix views, explicitly verify all `render/2` clauses are contiguous (not only clauses for the same template). +7. If you add a helper used by `render/2`, place it after the last `render/2` clause in the module. + +## Trigger Cues + +- Compiler warning: `clauses with the same name and arity ... should be grouped together` +- Credo warning about grouping/splitting function clauses +- A diff that inserts `defp` between two `def render(` declarations ## Notes diff --git a/.agents/skills/openapi-spec/SKILL.md b/.agents/skills/openapi-spec/SKILL.md index d03a771545f..b74c3c45c18 100644 --- a/.agents/skills/openapi-spec/SKILL.md +++ b/.agents/skills/openapi-spec/SKILL.md @@ -25,7 +25,7 @@ All paths are relative to `apps/block_scout_web/lib/block_scout_web/`. Most endp | Account controllers (Private spec) | `controllers/account/api/v2/_controller.ex` | | Legacy controllers | `controllers/api/legacy/_controller.ex` (routed under `/legacy`) | | V2 schema modules | `schemas/api/v2/.ex` and `schemas/api/v2//*.ex` | -| V2 chain-type schema subdirs | `schemas/api/v2//*.ex` (e.g. `schemas/api/v2/{arbitrum,beacon,celo,optimism,scroll,zilliqa,mud}/*.ex`) | +| V2 chain-type schema subdirs | `schemas/api/v2//*.ex` (e.g. `schemas/api/v2/{arbitrum,beacon,celo,optimism,scroll,zilliqa}/*.ex`) | | V2 proxy schemas | `schemas/api/v2/proxy/*.ex` | | Account schemas (Private spec) | `schemas/api/v2/account/*.ex` | | Legacy schemas | `schemas/api/legacy/*.ex` | @@ -46,6 +46,12 @@ All paths are relative to `apps/block_scout_web/lib/block_scout_web/`. Most endp **On router coverage of the public spec:** `specs/public.ex` builds `paths` via `Paths.from_router(ApiRouter)`, which picks up everything reachable from `api_router.ex` — including endpoints declared in the sub-routers that `api_router.ex` `forward`s to (api-key, utils, address-badges). Only `TokensApiV2Router` and `SmartContractsApiV2Router` need the extra `Paths.from_routes(...)` merges in `public.ex` because their prefixes are stripped by Phoenix `forward` and must be re-added. A new annotated endpoint placed in any of the other sub-routers needs no extra wiring beyond the `forward` that already exists in `api_router.ex`. +## Leaf-schemas catalog (bootstrap step) + +Before any workflow below, invoke the `leaf-schemas-cataloger` agent (prompt: `Refresh the leaf-schemas catalog if stale.`). It maintains a compact catalog of every leaf type schema under `schemas/api/v2/general/` at `references/cache/leaf-schemas/catalog.md` (auto-generated, gitignored). Read that file when picking a primitive leaf type for a response property or parameter `schema:`. + +**Fallback:** if the agent fails or the catalog is unavailable, glob `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/*.ex` and read individual leaf files as needed. + ## Core patterns ### The operation macro @@ -144,7 +150,7 @@ The order of tag groups in the generated public spec is not derived from the con If a new annotated controller introduces a brand-new tag, the agent must register it in the right group, or the tag will still appear in the spec (via controller-side `tags(...)`) but with no ordering guarantee and no entry in the top-level `tags:` list: - Base endpoint → append the kebab-case tag to `@default_api_categories`. -- Chain-type endpoint → add it inside the relevant `case @chain_identity` branch, matching the existing patterns (module-attribute + `defp` for static lists, full `defp` body when the tag set depends on a runtime flag such as `mud_enabled?()`). +- Chain-type endpoint → add it inside the relevant `case @chain_identity` branch, matching the existing patterns (module-attribute + `defp` for static lists, full `defp` body when the tag set depends on a runtime flag). - Legacy endpoint → no action; `"legacy"` is already the trailer. Tags that are already covered by an existing group (e.g. another `addresses` endpoint) need no change. @@ -229,7 +235,7 @@ For each parameter the controller reads: - **Domain-specific but used by multiple operations in the same controller?** Add a private helper function in the controller itself. This avoids polluting `general.ex` with chain-specific concerns while preventing duplication across operations. - **Truly one-off (single operation)?** Define an inline `%OpenApiSpex.Parameter{}` struct directly in the `operation` macro arguments. -3. **For pagination parameters**, use `define_paging_params(field_names)` — pass the cursor field names as strings, and always include `"items_count"` (the `next_page_params` helper adds it to every cursor automatically). See `references/parameter-discovery.md` section "The `define_paging_params` factory" for details. +3. **For pagination parameters**, use `define_paging_params(field_names)` — pass the cursor field names as strings. See `references/parameter-discovery.md` section "The `define_paging_params` factory" for details. ### Step 3: Create or locate response schema @@ -365,6 +371,7 @@ Use this to audit an existing declaration for correctness, completeness, and adh | `references/inspection-checklist.md` | You're running an audit of an existing declaration (Workflow C) | | `references/spec-generation-and-verification.md` | You need to generate the spec YAML, validate it, or inspect specific operations/schemas with oastools | | `references/oastools-audit-recipes.md` | You want to audit the generated spec for spec-wide convention drift or reuse candidates, without reading every source file | +| `references/cache/leaf-schemas/catalog.md` | You need to pick a primitive leaf type (`General.AddressHash`, `General.IntegerString`, `General.Tag`, …) for a property or parameter — auto-generated by the `leaf-schemas-cataloger` agent; invoke it first if missing | ## Using subagents diff --git a/.agents/skills/openapi-spec/references/cache/.gitignore b/.agents/skills/openapi-spec/references/cache/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/.agents/skills/openapi-spec/references/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.agents/skills/openapi-spec/references/inspection-checklist.md b/.agents/skills/openapi-spec/references/inspection-checklist.md index ed39b7183d1..abd8c7a7005 100644 --- a/.agents/skills/openapi-spec/references/inspection-checklist.md +++ b/.agents/skills/openapi-spec/references/inspection-checklist.md @@ -73,7 +73,7 @@ The view layer is lossy about types. A field that renders as a plain string may 1. Find the corresponding Ecto schema module in `apps/explorer/lib/explorer/chain/`. Grep for `Ecto.Enum` in that file. 2. If the field is an `Ecto.Enum`, the OpenAPI property should use `enum: [...]` with the correct values, not just `type: :string`. 3. If the property already uses `enum:`, verify the values are **complete and current** by comparing against the Ecto enum definition. New values may have been added to the Ecto schema without updating the OpenAPI schema — this is a silent breakage where `CastAndValidate` rejects the new value on input. -4. Verify there is a code comment on the enum property pointing to the source Ecto field (e.g., `# Enum values must be kept in sync with Explorer.Chain. : field.`). If missing, add one. +4. If the property lists enum values literally (`enum: ["a", "b"]`), verify there's a sync comment pointing to the source Ecto field (e.g., `# Enum values must be kept in sync with Explorer.Chain. : field.`). Skip the check when values are pulled in at compile time — shared leaf schema, helper call like `SomeView.status_enum()`, or module attribute — since there is no copy to drift. If missing, add one. 5. Check existing schemas in the same domain for precedent — similar entities often already use enum for comparable fields. Also check for other type refinements: large integers serialized as strings should use `IntegerString`, hash fields should use `FullHash`/`AddressHash`, timestamps should use `Timestamp`/`TimestampNullable`. @@ -86,7 +86,9 @@ Every key the view always emits should be in `required:`. Keys that are conditio - Not be in `required:` (if the key might be absent) - Be in `required:` but have `nullable: true` on the schema (if the key is always present but sometimes null) -**Scope.** "Always emits" and "sometimes `nil`" refer to the render paths reachable via the **endpoints that currently reference this schema** in their `operation/2` `responses:`. Before flagging a `nullable: false` property as wrong: +`nullable: false` is the OpenAPI 3.0 default — file a nullability finding only when a nilable field lacks `nullable: true`, never to add or change `nullable: false`. + +**Scope.** "Always emits" and "sometimes `nil`" refer to the render paths reachable via the **endpoints that currently reference this schema** in their `operation/2` `responses:`. Before flagging a not-nullable property as wrong (i.e., claiming it should be `nullable: true`): 1. Enumerate those call sites mechanically: `Grep "Schemas\.\b" apps/block_scout_web/lib/block_scout_web/controllers`. 2. For each call site, check the controller's `necessity_by_association` / explicit `Repo.preload/2` / other data-shaping code to determine whether the value can in fact reach the view as `nil`. @@ -140,6 +142,16 @@ To find all endpoints whose 200 response currently uses `oneOf` (for precedent), This check is especially important for endpoints that filter by a specific discriminator value (e.g., a DA-type lookup that always returns one DA variant, but references a shared batch schema containing all DA variants). +### 2h. No `allOf: [Leaf]` overlay over `general/` primitives + +Never wrap a `general/` leaf as `%Schema{allOf: [Leaf], description: ...}` to attach `description:`/`example:`/`nullable:`. `OpenApiSpex.Cast.AllOf` enumerates per-branch results as maps and fails at runtime on non-Enumerable structs (`%DateTime{}`, `%Decimal{}`) produced by leaves with non-trivial `format:` casts. Use `Helper.extend_schema/2` at the property position instead. + +Source-side scan: `Grep "allOf: \[" apps/block_scout_web/lib/block_scout_web/schemas` and triage each hit. + +Severity: **Major** for primitive leaves with non-trivial `format:` casts (`Timestamp`, `Decimal`-typed, anything casting to a struct) — `CastAndValidate` will reject the response at runtime. **Minor** for object leaves (`Address`) where the cast succeeds but the convention is still violated. + +See `references/schema-conventions.md` section "Helper.extend_schema/2". + ## 3. Convention compliance ### 3a. Controller prerequisites @@ -194,6 +206,10 @@ See `references/schema-conventions.md` for full conventions. `BlockScoutWeb.Schemas.API.V2.` for base schemas, `BlockScoutWeb.Schemas.API.V2..Response` for response wrappers. +**Chain-namespaced schemas (under `schemas/api/v2//`) must declare an explicit `title: "."`.** Without it, the bare module name keys `components.schemas` and collides with same-named schemas from other chains when the all-in-one swagger merge unions per-chain specs (first-wins → silent substitution). Source-side scan: list files under `schemas/api/v2//` and check each `OpenApiSpex.schema(%{...})` block for a prefixed `title:`. + +See `references/schema-conventions.md` section "Chain-specific schemas". + ## 5. Verification After identifying and fixing issues from sections 1-4, run the verification ladder described in the "Verification" section of `SKILL.md` (compile → generate-spec → controller tests). Each step catches a different class of problems, and earlier steps are faster. @@ -251,6 +267,22 @@ Independent of any single-endpoint audit, run this sweep once per work session t Full recipes in `references/oastools-audit-recipes.md`. Results belong in the "Convention deviations" section of the audit output below. +## 7. Finding-quality gates + +Apply these gates **after** identifying a candidate issue but **before** writing it into the report. They reduce two failure modes: overengineered suggested fixes, and Minor/Nit findings that are taste rather than rule violations. + +### Gate 1 — Suggested fix is minimum-viable + +Propose the smallest edit that resolves the finding. Do not bundle new modules, shared schemas, or helpers into the fix unless **≥2 real duplicates already exist** in the audited code. + +If a larger refactor is also worth doing, file it as a separate Nit titled `Follow-up: consolidate `. Severity is independent of fix size — a trivial fix does not downgrade a Critical finding. + +### Gate 2 — Minor and Nit findings must cite a rule + +Every Minor/Nit finding must cite a section in one of the references (e.g., `inspection-checklist.md §2b`, `schema-conventions.md` "Nullable fields", `oastools-audit-recipes.md` Recipe N). If no section fits, drop the finding — or, if it actually violates an implicit rule, raise the severity and cite. + +**Critical/Major are exempt.** They may violate implicit rules that no single section captures cleanly. Do not downgrade Critical/Major to Nit to make this gate applicable — that is the failure mode this gate is *not* trying to enable. + ## Audit output After completing the checklist, summarize findings as: diff --git a/.agents/skills/openapi-spec/references/parameter-discovery.md b/.agents/skills/openapi-spec/references/parameter-discovery.md index 1da0c57f7e9..3a1c92fb552 100644 --- a/.agents/skills/openapi-spec/references/parameter-discovery.md +++ b/.agents/skills/openapi-spec/references/parameter-discovery.md @@ -143,8 +143,6 @@ define_paging_params(["index", "block_number", "batch_log_index"]) This creates one `%Parameter{in: :query, required: false}` per field name. The string names are converted to atoms as the parameter `:name`. Each gets an `IntegerString` schema by default. -**Always include `"items_count"`** in the field list. The `next_page_params/5` function in `chain.ex` unconditionally adds `items_count` to every pagination cursor. If the operation doesn't declare it as a query param, CastAndValidate will reject next-page requests with "Unexpected field: items_count." Example: `define_paging_params(["id", "items_count"])`. - There are specialized variants: - `define_state_changes_paging_params/1` — for state change pagination - `define_search_paging_params/1` — for search result pagination (uses object params) diff --git a/.agents/skills/openapi-spec/references/schema-conventions.md b/.agents/skills/openapi-spec/references/schema-conventions.md index 5750a10636a..79f4df90119 100644 --- a/.agents/skills/openapi-spec/references/schema-conventions.md +++ b/.agents/skills/openapi-spec/references/schema-conventions.md @@ -28,9 +28,9 @@ Create a subdirectory when 2+ sub-schemas exist for a domain entity. Simple leaf ### Shared primitives in `general/` -`general/` contains ~22 reusable type schemas: `AddressHash`, `FullHash`, `IntegerString`, `Timestamp`, nullable variants, etc. These are leaf schemas referenced by property types across all domain schemas. +`general/` contains ~24 reusable type schemas: `AddressHash`, `FullHash`, `IntegerString`, `Timestamp`, nullable variants, etc. These are leaf schemas referenced by property types across all domain schemas. -To discover available primitives, glob `schemas/api/v2/general/*.ex`. +To discover available primitives, consult the auto-generated catalog at `references/cache/leaf-schemas/catalog.md` — it lists every leaf with its type, constraints, and intended purpose, and is the fastest way to pick the right primitive without reading 24 source files. The catalog is maintained by the `leaf-schemas-cataloger` agent (see SKILL.md §"Leaf-schemas catalog (bootstrap step)"). If the catalog is unavailable, fall back to globbing `schemas/api/v2/general/*.ex` and reading individual files. ### Domain-scoped shared schemas @@ -113,6 +113,8 @@ Note: the "keep in sync" comment lives in the leaf module. Schemas that referenc Chain-specific schemas get top-level subdirectories: `optimism/`, `scroll/`, `celo/`, `zilliqa/`, `beacon/`. These map to chain-conditional router scopes. +**Every schema under a chain subdir must set `title: "."` explicitly.** Without it, OpenApiSpex auto-derives the title from the bare module name (`Batch`, `Message`, …) and the all-in-one swagger merge in `.github/workflows/generate-swagger.yml` (first-wins union over `components.schemas`) silently drops same-keyed schemas from other chains. The prefix tracks the directory: `arbitrum/batch.ex` → `"Arbitrum.Batch"`. Chain-agnostic schemas keep their bare title. + ### File naming Snake_case files, CamelCase modules: `transaction/fee.ex` contains `BlockScoutWeb.Schemas.API.V2.Transaction.Fee`. @@ -129,7 +131,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.General.FullHash do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General - OpenApiSpex.schema(%{type: :string, pattern: General.full_hash_pattern(), nullable: false}) + OpenApiSpex.schema(%{type: :string, pattern: General.full_hash_pattern()}) end ``` @@ -244,34 +246,58 @@ OpenApiSpex.schema( The pattern is: base schema -> extend with metadata -> apply chain-type fields. -### Helper.extend_schema/2 +### Helper.extend_schema/2 — define a new schema by extending another + +Located at `schemas/helper.ex`. Merges `:properties`, `:required`, `:title`, `:description`, `:nullable`, `:enum` into a base schema. Properties are merged, required lists concatenated, scalars replaced. -Located at `schemas/helper.ex`. Merges `:properties`, `:required`, `:title`, `:description`, `:nullable`, and `:enum` into an existing schema map: -- Properties are merged (new keys added, existing overwritten) -- Required lists are concatenated -- Scalar fields (title, description, nullable) are replaced +**Use only inside an outer `OpenApiSpex.schema(…)` macro** that re-establishes a new component identity (fresh `:title` + `x-struct`). Examples: `AddressNullable` (extends `Address` with `nullable: true`), `Arbitrum.Message` (extends `MinimalMessage`), `*Response` wrappers. ```elixir -schema_map -|> Helper.extend_schema( - title: "ExtendedSchema", - properties: %{new_field: %Schema{type: :string}}, - required: [:new_field] +OpenApiSpex.schema( + Base.schema() + |> Helper.extend_schema( + title: "Extended", # REQUIRED on every extend — see "Schema reuse and naming" + properties: %{new_field: ...}, + required: [:new_field] + ) ) ``` -**Reusing a leaf primitive with a per-property override.** When you want the type/format/nullable of a `general/` leaf (`Timestamp`, `IntegerString`, `FullHash`, …) *and* a per-property field like `description:`, `example:`, or a flipped `nullable:`, use `Helper.extend_schema` at the property position: +**Do not use at a property position** to overlay attributes on a `general/` leaf (`Timestamp`, `IntegerString`, `FullHash`, …). The result keeps the leaf's `:title` and `x-struct`, so `OpenApiSpex.resolve_schema_modules/1` re-registers it as `components.schemas.` (last-encounter wins). The overlay leaks onto the global component and every `$ref` to it across the spec. + +### Helper.describe_inline/2 — per-property description overlay on a primitive leaf + +Use at a property position when you want a leaf's exact type/format/nullable plus a per-property `description:`. Strips `:title` and `x-struct` (sets both to `nil`) so the result renders inline at the call site and the global component is untouched. ```elixir timestamp: - Helper.extend_schema(General.Timestamp.schema(), - description: "Block timestamp of the parent transaction." + Helper.describe_inline( + General.Timestamp.schema(), + "Block timestamp of the parent transaction." ) ``` -This inlines the leaf's shape and overlays the description, no `allOf` casting layer involved. +**Intentionally narrow — description only.** If a property needs a different `pattern:`, flipped `nullable:`, or any other constraint, define a new primitive in `schemas/api/v2/general/` and reference it. Do not reach for `extend_schema` at the property position to bend a leaf. -Avoid `%Schema{allOf: [Leaf], description: "..."}` as the overlay mechanism. `allOf` is for *object* composition: it works fine for object leaves like `Address` (and `advanced_filter.ex` does this for `from`/`to`/`created_contract`), but for primitive leaves with a non-trivial `format:` cast — `Timestamp`'s `format: :"date-time"` casts strings to `%DateTime{}`, `Decimal`-typed leaves cast to `%Decimal{}` — `OpenApiSpex.Cast.AllOf` enumerates the per-branch results as maps and fails on non-Enumerable structs. The `extend_schema` form sidesteps the cast composition entirely. +### Picking the right per-property overlay + +Two leaf shapes exist; treat them separately: + +- **Primitive leaf** — `type: :string` / `:integer` / `:number` / `:boolean`. Examples: `General.Timestamp`, `General.IntegerString`, `General.FullHash`, `General.AddressHash`. The schema body is 1–3 fields; inlining is cheap. +- **Object leaf** — `type: :object` with its own `properties`. Examples: `Address`, `Token`, `Arbitrum.MinimalMessage`. The schema body is many fields; inlining duplicates the entire structure at every call site and breaks `$ref` dedup. + +| Goal | Mechanism | Why | +|---|---|---| +| Description on a primitive leaf | `Helper.describe_inline(Leaf.schema(), "…")` | Inlines 1–3 fields cheaply; `$ref` dedup not meaningful at that size. `allOf` would crash at runtime when the leaf has a `format:` cast (`Timestamp` → `%DateTime{}`, `Decimal` → `%Decimal{}`) because `OpenApiSpex.Cast.AllOf` enumerates per-branch results as maps and fails on non-Enumerable structs. | +| Description (and/or `nullable:` flip) on an object leaf | `%Schema{allOf: [Leaf], description: "…", nullable: true}` | Keeps `$ref: '#/components/schemas/<Leaf>'`, so the object's properties stay deduplicated across the spec. No runtime crash because object branches all cast to maps. See `advanced_filter.ex` `from`/`to`/`created_contract`. | +| Any per-property override beyond description on either leaf shape (pattern, enum, narrower bounds, …) | Define a new primitive in `general/`, then reference it | Pushes `general/` to grow as a real library of reusable primitives instead of every domain bending a leaf locally. | +| Derived schema with its own component identity (response wrapper, nullable variant of an object, …) | `OpenApiSpex.schema(Base.schema() \|> Helper.extend_schema(title: …, …))` | The outer macro re-establishes `:title` + `x-struct`, so the result is a new registered component, not a property-position overlay. | + +**Anti-patterns — do not use:** + +- `Helper.extend_schema(Leaf.schema(), description: "…")` at a property position → leaks the description onto `components.schemas.<Leaf>` (last-encounter wins) and pollutes every `$ref` to that leaf across the spec. +- `Helper.describe_inline(ObjectLeaf.schema(), "…")` at a property position → technically works, but inlines the entire object structure at every call site and kills `$ref` dedup; tests still pass, so the bloat goes unnoticed in review. +- `%Schema{allOf: [PrimitiveLeaf], description: "…"}` at a property position → crashes at runtime on primitive leaves with `format:` casts. ### Schema reuse and naming for related schemas @@ -478,14 +504,14 @@ Note: `minimum:` is a JSON-Schema *numeric* keyword and is silently ignored on ` ### Required: Ecto.Enum sync comments -**Every `enum:` property in an OpenAPI schema must have a comment pointing to the source Ecto field.** There is no automatic sync between Ecto enums and OpenAPI enums — if someone adds a new value to the Ecto enum without updating the OpenAPI schema, `CastAndValidate` will reject the new value on input, and test-time validation will fail on output only if a test exercises that specific value. The comment is the only signal that tells the next developer where to look. +**Every `enum:` property whose values are listed literally in the OpenAPI schema must have a comment pointing to the source Ecto field.** There is no automatic sync between Ecto enums and OpenAPI enums — if someone adds a new value to the Ecto enum without updating the OpenAPI schema, `CastAndValidate` will reject the new value on input, and test-time validation will fail on output only if a test exercises that specific value. The comment is the only signal that tells the next developer where to look. Format: ```elixir # Enum values must be kept in sync with Explorer.Chain.<Module> :<field_name> field. ``` -When using a shared enum leaf schema (see "Domain-scoped shared schemas" above), the comment lives in the leaf module only — schemas that reference it don't need their own copy. When using an inline enum, the comment goes directly above the `%Schema{type: :string, enum: [...]}` definition. +When the property pulls values from the source at compile time — a shared enum leaf schema, a helper call like `SomeView.status_enum()`, or a module attribute — there is no separate copy to drift, so no sync comment is needed (the shared leaf module, if any, still carries the comment). When using a literal inline enum, the comment goes directly above the `%Schema{type: :string, enum: [...]}` definition. Before writing an inline enum, check existing schemas in the same domain — if another schema already defines the same enum, extract it into a shared leaf schema instead of duplicating it (and the comment). @@ -508,6 +534,8 @@ batch_data_container: %Schema{ ### Nullable fields +`nullable: false` is the OpenAPI 3.0 default — never declare it explicitly. Only set `nullable: true` when the field can be `nil`. + If the Ecto schema field can be `nil` (not in `@required_attrs`, or the view conditionally emits it), the OpenAPI property should have `nullable: true`. If the key is always present but sometimes null, keep it in `required:` and set `nullable: true`. If the key is sometimes absent entirely, remove it from `required:`. **Do not use `type: :null` in `anyOf`/`oneOf`, and do not use array types like `type: [:string, :null]`.** These are OpenAPI 3.1 / JSON Schema 2020-12 patterns. Blockscout's spec is OpenAPI 3.0 (open_api_spex v3.22), which predates the null type and supports nullability exclusively through the `nullable: true` keyword. diff --git a/.agents/skills/openapi-spec/scripts/leaf-schemas-cache-check.sh b/.agents/skills/openapi-spec/scripts/leaf-schemas-cache-check.sh new file mode 100755 index 00000000000..7466b687d3b --- /dev/null +++ b/.agents/skills/openapi-spec/scripts/leaf-schemas-cache-check.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Freshness check for the leaf-schemas catalog cache. +# +# Computes a SHA-256 digest over every `*.ex` under +# `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/` and +# compares it to the digest stored alongside the cached catalog. +# +# Output (stdout, three lines, plain text key=value): +# state=fresh|stale +# digest=<current-digest> +# cache_dir=<absolute-path-to-cache-dir> +# +# Exit code is always 0 unless something genuinely broke (missing schemas +# dir, missing hashing tool). The cataloger agent decides what to do based +# on the `state=` line. + +set -euo pipefail + +# Resolve repo root via git — robust to the script being moved within the +# repo, unlike counting `../..` levels from $0. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) +SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd) + +SCHEMAS_DIR="$REPO_ROOT/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general" +CACHE_DIR="$SKILL_DIR/references/cache/leaf-schemas" +DIGEST_FILE="$CACHE_DIR/digest" +CATALOG_FILE="$CACHE_DIR/catalog.md" + +if [[ ! -d "$SCHEMAS_DIR" ]]; then + echo "ERROR: schemas dir not found: $SCHEMAS_DIR" >&2 + exit 2 +fi + +# Pick a hashing tool. macOS has shasum, Linux usually has sha256sum. +hash_tool() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 + else + echo "ERROR: no sha256 tool found (need sha256sum or shasum)" >&2 + exit 2 + fi +} + +# Compute digest over sorted, NUL-delimited list of leaf files (avoids +# whitespace issues). Hash each file's contents, then hash the concatenation. +CURRENT_DIGEST=$( + find "$SCHEMAS_DIR" -maxdepth 1 -type f -name '*.ex' -print0 \ + | sort -z \ + | xargs -0 cat \ + | hash_tool \ + | cut -d' ' -f1 +) + +STATE=stale +if [[ -s "$DIGEST_FILE" && -s "$CATALOG_FILE" ]]; then + STORED_DIGEST=$(cat "$DIGEST_FILE") + if [[ "$STORED_DIGEST" == "$CURRENT_DIGEST" ]]; then + STATE=fresh + fi +fi + +echo "state=$STATE" +echo "digest=$CURRENT_DIGEST" +echo "cache_dir=$CACHE_DIR" diff --git a/.agents/skills/openapi-spec/scripts/leaf-schemas-collect-facts.py b/.agents/skills/openapi-spec/scripts/leaf-schemas-collect-facts.py new file mode 100755 index 00000000000..d495f39fb94 --- /dev/null +++ b/.agents/skills/openapi-spec/scripts/leaf-schemas-collect-facts.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Fact gatherer for the leaf-schemas catalog. + +For every `*.ex` file under +`apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/`, emits: + - the canonical module name (`defmodule …` line) + - the short name (`Foo` from `BlockScoutWeb.Schemas.API.V2.General.Foo`) + - the file path (repo-relative) + - the full source text + - up to N callsites elsewhere under `schemas/api/v2/**` (file + line) + +The cataloger agent consumes this JSON and uses the callsites only as +*input* to interpretation — they should not appear in the final catalog, +so callsite churn does not invalidate the cache. + +The script is deliberately dumb: no semantic interpretation, no AST +parsing, no filtering by relevance. It enumerates and hands material off. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import subprocess +import sys + +# Resolve repo root via git — robust to the script being moved within the +# repo, unlike counting `.parents[N]` levels from __file__. +SCRIPT_PATH = pathlib.Path(__file__).resolve() +REPO_ROOT = pathlib.Path( + subprocess.check_output( + ["git", "-C", str(SCRIPT_PATH.parent), "rev-parse", "--show-toplevel"], + text=True, + ).strip() +) + +SCHEMAS_DIR = ( + REPO_ROOT + / "apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general" +) +# Leaves are referenced from both schema modules and controllers (the +# latter for inline `%Parameter{schema: General.Foo}` definitions). Scope +# to the block_scout_web lib tree — broad enough to catch all callsites, +# narrow enough to keep grep fast and avoid test/fixture noise. +CALLSITE_SEARCH_ROOT = ( + REPO_ROOT / "apps/block_scout_web/lib/block_scout_web" +) + +# Cap callsites per leaf. Too many = noisy; too few = thin context. Three +# distinct files is enough to disambiguate purpose for any reasonable leaf. +MAX_CALLSITES = 3 + +DEFMODULE_RE = re.compile(r"^\s*defmodule\s+([\w.]+)\s+do\s*$", re.MULTILINE) + + +def main() -> int: + if not SCHEMAS_DIR.is_dir(): + print( + f"ERROR: schemas dir not found: {SCHEMAS_DIR}", + file=sys.stderr, + ) + return 2 + + leafs = [] + for path in sorted(SCHEMAS_DIR.glob("*.ex")): + leafs.append(_collect_leaf(path)) + + json.dump( + {"leafs": leafs}, + sys.stdout, + indent=2, + ensure_ascii=False, + ) + sys.stdout.write("\n") + return 0 + + +def _collect_leaf(path: pathlib.Path) -> dict: + source = path.read_text() + match = DEFMODULE_RE.search(source) + full_module = match.group(1) if match else None + short_name = ( + full_module.rsplit(".", 1)[-1] if full_module else path.stem + ) + return { + "module": full_module, + "short_name": short_name, + "path": str(path.relative_to(REPO_ROOT)), + "source": source, + "callsites": _find_callsites(short_name, exclude_path=path), + } + + +def _find_callsites( + short_name: str, + exclude_path: pathlib.Path, +) -> list[dict]: + """Find up to MAX_CALLSITES references to a leaf outside its own file. + + Matches either `General.<ShortName>` (qualified) or bare `<ShortName>` + (used in files that do `alias …General.{X, Y}` and reference X without + the prefix). Bare-name false positives on these PascalCase identifiers + are rare; the cataloger agent reads each callsite's line context + anyway and can sanity-check. + + Uses `\\b` to avoid prefix-matching (e.g. `AddressHash` matching + `AddressHashNullable`). One result per distinct file to spread coverage + rather than collapsing on a single popular caller. + """ + pattern = rf"\b(?:General\.)?{re.escape(short_name)}\b" + try: + proc = subprocess.run( + [ + "grep", + "-rEn", + "--include=*.ex", + pattern, + str(CALLSITE_SEARCH_ROOT), + ], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return [] + + if proc.returncode not in (0, 1): + # 1 = no matches; anything else is a real error + print( + f"WARN: grep failed for {short_name}: {proc.stderr.strip()}", + file=sys.stderr, + ) + return [] + + callsites: list[dict] = [] + seen_files: set[str] = set() + exclude_str = str(exclude_path) + for line in proc.stdout.splitlines(): + # Format: "<path>:<lineno>:<content>" + parts = line.split(":", 2) + if len(parts) < 3: + continue + filepath, lineno_str, content = parts + if filepath == exclude_str: + continue + if _is_alias_line(content, short_name): + # Alias declarations point Haiku at the import, not the real + # usage — skip and let a later line in the same file win. + continue + if filepath in seen_files: + continue + seen_files.add(filepath) + try: + lineno = int(lineno_str) + except ValueError: + continue + try: + rel = pathlib.Path(filepath).resolve().relative_to(REPO_ROOT) + except ValueError: + rel = pathlib.Path(filepath) + callsites.append({"file": str(rel), "line": lineno}) + if len(callsites) >= MAX_CALLSITES: + break + return callsites + + +_ALIAS_LINE_RE = re.compile(r"^\s*alias\b") + + +def _is_alias_line(content: str, short_name: str) -> bool: + """Heuristic: is this line an `alias` declaration rather than real use? + + Catches both single-line aliases (`alias Foo.NullString`) and entries + inside multi-line alias blocks (` NullString,` or ` NullString`). + """ + stripped = content.strip() + if _ALIAS_LINE_RE.match(content): + return True + # Multi-line `alias Foo.{Bar, Baz}` entries appear as bare-name tokens, + # optionally with a trailing comma. + if stripped.rstrip(",") == short_name: + return True + return False + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 324a6d2eb81..8cef4b830bc 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -38,7 +38,8 @@ RUN apt-get update \ && npm install -g cspell@latest \ # # Install dependencies - && apt-get install -y build-essential inotify-tools \ + && apt-get install -y build-essential inotify-tools ffmpeg pkg-config \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavdevice-dev \ # # Clean up && apt-get autoremove -y \ diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 170fe3dbb0f..382aae46e53 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -9,5 +9,6 @@ {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 1}, {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :exact_eq, 15}, {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 15}, - ~r/lib\/phoenix\/router.ex/ + ~r/lib\/phoenix\/router.ex/, + ~r/Poison\.Encoder/ ] diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 68ed91347de..8be627a76a5 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -127,6 +127,11 @@ jobs: restore-keys: | ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash- + - name: Install FFmpeg development libraries + run: | + sudo apt-get update + sudo apt-get install -y pkg-config ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavdevice-dev + - name: Conditionally build Mix deps cache if: steps.deps-cache.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index 30171d27d5a..1bebba416e2 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.3.3 + RELEASE_VERSION: 12.0.0 jobs: matrix-builder: @@ -86,6 +86,10 @@ jobs: restore-keys: | ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash- + - name: Install FFmpeg development libraries + run: | + sudo apt-get update + sudo apt-get install -y pkg-config ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavdevice-dev - name: Conditionally build Mix deps cache if: steps.deps-cache.outputs.cache-hit != 'true' run: | @@ -129,15 +133,6 @@ jobs: mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Public openapi.${{ matrix.chain-type }}.yaml --start-app=false env: CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} - MUD_INDEXER_ENABLED: false - - - name: Generate MUD-enabled spec for Optimism - if: matrix.chain-type == 'optimism' - run: | - mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Public openapi.mud.yaml --start-app=false - env: - CHAIN_TYPE: optimism - MUD_INDEXER_ENABLED: true - name: Upload OpenAPI spec uses: actions/upload-artifact@v4 @@ -146,14 +141,6 @@ jobs: path: openapi.${{ matrix.chain-type }}.yaml retention-days: 1 - - name: Upload MUD-enabled spec - if: matrix.chain-type == 'optimism' - uses: actions/upload-artifact@v4 - with: - name: openapi-spec-mud - path: openapi.mud.yaml - retention-days: 1 - push-specs: needs: - generate-swagger @@ -255,16 +242,10 @@ jobs: if [ -f "$SPEC_FILE" ]; then FILENAME=$(basename "$SPEC_FILE") - # Handle MUD spec specially - if [ "$FILENAME" = "openapi.mud.yaml" ]; then - mkdir -p "api-specs/blockscout/${VERSION}/mud" - cp "$SPEC_FILE" "api-specs/blockscout/${VERSION}/mud/swagger.yaml" - else - # Extract chain type from filename (openapi.CHAINTYPE.yaml) - CHAIN_TYPE=$(echo "$FILENAME" | sed 's/openapi\.\(.*\)\.yaml/\1/') - mkdir -p "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}" - cp "$SPEC_FILE" "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}/swagger.yaml" - fi + # Extract chain type from filename (openapi.CHAINTYPE.yaml) + CHAIN_TYPE=$(echo "$FILENAME" | sed 's/openapi\.\(.*\)\.yaml/\1/') + mkdir -p "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}" + cp "$SPEC_FILE" "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}/swagger.yaml" fi done diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index ec5606313a6..052b8d09dc6 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.3.3 + RELEASE_VERSION: 12.0.0 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 57e6632ec67..cbea00592d7 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.3.3 + RELEASE_VERSION: 12.0.0 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 236a816e068..f679a766d46 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.3.3 + RELEASE_VERSION: 12.0.0 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 60dd7d0b3d9..5af74f846b1 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.3.3 + RELEASE_VERSION: 12.0.0 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 06fc0e79b51..222f9a15186 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.3.3 + RELEASE_VERSION: 12.0.0 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 3c343ecfda3..b0c2691a58b 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.3.3 + RELEASE_VERSION: 12.0.0 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 8b884a6aba0..9058da092b6 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.3.3 + RELEASE_VERSION: 12.0.0 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 9bddf57c785..2388c67b296 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.3.3 + RELEASE_VERSION: 12.0.0 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 33ae2115691..6abe72e98e7 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.3.3 + RELEASE_VERSION: 12.0.0 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 7e5bdb340c7..15ee5398215 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.3.3 + RELEASE_VERSION: 12.0.0 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 8809869e14f..5ddc66cad1a 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.3.3 + RELEASE_VERSION: 12.0.0 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 bc37cee7ceb..374b7e6f433 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 7186fcbf5ef..b108b16740a 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/publish-api-types-npm.yml b/.github/workflows/publish-api-types-npm.yml index 86f818b2087..2e0e017cda7 100644 --- a/.github/workflows/publish-api-types-npm.yml +++ b/.github/workflows/publish-api-types-npm.yml @@ -7,9 +7,8 @@ on: description: "Package version (semver or Git tag, e.g. 1.0.0 or v1.0.0-beta.1)" required: true type: string - # todo: re-enable once all fixes to OpenApi schemas will be made - # release: - # types: [published] + release: + types: [published] workflow_call: inputs: package_version: diff --git a/.github/workflows/publish-docker-image-custom-build.yml b/.github/workflows/publish-docker-image-custom-build.yml index 7e7364bde3a..fbb4f1e8f20 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.3.3 + RELEASE_VERSION: 12.0.0 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 fdb981e85b4..0ffd33434b5 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.3.3 + RELEASE_VERSION: 12.0.0 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index ae42fe7163b..fdd73bf7fb2 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.3.3 + RELEASE_VERSION: 12.0.0 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 c8848c853ca..5d6e8d89d3b 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.3.3 + RELEASE_VERSION: 12.0.0 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 5800cd92196..6fe5c6360ab 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.3.3 + RELEASE_VERSION: 12.0.0 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 fef72f13d6b..7a5efef92da 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.3.3 + RELEASE_VERSION: 12.0.0 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 263495c9a37..9b2fc413b79 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.3.3 + RELEASE_VERSION: 12.0.0 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 10752942cd7..0130ed94564 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.3.3 + RELEASE_VERSION: 12.0.0 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 54f0234789a..fb83e0c6f09 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.3.3 + RELEASE_VERSION: 12.0.0 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 13b3215ac21..e5331c7be4b 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.3.3 + RELEASE_VERSION: 12.0.0 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 d756516e86d..f0609054991 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.3.3 + RELEASE_VERSION: 12.0.0 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 d0d34be53c0..7e5b4cd1e46 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.3.3 + RELEASE_VERSION: 12.0.0 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 255c2f04b1b..c177ce62fff 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.3.3 + RELEASE_VERSION: 12.0.0 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 916d3ca8154..07f3e3b452c 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.3.3 + RELEASE_VERSION: 12.0.0 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 527d6db1016..7c99dfd5be7 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.3.3 + RELEASE_VERSION: 12.0.0 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 b35d53276c2..7c0d505ff3b 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.3.3 + RELEASE_VERSION: 12.0.0 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 81eb56a876d..e915c23ce7a 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.3.3 + RELEASE_VERSION: 12.0.0 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 cc1f91c6ca9..c24efe167ec 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index 58ae4b6d5ad..7b88b2487c4 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index 5775d7825b4..0a1461c9f39 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.3.3 + RELEASE_VERSION: 12.0.0 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 51726b50931..e9661326387 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index 0b784aea780..fa1a45d05d6 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 064ee79bd7d..55c0513a2ac 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index 775d27f45af..e0104329a50 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index e0a7310f06a..d117707453c 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index 8b94a2898fa..da4b4de2fe8 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index 1df57481158..c95c82bb8eb 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index bf07a3764b3..d47a9b43a10 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index fa371bffdcc..c5e93f72bac 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index 7494055ab91..320a1b2ed72 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index 7bf65163865..e1c795d9fd9 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index 2083327dde7..2ebf5a0c197 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.3.3 + RELEASE_VERSION: 12.0.0 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.gitignore b/.gitignore index 0d1d5db737b..6eee0984e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,8 +58,10 @@ dump.rdb *.iml .vscode +.cursor/ .cursorignore .cursorrules +.cursor .elixir_ls .claude/settings.local.json .claude/commands/review-pr.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 286bb2cacc0..8fe0e091f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,182 @@ # Changelog +## 12.0.0 + +### 🚀 Features + +- Add ERC-8056 token type ([#14731](https://github.com/blockscout/blockscout/pull/14731)) +- Migrate Sourcify integration from API v1 to v2 ([#14584](https://github.com/blockscout/blockscout/pull/14584)) +- Add universal merged API types, operation shorthands, and API v2 schema correctness improvements ([#14515](https://github.com/blockscout/blockscout/pull/14515)) +- Add an option to disable core proxy methods in the Ethereum JSON-RPC API ([#14495](https://github.com/blockscout/blockscout/pull/14495)) +- Add ETH Swarm (bzz://) support for token metadata ([#14446](https://github.com/blockscout/blockscout/pull/14446), [#14744](https://github.com/blockscout/blockscout/pull/14744)) +- Add ENS and metadata preloads to advanced filters and NFT owner output ([#14443](https://github.com/blockscout/blockscout/issues/14443), [#14428](https://github.com/blockscout/blockscout/pull/14428)) +- Add an include_zero_value flag for filtering zero-value internal transactions in REST endpoints ([#14400](https://github.com/blockscout/blockscout/issues/14400)) +- Add transaction log input decoding in ABI-like format ([#13783](https://github.com/blockscout/blockscout/issues/13783)) +- Add support for exporting token transfers from a token through CSV export ([#7909](https://github.com/blockscout/blockscout/issues/7909)) +- Add support for runtime Redis SSL/TLS and Sentinel-based configuration for rate limiting and account services ([#12827](https://github.com/blockscout/blockscout/issues/12827), [#12889](https://github.com/blockscout/blockscout/issues/12889)) +- Add support for Search + BENS integration ([#13180](https://github.com/blockscout/blockscout/issues/13180)) +- Add support for token instance media type collection and metadata backfill ([#11208](https://github.com/blockscout/blockscout/issues/11208)) +- Add api/v2/tokens/batch endpoint ([#14548](https://github.com/blockscout/blockscout/pull/14548)) +- Add pending status to v2 internal transaction APIs ([#14212](https://github.com/blockscout/blockscout/pull/14212)) +- Allow to change page size ([#14244](https://github.com/blockscout/blockscout/pull/14244)) + +### 🐛 Bug Fixes + +- Cap internal transaction error message at 255 characters ([#14907](https://github.com/blockscout/blockscout/pull/14907)) +- Exclude recorded null rounds from missing_blocks_count metric ([#14906](https://github.com/blockscout/blockscout/pull/14906)) +- ReindexBlocksWithStaleInternalTransactions return value ([#14901](https://github.com/blockscout/blockscout/pull/14901)) +- move /api/legacy/eth/* POST routes off the CSRF-protected pipeline ([#14881](https://github.com/blockscout/blockscout/pull/14881)) +- Sanitize 12 version ([#14867](https://github.com/blockscout/blockscout/pull/14867)) +- Compare checksummed hashes in Celo election rewards controller test ([#14863](https://github.com/blockscout/blockscout/pull/14863)) +- Update Arc coin balance tests after address_hash_to_coin_balances rename ([#14860](https://github.com/blockscout/blockscout/pull/14860)) +- Drop redundant logs_address_hash_first_topic_second_topic_block_number_index ([#14858](https://github.com/blockscout/blockscout/pull/14858)) +- Keep address and transaction pages responsive while logs table is locked ([#14848](https://github.com/blockscout/blockscout/pull/14848)) +- Validate redirect targets and IPv6 hosts in NFT metadata fetching ([#14726](https://github.com/blockscout/blockscout/pull/14726)) +- Async fetch internal transactions after pending operations type switch ([#14330](https://github.com/blockscout/blockscout/issues/14330)) +- Adapt OnDemand IT fetcher test for rsk and filecoin ([#14315](https://github.com/blockscout/blockscout/issues/14315)) +- Reorganize logs indexes creation order ([#14676](https://github.com/blockscout/blockscout/pull/14676)) +- Correct API v2 schemas and drop dead internal-tx limit param ([#14662](https://github.com/blockscout/blockscout/pull/14662)) +- Delete massive blocks as well on ranges_init in MissingRangesCollector ([#14661](https://github.com/blockscout/blockscout/pull/14661)) +- Update logs PK fixes ([#14660](https://github.com/blockscout/blockscout/pull/14660)) +- Fix DeleteZeroValueInternalTransactions status inconsistency ([#14658](https://github.com/blockscout/blockscout/pull/14658)) +- Fix bytes32 ERC-20 name and symbol decoding ([#14578](https://github.com/blockscout/blockscout/pull/14578)) +- Restore per-instance results for on-demand NFT metadata refetch ([#14530](https://github.com/blockscout/blockscout/pull/14530)) +- Fix Solidity verifier false positives caused by substring collisions in constructor argument matching ([#14477](https://github.com/blockscout/blockscout/issues/14477)) +- Fix Solidity verifier version-check bypass and malformed version parsing ([#14475](https://github.com/blockscout/blockscout/issues/14475)) +- Fix unguarded Integer.parse issues in Solidity version parsing ([#14505](https://github.com/blockscout/blockscout/issues/14505)) +- Fix silent fallback and empty-map masking in bytecode metadata extraction and CBOR decoding ([#14504](https://github.com/blockscout/blockscout/issues/14504), [#14503](https://github.com/blockscout/blockscout/issues/14503), [#14502](https://github.com/blockscout/blockscout/issues/14502), [#14499](https://github.com/blockscout/blockscout/issues/14499), [#14497](https://github.com/blockscout/blockscout/issues/14497)) +- Fix an OpenAPI issue where CSV-returning endpoints could return 406 errors ([#14416](https://github.com/blockscout/blockscout/issues/14416)) +- Fix OpenAPI coverage for Stability validators and Shibarium endpoints ([#14323](https://github.com/blockscout/blockscout/issues/14323), [#14322](https://github.com/blockscout/blockscout/issues/14322)) +- Fix missing transaction links in Arc coin balance history ([#14382](https://github.com/blockscout/blockscout/pull/14382)) +- Fix recaptcha bypass handling when reCAPTCHA is disabled ([#14379](https://github.com/blockscout/blockscout/pull/14379)) +- Fix recaptcha_to_bypass_429 handling when reCAPTCHA is disabled and restore scoped bypass token query support ([#13703](https://github.com/blockscout/blockscout/issues/13703), [#13513](https://github.com/blockscout/blockscout/issues/13513)) +- Fix address tab counters by chain identity and type ([#14271](https://github.com/blockscout/blockscout/pull/14271)) +- Fix JSON-RPC error formatting and hex block parameter parsing ([#14216](https://github.com/blockscout/blockscout/issues/14216)) +- Fix scam-token counters when token filtering is disabled on an instance ([#13944](https://github.com/blockscout/blockscout/issues/13944)) +- Fix coin balance history websocket payloads to include transaction hashes ([#8849](https://github.com/blockscout/blockscout/issues/8849)) +- Distinguish eth_call reverts from RPC errors in proxy fetch ([#14747](https://github.com/blockscout/blockscout/pull/14747)) +- Fix Arbitrum missing origination chunking ([#14730](https://github.com/blockscout/blockscout/pull/14730)) +- Handle node JSON-RPC errors in batched eth-rpc proxy requests ([#14718](https://github.com/blockscout/blockscout/pull/14718)) +- Prepare logs for bloom filter ([#14713](https://github.com/blockscout/blockscout/pull/14713)) +- Rollback priority of address higher than ENS in search redirect ([#14712](https://github.com/blockscout/blockscout/pull/14712)) +- Filter non chain type related migrations in VersionUpgrade ([#14659](https://github.com/blockscout/blockscout/pull/14659)) +- Fix refetch multichain queue and eden OpenAPI spec generation ([#14656](https://github.com/blockscout/blockscout/pull/14656)) +- Regenerate types-package lockfile so npm ci works ([#14639](https://github.com/blockscout/blockscout/pull/14639)) +- Fix logs conflicts ([#14631](https://github.com/blockscout/blockscout/pull/14631)) +- Use named volume for backend dets to fix permission errors ([#14561](https://github.com/blockscout/blockscout/pull/14561)) +- Add required max_body_size option to Tesla.Middleware.Compression ([#14484](https://github.com/blockscout/blockscout/pull/14484)) +- Resolve beacon deposit status OpenAPI title collision ([#14440](https://github.com/blockscout/blockscout/pull/14440)) +- Fix swagger generation workflow ([#14439](https://github.com/blockscout/blockscout/pull/14439)) +- Fix BackfillMultichainSearchDbCurrentTokenBalancesTest ([#14429](https://github.com/blockscout/blockscout/pull/14429)) +- Use describe_inline for leaf-type properties in ZkSync batch schemas ([#14409](https://github.com/blockscout/blockscout/pull/14409)) +- Use string keys in ZkSync batches paging function ([#14408](https://github.com/blockscout/blockscout/pull/14408)) +- Fix compilation warning in OP chain types ([#14363](https://github.com/blockscout/blockscout/pull/14363)) + +### 🚜 Refactor + +- Refactor smart contract decoding and reader flow for ABI-like input parsing ([#7744](https://github.com/blockscout/blockscout/issues/7744)) +- Refactor JSON-RPC HTTP compression handling ([#12997](https://github.com/blockscout/blockscout/issues/12997)) +- Remove MUD customizations from the codebase ([#14485](https://github.com/blockscout/blockscout/pull/14485)) +- Remove the key from next_page_params to prevent secret leaks ([#14361](https://github.com/blockscout/blockscout/issues/14361)) +- Remove items_count from API v2 next_page_params responses to simplify payloads ([#12901](https://github.com/blockscout/blockscout/issues/12901)) +- Remove the token object from token instance API responses to keep payloads smaller ([#8805](https://github.com/blockscout/blockscout/issues/8805)) +- Drop address_hash columns from the internal_transactions storage layout ([#14230](https://github.com/blockscout/blockscout/issues/14230)) +- Refactor event-log storage layout around first-topic handling and new primary keys ([#14012](https://github.com/blockscout/blockscout/issues/14012), [#14014](https://github.com/blockscout/blockscout/issues/14014), [#14016](https://github.com/blockscout/blockscout/issues/14016), [#14017](https://github.com/blockscout/blockscout/issues/14017), [#14021](https://github.com/blockscout/blockscout/issues/14021)) +- Name the transaction preview schemas TransactionPreview ([#14711](https://github.com/blockscout/blockscout/pull/14711)) +- Migrate JSON handling to Elixir built-in JSON lib ([#14302](https://github.com/blockscout/blockscout/pull/14302)) + +### 📚 Documentation + +- document reCAPTCHA as headers in refetch-metadata OpenAPI spec ([#14690](https://github.com/blockscout/blockscout/pull/14690)) +- Add OpenAPI coverage for Stability validators endpoints ([#14323](https://github.com/blockscout/blockscout/issues/14323)) +- Add OpenAPI coverage for Shibarium endpoints ([#14322](https://github.com/blockscout/blockscout/issues/14322)) +- Add OpenAPI coverage for ZKSync-specific REST API methods ([#13753](https://github.com/blockscout/blockscout/issues/13753)) +- Add /api prefixes to endpoints in Swagger/OpenAPI docs ([#14263](https://github.com/blockscout/blockscout/pull/14263)) +- Add property-level description overlays without global component pollution ([#14367](https://github.com/blockscout/blockscout/issues/14367)) +- Improve API schema correctness and shared operation shorthands in the OpenAPI surface ([#14515](https://github.com/blockscout/blockscout/pull/14515)) +- Add OpenAPI specification for dedicated /api/eth-rpc methods ([#14407](https://github.com/blockscout/blockscout/pull/14407)) + +### ⚡ Performance + +- Cache Optimism deposits count ([#14733](https://github.com/blockscout/blockscout/pull/14733)) +- Skip expensive cleanup queries in lose_consensus when there are no block changes ([#14489](https://github.com/blockscout/blockscout/pull/14489)) +- Improve filling migration scheduling for background backfills ([#14493](https://github.com/blockscout/blockscout/pull/14493)) +- Optimize event-log storage and compression migrations for larger datasets ([#14012](https://github.com/blockscout/blockscout/issues/14012), [#14014](https://github.com/blockscout/blockscout/issues/14014), [#14016](https://github.com/blockscout/blockscout/issues/14016), [#14021](https://github.com/blockscout/blockscout/issues/14021)) +- Improve advanced filters performance and backfill throughput ([#13428](https://github.com/blockscout/blockscout/pull/13428), [#14493](https://github.com/blockscout/blockscout/pull/14493)) +- Backfill current token balances for the multichain DB exporter ([#12794](https://github.com/blockscout/blockscout/issues/12794)) +- Improve multichain search integration by importing addresses of smart-contract implementations ([#11491](https://github.com/blockscout/blockscout/issues/11491)) +- Add media type collection for token instances to improve NFT metadata workflows ([#11208](https://github.com/blockscout/blockscout/issues/11208)) +- Restore partial index usage for token holders queries ([#14728](https://github.com/blockscout/blockscout/pull/14728)) + +### ⚙️ Miscellaneous Tasks + +- Optimize logs by address queries ([#14680](https://github.com/blockscout/blockscout/pull/14680)) +- Improve endpoint availability fault tolerance ([#14514](https://github.com/blockscout/blockscout/pull/14514)) +- Improve migration scheduling for log-filling tasks ([#14493](https://github.com/blockscout/blockscout/pull/14493)) +- Update smart_contracts.constructor_arguments from text to bytea ([#13371](https://github.com/blockscout/blockscout/issues/13371)) +- Encrypt management JWTs for Auth0 before storing them in Redis ([#12832](https://github.com/blockscout/blockscout/issues/12832)) +- Add automatic Optimism Holocene timestamp loading from Superchain registry TOML config ([#11641](https://github.com/blockscout/blockscout/issues/11641)) +- Spread scam-token filtering to GraphQL functionality ([#12140](https://github.com/blockscout/blockscout/issues/12140)) +- Support runtime Redis SSL/TLS and Sentinel-based configuration for rate limiting and account services ([#12827](https://github.com/blockscout/blockscout/issues/12827), [#12889](https://github.com/blockscout/blockscout/issues/12889)) +- Improve multichain search integration by importing smart-contract implementation addresses ([#11491](https://github.com/blockscout/blockscout/issues/11491)) +- Add support for circulating supply reporting ([#14532](https://github.com/blockscout/blockscout/issues/14532)) +- Add support for the EigenDA blob schema in Optimism block OpenAPI ([#14496](https://github.com/blockscout/blockscout/pull/14496)) +- Add support for disallowing extra properties in Optimism Alt-DA block schema ([#14488](https://github.com/blockscout/blockscout/pull/14488)) +- Fix warnings ([#14821](https://github.com/blockscout/blockscout/pull/14821)) +- Propagate cache between API pods as well ([#14614](https://github.com/blockscout/blockscout/pull/14614)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `API_ETH_RPC_EXTENDED_PROXY_METHODS_ENABLED` | Enables extended proxy method support for API ETH RPC. Added for the `v12.0.0` release and disabled by default for backward compatibility. | Version: v12.0.0\+ <br />Default: `false` <br />Applications: API | +| `API_ETH_RPC_DISABLE_CORE_PROXY_METHODS` | If `true`, disables core proxy methods (e.g. `eth_getCode`, `eth_getTransactionCount`, `eth_call`, etc.) in the API ETH RPC JSON-RPC method set. Extended proxy methods controlled by `API_ETH_RPC_EXTENDED_PROXY_METHODS_ENABLED` are unaffected. Implemented in [#14495](https://github.com/blockscout/blockscout/pull/14495). | Version: v12.0.0\+ <br />Default: `false` <br />Applications: API | +| `MAX_ITEMS_PER_PAGE` | Maximum size of the page returned by the API, in other words, the maximum allowed value for `items_count` query param. Implemented in [#14244](https://github.com/blockscout/blockscout/pull/14244). | Version: v12.0.0\+ <br />Default: `100` <br />Applications: API | +| `ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_HEAVY_METHODS_ENABLED` | Controls whether compression is applied when calling high-volume JSON-RPC endpoints (such as debug_*, trace_*, and eth_getBlockReceipts). Implemented in [#14319](https://github.com/blockscout/blockscout/pull/14303). | Version: v12.0.0\+ <br />Default: `true` <br />Applications: API, Indexer | +| `ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_ALL_METHODS_ENABLED` | Controls whether compression is applied when calling all JSON RPC endpoints. Implemented in [#14319](https://github.com/blockscout/blockscout/pull/14303). | Version: v12.0.0\+ <br />Default: `false` <br />Applications: API, Indexer | +| `INDEXER_DISABLE_TOKEN_INSTANCE_MEDIA_TYPE_FETCHER` | If `true`, token instance media type backfill fetcher is disabled. When disabled, `image_type` and `animation_type` fields will not be populated for existing token instances. Implemented in [#14365](https://github.com/blockscout/blockscout/pull/14365). | Version: v12.0.0\+ <br />Default: `false` <br />Applications: Indexer | +| `INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_BATCH_SIZE` | Number of token instances processed per batch by the media type fetcher, which backfills MIME types (`image_type`, `animation_type`) for NFT instances via HTTP HEAD requests to image/animation URLs from metadata. Implemented in [#14365](https://github.com/blockscout/blockscout/pull/14365). | Version: v12.0.0\+ <br />Default: `10` <br />Applications: Indexer | +| `INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_CONCURRENCY` | Max number of concurrent workers resolving media types for token instances. Implemented in [#14365](https://github.com/blockscout/blockscout/pull/14365). | Version: v12.0.0\+ <br />Default: `5` <br />Applications: Indexer | +| `MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_ENABLED` | Enable background migration to backfill `has_token_transfers` for existing transactions. Implemented in [#13428](https://github.com/blockscout/blockscout/pull/13428). | Version: v12.0.0\+ <br />Default: `true` <br />Applications: API, Indexer | +| `MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_BATCH_SIZE` | Number of transactions to backfill `has_token_transfers` in the batch. Implemented in [#13428](https://github.com/blockscout/blockscout/pull/13428). | Version: v12.0.0\+ <br />Default: `100` <br />Applications: API, Indexer | +| `MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_CONCURRENCY` | Number of parallel backfilling transaction batches processing. Implemented in [#13428](https://github.com/blockscout/blockscout/pull/13428). | Version: v12.0.0\+ <br />Default: `10` <br />Applications: API, Indexer | +| `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_DISABLED` | If `true`, the backfill migration that exports current token balances from Blockscout to Multichain Search DB does not run. The migration is also disabled when `MICROSERVICE_MULTICHAIN_SEARCH_URL` is unset or `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_LAST_BLOCK_NUMBER` is unset. Implemented in [#14304](https://github.com/blockscout/blockscout/pull/14304). | Version: v12.0.0\+ <br />Default: `false` <br />Applications: Indexer | +| `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_LAST_BLOCK_NUMBER` | Upper block number bound for the current token balances backfill. Only records from `address_current_token_balances` with `block_number` less than or equal to this value are exported. Required for the migration to start. When `TRACE_BLOCK_RANGES` is configured, only balances within those ranges are exported. Implemented in [#14304](https://github.com/blockscout/blockscout/pull/14304). | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: Indexer | +| `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_BATCH_SIZE` | Number of current token balances to export in each batch during the Multichain Search DB backfill. Implemented in [#14304](https://github.com/blockscout/blockscout/pull/14304). | Version: v12.0.0\+ <br />Default: `100` <br />Applications: Indexer | +| `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_CONCURRENCY` | Number of parallel batches processing during the current token balances Multichain Search DB backfill. Implemented in [#14304](https://github.com/blockscout/blockscout/pull/14304). | Version: v12.0.0\+ <br />Default: `4` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_BATCH_SIZE` | Number of logs to fill their address ids, first topic ids, transaction indexes and trimmed topics in the batch. Implemented in [#14494](https://github.com/blockscout/blockscout/pull/14494). | Version: v12.0.0\+ <br />Default: `30` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_CONCURRENCY` | Number of parallel processes filling logs address ids, first topic ids, transaction indexes and trimmed topics. Implemented in [#14494](https://github.com/blockscout/blockscout/pull/14494). | Version: v12.0.0\+ <br />Default: `10` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_TIMEOUT` | Timeout between filling logs address ids, first topic ids, transaction indexes and trimmed topics batches processing. Implemented in [#14494](https://github.com/blockscout/blockscout/pull/14494). | Version: v12.0.0\+ <br />Default: `5s` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_COMPRESSED_DATA_BATCH_SIZE` | Number of logs to fill their compressed data in the batch. Implemented in [#14526](https://github.com/blockscout/blockscout/pull/14526). | Version: v12.0.0\+ <br />Default: `5000` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_COMPRESSED_DATA_CONCURRENCY` | Number of parallel processes filling logs compressed data. Implemented in [#14526](https://github.com/blockscout/blockscout/pull/14526). | Version: v12.0.0\+ <br />Default: `10` <br />Applications: Indexer | +| `MIGRATION_FILL_LOGS_COMPRESSED_DATA_TIMEOUT` | Timeout between filling logs compressed data batches processing. Implemented in [#14526](https://github.com/blockscout/blockscout/pull/14526). | Version: v12.0.0\+ <br />Default: `1s` <br />Applications: Indexer | +| `API_RATE_LIMIT_HAMMER_REDIS_SSL_ENABLED` | If `true` Blockscout will use SSL to connect to Redis DB for Hammer rate limit library. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_URLS` | List of Redis sentinel urls for Hammer rate limit library. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_MASTER_NAME`| Sentinel master name for Hammer rate limit library. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `API_RATE_LIMIT_RECAPTCHA_DISABLED_LIMIT_MULTIPLIER` | Integer multiplier applied to rate limits on endpoints that use `recaptcha_to_bypass_429` or `temporary_token` when reCAPTCHA is disabled (`RE_CAPTCHA_DISABLED=true`). When reCAPTCHA is disabled, neither bypass mechanism is available, so this multiplier compensates by raising the base limit for all users. Implemented in [#14379](https://github.com/blockscout/blockscout/pull/14379) | Version: v12.0.0\+ <br />Default: `2` <br />Applications: API | +| `RATE_LIMITER_REDIS_SSL_ENABLED` | If `true` Blockscout will use SSL to connect to Redis DB for rate limiter. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `RATE_LIMITER_REDIS_SENTINEL_URLS` | List of Redis sentinel urls for rate limiter. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `RATE_LIMITER_REDIS_SENTINEL_MASTER_NAME` | Sentinel master name for rate limiter. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901) | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `ACCOUNT_REDIS_SSL_ENABLED` | If `true` Blockscout will use SSL to connect to Account Redis DB. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901). | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `ACCOUNT_REDIS_SENTINEL_URLS` | List of Account Redis sentinel urls. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901). | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `ACCOUNT_REDIS_SENTINEL_MASTER_NAME` | Account Sentinel master name. Implemented in [#13901](https://github.com/blockscout/blockscout/pull/13901). | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: API | +| `INDEXER_OPTIMISM_SUPERCHAIN_CONFIG_FILE_PATH` | Points to a Superchain TOML source (local file path or http(s) URL, including GitHub blob URLs) used to auto-load Optimism network parameters at startup and persist them into constants; empty/blank values are treated as unset. When provided, TOML values take priority and legacy envs are used only as per-field fallback; if neither TOML nor fallback provides a valid value, the stored constant is cleared to avoid stale config. This single variable can replace manual setup of: `INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP`, `INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP`, `INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP`, `EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR`, `EIP_1559_ELASTICITY_MULTIPLIER`, `INDEXER_OPTIMISM_L1_SYSTEM_CONFIG_CONTRACT`, `INDEXER_OPTIMISM_L1_PORTAL_CONTRACT`, `INDEXER_OPTIMISM_L1_BATCH_SUBMITTER`, `INDEXER_OPTIMISM_L1_BATCH_INBOX`, `INDEXER_OPTIMISM_L1_START_BLOCK`, and `INDEXER_OPTIMISM_L2_BATCH_GENESIS_BLOCK_NUMBER`. | Version: v12.0.0\+ <br />Default: (empty) <br />Applications: Indexer | +| `CACHE_OPTIMISM_DEPOSITS_COUNTER_PERIOD` | Interval to update the Optimism deposits count. Introduced in [#14733](https://github.com/blockscout/blockscout/pull/14733). | Version: v12.0.0\+ <br />Default: `1h` <br />Applications: API, Indexer | +| `API_OPTIONAL_QUERIES_LOCK_TIMEOUT` | Maximum time optional API queries wait for a PostgreSQL table lock before failing fast and returning a degraded response. Implemented in [#14848](https://github.com/blockscout/blockscout/pull/14848). | Version: v12.0.0\+ <br />Default: `100ms` <br />Applications: API | +| `MIGRATION_BACKFILL_SCALED_UI_AMOUNT_TOKENS_BATCH_SIZE` | ERC-8056 - related. Number of logs to backfill scaled UI amount from in the batch. Implemented in [#14731](https://github.com/blockscout/blockscout/pull/14731). | Version: v12.0.0\+ <br />Default: `100` <br />Applications: Indexer | +| `MIGRATION_BACKFILL_SCALED_UI_AMOUNT_TOKENS_CONCURRENCY` | ERC-8056 - related. Number of parallel processes backfilling scaled UI amount from logs. Implemented in [#14731](https://github.com/blockscout/blockscout/pull/14731). | Version: v12.0.0\+ <br />Default: `1` <br />Applications: Indexer | +| `MIGRATION_SANITIZE_SCALED_UI_AMOUNT_TOKEN_TRANSFER_TYPES_BATCH_SIZE` | ERC-8056 - related. Number of token transfers to sanitize their token type in batch. Implemented in [#14731](https://github.com/blockscout/blockscout/pull/14731). | Version: v12.0.0\+ <br />Default: `100` <br />Applications: Indexer | +| `MIGRATION_SANITIZE_SCALED_UI_AMOUNT_TOKEN_TRANSFER_TYPES_CONCURRENCY` | ERC-8056 - related. Number of parallel processes sanitizing token types. Implemented in [#14731](https://github.com/blockscout/blockscout/pull/14731). | Version: v12.0.0\+ <br />Default: `1` <br />Applications: Indexer | + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Need recompile | Deprecated in Version | +| -------- | ----------- | ------- | ------- | -------------- | --------------------- | +| <span style={{color: "red"}}>Deprecated</span> `ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED` | If `true`, then send gzip encoding header and expect encoding in response. Implemented in [#11292](https://github.com/blockscout/blockscout/pull/11292). Replaced with `ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_ALL_METHODS_ENABLED`. | `false` | v6.10.0+ | | v12.0.0+ | +| <span style={{color: "red"}}>Deprecated</span> `MUD_INDEXER_ENABLED` | If `true`, integration with [MUD](https://mud.dev/services/indexer#schemaless-indexing-with-postgresql-via-docker) is enabled. Implemented in [#9869](https://github.com/blockscout/blockscout/pull/9869). | (empty) | v6.6.0+ | | v12.0.0+ | +| <span style={{color: "red"}}>Deprecated</span> `MUD_DATABASE_URL` | MUD indexer DB connection URL. | value from `DATABASE_URL` | v6.6.0+ | | v12.0.0+ | +| <span style={{color: "red"}}>Deprecated</span> `MUD_POOL_SIZE` | MUD indexer DB `pool_size`. | `50` | v6.6.0+ | | v12.0.0+ | + ## 11.3.3 @@ -23,6 +200,7 @@ - Add env var to disable transactions count consolidation ([#14877](https://github.com/blockscout/blockscout/issues/14877)) + ### New ENV variables | Variable | Description | Parameters | @@ -34,7 +212,6 @@ | `MIGRATION_REINDEX_BLOCKS_WITH_STALE_INTERNAL_TRANSACTIONS_TIMEOUT` | Timeout between reindexing block batches processing. Implemented in [#14884](https://github.com/blockscout/blockscout/pull/14884). | Version: v11.3.3\+ <br />Default: `0s` <br />Applications: Indexer | | `CACHE_TXS_COUNT_CONSOLIDATION_DISABLED` | If `true`, disables exact total transaction count consolidation and uses the PostgreSQL `pg_class` estimate directly. Implemented in [#14877](https://github.com/blockscout/blockscout/pull/14877). | Version: v11.3.3\+ <br />Default: `false` <br />Applications: API | - ## 11.3.2 ### 🐛 Bug Fixes diff --git a/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex b/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex index 31ec94a7380..3dda633e349 100644 --- a/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex @@ -12,8 +12,8 @@ defmodule BlockScoutWeb.CaptchaHelper do @doc """ Verifies if the CAPTCHA challenge has been passed based on the provided parameters. - This function first checks for a bypass token, then handles both reCAPTCHA v3 and v2 - responses, as well as cases where CAPTCHA is disabled. + This function first checks for a bypass token, then handles both reCAPTCHA v3 + and v2 responses. Disabled or missing CAPTCHA input is treated as not passed. ## Parameters - `params`: A map containing CAPTCHA response parameters or nil. Can include: @@ -21,9 +21,16 @@ defmodule BlockScoutWeb.CaptchaHelper do * `"recaptcha_v3_response"` - A reCAPTCHA v3 response token * `"recaptcha_response"` - A reCAPTCHA v2 response token + These keys are the internal contract of this function, not a wire format. + `/api/v2/key` passes request params through as-is, so there they match the + body params; for rate limited endpoints + `BlockScoutWeb.RateLimit.collect_recaptcha_headers/1` builds this map from + the `recaptcha-*` request headers instead. + ## Returns - - `true` if the CAPTCHA challenge is passed or disabled, or if a valid bypass token is provided. - - `false` if the CAPTCHA challenge fails or an error occurs during verification. + - `true` if the CAPTCHA challenge is passed or if a valid bypass token is provided. + - `false` if the CAPTCHA challenge fails, input is missing, CAPTCHA is disabled, + or an error occurs during verification. """ @spec recaptcha_passed?(%{String.t() => String.t()} | nil) :: bool def recaptcha_passed?(%{"recaptcha_bypass_token" => given_bypass_token}) do @@ -47,7 +54,7 @@ defmodule BlockScoutWeb.CaptchaHelper do do_recaptcha_passed?(re_captcha_v2_secret_key, recaptcha_response) end - def recaptcha_passed?(_), do: Application.get_env(:block_scout_web, :recaptcha)[:is_disabled] + def recaptcha_passed?(_), do: false @doc """ Same as recaptcha_passed/1, but with scoped tokens authentication method. @@ -71,7 +78,7 @@ defmodule BlockScoutWeb.CaptchaHelper do ## Returns - `true` if either: - * A valid API key is provided for an allowed endpoint + * A valid scoped bypass token is provided for `scope` * The CAPTCHA verification succeeds - `false` otherwise """ @@ -110,10 +117,10 @@ defmodule BlockScoutWeb.CaptchaHelper do headers ) do {:ok, %{status_code: 200, body: body}} -> - body |> Jason.decode!() |> success?() + body |> Utils.JSON.decode!() |> success?() false -> - true + false error -> Logger.error("Failed to verify reCAPTCHA: #{inspect(error)}") diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index 752edf0d79e..9dbcdb8cff5 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -16,11 +16,7 @@ defmodule BlockScoutWeb.Chain do import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] - import Explorer.PagingOptions, - only: [ - default_paging_options: 0, - page_size: 0 - ] + import Explorer.PagingOptions, only: [default_paging_options: 0] import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] @@ -47,7 +43,6 @@ defmodule BlockScoutWeb.Chain do Token.Instance, TokenTransfer, Transaction, - Transaction.StateChange, UserOperation, Wei } @@ -63,7 +58,6 @@ defmodule BlockScoutWeb.Chain do alias Indexer.Fetcher.OnDemand.InternalTransaction, as: InternalTransactionOnDemand alias Plug.Conn - @page_size page_size() @default_paging_options default_paging_options() @address_hash_len 40 @full_hash_len 64 @@ -138,85 +132,42 @@ defmodule BlockScoutWeb.Chain do end end - @spec next_page_params(any(), list(), map(), boolean(), (any() -> map())) :: nil | map() - def next_page_params(next_page, list, params, increment_items_count? \\ false, paging_function \\ &paging_params/1) - - def next_page_params([], _list, _params, _increment_items_count?, _), do: nil - - def next_page_params(_, list, params, increment_items_count?, paging_function) do - paging_params = paging_function.(List.last(list)) - - string_keys = map_to_string_keys(paging_params) + @spec next_page_params_for_state_changes(list(), map(), PagingOptions.t()) :: {list(), map() | nil} + def next_page_params_for_state_changes(list_plus_one, params, %PagingOptions{page_size: page_size}) do + actual_page_size = page_size - 1 + {items, next_page} = Enum.split(list_plus_one, actual_page_size) next_page_params = - params - |> delete_parameters_from_next_page_params() - |> Map.drop(string_keys) - |> Map.merge(paging_params) - - items_count = next_items_count(next_page_params, list, increment_items_count?) - - cond do - Map.has_key?(next_page_params, "items_count") -> - Map.put(next_page_params, "items_count", items_count) - - Map.has_key?(next_page_params, :items_count) -> - Map.put(next_page_params, :items_count, items_count) - - true -> - Map.put(next_page_params, :items_count, items_count) - end - end - - defp get_items_count_from_next_page_params(next_page_params) do - cond do - Map.has_key?(next_page_params, "items_count") -> - Map.get(next_page_params, "items_count") + case next_page do + [] -> + nil - Map.has_key?(next_page_params, :items_count) -> - Map.get(next_page_params, :items_count) - - true -> - nil - end - end - - defp next_items_count(_next_page_params, list, false) do - Enum.count(list) - end - - defp next_items_count(next_page_params, list, true) do - current_items_count_object = get_items_count_from_next_page_params(next_page_params) - - current_items_count = - cond do - is_binary(current_items_count_object) -> - {current_items_count, _} = Integer.parse(current_items_count_object) - current_items_count - - is_integer(current_items_count_object) -> - current_items_count_object - - true -> - 0 + _ -> + current_count = params[:state_changes_count] || parse_integer(params["state_changes_count"]) || 0 + %{state_changes_count: current_count + Enum.count(items)} end - current_items_count + Enum.count(list) + {items, next_page_params} end @doc """ Makes Explorer.PagingOptions map. Overloaded by different params in the input map for different modules using this function. """ - @spec paging_options(any) :: - [{:paging_options, Explorer.PagingOptions.t()}, ...] | Explorer.PagingOptions.t() + @spec paging_options(any) :: [{:paging_options, Explorer.PagingOptions.t()}, ...] + def paging_options(params) do + params + |> do_paging_options() + |> maybe_override_page_size(params) + end + # todo: function clause for the old UI, to be removed later - def paging_options(%{ - "hash" => hash_string, - "fetched_coin_balance" => fetched_coin_balance_string, - "transactions_count" => transactions_count_string - }) - when is_binary(hash_string) do + defp do_paging_options(%{ + "hash" => hash_string, + "fetched_coin_balance" => fetched_coin_balance_string, + "transactions_count" => transactions_count_string + }) + when is_binary(hash_string) do case string_to_address_hash(hash_string) do {:ok, address_hash} -> [ @@ -235,12 +186,12 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - hash: hash_string, - fetched_coin_balance: fetched_coin_balance_string, - transactions_count: transactions_count_string - }) - when is_binary(hash_string) do + defp do_paging_options(%{ + hash: hash_string, + fetched_coin_balance: fetched_coin_balance_string, + transactions_count: transactions_count_string + }) + when is_binary(hash_string) do case string_to_address_hash(hash_string) do {:ok, address_hash} -> [ @@ -259,14 +210,14 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - fee: fee_string, - value: value_string, - block_number: block_number_string, - index: index_string, - inserted_at: inserted_at, - hash: hash_string - }) do + defp do_paging_options(%{ + fee: fee_string, + value: value_string, + block_number: block_number_string, + index: index_string, + inserted_at: inserted_at, + hash: hash_string + }) do case string_to_full_hash(hash_string) do {:ok, hash} -> [ @@ -288,17 +239,17 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options( - %{ - "market_cap" => market_cap_string, - "holders_count" => holders_count_string, - "name" => name_string, - "contract_address_hash" => contract_address_hash_string, - "is_name_null" => is_name_null - } = params - ) - when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and - is_binary(contract_address_hash_string) do + defp do_paging_options( + %{ + "market_cap" => market_cap_string, + "holders_count" => holders_count_string, + "name" => name_string, + "contract_address_hash" => contract_address_hash_string, + "is_name_null" => is_name_null + } = params + ) + when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and + is_binary(contract_address_hash_string) do market_cap_decimal = decimal_parse(market_cap_string) fiat_value_decimal = decimal_parse(params["fiat_value"]) @@ -326,17 +277,17 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options( - %{ - market_cap: market_cap_string, - holders_count: holders_count_string, - name: name_string, - contract_address_hash: contract_address_hash_string, - is_name_null: is_name_null - } = params - ) - when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and - is_binary(contract_address_hash_string) do + defp do_paging_options( + %{ + market_cap: market_cap_string, + holders_count: holders_count_string, + name: name_string, + contract_address_hash: contract_address_hash_string, + is_name_null: is_name_null + } = params + ) + when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and + is_binary(contract_address_hash_string) do market_cap_decimal = decimal_parse(market_cap_string) fiat_value_decimal = decimal_parse(params[:fiat_value]) @@ -364,12 +315,12 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - "block_number" => block_number_string, - "transaction_index" => transaction_index_string, - "index" => index_string - }) - when is_binary(block_number_string) and is_binary(transaction_index_string) and is_binary(index_string) do + defp do_paging_options(%{ + "block_number" => block_number_string, + "transaction_index" => transaction_index_string, + "index" => index_string + }) + when is_binary(block_number_string) and is_binary(transaction_index_string) and is_binary(index_string) do with {block_number, ""} <- Integer.parse(block_number_string), {transaction_index, ""} <- Integer.parse(transaction_index_string), {index, ""} <- Integer.parse(index_string) do @@ -380,25 +331,25 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - block_number: block_number, - transaction_index: transaction_index, - index: index - }) do + defp do_paging_options(%{ + block_number: block_number, + transaction_index: transaction_index, + index: index + }) do [paging_options: %{@default_paging_options | key: {block_number, transaction_index, index}}] end - def paging_options(%{ - "block_number" => block_number_string, - "index" => index_string, - "batch_log_index" => batch_log_index_string, - "batch_block_hash" => batch_block_hash_string, - "batch_transaction_hash" => batch_transaction_hash_string, - "index_in_batch" => index_in_batch_string - }) - when is_binary(block_number_string) and is_binary(index_string) and is_binary(batch_log_index_string) and - is_binary(batch_block_hash_string) and is_binary(batch_transaction_hash_string) and - is_binary(index_in_batch_string) do + defp do_paging_options(%{ + "block_number" => block_number_string, + "index" => index_string, + "batch_log_index" => batch_log_index_string, + "batch_block_hash" => batch_block_hash_string, + "batch_transaction_hash" => batch_transaction_hash_string, + "index_in_batch" => index_in_batch_string + }) + when is_binary(block_number_string) and is_binary(index_string) and is_binary(batch_log_index_string) and + is_binary(batch_block_hash_string) and is_binary(batch_transaction_hash_string) and + is_binary(index_in_batch_string) do with {block_number, ""} <- Integer.parse(block_number_string), {index, ""} <- Integer.parse(index_string), {index_in_batch, ""} <- Integer.parse(index_in_batch_string), @@ -418,14 +369,14 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - "batch_log_index" => batch_log_index_string, - "batch_block_hash" => batch_block_hash_string, - "batch_transaction_hash" => batch_transaction_hash_string, - "index_in_batch" => index_in_batch_string - }) - when is_binary(batch_log_index_string) and is_binary(batch_block_hash_string) and - is_binary(batch_transaction_hash_string) and is_binary(index_in_batch_string) do + defp do_paging_options(%{ + "batch_log_index" => batch_log_index_string, + "batch_block_hash" => batch_block_hash_string, + "batch_transaction_hash" => batch_transaction_hash_string, + "index_in_batch" => index_in_batch_string + }) + when is_binary(batch_log_index_string) and is_binary(batch_block_hash_string) and + is_binary(batch_transaction_hash_string) and is_binary(index_in_batch_string) do with {index_in_batch, ""} <- Integer.parse(index_in_batch_string), {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string), @@ -442,15 +393,15 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - block_number: block_number, - index: index, - batch_log_index: batch_log_index, - batch_block_hash: batch_block_hash_string, - batch_transaction_hash: batch_transaction_hash_string, - index_in_batch: index_in_batch - }) - when is_binary(batch_transaction_hash_string) and is_binary(batch_block_hash_string) do + defp do_paging_options(%{ + block_number: block_number, + index: index, + batch_log_index: batch_log_index, + batch_block_hash: batch_block_hash_string, + batch_transaction_hash: batch_transaction_hash_string, + index_in_batch: index_in_batch + }) + when is_binary(batch_transaction_hash_string) and is_binary(batch_block_hash_string) do with {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string) do [ @@ -466,14 +417,14 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - batch_log_index: batch_log_index, - batch_block_hash: batch_block_hash_string, - batch_transaction_hash: batch_transaction_hash_string, - index_in_batch: index_in_batch - }) - when is_binary(batch_block_hash_string) and - is_binary(batch_transaction_hash_string) do + defp do_paging_options(%{ + batch_log_index: batch_log_index, + batch_block_hash: batch_block_hash_string, + batch_transaction_hash: batch_transaction_hash_string, + index_in_batch: index_in_batch + }) + when is_binary(batch_block_hash_string) and + is_binary(batch_transaction_hash_string) do with {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string) do [ @@ -488,8 +439,8 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"block_number" => block_number_string, "index" => index_string}) - when is_binary(block_number_string) and is_binary(index_string) do + defp do_paging_options(%{"block_number" => block_number_string, "index" => index_string}) + when is_binary(block_number_string) and is_binary(index_string) do with {block_number, ""} <- Integer.parse(block_number_string), {index, ""} <- Integer.parse(index_string) do [paging_options: %{@default_paging_options | key: {block_number, index}}] @@ -499,11 +450,15 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{block_number: block_number, index: index}) do + defp do_paging_options(%{block_number: block_number, index: index}) do [paging_options: %{@default_paging_options | key: {block_number, index}}] end - def paging_options(%{"block_number" => block_number_string}) when is_binary(block_number_string) do + defp do_paging_options(%{block_number: block_number, log_index: log_index}) do + [paging_options: %{@default_paging_options | key: {block_number, log_index}}] + end + + defp do_paging_options(%{"block_number" => block_number_string}) when is_binary(block_number_string) do case Integer.parse(block_number_string) do {block_number, ""} -> [paging_options: %{@default_paging_options | key: {block_number}}] @@ -513,12 +468,12 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{block_number: block_number}) do + defp do_paging_options(%{block_number: block_number}) do [paging_options: %{@default_paging_options | key: {block_number}}] end - def paging_options(%{"transaction_index" => transaction_index_string, "index" => index_string}) - when is_binary(transaction_index_string) and is_binary(index_string) do + defp do_paging_options(%{"transaction_index" => transaction_index_string, "index" => index_string}) + when is_binary(transaction_index_string) and is_binary(index_string) do with {transaction_index, ""} <- Integer.parse(transaction_index_string), {index, ""} <- Integer.parse(index_string) do [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] @@ -528,16 +483,16 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"transaction_index" => transaction_index, "index" => index}) - when is_integer(transaction_index) and is_integer(index) do + defp do_paging_options(%{"transaction_index" => transaction_index, "index" => index}) + when is_integer(transaction_index) and is_integer(index) do [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] end - def paging_options(%{transaction_index: transaction_index, index: index}) do + defp do_paging_options(%{transaction_index: transaction_index, index: index}) do [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] end - def paging_options(%{"index" => index_string}) when is_binary(index_string) do + defp do_paging_options(%{"index" => index_string}) when is_binary(index_string) do case Integer.parse(index_string) do {index, ""} -> [paging_options: %{@default_paging_options | key: {index}}] @@ -547,15 +502,15 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"index" => index}) when is_integer(index) do + defp do_paging_options(%{"index" => index}) when is_integer(index) do [paging_options: %{@default_paging_options | key: {index}}] end - def paging_options(%{index: index}) do + defp do_paging_options(%{index: index}) do [paging_options: %{@default_paging_options | key: {index}}] end - def paging_options(%{"nonce" => nonce_string}) when is_binary(nonce_string) do + defp do_paging_options(%{"nonce" => nonce_string}) when is_binary(nonce_string) do case Integer.parse(nonce_string) do {nonce, ""} -> [paging_options: %{@default_paging_options | key: {nonce}}] @@ -565,15 +520,15 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"nonce" => nonce}) when is_integer(nonce) do + defp do_paging_options(%{"nonce" => nonce}) when is_integer(nonce) do [paging_options: %{@default_paging_options | key: {nonce}}] end - def paging_options(%{nonce: nonce}) do + defp do_paging_options(%{nonce: nonce}) do [paging_options: %{@default_paging_options | key: {nonce}}] end - def paging_options(%{"number" => number_string}) when is_binary(number_string) do + defp do_paging_options(%{"number" => number_string}) when is_binary(number_string) do case Integer.parse(number_string) do {number, ""} -> [paging_options: %{@default_paging_options | key: {number}}] @@ -583,16 +538,16 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"number" => number}) when is_integer(number) do + defp do_paging_options(%{"number" => number}) when is_integer(number) do [paging_options: %{@default_paging_options | key: {number}}] end - def paging_options(%{number: number}) do + defp do_paging_options(%{number: number}) do [paging_options: %{@default_paging_options | key: {number}}] end - def paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}) - when is_binary(inserted_at_string) and is_binary(hash_string) do + defp do_paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}) + when is_binary(inserted_at_string) and is_binary(hash_string) do with {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string), {:ok, hash} <- string_to_full_hash(hash_string) do [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] @@ -602,7 +557,7 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{inserted_at: inserted_at, hash: hash_string}) when is_binary(hash_string) do + defp do_paging_options(%{inserted_at: inserted_at, hash: hash_string}) when is_binary(hash_string) do case string_to_full_hash(hash_string) do {:ok, hash} -> [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] @@ -612,30 +567,30 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at}), + defp do_paging_options(%{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at}), do: [paging_options: %{@default_paging_options | key: {name, type, inserted_at}}] - def paging_options(%{token_name: name, token_type: type, token_inserted_at: inserted_at}), + defp do_paging_options(%{token_name: name, token_type: type, token_inserted_at: inserted_at}), do: [paging_options: %{@default_paging_options | key: {name, type, inserted_at}}] - def paging_options(%{"value" => value, "address_hash" => address_hash}) do + defp do_paging_options(%{"value" => value, "address_hash" => address_hash}) do [paging_options: %{@default_paging_options | key: {value, address_hash}}] end - def paging_options(%{value: "", address_hash: address_hash}) do + defp do_paging_options(%{value: "", address_hash: address_hash}) do [paging_options: %{@default_paging_options | key: {nil, address_hash}}] end - def paging_options(%{value: "null", address_hash: address_hash}) do + defp do_paging_options(%{value: "null", address_hash: address_hash}) do [paging_options: %{@default_paging_options | key: {nil, address_hash}}] end - def paging_options(%{value: value, address_hash: address_hash}) do + defp do_paging_options(%{value: value, address_hash: address_hash}) do [paging_options: %{@default_paging_options | key: {value, address_hash}}] end - def paging_options(%{"fiat_value" => fiat_value_string, "value" => value_string, "id" => id_string}) - when is_binary(fiat_value_string) and is_binary(value_string) and is_binary(id_string) do + defp do_paging_options(%{"fiat_value" => fiat_value_string, "value" => value_string, "id" => id_string}) + when is_binary(fiat_value_string) and is_binary(value_string) and is_binary(id_string) do with {id, ""} <- Integer.parse(id_string), {value, ""} <- Decimal.parse(value_string), {_id, _value, {fiat_value, ""}} <- {id, value, Decimal.parse(fiat_value_string)} do @@ -649,8 +604,8 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{fiat_value: fiat_value_string, value: value_string, id: id}) - when is_binary(fiat_value_string) and is_binary(value_string) do + defp do_paging_options(%{fiat_value: fiat_value_string, value: value_string, id: id}) + when is_binary(fiat_value_string) and is_binary(value_string) do with {value, ""} <- Decimal.parse(value_string), {_id, _value, {fiat_value, ""}} <- {id, value, Decimal.parse(fiat_value_string)} do [paging_options: %{@default_paging_options | key: {fiat_value, value, id}}] @@ -663,8 +618,8 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"value" => value_string, "id" => id_string}) - when is_binary(value_string) and is_binary(id_string) do + defp do_paging_options(%{"value" => value_string, "id" => id_string}) + when is_binary(value_string) and is_binary(id_string) do with {id, ""} <- Integer.parse(id_string), {value, ""} <- Decimal.parse(value_string) do [paging_options: %{@default_paging_options | key: {nil, value, id}}] @@ -674,22 +629,23 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{value: value, id: id}) do + defp do_paging_options(%{value: value, id: id}) do [paging_options: %{@default_paging_options | key: {nil, value, id}}] end - def paging_options(%{"items_count" => items_count_string, "state_changes" => _}) when is_binary(items_count_string) do - case Integer.parse(items_count_string) do + defp do_paging_options(%{"state_changes_count" => state_changes_count_string}) + when is_binary(state_changes_count_string) do + case Integer.parse(state_changes_count_string) do {count, ""} -> [paging_options: %{@default_paging_options | key: {count}}] - _ -> @default_paging_options + _ -> [paging_options: @default_paging_options] end end - def paging_options(%{items_count: items_count, state_changes: _}) when is_integer(items_count) do - [paging_options: %{@default_paging_options | key: {items_count}}] + defp do_paging_options(%{state_changes_count: state_changes_count}) when is_integer(state_changes_count) do + [paging_options: %{@default_paging_options | key: {state_changes_count}}] end - def paging_options(%{"l1_block_number" => block_number, "transaction_hash" => transaction_hash}) do + defp do_paging_options(%{"l1_block_number" => block_number, "transaction_hash" => transaction_hash}) do with {block_number, ""} <- Integer.parse(block_number), {:ok, transaction_hash} <- string_to_full_hash(transaction_hash) do [paging_options: %{@default_paging_options | key: {block_number, transaction_hash}}] @@ -699,7 +655,7 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{l1_block_number: block_number, transaction_hash: transaction_hash}) do + defp do_paging_options(%{l1_block_number: block_number, transaction_hash: transaction_hash}) do case string_to_full_hash(transaction_hash) do {:ok, transaction_hash} -> [paging_options: %{@default_paging_options | key: {block_number, transaction_hash}}] @@ -712,7 +668,7 @@ defmodule BlockScoutWeb.Chain do # clause for pagination of entities: # - Account's entities # - Arbitrum cross chain messages - def paging_options(%{"id" => id_string}) when is_binary(id_string) do + defp do_paging_options(%{"id" => id_string}) when is_binary(id_string) do case Integer.parse(id_string) do {id, ""} -> [paging_options: %{@default_paging_options | key: {id}}] @@ -722,20 +678,20 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"id" => id}) when is_integer(id) do + defp do_paging_options(%{"id" => id}) when is_integer(id) do [paging_options: %{@default_paging_options | key: {id}}] end # clause for pagination of entities: # - Optimism frame sequences # - Scroll cross chain messages - def paging_options(%{id: id}) do + defp do_paging_options(%{id: id}) do [paging_options: %{@default_paging_options | key: {id}}] end # Clause for `Explorer.Chain.Optimism.InteropMessage`, # returned by `BlockScoutWeb.API.V2.OptimismController.interop_messages/2` (`/api/v2/optimism/interop/messages`) - def paging_options(%{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do + defp do_paging_options(%{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do with {ts, ""} <- Integer.parse(timestamp), {:ok, transaction_hash} <- string_to_full_hash(init_transaction_hash) do [paging_options: %{@default_paging_options | key: {ts, transaction_hash}}] @@ -745,31 +701,34 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{ - token_contract_address_hash: token_contract_address_hash, - token_id: token_id, - token_type: token_type - }) do + defp do_paging_options(%{ + token_contract_address_hash: token_contract_address_hash, + token_id: token_id, + token_type: token_type + }) do [paging_options: %{@default_paging_options | key: {token_contract_address_hash, token_id, token_type}}] end - def paging_options(%{token_contract_address_hash: token_contract_address_hash, token_type: token_type}) do + defp do_paging_options(%{token_contract_address_hash: token_contract_address_hash, token_type: token_type}) do [paging_options: %{@default_paging_options | key: {token_contract_address_hash, token_type}}] end # Clause for `Explorer.Chain.Stability.Validator`, - # returned by `BlockScoutWeb.API.V2.ValidatorController.stability_validators_list/2` (`/api/v2/validators/stability`) - def paging_options(%{ - "state" => state, - "address_hash" => address_hash_string, - "blocks_validated" => blocks_validated_string - }) do + # returned by `BlockScoutWeb.API.V2.ValidatorController.stability_validators_list/2` (`/api/v2/validators/stability`). + # The endpoint is OpenAPI-spec'd, so `CastAndValidate` delivers atom-keyed params with the + # `blocks_validated` value already cast to an integer. + defp do_paging_options(%{ + state: state, + address_hash: address_hash_string, + blocks_validated: blocks_validated + }) + when is_integer(blocks_validated) do [ paging_options: %{ @default_paging_options | key: %{ address_hash: parse_address_hash(address_hash_string), - blocks_validated: parse_integer(blocks_validated_string), + blocks_validated: blocks_validated, state: if(state in PagingHelper.allowed_stability_validators_states(), do: state) } } @@ -778,9 +737,9 @@ defmodule BlockScoutWeb.Chain do # Clause for `Explorer.Chain.Blackfort.Validator`, # returned by `BlockScoutWeb.API.V2.ValidatorController.blackfort_validators_list/2` (`/api/v2/validators/blackfort`) - def paging_options(%{ - "address_hash" => address_hash_string - }) do + defp do_paging_options(%{ + "address_hash" => address_hash_string + }) do [ paging_options: %{ @default_paging_options @@ -791,7 +750,7 @@ defmodule BlockScoutWeb.Chain do ] end - def paging_options(_params), do: [paging_options: @default_paging_options] + defp do_paging_options(_params), do: [paging_options: @default_paging_options] def hot_smart_contracts_paging_options(%{ transactions_count: transactions_count, @@ -817,23 +776,6 @@ defmodule BlockScoutWeb.Chain do [paging_options: Map.put(paging_options, key, value)] end - def fetch_page_number(%{"page_number" => page_number_string}) do - case Integer.parse(page_number_string) do - {number, ""} -> - number - - _ -> - 1 - end - end - - def fetch_page_number(%{"items_count" => items_count_str}) do - {items_count, _} = Integer.parse(items_count_str) - div(items_count, @page_size) + 1 - end - - def fetch_page_number(_), do: 1 - def update_page_parameters(new_page_number, new_page_size, %PagingOptions{} = options) do %PagingOptions{options | page_number: new_page_number, page_size: new_page_size} end @@ -894,7 +836,43 @@ defmodule BlockScoutWeb.Chain do end end - def split_list_by_page(list_plus_one), do: Enum.split(list_plus_one, @page_size) + @doc """ + Splits a list fetched with n+1 strategy by the actual page size from paging_options, + and generates next_page_params if there are more items. + + Returns `{items, next_page_params | nil}`. + + ## Options + * `:paging_function` - custom function to extract paging params from the last item. + Defaults to `&paging_params/1`. + """ + @spec paginate_list(list(), map(), PagingOptions.t(), keyword()) :: {list(), map() | nil} + def paginate_list(list_plus_one, params, paging_options, opts \\ []) + + def paginate_list([], _params, _paging_options, _opts), do: {[], nil} + + def paginate_list(list_plus_one, params, %PagingOptions{page_size: page_size}, opts) do + actual_page_size = page_size - 1 + {items, next_page} = Enum.split(list_plus_one, actual_page_size) + + next_page_params = + case next_page do + [] -> + nil + + _ -> + paging_function = Keyword.get(opts, :paging_function, &paging_params/1) + paging_params = paging_function.(List.last(items)) + string_keys = map_to_string_keys(paging_params) + + params + |> delete_parameters_from_next_page_params() + |> Map.drop(string_keys) + |> Map.merge(paging_params) + end + + {items, next_page_params} + end defp decimal_parse(input_string) do case Decimal.parse(input_string) do @@ -930,11 +908,11 @@ defmodule BlockScoutWeb.Chain do end end - defp paging_params(%Address{ - hash: hash, - fetched_coin_balance: fetched_coin_balance, - transactions_count: transactions_count - }) do + def paging_params(%Address{ + hash: hash, + fetched_coin_balance: fetched_coin_balance, + transactions_count: transactions_count + }) do %{ hash: hash, fetched_coin_balance: fetched_coin_balance && Wei.to(fetched_coin_balance, :wei), @@ -942,13 +920,13 @@ defmodule BlockScoutWeb.Chain do } end - defp paging_params(%Token{ - contract_address_hash: contract_address_hash, - circulating_market_cap: circulating_market_cap, - holder_count: holders_count, - name: token_name, - fiat_value: fiat_value - }) do + def paging_params(%Token{ + contract_address_hash: contract_address_hash, + circulating_market_cap: circulating_market_cap, + holder_count: holders_count, + name: token_name, + fiat_value: fiat_value + }) do %{ market_cap: circulating_market_cap, holders_count: holders_count, @@ -959,93 +937,93 @@ defmodule BlockScoutWeb.Chain do } end - defp paging_params({%Token{} = token, _}) do + def paging_params({%Token{} = token, _}) do paging_params(token) end - defp paging_params(%TagAddress{id: id}) do + def paging_params(%TagAddress{id: id}) do %{"id" => id} end - defp paging_params(%TagTransaction{id: id}) do + def paging_params(%TagTransaction{id: id}) do %{"id" => id} end - defp paging_params(%WatchlistAddress{id: id}) do + def paging_params(%WatchlistAddress{id: id}) do %{"id" => id} end - defp paging_params([%Token{} = token, _]) do + def paging_params([%Token{} = token, _]) do paging_params(token) end - defp paging_params({%Reward{block: %{number: number}}, _}) do + def paging_params({%Reward{block: %{number: number}}, _}) do %{"block_number" => number, "index" => 0} end - defp paging_params(%Block{number: number}) do + def paging_params(%Block{number: number}) do %{block_number: number} end - defp paging_params(%InternalTransaction{ - index: index, - transaction_index: transaction_index, - block_number: block_number - }) do + def paging_params(%InternalTransaction{ + index: index, + transaction_index: transaction_index, + block_number: block_number + }) do %{block_number: block_number, transaction_index: transaction_index, index: index} end - defp paging_params(%Log{index: index, block_number: block_number}) do + def paging_params(%Log{index: index, block_number: block_number}) do %{block_number: block_number, index: index} end - defp paging_params(%Transaction{block_number: nil, inserted_at: inserted_at, hash: hash}) do + def paging_params(%Transaction{block_number: nil, inserted_at: inserted_at, hash: hash}) do %{inserted_at: DateTime.to_iso8601(inserted_at), hash: hash} end - defp paging_params(%Transaction{block_number: block_number, index: index}) do + def paging_params(%Transaction{block_number: block_number, index: index}) do %{block_number: block_number, index: index} end - defp paging_params(%TokenTransfer{block_number: block_number, log_index: index}) do + def paging_params(%TokenTransfer{block_number: block_number, log_index: index}) do %{block_number: block_number, index: index} end - defp paging_params(%Address.Token{name: name, type: type, inserted_at: inserted_at}) do + def paging_params(%Address.Token{name: name, type: type, inserted_at: inserted_at}) do inserted_at_datetime = DateTime.to_iso8601(inserted_at) %{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at_datetime} end - defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) when is_nil(value) do + def paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) when is_nil(value) do %{address_hash: to_string(address_hash), value: nil} end - defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) do + def paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) do %{address_hash: to_string(address_hash), value: to_string(Decimal.to_integer(value))} end - defp paging_params(%CoinBalance{block_number: block_number}) do + def paging_params(%CoinBalance{block_number: block_number}) do %{block_number: block_number} end - defp paging_params(%SmartContract{address: %NotLoaded{}} = smart_contract) do + def paging_params(%SmartContract{address: %NotLoaded{}} = smart_contract) do %{smart_contract_id: smart_contract.id} end - defp paging_params(%OptimismFrameSequence{id: id}) do + def paging_params(%OptimismFrameSequence{id: id}) do %{id: id} end - defp paging_params(%OptimismOutputRoot{l2_output_index: index}) do + def paging_params(%OptimismOutputRoot{l2_output_index: index}) do %{index: index} end - defp paging_params(%OptimismInteropMessage{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do + def paging_params(%OptimismInteropMessage{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do %{timestamp: DateTime.to_unix(timestamp), init_transaction_hash: init_transaction_hash} end - defp paging_params(%SmartContract{} = smart_contract) do + def paging_params(%SmartContract{} = smart_contract) do %{ smart_contract_id: smart_contract.id, transactions_count: smart_contract.address.transactions_count, @@ -1054,50 +1032,47 @@ defmodule BlockScoutWeb.Chain do } end - defp paging_params(%ScrollBatch{number: number}) do + def paging_params(%ScrollBatch{number: number}) do %{number: number} end - defp paging_params(%ScrollBridge{index: id}) do + def paging_params(%ScrollBridge{index: id}) do %{id: id} end - defp paging_params(%Instance{token_id: token_id}) do + def paging_params(%Instance{token_id: token_id}) do %{"unique_token" => Decimal.to_integer(token_id)} end - defp paging_params(%StateChange{}) do - # todo: remove in the future as this param is unused in the pagination of state changes - %{state_changes: nil} - end - - defp paging_params(%{index: index}) do + def paging_params(%{index: index}) do %{index: index} end # clause for zkEVM batches pagination - defp paging_params(%{number: number}) do + def paging_params(%{number: number}) do %{"number" => number} end - # clause for Optimism Deposits - defp paging_params(%{l1_block_number: l1_block_number, l2_transaction_hash: l2_transaction_hash}) do - %{l1_block_number: l1_block_number, transaction_hash: l2_transaction_hash} + # Shibarium Deposits — must precede the Optimism Deposits clause since the + # Shibarium reader map also contains :l1_block_number and :l2_transaction_hash; + # :user is present only in the Shibarium-shaped map. + def paging_params(%{l1_block_number: block_number, user: _}) do + %{block_number: block_number} end - # clause for Optimism Withdrawals - defp paging_params(%{msg_nonce: nonce}) do - %{nonce: nonce} + # Shibarium Withdrawals — kept next to deposits for symmetry; :user is the discriminator. + def paging_params(%{l2_block_number: block_number, user: _}) do + %{block_number: block_number} end - # clause for Shibarium Deposits - defp paging_params(%{l1_block_number: block_number}) do - %{"block_number" => block_number} + # clause for Optimism Deposits + def paging_params(%{l1_block_number: l1_block_number, l2_transaction_hash: l2_transaction_hash}) do + %{l1_block_number: l1_block_number, transaction_hash: l2_transaction_hash} end - # clause for Shibarium Withdrawals - defp paging_params(%{l2_block_number: block_number}) do - %{"block_number" => block_number} + # clause for Optimism Withdrawals + def paging_params(%{msg_nonce: nonce}) do + %{nonce: nonce} end @spec paging_params_with_fiat_value(CurrentTokenBalance.t()) :: %{ @@ -1152,8 +1127,6 @@ defmodule BlockScoutWeb.Chain do |> delete_parameters_from_next_page_params() end - def token_transfers_next_page_params([], _list, _params), do: nil - @batch_transfer_fields_to_delete_from_next_page_params [ "batch_log_index", "batch_block_hash", @@ -1165,42 +1138,52 @@ defmodule BlockScoutWeb.Chain do :index_in_batch ] - def token_transfers_next_page_params(next_page, list, params) do - next_token_transfer = List.first(next_page) - current_token_transfer = List.last(list) - - if next_token_transfer.log_index == current_token_transfer.log_index and - next_token_transfer.block_hash == current_token_transfer.block_hash and - next_token_transfer.transaction_hash == current_token_transfer.transaction_hash do - new_params = - list - |> last_token_transfer_before_current(current_token_transfer) - |> (&if(is_nil(&1), do: %{}, else: paging_params(&1))).() - - # todo: consider removing it, when all controllers will get OpenAPI specs - string_keys = map_to_string_keys(new_params) - - params - |> delete_parameters_from_next_page_params() - |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) - |> Map.merge(new_params) - |> Map.merge(%{ - batch_log_index: current_token_transfer.log_index, - batch_block_hash: current_token_transfer.block_hash, - batch_transaction_hash: current_token_transfer.transaction_hash, - index_in_batch: current_token_transfer.index_in_batch - }) - else - new_params = paging_params(List.last(list)) + @spec token_transfers_next_page_params(list(), map(), PagingOptions.t()) :: {list(), map() | nil} + def token_transfers_next_page_params(list_plus_one, params, %PagingOptions{page_size: page_size}) do + actual_page_size = page_size - 1 + {items, next_page} = Enum.split(list_plus_one, actual_page_size) - # todo: consider removing it, when all controllers will get OpenAPI specs - string_keys = map_to_string_keys(new_params) + next_page_params = + case next_page do + [] -> + nil - params - |> delete_parameters_from_next_page_params() - |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) - |> Map.merge(new_params) - end + _ -> + next_token_transfer = List.first(next_page) + current_token_transfer = List.last(items) + + if next_token_transfer.log_index == current_token_transfer.log_index and + next_token_transfer.block_hash == current_token_transfer.block_hash and + next_token_transfer.transaction_hash == current_token_transfer.transaction_hash do + new_params = + items + |> last_token_transfer_before_current(current_token_transfer) + |> (&if(is_nil(&1), do: %{}, else: paging_params(&1))).() + + string_keys = map_to_string_keys(new_params) + + params + |> delete_parameters_from_next_page_params() + |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) + |> Map.merge(new_params) + |> Map.merge(%{ + batch_log_index: current_token_transfer.log_index, + batch_block_hash: current_token_transfer.block_hash, + batch_transaction_hash: current_token_transfer.transaction_hash, + index_in_batch: current_token_transfer.index_in_batch + }) + else + new_params = paging_params(List.last(items)) + string_keys = map_to_string_keys(new_params) + + params + |> delete_parameters_from_next_page_params() + |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) + |> Map.merge(new_params) + end + end + + {items, next_page_params} end defp last_token_transfer_before_current(list, current_token_transfer) do @@ -1299,6 +1282,7 @@ defmodule BlockScoutWeb.Chain do def fetch_internal_transactions(options) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) transaction_hash = Keyword.get(options, :transaction_hash) + include_zero_value? = Keyword.get(options, :include_zero_value, true) necessity_by_association = %{block: :optional} @@ -1327,6 +1311,7 @@ defmodule BlockScoutWeb.Chain do end match?(%PagingOptions{key: {_, _, _}}, paging_options) and + include_zero_value? and not InternalTransaction.present_in_db?(elem(paging_options.key, 0)) -> InternalTransactionOnDemand.fetch_latest(options_with_necessity) @@ -1334,7 +1319,7 @@ defmodule BlockScoutWeb.Chain do from_db = InternalTransaction.fetch(options_with_necessity) from_node = - if InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do + if include_zero_value? and InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do InternalTransactionOnDemand.fetch_latest(options_with_necessity) else [] @@ -1366,7 +1351,9 @@ defmodule BlockScoutWeb.Chain do """ @spec transaction_to_internal_transactions(Transaction.t(), Keyword.t()) :: [InternalTransaction.t()] def transaction_to_internal_transactions(transaction, options \\ []) do - if InternalTransaction.present_in_db?(transaction.block_number) do + include_zero_value? = Keyword.get(options, :include_zero_value, true) + + if InternalTransaction.present_in_db?(transaction.block_number) or not include_zero_value? do InternalTransaction.transaction_to_internal_transactions(transaction.hash, options) else InternalTransactionOnDemand.fetch_by_transaction(transaction, options) @@ -1395,7 +1382,9 @@ defmodule BlockScoutWeb.Chain do """ @spec block_to_internal_transactions(Block.t(), Keyword.t()) :: [InternalTransaction.t()] def block_to_internal_transactions(block, options \\ []) do - if InternalTransaction.present_in_db?(block.number) do + include_zero_value? = Keyword.get(options, :include_zero_value, true) + + if InternalTransaction.present_in_db?(block.number) or not include_zero_value? do InternalTransaction.block_to_internal_transactions(block.number, options) else InternalTransactionOnDemand.fetch_by_block(block, options) @@ -1428,10 +1417,12 @@ defmodule BlockScoutWeb.Chain do [] _ -> + include_zero_value? = Keyword.get(options, :include_zero_value, true) + from_db = InternalTransaction.fetch_from_db_by_address(address_hash, options) from_node = - if InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do + if include_zero_value? and InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do InternalTransactionOnDemand.fetch_by_address(address_hash, options) else [] @@ -1530,6 +1521,35 @@ defmodule BlockScoutWeb.Chain do |> Enum.take(limit) end + @doc """ + Overrides page size in paging options based on `items_count` parameter. + """ + @spec maybe_override_page_size(keyword(), map()) :: keyword() + def maybe_override_page_size(paging_options_keyword, params) do + case parse_items_count(params[:items_count] || params["items_count"]) do + count when is_integer(count) and count > 0 -> + clamped = min(count, Explorer.PagingOptions.max_page_size()) + + Keyword.update(paging_options_keyword, :paging_options, @default_paging_options, fn paging_options -> + %{paging_options | page_size: clamped + 1} + end) + + _ -> + paging_options_keyword + end + end + + defp parse_items_count(count) when is_integer(count), do: count + + defp parse_items_count(count) when is_binary(count) do + case Integer.parse(count) do + {parsed, ""} -> parsed + _ -> nil + end + end + + defp parse_items_count(_), do: nil + defp map_to_string_keys(map) do map |> Map.keys() diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex index 64c98210617..08aaaa29e1d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex @@ -38,7 +38,7 @@ defmodule BlockScoutWeb.Account.API.V2.EmailController do "user_id" => user.uid } - case HttpClient.post(url, Jason.encode!(body), headers) do + case HttpClient.post(url, Utils.JSON.encode!(body), headers) do {:ok, %{body: _body, status_code: 201}} -> identity |> Identity.changeset(%{verification_email_sent_at: DateTime.utc_now()}) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex index 6e99d13a4db..8fc5b7a5db7 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex @@ -9,9 +9,8 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 + paginate_list: 3, + paging_options: 1 ] alias Explorer.Account.Api.Key, as: ApiKey @@ -49,12 +48,11 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, {:watchlist, %{watchlists: [watchlist | _]}} <- {:watchlist, Repo.account_repo().preload(identity, :watchlists)} do - results_plus_one = WatchlistAddress.get_watchlist_addresses_by_watchlist_id(watchlist.id, paging_options(params)) + watchlist_paging_opts = paging_options(params) + results_plus_one = WatchlistAddress.get_watchlist_addresses_by_watchlist_id(watchlist.id, watchlist_paging_opts) - {watchlist_addresses, next_page} = split_list_by_page(results_plus_one) - - next_page_params = - next_page |> next_page_params(watchlist_addresses, params) + {watchlist_addresses, next_page_params} = + paginate_list(results_plus_one, params, watchlist_paging_opts[:paging_options]) watchlist_addresses_prepared = Enum.map(watchlist_addresses, fn %WatchlistAddress{} = wa -> @@ -235,11 +233,10 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do def tags_address(conn, params) do with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)} do - results_plus_one = TagAddress.get_tags_address_by_identity_id(identity.id, paging_options(params)) - - {tags, next_page} = split_list_by_page(results_plus_one) + tags_paging_opts = paging_options(params) + results_plus_one = TagAddress.get_tags_address_by_identity_id(identity.id, tags_paging_opts) - next_page_params = next_page |> next_page_params(tags, params) + {tags, next_page_params} = paginate_list(results_plus_one, params, tags_paging_opts[:paging_options]) conn |> put_status(200) @@ -294,11 +291,10 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do def tags_transaction(conn, params) do with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)} do - results_plus_one = TagTransaction.get_tags_transaction_by_identity_id(identity.id, paging_options(params)) - - {tags, next_page} = split_list_by_page(results_plus_one) + tx_tags_paging_opts = paging_options(params) + results_plus_one = TagTransaction.get_tags_transaction_by_identity_id(identity.id, tx_tags_paging_opts) - next_page_params = next_page |> next_page_params(tags, params) + {tags, next_page_params} = paginate_list(results_plus_one, params, tx_tags_paging_opts[:paging_options]) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex index fe61bf158c5..3a7b2c978fe 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex @@ -7,7 +7,8 @@ defmodule BlockScoutWeb.AddressCoinBalanceController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] alias BlockScoutWeb.{AccessHelper, AddressCoinBalanceView, Controller} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex index f68551b900a..38a481e4ee6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex @@ -5,7 +5,8 @@ defmodule BlockScoutWeb.AddressController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] @@ -64,16 +65,6 @@ defmodule BlockScoutWeb.AddressController do exchange_rate = Market.get_coin_exchange_rate() total_supply = Chain.total_supply() - items_count_str = Map.get(params, "items_count") - - items_count = - if items_count_str do - {items_count, _} = Integer.parse(items_count_str) - items_count - else - 0 - end - items = addresses_page |> Enum.with_index(1) @@ -82,7 +73,7 @@ defmodule BlockScoutWeb.AddressController do AddressView, "_tile.html", address: address, - index: items_count + index, + index: index, exchange_rate: exchange_rate, total_supply: total_supply, transaction_count: address.transactions_count diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex index 3b4237fe9d5..4ccc87867f2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex @@ -12,11 +12,11 @@ defmodule BlockScoutWeb.AddressInternalTransactionController do only: [ current_filter: 1, paging_options: 1, - next_page_params: 3, - split_list_by_page: 1, address_to_internal_transactions: 2 ] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] alias BlockScoutWeb.{AccessHelper, Controller, InternalTransactionView} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex index f0bdb668281..8cbfad05877 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex @@ -6,7 +6,8 @@ defmodule BlockScoutWeb.AddressLogsController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] @@ -27,10 +28,7 @@ defmodule BlockScoutWeb.AddressLogsController do params |> paging_options() |> Keyword.merge( - necessity_by_association: %{ - [address: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()]] => - :optional - } + address_preloads: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()] ) logs_plus_one = Chain.address_to_logs(address_hash, false, options) @@ -102,10 +100,7 @@ defmodule BlockScoutWeb.AddressLogsController do params |> paging_options() |> Keyword.merge( - necessity_by_association: %{ - [address: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()]] => - :optional - } + address_preloads: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()] ) |> Keyword.merge(topic: formatted_topic) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex index e42d7131893..1825f9df89d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex @@ -2,8 +2,8 @@ defmodule BlockScoutWeb.AddressTokenController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, - only: [next_page_params: 5, paging_options: 1, split_list_by_page: 1, paging_params_with_fiat_value: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1, paging_params_with_fiat_value: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 4, split_list_by_page: 1] import BlockScoutWeb.Account.AuthController, only: [current_user: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] @@ -25,7 +25,7 @@ defmodule BlockScoutWeb.AddressTokenController do {tokens, next_page} = split_list_by_page(token_balances_plus_one) next_page_path = - case next_page_params(next_page, tokens, params, false, &paging_params_with_fiat_value/1) do + case next_page_params(next_page, tokens, params, &paging_params_with_fiat_value/1) do nil -> nil diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex index bd00d9f1812..72a6aa53c47 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex @@ -11,8 +11,8 @@ defmodule BlockScoutWeb.AddressTokenTransferController do alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Phoenix.View - import BlockScoutWeb.Chain, - only: [current_filter: 1, next_page_params: 3, paging_options: 1, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [current_filter: 1, paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex index ac40c01d20f..f18e35e4157 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex @@ -8,8 +8,8 @@ defmodule BlockScoutWeb.AddressTransactionController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, - only: [current_filter: 1, paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [current_filter: 1, paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex index a104540f8c9..424b645ea2f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex @@ -7,8 +7,8 @@ defmodule BlockScoutWeb.AddressValidationController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, - only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex index a10d0c614f1..6131c9b0bd8 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex @@ -8,7 +8,8 @@ defmodule BlockScoutWeb.AddressWithdrawalController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex index 9dffd228804..6cd5cc456e4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex @@ -6,18 +6,18 @@ defmodule BlockScoutWeb.Admin.TaskController do alias Explorer.Chain.ContractMethod - @ok_resp Poison.encode!(%{status: "success"}) - @not_ok_resp Poison.encode!(%{status: "failure"}) + @ok_resp %{status: "success"} + @not_ok_resp %{status: "failure"} def create_contract_methods(conn, _) do case ContractMethod.import_all() do :ok -> - send_resp(conn, 200, Poison.encode!(@ok_resp)) + send_resp(conn, 200, Utils.JSON.encode!(@ok_resp)) {:error, error} -> Logger.error(fn -> ["Something went wrong while creating contract methods: ", inspect(error)] end) - send_resp(conn, 500, Poison.encode!(@not_ok_resp)) + send_resp(conn, 500, Utils.JSON.encode!(@not_ok_resp)) end end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex index d3f9d488627..4e4f2e96dbd 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex @@ -39,7 +39,7 @@ defmodule BlockScoutWeb.API.EthRPC.EthController do # nil and the body being the key (as in a CURL request w/ no content type header) decoded_request = with [{single_key, nil}] <- Map.to_list(request), - {:ok, decoded} <- Jason.decode(single_key) do + {:ok, decoded} <- Utils.JSON.decode(single_key) do decoded else _ -> request diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex index 26bc3a8ca27..593211f8865 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex @@ -75,7 +75,7 @@ defmodule BlockScoutWeb.API.HealthController do conn, status, health_status_with_error - |> Jason.encode!() + |> Utils.JSON.encode!() ) end @@ -87,7 +87,7 @@ defmodule BlockScoutWeb.API.HealthController do "healthy" => true, "data" => %{} } - |> Jason.encode!() + |> Utils.JSON.encode!() ) end @@ -167,12 +167,12 @@ defmodule BlockScoutWeb.API.HealthController do metadata: meta } } - |> Jason.encode!() + |> Utils.JSON.encode!() send_resp(conn, :ok, response) _ -> - send_resp(conn, :internal_server_error, Jason.encode!(%{error: "Failed to fetch migration status"})) + send_resp(conn, :internal_server_error, Utils.JSON.encode!(%{error: "Failed to fetch migration status"})) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex index a7a922b999f..d627704de79 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex @@ -6,7 +6,7 @@ defmodule BlockScoutWeb.API.Legacy.BlockController do # aliased with as: to avoid shadowing this module's own name # (BlockScoutWeb.API.V2.Legacy.BlockController) alias BlockScoutWeb.API.RPC.BlockController, as: V1BlockController - alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, EthBlockNumberResult, GetBlockNumberByTimeResult} + alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, GetBlockNumberByTimeResult} alias BlockScoutWeb.Schemas.API.V2.General alias OpenApiSpex.{Parameter, Schema} @@ -45,30 +45,4 @@ defmodule BlockScoutWeb.API.Legacy.BlockController do """ @spec get_block_number_by_time(Plug.Conn.t(), map()) :: Plug.Conn.t() def get_block_number_by_time(conn, params), do: V1BlockController.getblocknobytime(conn, params) - - operation :eth_block_number, - summary: "Get the latest block number", - description: """ - Returns the latest block number as a hex-encoded string in a JSON-RPC 2.0 response. - """, - parameters: - [ - %Parameter{ - name: :id, - in: :query, - schema: %Schema{anyOf: [%Schema{type: :integer}, %Schema{type: :string}]}, - description: - "JSON-RPC request id echoed back in the response. " <> - "Defaults to 1 when omitted." - } - ] ++ General.base_params(), - responses: [ - ok: {"Latest block number", "application/json", Envelope.eth_rpc_envelope(EthBlockNumberResult)} - ] - - @doc """ - Thin bridge to the v1 `eth_block_number` action at `/api?module=block&action=eth_block_number`. - """ - @spec eth_block_number(Plug.Conn.t(), map()) :: Plug.Conn.t() - def eth_block_number(conn, params), do: V1BlockController.eth_block_number(conn, params) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/eth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/eth_controller.ex new file mode 100644 index 00000000000..6bf0786944f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/eth_controller.ex @@ -0,0 +1,186 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.EthController do + @moduledoc """ + Thin OpenAPI-documented wrappers for individual JSON-RPC methods that are + otherwise reachable only via the dispatch-by-`method` endpoint `/api/eth-rpc`. + + Each action: + + * accepts a JSON-RPC 2.0 single-object body (batch arrays are rejected), + * validates that the body's `method` field matches the action's pinned + method name and otherwise renders a JSON-RPC error envelope with the + same `id`, + * delegates to `BlockScoutWeb.API.EthRPC.EthController.eth_request/2`, + which performs the actual work and renders via `EthRPCView`. + + Like `/api/eth-rpc`, the legacy endpoints use no session authentication and + no CSRF check (the `:api_v2_no_session` pipeline), and they are compiled + unless `API_V1_READ_METHODS_DISABLED=true` at compile time. + + Behavioral divergence from `/api/eth-rpc` worth noting: + + * the legacy endpoints check that API v2 is enabled, so when API v2 is + globally disabled the legacy routes return 404 while `/api/eth-rpc` + keeps working; + * batches are not supported here — POST an array body and the endpoint + returns a JSON-RPC error envelope rather than dispatching the batch. + """ + + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias BlockScoutWeb.API.EthRPC.EthController, as: V1EthController + alias BlockScoutWeb.API.EthRPC.View, as: EthRPCView + alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, EthBlockNumberResult} + alias BlockScoutWeb.Schemas.API.V2.General + + alias BlockScoutWeb.Schemas.API.Legacy.EthRpc.{ + EthCallResult, + EthGetBalanceResult, + EthGetLogsResult, + EthGetStorageAtResult, + EthSendRawTransactionResult, + Requests + } + + tags(["legacy"]) + + operation :eth_call, + summary: "Execute a contract call (eth_call)", + description: """ + Performs a read-only EVM call against the given contract at the given block, + without creating a transaction or modifying chain state — useful for + querying contract data without spending gas. + """, + parameters: General.base_params(), + request_body: Requests.eth_call(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(EthCallResult)} + ] + + @spec eth_call(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_call(conn, params), do: dispatch(conn, params, "eth_call") + + operation :eth_get_balance, + summary: "Get an address balance (eth_getBalance)", + description: """ + Returns the wei balance of the given address at the given block. + """, + parameters: General.base_params(), + request_body: Requests.eth_get_balance(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(EthGetBalanceResult)} + ] + + @spec eth_get_balance(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_get_balance(conn, params), do: dispatch(conn, params, "eth_getBalance") + + operation :eth_get_storage_at, + summary: "Read contract storage (eth_getStorageAt)", + description: """ + Returns the raw storage value at the given slot of the given contract + address at the given block. + """, + parameters: General.base_params(), + request_body: Requests.eth_get_storage_at(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(EthGetStorageAtResult)} + ] + + @spec eth_get_storage_at(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_get_storage_at(conn, params), do: dispatch(conn, params, "eth_getStorageAt") + + operation :eth_send_raw_transaction, + summary: "Submit a signed transaction (eth_sendRawTransaction)", + description: """ + Submits a pre-signed transaction to the underlying JSON-RPC node for + inclusion in the mempool, and returns the transaction hash on acceptance. + """, + parameters: General.base_params(), + request_body: Requests.eth_send_raw_transaction(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(EthSendRawTransactionResult)} + ] + + @spec eth_send_raw_transaction(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_send_raw_transaction(conn, params), do: dispatch(conn, params, "eth_sendRawTransaction") + + operation :eth_block_number, + summary: "Get the latest block number (eth_blockNumber)", + description: """ + Returns the latest block number as a hex-encoded string in a JSON-RPC 2.0 response. + """, + parameters: General.base_params(), + request_body: Requests.eth_block_number(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(EthBlockNumberResult)} + ] + + @spec eth_block_number(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_block_number(conn, params), do: dispatch(conn, params, "eth_blockNumber") + + operation :eth_get_logs, + summary: "Get event logs (eth_getLogs)", + description: """ + Returns event logs matching the supplied filter object, in JSON-RPC 2.0 format. + Up to a maximum of 1,000 log entries are returned per request. + """, + parameters: General.base_params(), + request_body: Requests.eth_get_logs(), + responses: [ + ok: + {"JSON-RPC 2.0 envelope (success or error; HTTP 200 regardless).", "application/json", + Envelope.eth_rpc_envelope(%OpenApiSpex.Schema{type: :array, items: EthGetLogsResult, nullable: true})} + ] + + @spec eth_get_logs(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_get_logs(conn, params), do: dispatch(conn, params, "eth_getLogs") + + @spec dispatch(Plug.Conn.t(), map(), String.t()) :: Plug.Conn.t() + defp dispatch(conn, params, expected_method) do + cond do + batch?(params) -> + render_jsonrpc_error( + conn, + "Batch requests are not supported on this endpoint", + extract_id(params) + ) + + not method_matches?(params, expected_method) -> + render_jsonrpc_error( + conn, + "`method` must be `#{expected_method}` for this endpoint", + extract_id(params) + ) + + true -> + V1EthController.eth_request(conn, params) + end + end + + defp batch?(%{"_json" => list}) when is_list(list), do: true + defp batch?(_), do: false + + defp method_matches?(%{"method" => method}, expected) when is_binary(method), do: method == expected + defp method_matches?(_, _), do: false + + defp extract_id(%{"id" => id}) when is_integer(id) or is_binary(id) or is_nil(id), do: id + defp extract_id(_), do: 0 + + defp render_jsonrpc_error(conn, message, id) do + conn + |> put_status(200) + |> put_view(EthRPCView) + |> render("error.json", %{error: message, id: id}) + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex deleted file mode 100644 index 32ad67567a4..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.API.Legacy.LogsController do - use BlockScoutWeb, :controller - use OpenApiSpex.ControllerSpecs - - alias BlockScoutWeb.API.RPC.LogsController, as: V1LogsController - alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, LogItem} - alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.{Parameter, Schema} - - tags(["legacy"]) - - @topic_schema %Schema{type: :string, pattern: ~r/^0x[0-9a-fA-F]{64}$/} - @topic_opr_schema %Schema{type: :string, enum: ["and", "or"]} - - operation :get_logs, - summary: "Get Event Logs by Address and/or Topic(s)", - description: """ - Event logs for an address and topic. Use and/or with the topic operator to specify - topic retrieval options when adding multiple topics. Up to a maximum of 1,000 event logs. - - Required: - - `fromBlock` and `toBlock` - - At least one of `address`, `topic0`, `topic1`, `topic2`, `topic3` - - If any pair of topic parameters is set, the corresponding `topicA_B_opr` is required. - """, - parameters: - [ - %Parameter{ - name: :fromBlock, - in: :query, - schema: %Schema{anyOf: [General.IntegerString, %Schema{type: :string, enum: ["latest"]}]}, - description: "Start block: integer or the sentinel \"latest\"" - }, - %Parameter{ - name: :toBlock, - in: :query, - schema: %Schema{anyOf: [General.IntegerString, %Schema{type: :string, enum: ["latest"]}]}, - description: "End block: integer or the sentinel \"latest\"" - }, - %Parameter{name: :address, in: :query, schema: General.AddressHash}, - %Parameter{name: :topic0, in: :query, schema: @topic_schema}, - %Parameter{name: :topic1, in: :query, schema: @topic_schema}, - %Parameter{name: :topic2, in: :query, schema: @topic_schema}, - %Parameter{name: :topic3, in: :query, schema: @topic_schema}, - %Parameter{name: :topic0_1_opr, in: :query, schema: @topic_opr_schema}, - %Parameter{name: :topic0_2_opr, in: :query, schema: @topic_opr_schema}, - %Parameter{name: :topic0_3_opr, in: :query, schema: @topic_opr_schema}, - %Parameter{name: :topic1_2_opr, in: :query, schema: @topic_opr_schema}, - %Parameter{name: :topic1_3_opr, in: :query, schema: @topic_opr_schema}, - %Parameter{name: :topic2_3_opr, in: :query, schema: @topic_opr_schema} - ] ++ General.base_params(), - responses: [ - ok: - {"Event logs", "application/json", Envelope.rpc_envelope(%Schema{type: :array, items: LogItem, nullable: true})} - ] - - @doc """ - Thin bridge to the v1 `getlogs` action at `/api?module=logs&action=getlogs`. - """ - @spec get_logs(Plug.Conn.t(), map()) :: Plug.Conn.t() - def get_logs(conn, params), do: V1LogsController.getlogs(conn, params) -end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex index 30baabf032d..aeeb1865df9 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex @@ -2,7 +2,8 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 2] alias Explorer.Chain alias Explorer.Chain.{DenormalizationHelper, Transaction} @@ -16,7 +17,7 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do transaction_from_hash(transaction_hash), paging_options <- paging_options(params) do logs = Chain.transaction_to_logs(transaction_hash, Keyword.merge(paging_options, @api_true)) - {logs, next_page} = split_list_by_page(logs) + {logs, next_page} = split_list_by_page(logs, paging_options[:paging_options]) transaction_updated = if (error == "Reverted" || error == "execution reverted") && !revert_reason do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex index e9c2dc4fbb9..fb2a3f33678 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex @@ -33,7 +33,7 @@ defmodule BlockScoutWeb.API.V1.GasPriceOracleController do defp result(gas_prices) do %{slow: gas_prices[:slow][:price], average: gas_prices[:average][:price], fast: gas_prices[:fast][:price]} - |> Jason.encode!() + |> Utils.JSON.encode!() end def error({:error, error}) do @@ -46,6 +46,6 @@ defmodule BlockScoutWeb.API.V1.GasPriceOracleController do "error_title" => "Error", "error_description" => "Internal server error" } - |> Jason.encode!() + |> Utils.JSON.encode!() end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex index a4b23d2f689..4fb56c660a4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex @@ -50,7 +50,7 @@ defmodule BlockScoutWeb.API.V1.VerifiedSmartContractController do end defp encode(data) do - Jason.encode!(data) + Utils.JSON.encode!(data) end defp fetch_external_libraries(params) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex index 46322431d6a..646995a68e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex @@ -56,7 +56,7 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeController do def show_badge_addresses(conn, _) do with {:ok, body, _conn} <- Conn.read_body(conn, []), - {:ok, %{"api_key" => provided_api_key}} <- Jason.decode(body), + {:ok, %{"api_key" => provided_api_key}} <- Utils.JSON.decode(body), :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(provided_api_key) do badge_to_address_list = ScamBadgeToAddress.get(@api_true) 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 f72534d4257..109ef28f16e 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 @@ -7,11 +7,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - next_page_params: 5, + paginate_list: 3, + paginate_list: 4, token_transfers_next_page_params: 3, paging_options: 1, - split_list_by_page: 1, + maybe_override_page_size: 2, current_filter: 1, paging_params_with_fiat_value: 1, fetch_scam_token_toggle: 2, @@ -41,6 +41,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do BlockView, Ethereum.DepositController, Ethereum.DepositView, + InternalTransactionsPendingStatusHelper, TransactionView, WithdrawalView } @@ -185,6 +186,36 @@ defmodule BlockScoutWeb.API.V2.AddressController do } ] + @base_counter_name_to_json_field_name %{ + validations: :validations_count, + transactions: :transactions_count, + token_transfers: :token_transfers_count, + token_balances: :token_balances_count, + logs: :logs_count, + withdrawals: :withdrawals_count, + internal_transactions: :internal_transactions_count + } + + case @chain_identity do + {:optimism, :celo} -> + @chain_identity_counter_name_to_json_field_name %{celo_election_rewards: :celo_election_rewards_count} + + _ -> + @chain_identity_counter_name_to_json_field_name %{} + end + + case @chain_type do + :ethereum -> + @chain_type_counter_name_to_json_field_name %{beacon_deposits: :beacon_deposits_count} + + _ -> + @chain_type_counter_name_to_json_field_name %{} + end + + @counter_name_to_json_field_name @base_counter_name_to_json_field_name + |> Map.merge(@chain_identity_counter_name_to_json_field_name) + |> Map.merge(@chain_type_counter_name_to_json_field_name) + @spec contract_address_preloads() :: [keyword()] defp contract_address_preloads do chain_type_associations = @@ -318,9 +349,9 @@ defmodule BlockScoutWeb.API.V2.AddressController do end if @chain_type == :zilliqa do - @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, ERC-404, and ZRC-2 tokens." + @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, ERC-404, ERC-8056, and ZRC-2 tokens." else - @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, and ERC-404 tokens." + @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, ERC-404, and ERC-8056 tokens." end operation :token_balances, @@ -396,8 +427,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do "inserted_at", "hash", "value", - "fee", - "items_count" + "fee" ]), responses: [ ok: @@ -410,7 +440,6 @@ defmodule BlockScoutWeb.API.V2.AddressController do "hash" => "0xe38d616dade747097354b0731b5560f581536dacf22121feb4bb4a0b776018aa", "index" => 103, "inserted_at" => "2025-05-26T10:26:51.474448Z", - "items_count" => 50, "value" => "24741049597737" } )}, @@ -445,15 +474,10 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Keyword.merge(address_transactions_sorting(params)) results_plus_one = Transaction.address_to_transactions_without_rewards(address_hash, options, false) - {transactions, next_page} = split_list_by_page(results_plus_one) - - next_page_params = - next_page - |> next_page_params( - transactions, - params, - false, - &Transaction.address_transactions_next_page_params/1 + + {transactions, next_page_params} = + paginate_list(results_plus_one, params, options[:paging_options], + paging_function: &Transaction.address_transactions_next_page_params/1 ) conn @@ -501,7 +525,6 @@ defmodule BlockScoutWeb.API.V2.AddressController do define_paging_params([ "block_number", "index", - "items_count", "batch_log_index", "batch_block_hash", "batch_transaction_hash", @@ -514,8 +537,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do items: Schemas.TokenTransfer, next_page_params_example: %{ "block_number" => 12_345_678, - "index" => 0, - "items_count" => 50 + "index" => 0 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -564,11 +586,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Chain.flat_1155_batch_token_transfers() |> Chain.paginate_1155_batch_token_transfers(paging_options) - {token_transfers, next_page} = split_list_by_page(results) - - next_page_params = - next_page - |> token_transfers_next_page_params(token_transfers, params) + {token_transfers, next_page_params} = + token_transfers_next_page_params(results, params, options[:paging_options]) conn |> put_status(200) @@ -604,17 +623,17 @@ defmodule BlockScoutWeb.API.V2.AddressController do "Retrieves all internal transactions involving a specific address, with optional filtering for internal transactions sent from or to the address.", parameters: base_params() ++ - [address_hash_param(), direction_filter_param()] ++ - define_paging_params(["block_number", "index", "items_count", "transaction_index"]), + [address_hash_param(), direction_filter_param(), include_zero_value_param()] ++ + define_paging_params(["block_number", "index", "transaction_index"]), responses: [ ok: {"All internal transactions for the specified address.", "application/json", paginated_response( items: Schemas.InternalTransaction, + include_pending_status?: true, next_page_params_example: %{ "block_number" => 22_530_770, "index" => 8, - "items_count" => 50, "transaction_index" => 8 } )}, @@ -652,12 +671,15 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> paging_options() |> Keyword.merge(current_filter(params)) |> Keyword.merge(@api_true) + |> Keyword.put(:include_zero_value, Map.get(params, :include_zero_value, true)) results_plus_one = address_to_internal_transactions(address_hash, full_options) - {internal_transactions, next_page} = split_list_by_page(results_plus_one) - next_page_params = - next_page |> next_page_params(internal_transactions, params) + {internal_transactions, next_page_params} = + paginate_list(results_plus_one, params, full_options[:paging_options]) + + pending_status? = + InternalTransactionsPendingStatusHelper.address_internal_transactions_pending?(internal_transactions) conn |> put_status(200) @@ -671,16 +693,20 @@ defmodule BlockScoutWeb.API.V2.AddressController do @api_true ) |> maybe_preload_ens_and_metadata(), - next_page_params: next_page_params + next_page_params: next_page_params, + pending_status?: pending_status? }) _ -> + pending_status? = InternalTransactionsPendingStatusHelper.address_internal_transactions_pending?([]) + conn |> put_status(200) |> put_view(TransactionView) |> render(:internal_transactions, %{ internal_transactions: [], - next_page_params: nil + next_page_params: nil, + pending_status?: pending_status? }) end end @@ -691,13 +717,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do description: "Retrieves event logs emitted by or involving a specific address.", parameters: base_params() ++ - [address_hash_param(), topic_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + [address_hash_param(), topic_param()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"Event logs for the specified address, with pagination.", "application/json", paginated_response( items: Schemas.Log, - next_page_params_example: %{"block_number" => 22_546_398, "index" => 268, "items_count" => 50} + next_page_params_example: %{"block_number" => 22_546_398, "index" => 268} )}, unprocessable_entity: JsonErrorResponse.response(), forbidden: ForbiddenResponse.response() @@ -727,19 +753,15 @@ defmodule BlockScoutWeb.API.V2.AddressController do params |> paging_options() |> Keyword.merge( - necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional, - :block => :optional - } + address_preloads: [:names, :smart_contract, proxy_implementations_smart_contracts_association()], + transaction_preloads: [to_address: [:smart_contract, proxy_implementations_smart_contracts_association()]] ) |> Keyword.merge(@api_true) |> Keyword.put(:topic, topic) results_plus_one = Chain.address_to_logs(address_hash, false, options) - {logs, next_page} = split_list_by_page(results_plus_one) - - next_page_params = next_page |> next_page_params(logs, params) + {logs, next_page_params} = paginate_list(results_plus_one, params, options[:paging_options]) conn |> put_status(200) @@ -765,13 +787,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do summary: "List blocks validated (mined) by a specific validator/miner address", description: "Retrieves blocks that were validated (mined) by a specific address. Useful for tracking validator/miner performance.", - parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number", "items_count"]), + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number"]), responses: [ ok: {"Blocks validated by the specified address, with pagination.", "application/json", paginated_response( items: Schemas.Block, - next_page_params_example: %{"block_number" => 22_546_398, "items_count" => 50} + next_page_params_example: %{"block_number" => 22_546_398} )}, unprocessable_entity: JsonErrorResponse.response(), forbidden: ForbiddenResponse.response() @@ -810,9 +832,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Keyword.merge(@api_true) results_plus_one = Block.get_blocks_validated_by_address(full_options, address_hash) - {blocks, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(blocks, params) + {blocks, next_page_params} = paginate_list(results_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -832,13 +853,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do summary: "Get native coin balance history for an address showing all balance changes", description: "Retrieves historical native coin balance changes for a specific address, tracking how an address's balance has changed over time.", - parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number", "items_count"]), + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number"]), responses: [ ok: {"Historical coin balance changes for the specified address, with pagination.", "application/json", paginated_response( items: Schemas.CoinBalance, - next_page_params_example: %{"block_number" => 22_546_398, "items_count" => 50} + next_page_params_example: %{"block_number" => 22_546_398} )}, unprocessable_entity: JsonErrorResponse.response(), forbidden: ForbiddenResponse.response() @@ -867,10 +888,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do results_plus_one = CoinBalance.address_hash_to_coin_balances(address_hash, full_options) - {coin_balances, next_page} = split_list_by_page(results_plus_one) - - next_page_params = - next_page |> next_page_params(coin_balances, params) + {coin_balances, next_page_params} = paginate_list(results_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -948,7 +966,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [address_hash_param(), token_type_param()] ++ - define_paging_params(["fiat_value_nullable", "id", "items_count", "value"]), + define_paging_params(["fiat_value_nullable", "id", "value"]), responses: [ ok: {"Token balances for the specified address with pagination.", "application/json", @@ -957,7 +975,6 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page_params_example: %{ "fiat_value" => nil, "id" => 12_519_063_346, - "items_count" => 50, "value" => "3750000000000000000000" } )}, @@ -999,15 +1016,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do TokenBalanceOnDemand.trigger_fetch(ip, address_hash) - {tokens, next_page} = split_list_by_page(results_plus_one) + paging_opts = + params + |> paging_options() - next_page_params = - next_page - |> next_page_params( - tokens, - params, - false, - &paging_params_with_fiat_value/1 + {tokens, next_page_params} = + paginate_list(results_plus_one, params, paging_opts[:paging_options], + paging_function: &paging_params_with_fiat_value/1 ) conn @@ -1026,14 +1041,14 @@ defmodule BlockScoutWeb.API.V2.AddressController do summary: "List validator withdrawals involving a specific address", description: "Retrieves withdrawals involving a specific address, typically for proof-of-stake networks supporting validator withdrawals.", - parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["index", "items_count"]), + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["index"]), responses: [ ok: {"Withdrawals for the specified address, with pagination. Note that receiver field is not included in this endpoint.", "application/json", paginated_response( items: Schemas.Withdrawal, - next_page_params_example: %{"index" => 88_192_653, "items_count" => 50} + next_page_params_example: %{"index" => 88_192_653} )}, unprocessable_entity: JsonErrorResponse.response(), forbidden: ForbiddenResponse.response() @@ -1060,9 +1075,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do :ok -> options = @api_true |> Keyword.merge(paging_options(params)) withdrawals_plus_one = address_hash |> Chain.address_hash_to_withdrawals(options) - {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, params) + {withdrawals, next_page_params} = paginate_list(withdrawals_plus_one, params, options[:paging_options]) conn |> put_status(200) @@ -1090,7 +1104,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [sort_param(["balance", "transactions_count"]), order_param()] ++ - define_paging_params(["fetched_coin_balance", "address_hash", "items_count", "transactions_count"]), + define_paging_params(["fetched_coin_balance", "address_hash", "transactions_count"]), responses: [ ok: {"List of native coin holders with their balances, with pagination.", "application/json", @@ -1100,7 +1114,6 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page_params_example: %{ "fetched_coin_balance" => "124355417998347240251800", "hash" => "0x59708733fbbf64378d9293ec56b977c011a08fd2", - "items_count" => 50, "transactions_count" => nil } ), @@ -1127,15 +1140,15 @@ defmodule BlockScoutWeb.API.V2.AddressController do """ @spec addresses_list(Plug.Conn.t(), map()) :: Plug.Conn.t() def addresses_list(conn, params) do - {addresses, next_page} = + options = params |> paging_options() |> Keyword.merge(@api_true) |> Keyword.merge(addresses_sorting(params)) - |> Address.list_top_addresses() - |> split_list_by_page() - next_page_params = next_page_params(next_page, addresses, params) + results_plus_one = Address.list_top_addresses(options) + + {addresses, next_page_params} = paginate_list(results_plus_one, params, options[:paging_options]) exchange_rate = Market.get_coin_exchange_rate() total_supply = Chain.total_supply() @@ -1177,25 +1190,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do @spec tabs_counters(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() def tabs_counters(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - counter_name_to_json_field_name = %{ - validations: :validations_count, - transactions: :transactions_count, - token_transfers: :token_transfers_count, - token_balances: :token_balances_count, - logs: :logs_count, - withdrawals: :withdrawals_count, - internal_transactions: :internal_transactions_count, - celo_election_rewards: :celo_election_rewards_count, - beacon_deposits: :beacon_deposits_count - } - case Address.check_address_exists(address_hash, @api_true) do :ok -> counters_json = address_hash - |> Counters.address_limited_counters(@api_true) + |> Counters.address_limited_counters(@api_true |> fetch_scam_token_toggle(conn)) |> Enum.reduce(%{}, fn {counter_name, counter_value}, acc -> - counter_name_to_json_field_name + @counter_name_to_json_field_name |> Map.fetch(counter_name) # credo:disable-for-next-line |> case do @@ -1213,7 +1214,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do _ -> counters_json = - counter_name_to_json_field_name + @counter_name_to_json_field_name |> Enum.reduce(%{}, fn {_counter_type, json_field}, acc -> Map.put(acc, json_field, 0) end) @@ -1232,14 +1233,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [address_hash_param(), nft_token_type_param()] ++ - define_paging_params(["items_count", "token_contract_address_hash", "token_id", "token_type"]), + define_paging_params(["token_contract_address_hash", "token_id", "token_type"]), responses: [ ok: {"NFTs owned by the specified address, with pagination.", "application/json", paginated_response( items: Schemas.TokenInstanceInList, next_page_params_example: %{ - "items_count" => 50, "token_contract_address_hash" => "0x1ffe11b9fb7f6ff1b153ab8608cf403ecaf9d44a", "token_id" => "24950", "token_type" => "ERC-721" @@ -1279,15 +1279,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> fetch_scam_token_toggle(conn) ) - {nfts, next_page} = split_list_by_page(results_plus_one) + nft_paging_opts = paging_options(params) - next_page_params = - next_page - |> next_page_params( - nfts, - params, - false, - &Instance.nft_list_next_page_params/1 + {nfts, next_page_params} = + paginate_list(results_plus_one, params, nft_paging_opts[:paging_options], + paging_function: &Instance.nft_list_next_page_params/1 ) conn @@ -1309,14 +1305,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [address_hash_param(), nft_token_type_param()] ++ - define_paging_params(["items_count", "token_contract_address_hash", "token_type"]), + define_paging_params(["token_contract_address_hash", "token_type"]), responses: [ ok: {"NFTs owned by the specified address, grouped by collection, with pagination.", "application/json", paginated_response( items: Schemas.NFTCollection, next_page_params_example: %{ - "items_count" => 50, "token_contract_address_hash" => "0x1ffe11b9fb7f6ff1b153ab8608cf403ecaf9d44a", "token_type" => "ERC-721" } @@ -1355,15 +1350,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> fetch_scam_token_toggle(conn) ) - {collections, next_page} = split_list_by_page(results_plus_one) + collections_paging_opts = paging_options(params) - next_page_params = - next_page - |> next_page_params( - collections, - params, - false, - &Instance.nft_collections_next_page_params/1 + {collections, next_page_params} = + paginate_list(results_plus_one, params, collections_paging_opts[:paging_options], + paging_function: &Instance.nft_collections_next_page_params/1 ) conn @@ -1384,7 +1375,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [address_hash_param()] ++ - define_paging_params(["items_count", "epoch_number", "amount", "associated_account_address_hash", "type"]), + define_paging_params(["epoch_number", "amount", "associated_account_address_hash", "type"]), responses: [ ok: {"Celo election rewards for the specified address.", "application/json", @@ -1394,8 +1385,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do "epoch_number" => 100, "amount" => "1000000000000000000", "associated_account_address_hash" => "0x1234567890123456789012345678901234567890", - "type" => "validator", - "items_count" => 50 + "type" => "validator" } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -1415,11 +1405,10 @@ defmodule BlockScoutWeb.API.V2.AddressController do :paging_options, celo_election_rewards_paging_options(params) ) + |> maybe_override_page_size(params) results_plus_one = CeloElectionReward.address_hash_to_rewards(address_hash, full_options) - {rewards, next_page} = split_list_by_page(results_plus_one) - filtered_params = params |> Map.drop([ @@ -1429,18 +1418,15 @@ defmodule BlockScoutWeb.API.V2.AddressController do "type" ]) - next_page_params = - next_page_params( - next_page, - rewards, - filtered_params, - false, - &%{ - epoch_number: &1.epoch_number, - amount: &1.amount, - associated_account_address_hash: &1.associated_account_address_hash, - type: &1.type - } + {rewards, next_page_params} = + paginate_list(results_plus_one, filtered_params, full_options[:paging_options], + paging_function: + &%{ + epoch_number: &1.epoch_number, + amount: &1.amount, + associated_account_address_hash: &1.associated_account_address_hash, + type: &1.type + } ) conn @@ -1515,15 +1501,14 @@ defmodule BlockScoutWeb.API.V2.AddressController do parameters: base_params() ++ [address_hash_param()] ++ - define_paging_params(["deposit_index", "items_count"]), + define_paging_params(["deposit_index"]), responses: [ ok: {"Beacon deposits for the specified address.", "application/json", paginated_response( items: Schemas.Beacon.Deposit, next_page_params_example: %{ - "index" => 123, - "items_count" => 50 + "index" => 123 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -1569,15 +1554,10 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Keyword.merge(DepositController.paging_options(params)) deposit_plus_one = Deposit.from_address_hash(address_hash, full_options) - {deposits, next_page} = split_list_by_page(deposit_plus_one) - - next_page_params = - next_page - |> next_page_params( - deposits, - params, - false, - DepositController.paging_function() + + {deposits, next_page_params} = + paginate_list(deposit_plus_one, params, full_options[:paging_options], + paging_function: DepositController.paging_function() ) conn diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex index c1e50df7fa1..daff5c408bc 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex @@ -4,7 +4,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do use OpenApiSpex.ControllerSpecs use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - import BlockScoutWeb.Chain, only: [split_list_by_page: 1, next_page_params: 5, fetch_scam_token_toggle: 2] + import BlockScoutWeb.Chain, only: [paginate_list: 4, fetch_scam_token_toggle: 2, maybe_override_page_size: 2] + import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.PagingOptions, only: [default_paging_options: 0] alias BlockScoutWeb.AccessHelper @@ -75,7 +77,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do required: false, description: "Comma-separated list of transaction types to include. Allowed values: `COIN_TRANSFER`, " <> - "`CONTRACT_INTERACTION`, `CONTRACT_CREATION`, `ERC-20`, `ERC-404`, `ERC-721`, `ERC-1155`, `ERC-7984` " <> + "`CONTRACT_INTERACTION`, `CONTRACT_CREATION`, `ERC-20`, `ERC-404`, `ERC-721`, `ERC-1155`, `ERC-7984`, `ERC-8056` " <> "(plus `ZRC-2` on Zilliqa). Values are matched case-insensitively; unknown entries are silently dropped.", example: "COIN_TRANSFER,ERC-20" }, @@ -256,14 +258,6 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do } ] - @items_count_param %OpenApiSpex.Parameter{ - name: :items_count, - in: :query, - schema: %OpenApiSpex.Schema{type: :integer, minimum: 1}, - required: false, - description: "Cumulative number of items already returned across previous pages." - } - operation :list, summary: "List transactions, internal transactions and token transfers matching the advanced filter criteria", description: @@ -272,7 +266,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do "and/or token contract. The response also echoes the resolved human-readable names of the methods and " <> "tokens referenced in the request filters.", parameters: - base_params() ++ @advanced_filter_query_params ++ @advanced_filter_keyset_params ++ [@items_count_param], + base_params() ++ @advanced_filter_query_params ++ @advanced_filter_keyset_params ++ define_paging_params([]), responses: [ ok: {"List of matching items with pagination information and resolved search params.", "application/json", @@ -289,21 +283,25 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do params |> extract_filters() |> Keyword.merge(paging_options(params)) + |> maybe_override_page_size(params) |> Keyword.merge(@api_true) |> fetch_scam_token_toggle(conn) advanced_filters_plus_one = AdvancedFilter.list(full_options) - {advanced_filters, next_page} = split_list_by_page(advanced_filters_plus_one) + {advanced_filters, next_page_params} = + paginate_list(advanced_filters_plus_one, %{}, full_options[:paging_options], paging_function: &paging_params/1) + + advanced_filters = + advanced_filters + |> maybe_preload_ens() + |> maybe_preload_metadata() decoded_transactions = advanced_filters |> Enum.map(fn af -> %Transaction{to_address: af.to_address, input: af.input, hash: af.hash} end) |> Transaction.decode_transactions(true, @api_true) - next_page_params = - next_page |> next_page_params(advanced_filters, Map.take(params, [:items_count]), false, &paging_params/1) - render(conn, :advanced_filters, advanced_filters: advanced_filters, decoded_transactions: decoded_transactions, @@ -536,7 +534,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do ] end - @default_allowed_transaction_types ~w(COIN_TRANSFER CONTRACT_INTERACTION CONTRACT_CREATION ERC-20 ERC-404 ERC-721 ERC-1155 ERC-7984) + @default_allowed_transaction_types ~w(COIN_TRANSFER CONTRACT_INTERACTION CONTRACT_CREATION ERC-20 ERC-404 ERC-721 ERC-1155 ERC-7984 ERC-8056) if @chain_type == :zilliqa do @chain_type_allowed_transaction_types ~w(ZRC-2) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex index a32ee1e701c..8c0906e317e 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex @@ -5,9 +5,8 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do import BlockScoutWeb.Chain, only: [ - next_page_params: 5, + paginate_list: 4, paging_options: 1, - split_list_by_page: 1, parse_block_hash_or_number_param: 1 ] @@ -42,7 +41,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do operation :messages, summary: "List cross-chain messages.", description: "Retrieves a paginated list of Arbitrum cross-chain messages filtered by the specified direction.", - parameters: [@direction_param | base_params()] ++ define_paging_params(["id", "items_count"]), + parameters: [@direction_param | base_params()] ++ define_paging_params(["id"]), responses: [ ok: {"Paginated list of cross-chain messages.", "application/json", @@ -158,7 +157,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do schema: %Schema{type: :string, enum: ["all"]}, description: "When set to `all`, returns a paginated list of all batches for this data hash." } - ] ++ define_paging_params(["number", "items_count"]), + ] ++ define_paging_params(["number"]), responses: [ ok: {"Batch info, or paginated batch list when `type=all`.", "application/json", @@ -200,7 +199,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do schema: %Schema{type: :string, enum: ["all"]}, description: "When set to `all`, returns a paginated list of all batches for this data hash." } - ] ++ define_paging_params(["number", "items_count"]), + ] ++ define_paging_params(["number"]), responses: [ ok: {"Batch info, or paginated batch list when `type=all`.", "application/json", @@ -267,7 +266,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do schema: %Schema{type: :array, items: %Schema{type: :integer, minimum: 0}}, description: "Optional list of specific batch numbers to retrieve." } - ] ++ define_paging_params(["number", "items_count"]), + ] ++ define_paging_params(["number"]), responses: [ ok: {"Paginated list of Arbitrum batches.", "application/json", @@ -345,18 +344,11 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do params |> paging_options() - {messages, next_page} = + {messages, next_page_params} = direction |> MessagesReader.messages(options) - |> split_list_by_page() - - next_page_params = - next_page_params( - next_page, - messages, - params, - false, - fn %Message{message_id: message_id} -> %{"id" => message_id} end + |> paginate_list(params, options[:paging_options], + paging_function: fn %Message{message_id: message_id} -> %{"id" => message_id} end ) conn @@ -562,21 +554,17 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do """ @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches(conn, params) do - {batches, next_page} = + batches_options = params |> paging_options() |> maybe_add_batch_numbers(params) |> Keyword.put(:necessity_by_association, @batch_necessity_by_association) + + {batches, next_page_params} = + batches_options |> SettlementReader.batches() - |> split_list_by_page() - - next_page_params = - next_page_params( - next_page, - batches, - params, - false, - fn %L1Batch{number: number} -> %{"number" => number} end + |> paginate_list(params, batches_options[:paging_options], + paging_function: fn %L1Batch{number: number} -> %{"number" => number} end ) conn 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 d5258d0d0da..a35ad8bfada 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 @@ -10,12 +10,11 @@ defmodule BlockScoutWeb.API.V2.BlockController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - next_page_params: 5, + paginate_list: 3, + paginate_list: 4, paging_options: 1, param_to_block_number: 2, put_key_value_to_paging_options: 3, - split_list_by_page: 1, parse_block_hash_or_number_param: 1, block_to_internal_transactions: 2 ] @@ -36,6 +35,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do alias BlockScoutWeb.API.V2.{ Ethereum.DepositController, Ethereum.DepositView, + InternalTransactionsPendingStatusHelper, TransactionView, WithdrawalView } @@ -186,21 +186,20 @@ defmodule BlockScoutWeb.API.V2.BlockController do that lost consensus during a chain reorganization. Pagination is cursor-based: the response contains `next_page_params` with - `block_number` and `items_count` — pass these back as query parameters on + `block_number` — pass these back as query parameters on the next request to fetch the following page. """, parameters: base_params() ++ [block_type_param()] ++ - define_paging_params(["block_number", "items_count"]), + define_paging_params(["block_number"]), responses: [ ok: {"List of blocks with pagination information.", "application/json", paginated_response( items: Schemas.Block, next_page_params_example: %{ - "block_number" => 22_566_361, - "items_count" => 50 + "block_number" => 22_566_361 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -213,15 +212,14 @@ defmodule BlockScoutWeb.API.V2.BlockController do def blocks(conn, params) do full_options = select_block_type(params) - blocks_plus_one = + options = full_options |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) - |> Chain.list_blocks() - {blocks, next_page} = split_list_by_page(blocks_plus_one) + blocks_plus_one = Chain.list_blocks(options) - next_page_params = next_page |> next_page_params(blocks, params) + {blocks, next_page_params} = paginate_list(blocks_plus_one, params, options[:paging_options]) conn |> put_status(200) @@ -237,15 +235,14 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [batch_number_param()] ++ - define_paging_params(["block_number", "items_count"]), + define_paging_params(["block_number"]), responses: [ ok: {"L2 blocks in the specified Arbitrum batch.", "application/json", paginated_response( items: Schemas.Block, next_page_params_example: %{ - "block_number" => 22_566_361, - "items_count" => 50 + "block_number" => 22_566_361 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -263,12 +260,10 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> select_block_type() |> Keyword.merge(paging_options(params)) - {blocks, next_page} = + {blocks, next_page_params} = batch_number |> ArbitrumSettlementReader.batch_blocks(full_options) - |> split_list_by_page() - - next_page_params = next_page |> next_page_params(blocks, params) + |> paginate_list(params, full_options[:paging_options]) conn |> put_status(200) @@ -284,15 +279,14 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [batch_number_param()] ++ - define_paging_params(["block_number", "items_count"]), + define_paging_params(["block_number"]), responses: [ ok: {"L2 blocks in the specified Optimism batch.", "application/json", paginated_response( items: Schemas.Block, next_page_params_example: %{ - "block_number" => 22_566_361, - "items_count" => 50 + "block_number" => 22_566_361 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -311,12 +305,10 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) - {blocks, next_page} = + {blocks, next_page_params} = batch_number |> OptimismTransactionBatch.batch_blocks(full_options) - |> split_list_by_page() - - next_page_params = next_page |> next_page_params(blocks, params) + |> paginate_list(params, full_options[:paging_options]) conn |> put_status(200) @@ -332,15 +324,14 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [batch_number_param()] ++ - define_paging_params(["block_number", "items_count"]), + define_paging_params(["block_number"]), responses: [ ok: {"L2 blocks in the specified Scroll batch.", "application/json", paginated_response( items: Schemas.Block, next_page_params_example: %{ - "block_number" => 22_566_361, - "items_count" => 50 + "block_number" => 22_566_361 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -359,12 +350,10 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) - {blocks, next_page} = + {blocks, next_page_params} = batch_number |> ScrollReader.batch_blocks(full_options) - |> split_list_by_page() - - next_page_params = next_page |> next_page_params(blocks, params) + |> paginate_list(params, full_options[:paging_options]) conn |> put_status(200) @@ -380,7 +369,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [block_hash_or_number_param(), block_transaction_type_param()] ++ - define_paging_params(["block_number", "index", "items_count"]), + define_paging_params(["block_number", "index"]), responses: [ ok: {"Transactions in the specified block, with pagination.", "application/json", @@ -388,8 +377,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 12_345_678, - "index" => 103, - "items_count" => 50 + "index" => 103 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -413,11 +401,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do transactions_plus_one = Chain.block_to_transactions(block.hash, full_options, false) - {transactions, next_page} = split_list_by_page(transactions_plus_one) - - next_page_params = - next_page - |> next_page_params(transactions, params) + {transactions, next_page_params} = paginate_list(transactions_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -435,17 +419,22 @@ defmodule BlockScoutWeb.API.V2.BlockController do "Retrieves internal transactions included in a specific block with optional filtering by type and call type.", parameters: base_params() ++ - [block_hash_or_number_param(), internal_transaction_type_param(), internal_transaction_call_type_param()] ++ - define_paging_params(["transaction_index", "index", "items_count"]), + [ + block_hash_or_number_param(), + internal_transaction_type_param(), + internal_transaction_call_type_param(), + include_zero_value_param() + ] ++ + define_paging_params(["transaction_index", "index"]), responses: [ ok: {"Internal transactions in the specified block.", "application/json", paginated_response( items: Schemas.InternalTransaction, + include_pending_status?: true, next_page_params_example: %{ "transaction_index" => 3, - "index" => 8, - "items_count" => 50 + "index" => 8 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -471,18 +460,19 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> Keyword.merge(@api_true) |> Keyword.merge(internal_transaction_type_options(params)) |> Keyword.merge(internal_transaction_call_type_options(params)) + |> Keyword.put(:include_zero_value, Map.get(params, :include_zero_value, true)) internal_transactions_plus_one = block_to_internal_transactions(block, full_options) - {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) + {internal_transactions, next_page_params} = + paginate_list(internal_transactions_plus_one, params, full_options[:paging_options], + paging_function: &InternalTransaction.internal_transaction_to_block_paging_options/1 + ) - next_page_params = - next_page - |> next_page_params( + pending_status? = + InternalTransactionsPendingStatusHelper.block_internal_transactions_pending?( internal_transactions, - params, - false, - &InternalTransaction.internal_transaction_to_block_paging_options/1 + block.number ) conn @@ -491,7 +481,8 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> render(:internal_transactions, %{ internal_transactions: internal_transactions, next_page_params: next_page_params, - block: block + block: block, + pending_status?: pending_status? }) end end @@ -502,7 +493,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [block_hash_or_number_param()] ++ - define_paging_params(["index", "items_count"]), + define_paging_params(["index"]), responses: [ ok: {"Withdrawals in the specified block, with pagination. Note that block_number and timestamp fields are not included in this endpoint.", @@ -510,8 +501,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do paginated_response( items: Schemas.Withdrawal, next_page_params_example: %{ - "index" => 88_192_653, - "items_count" => 50 + "index" => 88_192_653 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -537,9 +527,8 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> Keyword.merge(paging_options(params)) withdrawals_plus_one = Chain.block_to_withdrawals(block.hash, full_options) - {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, params) + {withdrawals, next_page_params} = paginate_list(withdrawals_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -607,15 +596,14 @@ defmodule BlockScoutWeb.API.V2.BlockController do parameters: base_params() ++ [block_hash_or_number_param()] ++ - define_paging_params(["index", "items_count"]), + define_paging_params(["index"]), responses: [ ok: {"Beacon deposits in the specified block.", "application/json", paginated_response( items: Schemas.Beacon.Deposit, next_page_params_example: %{ - "index" => 123, - "items_count" => 50 + "index" => 123 } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -668,15 +656,10 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> Keyword.merge(DepositController.paging_options(params)) deposit_plus_one = Deposit.from_block_hash(block.hash, full_options) - {deposits, next_page} = split_list_by_page(deposit_plus_one) - - next_page_params = - next_page - |> next_page_params( - deposits, - params, - false, - DepositController.paging_function() + + {deposits, next_page_params} = + paginate_list(deposit_plus_one, params, full_options[:paging_options], + paging_function: DepositController.paging_function() ) conn diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex index 175f4046c04..8a6082c00f5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex @@ -7,8 +7,8 @@ defmodule BlockScoutWeb.API.V2.CeloController do import BlockScoutWeb.Chain, only: [ - next_page_params: 5, - split_list_by_page: 1 + maybe_override_page_size: 2, + paginate_list: 4 ] import Explorer.PagingOptions, only: [default_paging_options: 0] @@ -32,8 +32,7 @@ defmodule BlockScoutWeb.API.V2.CeloController do parameters: base_params() ++ define_paging_params([ - "number", - "items_count" + "number" ]), responses: [ ok: @@ -41,10 +40,8 @@ defmodule BlockScoutWeb.API.V2.CeloController do paginated_response( items: Schemas.Celo.Epoch, next_page_params_example: %{ - "number" => 100, - "items_count" => 50 - }, - title_prefix: "CeloEpochs" + "number" => 100 + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -62,32 +59,25 @@ defmodule BlockScoutWeb.API.V2.CeloController do _ -> default_paging_options() end - options = [ - necessity_by_association: %{ - :end_processing_block => :optional, - :distribution => :optional - }, - paging_options: paging_options, - api?: true - ] - - {epochs, next_page} = - options - |> Epoch.all() - |> split_list_by_page() + options = + [ + necessity_by_association: %{ + :end_processing_block => :optional, + :distribution => :optional + }, + paging_options: paging_options, + api?: true + ] + |> maybe_override_page_size(params) filtered_params = params |> Map.drop([:number]) - next_page_params = - next_page_params( - next_page, - epochs, - filtered_params, - false, - &%{number: &1.number} - ) + {epochs, next_page_params} = + options + |> Epoch.all() + |> paginate_list(filtered_params, options[:paging_options], paging_function: &%{number: &1.number}) conn |> render(:celo_epochs, %{ @@ -165,8 +155,7 @@ defmodule BlockScoutWeb.API.V2.CeloController do define_paging_params([ "amount", "account_address_hash", - "associated_account_address_hash", - "items_count" + "associated_account_address_hash" ]), responses: [ ok: @@ -176,10 +165,8 @@ defmodule BlockScoutWeb.API.V2.CeloController do next_page_params_example: %{ "amount" => "1000000000000000000", "account_address_hash" => "0x1234567890123456789012345678901234567890", - "associated_account_address_hash" => "0x0987654321098765432109876543210987654321", - "items_count" => 50 - }, - title_prefix: "CeloEpochElectionRewards" + "associated_account_address_hash" => "0x0987654321098765432109876543210987654321" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -194,14 +181,16 @@ defmodule BlockScoutWeb.API.V2.CeloController do {:ok, reward_type_atom} <- parse_celo_reward_type(reward_type) do address_associations = [:names, :smart_contract, proxy_implementations_association()] - full_options = [ - necessity_by_association: %{ - [account_address: address_associations] => :optional, - [associated_account_address: address_associations] => :optional - }, - paging_options: election_rewards_paging_options(params), - api?: true - ] + full_options = + [ + necessity_by_association: %{ + [account_address: address_associations] => :optional, + [associated_account_address: address_associations] => :optional + }, + paging_options: election_rewards_paging_options(params), + api?: true + ] + |> maybe_override_page_size(params) rewards_plus_one = ElectionReward.epoch_number_and_type_to_rewards( @@ -210,8 +199,6 @@ defmodule BlockScoutWeb.API.V2.CeloController do full_options ) - {rewards, next_page} = split_list_by_page(rewards_plus_one) - filtered_params = params |> Map.drop([ @@ -222,17 +209,14 @@ defmodule BlockScoutWeb.API.V2.CeloController do :associated_account_address_hash ]) - next_page_params = - next_page_params( - next_page, - rewards, - filtered_params, - false, - &%{ - amount: &1.amount, - account_address_hash: &1.account_address_hash, - associated_account_address_hash: &1.associated_account_address_hash - } + {rewards, next_page_params} = + paginate_list(rewards_plus_one, filtered_params, full_options[:paging_options], + paging_function: + &%{ + amount: &1.amount, + account_address_hash: &1.account_address_hash, + associated_account_address_hash: &1.associated_account_address_hash + } ) conn diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex index fc2fdf3fdfd..fe164970034 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex @@ -50,15 +50,10 @@ defmodule BlockScoutWeb.API.V2.ConfigController do @spec chain_type_translate_to_openapi_spec_folder_name() :: String.t() defp chain_type_translate_to_openapi_spec_folder_name do - cond do - Application.get_env(:explorer, Explorer.Chain.Mud)[:enabled] -> - "mud" - - chain_identity() == {:optimism, :celo} -> - "optimism-celo" - - true -> - chain_type() || "default" + if chain_identity() == {:optimism, :celo} do + "optimism-celo" + else + chain_type() || "default" end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex index e43d6750e9a..2454cc934e4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex @@ -16,6 +16,7 @@ defmodule BlockScoutWeb.API.V2.CsvExportController do alias Explorer.Chain.CsvExport.Helper, as: CsvHelper alias Explorer.Chain.CsvExport.Request, as: AsyncCsvExportRequest alias Explorer.Chain.CsvExport.Token.Holders, as: TokenHoldersCsvExporter + alias Explorer.Chain.CsvExport.Token.Transfers, as: TokenTransfersCsvExporter alias Plug.Conn import BlockScoutWeb.Chain, only: [fetch_scam_token_toggle: 2] @@ -74,6 +75,52 @@ defmodule BlockScoutWeb.API.V2.CsvExportController do end end + operation :export_token_transfers, + summary: "Export token transfers as CSV", + description: "Exports the transfers of a specific token as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of token transfers.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["tokens"] + + @doc """ + Performs CSV export of token transfers for a given token address. + Endpoint: `/api/v2/tokens/:address_hash_param/transfers/csv` + """ + @spec export_token_transfers(Conn.t(), map()) :: Conn.t() + def export_token_transfers(conn, %{address_hash_param: address_hash_string, from_period: _, to_period: _} = params) do + with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), + {:not_found, {:ok, _token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do + opts = %{ + address_hash: address_hash, + from_period: params[:from_period], + to_period: params[:to_period], + filter_type: nil, + filter_value: nil, + show_scam_tokens?: nil + } + + do_csv_export( + CsvHelper.async_enabled?(), + conn, + opts, + TokenTransfersCsvExporter + ) + end + end + @spec put_resp_params(Conn.t()) :: Conn.t() def put_resp_params(conn) do conn 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 1298cfd76d1..fbc05cdc06a 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 @@ -3,7 +3,7 @@ defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do use BlockScoutWeb, :controller use OpenApiSpex.ControllerSpecs - import BlockScoutWeb.Chain, only: [next_page_params: 5, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paginate_list: 4, maybe_override_page_size: 2] import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] alias Explorer.{Chain, PagingOptions} @@ -18,15 +18,14 @@ defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do operation :list, summary: "Lists all beacon deposits", description: "Retrieves a paginated list of all beacon deposits.", - parameters: base_params() ++ define_paging_params(["deposit_index", "items_count"]), + parameters: base_params() ++ define_paging_params(["deposit_index"]), responses: [ ok: {"List of Beacon Deposits, with pagination.", "application/json", paginated_response( items: Schemas.Beacon.Deposit, next_page_params_example: %{ - "index" => 123, - "items_count" => 50 + "index" => 123 } )}, forbidden: ForbiddenResponse.response() @@ -62,13 +61,12 @@ defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do api?: true ] |> Keyword.merge(paging_options(params)) + |> maybe_override_page_size(params) deposit_plus_one = Deposit.all(full_options) - {deposits, next_page} = split_list_by_page(deposit_plus_one) - next_page_params = - next_page - |> next_page_params(deposits, params, false, paging_function()) + {deposits, next_page_params} = + paginate_list(deposit_plus_one, params, full_options[:paging_options], paging_function: paging_function()) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex index 8ff0b142bb4..2a7432c53ed 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex @@ -3,20 +3,18 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do use BlockScoutWeb, :controller use OpenApiSpex.ControllerSpecs - alias Explorer.{Chain, PagingOptions} + alias BlockScoutWeb.API.V2.InternalTransactionsPendingStatusHelper + alias Explorer.Chain alias Explorer.Chain.Cache.BackgroundMigrations import BlockScoutWeb.Chain, only: [ - split_list_by_page: 1, + paginate_list: 3, paging_options: 1, - next_page_params: 3, fetch_internal_transactions: 1 ] - import Explorer.PagingOptions, only: [default_paging_options: 0] - action_fallback(BlockScoutWeb.API.V2.FallbackController) plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) @@ -31,18 +29,18 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do "Retrieves a paginated list of internal transactions. Internal transactions are generated during contract execution and not directly recorded on the blockchain.", parameters: base_params() ++ - [query_transaction_hash_param(), limit_param()] ++ - define_paging_params(["index", "block_number", "transaction_index", "items_count"]), + [query_transaction_hash_param(), include_zero_value_param()] ++ + define_paging_params(["index", "block_number", "transaction_index"]), responses: [ ok: {"List of internal transactions with pagination information.", "application/json", paginated_response( items: Schemas.InternalTransaction, + include_pending_status?: true, next_page_params_example: %{ "index" => 50, "transaction_index" => 68, - "block_number" => 22_133_247, - "items_count" => 50 + "block_number" => 22_133_247 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -53,41 +51,49 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do """ @spec internal_transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() def internal_transactions(conn, params) do + transaction_hash = transaction_hash_from_params(params) + with true <- BackgroundMigrations.get_heavy_indexes_create_internal_transactions_block_number_desc_transaction_index_desc_index_desc_index_finished(), - transaction_hash = transaction_hash_from_params(params), false <- transaction_hash == :invalid do paging_options = paging_options(params) - options = options(paging_options, %{transaction_hash: transaction_hash, limit: params[:limit]}) - result = + options = + options(paging_options, %{ + transaction_hash: transaction_hash, + include_zero_value: Map.get(params, :include_zero_value, true) + }) + + {internal_transactions, next_page_params} = options |> fetch_internal_transactions() - |> split_list_by_page() - - {internal_transactions, next_page} = result + |> paginate_list(params, options[:paging_options]) - next_page_params = - next_page |> next_page_params(internal_transactions, params) + pending_status? = + pending_status?(internal_transactions, transaction_hash) conn |> put_status(200) |> render(:internal_transactions, %{ internal_transactions: internal_transactions, - next_page_params: next_page_params + next_page_params: next_page_params, + pending_status?: pending_status? }) else _ -> - empty_response(conn) + empty_response(conn, if(transaction_hash == :invalid, do: nil, else: transaction_hash)) end end - defp empty_response(conn) do + defp empty_response(conn, transaction_hash) do + pending_status? = pending_status?([], transaction_hash) + conn |> put_status(200) |> render(:internal_transactions, %{ internal_transactions: [], - next_page_params: nil + next_page_params: nil, + pending_status?: pending_status? }) end @@ -95,12 +101,7 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do paging_options |> Keyword.put(:transaction_hash, params.transaction_hash) |> Keyword.put(:exclude_origin_internal_transaction, true) - |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ - page_size: page_size - } = paging_options -> - maybe_parsed_limit = params[:limit] - %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} - end) + |> Keyword.put(:include_zero_value, params.include_zero_value) |> Keyword.merge(@api_true) end @@ -113,4 +114,8 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do :error -> :invalid end end + + defp pending_status?(internal_transactions, transaction_hash) do + InternalTransactionsPendingStatusHelper.internal_transactions_pending?(internal_transactions, transaction_hash) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transactions_pending_status_helper.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transactions_pending_status_helper.ex new file mode 100644 index 00000000000..0067964fa1c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transactions_pending_status_helper.ex @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.InternalTransactionsPendingStatusHelper do + @moduledoc """ + Helpers for calculating whether internal-transactions API v2 responses should + include the pending-processing status metadata. + """ + + alias Explorer.Chain.PendingOperationsHelper + + @pending_message "Some internal transactions within this block range have not yet been processed" + + @doc """ + Returns the standard message used when internal transactions are still pending processing. + """ + def pending_message, do: @pending_message + + @doc """ + Checks whether the global internal-transactions endpoint scope contains pending operations. + """ + @spec internal_transactions_pending?(list(), any() | nil) :: boolean() + def internal_transactions_pending?(internal_transactions, transaction_hash \\ nil) do + {min_block_number, max_block_number} = + internal_transactions + |> extract_block_numbers() + |> expand_block_range() + |> then(&(&1 || {nil, nil})) + + # Tx hashes extracted from the fetched page are already within the page's block range, so + # they are covered by the block-range PTO check inside + # pending_operations_for_block_range_or_transactions?/3. + # Only the explicit transaction_hash (which may fall outside the page's range) is passed + # separately so it is still checked even when the block range wouldn't cover it. + PendingOperationsHelper.pending_operations_for_block_range_or_transactions?( + min_block_number, + max_block_number, + maybe_prepend_hash([], transaction_hash) + ) + end + + @doc """ + Checks whether the address internal-transactions endpoint scope contains pending operations. + + Only checks block-range pending operations — tx-hash checking is not meaningful here + because internal transactions are imported atomically per transaction: if any internal + transaction exists for a given tx_hash, all of them are already imported. + """ + @spec address_internal_transactions_pending?(list()) :: boolean() + def address_internal_transactions_pending?(internal_transactions) do + {min_block_number, max_block_number} = + internal_transactions + |> extract_block_numbers() + |> expand_block_range() + |> then(&(&1 || {nil, nil})) + + PendingOperationsHelper.pending_operations_in_block_range?(min_block_number, max_block_number) + end + + @doc """ + Checks whether the block internal-transactions endpoint scope contains pending operations. + + It verifies both pending block operations for the block number itself and pending + transaction operations associated with that block. + """ + @spec block_internal_transactions_pending?(list(), non_neg_integer()) :: boolean() + def block_internal_transactions_pending?(_internal_transactions, block_number) do + # pending_operations_in_block_range?/2 checks both PBO (block pending) and PTO (any + # transaction in that block still pending). Checking tx hashes individually would be + # redundant — all fetched internal-tx hashes belong to that block and are already + # covered by the PTO block-range check. + PendingOperationsHelper.pending_operations_in_block_range?(block_number, block_number) + end + + @doc """ + Checks whether the transaction internal-transactions endpoint scope contains pending operations. + + The check includes the requested transaction hash and block number together with + hashes and block numbers extracted from the returned internal transactions. + """ + @spec transaction_internal_transactions_pending?(list(), any(), non_neg_integer() | nil) :: boolean() + def transaction_internal_transactions_pending?(internal_transactions, transaction_hash, block_number) do + transaction_hashes = + internal_transactions + |> extract_transaction_hashes() + |> maybe_prepend_hash(transaction_hash) + + block_numbers = + internal_transactions + |> extract_block_numbers() + |> maybe_prepend_block_number(block_number) + + PendingOperationsHelper.pending_operations_for_blocks_or_transactions?(block_numbers, transaction_hashes) + end + + defp maybe_prepend_hash(transaction_hashes, nil), do: transaction_hashes + + defp maybe_prepend_hash(transaction_hashes, transaction_hash) do + [transaction_hash | transaction_hashes] |> Enum.uniq() + end + + defp maybe_prepend_block_number(block_numbers, nil), do: block_numbers + + defp maybe_prepend_block_number(block_numbers, block_number) do + [block_number | block_numbers] |> Enum.uniq() + end + + defp extract_block_numbers(internal_transactions) do + internal_transactions + |> Enum.map(& &1.block_number) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp extract_transaction_hashes(internal_transactions) do + internal_transactions + |> Enum.map(& &1.transaction_hash) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp expand_block_range([]), do: nil + + defp expand_block_range(block_numbers) do + Enum.min_max(block_numbers) + end +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 bde0964e879..7900ca1ed5f 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 @@ -101,7 +101,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do {"List of recent transactions on the home page.", "application/json", %Schema{ type: :array, - items: Schemas.Transaction.Response, + items: Schemas.Transaction, nullable: false }}, unprocessable_entity: JsonErrorResponse.response() @@ -145,7 +145,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do {"List of watchlist transactions", "application/json", %Schema{ type: :array, - items: Schemas.Transaction.Response, + items: Schemas.Transaction, nullable: false }}, unprocessable_entity: JsonErrorResponse.response() 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 deleted file mode 100644 index 9d79b53d57c..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex +++ /dev/null @@ -1,475 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.API.V2.MudController do - use BlockScoutWeb, :controller - use OpenApiSpex.ControllerSpecs - - import BlockScoutWeb.Chain, - only: [ - next_page_params: 5, - split_list_by_page: 1 - ] - - import BlockScoutWeb.PagingHelper, only: [mud_records_sorting: 1] - import Explorer.PagingOptions, only: [default_paging_options: 0] - - import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] - - alias BlockScoutWeb.Schemas.Helper, as: SchemasHelper - alias Explorer.Chain - alias Explorer.Chain.{Address, Data, Hash, Mud, Mud.Schema.FieldSchema, Mud.Table} - - @api_true [api?: true] - - action_fallback(BlockScoutWeb.API.V2.FallbackController) - - plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) - - tags(["mud"]) - - operation :worlds, - summary: "List of MUD worlds.", - description: "Retrieves a paginated list of MUD worlds with basic stats.", - parameters: - base_params() ++ - define_paging_params([ - "world", - "items_count" - ]), - responses: [ - ok: - {"List of MUD worlds.", "application/json", - paginated_response( - items: Schemas.MUD.World, - next_page_params_example: %{ - "world" => "0x82cb040ff4463bff3395d52b558fd77c61583b27", - "items_count" => 50 - }, - title_prefix: "Worlds" - )} - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds` endpoint. - """ - @spec worlds(Plug.Conn.t(), map()) :: Plug.Conn.t() - def worlds(conn, params) do - {worlds, next_page} = - params - |> mud_paging_options([:world], [Hash.Address]) - |> Mud.worlds_list() - |> split_list_by_page() - - world_addresses = - worlds - |> Chain.hashes_to_addresses( - necessity_by_association: %{ - :names => :optional, - :smart_contract => :optional, - proxy_implementations_association() => :optional - }, - api?: true - ) - |> Enum.into(%{}, &{&1.hash, &1}) - - next_page_params = - next_page_params(next_page, worlds, conn.query_params, false, fn item -> - %{"world" => item} - end) - - conn - |> put_status(200) - |> render(:worlds, %{ - worlds: - worlds - |> Enum.map(fn world -> Map.get(world_addresses, world, %Address{hash: world}) end) - |> maybe_preload_ens_and_metadata(), - next_page_params: next_page_params - }) - end - - operation :worlds_count, - summary: "Number of known MUD worlds.", - description: "Retrieves the total number of known MUD worlds.", - parameters: base_params(), - responses: [ - ok: {"Number of known MUD worlds.", "application/json", %Schema{type: :integer}} - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/count` endpoint. - """ - @spec worlds_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def worlds_count(conn, _params) do - count = Mud.worlds_count() - - conn - |> put_status(200) - |> render(:count, %{count: count}) - end - - operation :world_tables, - summary: "List of MUD world tables.", - description: "Retrieves a paginated list of MUD tables in the specific MUD world.", - parameters: - base_params() ++ - [world_param(), q_param(), filter_namespace_param()] ++ - define_paging_params([ - "table_id", - "items_count" - ]), - responses: [ - ok: - {"List of MUD tables.", "application/json", - paginated_response( - items: Schemas.MUD.TableWithSchema, - next_page_params_example: %{ - "table_id" => "0x746243484553545f5641554c5400000043686573744163636573730000000000", - "items_count" => 50 - }, - title_prefix: "Tables" - )}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/tables` endpoint. - """ - @spec world_tables(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_tables(conn, %{world: world_param} = params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do - options = params |> mud_paging_options([:table_id], [Hash.Full]) |> Keyword.merge(mud_tables_filter(params)) - - {tables, next_page} = - world - |> Mud.world_tables(options) - |> split_list_by_page() - - next_page_params = - next_page_params(next_page, tables, conn.query_params, false, fn item -> - %{"table_id" => item |> elem(0)} - end) - - conn - |> put_status(200) - |> render(:tables, %{tables: tables, next_page_params: next_page_params}) - end - end - - operation :world_systems, - summary: "List of MUD world systems.", - description: "Retrieves a list of MUD systems registered in the specific MUD world.", - parameters: [world_param() | base_params()], - responses: [ - ok: {"List of MUD systems.", "application/json", %Schema{type: :array, items: Schemas.MUD.System}}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/systems` endpoint. - """ - @spec world_systems(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_systems(conn, %{world: world_param} = _params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do - systems = world |> Mud.world_systems() - - conn - |> put_status(200) - |> render(:systems, %{systems: systems}) - end - end - - operation :world_system, - summary: "List of MUD world system ABI methods.", - description: "Retrieves a list of MUD system ABI methods registered in the specific MUD world.", - parameters: base_params() ++ [world_param(), system_param()], - responses: [ - ok: {"List of MUD world system ABI methods.", "application/json", Schemas.MUD.SystemDetails}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/systems/:system` endpoint. - """ - @spec world_system(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_system(conn, %{world: world_param, system: system_param} = _params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, - {:format, {:ok, system}} <- {:format, Hash.Address.cast(system_param)}, - {:ok, system_id, abi} <- Mud.world_system(world, system, @api_true) do - conn - |> put_status(200) - |> render(:system, %{system_id: system_id, abi: abi}) - end - end - - operation :world_tables_count, - summary: "Number of known MUD world tables.", - description: "Retrieves the total number of known MUD tables in the specific MUD world.", - parameters: base_params() ++ [world_param(), q_param(), filter_namespace_param()], - responses: [ - ok: {"Number of known MUD world tables.", "application/json", %Schema{type: :integer}}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/count` endpoint. - """ - @spec world_tables_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_tables_count(conn, %{world: world_param} = params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do - options = params |> mud_tables_filter() - - count = Mud.world_tables_count(world, options) - - conn - |> put_status(200) - |> render(:count, %{count: count}) - end - end - - operation :world_table_records, - summary: "List of MUD world table records.", - description: "Retrieves a paginated list of records in the specific MUD world table.", - parameters: - base_params() ++ - [ - world_param(), - table_id_param(), - filter_key0_param(), - filter_key1_param(), - sort_param(["key_bytes", "key0", "key1"]), - order_param() - ] ++ - define_paging_params([ - "key_bytes", - "key0", - "key1", - "items_count" - ]), - responses: [ - ok: - {"List of MUD world table records.", "application/json", - SchemasHelper.extend_schema( - paginated_response( - items: Schemas.MUD.Record, - next_page_params_example: %{ - "key_bytes" => "0x73796269746c7900000000000000000043686573743332000000000000000000", - "key0" => "0x73796269746c7900000000000000000043686573743332000000000000000000", - "items_count" => 50 - }, - title_prefix: "Records" - ), - properties: %{ - table: Schemas.MUD.Table, - schema: Schemas.MUD.TableSchema - }, - required: [:table, :schema] - )}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records` endpoint. - """ - @spec world_table_records(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_table_records(conn, %{world: world_param, table_id: table_id_param} = params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, - {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, - {:ok, schema} <- Mud.world_table_schema(world, table_id) do - options = - params - |> mud_paging_options([:key_bytes, :key0, :key1], [Data, Hash.Full, Hash.Full]) - |> Keyword.merge(mud_records_filter(params, schema)) - |> Keyword.merge(mud_records_sorting(params)) - - {records, next_page} = world |> Mud.world_table_records(table_id, options) |> split_list_by_page() - - blocks = Mud.preload_records_timestamps(records, @api_true) - - next_page_params = - next_page_params(next_page, records, conn.query_params, false, fn item -> - keys = [item.key_bytes, item.key0, item.key1] |> Enum.filter(&(!is_nil(&1))) - ["key_bytes", "key0", "key1"] |> Enum.zip(keys) |> Enum.into(%{}) - end) - - conn - |> put_status(200) - |> render(:records, %{ - records: records, - table_id: table_id, - schema: schema, - blocks: blocks, - next_page_params: next_page_params - }) - end - end - - operation :world_table_records_count, - summary: "Number of known MUD world table records.", - description: "Retrieves the total number of records in the specific MUD world table.", - parameters: base_params() ++ [world_param(), table_id_param(), filter_key0_param(), filter_key1_param()], - responses: [ - ok: {"Number of known MUD world table records.", "application/json", %Schema{type: :integer}}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records/count` endpoint. - """ - @spec world_table_records_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_table_records_count(conn, %{world: world_param, table_id: table_id_param} = params) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, - {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, - {:ok, schema} <- Mud.world_table_schema(world, table_id) do - options = params |> mud_records_filter(schema) - - count = Mud.world_table_records_count(world, table_id, options) - - conn - |> put_status(200) - |> render(:count, %{count: count}) - end - end - - operation :world_table_record, - summary: "Single MUD world table record.", - description: "Retrieves a single record in the specific MUD world table.", - parameters: base_params() ++ [world_param(), table_id_param(), record_id_param()], - responses: [ - ok: - {"Single MUD world table record.", "application/json", - Schemas.MUD.TableWithSchema.schema() - |> SchemasHelper.extend_schema( - properties: %{record: Schemas.MUD.Record}, - required: [:record] - )}, - unprocessable_entity: JsonErrorResponse.response() - ] - - @doc """ - Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records/:record_id` endpoint. - """ - @spec world_table_record(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_table_record( - conn, - %{world: world_param, table_id: table_id_param, record_id: record_id_param} = _params - ) do - with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, - {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, - {:format, {:ok, record_id}} <- {:format, Data.cast(record_id_param)}, - {:ok, schema} <- Mud.world_table_schema(world, table_id), - {:ok, record} <- Mud.world_table_record(world, table_id, record_id) do - blocks = Mud.preload_records_timestamps([record], @api_true) - - conn - |> put_status(200) - |> render(:record, %{record: record, table_id: table_id, schema: schema, blocks: blocks}) - end - end - - defp mud_tables_filter(params) do - Enum.reduce(params, [], fn {key, value}, acc -> - case key do - :filter_namespace -> - Keyword.put(acc, :filter_namespace, parse_namespace_string(value)) - - :q -> - Keyword.put(acc, :filter_search, parse_search_string(value)) - - _ -> - acc - end - end) - end - - defp parse_namespace_string(namespace) do - filter = - case namespace do - nil -> {:ok, nil} - "0x" <> hex -> Base.decode16(hex, case: :mixed) - str -> {:ok, str} - end - - case filter do - {:ok, ns} when is_binary(ns) and byte_size(ns) <= 14 -> - ns |> String.pad_trailing(14, <<0>>) - - _ -> - :error - end - end - - defp parse_search_string(q) do - # If the search string looks like hex-encoded table id or table full name, - # we try to parse and filter by that table id directly. - # Otherwise we do a full-text search of given string inside table id. - with :error <- Hash.Full.cast(q), - :error <- Table.table_full_name_to_table_id(q) do - q - else - {:ok, table_id} -> table_id - end - end - - defp mud_records_filter(params, schema) do - Enum.reduce(params, [], fn {key, value}, acc -> - case key do - :filter_key0 -> Keyword.put(acc, :filter_key0, encode_filter(value, schema, 0)) - :filter_key1 -> Keyword.put(acc, :filter_key1, encode_filter(value, schema, 1)) - _ -> acc - end - end) - end - - defp encode_filter(value, schema, field_idx) do - case value do - "false" -> - <<0::256>> - - "true" -> - <<1::256>> - - "0x" <> hex -> - decode_hex_filter(hex, schema, field_idx) - - dec -> - decode_decimal_filter(dec) - end - end - - defp decode_hex_filter(hex, schema, field_idx) do - with {:ok, bin} <- Base.decode16(hex, case: :mixed) do - # addresses are padded to 32 bytes with zeros on the right - if FieldSchema.type_of(schema.key_schema, field_idx) == 97 do - <<0::size(256 - byte_size(bin) * 8), bin::binary>> - else - <<bin::binary, 0::size(256 - byte_size(bin) * 8)>> - end - end - end - - defp decode_decimal_filter(dec) do - with {num, _} <- Integer.parse(dec) do - <<num::256>> - end - end - - defp mud_paging_options(params, keys, types) do - page_key = - keys - |> Enum.zip(types) - |> Enum.reduce(%{}, fn {key, type}, acc -> - with param when param != nil <- Map.get(params, key), - {:ok, val} <- type.cast(param) do - acc |> Map.put(key, val) - else - _ -> acc - end - end) - - if page_key == %{} do - [paging_options: default_paging_options()] - else - [paging_options: %{default_paging_options() | key: page_key}] - end - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex index bc3afc6dc1f..0ffbed54c61 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex @@ -7,9 +7,8 @@ defmodule BlockScoutWeb.API.V2.OptimismController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 + paginate_list: 3, + paging_options: 1 ] import Explorer.Helper, only: [hash_to_binary: 1] @@ -18,6 +17,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.Cache.ChainId + alias Explorer.Chain.Cache.Counters.Optimism.DepositsCount, as: DepositsCountCache alias Explorer.Chain.{Data, Hash, Token, Transaction} alias Explorer.Chain.Optimism.{ @@ -46,20 +46,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do description: "Retrieves a paginated list of batches.", parameters: base_params() ++ - define_paging_params([ - "id", - "items_count" - ]), + define_paging_params(["id"]), responses: [ ok: {"List of batches.", "application/json", paginated_response( items: Schemas.Optimism.Batch, next_page_params_example: %{ - "id" => 394_591, - "items_count" => 50 - }, - title_prefix: "Batches" + "id" => 394_591 + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -69,15 +64,16 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches(conn, params) do - {batches, next_page} = + options = params |> paging_options() |> Keyword.put(:api?, true) |> Keyword.put(:only_view_ready?, true) - |> FrameSequence.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, batches, params) + {batches, next_page_params} = + options + |> FrameSequence.list() + |> paginate_list(params, options[:paging_options]) items = batches @@ -151,7 +147,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do %OpenApiSpex.Parameter{ name: :commitment, in: :path, - schema: Schemas.General.HexString, + schema: Schemas.General.HexData, required: true, description: "Celestia blob commitment in the path." } @@ -229,20 +225,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do description: "Retrieves a paginated list of output roots.", parameters: base_params() ++ - define_paging_params([ - "index", - "items_count" - ]), + define_paging_params(["index"]), responses: [ ok: {"List of output roots.", "application/json", paginated_response( items: Schemas.Optimism.OutputRoot, next_page_params_example: %{ - "index" => 8829, - "items_count" => 50 - }, - title_prefix: "OutputRoots" + "index" => 8829 + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -252,14 +243,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec output_roots(Plug.Conn.t(), map()) :: Plug.Conn.t() def output_roots(conn, params) do - {roots, next_page} = + roots_options = params |> paging_options() |> Keyword.put(:api?, true) - |> OutputRoot.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, roots, params) + {roots, next_page_params} = + roots_options + |> OutputRoot.list() + |> paginate_list(params, roots_options[:paging_options]) conn |> put_status(200) @@ -291,20 +283,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do description: "Retrieves a paginated list of games.", parameters: base_params() ++ - define_paging_params([ - "index", - "items_count" - ]), + define_paging_params(["index"]), responses: [ ok: {"List of games.", "application/json", paginated_response( items: Schemas.Optimism.Game, next_page_params_example: %{ - "index" => 12967, - "items_count" => 50 - }, - title_prefix: "Games" + "index" => 12967 + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -314,14 +301,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec games(Plug.Conn.t(), map()) :: Plug.Conn.t() def games(conn, params) do - {games, next_page} = + games_options = params |> paging_options() |> Keyword.put(:api?, true) - |> DisputeGame.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, games, params) + {games, next_page_params} = + games_options + |> DisputeGame.list() + |> paginate_list(params, games_options[:paging_options]) conn |> put_status(200) @@ -357,22 +345,16 @@ defmodule BlockScoutWeb.API.V2.OptimismController do description: "Retrieves a paginated list of deposits.", parameters: base_params() ++ - define_paging_params([ - "items_count", - "l1_block_number", - "transaction_hash" - ]), + define_paging_params(["l1_block_number", "transaction_hash"]), responses: [ ok: {"List of deposits.", "application/json", paginated_response( items: Schemas.Optimism.Deposit, next_page_params_example: %{ - "items_count" => 50, "l1_block_number" => 23_937_283, "transaction_hash" => "0x5dc155c382d95353c5876e735d675d284e3b29b1379e5859dc35cfd4a1dd5188" - }, - title_prefix: "Deposits" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -382,14 +364,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits(conn, params) do - {deposits, next_page} = + deposits_options = params |> paging_options() |> Keyword.put(:api?, true) - |> Deposit.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, deposits, params) + {deposits, next_page_params} = + deposits_options + |> Deposit.list() + |> paginate_list(params, deposits_options[:paging_options]) conn |> put_status(200) @@ -441,7 +424,11 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec deposits_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits_count(conn, _params) do - count = Deposit.count(@api_true) + count = + case DepositsCountCache.fetch(@api_true) do + nil -> Chain.get_table_rows_total_count(Deposit, @api_true) + cached_count -> Decimal.to_integer(cached_count) + end conn |> put_status(200) @@ -531,16 +518,17 @@ defmodule BlockScoutWeb.API.V2.OptimismController do def interop_messages(conn, params) do current_chain_id = ChainId.get_id() - {messages, next_page} = + interop_options = params |> interop_extract_message_filters() |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) |> Keyword.merge(current_chain_id: current_chain_id) - |> InteropMessage.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, messages, Map.take(params, ["items_count"])) + {messages, next_page_params} = + interop_options + |> InteropMessage.list() + |> paginate_list(%{}, interop_options[:paging_options]) messages_extended = messages @@ -585,20 +573,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do description: "Retrieves a paginated list of withdrawals.", parameters: base_params() ++ - define_paging_params([ - "items_count", - "nonce" - ]), + define_paging_params(["nonce"]), responses: [ ok: {"List of withdrawals.", "application/json", paginated_response( items: Schemas.Optimism.Withdrawal, next_page_params_example: %{ - "items_count" => 50, "nonce" => "1766847064778384329583297500742918515827483896875618958121606201292650102" - }, - title_prefix: "Withdrawals" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -608,14 +591,15 @@ defmodule BlockScoutWeb.API.V2.OptimismController do """ @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals(conn, params) do - {withdrawals, next_page} = + withdrawals_options = params |> paging_options() |> Keyword.put(:api?, true) - |> Withdrawal.list() - |> split_list_by_page() - next_page_params = next_page_params(next_page, withdrawals, params) + {withdrawals, next_page_params} = + withdrawals_options + |> Withdrawal.list() + |> paginate_list(params, withdrawals_options[:paging_options]) conn |> put_status(200) @@ -1058,7 +1042,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do with {:ok, %{body: response_body, status: 200}} <- Tesla.get(client, url, opts: [adapter: [timeout: recv_timeout, transport_opts: [timeout: timeout]]]), - {:ok, %{"public_key" => "0x" <> key}} <- Jason.decode(response_body), + {:ok, %{"public_key" => "0x" <> key}} <- Utils.JSON.decode(response_body), {:ok, key_binary} <- Base.decode16(key, case: :mixed), true <- byte_size(key_binary) > 0 do ConCache.put(InteropMessage.interop_instance_api_url_to_public_key_cache(), instance_api_url, key_binary) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex index 38e7293642e..e394f9e5f40 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex @@ -79,7 +79,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do {response, code} = case TransactionInterpretationService.interpret_user_operation(user_op) do {:ok, response} -> {response, 200} - {:error, %Jason.DecodeError{}} -> {%{error: "Error while transaction interpreter response decoding"}, 500} + {:error, _decode_error} -> {%{error: "Error while transaction interpreter response decoding"}, 500} {{:error, error}, code} -> {%{error: error}, code} end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex index ceaeb173fce..2e2e2cfaf5b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex @@ -5,9 +5,8 @@ defmodule BlockScoutWeb.API.V2.ScrollController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 + paginate_list: 3, + paging_options: 1 ] alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse @@ -70,7 +69,6 @@ defmodule BlockScoutWeb.API.V2.ScrollController do parameters: base_params() ++ define_paging_params([ - "items_count", "number" ]), responses: [ @@ -79,10 +77,8 @@ defmodule BlockScoutWeb.API.V2.ScrollController do paginated_response( items: Schemas.Scroll.Batch, next_page_params_example: %{ - "items_count" => 50, "number" => 502_655 - }, - title_prefix: "Batches" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -92,14 +88,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do """ @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches(conn, params) do - {batches, next_page} = + batches_options = params |> paging_options() |> Keyword.merge(@api_true) - |> Reader.batches() - |> split_list_by_page() - next_page_params = next_page_params(next_page, batches, params) + {batches, next_page_params} = + batches_options + |> Reader.batches() + |> paginate_list(params, batches_options[:paging_options]) conn |> put_status(200) @@ -134,7 +131,6 @@ defmodule BlockScoutWeb.API.V2.ScrollController do parameters: base_params() ++ define_paging_params([ - "items_count", "id" ]), responses: [ @@ -143,10 +139,8 @@ defmodule BlockScoutWeb.API.V2.ScrollController do paginated_response( items: Schemas.Scroll.Bridge, next_page_params_example: %{ - "items_count" => 50, "id" => 986_043 - }, - title_prefix: "Deposits" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -156,14 +150,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do """ @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits(conn, params) do - {deposits, next_page} = + deposits_options = params |> paging_options() |> Keyword.merge(@api_true) - |> Reader.deposits() - |> split_list_by_page() - next_page_params = next_page_params(next_page, deposits, params) + {deposits, next_page_params} = + deposits_options + |> Reader.deposits() + |> paginate_list(params, deposits_options[:paging_options]) conn |> put_status(200) @@ -201,7 +196,6 @@ defmodule BlockScoutWeb.API.V2.ScrollController do parameters: base_params() ++ define_paging_params([ - "items_count", "id" ]), responses: [ @@ -210,10 +204,8 @@ defmodule BlockScoutWeb.API.V2.ScrollController do paginated_response( items: Schemas.Scroll.Bridge, next_page_params_example: %{ - "items_count" => 50, "id" => 220_243 - }, - title_prefix: "Withdrawals" + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -223,14 +215,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do """ @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals(conn, params) do - {withdrawals, next_page} = + withdrawals_options = params |> paging_options() |> Keyword.merge(@api_true) - |> Reader.withdrawals() - |> split_list_by_page() - next_page_params = next_page_params(next_page, withdrawals, params) + {withdrawals, next_page_params} = + withdrawals_options + |> Reader.withdrawals() + |> paginate_list(params, withdrawals_options[:paging_options]) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex index 26a0bf5e8f2..afe92c09202 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex @@ -63,6 +63,40 @@ defmodule BlockScoutWeb.API.V2.SearchController do }) end + operation :search_v1_compatibility, + summary: "Search (legacy v1 alias)", + description: + "Backward-compatible alias of `GET /api/v2/search` served at `GET /api/v1/search`. " <> + "Behaves identically; kept for legacy clients.", + parameters: + [q_param() | base_params()] ++ + define_search_paging_params([ + "next_page_params_type", + "label", + "token", + "contract", + "tac_operation", + "metadata_tag", + "block", + "blob", + "user_operation", + "address", + "ens_domain" + ]), + responses: [ + ok: + {"Successful search response containing matched items and pagination information. + Results are ordered by relevance and limited to 50 items per page.", "application/json", + Schemas.Search.Results}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Backward-compatible v1 alias of `search/2` (served at `/api/v1/search`). + """ + @spec search_v1_compatibility(Plug.Conn.t(), map()) :: Plug.Conn.t() + def search_v1_compatibility(conn, params), do: search(conn, params) + operation :check_redirect, summary: "Check if search query should redirect to a specific entity page", description: "Checks if a search query redirects to a specific entity page rather than showing search results.", @@ -77,7 +111,7 @@ defmodule BlockScoutWeb.API.V2.SearchController do redirect: %Schema{type: :boolean, nullable: true}, type: %Schema{ type: :string, - enum: ["address", "block", "transaction", "user_operation", "blob"], + enum: ["address", "ens_domain", "block", "transaction", "user_operation", "blob"], nullable: true } } @@ -107,7 +141,7 @@ defmodule BlockScoutWeb.API.V2.SearchController do responses: [ ok: {"Quick search results.", "application/json", - %Schema{type: :array, items: Schemas.Search.ResultItem, nullable: false}}, + %Schema{type: :array, items: Schemas.Search.Result.Item, nullable: false}}, unprocessable_entity: JsonErrorResponse.response() ] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex index 4b2c9e47464..60372d9c9c5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex @@ -1,31 +1,69 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ShibariumController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 + paginate_list: 3, + paging_options: 1 ] alias Explorer.Chain.Cache.Counters.Shibarium.DepositsAndWithdrawalsCount alias Explorer.Chain.Shibarium.Reader + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["shibarium"]) + @api_true [api?: true] action_fallback(BlockScoutWeb.API.V2.FallbackController) + operation :deposits, + summary: "List Shibarium deposits.", + description: """ + Retrieves a paginated list of completed Shibarium deposits ordered by parent chain block number descending. + A deposit is "completed" when both the parent-chain and Shibarium sides of the bridge have been observed. + """, + # The `Enum.map` block below is mirrored in :withdrawals. The only delta is the `block_number` + # description, so the block is duplicated intentionally rather than hidden behind a helper for + # two call sites. + parameters: + base_params() ++ + Enum.map( + define_paging_params(["block_number"]), + fn + %OpenApiSpex.Parameter{name: :block_number} = param -> + %{param | description: "Parent chain block number for paging (cursor on `l1_block_number`)."} + + param -> + param + end + ), + responses: [ + ok: + {"List of Shibarium deposits.", "application/json", + paginated_response( + items: Schemas.Shibarium.Deposit, + next_page_params_example: %{ + "block_number" => 17_500_000 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits(conn, params) do - {deposits, next_page} = + deposits_options = params |> paging_options() |> Keyword.put(:api?, true) - |> Reader.deposits() - |> split_list_by_page() - next_page_params = next_page_params(next_page, deposits, params) + {deposits, next_page_params} = + deposits_options + |> Reader.deposits() + |> paginate_list(params, deposits_options[:paging_options]) conn |> put_status(200) @@ -35,6 +73,20 @@ defmodule BlockScoutWeb.API.V2.ShibariumController do }) end + # The `%Schema{type: :integer, ...}` body below is mirrored in :withdrawals_count. The shape is a + # one-line primitive, so the schema is inlined at each call site rather than extracted into a + # shared leaf module that would only be referenced twice within this controller. + operation :deposits_count, + summary: "Number of Shibarium deposits.", + description: "Retrieves the total count of completed Shibarium deposits.", + parameters: base_params(), + responses: [ + ok: + {"Number of items in the deposits list.", "application/json", + %Schema{type: :integer, nullable: false, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @spec deposits_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits_count(conn, _params) do count = @@ -48,16 +100,50 @@ defmodule BlockScoutWeb.API.V2.ShibariumController do |> render(:shibarium_items_count, %{count: count}) end + operation :withdrawals, + summary: "List Shibarium withdrawals.", + description: """ + Retrieves a paginated list of completed Shibarium withdrawals ordered by Shibarium block number descending. + A withdrawal is "completed" when both the Shibarium and parent-chain sides of the bridge have been observed. + """, + # The `Enum.map` block below is mirrored in :deposits. The only delta is the `block_number` + # description, so the block is duplicated intentionally rather than hidden behind a helper for + # two call sites. + parameters: + base_params() ++ + Enum.map( + define_paging_params(["block_number"]), + fn + %OpenApiSpex.Parameter{name: :block_number} = param -> + %{param | description: "Shibarium block number for paging (cursor on `l2_block_number`)."} + + param -> + param + end + ), + responses: [ + ok: + {"List of Shibarium withdrawals.", "application/json", + paginated_response( + items: Schemas.Shibarium.Withdrawal, + next_page_params_example: %{ + "block_number" => 5_000_000 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals(conn, params) do - {withdrawals, next_page} = + withdrawals_options = params |> paging_options() |> Keyword.put(:api?, true) - |> Reader.withdrawals() - |> split_list_by_page() - next_page_params = next_page_params(next_page, withdrawals, params) + {withdrawals, next_page_params} = + withdrawals_options + |> Reader.withdrawals() + |> paginate_list(params, withdrawals_options[:paging_options]) conn |> put_status(200) @@ -67,6 +153,20 @@ defmodule BlockScoutWeb.API.V2.ShibariumController do }) end + # The `%Schema{type: :integer, ...}` body below is mirrored in :deposits_count. The shape is a + # one-line primitive, so the schema is inlined at each call site rather than extracted into a + # shared leaf module that would only be referenced twice within this controller. + operation :withdrawals_count, + summary: "Number of Shibarium withdrawals.", + description: "Retrieves the total count of completed Shibarium withdrawals.", + parameters: base_params(), + responses: [ + ok: + {"Number of items in the withdrawals list.", "application/json", + %Schema{type: :integer, nullable: false, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @spec withdrawals_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals_count(conn, _params) do count = diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex index 33352f18624..6966a49dbb6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex @@ -9,8 +9,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do import BlockScoutWeb.Chain, only: [ fetch_scam_token_toggle: 2, - next_page_params: 5, - split_list_by_page: 1 + maybe_override_page_size: 2, + paginate_list: 4 ] import BlockScoutWeb.PagingHelper, @@ -95,8 +95,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do "smart_contract_id", "coin_balance", "address_hash", - "transactions_count", - "items_count" + "transactions_count" ]), responses: [ ok: @@ -104,8 +103,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do paginated_response( items: Schemas.SmartContract.ListItem, next_page_params_example: %{ - "smart_contract_id" => 1_947_801, - "items_count" => 50 + "smart_contract_id" => 1_947_801 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -121,11 +119,11 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do |> Keyword.merge(current_filter(params)) |> Keyword.merge(search_query(params)) |> Keyword.merge(smart_contract_addresses_paging_options(params)) + |> maybe_override_page_size(params) |> Keyword.merge(smart_contract_addresses_sorting(params)) |> fetch_scam_token_toggle(conn) addresses_plus_one = SmartContract.verified_contract_addresses(full_options) - {addresses, next_page} = split_list_by_page(addresses_plus_one) # If no sorting options are provided, we sort by `id` descending only. If # there are some sorting options supplied, we sort by `:hash` ascending as a @@ -139,14 +137,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do &%{smart_contract_id: &1.smart_contract.id} end - next_page_params = - next_page - |> next_page_params( - addresses, - params, - false, - pager - ) + {addresses, next_page_params} = + paginate_list(addresses_plus_one, params, full_options[:paging_options], paging_function: pager) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex index 073258705fc..e42069083c7 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex @@ -7,13 +7,12 @@ defmodule BlockScoutWeb.API.V2.StatsController do chain_type: [:explorer, :chain_type], chain_identity: [:explorer, :chain_identity] - import BlockScoutWeb.PagingHelper, only: [hot_smart_contracts_sorting: 1, delete_items_count_from_next_page_params: 1] + import BlockScoutWeb.PagingHelper, only: [hot_smart_contracts_sorting: 1] import BlockScoutWeb.Chain, only: [ hot_smart_contracts_paging_options: 1, - split_list_by_page: 1, - next_page_params: 5, + paginate_list: 4, fetch_scam_token_toggle: 2 ] @@ -139,7 +138,13 @@ defmodule BlockScoutWeb.API.V2.StatsController do responses: [ ok: {"Time series data for transaction count charts.", "application/json", - %Schema{type: :object, properties: %{chart_data: %Schema{type: :array, items: %Schema{type: :object}}}}}, + %Schema{ + type: :object, + properties: %{ + chart_data: %Schema{type: :array, items: Schemas.Stats.TransactionsChartDataPoint, nullable: false} + }, + required: [:chart_data] + }}, unprocessable_entity: JsonErrorResponse.response() ] @@ -179,9 +184,10 @@ defmodule BlockScoutWeb.API.V2.StatsController do %Schema{ type: :object, properties: %{ - chart_data: %Schema{type: :array, items: %Schema{type: :object}}, + chart_data: %Schema{type: :array, items: Schemas.Stats.MarketChartDataPoint, nullable: false}, available_supply: %Schema{anyOf: [Schemas.General.FloatString, %Schema{type: :integer}]} - } + }, + required: [:chart_data, :available_supply] }}, unprocessable_entity: JsonErrorResponse.response() ] @@ -230,7 +236,17 @@ defmodule BlockScoutWeb.API.V2.StatsController do responses: [ ok: {"Secondary coin market chart data.", "application/json", - %Schema{type: :object, properties: %{chart_data: %Schema{type: :array, items: %Schema{type: :object}}}}}, + %Schema{ + type: :object, + properties: %{ + chart_data: %Schema{ + type: :array, + items: Schemas.Stats.SecondaryCoinMarketChartDataPoint, + nullable: false + } + }, + required: [:chart_data] + }}, unprocessable_entity: JsonErrorResponse.response() ] @@ -259,8 +275,7 @@ defmodule BlockScoutWeb.API.V2.StatsController do define_paging_params([ "transactions_count_positive", "total_gas_used", - "contract_address_hash_not_nullable", - "items_count" + "contract_address_hash_not_nullable" ]), responses: [ ok: @@ -270,8 +285,7 @@ defmodule BlockScoutWeb.API.V2.StatsController do next_page_params_example: %{ "transactions_count" => 100, "total_gas_used" => "100", - "contract_address_hash" => "0x01a2A10583675E0e5dF52DE1b62734109201477a", - "items_count" => 50 + "contract_address_hash" => "0x01a2A10583675E0e5dF52DE1b62734109201477a" } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -287,19 +301,14 @@ defmodule BlockScoutWeb.API.V2.StatsController do |> Keyword.merge(@api_true) |> fetch_scam_token_toggle(conn) - {hot_smart_contracts, next_page} = + {hot_smart_contracts, next_page_params} = scale |> HotSmartContractsCache.fetch(options, fn -> HotSmartContracts.paginated(scale, options) end) |> case do {:error, :not_found} -> [] hot_smart_contracts -> hot_smart_contracts end - |> split_list_by_page() - - next_page_params = - next_page - |> next_page_params(hot_smart_contracts, params, false, &hot_smart_contracts_paging_params/1) - |> delete_items_count_from_next_page_params() + |> paginate_list(params, options[:paging_options], paging_function: &hot_smart_contracts_paging_params/1) conn |> put_status(200) 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 b6fa0396407..b077bb32ef6 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 @@ -11,27 +11,30 @@ defmodule BlockScoutWeb.API.V2.TokenController do alias BlockScoutWeb.{AccessHelper, AuthenticationHelper} alias BlockScoutWeb.API.V2.{AddressView, TransactionView} alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse - alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.{Address, BridgedToken, Token, Token.Instance} + alias Explorer.Chain + alias Explorer.Chain.{Address, BridgedToken, Token, Token.Instance, Token.UIMultiplierChange} alias Explorer.Migrator.BackfillMetadataURL alias Indexer.Fetcher.OnDemand.NFTCollectionMetadataRefetch, as: NFTCollectionMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, as: TokenInstanceMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: TokenTotalSupplyOnDemand + alias Indexer.Fetcher.TokenInstance.Helper, as: TokenInstanceHelper alias Plug.Conn import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] import BlockScoutWeb.Chain, only: [ - split_list_by_page: 1, + paginate_list: 3, + paginate_list: 4, paging_options: 1, - next_page_params: 3, token_transfers_next_page_params: 3, unique_tokens_paging_options: 1, unique_tokens_next_page: 3, fetch_scam_token_toggle: 2 ] + import BlockScoutWeb.LegacyPagingHelper, only: [split_list_by_page: 1] + import BlockScoutWeb.PagingHelper, only: [ chain_ids_filter_options: 1, @@ -39,11 +42,15 @@ defmodule BlockScoutWeb.API.V2.TokenController do tokens_sorting: 1 ] + import Explorer.MicroserviceInterfaces.BENS, + only: [maybe_preload_ens_to_instance: 1] + + import Explorer.MicroserviceInterfaces.Metadata, + only: [maybe_preload_metadata_to_instance: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1, maybe_preload_ens_and_metadata: 2] - import Explorer.PagingOptions, only: [default_paging_options: 0] - action_fallback(BlockScoutWeb.API.V2.FallbackController) plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) @@ -133,7 +140,9 @@ defmodule BlockScoutWeb.API.V2.TokenController do operation :counters, summary: "Get holder and transfer count statistics for a specific token", - description: "Retrieves count statistics for a specific token, including holders count and transfers count.", + description: + "Retrieves count statistics for a specific token, including holders count, transfers count and, for an ERC-8056 token, " <> + "the number of multiplier changes listed by `/api/v2/tokens/{address_hash}/ui-multiplier-changes`.", parameters: [address_hash_param() | base_params()], responses: [ ok: {"Count statistics for the specified token.", "application/json", Schemas.Token.Counters}, @@ -151,7 +160,11 @@ defmodule BlockScoutWeb.API.V2.TokenController do {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do {transfers_count, holders_count} = Token.fetch_token_counters(token) - json(conn, %{transfers_count: to_string(transfers_count), token_holders_count: to_string(holders_count)}) + json(conn, %{ + transfers_count: to_string(transfers_count), + token_holders_count: to_string(holders_count), + ui_multiplier_changes_count: to_string(UIMultiplierChange.count_for_token(address_hash, @api_true)) + }) end end @@ -203,11 +216,8 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> Chain.flat_1155_batch_token_transfers() |> Chain.paginate_1155_batch_token_transfers(paging_options) - {token_transfers, next_page} = split_list_by_page(results) - - next_page_params = - next_page - |> token_transfers_next_page_params(token_transfers, params) + {token_transfers, next_page_params} = + token_transfers_next_page_params(results, params, paging_options[:paging_options]) conn |> put_status(200) @@ -234,7 +244,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do parameters: base_params() ++ [address_hash_param()] ++ - define_paging_params(["address_hash_param", "value", "items_count"]), + define_paging_params(["address_hash_param", "value"]), responses: [ ok: {"Holders of the specified token, with pagination.", "application/json", @@ -242,8 +252,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do items: Schemas.Token.Holder, next_page_params_example: %{ "address_hash" => "0x48bb9b14483e43c7726df702b271d410e7460656", - "value" => "200000000000000", - "items_count" => 50 + "value" => "200000000000000" } )}, unprocessable_entity: JsonErrorResponse.response(), @@ -258,12 +267,12 @@ defmodule BlockScoutWeb.API.V2.TokenController do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do - results_plus_one = - Chain.fetch_token_holders_from_token_hash(address_hash, Keyword.merge(paging_options(params), @api_true)) + holders_paging_opts = paging_options(params) - {token_balances, next_page} = split_list_by_page(results_plus_one) + results_plus_one = + Chain.fetch_token_holders_from_token_hash(address_hash, Keyword.merge(holders_paging_opts, @api_true)) - next_page_params = next_page |> next_page_params(token_balances, params) + {token_balances, next_page_params} = paginate_list(results_plus_one, params, holders_paging_opts[:paging_options]) conn |> put_status(200) @@ -274,6 +283,62 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + operation :ui_multiplier_changes, + summary: "List the ERC-8056 multiplier changes of a specific token", + description: + "Retrieves the history of `UIMultiplierUpdated` events of an ERC-8056 token, newest first. " <> + "Every entry carries the multiplier it replaces, the one it schedules and the moment that one takes effect, " <> + "so an entry whose `effective_at` is still in the future is an announced but pending change. " <> + "Entries from blocks that lost consensus are left out. Empty for a token that does not implement ERC-8056. " <> + "Note that a token which announces more than #{UIMultiplierChange.max_changes_per_token()} changes stops being " <> + "resolved altogether: its history stays listed here, but the `ui_multiplier` of its token transfers becomes `null`.", + parameters: + base_params() ++ + [address_hash_param()] ++ + define_paging_params(["block_number", "log_index"]), + responses: [ + ok: + {"ERC-8056 multiplier history of the specified token, with pagination.", "application/json", + paginated_response( + items: Schemas.Token.UIMultiplierChange, + next_page_params_example: %{ + "block_number" => 12_345, + "log_index" => 3 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/ui-multiplier-changes` endpoint. + """ + @spec ui_multiplier_changes(Plug.Conn.t(), map()) :: Plug.Conn.t() + def ui_multiplier_changes(conn, %{address_hash_param: address_hash_string} = params) do + with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), + {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do + paging_options = paging_options(params) + + {ui_multiplier_changes, next_page_params} = + address_hash + |> UIMultiplierChange.paginated_for_token(Keyword.merge(paging_options, @api_true)) + |> paginate_list(params, paging_options[:paging_options], + paging_function: &ui_multiplier_change_paging_params/1 + ) + + conn + |> put_status(200) + |> render(:ui_multiplier_changes, %{ + ui_multiplier_changes: ui_multiplier_changes, + next_page_params: next_page_params + }) + end + end + + defp ui_multiplier_change_paging_params(%{block_number: block_number, log_index: log_index}), + do: %{block_number: block_number, log_index: log_index} + operation :instances, summary: "List individual NFT instances for a token contract", description: @@ -286,7 +351,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do ok: {"NFT instances for the specified token contract, with pagination.", "application/json", paginated_response( - items: Schemas.TokenInstance, + items: Schemas.TokenInstanceInTokenInstancesList, next_page_params_example: %{ "unique_token" => 782_098 } @@ -419,12 +484,82 @@ defmodule BlockScoutWeb.API.V2.TokenController do conn |> put_status(200) |> render(:token_instance, %{ - token_instance: updated_token_instance, + token_instance: + updated_token_instance |> maybe_preload_ens_to_instance() |> maybe_preload_metadata_to_instance(), token: token }) end end + operation :media_type, + summary: "Fetch media type for a specific NFT instance", + description: + "Determines and returns the media type categories (image/video/html) for the image and animation URLs of a specific NFT instance. Fetches via HTTP HEAD if not yet determined and stores the result.", + parameters: + base_params() ++ + [ + address_hash_param(), + token_id_param() + ], + responses: [ + ok: + {"Media type categories for the NFT instance.", "application/json", + %Schema{ + type: :object, + properties: %{ + image_media_type: %Schema{ + type: :string, + enum: ["image", "video", "html"], + nullable: true + }, + animation_media_type: %Schema{ + type: :string, + enum: ["image", "video", "html"], + nullable: true + } + } + }}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param/media-type` endpoint. + """ + @spec media_type(Plug.Conn.t(), map()) :: Plug.Conn.t() + def media_type(conn, %{address_hash_param: address_hash_string, token_id_param: token_id_string} = params) do + with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, + {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)}, + {:ok, token_instance} <- + Instance.nft_instance_by_token_id_and_token_address(token_id, address_hash, @api_true) do + case TokenInstanceHelper.fetch_media_types(token_instance) do + {:ok, %{image_type: image_type, animation_type: animation_type}} -> + conn + |> put_status(200) + |> json(%{ + image_media_type: Instance.mime_to_media_category(image_type), + animation_media_type: Instance.mime_to_media_category(animation_type) + }) + + {:error, :metadata_not_found} -> + conn + |> put_status(422) + |> json(%{message: "Metadata is not fetched yet"}) + + {:error, :already_fetched} -> + conn + |> put_status(200) + |> json(%{ + image_media_type: Instance.mime_to_media_category(token_instance.image_type), + animation_media_type: Instance.mime_to_media_category(token_instance.animation_type) + }) + end + end + end + operation :transfers_by_instance, summary: "List token transfers for a specific token instance", description: "Retrieves token transfers for a specific token instance (by token address and token ID).", @@ -468,11 +603,8 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> Chain.flat_1155_batch_token_transfers(Decimal.new(token_id)) |> Chain.paginate_1155_batch_token_transfers(paging_options) - {token_transfers, next_page} = split_list_by_page(results) - - next_page_params = - next_page - |> token_transfers_next_page_params(token_transfers, params) + {token_transfers, next_page_params} = + token_transfers_next_page_params(results, params, paging_options[:paging_options]) conn |> put_status(200) @@ -491,7 +623,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do parameters: base_params() ++ [address_hash_param(), token_id_param()] ++ - define_paging_params(["address_hash_param", "items_count", "token_id", "value"]), + define_paging_params(["address_hash_param", "token_id", "value"]), responses: [ ok: {"Current holders of the specified NFT instance, with pagination.", "application/json", @@ -499,7 +631,6 @@ defmodule BlockScoutWeb.API.V2.TokenController do items: Schemas.Token.Holder, next_page_params_example: %{ "address_hash" => "0x1d2c163fbda9486c3a384b6fa5e34c96fe948e9a", - "items_count" => 50, "token_id" => "0", "value" => "4217417051704137590935" } @@ -527,11 +658,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do Keyword.merge(paging_options, @api_true) ) - {token_holders, next_page} = split_list_by_page(results) - - next_page_params = - next_page - |> next_page_params(token_holders, params) + {token_holders, next_page_params} = paginate_list(results, params, paging_options[:paging_options]) conn |> put_status(200) @@ -599,8 +726,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do "holders_count", "is_name_null", "market_cap", - "name", - "items_count" + "name" ]), responses: [ ok: @@ -613,8 +739,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do "holders_count" => 59_731, "is_name_null" => false, "market_cap" => "570958125.135513", - "name" => "Wrapped Staked ETH", - "items_count" => 50 + "name" => "Wrapped Staked ETH" } )}, unprocessable_entity: JsonErrorResponse.response() @@ -630,20 +755,12 @@ defmodule BlockScoutWeb.API.V2.TokenController do options = params |> paging_options() - |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ - page_size: page_size - } = paging_options -> - maybe_parsed_limit = params[:limit] - %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} - end) |> Keyword.merge(token_transfers_types_options(params)) |> Keyword.merge(tokens_sorting(params)) |> Keyword.merge(@api_true) |> fetch_scam_token_toggle(conn) - {tokens, next_page} = filter |> Token.list_top(options) |> split_list_by_page() - - next_page_params = next_page |> next_page_params(tokens, params) + {tokens, next_page_params} = filter |> Token.list_top(options) |> paginate_list(params, options[:paging_options]) conn |> put_status(200) @@ -667,8 +784,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do "holders_count", "is_name_null", "market_cap", - "name", - "items_count" + "name" ]), responses: [ ok: @@ -681,8 +797,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do "holders_count" => 59_731, "is_name_null" => false, "market_cap" => "570958125.135513", - "name" => "Wrapped Staked ETH", - "items_count" => 50 + "name" => "Wrapped Staked ETH" } )}, unprocessable_entity: JsonErrorResponse.response() @@ -702,9 +817,8 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> Keyword.merge(tokens_sorting(params)) |> Keyword.merge(@api_true) - {tokens, next_page} = filter |> BridgedToken.list_top_bridged_tokens(options) |> split_list_by_page() - - next_page_params = next_page |> next_page_params(tokens, params) + {tokens, next_page_params} = + filter |> BridgedToken.list_top_bridged_tokens(options) |> paginate_list(params, options[:paging_options]) conn |> put_status(200) @@ -714,14 +828,14 @@ defmodule BlockScoutWeb.API.V2.TokenController do operation :refetch_metadata, summary: "Trigger a refresh of metadata for a specific NFT", description: - "Triggers a refresh of metadata for a specific NFT instance. Useful when the NFT's metadata has been updated but is not yet reflected in the BlockScout database.", + "Triggers a refresh of metadata for a specific NFT instance. Useful when the NFT's metadata has been updated but is not yet reflected in the BlockScout database. The endpoint is rate limited per IP; once the limit is reached, a valid reCAPTCHA header is required to proceed.", parameters: base_params() ++ [ address_hash_param(), token_id_param(), - recaptcha_response_param() - ], + scoped_recaptcha_bypass_token_param() + ] ++ recaptcha_params(), responses: [ ok: {"Metadata refresh has been successfully initiated.", "application/json", @@ -798,6 +912,66 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + @max_batch_size 50 + + operation :tokens_batch, + summary: "Get token info for a batch of token addresses", + description: "Retrieves token information for a list of token contract addresses.", + parameters: base_params(), + request_body: + {"List of token contract address hashes", "application/json", + %Schema{ + type: :object, + properties: %{ + address_hashes: %Schema{ + type: :array, + items: Schemas.General.AddressHash, + maxItems: @max_batch_size + } + }, + required: [:address_hashes] + }}, + responses: [ + ok: + {"List of tokens for given addresses.", "application/json", + %Schema{type: :array, items: Schemas.Token.Response}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles POST requests to `/api/v2/tokens/batch` endpoint. + """ + @spec tokens_batch(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:format, nil} + def tokens_batch(conn, params) do + case conn.body_params do + %{address_hashes: address_hashes} when is_list(address_hashes) -> + do_tokens_batch(conn, params, address_hashes) + + _ -> + {:format, nil} + end + end + + defp do_tokens_batch(conn, params, address_hashes) do + valid_hashes = + address_hashes + |> Enum.flat_map(fn hash_string -> + with {:ok, hash} <- Chain.string_to_address_hash(hash_string), + {:ok, false} <- AccessHelper.restricted_access?(hash_string, params) do + [hash] + else + _ -> [] + end + end) + |> Enum.uniq() + + tokens = Token.get_by_contract_address_hashes(valid_hashes, @token_options) + + conn + |> put_status(200) + |> render(:tokens_batch, %{tokens: tokens}) + end + defp get_api_key(conn) do case Conn.get_req_header(conn, "x-api-key") do [api_key] -> @@ -811,7 +985,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do defp maybe_run_fill_metadata_url_task(token_instance, token) do if not is_nil(token_instance.metadata) && is_nil(token_instance.skip_metadata_url) do Task.async(fn -> - BackfillMetadataURL.update_batch([ + BackfillMetadataURL.update_batch_with_results([ {token_instance.token_contract_address_hash, token_instance.token_id, token.type} ]) 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 57f2c6929d5..526fe62f706 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 @@ -2,12 +2,11 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do use BlockScoutWeb, :controller use OpenApiSpex.ControllerSpecs - alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain alias Explorer.Chain.{TokenTransfer, Transaction} import BlockScoutWeb.Chain, only: [ - split_list_by_page: 1, paging_options: 1, token_transfers_next_page_params: 3, fetch_scam_token_toggle: 2 @@ -19,7 +18,6 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do ] 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 @@ -36,7 +34,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do description: "Retrieves a paginated list of token transfers across all token types (ERC-20, ERC-721, ERC-1155).", parameters: base_params() ++ - [token_type_param(), limit_param()] ++ + [token_type_param()] ++ define_paging_params([ "index", "block_number", @@ -67,24 +65,17 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do options = paging_options - |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ - page_size: page_size - } = paging_options -> - maybe_parsed_limit = params[:limit] - %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} - end) |> Keyword.merge(token_transfers_types_options(params)) |> Keyword.merge(@api_true) |> fetch_scam_token_toggle(conn) - result = + results = options |> TokenTransfer.fetch() |> Chain.flat_1155_batch_token_transfers() |> Chain.paginate_1155_batch_token_transfers(paging_options) - |> split_list_by_page() - {token_transfers, next_page} = result + {token_transfers, next_page_params} = token_transfers_next_page_params(results, params, options[:paging_options]) transactions = token_transfers @@ -98,9 +89,6 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do |> Enum.zip(decoded_transactions) |> Enum.into(%{}, fn {%{hash: hash}, decoded_input} -> {hash, decoded_input} end) - next_page_params = - next_page |> token_transfers_next_page_params(token_transfers, params) - conn |> put_status(200) |> render(:token_transfers, %{ 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 fa45a54e3a8..1b917b21ca6 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 @@ -11,13 +11,12 @@ defmodule BlockScoutWeb.API.V2.TransactionController do import BlockScoutWeb.Chain, only: [ - next_page_params: 3, - next_page_params: 4, - next_page_params: 5, + next_page_params_for_state_changes: 3, + paginate_list: 3, + paginate_list: 4, put_key_value_to_paging_options: 3, token_transfers_next_page_params: 3, paging_options: 1, - split_list_by_page: 1, fetch_scam_token_toggle: 2, transaction_to_internal_transactions: 2 ] @@ -48,7 +47,14 @@ defmodule BlockScoutWeb.API.V2.TransactionController do require Logger alias BlockScoutWeb.AccessHelper - alias BlockScoutWeb.API.V2.{BlobView, Ethereum.DepositController, Ethereum.DepositView} + + alias BlockScoutWeb.API.V2.{ + BlobView, + Ethereum.DepositController, + Ethereum.DepositView, + InternalTransactionsPendingStatusHelper + } + alias BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, as: TransactionInterpretationService alias BlockScoutWeb.Models.TransactionStateHelper alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.{ForbiddenResponse, NotFoundResponse} @@ -248,16 +254,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do parameters: base_params() ++ [transaction_filter_param(), transaction_type_param()] ++ - define_paging_params(["block_number", "index", "items_count", "hash", "inserted_at"]), + define_paging_params(["block_number", "index", "hash", "inserted_at"]), responses: [ ok: {"List of transactions with pagination information.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 23_532_302, - "index" => 375, - "items_count" => 50 + "index" => 375 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -281,9 +286,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do transactions_plus_one = Chain.recent_transactions(full_options, filter_options) - {transactions, next_page} = split_list_by_page(transactions_plus_one) - - next_page_params = next_page |> next_page_params(transactions, params) + {transactions, next_page_params} = paginate_list(transactions_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -316,16 +319,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do description: "Retrieves L2 transactions bound to a specific ZkSync batch number.", parameters: base_params() ++ - [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + [batch_number_param()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"ZkSync batch transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 65_361_291, - "index" => 1, - "items_count" => 50 + "index" => 1 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -345,16 +347,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do description: "Retrieves L2 transactions bound to a specific Arbitrum batch number.", parameters: base_params() ++ - [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + [batch_number_param()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"Arbitrum batch transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 391_483_842, - "index" => 0, - "items_count" => 50 + "index" => 0 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -415,16 +416,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do description: "Retrieves L2 transactions bound to a specific Optimism batch number.", parameters: base_params() ++ - [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + [batch_number_param()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"Optimism batch transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 142_678_440, - "index" => 5, - "items_count" => 50 + "index" => 5 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -447,16 +447,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do description: "Retrieves L2 transactions bound to a specific Scroll batch number.", parameters: base_params() ++ - [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + [batch_number_param()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"Scroll batch transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 14_127_868, - "index" => 0, - "items_count" => 50 + "index" => 0 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -515,8 +514,8 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Repo.replica().all() end - {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, params) + {transactions, next_page_params} = + paginate_list(transactions_plus_one, params, paging_options(params)[:paging_options]) conn |> put_status(200) @@ -559,8 +558,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Enum.map(fn transaction -> transaction.transaction_hash end) |> Chain.hashes_to_transactions(full_options) - {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, params) + {transactions, next_page_params} = paginate_list(transactions_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -573,17 +571,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do operation :execution_node, summary: "List transactions executed on a specific execution node", description: "Retrieves transactions that were executed on the specified execution node.", - parameters: - [execution_node_hash_param() | base_params()] ++ define_paging_params(["block_number", "index", "items_count"]), + parameters: [execution_node_hash_param() | base_params()] ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"List of transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 14_127_868, - "index" => 0, - "items_count" => 50 + "index" => 0 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -603,11 +599,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do transactions_plus_one = Chain.execution_node_to_transactions(execution_node_hash, full_options) - {transactions, next_page} = split_list_by_page(transactions_plus_one) - - next_page_params = - next_page - |> next_page_params(transactions, params) + {transactions, next_page_params} = paginate_list(transactions_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -710,11 +702,8 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Chain.flat_1155_batch_token_transfers() |> Chain.paginate_1155_batch_token_transfers(paging_options) - {token_transfers, next_page} = split_list_by_page(results) - - next_page_params = - next_page - |> token_transfers_next_page_params(token_transfers, params) + {token_transfers, next_page_params} = + token_transfers_next_page_params(results, params, full_options[:paging_options]) conn |> put_status(200) @@ -739,17 +728,18 @@ defmodule BlockScoutWeb.API.V2.TransactionController do "Retrieves internal transactions generated during the execution of a specific transaction. Useful for analyzing contract interactions and debugging failed transactions.", parameters: [transaction_hash_param() | base_params()] ++ - define_paging_params(["index", "block_number", "transaction_index", "items_count"]), + [include_zero_value_param()] ++ + define_paging_params(["index", "block_number", "transaction_index"]), responses: [ ok: {"Internal transactions for the specified transaction, with pagination.", "application/json", paginated_response( items: Schemas.InternalTransaction, + include_pending_status?: true, next_page_params_example: %{ "index" => 50, "block_number" => 22_133_247, - "transaction_index" => 68, - "items_count" => 50 + "transaction_index" => 68 } )}, not_found: NotFoundResponse.response(), @@ -766,20 +756,26 @@ defmodule BlockScoutWeb.API.V2.TransactionController do @internal_transaction_address_preloads |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) + |> Keyword.put(:include_zero_value, Map.get(params, :include_zero_value, true)) internal_transactions_plus_one = transaction_to_internal_transactions(transaction, full_options) - {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) + {internal_transactions, next_page_params} = + paginate_list(internal_transactions_plus_one, params, full_options[:paging_options]) - next_page_params = - next_page - |> next_page_params(internal_transactions, params) + pending_status? = + InternalTransactionsPendingStatusHelper.transaction_internal_transactions_pending?( + internal_transactions, + transaction.hash, + transaction.block_number + ) conn |> put_status(200) |> render(:internal_transactions, %{ internal_transactions: internal_transactions |> maybe_preload_ens_and_metadata(), - next_page_params: next_page_params + next_page_params: next_page_params, + pending_status?: pending_status? }) end end @@ -790,7 +786,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do "Retrieves event logs emitted during the execution of a specific transaction. Logs contain information about contract events and state changes.", parameters: [transaction_hash_param() | base_params()] ++ - define_paging_params(["index", "block_number", "items_count"]), + define_paging_params(["index", "block_number"]), responses: [ ok: {"Event logs for the specified transaction, with pagination.", "application/json", @@ -798,8 +794,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do items: Schemas.Log, next_page_params_example: %{ "index" => 124, - "block_number" => 21_925_703, - "items_count" => 50 + "block_number" => 21_925_703 } )}, not_found: NotFoundResponse.response(), @@ -811,28 +806,26 @@ defmodule BlockScoutWeb.API.V2.TransactionController do """ @spec logs(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def logs(conn, %{transaction_hash_param: transaction_hash_string} = params) do - with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do + with {:ok, transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do full_options = [ necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional, :block => :optional - } + }, + address_preloads: [:names, :smart_contract, proxy_implementations_smart_contracts_association()], + transaction_preloads: [to_address: [:smart_contract, proxy_implementations_smart_contracts_association()]] ] |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) logs_plus_one = Chain.transaction_to_logs(transaction_hash, full_options) - {logs, next_page} = split_list_by_page(logs_plus_one) - - next_page_params = - next_page - |> next_page_params(logs, params) + {logs, next_page_params} = paginate_list(logs_plus_one, params, full_options[:paging_options]) conn |> put_status(200) |> render(:logs, %{ + transaction: transaction, transaction_hash: transaction_hash, logs: logs |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params @@ -843,8 +836,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do operation :state_changes, summary: "Get on-chain state changes caused by a specific transaction", description: "Retrieves state changes (balance changes, token transfers) caused by a specific transaction.", - parameters: - [transaction_hash_param() | base_params()] ++ define_state_changes_paging_params(["state_changes", "items_count"]), + parameters: [transaction_hash_param() | base_params()] ++ define_paging_params(["state_changes_count"]), responses: [ ok: { "State changes caused by the specified transaction, with pagination.", @@ -852,8 +844,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do paginated_response( items: Schemas.Transaction.StateChange, next_page_params_example: %{ - "state_changes" => nil, - "items_count" => 50 + "state_changes_count" => 50 } ) }, @@ -913,20 +904,18 @@ defmodule BlockScoutWeb.API.V2.TransactionController do @spec state_changes(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def state_changes(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params) do + paging_opts = paging_options(params) + state_changes_plus_next_page = transaction |> TransactionStateHelper.state_changes( - params - |> paging_options() + paging_opts |> Keyword.merge(@api_true) |> Keyword.put(:ip, AccessHelper.conn_to_ip_string(conn)) ) - {state_changes, next_page} = split_list_by_page(state_changes_plus_next_page) - - next_page_params = - next_page - |> next_page_params(state_changes, params, true) + {state_changes, next_page_params} = + next_page_params_for_state_changes(state_changes_plus_next_page, params, paging_opts[:paging_options]) conn |> put_status(200) @@ -937,16 +926,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do operation :watchlist_transactions, summary: "List transactions in a user's watchlist", description: "Retrieves transactions in the authenticated user's watchlist.", - parameters: base_params() ++ define_paging_params(["block_number", "index", "items_count"]), + parameters: base_params() ++ define_paging_params(["block_number", "index"]), responses: [ ok: {"Watchlist transactions.", "application/json", paginated_response( - items: Schemas.Transaction.Response, + items: Schemas.Transaction, next_page_params_example: %{ "block_number" => 23_617_990, - "index" => 128, - "items_count" => 50 + "index" => 128 } )}, forbidden: ForbiddenResponse.response(), @@ -969,9 +957,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do {watchlist_names, transactions_plus_one} = WatchlistAddress.fetch_watchlist_transactions(watchlist_id, full_options) - {transactions, next_page} = split_list_by_page(transactions_plus_one) - - next_page_params = next_page |> next_page_params(transactions, params) + {transactions, next_page_params} = paginate_list(transactions_plus_one, params, full_options[:paging_options]) conn |> put_status(200) @@ -1022,8 +1008,15 @@ defmodule BlockScoutWeb.API.V2.TransactionController do with {:transaction_interpreter_enabled, true} <- {:transaction_interpreter_enabled, TransactionInterpretationService.enabled?()}, {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params, options) do - conn - |> json(TransactionInterpretationService.get_request_body(transaction)) + case TransactionInterpretationService.get_request_body(transaction) do + {:ok, body} -> + json(conn, body) + + {:error, :lock_timeout} -> + conn + |> put_status(503) + |> json(%{error: "Transaction data is temporarily unavailable"}) + end end end @@ -1043,7 +1036,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do {response, code} = case TransactionInterpretationService.interpret(transaction) do {:ok, response} -> {response, 200} - {:error, %Jason.DecodeError{}} -> {%{error: "Error while transaction interpreter response decoding"}, 500} + {:error, _decode_error} -> {%{error: "Error while transaction interpreter response decoding"}, 500} {{:error, error}, code} -> {%{error: error}, code} end @@ -1206,15 +1199,14 @@ defmodule BlockScoutWeb.API.V2.TransactionController do operation :beacon_deposits, summary: "List beacon deposits in a transaction", description: "Retrieves beacon deposits included in a specific transaction with pagination support.", - parameters: [transaction_hash_param() | base_params()] ++ define_paging_params(["index", "items_count"]), + parameters: [transaction_hash_param() | base_params()] ++ define_paging_params(["index"]), responses: [ ok: {"Beacon deposits for transaction.", "application/json", paginated_response( items: Schemas.Beacon.Deposit.Response, next_page_params_example: %{ - "index" => 2_287_943, - "items_count" => 50 + "index" => 2_287_943 } )}, not_found: NotFoundResponse.response(), @@ -1263,15 +1255,10 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Keyword.merge(DepositController.paging_options(params)) deposit_plus_one = BeaconDeposit.from_transaction_hash(transaction_hash, full_options) - {deposits, next_page} = split_list_by_page(deposit_plus_one) - - next_page_params = - next_page - |> next_page_params( - deposits, - params, - false, - DepositController.paging_function() + + {deposits, next_page_params} = + paginate_list(deposit_plus_one, params, full_options[:paging_options], + paging_function: DepositController.paging_function() ) conn diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex index f35153699bf..10f82d52072 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex @@ -12,6 +12,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do alias Explorer.Chain.Stability.Validator, as: ValidatorStability alias Explorer.Chain.Zilliqa.Hash.BLSPublicKey alias Explorer.Chain.Zilliqa.Staker, as: ValidatorZilliqa + alias OpenApiSpex.Parameter import BlockScoutWeb.PagingHelper, only: [ @@ -22,9 +23,9 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do import BlockScoutWeb.Chain, only: [ - split_list_by_page: 1, - paging_options: 1, - next_page_params: 5 + maybe_override_page_size: 2, + paginate_list: 4, + paging_options: 1 ] @api_true api?: true @@ -33,7 +34,73 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) - operation :stability_validators_list, false + tags(["stability"]) + + operation :stability_validators_list, + summary: "List Stability validators.", + description: + "Retrieves a paginated list of validators on the Stability chain, optionally filtered by state and sorted by state, address hash or number of blocks produced.", + parameters: + base_params() ++ + [ + %Parameter{ + name: :state_filter, + in: :query, + schema: %Schema{ + type: :string, + pattern: ~r/^(active|probation|inactive)(,(active|probation|inactive))*$/ + }, + required: false, + description: + "Comma-separated list of validator states to keep. Allowed values: `active`, `probation`, `inactive` (case-sensitive)." + }, + sort_param(["state", "address_hash", "blocks_validated"]), + order_param(), + # NOTE: this cursor parameter is intentionally NOT marked `nullable: true`, even though + # the `StabilityValidator.state` response property is. HTTP query strings cannot carry + # JSON `null` — a client can only send a string value, an empty string, or omit the + # parameter — so `nullable: true` on a query parameter is meaningless and misleading. + # If the previous page's last validator had `state == nil`, the client cannot echo that + # null back; the cursor for that boundary is effectively non-resumable. This asymmetry + # is a property of HTTP, not of the spec. + %Parameter{ + name: :state, + in: :query, + schema: %Schema{ + type: :string, + enum: ["active", "probation", "inactive"] + }, + required: false, + description: "Cursor field — validator state from the previous page's `next_page_params`." + }, + %Parameter{ + name: :address_hash, + in: :query, + schema: Schemas.General.AddressHash, + required: false, + description: "Cursor field — validator address hash from the previous page's `next_page_params`." + }, + %Parameter{ + name: :blocks_validated, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "Cursor field — number of blocks validated from the previous page's `next_page_params`." + } + ], + responses: [ + ok: + {"List of Stability validators.", "application/json", + paginated_response( + items: Schemas.Stability.Validator, + next_page_params_example: %{ + "state" => "active", + "address_hash" => "0x0000000000000000000000000000000000000805", + "blocks_validated" => 100 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] @doc """ Function to handle GET requests to `/api/v2/validators/stability` endpoint. @@ -51,22 +118,23 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do |> Keyword.merge(validators_stability_sorting(params)) |> Keyword.merge(stability_validators_state_options(params)) - {validators, next_page} = options |> ValidatorStability.get_paginated_validators() |> split_list_by_page() - - next_page_params = - next_page - |> next_page_params( - validators, - params, - false, - &ValidatorStability.next_page_params/1 - ) + {validators, next_page_params} = + options + |> ValidatorStability.get_paginated_validators() + |> paginate_list(params, options[:paging_options], paging_function: &ValidatorStability.next_page_params/1) conn |> render(:stability_validators, %{validators: validators, next_page_params: next_page_params}) end - operation :stability_validators_counters, false + operation :stability_validators_counters, + summary: "Get Stability validators counters.", + description: "Retrieves aggregate counters for the Stability chain validator set.", + parameters: base_params(), + responses: [ + ok: {"Stability validators counters.", "application/json", Schemas.Stability.Counters}, + unprocessable_entity: JsonErrorResponse.response() + ] @doc """ Function to handle GET requests to `/api/v2/validators/stability/counters` endpoint. @@ -106,16 +174,10 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do |> Keyword.merge(paging_options(params)) |> Keyword.merge(validators_blackfort_sorting(params)) - {validators, next_page} = options |> ValidatorBlackfort.get_paginated_validators() |> split_list_by_page() - - next_page_params = - next_page - |> next_page_params( - validators, - params, - false, - &ValidatorBlackfort.next_page_params/1 - ) + {validators, next_page_params} = + options + |> ValidatorBlackfort.get_paginated_validators() + |> paginate_list(params, options[:paging_options], paging_function: &ValidatorBlackfort.next_page_params/1) conn |> render(:blackfort_validators, %{validators: validators, next_page_params: next_page_params}) @@ -157,28 +219,10 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do description: "Retrieves the list of Zilliqa validators.", parameters: base_params() ++ - define_paging_params(["index", "items_count"]) ++ + define_paging_params(["index"]) ++ [ - %OpenApiSpex.Parameter{ - name: :sort, - in: :query, - schema: %OpenApiSpex.Schema{ - type: :string, - enum: ["index"], - nullable: false - }, - required: false - }, - %OpenApiSpex.Parameter{ - name: :order, - in: :query, - schema: %OpenApiSpex.Schema{ - type: :string, - enum: ["asc", "desc"], - nullable: false - }, - required: false - } + sort_param(["index"]), + order_param() ], responses: [ ok: @@ -186,10 +230,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do paginated_response( items: Schemas.Zilliqa.Staker, next_page_params_example: %{ - "index" => 55, - "items_count" => 50 - }, - title_prefix: "Validators" + "index" => 55 + } )}, unprocessable_entity: JsonErrorResponse.response() ] @@ -215,21 +257,13 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do options = @api_true |> Keyword.merge(paging_options: paging_options) + |> maybe_override_page_size(params) |> Keyword.merge(sorting_options: sorting_options) - {validators, next_page} = + {validators, next_page_params} = options |> ValidatorZilliqa.paginated_active_stakers() - |> split_list_by_page() - - next_page_params = - next_page - |> next_page_params( - validators, - params, - false, - &ValidatorZilliqa.next_page_params/1 - ) + |> paginate_list(params, options[:paging_options], paging_function: &ValidatorZilliqa.next_page_params/1) conn |> render(:zilliqa_validators, %{ @@ -245,7 +279,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do %OpenApiSpex.Parameter{ name: :bls_public_key, in: :path, - schema: Schemas.General.HexString, + schema: Schemas.General.HexData, required: true } | base_params() diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex index 111d7246740..3a3fcc13af8 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex @@ -199,7 +199,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do with :verifier_enabled <- check_microservice(), :validated <- validate_address(conn, params), libraries <- Map.get(params, "libraries", "{}"), - {:libs_format, {:ok, json}} <- {:libs_format, Jason.decode(libraries)} do + {:libs_format, {:ok, json}} <- {:libs_format, Utils.JSON.decode(libraries)} do verification_params = %{ "address_hash" => String.downcase(address_hash_string), @@ -367,7 +367,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do defp parse_interfaces(interfaces) do cond do is_binary(interfaces) -> - case Jason.decode(interfaces) do + case Utils.JSON.decode(interfaces) do {:ok, map} -> map 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 b9c2715e166..3e98c9f5cfc 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 @@ -4,7 +4,7 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, - only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + only: [paging_options: 1, paginate_list: 3] import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] @@ -19,15 +19,14 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do "Retrieves a paginated list of withdrawals, typically for proof-of-stake networks supporting validator withdrawals.", parameters: base_params() ++ - define_paging_params(["index", "items_count"]), + define_paging_params(["index"]), responses: [ ok: {"List of withdrawals with pagination.", "application/json", paginated_response( items: Schemas.Withdrawal, next_page_params_example: %{ - "index" => 50, - "items_count" => 50 + "index" => 50 } )}, unprocessable_entity: JsonErrorResponse.response() @@ -45,9 +44,8 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do |> Keyword.merge(paging_options(params)) withdrawals_plus_one = Chain.list_withdrawals(full_options) - {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, params) + {withdrawals, next_page_params} = paginate_list(withdrawals_plus_one, params, full_options[:paging_options]) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex index 25a21adf464..0ea6f07ae49 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex @@ -1,16 +1,21 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ZkSyncController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ - next_page_params: 5, - paging_options: 1, - split_list_by_page: 1 + paginate_list: 4, + paging_options: 1 ] + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.Chain.ZkSync.{Reader, TransactionBatch} + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["zksync"]) + action_fallback(BlockScoutWeb.API.V2.FallbackController) @batch_necessity_by_association %{ @@ -19,11 +24,21 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do :execute_transaction => :optional } + operation :batch, + summary: "Get batch by number.", + description: "Retrieves detailed information about a ZkSync batch by its number.", + parameters: [batch_number_param() | base_params()], + responses: [ + ok: {"Batch info.", "application/json", Schemas.ZkSync.Batch}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/zksync/batches/:batch_number` endpoint. + Function to handle GET requests to `/api/v2/zksync/batches/:batch_number_param` endpoint. """ @spec batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch(conn, %{"batch_number" => batch_number} = _params) do + def batch(conn, %{batch_number_param: batch_number} = _params) do case Reader.batch( batch_number, necessity_by_association: @batch_necessity_by_association, @@ -39,26 +54,38 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do end end + operation :batches, + summary: "List batches.", + description: "Retrieves a paginated list of ZkSync rollup batches, newest first.", + parameters: + base_params() ++ + define_paging_params(["number"]), + responses: [ + ok: + {"List of batches.", "application/json", + paginated_response( + items: Schemas.ZkSync.BatchListItem, + next_page_params_example: %{"number" => 502_655} + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/zksync/batches` endpoint. """ @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches(conn, params) do - {batches, next_page} = + zksync_options = params |> paging_options() |> Keyword.put(:necessity_by_association, @batch_necessity_by_association) |> Keyword.put(:api?, true) + + {batches, next_page_params} = + zksync_options |> Reader.batches() - |> split_list_by_page() - - next_page_params = - next_page_params( - next_page, - batches, - params, - false, - fn %TransactionBatch{number: number} -> %{"number" => number} end + |> paginate_list(params, zksync_options[:paging_options], + paging_function: fn %TransactionBatch{number: number} -> %{"number" => number} end ) conn @@ -69,6 +96,15 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do }) end + operation :batches_count, + summary: "Get batches count.", + description: "Retrieves the total count of ZkSync rollup batches.", + parameters: base_params(), + responses: [ + ok: {"Total count of batches.", "application/json", %Schema{type: :integer, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/zksync/batches/count` endpoint. """ @@ -79,6 +115,29 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do |> render(:zksync_batches_count, %{count: Reader.batches_count(api?: true)}) end + operation :batches_confirmed, + summary: "List confirmed batches on the main page.", + description: "Retrieves up to ten most-recently-committed ZkSync rollup batches, displayed on the main page.", + parameters: base_params(), + responses: [ + ok: + {"List of confirmed ZkSync batches.", "application/json", + %Schema{ + type: :object, + properties: %{ + items: %Schema{ + type: :array, + items: Schemas.ZkSync.ConfirmedBatchListItem, + nullable: false + } + }, + required: [:items], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ], + tags: ["main-page"] + @doc """ Function to handle GET requests to `/api/v2/main-page/zksync/batches/confirmed` endpoint. """ @@ -96,6 +155,16 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do |> render(:zksync_batches, %{batches: batches}) end + operation :batch_latest_number, + summary: "Get the latest batch number.", + description: "Retrieves the number of the most recent ZkSync rollup batch. Returns 0 if no batches exist.", + parameters: base_params(), + responses: [ + ok: {"Latest ZkSync batch number.", "application/json", %Schema{type: :integer, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ], + tags: ["main-page"] + @doc """ Function to handle GET requests to `/api/v2/main-page/zksync/batches/latest-number` endpoint. """ diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex index 03c48359f00..887fa0ef6ce 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex @@ -2,7 +2,8 @@ defmodule BlockScoutWeb.BlockController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] alias BlockScoutWeb.{BlockView, Controller} alias Explorer.Chain diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex index 83786797fbe..30812053623 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex @@ -6,11 +6,11 @@ defmodule BlockScoutWeb.BlockTransactionController do only: [ paging_options: 1, put_key_value_to_paging_options: 3, - next_page_params: 3, - split_list_by_page: 1, parse_block_hash_or_number_param: 1 ] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] + import Explorer.Chain, only: [hash_to_block: 2, number_to_block: 2] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex index 95fc7d5f3c6..e3a2537dff4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex @@ -2,8 +2,8 @@ defmodule BlockScoutWeb.BlockWithdrawalController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, - only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.BlockTransactionController, only: [param_block_hash_or_number_to_block: 2, block_above_tip: 1] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex index 9b4d9f3d301..3f8b0fe1281 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex @@ -34,7 +34,7 @@ defmodule BlockScoutWeb.Chain.MarketHistoryChartController do def available_supply({:ok, supply_for_days}, _exchange_rate) do supply_for_days - |> Jason.encode() + |> Utils.JSON.encode() |> case do {:ok, _data} -> current_date = @@ -62,9 +62,9 @@ defmodule BlockScoutWeb.Chain.MarketHistoryChartController do |> Map.put(:market_cap, market_cap) |> Map.take([:closing_price, :market_cap, :tvl, :date]) end) - |> Jason.encode() + |> Utils.JSON.encode() |> case do - {:ok, data} -> Jason.decode!(data) + {:ok, data} -> Utils.JSON.decode!(data) _ -> [] end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex index af267e5bc04..7f53cb0be90 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex @@ -35,7 +35,7 @@ defmodule BlockScoutWeb.Chain.TransactionHistoryChartController do defp encode_transaction_history_data(transaction_history_data) do transaction_history_data - |> Jason.encode() + |> Utils.JSON.encode() |> case do {:ok, data} -> data _ -> [] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex index d215c96f446..0e66c620ace 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex @@ -46,7 +46,7 @@ defmodule BlockScoutWeb.ChainController do average_block_time: AverageBlockTime.average_block_time(), exchange_rate: exchange_rate, chart_config: chart_config, - chart_config_json: Jason.encode!(chart_config), + chart_config_json: Utils.JSON.encode!(chart_config), chart_data_paths: chart_data_paths, market_cap_calculation: market_cap_calculation, transaction_estimated_count: transactions_count, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex index be42444a820..6d8b627e158 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex @@ -2,7 +2,8 @@ defmodule BlockScoutWeb.PendingTransactionController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] alias BlockScoutWeb.{Controller, TransactionView} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex index 2355aa96a19..9f7dcf2bc4b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex @@ -35,13 +35,13 @@ defmodule BlockScoutWeb.SmartContractController do read_functions_required_wallet = load_read_functions_required_wallet(action, contract_type, implementation_address_hash_string, address) - contract_abi = Poison.encode!(address.smart_contract.abi) + contract_abi = Utils.JSON.encode!(address.smart_contract.abi) implementation_abi = if contract_type == "proxy" do implementation_address_hash_string |> SmartContract.get_abi() - |> Poison.encode!() + |> Utils.JSON.encode!() else [] end @@ -129,7 +129,7 @@ defmodule BlockScoutWeb.SmartContractController do [] end - contract_abi = Poison.encode!(abi) + contract_abi = Utils.JSON.encode!(abi) conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex index b4e590157e7..3a0470559a4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex @@ -7,12 +7,8 @@ defmodule BlockScoutWeb.Tokens.HolderController do alias Explorer.Chain alias Phoenix.View - import BlockScoutWeb.Chain, - only: [ - split_list_by_page: 1, - paging_options: 1, - next_page_params: 3 - ] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] def index(conn, %{"token_id" => address_hash_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex index 3b831717e5f..01b6e65dd02 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex @@ -9,7 +9,8 @@ defmodule BlockScoutWeb.Tokens.Instance.HolderController do alias Explorer.Chain.Token.Instance alias Phoenix.View - import BlockScoutWeb.Chain, only: [split_list_by_page: 1, paging_options: 1, next_page_params: 3] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(token_address_hash), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex index cf500494661..aa32a29631b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex @@ -9,7 +9,8 @@ defmodule BlockScoutWeb.Tokens.Instance.TransferController do alias Explorer.Chain.Token.Instance alias Phoenix.View - import BlockScoutWeb.Chain, only: [split_list_by_page: 1, paging_options: 1, next_page_params: 3] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex index bce53330861..b0f15b61328 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex @@ -10,11 +10,12 @@ defmodule BlockScoutWeb.Tokens.InventoryController do import BlockScoutWeb.Chain, only: [ - split_list_by_page: 1, unique_tokens_paging_options: 1, unique_tokens_next_page: 3 ] + import BlockScoutWeb.LegacyPagingHelper, only: [split_list_by_page: 1] + def index(conn, %{"token_id" => address_hash_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, token} <- Chain.token_from_address_hash(address_hash), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex index b14faa7a68b..968669b5b97 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex @@ -2,7 +2,8 @@ defmodule BlockScoutWeb.TokensController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] alias BlockScoutWeb.{Controller, TokensView} alias Explorer.Chain.Token alias Phoenix.View @@ -36,16 +37,6 @@ defmodule BlockScoutWeb.TokensController do ) end - items_count_str = Map.get(params, "items_count") - - items_count = - if items_count_str do - {items_count, _} = Integer.parse(items_count_str) - items_count - else - 0 - end - items = tokens_page |> Enum.with_index(1) @@ -54,7 +45,7 @@ defmodule BlockScoutWeb.TokensController do TokensView, "_tile.html", token: token, - index: items_count + index, + index: index, conn: conn ) end) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex index ab4f7b7ec8b..27113aca514 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex @@ -12,7 +12,8 @@ defmodule BlockScoutWeb.Tokens.TransferController do alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: TokenTotalSupplyOnDemand alias Phoenix.View - import BlockScoutWeb.Chain, only: [split_list_by_page: 1, paging_options: 1, next_page_params: 3] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex index a3bef06d6b4..e531c9ece9f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex @@ -4,8 +4,8 @@ defmodule BlockScoutWeb.TransactionInternalTransactionController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, - only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1, transaction_to_internal_transactions: 2] + import BlockScoutWeb.Chain, only: [paging_options: 1, transaction_to_internal_transactions: 2] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex index 05f6fd9743d..2060e9c2e59 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex @@ -3,7 +3,8 @@ defmodule BlockScoutWeb.TransactionLogController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] @@ -22,9 +23,7 @@ defmodule BlockScoutWeb.TransactionLogController do full_options = Keyword.merge( [ - necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional - } + address_preloads: [:names, :smart_contract, proxy_implementations_smart_contracts_association()] ], paging_options(params) ) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex index 42a1b318bb7..8955dcf91e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.TransactionStateController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params_for_state_changes: 3] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) @@ -31,18 +31,19 @@ defmodule BlockScoutWeb.TransactionStateController do AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do + full_options = + params + |> paging_options() + |> Keyword.put(:ip, AccessHelper.conn_to_ip_string(conn)) + state_changes_plus_next_page = - transaction - |> TransactionStateHelper.state_changes( - params - |> paging_options() - |> Keyword.put(:ip, AccessHelper.conn_to_ip_string(conn)) - ) + TransactionStateHelper.state_changes(transaction, full_options) - {state_changes, next_page} = split_list_by_page(state_changes_plus_next_page) + {state_changes, next_page_params} = + next_page_params_for_state_changes(state_changes_plus_next_page, params, full_options[:paging_options]) next_page_url = - case next_page_params(next_page, state_changes, params) do + case next_page_params do nil -> nil @@ -62,6 +63,7 @@ defmodule BlockScoutWeb.TransactionStateController do balance_after: state_change.balance_after, balance_diff: state_change.balance_diff, token_id: state_change.token_id, + ui_multiplier: state_change.ui_multiplier, conn: conn, miner: state_change.miner? ) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex index f973c0bf43e..92e7996aab8 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex @@ -3,7 +3,8 @@ defmodule BlockScoutWeb.TransactionTokenTransferController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex index 10c07a33c1d..9a37523ea73 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex @@ -2,8 +2,7 @@ defmodule BlockScoutWeb.VerifiedContractsController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, - only: [next_page_params: 5, split_list_by_page: 1, fetch_page_number: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 4, split_list_by_page: 1] import BlockScoutWeb.PagingHelper, only: [current_filter: 1, search_query: 1] @@ -49,7 +48,6 @@ defmodule BlockScoutWeb.VerifiedContractsController do next_page, verified_contracts, params, - false, &%{id: &1.id} ) do nil -> nil @@ -63,7 +61,7 @@ defmodule BlockScoutWeb.VerifiedContractsController do render(conn, "index.html", current_path: Controller.current_full_path(conn), filter: params["filter"], - page_number: params |> fetch_page_number() |> Integer.to_string(), + page_number: 1, contracts_count: Chain.count_contracts_from_cache(), verified_contracts_count: Chain.count_verified_contracts_from_cache(), new_contracts_count: Chain.count_new_contracts_from_cache(), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex index 96a662f0f62..2b9f1e95ac0 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex @@ -2,8 +2,8 @@ defmodule BlockScoutWeb.WithdrawalController do use BlockScoutWeb, :controller - import BlockScoutWeb.Chain, - only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1, fetch_page_number: 1] + import BlockScoutWeb.Chain, only: [paging_options: 1] + import BlockScoutWeb.LegacyPagingHelper, only: [next_page_params: 3, split_list_by_page: 1] alias BlockScoutWeb.{Controller, WithdrawalView} alias Explorer.Chain @@ -32,10 +32,10 @@ defmodule BlockScoutWeb.WithdrawalController do json(conn, %{items: items, next_page_path: next_page_path}) end - def index(conn, params) do + def index(conn, _params) do render(conn, "index.html", current_path: Controller.current_full_path(conn), - page_number: params |> fetch_page_number() |> Integer.to_string(), + page_number: 1, withdrawals_count: Withdrawal.count_withdrawals_from_cache(), withdrawals_sum: Withdrawal.sum_withdrawals_from_cache() |> Wei.from(:wei) ) diff --git a/apps/block_scout_web/lib/block_scout_web/etherscan.ex b/apps/block_scout_web/lib/block_scout_web/etherscan.ex index 3f570417c8b..4832627897c 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -1227,7 +1227,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_eth_get_balance_example_value), + example_value: Utils.JSON.encode!(@account_eth_get_balance_example_value), model: %{ name: "Result", fields: %{ @@ -1265,7 +1265,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_balance_example_value), + example_value: Utils.JSON.encode!(@account_balance_example_value), model: %{ name: "Result", fields: %{ @@ -1278,7 +1278,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_balance_example_value_error) + example_value: Utils.JSON.encode!(@account_balance_example_value_error) } ] } @@ -1308,7 +1308,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_balancemulti_example_value), + example_value: Utils.JSON.encode!(@account_balancemulti_example_value), model: %{ name: "Result", fields: %{ @@ -1324,7 +1324,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_balance_example_value_error) + example_value: Utils.JSON.encode!(@account_balance_example_value_error) } ] } @@ -1358,7 +1358,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_pendingtxlist_example_value), + example_value: Utils.JSON.encode!(@account_pendingtxlist_example_value), model: %{ name: "Result", fields: %{ @@ -1374,7 +1374,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_txlist_example_value_error) + example_value: Utils.JSON.encode!(@account_txlist_example_value_error) } ] } @@ -1444,7 +1444,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_txlist_example_value), + example_value: Utils.JSON.encode!(@account_txlist_example_value), model: %{ name: "Result", fields: %{ @@ -1460,7 +1460,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_txlist_example_value_error) + example_value: Utils.JSON.encode!(@account_txlist_example_value_error) } ] } @@ -1519,7 +1519,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_txlistinternal_example_value), + example_value: Utils.JSON.encode!(@account_txlistinternal_example_value), model: %{ name: "Result", fields: %{ @@ -1535,7 +1535,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_txlistinternal_example_value_error) + example_value: Utils.JSON.encode!(@account_txlistinternal_example_value_error) } ] } @@ -1592,7 +1592,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_tokentx_example_value), + example_value: Utils.JSON.encode!(@account_tokentx_example_value), model: %{ name: "Result", fields: %{ @@ -1608,7 +1608,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_tokentx_example_value_error) + example_value: Utils.JSON.encode!(@account_tokentx_example_value_error) } ] } @@ -1635,7 +1635,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_tokenbalance_example_value), + example_value: Utils.JSON.encode!(@account_tokenbalance_example_value), model: %{ name: "Result", fields: %{ @@ -1652,7 +1652,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_tokenbalance_example_value_error) + example_value: Utils.JSON.encode!(@account_tokenbalance_example_value_error) } ] } @@ -1673,7 +1673,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_tokenlist_example_value), + example_value: Utils.JSON.encode!(@account_tokenlist_example_value), model: %{ name: "Result", fields: %{ @@ -1689,7 +1689,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_tokenbalance_example_value_error) + example_value: Utils.JSON.encode!(@account_tokenbalance_example_value_error) } ] } @@ -1723,7 +1723,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_getminedblocks_example_value), + example_value: Utils.JSON.encode!(@account_getminedblocks_example_value), model: %{ name: "Result", fields: %{ @@ -1739,7 +1739,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@account_getminedblocks_example_value_error) + example_value: Utils.JSON.encode!(@account_getminedblocks_example_value_error) } ] } @@ -1767,7 +1767,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@account_listaccounts_example_value), + example_value: Utils.JSON.encode!(@account_listaccounts_example_value), model: %{ name: "Result", fields: %{ @@ -1877,7 +1877,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@logs_getlogs_example_value), + example_value: Utils.JSON.encode!(@logs_getlogs_example_value), model: %{ name: "Result", fields: %{ @@ -1893,7 +1893,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@logs_getlogs_example_value_error) + example_value: Utils.JSON.encode!(@logs_getlogs_example_value_error) } ] } @@ -1916,7 +1916,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@token_gettoken_example_value), + example_value: Utils.JSON.encode!(@token_gettoken_example_value), model: %{ name: "Result", fields: %{ @@ -1932,7 +1932,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@token_gettoken_example_value_error) + example_value: Utils.JSON.encode!(@token_gettoken_example_value_error) } ] } @@ -1966,7 +1966,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@token_gettokenholders_example_value), + example_value: Utils.JSON.encode!(@token_gettokenholders_example_value), model: %{ name: "Result", fields: %{ @@ -1982,7 +1982,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@token_gettokenholders_example_value_error) + example_value: Utils.JSON.encode!(@token_gettokenholders_example_value_error) } ] } @@ -2081,7 +2081,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@token_bridgedtokenlist_example_value), + example_value: Utils.JSON.encode!(@token_bridgedtokenlist_example_value), model: %{ name: "Result", fields: %{ @@ -2117,7 +2117,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_tokensupply_example_value), + example_value: Utils.JSON.encode!(@stats_tokensupply_example_value), model: %{ name: "Result", fields: %{ @@ -2134,7 +2134,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@token_gettoken_example_value_error) + example_value: Utils.JSON.encode!(@token_gettoken_example_value_error) } ] } @@ -2148,7 +2148,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_ethsupplyexchange_example_value), + example_value: Utils.JSON.encode!(@stats_ethsupplyexchange_example_value), model: %{ name: "Result", fields: %{ @@ -2174,7 +2174,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_ethsupply_example_value), + example_value: Utils.JSON.encode!(@stats_ethsupply_example_value), model: %{ name: "Result", fields: %{ @@ -2200,7 +2200,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_coinsupply_example_value), + example_value: Utils.JSON.encode!(@stats_coinsupply_example_value), model: %{ name: "Result", fields: %{ @@ -2224,7 +2224,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_coinprice_example_value), + example_value: Utils.JSON.encode!(@stats_coinprice_example_value), model: %{ name: "Result", fields: %{ @@ -2256,7 +2256,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@stats_totalfees_example_value), + example_value: Utils.JSON.encode!(@stats_totalfees_example_value), model: %{ name: "Result", fields: %{ @@ -2272,7 +2272,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@stats_totalfees_example_value_error) + example_value: Utils.JSON.encode!(@stats_totalfees_example_value_error) } ] } @@ -2293,7 +2293,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful request", - example_value: Jason.encode!(@block_eth_block_number_example_value), + example_value: Utils.JSON.encode!(@block_eth_block_number_example_value), model: %{ name: "Result", fields: %{ @@ -2322,7 +2322,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@block_getblockreward_example_value), + example_value: Utils.JSON.encode!(@block_getblockreward_example_value), model: %{ name: "Result", fields: %{ @@ -2338,7 +2338,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@block_getblockreward_example_value_error) + example_value: Utils.JSON.encode!(@block_getblockreward_example_value_error) } ] } @@ -2365,7 +2365,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@block_getblocknobytime_example_value), + example_value: Utils.JSON.encode!(@block_getblocknobytime_example_value), model: %{ name: "Result", fields: %{ @@ -2381,7 +2381,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@block_getblocknobytime_example_value_error) + example_value: Utils.JSON.encode!(@block_getblocknobytime_example_value_error) } ] } @@ -2430,7 +2430,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_listcontracts_example_value), + example_value: Utils.JSON.encode!(@contract_listcontracts_example_value), model: %{ name: "Result", fields: %{ @@ -2577,14 +2577,14 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_verify_example_value), + example_value: Utils.JSON.encode!(@contract_verify_example_value), type: "model", model: @contract_model }, %{ code: "200", description: "error", - example_value: Jason.encode!(@contract_verify_example_value_error) + example_value: Utils.JSON.encode!(@contract_verify_example_value_error) } ] } @@ -2645,14 +2645,14 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_verify_example_value), + example_value: Utils.JSON.encode!(@contract_verify_example_value), type: "model", model: @contract_model }, %{ code: "200", description: "error", - example_value: Jason.encode!(@contract_verify_example_value_error) + example_value: Utils.JSON.encode!(@contract_verify_example_value_error) } ] } @@ -2716,14 +2716,14 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_verify_example_value), + example_value: Utils.JSON.encode!(@contract_verify_example_value), type: "model", model: @contract_model }, %{ code: "200", description: "error", - example_value: Jason.encode!(@contract_verify_example_value_error) + example_value: Utils.JSON.encode!(@contract_verify_example_value_error) } ] } @@ -2786,7 +2786,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_verifysourcecode_example_value), + example_value: Utils.JSON.encode!(@contract_verifysourcecode_example_value), type: "model", model: @uid_response_model } @@ -2809,7 +2809,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_checkverifystatus_example_value), + example_value: Utils.JSON.encode!(@contract_checkverifystatus_example_value), type: "model", model: @status_response_model } @@ -2832,7 +2832,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_getabi_example_value), + example_value: Utils.JSON.encode!(@contract_getabi_example_value), model: %{ name: "Result", fields: %{ @@ -2848,7 +2848,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@contract_getabi_example_value_error) + example_value: Utils.JSON.encode!(@contract_getabi_example_value_error) } ] } @@ -2869,7 +2869,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@contract_getsourcecode_example_value), + example_value: Utils.JSON.encode!(@contract_getsourcecode_example_value), model: %{ name: "Result", fields: %{ @@ -2885,7 +2885,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@contract_getsourcecode_example_value_error) + example_value: Utils.JSON.encode!(@contract_getsourcecode_example_value_error) } ] } @@ -2912,7 +2912,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@transaction_gettxinfo_example_value), + example_value: Utils.JSON.encode!(@transaction_gettxinfo_example_value), model: %{ name: "Result", fields: %{ @@ -2928,7 +2928,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@transaction_gettxreceiptstatus_example_value_error) + example_value: Utils.JSON.encode!(@transaction_gettxreceiptstatus_example_value_error) } ] } @@ -2949,7 +2949,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@transaction_gettxreceiptstatus_example_value), + example_value: Utils.JSON.encode!(@transaction_gettxreceiptstatus_example_value), model: %{ name: "Result", fields: %{ @@ -2965,7 +2965,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@transaction_gettxreceiptstatus_example_value_error) + example_value: Utils.JSON.encode!(@transaction_gettxreceiptstatus_example_value_error) } ] } @@ -2986,7 +2986,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "successful operation", - example_value: Jason.encode!(@transaction_getstatus_example_value), + example_value: Utils.JSON.encode!(@transaction_getstatus_example_value), model: %{ name: "Result", fields: %{ @@ -3002,7 +3002,7 @@ defmodule BlockScoutWeb.Etherscan do %{ code: "200", description: "error", - example_value: Jason.encode!(@transaction_getstatus_example_value_error) + example_value: Utils.JSON.encode!(@transaction_getstatus_example_value_error) } ] } diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex b/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex index 1c52b8f75bf..c3f04640359 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex @@ -12,7 +12,7 @@ defmodule BlockScoutWeb.GraphQL.BodyReader do {:ok, body, conn} = Conn.read_body(conn, opts) updated_conn = update_in(conn.assigns[:raw_body], &[body | &1 || []]) - json_body = Jason.decode!(body) + json_body = Utils.JSON.decode!(body) json_body_length = if is_list(json_body) do @@ -27,7 +27,7 @@ defmodule BlockScoutWeb.GraphQL.BodyReader do {:ok, "", updated_conn |> Conn.put_resp_content_type("application/json") - |> Conn.resp(400, Jason.encode!(error)) + |> Conn.resp(400, Utils.JSON.encode!(error)) |> Conn.halt()} else {:ok, body, updated_conn} diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex index c821bceea5c..305f9116da6 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex @@ -70,7 +70,7 @@ defmodule BlockScoutWeb.GraphQL.Celo.Schema.Types do %{transaction_hash: transaction_hash, address_hash: address_hash}, _ ) do - Jason.encode!(%{ + Utils.JSON.encode!(%{ transaction_hash: to_string(transaction_hash), address_hash: to_string(address_hash) }) @@ -80,7 +80,7 @@ defmodule BlockScoutWeb.GraphQL.Celo.Schema.Types do %{transaction_hash: transaction_hash, log_index: log_index}, _ ) do - Jason.encode!(%{ + Utils.JSON.encode!(%{ transaction_hash: to_string(transaction_hash), log_index: log_index }) diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex index 6e3a6ba3f88..a10344f537f 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex @@ -10,26 +10,31 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.TokenTransfer do GraphQL.get_token_transfer(args) end - def get_by(_, %{token_contract_address_hash: token_contract_address_hash} = args, _) do + def get_by(_, %{token_contract_address_hash: token_contract_address_hash} = args, resolution) do connection_args = Map.take(args, [:after, :before, :first, :last]) replica = Repo.replica() + scam_opts = scam_token_opts(resolution) token_contract_address_hash - |> GraphQL.list_token_transfers_query() + |> GraphQL.list_token_transfers_query(scam_opts) |> Connection.from_query(&replica.all/1, connection_args, options(args)) end - def get_by(%Address{hash: address_hash}, args, _) do + def get_by(%Address{hash: address_hash}, args, resolution) do connection_args = Map.take(args, [:after, :before, :first, :last]) replica = Repo.replica() + scam_opts = scam_token_opts(resolution) address_hash - |> TokenTransfer.token_transfers_by_address_hash(nil, nil, [], nil, []) + |> TokenTransfer.token_transfers_by_address_hash(nil, nil, [], nil, scam_opts) |> Connection.from_query(&replica.all/1, connection_args, options(args)) end + defp scam_token_opts(%{context: context}), + do: [show_scam_tokens?: Map.get(context, :show_scam_tokens?, false)] + defp options(%{before: _}), do: [] defp options(%{count: count}), do: [count: count] diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex index 9db86a1e7e3..c822aa8d54e 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex @@ -50,12 +50,12 @@ defmodule BlockScoutWeb.GraphQL.Schema do node field do resolve(fn %{type: :internal_transaction, id: id}, resolution -> - %{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id) + %{"transaction_hash" => transaction_hash_string, "index" => index} = Utils.JSON.decode!(id) {:ok, transaction_hash} = Chain.string_to_full_hash(transaction_hash_string) InternalTransaction.get_by(%{transaction_hash: transaction_hash, index: index}, resolution) %{type: :token_transfer, id: id}, resolution -> - %{"transaction_hash" => transaction_hash_string, "log_index" => log_index} = Jason.decode!(id) + %{"transaction_hash" => transaction_hash_string, "log_index" => log_index} = Utils.JSON.decode!(id) {:ok, transaction_hash} = Chain.string_to_full_hash(transaction_hash_string) TokenTransfer.get_by(%{transaction_hash: transaction_hash, log_index: log_index}, resolution) diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex index cd9a0889d09..8744edccd29 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Scalars.JSON do end defp decode(%Absinthe.Blueprint.Input.String{value: value}) do - case Jason.decode(value) do + case Utils.JSON.decode(value) do {:ok, result} -> {:ok, result} _ -> :error end @@ -30,5 +30,5 @@ defmodule BlockScoutWeb.GraphQL.Schema.Scalars.JSON do :error end - defp encode(value), do: Jason.encode!(value) + defp encode(value), do: Utils.JSON.encode!(value) end diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex index b23da37c510..729884d6bb6 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex @@ -271,13 +271,13 @@ defmodule BlockScoutWeb.GraphQL.Schema.Types do TransactionSchema.generate() def token_transfer_id_fetcher(%{transaction_hash: transaction_hash, log_index: log_index}, _) do - Jason.encode!(%{transaction_hash: to_string(transaction_hash), log_index: log_index}) + Utils.JSON.encode!(%{transaction_hash: to_string(transaction_hash), log_index: log_index}) end def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) def internal_transaction_id_fetcher(%{transaction_hash: transaction_hash, index: index}, _) do - Jason.encode!(%{transaction_hash: to_string(transaction_hash), index: index}) + Utils.JSON.encode!(%{transaction_hash: to_string(transaction_hash), index: index}) end defp process_complexity(params, child_complexity) do diff --git a/apps/block_scout_web/lib/block_scout_web/legacy_paging_helper.ex b/apps/block_scout_web/lib/block_scout_web/legacy_paging_helper.ex new file mode 100644 index 00000000000..6f1c80e98bd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/legacy_paging_helper.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.LegacyPagingHelper do + @moduledoc """ + Legacy pagination helpers for old UI controllers. + New API v2 code should use BlockScoutWeb.Chain.paginate_list/3,4 instead. + """ + + import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] + + alias BlockScoutWeb.Chain + alias Explorer.PagingOptions + + @page_size 50 + + def split_list_by_page(list_plus_one), do: Enum.split(list_plus_one, @page_size) + + @doc """ + Splits the list into the current page of `page_size - 1` items and the rest. + + `paging_options.page_size` is the requested page size plus one (see + `BlockScoutWeb.Chain.paging_options/1`), so a non-empty rest means there is a + next page. + + ## Parameters + - `list_plus_one`: Items fetched with `paging_options`. + - `paging_options`: Paging options the items were fetched with. + + ## Returns + - `{page, rest}`, where `page` holds the first `page_size - 1` items. + """ + @spec split_list_by_page(list(), PagingOptions.t()) :: {list(), list()} + def split_list_by_page(list_plus_one, %PagingOptions{page_size: page_size_plus_one}), + do: Enum.split(list_plus_one, page_size_plus_one - 1) + + @spec next_page_params(list(), list(), map(), (any() -> map())) :: nil | map() + def next_page_params(next_page, list, params, paging_function \\ &Chain.paging_params/1) + + def next_page_params([], _list, _params, _), do: nil + + def next_page_params(_, list, params, paging_function) do + paging_params = paging_function.(List.last(list)) + string_keys = map_to_string_keys(paging_params) + + params + |> delete_parameters_from_next_page_params() + |> Map.drop(string_keys) + |> Map.merge(paging_params) + end + + defp map_to_string_keys(map) do + map + |> Map.keys() + |> Enum.map(fn + key when is_atom(key) -> Atom.to_string(key) + key -> key + end) + end +end 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 4d588047a9b..5a3b583061b 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 @@ -12,7 +12,9 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do alias Ecto.Association.NotLoaded alias Explorer.{Chain, HttpClient} alias Explorer.Chain.{Data, InternalTransaction, Log, TokenTransfer, Transaction} + alias Explorer.Chain.Token.UIMultiplierChange alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Repo.LockTimeout import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] @@ -25,6 +27,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do @post_timeout :timer.minutes(5) @request_error_msg "Error while sending request to Transaction Interpretation Service" + @lock_timeout_error_msg "Transaction data is temporarily unavailable" @api_true api?: true @items_limit 50 @@ -63,7 +66,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do """ @spec interpret(Transaction.t() | map(), (Transaction.t() -> any()) | (map() -> any())) :: {{:error, :disabled | binary()}, integer()} - | {:error, Jason.DecodeError.t()} + | {:error, term()} | {:ok, any()} def interpret(transaction_or_map, request_builder \\ &prepare_request_body/1) do with {:enabled, true} <- {:enabled, enabled?()}, @@ -71,11 +74,10 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do {:success_transaction, success_transaction_or_user_op?(transaction_or_map)}, {:cache, :no_cached_data} <- {:cache, try_get_cached_value(get_hash(transaction_or_map))} do - url = interpret_url() - - body = request_builder.(transaction_or_map) - - http_post_request(url, body) + case request_builder.(transaction_or_map) do + {:ok, body} -> http_post_request(interpret_url(), body) + {:error, :lock_timeout} -> {{:error, @lock_timeout_error_msg}, 503} + end else {:cache, {:ok, _response} = result} -> result {:success_transaction, false} -> {:ok, nil} @@ -88,7 +90,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do """ @spec interpret_user_operation(map()) :: {{:error, :disabled | binary()}, integer()} - | {:error, Jason.DecodeError.t()} + | {:error, term()} | {:ok, any()} def interpret_user_operation(user_operation) do interpret(user_operation, &prepare_request_body_from_user_op/1) @@ -96,8 +98,11 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do @doc """ Build the request body as for the transaction interpreter POST request. + + Returns `{:error, :lock_timeout}` when the transaction logs can't be read + because the `logs` table is locked, see `Explorer.Repo.LockTimeout`. """ - @spec get_request_body(Transaction.t()) :: map() + @spec get_request_body(Transaction.t()) :: {:ok, map()} | {:error, :lock_timeout} def get_request_body(transaction) do prepare_request_body(transaction) end @@ -107,15 +112,16 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do """ @spec get_user_op_request_body(map()) :: map() def get_user_op_request_body(user_op) do - prepare_request_body_from_user_op(user_op) + {:ok, body} = prepare_request_body_from_user_op(user_op) + body end 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, Utils.JSON.encode!(body), headers, recv_timeout: @post_timeout) do {:ok, %{body: body, status_code: 200}} -> - body |> Jason.decode() |> preload_template_variables() + body |> Utils.JSON.decode() |> preload_template_variables() error -> Logger.error(fn -> @@ -138,7 +144,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp try_get_cached_value(hash) do with {:ok, %{body: body, status_code: 200}} <- HttpClient.get(cache_url(hash)), - {:ok, json} <- body |> Jason.decode() do + {:ok, json} <- body |> Utils.JSON.decode() do {:ok, json} |> preload_template_variables() else _ -> @@ -173,8 +179,13 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do token_transfers = transaction |> fetch_token_transfers() |> Enum.reverse() internal_transactions = transaction |> fetch_internal_transactions() |> Enum.reverse() - logs = transaction |> fetch_logs() |> Enum.reverse() + with {:ok, logs} <- fetch_logs(transaction) do + {:ok, build_request_body(transaction, token_transfers, internal_transactions, Enum.reverse(logs))} + end + end + + defp build_request_body(transaction, token_transfers, internal_transactions, logs) do [transaction_with_meta | other_elements] = ([transaction | token_transfers] ++ internal_transactions ++ logs) |> maybe_preload_metadata() @@ -267,6 +278,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp prepare_token_transfers(token_transfers, decoded_input) do token_transfers + |> UIMultiplierChange.put_ui_multipliers(@api_true) |> Enum.map(&TokenTransferView.prepare_token_transfer(&1, nil, decoded_input)) end @@ -283,18 +295,21 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do |> Enum.map(&InternalTransactionView.prepare_internal_transaction(&1, transaction.block)) end + # The `logs` table may be locked exclusively (e.g. by `VACUUM FULL`); the + # summary request must fail fast in that case instead of hanging and keeping + # a pool connection busy, see `Explorer.Repo.LockTimeout`. defp fetch_logs(transaction) do full_options = [ - necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional - } + address_preloads: [:names, :smart_contract, proxy_implementations_smart_contracts_association()] ] |> Keyword.merge(@api_true) - transaction.hash - |> Chain.transaction_to_logs(full_options) - |> Enum.take(@items_limit) + LockTimeout.run(Chain.select_repo(@api_true), fn _repo -> + transaction.hash + |> Chain.transaction_to_logs(full_options) + |> Enum.take(@items_limit) + end) end defp prepare_logs(logs, transaction) do @@ -308,9 +323,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp user_op_to_logs_and_token_transfers(user_op, decoded_input) do log_options = [ - necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional - }, + address_preloads: [:names, :smart_contract, proxy_implementations_smart_contracts_association()], limit: @items_limit ] |> Keyword.merge(@api_true) @@ -341,6 +354,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do |> TokenTransfer.logs_to_token_transfers(token_transfer_options) |> Chain.flat_1155_batch_token_transfers() |> Enum.take(@items_limit) + |> UIMultiplierChange.put_ui_multipliers(@api_true) |> Enum.map(&TokenTransferView.prepare_token_transfer(&1, nil, decoded_input)) {prepared_logs, prepared_token_transfers} @@ -429,7 +443,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do to_address = Chain.hash_to_address(to_address_hash, []) - %{ + body = %{ data: %{ to: Helper.address_with_info(nil, to_address, to_address_hash, true), from: Helper.address_with_info(nil, from_address, from_address_hash, true), @@ -447,6 +461,8 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do logs_data: %{items: prepared_logs}, chain_id: :block_scout_web |> Application.get_env(:chain_id) |> ExplorerHelper.parse_integer() } + + {:ok, body} end @doc """ 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 bf43d1705f6..e3324fb6c8d 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 @@ -113,7 +113,10 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do |> Enum.reduce(%{}, &token_transfers_to_balances_reducer(&1, &2, previous_block_number, options)) |> StateChange.token_balances_before(transaction, block_transactions) - tokens_entries = StateChange.token_entries(transaction.token_transfers, token_balances_before) + tokens_entries = + transaction.token_transfers + |> StateChange.token_entries(token_balances_before) + |> StateChange.put_ui_multipliers(transaction, options) native_coin_entries ++ tokens_entries end diff --git a/apps/block_scout_web/lib/block_scout_web/paging_helper.ex b/apps/block_scout_web/lib/block_scout_web/paging_helper.ex index 84d39f99f6e..0249d5b27f0 100644 --- a/apps/block_scout_web/lib/block_scout_web/paging_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/paging_helper.ex @@ -7,6 +7,7 @@ defmodule BlockScoutWeb.PagingHelper do import Explorer.Chain, only: [string_to_full_hash: 1] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + import Explorer.PagingOptions, only: [max_page_size: 0] alias BlockScoutWeb.Schemas.API.V2.General alias Explorer.Chain.InternalTransaction.CallType, as: InternalTransactionCallType @@ -18,7 +19,7 @@ defmodule BlockScoutWeb.PagingHelper do @page_size 50 @default_paging_options %PagingOptions{page_size: @page_size + 1} @allowed_filter_labels ["validated", "pending"] - @allowed_base_token_transfer_type_labels ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] + @allowed_base_token_transfer_type_labels ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984", "ERC-8056"] if @chain_type == :zilliqa do @allowed_chain_type_token_transfer_type_labels ["ZRC-2"] else @@ -33,7 +34,13 @@ defmodule BlockScoutWeb.PagingHelper do def allowed_stability_validators_states, do: @allowed_stability_validators_states - def paging_options(%{"block_number" => block_number_string, "index" => index_string}, [:validated | _]) do + def paging_options(params, filter) do + params + |> do_paging_options(filter) + |> maybe_override_page_size(params) + end + + defp do_paging_options(%{"block_number" => block_number_string, "index" => index_string}, [:validated | _]) do with {:ok, block_number} <- Helper.safe_parse_non_negative_integer(block_number_string), {:ok, index} <- Helper.safe_parse_non_negative_integer(index_string) do [paging_options: %{@default_paging_options | key: {block_number, index}}] @@ -43,11 +50,11 @@ defmodule BlockScoutWeb.PagingHelper do end end - def paging_options(%{block_number: block_number, index: index}, [:validated | _]) do + defp do_paging_options(%{block_number: block_number, index: index}, [:validated | _]) do [paging_options: %{@default_paging_options | key: {block_number, index}}] end - def paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}, [:pending | _]) do + defp do_paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}, [:pending | _]) do with {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string), {:ok, hash} <- string_to_full_hash(hash_string) do [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] @@ -57,7 +64,7 @@ defmodule BlockScoutWeb.PagingHelper do end end - def paging_options(%{inserted_at: inserted_at, hash: hash_string}, [:pending | _]) do + defp do_paging_options(%{inserted_at: inserted_at, hash: hash_string}, [:pending | _]) do case string_to_full_hash(hash_string) do {:ok, hash} -> [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] @@ -67,11 +74,11 @@ defmodule BlockScoutWeb.PagingHelper do end end - def paging_options(_params, _filter), do: [paging_options: @default_paging_options] + defp do_paging_options(_params, _filter), do: [paging_options: @default_paging_options] @spec stability_validators_state_options(map()) :: [{:state, list()}, ...] - def stability_validators_state_options(%{"state_filter" => state}) do - [state: filters_to_list(state, @allowed_stability_validators_states, :downcase)] + def stability_validators_state_options(%{state_filter: state}) do + [state: parse_filter(state, @allowed_stability_validators_states)] end def stability_validators_state_options(_), do: [state: []] @@ -106,9 +113,7 @@ defmodule BlockScoutWeb.PagingHelper do def nft_types_options(_), do: [token_type: []] - defp filters_to_list(filters, allowed, variant \\ :upcase) - defp filters_to_list(filters, allowed, :downcase), do: filters |> String.downcase() |> parse_filter(allowed) - defp filters_to_list(filters, allowed, :upcase), do: filters |> String.upcase() |> parse_filter(allowed) + defp filters_to_list(filters, allowed), do: filters |> String.upcase() |> parse_filter(allowed) def filter_options(%{"filter" => filter}, fallback) do filter = filter |> parse_filter(@allowed_filter_labels) |> Enum.map(&String.to_existing_atom/1) @@ -268,12 +273,19 @@ defmodule BlockScoutWeb.PagingHelper do :block_hash_or_number_param, :direction, :transaction_hash_param, + :items_count, :scale, :token_id_param, :token_id, :type, :apikey, + :sort, + :order, + :state_filter, + :key, "apikey", + "items_count", + "key", "block_hash_or_number", "block_hash_or_number_param", "token_id_param", @@ -295,13 +307,6 @@ defmodule BlockScoutWeb.PagingHelper do def delete_parameters_from_next_page_params(_), do: nil - def delete_items_count_from_next_page_params(params) when is_map(params) do - params - |> Map.drop(["items_count"]) - end - - def delete_items_count_from_next_page_params(other), do: other - # todo: it is used in the old UI only, consider removing it later def current_filter(%{"filter" => language_string}) do SmartContract.language_string_to_atom() @@ -397,10 +402,10 @@ defmodule BlockScoutWeb.PagingHelper do defp do_address_transaction_sorting(_, _), do: [] - @spec validators_stability_sorting(%{required(String.t()) => String.t()}) :: [ + @spec validators_stability_sorting(map()) :: [ {:sorting, SortingHelper.sorting_params()} ] - def validators_stability_sorting(%{"sort" => sort_field, "order" => order}) do + def validators_stability_sorting(%{sort: sort_field, order: order}) do [sorting: do_validators_stability_sorting(sort_field, order)] end @@ -415,21 +420,6 @@ defmodule BlockScoutWeb.PagingHelper do defp do_validators_stability_sorting(_, _), do: [] - @spec mud_records_sorting(map()) :: [{:sorting, SortingHelper.sorting_params()}] - def mud_records_sorting(%{sort: sort_field, order: order}) do - [sorting: do_mud_records_sorting(sort_field, order)] - end - - def mud_records_sorting(_), do: [] - - defp do_mud_records_sorting("key_bytes", "asc"), do: [asc_nulls_first: :key_bytes] - defp do_mud_records_sorting("key_bytes", "desc"), do: [desc_nulls_last: :key_bytes] - defp do_mud_records_sorting("key0", "asc"), do: [asc_nulls_first: :key0] - defp do_mud_records_sorting("key0", "desc"), do: [desc_nulls_last: :key0] - defp do_mud_records_sorting("key1", "asc"), do: [asc_nulls_first: :key1] - defp do_mud_records_sorting("key1", "desc"), do: [desc_nulls_last: :key1] - defp do_mud_records_sorting(_, _), do: [] - @spec validators_blackfort_sorting(%{required(String.t()) => String.t()}) :: [ {:sorting, SortingHelper.sorting_params()} ] @@ -532,4 +522,18 @@ defmodule BlockScoutWeb.PagingHelper do {:dynamic, :total_gas_used, :desc_nulls_last, HotSmartContracts.total_gas_used_on_transactions_dynamic()} ] } + + defp maybe_override_page_size(paging_options_keyword, params) do + case params[:items_count] || params["items_count"] do + count when is_integer(count) and count > 0 -> + clamped = min(count, max_page_size()) + + Keyword.update(paging_options_keyword, :paging_options, @default_paging_options, fn paging_options -> + %{paging_options | page_size: clamped + 1} + end) + + _ -> + paging_options_keyword + end + end end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex index a25d443d3a8..a04d48921ad 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex @@ -15,7 +15,7 @@ defmodule BlockScoutWeb.Plug.CheckAccountAPI do else conn |> put_resp_content_type("application/json") - |> send_resp(404, Jason.encode!(%{message: "Account functionality is disabled"})) + |> send_resp(404, Utils.JSON.encode!(%{message: "Account functionality is disabled"})) |> halt() end end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex index 9b320d6d505..bc31698ee49 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex @@ -15,7 +15,7 @@ defmodule BlockScoutWeb.Plug.CheckApiV2 do else conn |> put_resp_content_type("application/json") - |> send_resp(404, Jason.encode!(%{message: "API V2 is disabled"})) + |> send_resp(404, Utils.JSON.encode!(%{message: "API V2 is disabled"})) |> halt() end end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/graphql_context.ex b/apps/block_scout_web/lib/block_scout_web/plug/graphql_context.ex new file mode 100644 index 00000000000..1b5007d57c2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/plug/graphql_context.ex @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Plug.GraphQLContext do + @moduledoc """ + A plug that puts request-derived values into the Absinthe context. + + `Absinthe.Plug` does not expose the `conn` to the schema context, so values + that depend on request headers or cookies (e.g. the scam token toggle) must + be assigned here before the request is forwarded to `Absinthe.Plug`. + """ + alias BlockScoutWeb.Chain + + def init(opts), do: opts + + def call(conn, _opts) do + show_scam_tokens? = + [] + |> Chain.fetch_scam_token_toggle(conn) + |> Keyword.get(:show_scam_tokens?, false) + + Absinthe.Plug.assign_context(conn, :show_scam_tokens?, show_scam_tokens?) + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex b/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex index 249b03637b8..6520785bef9 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Plug.GraphQLSchemaIntrospection do @introspection_json BlockScoutWeb.GraphQL.Schema |> Schema.introspect() |> (case do - {:ok, data} -> Jason.encode!(data) + {:ok, data} -> Utils.JSON.encode!(data) {:error, _} -> raise "Failed to introspect schema" end) diff --git a/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex b/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex index 9e21593dc7a..6f4670658f0 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex @@ -55,10 +55,11 @@ defmodule BlockScoutWeb.Plug.RateLimit do defp set_rate_limit_headers_for_frontend(conn, config) do user_agent = RateLimit.get_user_agent(conn) + recaptcha_disabled = Application.get_env(:block_scout_web, :recaptcha)[:is_disabled] || false option = cond do - config[:recaptcha_to_bypass_429] && user_agent -> "recaptcha" + config[:recaptcha_to_bypass_429] && user_agent && !recaptcha_disabled -> "recaptcha" config[:temporary_token] && user_agent -> "temporary_token" !is_nil(config) -> "no_bypass" true -> "no_bypass" diff --git a/apps/block_scout_web/lib/block_scout_web/rate_limit.ex b/apps/block_scout_web/lib/block_scout_web/rate_limit.ex index 821cd253da6..1f95308c327 100644 --- a/apps/block_scout_web/lib/block_scout_web/rate_limit.ex +++ b/apps/block_scout_web/lib/block_scout_web/rate_limit.ex @@ -80,8 +80,11 @@ defmodule BlockScoutWeb.RateLimit do @spec rate_limit_with_config(Plug.Conn.t(), map()) :: {:allow, -1} | {:deny, integer(), integer(), integer()} | {:allow, integer(), integer(), integer()} def rate_limit_with_config(conn, config) do + recaptcha_disabled = recaptcha_disabled?() + limit_multiplier = limit_multiplier(config, recaptcha_disabled) + config - |> prepare_pipeline(conn) + |> prepare_pipeline(conn, limit_multiplier) |> Enum.reject(&(is_nil(&1) || &1 == false)) |> Enum.reduce_while(nil, fn fun, _acc -> case fun.(conn) do @@ -92,7 +95,7 @@ defmodule BlockScoutWeb.RateLimit do |> maybe_check_recaptcha_response( conn, get_user_agent(conn), - config[:recaptcha_to_bypass_429], + config[:recaptcha_to_bypass_429] && !recaptcha_disabled, config[:bypass_token_scope] ) |> case do @@ -105,7 +108,7 @@ defmodule BlockScoutWeb.RateLimit do end end - defp prepare_pipeline(config, conn) do + defp prepare_pipeline(config, conn, limit_multiplier) do global_config = Application.get_env(:block_scout_web, :api_rate_limit) [ @@ -117,7 +120,8 @@ defmodule BlockScoutWeb.RateLimit do &1, config[:temporary_token], global_config[:temporary_token], - config[:bucket_key_prefix] + config[:bucket_key_prefix], + limit_multiplier )), config[:static_api_key] && (&rate_limit_by_static_api_key( @@ -125,14 +129,16 @@ defmodule BlockScoutWeb.RateLimit do config[:static_api_key], global_config[:static_api_key], global_config, - config[:bucket_key_prefix] + config[:bucket_key_prefix], + limit_multiplier )), config[:account_api_key] && (&rate_limit_by_account_api_key( &1, config[:account_api_key], global_config[:account_api_key], - config[:bucket_key_prefix] + config[:bucket_key_prefix], + limit_multiplier )), config[:whitelisted_ip] && (&rate_limit_by_whitelisted_ip( @@ -140,10 +146,11 @@ defmodule BlockScoutWeb.RateLimit do config[:whitelisted_ip], global_config[:whitelisted_ip], global_config, - config[:bucket_key_prefix] + config[:bucket_key_prefix], + limit_multiplier )), config[:ip] && - (&rate_limit_by_ip(&1, config[:ip], global_config[:ip], config[:bucket_key_prefix])) + (&rate_limit_by_ip(&1, config[:ip], global_config[:ip], config[:bucket_key_prefix], limit_multiplier)) ] end @@ -185,7 +192,10 @@ defmodule BlockScoutWeb.RateLimit do recaptcha_response = get_header_or_nil(conn, "recaptcha-v2-response") recaptcha_v3_response = get_header_or_nil(conn, "recaptcha-v3-response") recaptcha_bypass_token = get_header_or_nil(conn, "recaptcha-bypass-token") - scoped_recaptcha_bypass_token = get_header_or_nil(conn, "scoped-recaptcha-bypass-token") + + scoped_recaptcha_bypass_token = + normalize_blank_to_nil(get_header_or_nil(conn, "scoped-recaptcha-bypass-token")) || + normalize_blank_to_nil(get_query_param_or_nil(conn, "scoped_recaptcha_bypass_token")) cond do recaptcha_response -> @@ -213,6 +223,19 @@ defmodule BlockScoutWeb.RateLimit do end end + defp normalize_blank_to_nil(value) do + case value do + nil -> + nil + + binary when is_binary(binary) -> + case String.trim(binary) do + "" -> nil + trimmed -> trimmed + end + end + end + defp get_header_or_nil(conn, header_name) do case Conn.get_req_header(conn, header_name) do [response] -> @@ -223,21 +246,44 @@ defmodule BlockScoutWeb.RateLimit do end end - defp rate_limit_by_static_api_key(conn, route_config, default_config, global_config, bucket_key_prefix) do + defp get_query_param_or_nil(conn, param_name) do + case Map.get(conn.query_params, param_name) do + value when is_binary(value) -> + value + + _ -> + nil + end + end + + defp rate_limit_by_static_api_key( + conn, + route_config, + default_config, + global_config, + bucket_key_prefix, + limit_multiplier + ) do config = config_or_default(route_config, default_config) static_api_key = global_config[:static_api_key_value] user_api_key = get_api_key(conn) if valid_api_key?(user_api_key) && user_api_key == static_api_key do - rate_limit(static_api_key, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + rate_limit( + static_api_key, + config[:period], + config[:limit] * limit_multiplier, + config[:cost] || 1, + bucket_key_prefix + ) else :skip end end - @spec rate_limit_by_account_api_key(any(), any(), any(), String.t()) :: + @spec rate_limit_by_account_api_key(any(), any(), any(), String.t(), integer()) :: :skip | {:allow, -1} | {:deny, integer(), integer(), integer()} | {:allow, integer(), integer(), integer()} - defp rate_limit_by_account_api_key(conn, route_config, global_config, bucket_key_prefix) do + defp rate_limit_by_account_api_key(conn, route_config, global_config, bucket_key_prefix, limit_multiplier) do config = config_or_default(route_config, global_config) plan = get_plan(conn) @@ -247,7 +293,7 @@ defmodule BlockScoutWeb.RateLimit do rate_limit( api_key, config[:period], - config[:limit] || plan.max_req_per_second, + (config[:limit] || plan.max_req_per_second) * limit_multiplier, config[:cost] || 1, bucket_key_prefix ) @@ -256,34 +302,41 @@ defmodule BlockScoutWeb.RateLimit do end end - defp rate_limit_by_whitelisted_ip(conn, route_config, default_config, global_config, bucket_key_prefix) do + defp rate_limit_by_whitelisted_ip( + conn, + route_config, + default_config, + global_config, + bucket_key_prefix, + limit_multiplier + ) do config = config_or_default(route_config, default_config) ip_string = AccessHelper.conn_to_ip_string(conn) if Enum.member?(whitelisted_ips(global_config), ip_string) do - rate_limit(ip_string, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + rate_limit(ip_string, config[:period], config[:limit] * limit_multiplier, config[:cost] || 1, bucket_key_prefix) else :skip end end - defp rate_limit_by_temporary_token(conn, route_config, default_config, bucket_key_prefix) do + defp rate_limit_by_temporary_token(conn, route_config, default_config, bucket_key_prefix, limit_multiplier) do config = config_or_default(route_config, default_config) ip_string = AccessHelper.conn_to_ip_string(conn) token = get_ui_v2_token(conn, ip_string) if token && !is_nil(get_user_agent(conn)) do - rate_limit(token, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + rate_limit(token, config[:period], config[:limit] * limit_multiplier, config[:cost] || 1, bucket_key_prefix) else :skip end end - defp rate_limit_by_ip(conn, route_config, default_config, bucket_key_prefix) do + defp rate_limit_by_ip(conn, route_config, default_config, bucket_key_prefix, limit_multiplier) do config = config_or_default(route_config, default_config) ip_string = AccessHelper.conn_to_ip_string(conn) - rate_limit(ip_string, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + rate_limit(ip_string, config[:period], config[:limit] * limit_multiplier, config[:cost] || 1, bucket_key_prefix) end @spec config_or_default(any(), any()) :: any() @@ -392,4 +445,18 @@ defmodule BlockScoutWeb.RateLimit do nil end end + + defp limit_multiplier(config, recaptcha_disabled) do + if (config[:recaptcha_to_bypass_429] || config[:temporary_token]) && recaptcha_disabled, + do: recaptcha_disabled_limit_multiplier(), + else: 1 + end + + defp recaptcha_disabled? do + Application.get_env(:block_scout_web, :recaptcha)[:is_disabled] || false + end + + defp recaptcha_disabled_limit_multiplier do + Application.get_env(:block_scout_web, :api_rate_limit)[:recaptcha_disabled_limit_multiplier] + end end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index 709df8da2c4..5c00de845db 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -23,7 +23,7 @@ defmodule BlockScoutWeb.Router do length: 100_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :block_scout_web) @@ -41,7 +41,7 @@ defmodule BlockScoutWeb.Router do length: 20_000_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api) @@ -53,18 +53,20 @@ defmodule BlockScoutWeb.Router do plug( Plug.Parsers, parsers: [:json, Absinthe.Plug.Parser], - json_decoder: Poison, + json_decoder: JSON, body_reader: {BlockScoutWeb.GraphQL.BodyReader, :read_body, []} ) plug(BlockScoutWeb.Plug.Logger, application: :api) plug(:accepts, ["json"]) plug(BlockScoutWeb.Plug.RateLimit) + plug(:fetch_cookies) + plug(BlockScoutWeb.Plug.GraphQLContext) end pipeline :rate_limit do plug(:fetch_query_params) - plug(:accepts, ["json"]) + plug(:accepts, ["json", "csv"]) plug(BlockScoutWeb.Plug.RateLimit) end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex index 00e09441ad5..01de7f2b25e 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex @@ -24,7 +24,7 @@ defmodule BlockScoutWeb.Routers.AccountRouter do length: 100_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :block_scout_web) @@ -44,7 +44,7 @@ defmodule BlockScoutWeb.Routers.AccountRouter do length: 100_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -64,7 +64,7 @@ defmodule BlockScoutWeb.Routers.AccountRouter do length: 20_000_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex index 72e5bec1d4b..d3a6c95bbc5 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do parsers: [:urlencoded, :multipart, :json], query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -33,7 +33,7 @@ defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do length: 20_000_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex index b9e3d72e344..44546641b40 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.Routers.AdminRouter do length: 10_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(:accepts, ["html"]) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex index b06f13a4c57..4381ed82346 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex @@ -13,7 +13,7 @@ defmodule BlockScoutWeb.Routers.APIKeyV2Router do length: 10_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(Logger, application: :api_v2) 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 95f20f7b514..3564773090b 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 @@ -23,11 +23,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do graphql_max_complexity: [:block_scout_web, [Api.GraphQL, :max_complexity]], graphql_token_limit: [:block_scout_web, [Api.GraphQL, :token_limit]], reading_enabled: [:block_scout_web, [__MODULE__, :reading_enabled]], - writing_enabled: [:block_scout_web, [__MODULE__, :writing_enabled]], - mud_enabled_compile_time?: [:explorer, [Explorer.Chain.Mud, :enabled]] - - use Utils.RuntimeEnvHelper, - mud_enabled?: [:explorer, [Explorer.Chain.Mud, :enabled]] + writing_enabled: [:block_scout_web, [__MODULE__, :writing_enabled]] alias BlockScoutWeb.Routers.{ AccountRouter, @@ -38,7 +34,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do UtilsApiV2Router } - alias BlockScoutWeb.Plug.{CheckApiV2, CheckFeature} + alias BlockScoutWeb.Plug.CheckApiV2 @max_query_string_length 5_000 @@ -56,7 +52,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do length: 20_000_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api) @@ -70,7 +66,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do parsers: [:urlencoded, :multipart, :json], query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -81,35 +77,55 @@ defmodule BlockScoutWeb.Routers.ApiRouter do plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end + pipeline :api_v2_csv do + plug( + Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + query_string_length: @max_query_string_length, + pass: ["*/*"], + json_decoder: JSON + ) + + plug(BlockScoutWeb.Plug.Logger, application: :api_v2) + plug(:accepts, ["json", "csv"]) + # CSV responses set their content type explicitly; keep JSON as the render + # format so error responses (404/422/403) render their JSON templates even + # when the client sends `Accept: application/csv`. + plug(:put_format, "json") + plug(CheckApiV2) + plug(:fetch_session) + plug(:protect_from_forgery) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) + end + pipeline :api_v2_no_session do plug( Plug.Parsers, parsers: [:urlencoded, :multipart, :json], query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end pipeline :api_v1_graphql do plug( Plug.Parsers, parsers: [:json, Absinthe.Plug.Parser], - json_decoder: Poison, + json_decoder: JSON, body_reader: {BlockScoutWeb.GraphQL.BodyReader, :read_body, []} ) plug(BlockScoutWeb.Plug.Logger, application: :api) plug(:accepts, ["json"]) plug(BlockScoutWeb.Plug.GraphQLSchemaIntrospection) - end - - pipeline :mud do - plug(CheckFeature, feature_check: &mud_enabled?/0) + plug(:fetch_cookies) + plug(BlockScoutWeb.Plug.GraphQLContext) end alias BlockScoutWeb.API.{Legacy, V2} @@ -246,13 +262,9 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/:address_hash_param/token-balances", V2.AddressController, :token_balances) get("/:address_hash_param/tokens", V2.AddressController, :tokens) get("/:address_hash_param/transactions", V2.AddressController, :transactions) - get("/:address_hash_param/transactions/csv", V2.CsvExportController, :transactions_csv) get("/:address_hash_param/token-transfers", V2.AddressController, :token_transfers) - get("/:address_hash_param/token-transfers/csv", V2.CsvExportController, :token_transfers_csv) get("/:address_hash_param/internal-transactions", V2.AddressController, :internal_transactions) - get("/:address_hash_param/internal-transactions/csv", V2.CsvExportController, :internal_transactions_csv) get("/:address_hash_param/logs", V2.AddressController, :logs) - get("/:address_hash_param/logs/csv", V2.CsvExportController, :logs_csv) get("/:address_hash_param/blocks-validated", V2.AddressController, :blocks_validated) get("/:address_hash_param/coin-balance-history", V2.AddressController, :coin_balance_history) get("/:address_hash_param/coin-balance-history-by-day", V2.AddressController, :coin_balance_history_by_day) @@ -262,7 +274,6 @@ defmodule BlockScoutWeb.Routers.ApiRouter do if @chain_identity == {:optimism, :celo} do get("/:address_hash_param/celo/election-rewards", V2.AddressController, :celo_election_rewards) - get("/:address_hash_param/celo/election-rewards/csv", V2.CsvExportController, :celo_election_rewards_csv) end if @chain_type == :ethereum do @@ -440,22 +451,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do if @chain_type == :zksync do get("/batches", V2.ZkSyncController, :batches) get("/batches/count", V2.ZkSyncController, :batches_count) - get("/batches/:batch_number", V2.ZkSyncController, :batch) - end - end - - if @mud_enabled_compile_time? do - scope "/mud" do - pipe_through(:mud) - get("/worlds", V2.MudController, :worlds) - get("/worlds/count", V2.MudController, :worlds_count) - get("/worlds/:world/tables", V2.MudController, :world_tables) - get("/worlds/:world/systems", V2.MudController, :world_systems) - get("/worlds/:world/systems/:system", V2.MudController, :world_system) - get("/worlds/:world/tables/count", V2.MudController, :world_tables_count) - get("/worlds/:world/tables/:table_id/records", V2.MudController, :world_table_records) - get("/worlds/:world/tables/:table_id/records/count", V2.MudController, :world_table_records_count) - get("/worlds/:world/tables/:table_id/records/:record_id", V2.MudController, :world_table_record) + get("/batches/:batch_number_param", V2.ZkSyncController, :batch) end end @@ -481,21 +477,45 @@ defmodule BlockScoutWeb.Routers.ApiRouter do scope "/advanced-filters" do get("/", V2.AdvancedFilterController, :list) - get("/csv", V2.AdvancedFilterController, :list_csv) get("/methods", V2.AdvancedFilterController, :list_methods) end end - scope "/legacy" do - pipe_through(:api_v2) + scope "/v2", as: :api_v2 do + pipe_through(:api_v2_csv) + + get("/addresses/:address_hash_param/transactions/csv", V2.CsvExportController, :transactions_csv) + get("/addresses/:address_hash_param/token-transfers/csv", V2.CsvExportController, :token_transfers_csv) + get("/addresses/:address_hash_param/internal-transactions/csv", V2.CsvExportController, :internal_transactions_csv) + get("/addresses/:address_hash_param/logs/csv", V2.CsvExportController, :logs_csv) - scope "/logs" do - get("/get-logs", Legacy.LogsController, :get_logs) + if @chain_identity == {:optimism, :celo} do + get( + "/addresses/:address_hash_param/celo/election-rewards/csv", + V2.CsvExportController, + :celo_election_rewards_csv + ) end - scope "/block" do - get("/get-block-number-by-time", Legacy.BlockController, :get_block_number_by_time) - get("/eth-block-number", Legacy.BlockController, :eth_block_number) + get("/advanced-filters/csv", V2.AdvancedFilterController, :list_csv) + end + + if @reading_enabled do + scope "/legacy" do + pipe_through(:api_v2_no_session) + + scope "/block" do + get("/get-block-number-by-time", Legacy.BlockController, :get_block_number_by_time) + end + + scope "/eth" do + post("/eth-call", Legacy.EthController, :eth_call) + post("/eth-get-balance", Legacy.EthController, :eth_get_balance) + post("/eth-get-storage-at", Legacy.EthController, :eth_get_storage_at) + post("/eth-send-raw-transaction", Legacy.EthController, :eth_send_raw_transaction) + post("/eth-block-number", Legacy.EthController, :eth_block_number) + post("/eth-get-logs", Legacy.EthController, :eth_get_logs) + end end end @@ -519,7 +539,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do alias BlockScoutWeb.API.V2.SearchController # leave the same endpoint in v1 in order to keep backward compatibility - get("/search", SearchController, :search) + get("/search", SearchController, :search_v1_compatibility) get("/gas-price-oracle", GasPriceOracleController, :gas_price_oracle) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex index 6de2a9d0e8e..842dc103aaf 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex @@ -18,7 +18,7 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do parsers: [:urlencoded, :multipart, :json], query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -36,7 +36,7 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do length: 20_000_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex index fcdaaa391e6..2f1c54d534f 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex @@ -18,7 +18,7 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do parsers: [:urlencoded, :multipart, :json], query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -29,6 +29,27 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end + pipeline :api_v2_csv do + plug( + Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + query_string_length: @max_query_string_length, + pass: ["*/*"], + json_decoder: JSON + ) + + plug(BlockScoutWeb.Plug.Logger, application: :api_v2) + plug(:accepts, ["json", "csv"]) + # CSV responses set their content type explicitly; keep JSON as the render + # format so error responses (404/422/403) render their JSON templates even + # when the client sends `Accept: application/csv`. + plug(:put_format, "json") + plug(CheckApiV2) + plug(:fetch_session) + plug(:protect_from_forgery) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) + end + pipeline :api_v2_no_forgery_protect do plug( Plug.Parsers, @@ -36,7 +57,7 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do length: 20_000_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) @@ -56,6 +77,8 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do V2.TokenController, :trigger_nft_collection_metadata_refetch ) + + post("/batch", V2.TokenController, :tokens_batch) end scope "/", as: :api_v2 do @@ -70,9 +93,10 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do get("/:address_hash_param/counters", V2.TokenController, :counters) get("/:address_hash_param/transfers", V2.TokenController, :transfers) get("/:address_hash_param/holders", V2.TokenController, :holders) - get("/:address_hash_param/holders/csv", V2.CsvExportController, :export_token_holders) + get("/:address_hash_param/ui-multiplier-changes", V2.TokenController, :ui_multiplier_changes) get("/:address_hash_param/instances", V2.TokenController, :instances) get("/:address_hash_param/instances/:token_id_param", V2.TokenController, :instance) + get("/:address_hash_param/instances/:token_id_param/media-type", V2.TokenController, :media_type) get("/:address_hash_param/instances/:token_id_param/transfers", V2.TokenController, :transfers_by_instance) get("/:address_hash_param/instances/:token_id_param/holders", V2.TokenController, :holders_by_instance) @@ -82,4 +106,11 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do :transfers_count_by_instance ) end + + scope "/", as: :api_v2 do + pipe_through(:api_v2_csv) + + get("/:address_hash_param/holders/csv", V2.CsvExportController, :export_token_holders) + get("/:address_hash_param/transfers/csv", V2.CsvExportController, :export_token_transfers) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex index 5f7adcf645a..ba447cf8289 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Routers.UtilsApiV2Router do length: 100_000, query_string_length: 5_000, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :api_v2) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex index 95158d98af0..5391257d1f2 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex @@ -17,7 +17,7 @@ defmodule BlockScoutWeb.Routers.WebRouter do length: 20_000_000, query_string_length: @max_query_string_length, pass: ["*/*"], - json_decoder: Poison + json_decoder: JSON ) plug(BlockScoutWeb.Plug.Logger, application: :block_scout_web) diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex index e482025dbd3..1cc7828eaa6 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex @@ -8,7 +8,7 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.Envelope do Returns an OpenAPI schema for the RPC response envelope: `{"status": "0"|"1"|"2", "message": "...", "result": <result_schema>}`. """ - @spec rpc_envelope(Schema.t()) :: Schema.t() + @spec rpc_envelope(module()) :: Schema.t() def rpc_envelope(result_schema) do %Schema{ type: :object, @@ -24,11 +24,7 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.Envelope do "Human-readable status string — `OK` on success, " <> "a descriptive error message otherwise." }, - result: %Schema{ - description: "Endpoint-specific payload on success; `null` on error.", - nullable: true, - allOf: [result_schema] - } + result: result_schema }, required: [:status, :message, :result], additionalProperties: false @@ -36,32 +32,84 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.Envelope do end @doc """ - Returns an OpenAPI schema for the JSON-RPC 2.0 envelope: - `{"jsonrpc": "2.0", "result": <result_schema>, "id": <integer|string>}`. + Returns an OpenAPI schema for the JSON-RPC 2.0 response envelope, covering + both success and error shapes: + + - success: `{"jsonrpc": "2.0", "result": <result_schema>, "id": <integer|string>}` + - error: `{"jsonrpc": "2.0", "error": <string|object>, "id": <integer|string>}` + + Modelled as `oneOf` because the two shapes are mutually exclusive — the + `EthRPCView` encoder emits `result` or `error`, never both. """ - @spec eth_rpc_envelope(Schema.t()) :: Schema.t() + @spec eth_rpc_envelope(module()) :: Schema.t() def eth_rpc_envelope(result_schema) do + %Schema{ + description: "JSON-RPC 2.0 response envelope — `result` on success, `error` on failure.", + oneOf: [ + eth_rpc_success_envelope(result_schema), + eth_rpc_error_envelope() + ] + } + end + + @spec eth_rpc_success_envelope(module()) :: Schema.t() + defp eth_rpc_success_envelope(result_schema) do %Schema{ type: :object, properties: %{ - jsonrpc: %Schema{ - type: :string, - enum: ["2.0"], - description: "JSON-RPC protocol version, always `2.0`." - }, - result: %Schema{ - description: "Endpoint-specific payload.", - allOf: [result_schema] - }, - id: %Schema{ - anyOf: [%Schema{type: :integer}, %Schema{type: :string}], - description: - "Echoes the request id. When the client omits it, " <> - "the server echoes integer `1`." - } + jsonrpc: jsonrpc_property(), + result: result_schema, + id: id_property() }, required: [:jsonrpc, :result, :id], additionalProperties: false } end + + @spec eth_rpc_error_envelope() :: Schema.t() + defp eth_rpc_error_envelope do + %Schema{ + type: :object, + properties: %{ + jsonrpc: jsonrpc_property(), + error: %Schema{ + description: "Error description — a human-readable string or a JSON-RPC error object.", + anyOf: [ + %Schema{type: :string}, + %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :integer, description: "JSON-RPC error code."}, + message: %Schema{type: :string, description: "Human-readable error description."} + }, + required: [:code, :message], + additionalProperties: false + } + ] + }, + id: id_property() + }, + required: [:jsonrpc, :error, :id], + additionalProperties: false + } + end + + defp jsonrpc_property do + %Schema{ + type: :string, + enum: ["2.0"], + description: "JSON-RPC protocol version, always `2.0`." + } + end + + defp id_property do + %Schema{ + anyOf: [%Schema{type: :integer}, %Schema{type: :string}], + description: + "Echoes the request id as an integer or string. " <> + "If the client sent `id: null`, the echo is coerced to an empty string. " <> + "When the client omits `id` entirely, the response is always the error variant " <> + "of this envelope with integer `0`." + } + end end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex index 32e7ee60d8b..53904cc03bd 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex @@ -3,6 +3,8 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.EthBlockNumberResult do @moduledoc false require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + # nullable: true is kept defensively. In practice, BlockNumber.get_max/0 # delegates to Block.fetch_max_block_number/0 which returns Repo.one(query) || 0 # (with a rescue that also returns 0), so the v1 code path never produces a null @@ -10,7 +12,7 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.EthBlockNumberResult do # implementation ever changes. OpenApiSpex.schema(%{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), nullable: true, description: "Hex-encoded latest block number on the chain. " <> diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/block_tag.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/block_tag.ex new file mode 100644 index 00000000000..2dd1ad9f252 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/block_tag.ex @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.BlockTag do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + description: + "Block parameter: a hex-encoded block number (`0x` prefix) or one of the " <> + "tags `latest`, `earliest`, `pending`.", + anyOf: [ + %OpenApiSpex.Schema{type: :string, pattern: General.hex_quantity_pattern()}, + %OpenApiSpex.Schema{type: :string, enum: ["latest", "earliest", "pending"]} + ] + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_call_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_call_result.ex new file mode 100644 index 00000000000..a47a5f5e3bc --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_call_result.ex @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.EthCallResult do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :string, + pattern: General.hex_data_pattern(), + description: "Hex-encoded return data from the executed call. May be `0x` if the call returned no data." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_balance_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_balance_result.ex new file mode 100644 index 00000000000..43a55c7f018 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_balance_result.ex @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.EthGetBalanceResult do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :string, + pattern: General.hex_quantity_pattern(), + description: "Hex-encoded account balance in wei. Always at least one digit (`0x0` for zero)." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_logs_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_logs_result.ex new file mode 100644 index 00000000000..896ff6e404c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_logs_result.ex @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.EthGetLogsResult do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + # Shape returned by Explorer.EthRPC.render_log/1 — differs from the RPC-envelope + # LogItem (which includes timeStamp, gasPrice, gasUsed instead of blockHash/removed). + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + address: General.AddressHash, + blockHash: General.FullHash, + blockNumber: %Schema{ + type: :string, + pattern: General.hex_quantity_pattern(), + description: "Hex-encoded block number." + }, + data: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + description: "Hex-encoded event data payload." + }, + logIndex: %Schema{ + type: :string, + pattern: General.hex_quantity_pattern(), + description: "Hex-encoded position of the log within the block." + }, + removed: %Schema{ + type: :boolean, + description: "`true` if the log was removed due to a chain reorganization." + }, + topics: %Schema{ + type: :array, + description: "Indexed event topics. Up to four 32-byte hex strings; unused slots are `null`.", + items: General.FullHashNullable + }, + transactionHash: General.FullHash, + transactionIndex: %Schema{ + type: :string, + pattern: General.hex_quantity_pattern(), + description: "Hex-encoded position of the transaction within the block." + } + }, + required: [ + :address, + :blockHash, + :blockNumber, + :data, + :logIndex, + :removed, + :topics, + :transactionHash, + :transactionIndex + ] + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_storage_at_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_storage_at_result.ex new file mode 100644 index 00000000000..bbee9d1deeb --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_get_storage_at_result.ex @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.EthGetStorageAtResult do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :string, + pattern: General.full_hash_pattern(), + description: "Hex-encoded 32-byte storage value at the requested slot." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_send_raw_transaction_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_send_raw_transaction_result.ex new file mode 100644 index 00000000000..6a481009632 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/eth_send_raw_transaction_result.ex @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.EthSendRawTransactionResult do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :string, + pattern: General.full_hash_pattern(), + description: "Hash of the submitted transaction — 32 bytes, hex-encoded.", + example: "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331e" + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/requests.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/requests.ex new file mode 100644 index 00000000000..465f5f7ed79 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_rpc/requests.ex @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthRpc.Requests do + @moduledoc """ + Request-body schemas for `/api/legacy/eth/*` endpoints. + + Each helper returns an `OpenApiSpex.RequestBody` shaped as a single JSON-RPC 2.0 + object (never an array) with `method` pinned to one literal value via `enum`. + Batch requests are out of scope for these endpoints. + """ + + alias BlockScoutWeb.Schemas.API.Legacy.EthRpc.BlockTag + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.{MediaType, RequestBody, Schema} + + @id_schema %Schema{ + description: "JSON-RPC request id. Echoed back in the response.", + anyOf: [%Schema{type: :integer}, %Schema{type: :string}], + nullable: true + } + + @jsonrpc_schema %Schema{ + type: :string, + enum: ["2.0"], + description: "JSON-RPC protocol version, always `2.0`." + } + + # `additionalProperties: false` is intentionally NOT set on this schema. + # The Blockscout-side `eth_call_validator/1` only inspects the six fields + # listed below; any additional EVM call-object fields (e.g. `nonce`, `data`, + # `chainId`, `type`, `accessList`, `maxFeePerGas`, `maxPriorityFeePerGas`, + # `blobVersionedHashes`) pass validation untouched and are relayed verbatim + # to the upstream JSON-RPC node by `Explorer.EthRPC.json_rpc/1`, where they + # do influence execution. They are deliberately omitted from `properties:` + # because Blockscout itself does not process them — listing them would + # falsely imply Blockscout-side schema enforcement that does not exist. + # Closing the object with `additionalProperties: false` would break this + # passthrough by rejecting valid Ethereum call objects at the schema layer. + @eth_call_object_schema %Schema{ + type: :object, + description: + "Transaction call object — at minimum `to` is required. " <> + "Additional EVM call-object fields (`nonce`, `data`, `chainId`, `type`, " <> + "`accessList`, `maxFeePerGas`, `maxPriorityFeePerGas`, `blobVersionedHashes`) " <> + "are forwarded verbatim to the upstream JSON-RPC node and are not enforced here.", + properties: %{ + to: Helper.describe_inline(General.AddressHash.schema(), "Target contract address."), + from: + Helper.describe_inline( + General.AddressHash.schema(), + "Caller address used as `msg.sender` during execution. Optional." + ), + gas: Helper.describe_inline(General.HexQuantity.schema(), "Hex-encoded gas limit."), + gasPrice: Helper.describe_inline(General.HexQuantity.schema(), "Hex-encoded gas price in wei."), + value: Helper.describe_inline(General.HexQuantity.schema(), "Hex-encoded value sent with the call, in wei."), + input: Helper.describe_inline(General.HexData.schema(), "Hex-encoded calldata.") + }, + required: [:to] + } + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-call`. + """ + @spec eth_call() :: RequestBody.t() + def eth_call do + json_rpc_body( + method: "eth_call", + params_schema: %Schema{ + type: :array, + minItems: 2, + maxItems: 2, + items: %Schema{anyOf: [@eth_call_object_schema, BlockTag]}, + description: + "Two-element array `[<call object>, <block tag>]`: " <> + "the call object specifies the target contract and call data; " <> + "the block tag selects the chain state to execute against." + }, + example: %{ + "jsonrpc" => "2.0", + "id" => 0, + "method" => "eth_call", + "params" => [ + %{ + "to" => "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F", + "input" => "0xd4aae0c4", + "from" => "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + }, + "latest" + ] + } + ) + end + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-get-balance`. + """ + @spec eth_get_balance() :: RequestBody.t() + def eth_get_balance do + json_rpc_body( + method: "eth_getBalance", + params_schema: %Schema{ + type: :array, + minItems: 1, + maxItems: 2, + items: %Schema{anyOf: [General.AddressHash, BlockTag]}, + description: + "One- or two-element array `[<address>]` or `[<address>, <block tag>]`: " <> + "the address is the account to query; " <> + "the block tag selects the chain state to read from and defaults to `\"latest\"` when omitted." + }, + example: %{ + "jsonrpc" => "2.0", + "id" => 0, + "method" => "eth_getBalance", + "params" => ["0x0000000000000000000000000000000000000007", "latest"] + } + ) + end + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-get-storage-at`. + """ + @spec eth_get_storage_at() :: RequestBody.t() + def eth_get_storage_at do + json_rpc_body( + method: "eth_getStorageAt", + params_schema: %Schema{ + type: :array, + minItems: 3, + maxItems: 3, + items: %Schema{ + anyOf: [ + General.AddressHash, + General.HexQuantity, + BlockTag + ] + }, + description: + "Three-element array `[<address>, <storage position (hex)>, <block tag>]`: " <> + "the address is the contract to query; " <> + "the storage position is a hex-encoded slot index; " <> + "the block tag selects the chain state to read from." + }, + example: %{ + "jsonrpc" => "2.0", + "id" => 4, + "method" => "eth_getStorageAt", + "params" => ["0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F", "0x0", "latest"] + } + ) + end + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-send-raw-transaction`. + """ + @spec eth_send_raw_transaction() :: RequestBody.t() + def eth_send_raw_transaction do + json_rpc_body( + method: "eth_sendRawTransaction", + params_schema: %Schema{ + type: :array, + minItems: 1, + maxItems: 1, + description: + "Single-element array `[<signed transaction data>]`: " <> + "the signed transaction data is the RLP-encoded transaction bytes, hex-encoded with a `0x` prefix.", + items: %Schema{ + type: :string, + pattern: General.hex_quantity_pattern(), + description: "Hex-encoded signed transaction bytes." + } + }, + example: %{ + "jsonrpc" => "2.0", + "id" => 0, + "method" => "eth_sendRawTransaction", + "params" => ["0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675"] + } + ) + end + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-block-number`. + """ + @spec eth_block_number() :: RequestBody.t() + def eth_block_number do + method = "eth_blockNumber" + + %RequestBody{ + required: true, + description: "JSON-RPC 2.0 request body. `method` must be `#{method}`. No `params` field is needed.", + content: %{ + "application/json" => %MediaType{ + schema: %Schema{ + type: :object, + properties: %{ + jsonrpc: @jsonrpc_schema, + id: @id_schema, + method: %Schema{ + type: :string, + enum: [method], + description: "JSON-RPC method name. Must be `#{method}` for this endpoint." + } + }, + required: [:jsonrpc, :method, :id], + example: %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => method + } + } + } + } + } + end + + @doc """ + Request body schema for `POST /api/legacy/eth/eth-get-logs`. + """ + @spec eth_get_logs() :: RequestBody.t() + def eth_get_logs do + json_rpc_body( + method: "eth_getLogs", + params_schema: %Schema{ + type: :array, + minItems: 1, + maxItems: 1, + description: "Single-element array containing a filter object.", + items: %Schema{ + type: :object, + description: + "Log filter object. At least one of `address`, `topics` must be provided " <> + "alongside `fromBlock` and `toBlock`.", + properties: %{ + fromBlock: Helper.describe_inline(BlockTag.schema(), "Start of the block range to search."), + toBlock: Helper.describe_inline(BlockTag.schema(), "End of the block range to search."), + address: + Helper.describe_inline( + General.AddressHash.schema(), + "Contract address to filter logs by. Optional." + ), + topics: %Schema{ + type: :array, + description: + "Up to four topic filters. Each element is either a 32-byte hex topic string, " <> + "an array of topic strings (OR semantics), or `null` to match any value in that position.", + items: %Schema{ + nullable: true, + anyOf: [ + General.FullHash, + %Schema{type: :array, items: General.FullHash} + ] + } + } + } + } + }, + example: %{ + "jsonrpc" => "2.0", + "id" => 0, + "method" => "eth_getLogs", + "params" => [ + %{ + "fromBlock" => "0x5", + "toBlock" => "0xa", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + "topics" => ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"] + } + ] + } + ) + end + + defp json_rpc_body(opts) do + method = Keyword.fetch!(opts, :method) + params_schema = Keyword.fetch!(opts, :params_schema) + example = Keyword.fetch!(opts, :example) + + %RequestBody{ + required: true, + description: "JSON-RPC 2.0 request body. `method` must be `#{method}`.", + content: %{ + "application/json" => %MediaType{ + # `additionalProperties: false` is intentionally NOT set on this + # outer JSON-RPC envelope. The dispatch chain + # (`BlockScoutWeb.API.Legacy.EthController.dispatch/3` → + # `BlockScoutWeb.API.EthRPC.EthController.eth_request/2` → + # `Explorer.EthRPC.responses/1`) does not reject unknown top-level + # keys; closing the envelope here would misrepresent the runtime. + schema: %Schema{ + type: :object, + properties: %{ + jsonrpc: @jsonrpc_schema, + id: @id_schema, + method: %Schema{ + type: :string, + enum: [method], + description: "JSON-RPC method name. Must be `#{method}` for this endpoint." + }, + params: params_schema + }, + required: [:jsonrpc, :method, :params, :id], + example: example + } + } + } + } + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex index 41115903a95..bffbd2011fa 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex @@ -3,15 +3,13 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.GetBlockNumberByTimeResult do @moduledoc false require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.Schema + alias BlockScoutWeb.Schemas.Helper OpenApiSpex.schema(%{ + description: "Block number closest to the requested timestamp; `null` if the lookup fails.", type: :object, properties: %{ - blockNumber: %Schema{ - description: "Decimal-string block number.", - allOf: [General.IntegerString] - } + blockNumber: Helper.describe_inline(General.IntegerString.schema(), "Decimal-string block number.") }, required: [:blockNumber], additionalProperties: false, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex index ac578083497..931b0ca217f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex @@ -9,54 +9,50 @@ defmodule BlockScoutWeb.Schemas.API.Legacy.LogItem do type: :object, properties: %{ address: General.AddressHash, - # Each topic slot is nullable: LogsView.get_topics/1 at logs_view.ex:31-38 - # always returns a 4-element list, filling unset slots with nil. A non- - # nullable items schema would fail validation on any real log with <4 topics. + # Each topic slot is nullable: RPC.LogsView.get_topics/1 always returns a + # 4-element list, filling unset slots with nil. A non-nullable items schema + # would fail validation on any real log with <4 topics. topics: %Schema{ type: :array, minItems: 4, maxItems: 4, description: "32-byte indexed event topics. Always a 4-element array; unfilled slots are `null`.", - items: %Schema{ - type: :string, - pattern: ~r/^0x[0-9a-fA-F]{64}$/, - nullable: true - } + items: General.FullHashNullable }, data: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]*$/, + pattern: General.hex_data_pattern(), description: "Hex-encoded event data payload (`0x`-prefixed, arbitrary length)." }, blockNumber: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded block number." }, timeStamp: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded Unix timestamp in seconds of the block." }, gasPrice: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded gas price in wei." }, gasUsed: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded gas used by the transaction." }, logIndex: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded position of the log within the block." }, transactionHash: General.FullHash, transactionIndex: %Schema{ type: :string, - pattern: ~r/^0x[0-9a-fA-F]+$/, + pattern: General.hex_quantity_pattern(), description: "Hex-encoded position of the transaction within the block." } }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex index 81d8c6c3ed8..bfa69898499 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex @@ -3,9 +3,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations do @moduledoc false require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General alias BlockScoutWeb.Schemas.Helper - alias Ecto.Enum, as: EctoEnum - alias Explorer.Chain.Address alias OpenApiSpex.Schema @filecoin_robust_address_schema %Schema{ @@ -38,9 +37,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations do }, robust: @filecoin_robust_address_schema, actor_type: %Schema{ - type: :string, + allOf: [General.FilecoinActorType], description: "Type of actor associated with the Filecoin address", - enum: EctoEnum.values(Address, :filecoin_actor_type), nullable: true } }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex index aef8224b802..2872060b8f3 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex @@ -5,23 +5,47 @@ defmodule BlockScoutWeb.Schemas.API.V2.Address.TabsCounters do """ require OpenApiSpex + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + alias OpenApiSpex.Schema + @base_properties %{ + transactions_count: %Schema{type: :integer, nullable: false}, + token_transfers_count: %Schema{type: :integer, nullable: false}, + token_balances_count: %Schema{type: :integer, nullable: false}, + logs_count: %Schema{type: :integer, nullable: false}, + withdrawals_count: %Schema{type: :integer, nullable: false}, + internal_transactions_count: %Schema{type: :integer, nullable: false}, + validations_count: %Schema{type: :integer, nullable: false} + } + + case @chain_identity do + {:optimism, :celo} -> + @chain_identity_properties %{celo_election_rewards_count: %Schema{type: :integer, nullable: false}} + + _ -> + @chain_identity_properties %{} + end + + case @chain_type do + :ethereum -> + @chain_type_properties %{beacon_deposits_count: %Schema{type: :integer, nullable: false}} + + _ -> + @chain_type_properties %{} + end + + @properties @base_properties + |> Map.merge(@chain_identity_properties) + |> Map.merge(@chain_type_properties) + OpenApiSpex.schema(%{ title: "AddressTabsCounters", description: "Counters for address tabs", type: :object, - properties: %{ - transactions_count: %Schema{type: :integer, nullable: false}, - token_transfers_count: %Schema{type: :integer, nullable: false}, - token_balances_count: %Schema{type: :integer, nullable: false}, - logs_count: %Schema{type: :integer, nullable: false}, - withdrawals_count: %Schema{type: :integer, nullable: false}, - internal_transactions_count: %Schema{type: :integer, nullable: false}, - validations_count: %Schema{type: :integer, nullable: false}, - celo_election_rewards_count: %Schema{type: :integer, nullable: false}, - beacon_deposits_count: %Schema{type: :integer, nullable: false} - }, + properties: @properties, additionalProperties: false }) end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex index 90238b06d5b..793dd486f19 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex @@ -31,7 +31,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter do "ERC-721", "ERC-1155", "ERC-404", - "ERC-7984" + "ERC-7984", + "ERC-8056" ], description: "Kind of activity represented by the item. Values `coin_transfer`, `contract_interaction`, and " <> @@ -73,7 +74,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter do anyOf: [ TokenTransfer.TotalERC721, TokenTransfer.TotalERC1155, - TokenTransfer.TotalERC7984, TokenTransfer.Total ], description: @@ -86,8 +86,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter do description: "Token contract metadata. Populated only for token-transfer items; `null` otherwise." }, timestamp: - Helper.extend_schema(General.Timestamp.schema(), - description: "Block timestamp of the parent transaction." + Helper.describe_inline( + General.Timestamp.schema(), + "Block timestamp of the parent transaction." ), block_number: %Schema{ type: :integer, @@ -214,8 +215,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.Response do "transaction_index" => 1, "internal_transaction_index" => nil, "token_transfer_index" => 0, - "token_transfer_batch_index" => nil, - "items_count" => 50 + "token_transfer_batch_index" => nil } } }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex index 473af454e34..d8546b599e0 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex @@ -8,9 +8,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch do alias BlockScoutWeb.Schemas.API.V2.Arbitrum.CommitmentTransaction alias BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumBatch", description: "Detailed Arbitrum batch info.", type: :object, properties: %{ @@ -30,18 +32,18 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch do minimum: 0, description: "Last Rollup block included in the batch." }, - before_acc_hash: %Schema{ - allOf: [General.FullHash], - description: + before_acc_hash: + Helper.describe_inline( + General.FullHash.schema(), "Accumulator hash of the sequencer inbox before this batch was appended. " <> "Forms a hash chain: must equal `after_acc_hash` of the previous batch." - }, - after_acc_hash: %Schema{ - allOf: [General.FullHash], - description: + ), + after_acc_hash: + Helper.describe_inline( + General.FullHash.schema(), "Accumulator hash of the sequencer inbox after this batch was appended. " <> "Must equal `before_acc_hash` of the next batch." - }, + ), commitment_transaction: CommitmentTransaction, data_availability: %Schema{ oneOf: [ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex index 08fa523e5b5..118af975062 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByAnytrust do OpenApiSpex.schema( Batch.schema() |> Helper.extend_schema( - title: "Arbitrum.BatchByAnytrust", + title: "ArbitrumBatchByAnytrust", description: "Arbitrum batch with AnyTrust data availability.", properties: %{ data_availability: DataAvailability.Anytrust diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex index f0ecb2bda19..f0f0a0861c1 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByCelestia do OpenApiSpex.schema( Batch.schema() |> Helper.extend_schema( - title: "Arbitrum.BatchByCelestia", + title: "ArbitrumBatchByCelestia", description: "Arbitrum batch with Celestia data availability.", properties: %{ data_availability: DataAvailability.Celestia diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex index 4210d096ff1..c263aab3df6 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByEigenda do OpenApiSpex.schema( Batch.schema() |> Helper.extend_schema( - title: "Arbitrum.BatchByEigenda", + title: "ArbitrumBatchByEigenda", description: "Arbitrum batch with EigenDA data availability.", properties: %{ data_availability: DataAvailability.Eigenda diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex index a974c74082b..8baef8e59c3 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex @@ -7,6 +7,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchDataContainer do # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. OpenApiSpex.schema(%{ + title: "ArbitrumBatchDataContainer", type: :string, enum: ["in_blob4844", "in_calldata", "in_celestia", "in_anytrust", "in_eigenda"], nullable: true, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex index 85be537b7ad..0d520348e36 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchForList do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumBatchForList", description: "Arbitrum batch summary for list endpoints.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex index a6b9cd3894a..4e51281f05a 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex @@ -3,22 +3,23 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.ClaimMessage do @moduledoc "Schema for Arbitrum claim message response." alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema require OpenApiSpex OpenApiSpex.schema(%{ - title: "Arbitrum.ClaimMessage", + title: "ArbitrumClaimMessage", description: "Calldata and outbox contract address needed to execute a withdrawal on the Parent chain.", type: :object, required: [:calldata, :outbox_address_hash], properties: %{ calldata: %Schema{type: :string, description: "ABI-encoded calldata for the executeTransaction call."}, - outbox_address_hash: %Schema{ - allOf: [General.AddressHash], - description: + outbox_address_hash: + Helper.describe_inline( + General.AddressHash.schema(), "Address of the Arbitrum Outbox contract on the Parent chain through which the withdrawal is executed." - } + ) }, additionalProperties: false }) diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex index 89f56b25839..726aea22d27 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.CommitmentTransaction do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumCommitmentTransaction", description: "Parent chain transaction that committed the batch.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex index bba2c7f2949..44bec9bd2e8 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Anytrust do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumDataAvailabilityAnytrust", description: "AnyTrust data availability certificate.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex index d13a70df6e9..1b35812ed5f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Base do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumDataAvailabilityBase", description: "Data availability info for batches posted via EIP-4844 blobs, calldata, or with no DA.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex index 3098fd10953..98f4f46e97f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Celestia do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumDataAvailabilityCelestia", description: "Celestia data availability blob reference.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex index 0d55d63ea2e..e0a39ee97af 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Eigenda do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumDataAvailabilityEigenda", description: "EigenDA data availability blob reference.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex index 362ad5106aa..e0b1c48449b 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Message do OpenApiSpex.schema( MinimalMessage.schema() |> Helper.extend_schema( - title: "Arbitrum.Message", + title: "ArbitrumMessage", description: "Full Arbitrum cross-chain message.", properties: %{ id: %Schema{ @@ -24,10 +24,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Message do minimum: 0, description: "Unique cross-chain message identifier assigned by the protocol." }, - origination_address_hash: %Schema{ - allOf: [General.AddressHashNullable], - description: "Address that initiated the message on the originating chain." - }, + origination_address_hash: + Helper.describe_inline( + General.AddressHashNullable.schema(), + "Address that initiated the message on the originating chain." + ), # Enum values must be kept in sync with Explorer.Chain.Arbitrum.Message :status field. status: %Schema{ type: :string, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex index ba6c469d4a7..3f4461a6ef3 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex @@ -6,16 +6,19 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.MinimalMessage do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ArbitrumMinimalMessage", description: "Minimal Arbitrum cross-chain message with origination and completion fields.", type: :object, properties: %{ - origination_transaction_hash: %Schema{ - allOf: [General.FullHashNullable], - description: "Hash of the transaction on the originating chain that initiated this message." - }, + origination_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the transaction on the originating chain that initiated this message." + ), origination_timestamp: General.TimestampNullable, origination_transaction_block_number: %Schema{ type: :integer, @@ -23,10 +26,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.MinimalMessage do nullable: true, description: "Block number on the originating chain containing the initiation transaction." }, - completion_transaction_hash: %Schema{ - allOf: [General.FullHashNullable], - description: "Hash of the transaction on the destination chain that executed this message." - } + completion_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the transaction on the destination chain that executed this message." + ) }, required: [ :origination_transaction_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex index 815da48af1d..351207f02ee 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex @@ -5,10 +5,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Withdrawal do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema OpenApiSpex.schema(%{ - title: "Arbitrum.Withdrawal", + title: "ArbitrumWithdrawal", description: "Arbitrum Rollup withdrawal message.", type: :object, required: [ @@ -36,45 +37,51 @@ defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Withdrawal do "and `confirmed` (RBlock confirmed on Parent chain) to `relayed` (executed on Parent chain). " <> "`unknown` indicates the status could not be determined, e.g. when the Parent chain RPC is unavailable." }, - caller_address_hash: %Schema{ - allOf: [General.AddressHash], - description: "Address of the account that initiated the withdrawal on the Rollup." - }, - destination_address_hash: %Schema{ - allOf: [General.AddressHash], - description: "Recipient address on the Parent chain that will receive funds when the withdrawal is executed." - }, + caller_address_hash: + Helper.describe_inline( + General.AddressHash.schema(), + "Address of the account that initiated the withdrawal on the Rollup." + ), + destination_address_hash: + Helper.describe_inline( + General.AddressHash.schema(), + "Recipient address on the Parent chain that will receive funds when the withdrawal is executed." + ), arb_block_number: %Schema{type: :integer, minimum: 0, description: "Rollup block number."}, eth_block_number: %Schema{type: :integer, minimum: 0, description: "Parent chain block number."}, l2_timestamp: %Schema{type: :integer, minimum: 0, description: "Unix timestamp of the originating transaction."}, - callvalue: %Schema{ - allOf: [General.IntegerString], - description: "Native coin amount in wei attached to the withdrawal message." - }, - data: %Schema{ - allOf: [General.HexString], - description: + callvalue: + Helper.describe_inline( + General.IntegerString.schema(), + "Native coin amount in wei attached to the withdrawal message." + ), + data: + Helper.describe_inline( + General.HexData.schema(), "ABI-encoded calldata passed to the destination address when the withdrawal is executed on the Parent chain. " <> "Empty (`0x`) for plain native coin transfers." - }, + ), token: %Schema{ type: :object, nullable: true, description: "Token withdrawal details. Present when the withdrawal is for a bridged token, null for native coin.", properties: %{ - address_hash: %Schema{ - allOf: [General.AddressHashNullable], - description: "Token contract address on the Parent chain." - }, - destination_address_hash: %Schema{ - allOf: [General.AddressHashNullable], - description: "Token recipient address on the Parent chain." - }, - amount: %Schema{ - allOf: [General.IntegerString], - description: "Token amount in the token's smallest unit." - }, + address_hash: + Helper.describe_inline( + General.AddressHashNullable.schema(), + "Token contract address on the Parent chain." + ), + destination_address_hash: + Helper.describe_inline( + General.AddressHashNullable.schema(), + "Token recipient address on the Parent chain." + ), + amount: + Helper.describe_inline( + General.IntegerString.schema(), + "Token amount in the token's smallest unit." + ), decimals: %Schema{type: :integer, minimum: 0, nullable: true}, name: %Schema{type: :string, nullable: true}, symbol: %Schema{type: :string, nullable: true} diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex index f43bcc2b266..4de74cd56e8 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Beacon.Deposit do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "BeaconDeposit", type: :object, properties: %{ index: %Schema{type: :integer, nullable: false}, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex index 8a9a8dc59c2..68f07e415e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex @@ -5,5 +5,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Beacon.Deposit.Status do alias Explorer.Chain.Beacon.Deposit - OpenApiSpex.schema(%{title: "BeaconDepositStatus", type: :string, nullable: false, enum: Deposit.statuses()}) + OpenApiSpex.schema(%{ + title: "Beacon.Deposit.Status", + type: :string, + nullable: false, + enum: Deposit.statuses() |> Enum.map(&to_string/1), + description: "Beacon deposit lifecycle status." + }) end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex index c38f4173def..6862d8ca815 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex @@ -8,10 +8,10 @@ defmodule BlockScoutWeb.Schemas.API.V2.Blob do OpenApiSpex.schema(%{ type: :object, properties: %{ - blob_data: General.HexString, + blob_data: General.HexData, hash: General.FullHash, - kzg_commitment: General.HexString, - kzg_proof: General.HexString + kzg_commitment: General.HexData, + kzg_proof: General.HexData }, required: [:blob_data, :hash, :kzg_commitment, :kzg_proof], additionalProperties: false diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex index d91499bd7ce..9d2de574aa9 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex @@ -17,7 +17,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do batch_number: %Schema{type: :integer, nullable: true}, status: %Schema{ type: :string, - enum: ZkSyncView.batch_status_enum(), + enum: ZkSyncView.batched_item_lifecycle_status_enum(), nullable: false }, commit_transaction_hash: General.FullHashNullable, @@ -124,7 +124,20 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do l1_transaction_hash: General.FullHashNullable, l1_timestamp: General.TimestampNullable }, - required: [:commitment, :l1_transaction_hash, :l1_timestamp] + required: [:commitment, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + } + + @eigenda_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + cert: %Schema{type: :string, nullable: true}, + l1_transaction_hash: General.FullHashNullable, + l1_timestamp: General.TimestampNullable + }, + required: [:cert, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false } @doc """ @@ -147,7 +160,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do }, blobs: %Schema{ type: :array, - items: %Schema{anyOf: [@blob4844_schema, @celestia_schema, @alt_da_schema]}, + items: %Schema{ + anyOf: [@blob4844_schema, @celestia_schema, @eigenda_schema, @alt_da_schema] + }, nullable: false } }, @@ -202,7 +217,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do nullable: false, properties: %{ view: %Schema{type: :integer, nullable: false}, - signature: General.HexString, + signature: General.HexData, signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false} }, required: [:view, :signature, :signers], @@ -213,7 +228,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do nullable: false, properties: %{ view: %Schema{type: :integer, nullable: false}, - signature: General.HexString, + signature: General.HexData, signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false}, nested_quorum_certificates: %Schema{ type: :array, @@ -221,7 +236,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do type: :object, properties: %{ view: %Schema{type: :integer, nullable: false}, - signature: General.HexString, + signature: General.HexData, proposed_by_validator_index: %Schema{type: :integer, nullable: false}, signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false} }, @@ -261,9 +276,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do |> Helper.extend_schema( properties: %{ minimum_gas_price: General.IntegerString, - bitcoin_merged_mining_header: General.HexString, - bitcoin_merged_mining_coinbase_transaction: General.HexString, - bitcoin_merged_mining_merkle_proof: General.HexString, + bitcoin_merged_mining_header: General.HexData, + bitcoin_merged_mining_coinbase_transaction: General.HexData, + bitcoin_merged_mining_merkle_proof: General.HexData, hash_for_merged_mining: General.FullHash } ) @@ -316,6 +331,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.Common do This module defines common schema fields for block. """ alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema @required_fields [ @@ -445,34 +461,38 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.Common do }, hash: General.FullHash, parent_hash: General.FullHash, - difficulty: %Schema{ - allOf: [General.IntegerStringNullable], - description: "Proof-of-work difficulty of this block. Zero on proof-of-stake chains." - }, - total_difficulty: %Schema{ - allOf: [General.IntegerStringNullable], - description: + difficulty: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "Proof-of-work difficulty of this block. Zero on proof-of-stake chains." + ), + total_difficulty: + Helper.describe_inline( + General.IntegerStringNullable.schema(), "Cumulative chain difficulty through this block (sum of `difficulty` of this block and all ancestors)." - }, + ), gas_used: General.IntegerString, gas_limit: General.IntegerString, - nonce: %Schema{ - allOf: [General.HexString], - description: "Proof-of-work nonce used to satisfy the difficulty target. Zero on proof-of-stake chains." - }, - base_fee_per_gas: %Schema{ - allOf: [General.IntegerStringNullable], - description: + nonce: + Helper.describe_inline( + General.HexData.schema(), + "Proof-of-work nonce used to satisfy the difficulty target. Zero on proof-of-stake chains." + ), + base_fee_per_gas: + Helper.describe_inline( + General.IntegerStringNullable.schema(), "EIP-1559 base fee per gas, in wei. Null on blocks produced before EIP-1559 activation or on chains that do not implement EIP-1559." - }, - burnt_fees: %Schema{ - allOf: [General.IntegerStringNullable], - description: "Sum of EIP-1559 base fees burned by transactions in this block, in wei." - }, - priority_fee: %Schema{ - allOf: [General.IntegerStringNullable], - description: "Sum of validator tips (EIP-1559 priority fees) paid by transactions in this block, in wei." - }, + ), + burnt_fees: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "Sum of EIP-1559 base fees burned by transactions in this block, in wei." + ), + priority_fee: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "Sum of validator tips (EIP-1559 priority fees) paid by transactions in this block, in wei." + ), uncles_hashes: %Schema{ type: :array, description: "Hashes of ommer (uncle) blocks referenced by this block.", @@ -512,10 +532,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Block.Common do description: "Block classification: `block` = main-chain consensus block; `uncle` = ommer (valid but not in main chain); `reorg` = former main-chain block lost to reorganization." }, - transaction_fees: %Schema{ - allOf: [General.IntegerString], - description: "Sum of transaction fees (gas price × gas used) paid by transactions in this block, in wei." - }, + transaction_fees: + Helper.describe_inline( + General.IntegerString.schema(), + "Sum of transaction fees (gas price × gas used) paid by transactions in this block, in wei." + ), withdrawals_count: %Schema{ type: :integer, minimum: 0, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex index 07722b1836d..2a085f9f8bd 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex @@ -5,20 +5,22 @@ defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch do """ require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.Celo.Epoch.Distribution alias BlockScoutWeb.Schemas.API.V2.General alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "CeloEpoch", description: "Celo epoch summary.", type: :object, properties: %{ number: %Schema{type: :integer, nullable: false, minimum: 0}, type: %Schema{type: :string, enum: ["L1", "L2"], nullable: false}, - start_block_number: %Schema{type: :integer, nullable: false, minimum: 0}, - end_block_number: %Schema{type: :integer, nullable: false, minimum: 0}, + start_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, + end_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, timestamp: General.TimestampNullable, is_finalized: %Schema{type: :boolean, nullable: false}, - distribution: %Schema{type: :object, nullable: true, additionalProperties: true} + distribution: %Schema{allOf: [Distribution], nullable: true} }, required: [ :number, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/aggregated_election_rewards.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/aggregated_election_rewards.ex new file mode 100644 index 00000000000..e583a7c82ec --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/aggregated_election_rewards.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.AggregatedElectionReward do + @moduledoc """ + Aggregated election reward of a single type within a Celo epoch. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Token} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "CeloEpochAggregatedElectionReward", + type: :object, + properties: %{ + total: General.IntegerString, + count: %Schema{type: :integer, nullable: false, minimum: 0}, + token: Token + }, + required: [:total, :count, :token], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.AggregatedElectionRewards do + @moduledoc """ + Election rewards of a Celo epoch aggregated by reward type. Keys correspond to + `Explorer.Chain.Celo.ElectionReward` types; `delegated_payment` is `null` for + post-migration (L2) epochs. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Celo.Epoch.AggregatedElectionReward + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "CeloEpochAggregatedElectionRewards", + type: :object, + properties: %{ + voter: %Schema{allOf: [AggregatedElectionReward], nullable: true}, + validator: %Schema{allOf: [AggregatedElectionReward], nullable: true}, + group: %Schema{allOf: [AggregatedElectionReward], nullable: true}, + delegated_payment: %Schema{allOf: [AggregatedElectionReward], nullable: true} + }, + required: [:voter, :validator, :group, :delegated_payment], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex index 35d89f1034f..251b3dac8ad 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex @@ -6,6 +6,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.Detailed do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.{Celo.Epoch, General} + alias BlockScoutWeb.Schemas.API.V2.Celo.Epoch.{AggregatedElectionRewards, DetailedDistribution} alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema @@ -19,22 +20,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.Detailed do start_processing_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, end_processing_block_hash: General.FullHashNullable, end_processing_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, - aggregated_election_rewards: %Schema{ - type: :object, - nullable: true, - additionalProperties: %Schema{ - type: :object, - nullable: true, - properties: %{ - total: General.IntegerString, - count: %Schema{type: :integer, nullable: false, minimum: 0}, - token: %Schema{type: :object, nullable: true, additionalProperties: true} - }, - required: [:total, :count, :token], - additionalProperties: false - } - }, - distribution: %Schema{type: :object, nullable: true, additionalProperties: true} + aggregated_election_rewards: %Schema{allOf: [AggregatedElectionRewards], nullable: true}, + distribution: %Schema{allOf: [DetailedDistribution], nullable: true} }, required: [ :start_processing_block_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed_distribution.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed_distribution.ex new file mode 100644 index 00000000000..44cb5987faa --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed_distribution.ex @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.DetailedDistributionTotal do + @moduledoc """ + Aggregated total of all epoch reward transfers (with their common token), as + produced by `CeloView.calculate_total_epoch_rewards/1`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Token, TokenTransfer} + + OpenApiSpex.schema(%{ + title: "CeloEpochDetailedDistributionTotal", + type: :object, + properties: %{ + token: Token, + total: TokenTransfer.Total + }, + required: [:token, :total], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.DetailedDistribution do + @moduledoc """ + Epoch reward distribution as rendered for the detailed Celo epoch response + (`CeloView.prepare_distribution/1`). Each transfer is the full token transfer, + and `transfers_total` is the aggregated sum across them. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Celo.Epoch.DetailedDistributionTotal + alias BlockScoutWeb.Schemas.API.V2.TokenTransfer + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "CeloEpochDetailedDistribution", + description: "Epoch reward token transfers and their aggregated total.", + type: :object, + properties: %{ + reserve_bolster_transfer: %Schema{allOf: [TokenTransfer], nullable: true}, + community_transfer: %Schema{allOf: [TokenTransfer], nullable: true}, + carbon_offsetting_transfer: %Schema{allOf: [TokenTransfer], nullable: true}, + transfers_total: %Schema{allOf: [DetailedDistributionTotal], nullable: true} + }, + required: [ + :reserve_bolster_transfer, + :community_transfer, + :carbon_offsetting_transfer, + :transfers_total + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/distribution.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/distribution.ex new file mode 100644 index 00000000000..330465343bd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/distribution.ex @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.Distribution do + @moduledoc """ + Epoch reward distribution as rendered for a Celo epoch list item + (`CeloViewView.prepare_epoch/1`). Each transfer carries the token-transfer + total amount (or token id), and `transfers_total` is the aggregated sum. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.TokenTransfer + alias OpenApiSpex.Schema + + # Same shape as `TokenTransfer.total` (produced by `prepare_token_transfer_total/1`): + # fungible `{value, decimals}`, ERC-721 `{token_id, token_instance}`, or + # ERC-1155/404 `{token_id, value, decimals, token_instance}`. + @transfer_total %Schema{ + anyOf: [TokenTransfer.TotalERC721, TokenTransfer.TotalERC1155, TokenTransfer.Total], + nullable: true, + description: "Total amount (or token id) of the epoch reward transfer; `null` when the transfer is absent." + } + + OpenApiSpex.schema(%{ + title: "CeloEpochDistribution", + description: "Epoch reward transfers and their aggregated total.", + type: :object, + properties: %{ + reserve_bolster_transfer: @transfer_total, + community_transfer: @transfer_total, + carbon_offsetting_transfer: @transfer_total, + transfers_total: %Schema{ + allOf: [TokenTransfer.Total], + nullable: true, + description: "Aggregated total of all reward transfers; `null` when there are no transfers." + } + }, + required: [ + :reserve_bolster_transfer, + :community_transfer, + :carbon_offsetting_transfer, + :transfers_total + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex index 0750fbbeb84..a24ce975fa0 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex @@ -6,11 +6,14 @@ defmodule BlockScoutWeb.Schemas.API.V2.CSVExport.Response do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "CSVExportResponse", type: :object, properties: %{ status: %Schema{type: :string, nullable: false, enum: ["pending", "completed", "failed"]}, file_id: %Schema{type: :string, nullable: true}, expires_at: %Schema{type: :string, nullable: true, format: "date-time"} - } + }, + required: [:status, :file_id, :expires_at], + additionalProperties: false }) end 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 index de04fcad897..a8f29496c10 100644 --- 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 @@ -10,7 +10,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Eden.Call do properties: %{ to: General.AddressHashNullable, value: General.IntegerString, - input: General.HexString + input: General.HexData }, required: [:to, :value, :input], additionalProperties: false diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex index d148cdd2b92..25c1cc9d45f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex @@ -68,7 +68,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.FheOperation do hcu_depth: %Schema{type: :integer, nullable: false, example: 1, minimum: 0}, caller: %Schema{allOf: [Address], nullable: true}, inputs: @fhe_operation_inputs_schema, - result: General.HexString, + result: General.HexData, block_number: %Schema{type: :integer, nullable: false, example: 12_345_678} }, required: [ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex index 383c05241c5..0df14e4f8e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do EmptyString, FloatString, FullHash, - HexString, + HexData, IntegerString, IntegerStringNullable, NullString @@ -28,14 +28,17 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do @float_pattern ~r"^([1-9][0-9]*|0)(\.[0-9]+)?$" @address_hash_pattern ~r"^0x([A-Fa-f0-9]{40})$" @full_hash_pattern ~r"^0x([A-Fa-f0-9]{64})$" - @hex_string_pattern ~r"^0x([A-Fa-f0-9]*)$" + @hex_quantity_pattern ~r"^0x([A-Fa-f0-9]+)$" + @hex_data_pattern ~r"^0x([A-Fa-f0-9]*)$" if @chain_type == :zilliqa do - @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984))*\]?$/i + @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984|ERC-8056)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984|ERC-8056))*\]?$/i else - @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984))*\]?$/i + @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984|ERC-8056)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984|ERC-8056))*\]?$/i end + @nft_token_type_pattern ~r/^\[?(ERC-721|ERC-1155|ERC-404)(,(ERC-721|ERC-1155|ERC-404))*\]?$/i + # Matches ISO-like datetime strings where separators between time fields can be ':' or percent-encoded '%3A'. # Accepts examples like: # - "2025-10-12T09" @@ -332,6 +335,17 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do } end + @spec include_zero_value_param() :: Parameter.t() + def include_zero_value_param do + %Parameter{ + name: :include_zero_value, + in: :query, + schema: %Schema{type: :boolean, default: true}, + required: false, + description: "If `false`, zero-value call-type internal transactions are excluded from results." + } + end + @doc """ Returns a reusable OpenApiSpex.RequestBody for audit report submission. """ @@ -513,15 +527,16 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do end @sort_field_descriptions %{ + "address_hash" => "Sort by address hash", "balance" => "Sort by account balance", "block_number" => "Sort by block number", + "blocks_validated" => "Sort by number of blocks validated by the validator", "circulating_market_cap" => "Sort by circulating market cap of the token", "fee" => "Sort by transaction fee", "fiat_value" => "Sort by fiat value", "holders_count" => "Sort by number of token holders", - "key0" => "Sort by MUD record key0", - "key1" => "Sort by MUD record key1", - "key_bytes" => "Sort by MUD record key_bytes", + "index" => "Sort by validator index", + "state" => "Sort by validator operational state", "total_gas_used" => "Sort by total gas used", "transactions_count" => "Sort by number of transactions", "value" => "Sort by transaction value" @@ -565,10 +580,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do @token_type_param_description """ Filter by token type. Comma-separated list of: - * ERC-20 - Fungible tokens + * ERC-20 - Fungible tokens. Does not include ERC-8056, which is a type of its own * ERC-721 - Non-fungible tokens * ERC-1155 - Multi-token standard * ERC-404 - Hybrid fungible/non-fungible tokens + * ERC-8056 - Fungible tokens with a scaled UI amount. Such a token is labelled ERC-8056 instead of ERC-20, so list both to get every fungible token #{if @chain_type == :zilliqa do """ * ZRC-2 - Fungible tokens on Zilliqa @@ -615,7 +631,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do EmptyString, %Schema{ type: :string, - pattern: @token_type_pattern + pattern: @nft_token_type_pattern } ] }, @@ -639,7 +655,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do %Parameter{ name: :topic, in: :query, - schema: HexString, + schema: HexData, required: false, description: "Log topic param in the query" } @@ -724,16 +740,59 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do end @doc """ - Returns a parameter definition for reCAPTCHA response token. + Returns parameter definitions for the reCAPTCHA headers. + + These headers are consumed by the rate limiter, not by the controller: they are + checked only once the endpoint's rate limit has been reached, and a successful + verification allows the request instead of returning 429. Only one of them is + used per request, in the order listed below. """ - @spec recaptcha_response_param() :: Parameter.t() - def recaptcha_response_param do + @spec recaptcha_params() :: [Parameter.t()] + def recaptcha_params do + [ + %Parameter{ + name: :"recaptcha-v2-response", + in: :header, + schema: %Schema{type: :string}, + required: false, + description: "reCAPTCHA v2 response token" + }, + %Parameter{ + name: :"recaptcha-v3-response", + in: :header, + schema: %Schema{type: :string}, + required: false, + description: "reCAPTCHA v3 response token" + }, + %Parameter{ + name: :"scoped-recaptcha-bypass-token", + in: :header, + schema: %Schema{type: :string}, + required: false, + description: + "Bypass token issued to trusted clients for this specific endpoint. May also be passed as the `scoped_recaptcha_bypass_token` query parameter; the header takes precedence" + }, + %Parameter{ + name: :"recaptcha-bypass-token", + in: :header, + schema: %Schema{type: :string}, + required: false, + description: "Bypass token issued to trusted clients for all reCAPTCHA-protected endpoints" + } + ] + end + + @doc """ + Returns a parameter definition for scoped reCAPTCHA bypass token. + """ + @spec scoped_recaptcha_bypass_token_param() :: Parameter.t() + def scoped_recaptcha_bypass_token_param do %Parameter{ - name: :recaptcha_response, + name: :scoped_recaptcha_bypass_token, in: :query, schema: %Schema{type: :string}, required: false, - description: "reCAPTCHA response token" + description: "Scoped reCAPTCHA bypass token for trusted clients" } end @@ -780,104 +839,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do } end - @doc """ - Returns a parameter definition for MUD world address hash. - """ - @spec world_param() :: Parameter.t() - def world_param do - %Parameter{ - name: :world, - in: :path, - schema: AddressHash, - required: true, - description: "MUD world address hash in the path" - } - end - - @doc """ - Returns a parameter definition for MUD system address hash. - """ - @spec system_param() :: Parameter.t() - def system_param do - %Parameter{ - name: :system, - in: :path, - schema: AddressHash, - required: true, - description: "MUD system address hash in the path" - } - end - - @doc """ - Returns a parameter definition for MUD table ID. - """ - @spec table_id_param() :: Parameter.t() - def table_id_param do - %Parameter{ - name: :table_id, - in: :path, - schema: FullHash, - required: true, - description: "MUD table ID in the path" - } - end - - @doc """ - Returns a parameter definition for MUD record ID. - """ - @spec record_id_param() :: Parameter.t() - def record_id_param do - %Parameter{ - name: :record_id, - in: :path, - schema: HexString, - required: true, - description: "MUD record ID in the path" - } - end - - @doc """ - Returns a parameter definition for MUD tables namespace filter. - """ - @spec filter_namespace_param() :: Parameter.t() - def filter_namespace_param do - %Parameter{ - name: :filter_namespace, - in: :query, - schema: %Schema{type: :string}, - required: false, - description: "Filter by namespace" - } - end - - @doc """ - Returns a parameter definition for MUD table records key0 filter. - """ - @spec filter_key0_param() :: Parameter.t() - def filter_key0_param do - %Parameter{ - name: :filter_key0, - in: :query, - schema: %Schema{type: :string}, - required: false, - description: "Filter by key0" - } - end - - @doc """ - Returns a parameter definition for MUD table records key1 filter. - """ - @spec filter_key1_param() :: Parameter.t() - def filter_key1_param do - %Parameter{ - name: :filter_key1, - in: :query, - schema: %Schema{type: :string}, - required: false, - description: "Filter by key1" - } - end - @doc """ Returns a parameter definition for a user operation hash in the path. """ @@ -1023,19 +984,39 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do def paginated_response(options) do items_schema = Keyword.fetch!(options, :items) next_page_params_example = Keyword.fetch!(options, :next_page_params_example) + include_pending_status? = Keyword.get(options, :include_pending_status?, false) + + properties = %{ + items: %Schema{type: :array, items: items_schema, nullable: false}, + next_page_params: %Schema{ + type: :object, + nullable: true, + additionalProperties: true, + example: next_page_params_example + } + } - %Schema{ - type: :object, - properties: %{ - items: %Schema{type: :array, items: items_schema, nullable: false}, - next_page_params: %Schema{ + {properties, required} = + if include_pending_status? do + meta_schema = %Schema{ type: :object, - nullable: true, - additionalProperties: true, - example: next_page_params_example + nullable: false, + properties: %{ + status: %Schema{type: :integer, enum: [1, 2]}, + message: %Schema{type: :string, nullable: true} + }, + required: [:status, :message] } - }, - required: [:items, :next_page_params], + + {Map.put(properties, :meta, meta_schema), [:items, :next_page_params, :meta]} + else + {properties, [:items, :next_page_params]} + end + + %Schema{ + type: :object, + properties: properties, + required: required, nullable: false, additionalProperties: false } @@ -1117,6 +1098,13 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do required: false, description: "Transaction index for paging" }, + "log_index" => %Parameter{ + name: :log_index, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "Log index for paging" + }, "inserted_at" => %Parameter{ name: :inserted_at, in: :query, @@ -1181,13 +1169,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do required: false, description: "Transaction fee for paging" }, - "items_count" => %Parameter{ - name: :items_count, - in: :query, - schema: %Schema{type: :integer, minimum: 1, maximum: 50}, - required: false, - description: "Number of items returned per page" - }, "holders_count" => %Parameter{ name: :holders_count, in: :query, @@ -1272,6 +1253,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do required: false, description: "Smart-contract ID for paging" }, + # TODO: tighten `schema` to `%Schema{type: :integer, minimum: 0}`. Every current consumer of + # this paging param keys off a non-negative identifier (batch number, etc.), so accepting + # negative integers is wider than the domain. The change is shared across multiple chains' + # endpoints (e.g., Arbitrum/Optimism/Scroll/ZkSync batch listings), so it must be done as a + # separate sweep that audits every caller of `define_paging_params([..., "number", ...])`. "number" => %Parameter{ name: :number, in: :query, @@ -1400,41 +1386,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do required: false, description: "State changes for paging" }, - "world" => %Parameter{ - name: :world, - in: :query, - schema: AddressHash, - required: false, - description: "MUD world address hash for paging" - }, - "table_id" => %Parameter{ - name: :table_id, - in: :query, - schema: FullHash, - required: false, - description: "MUD table ID for paging" - }, - "key_bytes" => %Parameter{ - name: :key_bytes, - in: :query, - schema: HexString, - required: false, - description: "MUD record key_bytes for paging" - }, - "key0" => %Parameter{ - name: :key0, - in: :query, - schema: FullHash, - required: false, - description: "MUD record key0 for paging" - }, - "key1" => %Parameter{ - name: :key1, - in: :query, - schema: FullHash, - required: false, - description: "MUD record key1 for paging" - }, "page_size" => %Parameter{ name: :page_size, in: :query, @@ -1448,26 +1399,13 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do schema: %Schema{type: :string}, required: false, description: "Page token for paging" - } - } - - @state_changes_paging_params %{ - # "items_count" is used for pagination for the list of transactions's state changes and it can be higher than 50. - # Thus, we extracted it to a separate map. - "items_count" => %Parameter{ - name: :items_count, - in: :query, - schema: %Schema{type: :integer, minimum: 1}, - required: false, - description: "Cumulative number of items to skip for keyset-based pagination of state changes" }, - # todo: remove in the future as this param is unused in the pagination of state changes - "state_changes" => %Parameter{ - name: :state_changes, + "state_changes_count" => %Parameter{ + name: :state_changes_count, in: :query, - schema: %Schema{type: :string, nullable: true}, + schema: %Schema{type: :integer, minimum: 0}, required: false, - description: "State changes for paging" + description: "Cumulative number of state changes to skip for keyset-based pagination" } } @@ -1563,19 +1501,20 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do """ @spec define_paging_params([String.t()]) :: [Parameter.t()] def define_paging_params(fields) do - Enum.map(fields, fn field -> - Map.get(@paging_params, field) || raise "Unknown paging param: #{field}" - end) - end + items_count_param = %Parameter{ + name: :items_count, + in: :query, + schema: %Schema{type: :integer, minimum: 1}, + required: false, + description: "Number of items per page" + } - @doc """ - Returns a list of pagination parameters for `/api/v2/transactions/:transaction_hash_param/state-changes` API endpoint - """ - @spec define_state_changes_paging_params([String.t()]) :: [Parameter.t()] - def define_state_changes_paging_params(fields) do - Enum.map(fields, fn field -> - Map.get(@state_changes_paging_params, field) || raise "Unknown paging param: #{field}" - end) + paging_params = + Enum.map(fields, fn field -> + Map.get(@paging_params, field) || raise "Unknown paging param: #{field}" + end) + + [items_count_param | paging_params] end @doc """ @@ -1625,8 +1564,14 @@ defmodule BlockScoutWeb.Schemas.API.V2.General do def full_hash_pattern, do: @full_hash_pattern @doc """ - Returns the regex pattern for validating hex strings. + Returns the regex pattern for validating hex-encoded quantities (JSON-RPC QUANTITY). + """ + @spec hex_quantity_pattern() :: Regex.t() + def hex_quantity_pattern, do: @hex_quantity_pattern + + @doc """ + Returns the regex pattern for validating hex-encoded data (JSON-RPC DATA). """ - @spec hex_string_pattern() :: Regex.t() - def hex_string_pattern, do: @hex_string_pattern + @spec hex_data_pattern() :: Regex.t() + def hex_data_pattern, do: @hex_data_pattern end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex index 3d80320d0ab..7516e234621 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.General.DecodedLogInput do properties: %{ method_id: %Schema{type: :string, nullable: true}, method_call: %Schema{type: :string, nullable: true}, + abi: %Schema{type: :object, nullable: true}, parameters: %Schema{ type: :array, items: %Schema{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/filecoin_actor_type.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/filecoin_actor_type.ex new file mode 100644 index 00000000000..869838eef0d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/filecoin_actor_type.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.FilecoinActorType do + @moduledoc """ + Reusable enum of Filecoin actor types. + + Mirrors the `:filecoin_actor_type` Ecto enum on `Explorer.Chain.Address` + (which only exists for the `:filecoin` chain type, hence the compile-time guard). + """ + require OpenApiSpex + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + alias Ecto.Enum, as: EctoEnum + alias Explorer.Chain.Address + + # Resolved at compile time. On filecoin builds this stays in sync with the Ecto + # enum; on other chain types the field does not exist and this schema is unused. + @actor_type_values (case @chain_type do + :filecoin -> EctoEnum.values(Address, :filecoin_actor_type) + _ -> [] + end) + + OpenApiSpex.schema(%{ + title: "FilecoinActorType", + type: :string, + enum: @actor_type_values, + nullable: false, + description: "Type of actor associated with a Filecoin address." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data.ex new file mode 100644 index 00000000000..4ae5d5c4c0b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.HexData do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.hex_data_pattern()}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data_nullable.ex new file mode 100644 index 00000000000..deae806fdc3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_data_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.HexDataNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.hex_data_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_quantity.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_quantity.ex new file mode 100644 index 00000000000..f24b1764313 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_quantity.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.HexQuantity do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.hex_quantity_pattern()}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex deleted file mode 100644 index 589f1e057b4..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.General.HexString do - @moduledoc false - require OpenApiSpex - alias BlockScoutWeb.Schemas.API.V2.General - OpenApiSpex.schema(%{type: :string, pattern: General.hex_string_pattern(), nullable: false}) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex deleted file mode 100644 index 3cb6b0a0f21..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.General.HexStringNullable do - @moduledoc false - require OpenApiSpex - alias BlockScoutWeb.Schemas.API.V2.General - OpenApiSpex.schema(%{type: :string, pattern: General.hex_string_pattern(), nullable: true}) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex index 709dd6c7d7c..b383e0ea043 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex @@ -15,7 +15,10 @@ defmodule BlockScoutWeb.Schemas.API.V2.General.Implementation do address_hash: General.AddressHash, name: %Schema{type: :string, nullable: true} }, - required: [:address_hash, :name], + # `name` is omitted for conflicting proxy implementations (see + # `Explorer.Chain.SmartContract.Proxy.conflicting_implementations_info/1`), + # so only `address_hash` is required. + required: [:address_hash], additionalProperties: false } |> ChainTypeCustomizations.chain_type_fields() diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/non_negative_integer_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/non_negative_integer_string.ex new file mode 100644 index 00000000000..431dc8cd271 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/non_negative_integer_string.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.NonNegativeIntegerString do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.non_negative_integer_pattern(), nullable: false}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex index 614b8dce064..e03c3b76df3 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex @@ -22,6 +22,19 @@ defmodule BlockScoutWeb.Schemas.API.V2.InternalTransaction do type: %Schema{ type: :string, nullable: false, + # Rendered as `call_type || type` (CallType ∪ Type values); `invalid` is Arbitrum-only. + enum: [ + "call", + "callcode", + "delegatecall", + "staticcall", + "create", + "create2", + "reward", + "selfdestruct", + "stop", + "invalid" + ], description: "Type of the internal transaction (call, create, etc.)" }, transaction_hash: General.FullHash, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex index a51d5a8c478..36c17034610 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex @@ -13,8 +13,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Log do properties: %{ transaction_hash: General.FullHash, address: Address, - topics: %Schema{type: :array, items: General.HexStringNullable, nullable: false}, - data: General.HexString, + topics: %Schema{type: :array, items: General.HexDataNullable, nullable: false}, + data: General.HexData, index: %Schema{type: :integer, nullable: false}, decoded: %Schema{allOf: [General.DecodedLogInput], nullable: true}, smart_contract: %Schema{allOf: [Address], nullable: true}, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex deleted file mode 100644 index 99aec57da86..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.Record do - @moduledoc """ - This module defines the schema for the MUD Record struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD Record struct.", - type: :object, - properties: %{ - id: General.HexString, - raw: %Schema{ - type: :object, - properties: %{ - block_number: General.IntegerString, - log_index: General.IntegerString, - dynamic_data: General.HexString, - encoded_lengths: General.FullHash, - key0: General.FullHash, - key1: General.FullHash, - key_bytes: General.HexString, - static_data: General.HexString - }, - nullable: false - }, - timestamp: General.Timestamp, - decoded: %Schema{type: :object, nullable: true}, - is_deleted: %Schema{type: :boolean, nullable: false} - }, - required: [ - :id, - :raw, - :timestamp, - :decoded, - :is_deleted - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex deleted file mode 100644 index 4f25d631179..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.System do - @moduledoc """ - This module defines the schema for the MUD System struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD System struct.", - type: :object, - properties: %{ - name: %Schema{type: :string, nullable: false}, - address_hash: General.AddressHash - }, - required: [ - :name, - :address_hash - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex deleted file mode 100644 index 877e5df31ba..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.SystemDetails do - @moduledoc """ - This module defines the schema for the MUD SystemDetails struct. - """ - require OpenApiSpex - - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD SystemDetails struct.", - type: :object, - properties: %{ - name: %Schema{type: :string, nullable: false}, - abi: %Schema{type: :array, items: %Schema{type: :object}, nullable: false} - }, - required: [ - :name, - :abi - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex deleted file mode 100644 index ba7195ffc6b..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.Table do - @moduledoc """ - This module defines the schema for the MUD Table struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD Table struct.", - type: :object, - properties: %{ - table_id: General.FullHash, - table_full_name: %Schema{type: :string, nullable: false}, - table_type: %Schema{type: :string, enum: ["offchain", "onchain", "unknown"], nullable: false}, - table_namespace: %Schema{type: :string, nullable: false}, - table_name: %Schema{type: :string, nullable: false} - }, - required: [ - :table_id, - :table_full_name, - :table_type, - :table_namespace, - :table_name - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex deleted file mode 100644 index 4761fb42743..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.TableSchema do - @moduledoc """ - This module defines the schema for the MUD TableSchema struct. - """ - require OpenApiSpex - - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD TableSchema struct.", - type: :object, - properties: %{ - key_names: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, - key_types: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, - value_names: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, - value_types: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false} - }, - required: [ - :key_names, - :key_types, - :value_names, - :value_types - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex deleted file mode 100644 index 701e3703377..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.TableWithSchema do - @moduledoc """ - This module defines the schema for the MUD Table with TableSchema struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.MUD.{Table, TableSchema} - - OpenApiSpex.schema(%{ - description: "MUD Table with TableSchema struct.", - type: :object, - properties: %{ - table: Table, - schema: TableSchema - }, - required: [ - :table, - :schema - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex deleted file mode 100644 index 9be1ce3c597..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.MUD.World do - @moduledoc """ - This module defines the schema for the MUD World struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.{Address, General} - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - description: "MUD World struct.", - type: :object, - properties: %{ - address: Address.schema(), - coin_balance: General.IntegerStringNullable, - transactions_count: %Schema{type: :integer, nullable: true} - }, - required: [ - :address, - :coin_balance, - :transactions_count - ], - nullable: false, - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex index a535a05338f..6c0ddfe31e5 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex @@ -6,9 +6,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch.DataContainer alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismBatch", description: "Optimism Batch struct.", type: :object, properties: %{ @@ -20,11 +22,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch do items: General.FullHash, nullable: false }, - batch_data_container: %Schema{ - type: :string, - enum: ["in_blob4844", "in_celestia", "in_eigenda", "in_alt_da", "in_calldata"], - nullable: true - }, + batch_data_container: %Schema{allOf: [DataContainer], nullable: true}, l2_end_block_number: %Schema{type: :integer}, l2_start_block_number: %Schema{type: :integer} }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/blob.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/blob.ex new file mode 100644 index 00000000000..72281798095 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/blob.ex @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob.Eip4844 do + @moduledoc """ + Blob item bound to an Optimism batch whose data is stored in EIP-4844 blobs + (`batch_data_container == "in_blob4844"`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "OptimismBlobEip4844", + type: :object, + properties: %{ + hash: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + nullable: false, + description: "EIP-4844 blob hash." + }, + l1_transaction_hash: General.FullHash, + l1_timestamp: General.Timestamp + }, + required: [:hash, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob.Celestia do + @moduledoc """ + Blob item bound to an Optimism batch whose data is stored on Celestia + (`batch_data_container == "in_celestia"`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "OptimismBlobCelestia", + type: :object, + properties: %{ + height: %Schema{type: :integer, nullable: false, description: "Celestia block height."}, + namespace: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + nullable: false, + description: "Celestia blob namespace." + }, + commitment: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + nullable: false, + description: "Celestia blob commitment." + }, + l1_transaction_hash: General.FullHash, + l1_timestamp: General.Timestamp + }, + required: [:height, :namespace, :commitment, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob.Eigenda do + @moduledoc """ + Blob item bound to an Optimism batch whose data is stored on EigenDA + (`batch_data_container == "in_eigenda"`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "OptimismBlobEigenda", + type: :object, + properties: %{ + cert: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + nullable: false, + description: "EigenDA cert raw bytes." + }, + l1_transaction_hash: General.FullHash, + l1_timestamp: General.Timestamp + }, + required: [:cert, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob.AltDa do + @moduledoc """ + Blob item bound to an Optimism batch whose data is stored via an Alt-DA provider + (`batch_data_container == "in_alt_da"`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "OptimismBlobAltDa", + type: :object, + properties: %{ + commitment: %Schema{ + type: :string, + pattern: General.hex_data_pattern(), + nullable: false, + description: "Alt-DA blob commitment." + }, + l1_transaction_hash: General.FullHash, + l1_timestamp: General.Timestamp + }, + required: [:commitment, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/data_container.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/data_container.ex new file mode 100644 index 00000000000..38b5b18cfcc --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/data_container.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.DataContainer do + @moduledoc """ + Reusable enum describing where an Optimism batch's data is stored. + + Kept in sync with `Explorer.Chain.Optimism.FrameSequenceBlob.filter_blobs_by_type/1` + (the `:in_blob4844`/`:in_celestia`/`:in_eigenda`/`:in_alt_da`/`:in_calldata` tags). + Exposed as a standalone component (`OptimismBatchDataContainer`) so the value set + can be referenced directly from client code. + """ + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "OptimismBatchDataContainer", + type: :string, + enum: ["in_blob4844", "in_celestia", "in_eigenda", "in_alt_da", "in_calldata"], + nullable: false, + description: "Designates where the batch data is stored." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex index fb63d718f8b..f87a05a0709 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex @@ -1,12 +1,107 @@ # SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Detailed do +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.InBlob4844 do + @moduledoc """ + Detailed Optimism batch whose data is stored in EIP-4844 blobs. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "OptimismBatchInBlob4844", + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_blob4844"], nullable: false}, + blobs: %Schema{type: :array, items: Blob.Eip4844, nullable: false} + }, + required: [:blobs] + ) + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.InCelestia do + @moduledoc """ + Detailed Optimism batch whose data is stored on Celestia. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "OptimismBatchInCelestia", + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_celestia"], nullable: false}, + blobs: %Schema{type: :array, items: Blob.Celestia, nullable: false} + }, + required: [:blobs] + ) + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.InEigenda do + @moduledoc """ + Detailed Optimism batch whose data is stored on EigenDA. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "OptimismBatchInEigenda", + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_eigenda"], nullable: false}, + blobs: %Schema{type: :array, items: Blob.Eigenda, nullable: false} + }, + required: [:blobs] + ) + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.InAltDa do + @moduledoc """ + Detailed Optimism batch whose data is stored via an Alt-DA provider. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Blob + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "OptimismBatchInAltDa", + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_alt_da"], nullable: false}, + blobs: %Schema{type: :array, items: Blob.AltDa, nullable: false} + }, + required: [:blobs] + ) + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.InCalldata do @moduledoc """ - This module defines the schema for batch response from /api/v2/optimism/batches/:number and - /api/v2/optimism/batches/da/celestia/:height/:commitment + Detailed Optimism batch whose data is stored in L1 calldata. No `blobs` are + present in this case. """ require OpenApiSpex - alias BlockScoutWeb.Schemas.API.V2.General alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema @@ -14,61 +109,47 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Detailed do OpenApiSpex.schema( Batch.schema() |> Helper.extend_schema( - nullable: true, + title: "OptimismBatchInCalldata", properties: %{ - blobs: %Schema{ - type: :array, - items: %Schema{ - description: "Blob struct bound with Optimism batch.", - type: :object, - properties: %{ - hash: %Schema{ - type: :string, - pattern: General.hex_string_pattern(), - nullable: false, - description: "EIP-4844 blob hash." - }, - l1_transaction_hash: %Schema{ - type: :string, - pattern: General.full_hash_pattern(), - nullable: false, - description: "L1 transaction hash bound with the blob." - }, - l1_timestamp: %Schema{ - type: :string, - format: :"date-time", - nullable: false, - description: "L1 transaction timestamp bound with the blob." - }, - height: %Schema{type: :integer, nullable: false, description: "Celestia block height."}, - namespace: %Schema{ - type: :string, - pattern: General.hex_string_pattern(), - nullable: false, - description: "Celestia blob namespace." - }, - commitment: %Schema{ - type: :string, - pattern: General.hex_string_pattern(), - nullable: false, - description: "Celestia or Alt-DA blob commitment." - }, - cert: %Schema{ - type: :string, - pattern: General.hex_string_pattern(), - nullable: false, - description: "EigenDA cert raw bytes." - } - }, - required: [ - :l1_transaction_hash, - :l1_timestamp - ], - additionalProperties: false - }, - nullable: false - } + batch_data_container: %Schema{type: :string, enum: ["in_calldata"], nullable: false} } ) ) end + +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Detailed do + @moduledoc """ + Detailed batch response from `/api/v2/optimism/batches/:number` and + `/api/v2/optimism/batches/da/celestia/:height/:commitment`. + + Modeled as a discriminated union on `batch_data_container`: each variant pins + the container value and carries the matching `blobs` item shape (the `in_calldata` + variant carries no `blobs`). Mirrors + `Explorer.Chain.Optimism.FrameSequenceBlob.filter_blobs_by_type/1`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + + OpenApiSpex.schema(%{ + title: "OptimismBatchDetailed", + type: :object, + oneOf: [ + Batch.InBlob4844, + Batch.InCelestia, + Batch.InEigenda, + Batch.InAltDa, + Batch.InCalldata + ], + discriminator: %OpenApiSpex.Discriminator{ + propertyName: "batch_data_container", + mapping: %{ + "in_blob4844" => "#/components/schemas/OptimismBatchInBlob4844", + "in_celestia" => "#/components/schemas/OptimismBatchInCelestia", + "in_eigenda" => "#/components/schemas/OptimismBatchInEigenda", + "in_alt_da" => "#/components/schemas/OptimismBatchInAltDa", + "in_calldata" => "#/components/schemas/OptimismBatchInCalldata" + } + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex index f93befbee16..f980da46b8d 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Deposit do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismDeposit", description: "Optimism Deposit struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex index 504592a5652..dc3333ff699 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Deposit.MainPage do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismDepositMainPage", description: "Optimism Deposit struct for the main page.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex index a7d43450e29..3a3d4756e5b 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Game do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismGame", description: "Optimism Dispute Game struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/interop_message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/interop_message.ex new file mode 100644 index 00000000000..e4fa60d3f90 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/interop_message.ex @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.InteropMessage do + @moduledoc """ + Schema for an interop message included in an Optimism transaction (an item of + `Transaction.op_interop_messages`). + + Depending on the message direction, each item carries either the + `init_chain` / `init_transaction_hash` pair (incoming) or the + `relay_chain` / `relay_transaction_hash` pair (outgoing) — hence all four are + optional. Matches `Explorer.Chain.Optimism.InteropMessage.messages_by_transaction/1`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + # `interop_chain_id_to_instance_info/2` result; same shape for `init_chain` and `relay_chain`. + @chain_info %Schema{ + type: :object, + nullable: true, + properties: %{ + instance_url: %Schema{type: :string, nullable: true}, + chain_id: General.IntegerString, + chain_name: %Schema{type: :string, nullable: true}, + chain_logo: %Schema{type: :string, nullable: true} + }, + additionalProperties: false + } + + OpenApiSpex.schema(%{ + title: "OptimismInteropMessage", + description: "Interop message included in an Optimism transaction", + type: :object, + nullable: false, + properties: %{ + unique_id: %Schema{type: :string}, + nonce: %Schema{type: :integer, minimum: 0}, + status: %Schema{type: :string, enum: ["Sent", "Relayed", "Failed"]}, + sender_address_hash: General.AddressHash, + target_address_hash: General.AddressHash, + payload: General.HexData, + init_chain: @chain_info, + init_transaction_hash: General.FullHash, + relay_chain: @chain_info, + relay_transaction_hash: General.FullHash + }, + required: [:payload], + example: %{ + "unique_id" => "0000000100000000", + "nonce" => 0, + "status" => "Relayed", + "sender_address_hash" => "0x0000000000000000000000000000000000000003", + "target_address_hash" => "0x0000000000000000000000000000000000000004", + "payload" => "0x30787849009c24f10a91a327a9f2ed94ebc49ee9", + "relay_chain" => nil, + "relay_transaction_hash" => "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex index 63d8b5f19c0..c9891522e05 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.OutputRoot do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismOutputRoot", description: "Optimism Output Root struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/transaction_withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/transaction_withdrawal.ex new file mode 100644 index 00000000000..57089c4f552 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/transaction_withdrawal.ex @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.TransactionWithdrawal do + @moduledoc """ + Schema for an L2->L1 withdrawal message initiated by an Optimism transaction + (an item of `Transaction.op_withdrawals`). + + Matches the map built in `BlockScoutWeb.API.V2.OptimismView.add_optimism_fields/2`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "OptimismTransactionWithdrawal", + description: "L2->L1 withdrawal message initiated by an Optimism transaction", + type: :object, + nullable: false, + properties: %{ + nonce: %Schema{type: :integer}, + status: %Schema{type: :string, nullable: false}, + l1_transaction_hash: General.FullHashNullable, + portal_contract_address_hash: General.AddressHashNullable, + msg_nonce_raw: General.IntegerString, + msg_sender_address_hash: General.AddressHashNullable, + msg_target_address_hash: General.AddressHashNullable, + msg_value: General.IntegerStringNullable, + msg_gas_limit: General.IntegerStringNullable, + msg_data: General.HexDataNullable + }, + required: [ + :nonce, + :status, + :l1_transaction_hash, + :portal_contract_address_hash, + :msg_nonce_raw, + :msg_sender_address_hash, + :msg_target_address_hash, + :msg_value, + :msg_gas_limit, + :msg_data + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex index 6ba9cf2dbbb..d835023ad3d 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Withdrawal do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "OptimismWithdrawal", description: "Optimism Withdrawal struct.", type: :object, properties: %{ @@ -17,13 +18,13 @@ defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Withdrawal do l1_transaction_hash: General.FullHashNullable, l2_timestamp: General.TimestampNullable, l2_transaction_hash: General.FullHash, - msg_data: General.HexStringNullable, + msg_data: General.HexDataNullable, msg_gas_limit: General.IntegerStringNullable, msg_nonce: %Schema{type: :integer}, msg_nonce_raw: General.IntegerString, msg_nonce_version: %Schema{type: :integer}, - msg_sender_address_hash: General.FullHashNullable, - msg_target_address_hash: General.FullHashNullable, + msg_sender_address_hash: General.AddressHashNullable, + msg_target_address_hash: General.AddressHashNullable, msg_value: General.IntegerStringNullable, portal_contract_address_hash: General.AddressHashNullable, status: %Schema{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex index 8d06d1d96fc..5efa6b22780 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Account do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "AccountAbstractionAccount", description: "Account struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex index e7703f5b67d..a70305cc645 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Bundle do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "AccountAbstractionBundle", description: "Bundle struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex index fbb782dcf2e..21d5b72fd05 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Bundler do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "AccountAbstractionBundler", description: "Bundler struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex index 3f0391f7033..99aa275e5b9 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Factory do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "AccountAbstractionFactory", description: "Factory struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex index 92bfdf9d137..6d64ed80152 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Paymaster do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "AccountAbstractionPaymaster", description: "Paymaster struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex index 13847ecc7b0..a8b39653bfd 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex @@ -6,6 +6,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.{Address, AddressNullable, General} + alias BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation.{RawV06, RawV07ToV09} alias OpenApiSpex.Schema OpenApiSpex.schema(%{ @@ -15,80 +16,22 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation do hash: General.FullHash, sender: Address, nonce: General.FullHash, - call_data: General.HexString, + call_data: General.HexData, call_gas_limit: General.IntegerString, verification_gas_limit: General.IntegerString, pre_verification_gas: General.IntegerString, max_fee_per_gas: General.IntegerString, max_priority_fee_per_gas: General.IntegerString, - signature: General.HexString, + signature: General.HexData, raw: %Schema{ description: "Raw user operation data.", anyOf: [ - %Schema{ - description: "Raw user operation data v0.6.", - type: :object, - properties: %{ - sender: General.AddressHash, - nonce: General.IntegerString, - init_code: General.HexString, - call_data: General.HexString, - call_gas_limit: General.IntegerString, - verification_gas_limit: General.IntegerString, - pre_verification_gas: General.IntegerString, - max_fee_per_gas: General.IntegerString, - max_priority_fee_per_gas: General.IntegerString, - paymaster_and_data: General.HexString, - signature: General.HexString - }, - required: [ - :sender, - :nonce, - :init_code, - :call_data, - :call_gas_limit, - :verification_gas_limit, - :pre_verification_gas, - :max_fee_per_gas, - :max_priority_fee_per_gas, - :paymaster_and_data, - :signature - ], - nullable: false, - additionalProperties: false - }, - %Schema{ - description: "Raw user operation data v0.7-v0.9.", - type: :object, - properties: %{ - sender: General.AddressHash, - nonce: General.IntegerString, - init_code: General.HexString, - call_data: General.HexString, - account_gas_limits: General.FullHash, - pre_verification_gas: General.IntegerString, - gas_fees: General.FullHash, - paymaster_and_data: General.HexString, - signature: General.HexString - }, - required: [ - :sender, - :nonce, - :init_code, - :call_data, - :account_gas_limits, - :pre_verification_gas, - :gas_fees, - :paymaster_and_data, - :signature - ], - nullable: false, - additionalProperties: false - } + RawV06, + RawV07ToV09 ] }, aggregator: General.AddressHashNullable, - aggregator_signature: General.HexStringNullable, + aggregator_signature: General.HexDataNullable, entry_point: Address, entry_point_version: %Schema{ type: :string, @@ -104,7 +47,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation do factory: AddressNullable, paymaster: AddressNullable, status: %Schema{type: :boolean, nullable: false}, - revert_reason: General.HexStringNullable, + revert_reason: General.HexDataNullable, gas: General.IntegerString, gas_price: General.IntegerString, gas_used: General.IntegerString, @@ -119,7 +62,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation do consensus: %Schema{type: :boolean, nullable: true}, timestamp: General.TimestampNullable, execute_target: AddressNullable, - execute_call_data: General.HexStringNullable, + execute_call_data: General.HexDataNullable, decoded_call_data: %Schema{allOf: [General.DecodedInput], nullable: true}, decoded_execute_call_data: %Schema{allOf: [General.DecodedInput], nullable: true} }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation/raw.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation/raw.ex new file mode 100644 index 00000000000..c944c8e4247 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation/raw.ex @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation.RawV06 do + @moduledoc """ + Raw user operation data for EntryPoint v0.6. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + title: "UserOperationRawV06", + description: "Raw user operation data v0.6.", + type: :object, + properties: %{ + sender: General.AddressHash, + nonce: General.IntegerString, + init_code: General.HexData, + call_data: General.HexData, + call_gas_limit: General.IntegerString, + verification_gas_limit: General.IntegerString, + pre_verification_gas: General.IntegerString, + max_fee_per_gas: General.IntegerString, + max_priority_fee_per_gas: General.IntegerString, + paymaster_and_data: General.HexData, + signature: General.HexData + }, + required: [ + :sender, + :nonce, + :init_code, + :call_data, + :call_gas_limit, + :verification_gas_limit, + :pre_verification_gas, + :max_fee_per_gas, + :max_priority_fee_per_gas, + :paymaster_and_data, + :signature + ], + nullable: false, + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation.RawV07ToV09 do + @moduledoc """ + Raw user operation data for EntryPoint v0.7 through v0.9. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + title: "UserOperationRawV07ToV09", + description: "Raw user operation data v0.7-v0.9.", + type: :object, + properties: %{ + sender: General.AddressHash, + nonce: General.IntegerString, + init_code: General.HexData, + call_data: General.HexData, + account_gas_limits: General.FullHash, + pre_verification_gas: General.IntegerString, + gas_fees: General.FullHash, + paymaster_and_data: General.HexData, + signature: General.HexData + }, + required: [ + :sender, + :nonce, + :init_code, + :call_data, + :account_gas_limits, + :pre_verification_gas, + :gas_fees, + :paymaster_and_data, + :signature + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex index 1593fe3f759..efd4a7fa465 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Scroll.Batch do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ScrollBatch", description: "Scroll Batch struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex index a2df8336ae5..f08b9d9b537 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex @@ -9,6 +9,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Scroll.Bridge do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ScrollBridge", description: "Scroll Bridge item struct.", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result.ex new file mode 100644 index 00000000000..0eb555a4d09 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result.ex @@ -0,0 +1,534 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + + import BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations, only: [filecoin_robust_address_schema: 0] + + alias BlockScoutWeb.Schemas.Helper + + # On Filecoin, every address-bearing search result gets a `filecoin_robust_address` + # field (see `FilecoinView.preload_and_put_filecoin_robust_address_to_search_results/1`). + def address_chain_type_fields(schema) do + case Application.get_env(:explorer, :chain_type) do + :filecoin -> + Helper.extend_schema(schema, + properties: %{filecoin_robust_address: filecoin_robust_address_schema()}, + required: [:filecoin_robust_address] + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.EnsInfo do + @moduledoc """ + ENS info attached to address-type search results (`BENS.get_address/1`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultEnsInfo", + type: :object, + properties: %{ + name: %Schema{type: :string, nullable: false, description: "Resolved ENS domain name."}, + expiry_date: %Schema{type: :string, nullable: true, description: "Domain expiry date."}, + names_count: %Schema{type: :integer, nullable: false, minimum: 0}, + address_hash: General.AddressHash, + # Opaque protocol metadata; present only for ens_domain results (BENS multiprotocol). + protocol: %Schema{nullable: true, description: "ENS protocol metadata."}, + protocol_dapp_url: %Schema{type: :string, nullable: true}, + protocol_dapp_logo: %Schema{type: :string, nullable: true} + }, + required: [:name, :expiry_date, :names_count, :address_hash, :protocol_dapp_url, :protocol_dapp_logo], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Token do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Search.Result.ChainTypeCustomizations + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SearchResultToken", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["token"], nullable: false}, + name: %Schema{type: :string, nullable: true}, + symbol: %Schema{type: :string, nullable: true}, + address_hash: General.AddressHash, + token_url: %Schema{type: :string, nullable: false}, + address_url: %Schema{type: :string, nullable: false}, + icon_url: %Schema{type: :string, nullable: true}, + token_type: %Schema{type: :string, nullable: true}, + is_smart_contract_verified: %Schema{type: :boolean, nullable: true}, + exchange_rate: General.FloatStringNullable, + total_supply: General.IntegerStringNullable, + circulating_market_cap: General.FloatStringNullable, + is_verified_via_admin_panel: %Schema{type: :boolean, nullable: true}, + certified: %Schema{type: :boolean, nullable: false}, + priority: %Schema{type: :integer, nullable: false}, + reputation: %Schema{type: :string, enum: ["ok", "scam"], nullable: false}, + is_smart_contract_address: %Schema{type: :boolean, nullable: true} + }, + required: [ + :type, + :name, + :symbol, + :address_hash, + :token_url, + :address_url, + :icon_url, + :token_type, + :is_smart_contract_verified, + :exchange_rate, + :total_supply, + :circulating_market_cap, + :is_verified_via_admin_panel, + :certified, + :priority, + :reputation, + :is_smart_contract_address + ], + additionalProperties: false + } + |> ChainTypeCustomizations.address_chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.AddressOrContract do + @moduledoc """ + Search result for an address or smart contract (these share one shape, + distinguished by `type`). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Search.Result.{ChainTypeCustomizations, EnsInfo} + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SearchResultAddressOrContract", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["address", "contract"], nullable: false}, + name: %Schema{type: :string, nullable: true}, + address_hash: General.AddressHash, + url: %Schema{type: :string, nullable: false}, + is_smart_contract_verified: %Schema{type: :boolean, nullable: true}, + ens_info: %Schema{allOf: [EnsInfo], nullable: true}, + certified: %Schema{type: :boolean, nullable: false}, + priority: %Schema{type: :integer, nullable: false}, + reputation: %Schema{type: :string, enum: ["ok", "scam"], nullable: false}, + is_smart_contract_address: %Schema{type: :boolean, nullable: true} + }, + required: [ + :type, + :name, + :address_hash, + :url, + :is_smart_contract_verified, + :ens_info, + :certified, + :priority, + :reputation, + :is_smart_contract_address + ], + additionalProperties: false + } + |> ChainTypeCustomizations.address_chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Label do + @moduledoc """ + Search result for an address label (public/private tag). `name` (the label's + display name) and `address_hash` are always present. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Search.Result.{ChainTypeCustomizations, EnsInfo} + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SearchResultLabel", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["label"], nullable: false}, + name: %Schema{type: :string, nullable: false}, + address_hash: General.AddressHash, + url: %Schema{type: :string, nullable: false}, + is_smart_contract_verified: %Schema{type: :boolean, nullable: true}, + ens_info: %Schema{allOf: [EnsInfo], nullable: true}, + certified: %Schema{type: :boolean, nullable: false}, + priority: %Schema{type: :integer, nullable: false}, + reputation: %Schema{type: :string, enum: ["ok", "scam"], nullable: false}, + is_smart_contract_address: %Schema{type: :boolean, nullable: true} + }, + required: [ + :type, + :name, + :address_hash, + :url, + :is_smart_contract_verified, + :ens_info, + :certified, + :priority, + :reputation, + :is_smart_contract_address + ], + additionalProperties: false + } + |> ChainTypeCustomizations.address_chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.EnsDomain do + @moduledoc """ + Search result for an ENS domain. `ens_info` is always present; `address_hash` + (and the derived `url`) are `null` when the domain does not resolve to an address. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Search.Result.{ChainTypeCustomizations, EnsInfo} + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SearchResultEnsDomain", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["ens_domain"], nullable: false}, + name: %Schema{type: :string, nullable: true}, + address_hash: General.AddressHashNullable, + url: %Schema{type: :string, nullable: true}, + is_smart_contract_verified: %Schema{type: :boolean, nullable: true}, + ens_info: EnsInfo, + certified: %Schema{type: :boolean, nullable: false}, + priority: %Schema{type: :integer, nullable: false}, + reputation: %Schema{type: :string, enum: ["ok", "scam"], nullable: false}, + is_smart_contract_address: %Schema{type: :boolean, nullable: true} + }, + required: [ + :type, + :name, + :address_hash, + :url, + :is_smart_contract_verified, + :ens_info, + :certified, + :priority, + :reputation, + :is_smart_contract_address + ], + additionalProperties: false + } + |> ChainTypeCustomizations.address_chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.MetadataTag do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Proxy + alias BlockScoutWeb.Schemas.API.V2.Search.Result.{ChainTypeCustomizations, EnsInfo} + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SearchResultMetadataTag", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["metadata_tag"], nullable: false}, + name: %Schema{type: :string, nullable: true}, + address_hash: General.AddressHash, + url: %Schema{type: :string, nullable: false}, + is_smart_contract_verified: %Schema{type: :boolean, nullable: true}, + ens_info: %Schema{allOf: [EnsInfo], nullable: true}, + certified: %Schema{type: :boolean, nullable: false}, + priority: %Schema{type: :integer, nullable: false}, + metadata: Proxy.MetadataTag, + reputation: %Schema{type: :string, enum: ["ok", "scam"], nullable: false}, + is_smart_contract_address: %Schema{type: :boolean, nullable: true} + }, + required: [ + :type, + :name, + :address_hash, + :url, + :is_smart_contract_verified, + :ens_info, + :certified, + :priority, + :metadata, + :reputation, + :is_smart_contract_address + ], + additionalProperties: false + } + |> ChainTypeCustomizations.address_chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Block do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultBlock", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["block"], nullable: false}, + block_number: %Schema{type: :integer, nullable: false, minimum: 0}, + block_hash: General.FullHash, + url: %Schema{type: :string, nullable: false}, + timestamp: General.Timestamp, + block_type: %Schema{type: :string, nullable: false}, + priority: %Schema{type: :integer, nullable: false} + }, + required: [:type, :block_number, :block_hash, :url, :timestamp, :block_type, :priority], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Transaction do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultTransaction", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["transaction"], nullable: false}, + transaction_hash: General.FullHash, + url: %Schema{type: :string, nullable: false}, + timestamp: General.TimestampNullable, + priority: %Schema{type: :integer, nullable: false} + }, + required: [:type, :transaction_hash, :url, :timestamp, :priority], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.UserOperation do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultUserOperation", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["user_operation"], nullable: false}, + user_operation_hash: General.FullHash, + timestamp: General.TimestampNullable, + priority: %Schema{type: :integer, nullable: false} + }, + required: [:type, :user_operation_hash, :timestamp, :priority], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Blob do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultBlob", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["blob"], nullable: false}, + blob_hash: General.FullHash, + timestamp: General.TimestampNullable, + priority: %Schema{type: :integer, nullable: false} + }, + required: [:type, :blob_hash, :timestamp, :priority], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.TacOperation.Operation do + @moduledoc """ + This module defines the schema for the operation object nested in a TAC operation search + result. + + The object is returned by the `tac-operation-lifecycle` microservice (Read API v2) and is + proxied verbatim, so this schema mirrors the microservice contract rather than describing a + Blockscout-owned struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + @type_enum [ + "UNKNOWN", + "TON_TAC_TON", + "TAC_TON", + "TON_TAC" + ] + + @status_enum [ + "pending", + "success", + "failed" + ] + + @blockchain_enum [ + "TAC", + "TON", + "UNKNOWN_BLOCKCHAIN" + ] + + @sender_schema %Schema{ + type: :object, + properties: %{ + address: %Schema{ + type: :string, + nullable: false, + description: "TAC (EVM) or TON address, depending on `blockchain`", + example: "EQDoF2OkxsI3gc5jAuxlqozN9H/SgEOUCopMa1yU4djLaXuL" + }, + blockchain: %Schema{type: :string, enum: @blockchain_enum, nullable: false, example: "TON"} + }, + required: [:address, :blockchain], + additionalProperties: false, + nullable: true + } + + OpenApiSpex.schema(%{ + title: "TacOperation", + description: "TAC operation as returned by the tac-operation-lifecycle service Read API v2.", + type: :object, + properties: %{ + operation_id: %Schema{ + type: :string, + nullable: false, + example: "0xf01646ac36cbbcebd8a5ff09c300d5f3bebdd2fbe0135a377eaa485d9edcc670" + }, + type: %Schema{ + type: :string, + enum: @type_enum, + nullable: false, + description: "Transfer route. Never carries a lifecycle outcome — see `status` and `rollback`.", + example: "TAC_TON" + }, + status: %Schema{ + type: :string, + enum: @status_enum, + nullable: false, + description: "Business outcome of the operation.", + example: "success" + }, + rollback: %Schema{ + type: :boolean, + nullable: false, + description: "Whether a rollback occurred.", + example: false + }, + timestamp: General.Timestamp, + sender: @sender_schema, + error_reason: %Schema{ + type: :string, + nullable: true, + description: + "Short failure label. It is published only when the stored reason is short enough to be a label, so it is legitimately `null` on a failed operation — its absence does not imply success.", + example: "Insufficient Fee" + } + }, + required: [:operation_id, :type, :status, :rollback, :timestamp], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.TacOperation do + @moduledoc """ + Search result for a TAC operation. `tac_operation` is the operation object + returned by the external TAC microservice (Read API v2). + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Search.Result + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResultTacOperation", + type: :object, + properties: %{ + type: %Schema{type: :string, enum: ["tac_operation"], nullable: false}, + tac_operation: Result.TacOperation.Operation, + priority: %Schema{type: :integer, nullable: false} + }, + required: [:type, :tac_operation, :priority], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Search.Result.Item do + @moduledoc """ + A single search result item. Discriminated union on `type`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Search.Result + + OpenApiSpex.schema(%{ + title: "SearchResultItem", + type: :object, + oneOf: [ + Result.Token, + Result.AddressOrContract, + Result.Label, + Result.EnsDomain, + Result.MetadataTag, + Result.Block, + Result.Transaction, + Result.UserOperation, + Result.Blob, + Result.TacOperation + ], + discriminator: %OpenApiSpex.Discriminator{ + propertyName: "type", + mapping: %{ + "token" => "#/components/schemas/SearchResultToken", + "address" => "#/components/schemas/SearchResultAddressOrContract", + "contract" => "#/components/schemas/SearchResultAddressOrContract", + "label" => "#/components/schemas/SearchResultLabel", + "ens_domain" => "#/components/schemas/SearchResultEnsDomain", + "metadata_tag" => "#/components/schemas/SearchResultMetadataTag", + "block" => "#/components/schemas/SearchResultBlock", + "transaction" => "#/components/schemas/SearchResultTransaction", + "user_operation" => "#/components/schemas/SearchResultUserOperation", + "blob" => "#/components/schemas/SearchResultBlob", + "tac_operation" => "#/components/schemas/SearchResultTacOperation" + } + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result_item.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result_item.ex deleted file mode 100644 index aea2f36eca2..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/result_item.ex +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.Search.ResultItem do - @moduledoc """ - This module defines the schema for a single item of the search results. - - Only `tac_operation` is typed so far. The other result types (address, block, transaction, - token, etc.) are not described individually yet, so additional properties are deliberately - allowed and this schema only constrains an item that carries a TAC operation. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.Search.TacOperation - - OpenApiSpex.schema(%{ - title: "SearchResultItem", - description: "Single search result. The shape depends on `type`; only `tac_operation` results are fully described.", - type: :object, - properties: %{ - tac_operation: TacOperation - }, - required: [] - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex index 7534eba4feb..7d755482441 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex @@ -5,7 +5,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Search.Results do """ require OpenApiSpex - alias BlockScoutWeb.Schemas.API.V2.Search.ResultItem + alias BlockScoutWeb.Schemas.API.V2.Search.Result alias OpenApiSpex.Schema OpenApiSpex.schema(%{ @@ -13,9 +13,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.Search.Results do description: "Search results containing blocks, transactions, and addresses", type: :object, properties: %{ - items: %Schema{type: :array, items: ResultItem}, + items: %Schema{type: :array, items: Result.Item}, next_page_params: %Schema{type: :object, nullable: true, additionalProperties: true} }, - required: [] + required: [:items, :next_page_params] }) end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/tac_operation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/tac_operation.ex deleted file mode 100644 index 286ef8673e8..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/tac_operation.ex +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.Schemas.API.V2.Search.TacOperation do - @moduledoc """ - This module defines the schema for a TAC operation in the search results. - - The object is returned by the `tac-operation-lifecycle` microservice (Read API v2) and is - proxied verbatim, so this schema mirrors the microservice contract rather than describing a - Blockscout-owned struct. - """ - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.General - alias OpenApiSpex.Schema - - @type_enum [ - "UNKNOWN", - "TON_TAC_TON", - "TAC_TON", - "TON_TAC" - ] - - @status_enum [ - "pending", - "success", - "failed" - ] - - @blockchain_enum [ - "TAC", - "TON", - "UNKNOWN_BLOCKCHAIN" - ] - - @sender_schema %Schema{ - type: :object, - properties: %{ - address: %Schema{ - type: :string, - nullable: false, - description: "TAC (EVM) or TON address, depending on `blockchain`", - example: "EQDoF2OkxsI3gc5jAuxlqozN9H/SgEOUCopMa1yU4djLaXuL" - }, - blockchain: %Schema{type: :string, enum: @blockchain_enum, nullable: false, example: "TON"} - }, - required: [:address, :blockchain], - additionalProperties: false, - nullable: true - } - - OpenApiSpex.schema(%{ - title: "TacOperationSearchResult", - description: "TAC operation as returned by the tac-operation-lifecycle service Read API v2.", - type: :object, - properties: %{ - operation_id: %Schema{ - type: :string, - nullable: false, - example: "0xf01646ac36cbbcebd8a5ff09c300d5f3bebdd2fbe0135a377eaa485d9edcc670" - }, - type: %Schema{ - type: :string, - enum: @type_enum, - nullable: false, - description: "Transfer route. Never carries a lifecycle outcome — see `status` and `rollback`.", - example: "TAC_TON" - }, - status: %Schema{ - type: :string, - enum: @status_enum, - nullable: false, - description: "Business outcome of the operation.", - example: "success" - }, - rollback: %Schema{ - type: :boolean, - nullable: false, - description: "Whether a rollback occurred.", - example: false - }, - timestamp: General.Timestamp, - sender: @sender_schema, - error_reason: %Schema{ - type: :string, - nullable: true, - description: - "Short failure label. It is published only when the stored reason is short enough to be a label, so it is legitimately `null` on a failed operation — its absence does not imply success.", - example: "Insufficient Fee" - } - }, - required: [:operation_id, :type, :status, :rollback, :timestamp], - additionalProperties: false - }) -end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/deposit.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/deposit.ex new file mode 100644 index 00000000000..1ecb856b936 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/deposit.ex @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Shibarium.Deposit do + @moduledoc """ + This module defines the schema for the Shibarium Deposit struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "ShibariumDeposit", + description: "Shibarium deposit item that bridges assets from the parent chain to Shibarium.", + type: :object, + properties: %{ + l1_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "Parent chain block in which the deposit was initiated." + }, + l1_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the parent chain transaction that initiates the deposit." + ), + l2_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the Shibarium transaction that completes the deposit." + ), + user: %Schema{ + allOf: [Address], + description: + "Address of the user that initiated the deposit on the parent chain; the same address acts as the recipient on Shibarium." + }, + timestamp: + Helper.describe_inline( + General.TimestampNullable.schema(), + "Timestamp of the parent chain block that contains the deposit transaction." + ) + }, + required: [ + :l1_block_number, + :l1_transaction_hash, + :l2_transaction_hash, + :user, + :timestamp + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/withdrawal.ex new file mode 100644 index 00000000000..0d9c61685a5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/shibarium/withdrawal.ex @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Shibarium.Withdrawal do + @moduledoc """ + This module defines the schema for the Shibarium Withdrawal struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "ShibariumWithdrawal", + description: "Shibarium withdrawal item that bridges assets from Shibarium to the parent chain.", + type: :object, + properties: %{ + l2_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "Shibarium block in which the withdrawal was initiated." + }, + l2_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the Shibarium transaction that initiates the withdrawal." + ), + l1_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the parent chain transaction that completes the withdrawal." + ), + user: %Schema{ + allOf: [Address], + description: + "Address of the user that initiated the withdrawal on Shibarium; the same address acts as the recipient on the parent chain." + }, + timestamp: + Helper.describe_inline( + General.TimestampNullable.schema(), + "Timestamp of the Shibarium block that contains the withdrawal transaction." + ) + }, + required: [ + :l2_block_number, + :l2_transaction_hash, + :l1_transaction_hash, + :user, + :timestamp + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex index e527e3a8376..2133c38218f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex @@ -23,7 +23,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations do |> Helper.extend_schema( properties: %{ package_name: %Schema{type: :string, nullable: true}, - github_repository_metadata: %Schema{type: :object, nullable: true} + github_repository_metadata: %Schema{type: :object, nullable: true, additionalProperties: true} }, required: [] ) @@ -32,7 +32,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations do schema |> Helper.extend_schema( properties: %{ - zk_compiler_version: %Schema{type: :string, nullable: true} + zk_compiler_version: %Schema{type: :string, nullable: true}, + # zksync stores optimization_runs as a string (not an integer like other chains) + optimization_runs: %Schema{type: :string, nullable: true} }, required: [] ) @@ -62,6 +64,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do alias BlockScoutWeb.Schemas.API.V2.General alias BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations + alias Explorer.Chain.SmartContract alias OpenApiSpex.Schema OpenApiSpex.schema( @@ -70,7 +73,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do type: :object, properties: %{ file_path: %Schema{type: :string, nullable: true}, - creation_status: %Schema{type: :string, nullable: true}, + creation_status: %Schema{type: :string, enum: ["success", "failed", "selfdestructed"], nullable: true}, source_code: %Schema{type: :string, nullable: true}, deployed_bytecode: %Schema{type: :string, nullable: true}, coin_balance: %Schema{type: :string, nullable: true}, @@ -81,7 +84,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do enum: ["solidity", "vyper", "yul", "scilla", "stylus_rust", "geas"], nullable: true }, - license_type: %Schema{type: :string, nullable: true}, + license_type: %Schema{ + type: :string, + enum: SmartContract.license_types_enum() |> Keyword.keys() |> Enum.map(&Atom.to_string/1), + nullable: true + }, market_cap: %Schema{type: :string, nullable: true}, optimization_enabled: %Schema{type: :boolean, nullable: true}, reputation: %Schema{type: :string, nullable: true}, @@ -89,15 +96,31 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do verified_at: %Schema{type: :string, format: :"date-time", nullable: true}, verification_metadata: %Schema{type: :object, nullable: true}, verified_twin_address_hash: %Schema{type: :string, nullable: true}, - compiler_settings: %Schema{type: :object, nullable: true}, + compiler_settings: %Schema{type: :object, nullable: true, additionalProperties: true}, optimization_runs: %Schema{type: :integer, nullable: true}, sourcify_repo_url: %Schema{type: :string, nullable: true}, + # Each entry is a `[decoded_value, abi_input]` pair: the decoded value is a string + # (scalar) or a (possibly nested) array (array/tuple types), and `abi_input` is the + # ABI input descriptor (`type`, `name`, ...). OpenAPI 3.0 can't express the fixed + # 2-tuple, so it's modeled as an array whose items are one of those shapes. decoded_constructor_args: %Schema{ type: :array, items: %Schema{ type: :array, items: %Schema{ - anyOf: [%Schema{type: :array}, %Schema{type: :object}, %Schema{type: :string}] + anyOf: [ + %Schema{type: :string}, + %Schema{type: :array}, + %Schema{ + type: :object, + description: "ABI input descriptor", + properties: %{ + type: %Schema{type: :string}, + name: %Schema{type: :string, nullable: true} + }, + additionalProperties: true + } + ] } }, nullable: true @@ -119,7 +142,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do type: :array, items: %Schema{ type: :object, - properties: %{name: %Schema{type: :string, nullable: true}, address_hash: General.AddressHash} + properties: %{name: %Schema{type: :string, nullable: true}, address_hash: General.AddressHash}, + required: [:name, :address_hash], + additionalProperties: false }, nullable: true }, @@ -141,7 +166,19 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do nullable: true }, certified: %Schema{type: :boolean}, - conflicting_implementations: %Schema{type: :array, nullable: true, items: %Schema{type: :object}}, + conflicting_implementations: %Schema{ + type: :array, + nullable: true, + items: %Schema{ + type: :object, + properties: %{ + proxy_type: General.ProxyType, + implementations: %Schema{type: :array, items: General.Implementation, nullable: false} + }, + required: [:proxy_type, :implementations], + additionalProperties: false + } + }, abi: %Schema{type: :array, nullable: true, items: %Schema{type: :object}}, is_changed_bytecode: %Schema{type: :boolean, nullable: true}, is_partially_verified: %Schema{type: :boolean, nullable: true}, @@ -171,6 +208,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ListItem do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations + alias Explorer.Chain.SmartContract alias OpenApiSpex.Schema OpenApiSpex.schema( @@ -188,7 +226,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ListItem do market_cap: %Schema{type: :string, nullable: true}, has_constructor_args: %Schema{type: :boolean, nullable: true}, coin_balance: %Schema{type: :string, nullable: true}, - license_type: %Schema{type: :string, nullable: true}, + license_type: %Schema{ + type: :string, + enum: SmartContract.license_types_enum() |> Keyword.keys() |> Enum.map(&Atom.to_string/1), + nullable: true + }, certified: %Schema{type: :boolean}, reputation: %Schema{type: :string, nullable: true} }, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex index 1efe6233dc7..dceb14f033f 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex @@ -5,6 +5,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.Counters do alias BlockScoutWeb.Schemas.API.V2.General OpenApiSpex.schema(%{ + title: "SmartContractCounters", description: "Smart contracts counters", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/counters.ex new file mode 100644 index 00000000000..0ab545e9e01 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/counters.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stability.Counters do + @moduledoc """ + This module defines the schema for the response from /api/v2/validators/stability/counters. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StabilityValidatorsCounters", + description: "Aggregate counters describing the Stability chain validator set.", + type: :object, + properties: %{ + validators_count: + Helper.describe_inline( + General.NonNegativeIntegerString.schema(), + "Total number of validators known on the Stability chain across all operational states." + ), + new_validators_count_24h: + Helper.describe_inline( + General.NonNegativeIntegerString.schema(), + "Number of validators that joined the set within the last 24 hours." + ), + active_validators_count: + Helper.describe_inline( + General.NonNegativeIntegerString.schema(), + "Number of validators currently in the `active` operational state." + ), + active_validators_percentage: %Schema{ + type: :number, + format: :float, + minimum: 0, + maximum: 100, + nullable: true, + description: + "Share of `active` validators in the total set, expressed as a percentage and floored to two decimal places. `null` when there are no validators." + } + }, + required: [ + :validators_count, + :new_validators_count_24h, + :active_validators_count, + :active_validators_percentage + ], + additionalProperties: false, + example: %{ + validators_count: "9", + new_validators_count_24h: "2", + active_validators_count: "3", + active_validators_percentage: 33.33 + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/validator.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/validator.ex new file mode 100644 index 00000000000..8435030ae68 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stability/validator.ex @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stability.Validator do + @moduledoc """ + This module defines the schema for the Stability Validator struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address + alias Ecto.Enum, as: EctoEnum + alias Explorer.Chain.Stability.Validator, as: ValidatorStability + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StabilityValidator", + description: + "Validator on the Stability chain. Stability validators are the addresses authorized to produce blocks; each entry tracks the validator's operational state and the number of blocks it has produced so far.", + type: :object, + properties: %{ + address: Address, + state: %Schema{ + type: :string, + # Keep in sync with `state` field of `Explorer.Chain.Stability.Validator`. + enum: EctoEnum.values(ValidatorStability, :state) |> Enum.map(&to_string/1), + nullable: true, + description: + "Operational state of the validator (`active` — producing blocks, `probation` — missed blocks but still in the active set, `inactive` — removed from the active set)." + }, + blocks_validated_count: %Schema{ + type: :integer, + minimum: 0, + nullable: false, + description: "Total number of blocks produced by this validator." + } + }, + required: [ + :address, + :state, + :blocks_validated_count + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/chart.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/chart.ex new file mode 100644 index 00000000000..443e5a201ab --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/chart.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stats.TransactionsChartDataPoint do + @moduledoc """ + Single data point of the `/api/v2/stats/charts/transactions` chart. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsTransactionsChartDataPoint", + type: :object, + properties: %{ + date: %Schema{type: :string, format: :date, nullable: false, description: "Day the data point refers to."}, + transactions_count: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: "Number of transactions on that day." + } + }, + required: [:date, :transactions_count], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Stats.MarketChartDataPoint do + @moduledoc """ + Single data point of the `/api/v2/stats/charts/market` chart. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsMarketChartDataPoint", + type: :object, + properties: %{ + date: %Schema{type: :string, format: :date, nullable: false, description: "Day the data point refers to."}, + closing_price: General.FloatStringNullable, + market_cap: General.FloatStringNullable, + tvl: General.FloatStringNullable + }, + required: [:date, :closing_price, :market_cap, :tvl], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Stats.SecondaryCoinMarketChartDataPoint do + @moduledoc """ + Single data point of the `/api/v2/stats/charts/secondary-coin-market` chart. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsSecondaryCoinMarketChartDataPoint", + type: :object, + properties: %{ + date: %Schema{type: :string, format: :date, nullable: false, description: "Day the data point refers to."}, + closing_price: General.FloatStringNullable + }, + required: [:date, :closing_price], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/gas_prices.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/gas_prices.ex new file mode 100644 index 00000000000..64722fe0bf6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/gas_prices.ex @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stats.GasPriceInfo do + @moduledoc """ + Detailed gas price info for a single tier, as produced by + `Explorer.Chain.Cache.GasPriceOracle.compose_gas_price/5`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsGasPriceInfo", + type: :object, + properties: %{ + price: %Schema{type: :number, format: :float, nullable: true, description: "Estimated gas price, in Gwei."}, + time: %Schema{ + type: :number, + format: :float, + nullable: true, + description: "Estimated time until inclusion, in ms." + }, + base_fee: %Schema{type: :number, format: :float, nullable: true, description: "Base fee, in Gwei."}, + priority_fee: %Schema{ + type: :number, + format: :float, + nullable: true, + description: "Priority fee including base fee, in Gwei." + }, + fiat_price: General.FloatStringNullable, + priority_fee_wei: General.IntegerStringNullable, + wei: General.IntegerStringNullable + }, + required: [:price, :time, :base_fee, :priority_fee, :fiat_price, :priority_fee_wei, :wei], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Stats.GasPrices.Simple do + @moduledoc """ + Gas prices per tier as plain Gwei numbers — the default `/api/v2/stats` shape + (when the `updated-gas-oracle: true` request header is absent). + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsGasPricesSimple", + description: "Gas prices per tier as plain Gwei values.", + type: :object, + properties: %{ + slow: %Schema{type: :number, format: :float, nullable: true}, + average: %Schema{type: :number, format: :float, nullable: true}, + fast: %Schema{type: :number, format: :float, nullable: true} + }, + required: [:slow, :average, :fast], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Stats.GasPrices.Detailed do + @moduledoc """ + Gas prices per tier as detailed objects — returned by `/api/v2/stats` with the + `updated-gas-oracle: true` request header. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Stats.GasPriceInfo + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "StatsGasPricesDetailed", + description: "Gas prices per tier as detailed objects.", + type: :object, + properties: %{ + slow: %Schema{allOf: [GasPriceInfo], nullable: true}, + average: %Schema{allOf: [GasPriceInfo], nullable: true}, + fast: %Schema{allOf: [GasPriceInfo], nullable: true} + }, + required: [:slow, :average, :fast], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex index eee389ef387..7f47156aa52 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex @@ -85,6 +85,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Stats.GasPrices alias BlockScoutWeb.Schemas.API.V2.Stats.Response.ChainTypeCustomizations alias OpenApiSpex.Schema @@ -99,7 +100,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response do coin_price: General.FloatStringNullable, coin_price_change_percentage: %Schema{type: :number, format: :float, nullable: true}, gas_price_updated_at: General.TimestampNullable, - gas_prices: %Schema{type: :object, nullable: true}, + gas_prices: %Schema{anyOf: [GasPrices.Simple, GasPrices.Detailed], nullable: true}, gas_prices_update_in: %Schema{type: :integer, nullable: true}, gas_used_today: %Schema{anyOf: [General.IntegerStringNullable, %Schema{type: :integer}]}, market_cap: General.FloatString, @@ -112,7 +113,29 @@ defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response do total_transactions: General.IntegerString, transactions_today: General.IntegerString, tvl: General.IntegerStringNullable - } + }, + # Every base field is always rendered (static map in StatsController.stats/2); + # chain-type/identity fields are added conditionally and stay optional. + required: [ + :average_block_time, + :coin_image, + :coin_price, + :coin_price_change_percentage, + :gas_price_updated_at, + :gas_prices, + :gas_prices_update_in, + :gas_used_today, + :market_cap, + :network_utilization_percentage, + :secondary_coin_image, + :secondary_coin_price, + :static_gas_price, + :total_addresses, + :total_blocks, + :total_transactions, + :transactions_today, + :tvl + ] } |> ChainTypeCustomizations.chain_type_fields() |> ChainTypeCustomizations.chain_identity_fields() diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex index 788d4504a2d..15ed7541005 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex @@ -58,6 +58,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token do alias BlockScoutWeb.Schemas.API.V2.General alias BlockScoutWeb.Schemas.API.V2.Token.{ChainTypeCustomizations, Type} + alias BlockScoutWeb.Schemas.Helper alias Explorer.Chain.Address.Reputation alias OpenApiSpex.Schema @@ -72,12 +73,28 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token do decimals: General.IntegerStringNullable, type: %Schema{allOf: [Type], nullable: true}, holders_count: General.IntegerStringNullable, - exchange_rate: General.FloatStringNullable, + exchange_rate: + Helper.describe_inline( + General.FloatStringNullable.schema(), + "Price of one displayed unit of the token. For an ERC-8056 token that is the UI amount, the one that trades, so a fiat value is `raw_amount * ui_multiplier / 1e18 / 10 ^ decimals * exchange_rate`." + ), volume_24h: General.FloatStringNullable, - total_supply: General.IntegerStringNullable, + total_supply: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "Raw `totalSupply()` of the contract. ERC-8056 leaves it unscaled, so displaying it takes the same `ui_multiplier` as any other raw amount." + ), icon_url: General.URLNullable, - circulating_market_cap: General.FloatStringNullable, - circulating_supply: General.FloatStringNullable, + circulating_market_cap: + Helper.describe_inline( + General.FloatStringNullable.schema(), + "Market capitalisation as reported by the price source. A fiat figure: never scaled." + ), + circulating_supply: + Helper.describe_inline( + General.FloatStringNullable.schema(), + "Circulating supply as reported by the price source, in whichever unit that source uses. Not scaled, since the base cannot be told." + ), reputation: %Schema{ type: :string, enum: Reputation.enum_values(), @@ -91,7 +108,19 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token do nullable: true }, foreign_address: %Schema{type: :string, pattern: General.address_hash_pattern(), nullable: true}, - origin_chain_id: General.IntegerStringNullable + origin_chain_id: General.IntegerStringNullable, + ui_multiplier: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "ERC-8056 multiplier in effect at the time of the request, with 18 decimals of precision." + ), + new_ui_multiplier: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "ERC-8056 multiplier scheduled to replace `ui_multiplier` at `ui_multiplier_effective_at`." + ), + ui_multiplier_effective_at: + Helper.describe_inline(General.TimestampNullable.schema(), "Moment `new_ui_multiplier` takes effect.") }, required: [ :address_hash, @@ -106,7 +135,10 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token do :icon_url, :circulating_market_cap, :circulating_supply, - :reputation + :reputation, + :ui_multiplier, + :new_ui_multiplier, + :ui_multiplier_effective_at ], additionalProperties: false } @@ -123,7 +155,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token.Type do use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - @token_types ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] + @token_types ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984", "ERC-8056"] if @chain_type == :zilliqa do @chain_type_token_types ["ZRC-2"] diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex index beb1c8cae0f..4ae1d087435 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex @@ -2,11 +2,12 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token.Counters do @moduledoc """ This module defines the schema for the response from /api/v2/tokens/:address_hash_param/counters. - Example response: {"token_holders_count":"0","transfers_count":"0"} + Example response: {"token_holders_count":"0","transfers_count":"0","ui_multiplier_changes_count":"0"} """ require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema OpenApiSpex.schema(%Schema{ @@ -15,10 +16,15 @@ defmodule BlockScoutWeb.Schemas.API.V2.Token.Counters do type: :object, properties: %{ token_holders_count: General.IntegerString, - transfers_count: General.IntegerString + transfers_count: General.IntegerString, + ui_multiplier_changes_count: + Helper.describe_inline( + General.IntegerString.schema(), + "Number of ERC-8056 multiplier changes listed by `/api/v2/tokens/{address_hash}/ui-multiplier-changes`. Always `\"0\"` for a token that does not implement ERC-8056." + ) }, - required: [:token_holders_count, :transfers_count], + required: [:token_holders_count, :transfers_count, :ui_multiplier_changes_count], additionalProperties: false, - example: %{token_holders_count: "0", transfers_count: "0"} + example: %{token_holders_count: "0", transfers_count: "0", ui_multiplier_changes_count: "0"} }) end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/ui_multiplier_change.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/ui_multiplier_change.ex new file mode 100644 index 00000000000..edde0bb4b9d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/ui_multiplier_change.ex @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Token.UIMultiplierChange do + @moduledoc """ + This module defines the schema for one entry of the ERC-8056 multiplier + history of a token, as returned by + /api/v2/tokens/:address_hash_param/ui-multiplier-changes. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%Schema{ + title: "TokenUIMultiplierChange", + description: + "A single `UIMultiplierUpdated` event of an ERC-8056 token: the multiplier it replaces, the one it schedules, and the moment that one takes effect.", + type: :object, + properties: %{ + block_number: %Schema{type: :integer}, + block_hash: General.FullHash, + timestamp: + Helper.describe_inline( + General.Timestamp.schema(), + "Moment the change was announced, i.e. the timestamp of the block holding the event." + ), + transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Transaction the event was emitted from. `null` for changes recorded before this field existed and for chains that emit logs outside of a transaction." + ), + log_index: %Schema{type: :integer}, + old_multiplier: + Helper.describe_inline( + General.IntegerString.schema(), + "Multiplier in force until `effective_at`, with 18 decimals of precision." + ), + new_multiplier: + Helper.describe_inline( + General.IntegerString.schema(), + "Multiplier in force from `effective_at` on, with 18 decimals of precision." + ), + effective_at: + Helper.describe_inline( + General.Timestamp.schema(), + "Moment `new_multiplier` replaces `old_multiplier`. A moment still in the future means the change is announced but pending." + ) + }, + required: [ + :block_number, + :block_hash, + :timestamp, + :transaction_hash, + :log_index, + :old_multiplier, + :new_multiplier, + :effective_at + ], + additionalProperties: false, + example: %{ + block_number: 12_345, + block_hash: "0x3a2b4c07b7e4ec0a2e67d8e6e2f5c8f6a1f1f0c1c2f7d1d8e5b0a9c3d4e5f607", + timestamp: "2026-09-01T00:00:00.000000Z", + transaction_hash: "0x9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0", + log_index: 3, + old_multiplier: "1000000000000000000", + new_multiplier: "2000000000000000000", + effective_at: "2026-09-05T00:00:00.000000Z" + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex index 96078c5488a..838afd6acae 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex @@ -43,7 +43,19 @@ defmodule BlockScoutWeb.Schemas.API.V2.TokenInstance do description: "Mime type of the media in media_url", nullable: true }, - media_url: General.URLNullable + media_url: General.URLNullable, + image_media_type: %Schema{ + type: :string, + enum: ["image", "video", "html"], + description: "Media type category of the token instance image URL", + nullable: true + }, + animation_media_type: %Schema{ + type: :string, + enum: ["image", "video", "html"], + description: "Media type category of the token instance animation URL", + nullable: true + } }, required: [ :id, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_token_instances_list.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_token_instances_list.ex new file mode 100644 index 00000000000..5a62456ff60 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_token_instances_list.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.TokenInstanceInTokenInstancesList do + @moduledoc """ + This module defines the schema for a token instance item returned by the + `GET /api/v2/tokens/:address_hash/instances` endpoint. Unlike TokenInstanceInList, + the `token` field is omitted because all items belong to the same token contract. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Token.Type, TokenInstance} + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + TokenInstance.schema() + |> Helper.extend_schema( + title: "TokenInstanceInTokenInstancesList", + # token_type and value are present on the holder-filtered path but absent on + # the unfiltered path, so they are allowed but not required. + properties: %{ + token_type: Type, + value: General.IntegerStringNullable + } + ) + |> Map.update!(:properties, &Map.delete(&1, :token)) + |> Map.update!(:required, &List.delete(&1, :token)) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex index b150575f2be..24ac4aa081a 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex @@ -32,7 +32,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer do Total, TotalERC1155, TotalERC721, - TotalERC7984, TransactionHashCustomization } @@ -48,7 +47,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer do anyOf: [ TotalERC721, TotalERC1155, - TotalERC7984, Total ], nullable: true @@ -119,33 +117,25 @@ defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TotalERC1155 do end defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.Total do - @moduledoc false - require OpenApiSpex - - alias BlockScoutWeb.Schemas.API.V2.General - - OpenApiSpex.schema(%{ - type: :object, - properties: %{ - value: General.IntegerStringNullable, - decimals: General.IntegerStringNullable - }, - required: [:value, :decimals], - additionalProperties: false - }) -end - -defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TotalERC7984 do - @moduledoc false + @moduledoc """ + `total` shape for fungible-amount token transfers (ERC-20, ERC-8056, ZRC-2, ERC-7984). + """ require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper OpenApiSpex.schema(%{ + title: "TokenTransferTotalFungible", type: :object, properties: %{ value: General.IntegerStringNullable, - decimals: General.IntegerStringNullable + decimals: General.IntegerStringNullable, + ui_multiplier: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "ERC-8056 multiplier that was in force when this transfer happened, with 18 decimals of precision." + ) }, required: [:value, :decimals], additionalProperties: false 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 55ce62bb716..2e9b92a1c5b 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 @@ -4,6 +4,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do 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.Optimism.TransactionWithdrawal alias BlockScoutWeb.Schemas.API.V2.Transaction.Fee alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema @@ -19,7 +20,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do batch_number: %Schema{type: :integer, nullable: true}, status: %Schema{ type: :string, - enum: ZkSyncView.batch_status_enum(), + enum: ZkSyncView.batched_item_lifecycle_status_enum(), nullable: true }, commit_transaction_hash: General.FullHashNullable, @@ -97,18 +98,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do additionalProperties: false } - @optimism_withdrawal_schema %Schema{ - type: :object, - nullable: false, - properties: %{ - nonce: %Schema{type: :integer}, - status: %Schema{type: :string, nullable: false}, - l1_transaction_hash: General.FullHashNullable - }, - required: [:nonce, :status, :l1_transaction_hash], - additionalProperties: false - } - # OP Stack-specific transaction types appended to the `transaction_types` enum # for both `optimism` and `optimism-celo` chain types (the latter resolves to # the `:optimism` chain type with a `{:optimism, :celo}` chain identity). @@ -181,7 +170,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do l1_gas_used: General.IntegerString, op_withdrawals: %Schema{ type: :array, - items: @optimism_withdrawal_schema, + items: TransactionWithdrawal, nullable: false }, op_interop_messages: %Schema{ @@ -192,7 +181,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do status: %Schema{type: :string, nullable: false, enum: ["Sent", "Relayed", "Failed"]}, sender_address_hash: General.AddressHashNullable, target_address_hash: General.AddressHashNullable, - payload: General.HexString, + payload: General.HexData, relay_chain: %Schema{type: :object, nullable: true}, relay_transaction_hash: General.FullHashNullable, init_chain: %Schema{type: :object, nullable: true}, @@ -241,7 +230,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do hash: General.FullHash, method: General.MethodNameNullable, decoded_input: %Schema{allOf: [General.DecodedInput], nullable: true}, - raw_input: General.HexString + raw_input: General.HexData }, additionalProperties: false } @@ -377,7 +366,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction do }, timestamp: General.TimestampNullable, from: Address, - to: Address, + to: %Schema{allOf: [Address], nullable: true}, created_contract: %Schema{allOf: [Address], nullable: true}, confirmations: %Schema{ type: :integer, @@ -424,7 +413,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction do General.DecodedInput, %Schema{ type: :object, - properties: %{raw: %Schema{anyOf: [General.HexString, %Schema{type: :string}], nullable: true}}, + properties: %{raw: %Schema{anyOf: [General.HexData, %Schema{type: :string}], nullable: true}}, required: [:raw], nullable: false, additionalProperties: false @@ -432,7 +421,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction do ], nullable: true }, - raw_input: General.HexString, + raw_input: General.HexData, decoded_input: %Schema{allOf: [General.DecodedInput], nullable: true}, token_transfers: %Schema{type: :array, items: TokenTransfer, nullable: true}, token_transfers_overflow: %Schema{type: :boolean, nullable: true}, 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 index 505fe3886f0..80e61008509 100644 --- 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 @@ -10,6 +10,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.PreviewAddress do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "TransactionPreviewAddress", description: "Lightweight address in transaction preview", type: :object, nullable: true, @@ -51,6 +52,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Preview do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "TransactionPreview", description: "Lightweight transaction preview for social media embeds", type: :object, properties: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex index d56725b4187..ed4acafddd9 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex @@ -13,11 +13,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.RawTrace do properties: %{ callType: %Schema{type: :string, enum: ["call", "callcode", "delegatecall", "staticcall"]}, from: General.AddressHash, - gas: General.HexString, - input: General.HexString, - init: General.HexString, + gas: General.HexQuantity, + input: General.HexData, + init: General.HexData, to: General.AddressHash, - value: General.HexString + value: General.HexQuantity }, required: [:from, :gas, :input, :value], additionalProperties: false @@ -26,8 +26,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.RawTrace do result_schema = %Schema{ type: :object, properties: %{ - gasUsed: General.HexString, - output: General.HexString + gasUsed: General.HexQuantity, + output: General.HexData }, required: [:gasUsed, :output], additionalProperties: false diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex index 7fcf4f0b8a4..77444b6f861 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex @@ -3,46 +3,10 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Response.ChainTypeCustomizati @moduledoc false require OpenApiSpex - alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Optimism.InteropMessage alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema - @op_interop_message_schema %Schema{ - type: :object, - description: "Interop message included in an Optimism transaction", - nullable: false, - properties: %{ - nonce: %Schema{type: :integer, minimum: 0}, - payload: General.HexString, - relay_chain: %Schema{ - type: :object, - properties: %{ - instance_url: %Schema{type: :string, nullable: true}, - chain_id: General.IntegerString, - chain_name: %Schema{type: :string, nullable: true}, - chain_logo: %Schema{type: :string, nullable: true} - }, - nullable: true - }, - relay_transaction_hash: General.FullHash, - sender_address_hash: General.AddressHash, - status: %Schema{type: :string, enum: ["Sent", "Relayed", "Failed"]}, - target_address_hash: General.AddressHash, - unique_id: %Schema{type: :string} - }, - required: [:payload], - example: %{ - "nonce" => 0, - "payload" => "0x30787849009c24f10a91a327a9f2ed94ebc49ee9", - "relay_chain" => nil, - "relay_transaction_hash" => "0x0000000000000000000000000000000000000000000000000000000000000002", - "sender_address_hash" => "0x0000000000000000000000000000000000000003", - "status" => "Relayed", - "target_address_hash" => "0x0000000000000000000000000000000000000004", - "unique_id" => "0000000100000000" - } - } - @doc """ Applies chain-specific field customizations to the given schema based on the configured chain type. @@ -58,7 +22,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Response.ChainTypeCustomizati :optimism -> schema |> Helper.extend_schema( - properties: %{op_interop_messages: %Schema{type: :array, items: @op_interop_message_schema, nullable: false}} + properties: %{op_interop_messages: %Schema{type: :array, items: InteropMessage, nullable: false}} ) _ -> diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex index e3385bb71b0..ff52fb54530 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex @@ -6,6 +6,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.StateChange do require OpenApiSpex alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema OpenApiSpex.schema(%{ @@ -17,7 +18,8 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.StateChange do :change, :is_miner, :token, - :type + :type, + :ui_multiplier ], properties: %{ # Should reference Address schema if available @@ -28,7 +30,12 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.StateChange do is_miner: %Schema{type: :boolean}, token: %Schema{allOf: [Token], nullable: true}, token_id: General.IntegerStringNullable, - type: %Schema{type: :string, enum: ["token", "coin"]} + type: %Schema{type: :string, enum: ["token", "coin"]}, + ui_multiplier: + Helper.describe_inline( + General.IntegerStringNullable.schema(), + "ERC-8056 multiplier `balance_before`, `balance_after` and `change` are to be displayed with, as of this transaction, with 18 decimals of precision. The nested `token.ui_multiplier` is the one in force now, not then." + ) }, additionalProperties: false }) diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex index b2ef898500a..b357bf72fdb 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Withdrawal do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "BeaconWithdrawal", type: :object, properties: %{ index: %Schema{type: :integer, nullable: false}, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex index eca66a8109d..94535468b50 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex @@ -7,6 +7,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Withdrawal.Counter do alias BlockScoutWeb.Schemas.API.V2.General OpenApiSpex.schema(%{ + title: "BeaconWithdrawalCounter", type: :object, properties: %{ withdrawals_count: General.IntegerString, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex index 0f4ef2e2b8b..a7bb906c35b 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex @@ -9,11 +9,12 @@ defmodule BlockScoutWeb.Schemas.API.V2.Zilliqa.Staker do alias OpenApiSpex.Schema OpenApiSpex.schema(%{ + title: "ZilliqaStaker", description: "Zilliqa Staker struct.", type: :object, properties: %{ balance: General.IntegerString, - bls_public_key: General.HexString, + bls_public_key: General.HexData, index: %Schema{type: :integer, nullable: false} }, required: [ diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex index 02b88bda042..cfc19104be0 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex @@ -13,9 +13,9 @@ defmodule BlockScoutWeb.Schemas.API.V2.Zilliqa.Staker.Detailed do OpenApiSpex.schema( Staker.schema() |> Helper.extend_schema( - title: "StakerDetailed", + title: "ZilliqaStakerDetailed", properties: %{ - peer_id: General.HexStringNullable, + peer_id: General.HexDataNullable, control_address: AddressNullable, reward_address: AddressNullable, signing_address: AddressNullable, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch.ex new file mode 100644 index 00000000000..73284aa9ce1 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch.ex @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.ZkSync.Batch do + @moduledoc """ + Schema for the detailed ZkSync batch response. + + Extends `ZkSync.MinimalBatch` with rollup-block range, state root, the two + transaction counts (parent-chain originated and rollup originated), and the + gas prices observed for this batch. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.ZkSync.MinimalBatch + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + MinimalBatch.schema() + |> Helper.extend_schema( + title: "ZkSync.Batch", + description: + "ZkSync rollup batch - a group of rollup blocks settled together on the parent chain - " <> + "with the corresponding parent-chain lifecycle data.", + properties: %{ + root_hash: + Helper.describe_inline( + General.FullHash.schema(), + "State root hash committed for this batch on the parent chain." + ), + l1_transactions_count: %Schema{ + type: :integer, + minimum: 0, + description: "Number of transactions in the batch originating on the parent chain." + }, + l2_transactions_count: %Schema{ + type: :integer, + minimum: 0, + description: "Number of transactions in the batch originating on the rollup." + }, + l1_gas_price: + Helper.describe_inline( + General.NonNegativeIntegerString.schema(), + "Parent-chain gas price observed for this batch, in wei." + ), + l2_fair_gas_price: + Helper.describe_inline( + General.NonNegativeIntegerString.schema(), + "Rollup fair gas price for this batch, in wei." + ), + start_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "First rollup block included in the batch." + }, + end_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "Last rollup block included in the batch." + } + }, + required: [ + :root_hash, + :l1_transactions_count, + :l2_transactions_count, + :l1_gas_price, + :l2_fair_gas_price, + :start_block_number, + :end_block_number + ] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch_list_item.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch_list_item.ex new file mode 100644 index 00000000000..eb522ac48a3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/batch_list_item.ex @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.ZkSync.BatchListItem do + @moduledoc """ + Schema for a ZkSync batch item in list responses. + + Extends `ZkSync.MinimalBatch` with `transactions_count` — the aggregate of + parent-chain and rollup-originated transactions in the batch. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.ZkSync.MinimalBatch + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + MinimalBatch.schema() + |> Helper.extend_schema( + title: "ZkSync.BatchListItem", + description: "ZkSync rollup batch summary for list endpoints.", + properties: %{ + transactions_count: %Schema{ + type: :integer, + minimum: 0, + description: "Total number of transactions in the batch (parent-chain originated plus rollup originated)." + } + }, + required: [:transactions_count] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/confirmed_batch_list_item.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/confirmed_batch_list_item.ex new file mode 100644 index 00000000000..a277c444d6d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/confirmed_batch_list_item.ex @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.ZkSync.ConfirmedBatchListItem do + @moduledoc """ + Schema for a ZkSync batch item in the main-page confirmed-only list. + + Extends `ZkSync.BatchListItem` and narrows `status` to the three values + reachable when the database query filters out batches without a commit + transaction (see `Explorer.Chain.ZkSync.Reader.batches/1` with `confirmed?: true`). + """ + require OpenApiSpex + + alias BlockScoutWeb.API.V2.ZkSyncView + alias BlockScoutWeb.Schemas.API.V2.ZkSync.BatchListItem + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + # Reachable when `Reader.batches(confirmed?: true)` restricts results to batches + # with a non-null `commit_id`: the `lifecycle_status/1` cascade in `ZkSyncView` + # cannot fall through to `"Sealed on L2"`. + @confirmed_status_enum ZkSyncView.batch_lifecycle_status_enum() -- ["Sealed on L2"] + + OpenApiSpex.schema( + BatchListItem.schema() + |> Helper.extend_schema( + title: "ZkSync.ConfirmedBatchListItem", + description: + "ZkSync rollup batch summary for the main-page confirmed list. " <> + "Only batches with an observed commit transaction are included, so " <> + "`status` excludes the `Sealed on L2` value of the full batch lifecycle.", + properties: %{ + status: %Schema{ + type: :string, + enum: @confirmed_status_enum, + description: """ + Lifecycle status of the batch: + * `Sent to L1` - Commit transaction has been submitted on the parent chain. + * `Validated on L1` - Prove transaction has been submitted on the parent chain. + * `Executed on L1` - Execute transaction has been submitted on the parent chain. + """ + } + } + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/minimal_batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/minimal_batch.ex new file mode 100644 index 00000000000..edefe20894b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zksync/minimal_batch.ex @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.ZkSync.MinimalBatch do + @moduledoc """ + Shared base schema for ZkSync batch responses. + + Holds the properties common to both the detailed batch view (`ZkSync.Batch`) + and the batch-list item view (`ZkSync.BatchListItem`): batch identifier, + sealing timestamp, lifecycle status, and the three optional parent-chain + lifecycle transactions (commit / prove / execute). + """ + require OpenApiSpex + + alias BlockScoutWeb.API.V2.ZkSyncView + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "ZkSync.MinimalBatch", + description: "Common ZkSync rollup batch properties shared by list and detail responses.", + type: :object, + properties: %{ + number: %Schema{type: :integer, minimum: 0, description: "Batch number on the rollup."}, + timestamp: + Helper.describe_inline( + General.Timestamp.schema(), + "Timestamp when the batch was sealed on the rollup." + ), + status: %Schema{ + type: :string, + enum: ZkSyncView.batch_lifecycle_status_enum(), + description: """ + Lifecycle status of the batch: + * `Sealed on L2` - Batch is finalized on the rollup and no parent-chain lifecycle transaction has been observed yet. + * `Sent to L1` - Commit transaction has been submitted on the parent chain. + * `Validated on L1` - Prove transaction has been submitted on the parent chain. + * `Executed on L1` - Execute transaction has been submitted on the parent chain. + """ + }, + commit_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the parent-chain transaction that committed this batch. " <> + "`null` until the commit transaction is observed." + ), + commit_transaction_timestamp: + Helper.describe_inline( + General.TimestampNullable.schema(), + "Timestamp of the parent-chain transaction that committed this batch. " <> + "`null` until the commit transaction is observed." + ), + prove_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the parent-chain transaction that proved this batch. " <> + "`null` until the prove transaction is observed." + ), + prove_transaction_timestamp: + Helper.describe_inline( + General.TimestampNullable.schema(), + "Timestamp of the parent-chain transaction that proved this batch. " <> + "`null` until the prove transaction is observed." + ), + execute_transaction_hash: + Helper.describe_inline( + General.FullHashNullable.schema(), + "Hash of the parent-chain transaction that executed this batch. " <> + "`null` until the execute transaction is observed." + ), + execute_transaction_timestamp: + Helper.describe_inline( + General.TimestampNullable.schema(), + "Timestamp of the parent-chain transaction that executed this batch. " <> + "`null` until the execute transaction is observed." + ) + }, + required: [ + :number, + :timestamp, + :status, + :commit_transaction_hash, + :commit_transaction_timestamp, + :prove_transaction_hash, + :prove_transaction_timestamp, + :execute_transaction_hash, + :execute_transaction_timestamp + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex b/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex index 37b26f08152..865a12a8534 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex @@ -6,6 +6,27 @@ defmodule BlockScoutWeb.Schemas.Helper do @doc """ Extends a schema with additional properties and required fields. + + Designed for use **inside an outer `OpenApiSpex.schema(…)` macro** that + re-establishes a new component identity (a fresh `:title` and `x-struct`) + for the extended result — for example, when defining + `AddressNullable` by extending `Address.schema()` with `nullable: true`. + + Do **not** use this helper at a property position to overlay attributes + (`description:`, `nullable:`, `pattern:`, …) on a leaf primitive such as + `General.Timestamp`, `General.IntegerString`, or `General.HexData`. The + returned struct retains the leaf's `:title` and `x-struct`, so + `OpenApiSpex.resolve_schema_modules/1` registers it as + `components.schemas.<Title>` on a last-encounter-wins basis. The overlay + leaks onto the global component and every `$ref` pointing at it across + the spec inherits the property-specific value. + + For per-property description overlays on a leaf primitive, use + `describe_inline/2` instead — it strips `:title` and `x-struct` so the + result is rendered inline at the call site and the global component + stays pristine. For other per-property overrides (different `pattern:`, + flipped `nullable:`, etc.), define a new primitive in + `schemas/api/v2/general/` and reference that. """ @spec extend_schema(Schema.t() | map(), Keyword.t()) :: Schema.t() | map() def extend_schema(schema, options) do @@ -47,4 +68,26 @@ defmodule BlockScoutWeb.Schemas.Helper do updated_schema_with_enum end + + @doc """ + Attaches a per-property description to a leaf schema for use at a property position. + + The result is rendered inline at the call site: `x-struct` and `title` are + stripped from the input so `OpenApiSpex.resolve_schema_modules/1` does not + register the result as the named OpenAPI component. The global + `components.schemas.<Title>` entry stays untouched. + + This function is intentionally narrow — it only attaches a description. If + a property needs a different `pattern:`, `nullable:`, or other constraint + than the leaf provides, define a new primitive in + `schemas/api/v2/general/` and describe that. This pushes the codebase + toward a clean library of reusable primitives. + """ + @spec describe_inline(Schema.t() | map(), String.t()) :: Schema.t() | map() + def describe_inline(schema, description) do + schema + |> Map.put(:description, description) + |> Map.put(:"x-struct", nil) + |> Map.put(:title, nil) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/specs/private.ex b/apps/block_scout_web/lib/block_scout_web/specs/private.ex index d34e073e0e0..0ad717d6c5b 100644 --- a/apps/block_scout_web/lib/block_scout_web/specs/private.ex +++ b/apps/block_scout_web/lib/block_scout_web/specs/private.ex @@ -15,7 +15,7 @@ defmodule BlockScoutWeb.Specs.Private do def spec do %OpenApi{ servers: [ - %Server{url: to_string(Helper.instance_url() |> URI.append_path("/api"))} + %Server{url: to_string(Helper.instance_url())} ], info: %Info{ title: "Blockscout Private API", @@ -24,7 +24,7 @@ defmodule BlockScoutWeb.Specs.Private do email: "info@blockscout.com" } }, - paths: Paths.from_routes(Specs.routes_with_prefix(AccountRouter, "/account")), + paths: Paths.from_routes(Specs.routes_with_prefix(AccountRouter, "/api/account")), components: %Components{ securitySchemes: %{"dynamic_jwt" => %SecurityScheme{type: "http", scheme: "bearer", bearerFormat: "JWT"}} } diff --git a/apps/block_scout_web/lib/block_scout_web/specs/public.ex b/apps/block_scout_web/lib/block_scout_web/specs/public.ex index e9941b2397c..b4ef38207ab 100644 --- a/apps/block_scout_web/lib/block_scout_web/specs/public.ex +++ b/apps/block_scout_web/lib/block_scout_web/specs/public.ex @@ -10,10 +10,8 @@ defmodule BlockScoutWeb.Specs.Public do alias Utils.Helper use Utils.CompileTimeEnvHelper, - chain_identity: [:explorer, :chain_identity] - - use Utils.RuntimeEnvHelper, - mud_enabled?: [:explorer, [Explorer.Chain.Mud, :enabled]] + chain_identity: [:explorer, :chain_identity], + reading_enabled: [:block_scout_web, [ApiRouter, :reading_enabled]] @behaviour OpenApi @@ -47,14 +45,10 @@ defmodule BlockScoutWeb.Specs.Public do {:optimism, nil} -> defp chain_type_category_tags do - if mud_enabled?() do - [%Tag{name: "optimism"}, %Tag{name: "mud"}] - else - [%Tag{name: "optimism"}] - end + [%Tag{name: "optimism"}] end - {chain_type, nil} when chain_type in [:arbitrum, :scroll, :zilliqa] -> + {chain_type, nil} when chain_type in [:arbitrum, :scroll, :shibarium, :stability, :zilliqa, :zksync] -> @chain_type_category_tags [%Tag{name: to_string(chain_type)}] defp chain_type_category_tags, do: @chain_type_category_tags @@ -63,11 +57,19 @@ defmodule BlockScoutWeb.Specs.Public do defp chain_type_category_tags, do: @chain_type_category_tags end + # The `/api/legacy` routes are compiled only when `API_V1_READ_METHODS_DISABLED` + # is not `true`, so the tag is added only when at least one operation carries it. + if @reading_enabled do + defp legacy_category_tags, do: [%Tag{name: "legacy"}] + else + defp legacy_category_tags, do: [] + end + @impl OpenApi def spec do %OpenApi{ servers: [ - %Server{url: to_string(Helper.instance_url() |> URI.append_path("/api"))} + %Server{url: to_string(Helper.instance_url())} ], info: %Info{ title: "Blockscout", @@ -78,12 +80,13 @@ defmodule BlockScoutWeb.Specs.Public do }, paths: ApiRouter - |> Paths.from_router() - |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(TokensApiV2Router, "/v2/tokens"))) - |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(SmartContractsApiV2Router, "/v2/smart-contracts"))), + |> Specs.routes_with_prefix("/api") + |> Paths.from_routes() + |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(TokensApiV2Router, "/api/v2/tokens"))) + |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(SmartContractsApiV2Router, "/api/v2/smart-contracts"))), tags: Enum.map(@default_api_categories, fn category -> %Tag{name: category} end) ++ - chain_type_category_tags() ++ [%Tag{name: "legacy"}] + chain_type_category_tags() ++ legacy_category_tags() } |> OpenApiSpex.resolve_schema_modules() end diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction_state/_state_change.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction_state/_state_change.html.eex index bd4abe9c19c..dbfaec1d66d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction_state/_state_change.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction_state/_state_change.html.eex @@ -33,10 +33,10 @@ <% end %> <%= if not_negative?(@balance_before) and not_negative?(@balance_after) do %> <td class="stakes-td"> - <span><%= display_value(@balance_before, coin_or_transfer, @token_id) %></span> + <span><%= display_value(@balance_before, coin_or_transfer, @token_id, @ui_multiplier) %></span> </td> <td class="stakes-td"> - <span><%= display_value(@balance_after, coin_or_transfer, @token_id) %></span> + <span><%= display_value(@balance_after, coin_or_transfer, @token_id, @ui_multiplier) %></span> </td> <% else %> <td class="stakes-td"></td> @@ -55,9 +55,9 @@ <% end %> <% else %> <%= if not_negative?(@balance_diff) do %> - <span class="mr-4 text-success">▲ <%= display_value(@balance_diff, coin_or_transfer, @token_id) %></span> + <span class="mr-4 text-success">▲ <%= display_value(@balance_diff, coin_or_transfer, @token_id, @ui_multiplier) %></span> <% else %> - <span class="mr-4 text-danger">▼ <%= display_value(absolute_value_of(@balance_diff), coin_or_transfer, @token_id) %></span> + <span class="mr-4 text-danger">▼ <%= display_value(absolute_value_of(@balance_diff), coin_or_transfer, @token_id, @ui_multiplier) %></span> <% end %> <% end %> </td> diff --git a/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex b/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex index 612346f8cb3..9e121ed4a51 100644 --- a/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex @@ -87,15 +87,26 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelper do ) config - |> Jason.decode( - keys: fn key -> - if to_atom?[key] do - String.to_atom(key) - else - key - end - end - ) + |> Utils.JSON.decode() + |> case do + {:ok, decoded} -> {:ok, atomize_keys(decoded, to_atom?)} + error -> error + end + end + + defp atomize_keys(value, to_atom?) when is_map(value) do + Map.new(value, fn {key, val} -> + new_key = if is_binary(key) && to_atom?[key], do: String.to_atom(key), else: key + {new_key, atomize_keys(val, to_atom?)} + end) + end + + defp atomize_keys(list, to_atom?) when is_list(list) do + Enum.map(list, &atomize_keys(&1, to_atom?)) + end + + defp atomize_keys(value, _to_atom?) do + value end defp parse_config(config) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex index 5388692e12b..9024fd9c5f2 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex @@ -9,7 +9,7 @@ defmodule BlockScoutWeb.Account.CustomABIView do with {_type, abi} <- Changeset.fetch_field(custom_abi, :abi), false <- is_nil(abi), {:binary, false} <- {:binary, is_binary(abi)}, - {:ok, encoded_abi} <- Poison.encode(abi) do + {:ok, encoded_abi} <- Utils.JSON.encode(abi) do encoded_abi else {:binary, true} -> diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex index eb1a095d5d2..badc85f166b 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex @@ -19,7 +19,7 @@ defmodule BlockScoutWeb.AddressContractView do render_scripts(conn, "address_contract/code_highlighting.js") end - def format_smart_contract_abi(abi) when not is_nil(abi), do: Poison.encode!(abi, %{pretty: false}) + def format_smart_contract_abi(abi) when not is_nil(abi), do: Utils.JSON.encode!(abi, pretty: false) @doc """ Returns the correct format for the optimization text. @@ -155,14 +155,16 @@ defmodule BlockScoutWeb.AddressContractView do nil end - def sourcify_repo_url(address_hash, partial_match) do + # The Sourcify v2 repository UI serves verified contracts at + # `{repo_url}/{chainId}/{address}` regardless of full/partial match, so the + # `partial_match` flag is no longer part of the URL (kept for API compatibility). + def sourcify_repo_url(address_hash, _partial_match) do checksummed_hash = Address.checksum(address_hash) chain_id = Application.get_env(:explorer, Explorer.ThirdPartyIntegrations.Sourcify)[:chain_id] repo_url = Application.get_env(:explorer, Explorer.ThirdPartyIntegrations.Sourcify)[:repo_url] - match = if partial_match, do: "/partial_match/", else: "/full_match/" if chain_id do - repo_url <> match <> chain_id <> "/" <> checksummed_hash <> "/" + repo_url <> "/" <> chain_id <> "/" <> checksummed_hash else Logger.warning("chain_id is nil. Please set CHAIN_ID env variable.") nil diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex index ca8757f2b92..f06c109ecd0 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex @@ -64,53 +64,32 @@ defmodule BlockScoutWeb.API.EthRPC.View do @doc """ Encodes error into JSON string """ - @spec sanitize_error(any(), :jason | :poison) :: String.t() - def sanitize_error(error, json_encoder) do - case json_encoder do - :jason -> if is_map(error), do: Jason.encode!(error), else: "\"#{error}\"" - :poison -> if is_map(error), do: Poison.encode!(error), else: "\"#{error}\"" - end + @spec sanitize_error(any(), atom()) :: String.t() + def sanitize_error(error, _json_encoder) do + if is_map(error), do: Utils.JSON.encode!(error), else: "\"#{error}\"" end @doc """ - Pass "jsonrpc":"2.0" to use in Poison.Encoder and Jason.Encoder below + Pass "jsonrpc":"2.0" to use in JSON.Encoder below """ @spec jsonrpc_2_0() :: String.t() def jsonrpc_2_0, do: @jsonrpc_2_0 - defimpl Poison.Encoder, for: BlockScoutWeb.API.EthRPC.View do - alias BlockScoutWeb.API.EthRPC.View - - def encode(%View{result: result, id: id, error: error}, _options) when is_nil(error) do - result = Poison.encode!(result) - - """ - {#{View.jsonrpc_2_0()},"result": #{result},"id": #{View.sanitize_id(id)}} - """ - end - - def encode(%View{id: id, error: error}, _options) do - """ - {#{View.jsonrpc_2_0()},"error": #{View.sanitize_error(error, :poison)},"id": #{View.sanitize_id(id)}} - """ - end - end - - defimpl Jason.Encoder, for: BlockScoutWeb.API.EthRPC.View do + defimpl JSON.Encoder, for: BlockScoutWeb.API.EthRPC.View do # credo:disable-for-next-line alias BlockScoutWeb.API.EthRPC.View - def encode(%View{result: result, id: id, error: error}, _options) when is_nil(error) do - result = Jason.encode!(result) + def encode(%View{result: result, id: id, error: error}, _encoder) when is_nil(error) do + result = Utils.JSON.encode!(result) """ {#{View.jsonrpc_2_0()},"result": #{result},"id": #{View.sanitize_id(id)}} """ end - def encode(%View{id: id, error: error}, _options) do + def encode(%View{id: id, error: error}, _encoder) do """ - {#{View.jsonrpc_2_0()},"error": #{View.sanitize_error(error, :jason)},"id": #{View.sanitize_id(id)}} + {#{View.jsonrpc_2_0()},"error": #{View.sanitize_error(error, :json)},"id": #{View.sanitize_id(id)}} """ end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/legacy/eth_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/eth_view.ex new file mode 100644 index 00000000000..092196aae9b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/eth_view.ex @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.EthView do + @moduledoc false + defdelegate render(template, assigns), to: BlockScoutWeb.API.EthRPC.View +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex deleted file mode 100644 index bd0141a642b..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.API.Legacy.LogsView do - @moduledoc false - defdelegate render(template, assigns), to: BlockScoutWeb.API.RPC.LogsView -end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex index bf97405552e..0dec821a5bc 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex @@ -225,7 +225,8 @@ defmodule BlockScoutWeb.API.RPC.AddressView do |> Map.put_new(:value, to_string(List.first(token_transfer.amounts))) end - defp prepare_token_transfer(%{token_type: "ERC-20"} = token_transfer, max_block_number, decoded_input) do + defp prepare_token_transfer(%{token_type: token_type} = token_transfer, max_block_number, decoded_input) + when token_type in ["ERC-20", "ERC-8056"] do token_transfer |> prepare_common_token_transfer(max_block_number, decoded_input) |> Map.put_new(:value, to_string(token_transfer.amount)) diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex index 9fb8db287f2..3f3d925bbfc 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex @@ -27,7 +27,7 @@ defmodule BlockScoutWeb.API.RPC.ContractView do end def render("getabi.json", %{abi: abi}) do - RPCView.render("show.json", data: Jason.encode!(abi)) + RPCView.render("show.json", data: Utils.JSON.encode!(abi)) end def render("getsourcecode.json", %{contract: contract}) do @@ -141,7 +141,7 @@ defmodule BlockScoutWeb.API.RPC.ContractView do if is_nil(address.smart_contract) do "Contract source code not verified" else - Jason.encode!(contract.abi) + Utils.JSON.encode!(contract.abi) end contract_optimization = @@ -225,7 +225,7 @@ defmodule BlockScoutWeb.API.RPC.ContractView do smart_contract_info = %{ "Address" => to_string(hash), - "ABI" => Jason.encode!(contract.abi), + "ABI" => Utils.JSON.encode!(contract.abi), "ContractName" => contract.name, "CompilerVersion" => contract.compiler_version, "OptimizationUsed" => if(contract.optimization, do: "1", else: "0") 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 3ae83b75cf3..190eecef145 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 @@ -62,7 +62,15 @@ defmodule BlockScoutWeb.API.V2.AddressView do end def render("nft_list.json", %{token_instances: token_instances, token: token, next_page_params: next_page_params}) do - %{"items" => Enum.map(token_instances, &prepare_nft(&1, token)), "next_page_params" => next_page_params} + %{ + "items" => + Enum.map(token_instances, fn nft -> + nft + |> prepare_nft(token) + |> Map.delete("token") + end), + "next_page_params" => next_page_params + } end def render("nft_list.json", %{token_instances: token_instances, next_page_params: next_page_params}) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex index 35ef4658c8b..c047e1f1cd6 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex @@ -4,6 +4,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do alias BlockScoutWeb.API.V2.{Helper, TokenTransferView, TokenView, TransactionView} alias Explorer.Chain.{Address, Data, Transaction} + alias Explorer.Chain.Token.UIMultiplierChange def render("advanced_filters.json", %{ advanced_filters: advanced_filters, @@ -17,6 +18,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do %{ items: advanced_filters + |> put_token_transfer_ui_multipliers() |> Enum.zip(decoded_transactions) |> Enum.map(fn {af, decoded_input} -> prepare_advanced_filter(af, decoded_input) end), search_params: prepare_search_params(method_ids, tokens), @@ -28,6 +30,17 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do methods end + defp put_token_transfer_ui_multipliers(advanced_filters) do + resolved_token_transfers = + advanced_filters + |> Enum.map(& &1.token_transfer) + |> UIMultiplierChange.put_ui_multipliers(api?: true) + + advanced_filters + |> Enum.zip(resolved_token_transfers) + |> Enum.map(fn {advanced_filter, token_transfer} -> %{advanced_filter | token_transfer: token_transfer} end) + end + defp prepare_advanced_filter(advanced_filter, decoded_input) do %{ hash: advanced_filter.hash, diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex index 676fcb2a549..768121f4840 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex @@ -3,6 +3,7 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionView do use BlockScoutWeb, :view alias BlockScoutWeb.API.V2.Helper + alias BlockScoutWeb.API.V2.InternalTransactionsPendingStatusHelper alias Explorer.Chain.{Block, InternalTransaction, Wei} def render("internal_transaction.json", %{internal_transaction: nil}) do @@ -16,14 +17,18 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionView do prepare_internal_transaction(internal_transaction, block) end - def render("internal_transactions.json", %{ - internal_transactions: internal_transactions, - next_page_params: next_page_params - }) do + def render( + "internal_transactions.json", + %{ + internal_transactions: internal_transactions, + next_page_params: next_page_params + } = assigns + ) do %{ "items" => Enum.map(internal_transactions, &prepare_internal_transaction(&1, &1.block)), "next_page_params" => next_page_params } + |> put_pending_status(Map.get(assigns, :pending_status?, false)) end @doc """ @@ -55,4 +60,12 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionView do "gas_limit" => internal_transaction.gas || Decimal.new(0) } end + + defp put_pending_status(response, true) do + Map.put(response, "meta", %{"status" => 2, "message" => InternalTransactionsPendingStatusHelper.pending_message()}) + end + + defp put_pending_status(response, _) do + Map.put(response, "meta", %{"status" => 1, "message" => nil}) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex deleted file mode 100644 index 0c4dbd7dd93..00000000000 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.API.V2.MudView do - use BlockScoutWeb, :view - - alias BlockScoutWeb.API.V2.Helper - alias Explorer.Chain.{Address, Mud, Mud.Table} - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds` endpoint. - """ - @spec render(String.t(), map()) :: map() - def render("worlds.json", %{worlds: worlds, next_page_params: next_page_params}) do - %{ - items: worlds |> Enum.map(&prepare_world_for_list/1), - next_page_params: next_page_params - } - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/count` endpoint. - """ - def render("count.json", %{count: count}) do - count - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/:world/tables` endpoint. - """ - def render("tables.json", %{tables: tables, next_page_params: next_page_params}) do - %{ - items: tables |> Enum.map(&%{table: Table.from(&1 |> elem(0)), schema: &1 |> elem(1)}), - next_page_params: next_page_params - } - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/:world/systems` endpoint. - """ - def render("systems.json", %{systems: systems}) do - %{ - items: systems |> Enum.map(&prepare_system_for_list/1) - } - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/:world/systems/:system` endpoint. - """ - def render("system.json", %{system_id: system_id, abi: abi}) do - %{ - name: system_id |> Table.from() |> Map.get(:table_full_name), - abi: abi - } - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records` endpoint. - """ - def render("records.json", %{ - records: records, - table_id: table_id, - schema: schema, - blocks: blocks, - next_page_params: next_page_params - }) do - %{ - items: records |> Enum.map(&format_record(&1, schema, blocks)), - table: table_id |> Table.from(), - schema: schema, - next_page_params: next_page_params - } - end - - @doc """ - Function to render GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records/:record_id` endpoint. - """ - def render("record.json", %{record: record, table_id: table_id, blocks: blocks, schema: schema}) do - %{ - record: record |> format_record(schema, blocks), - table: table_id |> Table.from(), - schema: schema - } - end - - defp prepare_world_for_list(%Address{} = address) do - %{ - "address" => Helper.address_with_info(address, address.hash), - "transactions_count" => address.transactions_count, - "coin_balance" => if(address.fetched_coin_balance, do: address.fetched_coin_balance.value) - } - end - - defp prepare_system_for_list({system_id, system}) do - %{ - name: system_id |> Table.from() |> Map.get(:table_full_name), - address_hash: system - } - end - - defp format_record(nil, _schema, _blocks), do: nil - - defp format_record(record, schema, blocks) do - %{ - id: record.key_bytes, - raw: %{ - key_bytes: record.key_bytes, - key0: record.key0, - key1: record.key1, - static_data: record.static_data, - encoded_lengths: record.encoded_lengths, - dynamic_data: record.dynamic_data, - block_number: record.block_number, - log_index: record.log_index - }, - is_deleted: record.is_deleted, - decoded: Mud.decode_record(record, schema), - timestamp: blocks |> Map.get(Decimal.to_integer(record.block_number), nil) - } - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex index cbbfe81408b..22f727eef34 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex @@ -20,7 +20,7 @@ defmodule BlockScoutWeb.API.V2.ShibariumView do "l1_block_number" => deposit.l1_block_number, "l1_transaction_hash" => deposit.l1_transaction_hash, "l2_transaction_hash" => deposit.l2_transaction_hash, - "user" => Map.get(user_addresses, deposit.user, deposit.user), + "user" => resolve_user(user_addresses, deposit.user, conn), "timestamp" => deposit.timestamp } end), @@ -42,7 +42,7 @@ defmodule BlockScoutWeb.API.V2.ShibariumView do "l2_block_number" => withdrawal.l2_block_number, "l2_transaction_hash" => withdrawal.l2_transaction_hash, "l1_transaction_hash" => withdrawal.l1_transaction_hash, - "user" => Map.get(user_addresses, withdrawal.user, withdrawal.user), + "user" => resolve_user(user_addresses, withdrawal.user, conn), "timestamp" => withdrawal.timestamp } end), @@ -54,6 +54,12 @@ defmodule BlockScoutWeb.API.V2.ShibariumView do count end + defp resolve_user(user_addresses, user_hash, conn) do + Map.get_lazy(user_addresses, user_hash, fn -> + Helper.address_with_info(conn, nil, user_hash, true) + end) + end + defp get_user_addresses(items, conn) do items |> Enum.map(& &1.user) diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex index 386f0853c83..1c54634048b 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex @@ -91,13 +91,13 @@ defmodule BlockScoutWeb.API.V2.StabilityView do defp do_extend_with_stability_fees_info(transactions) when is_list(transactions) do {transactions, _tokens_acc} = Enum.map_reduce(transactions, %{}, fn transaction = %Transaction{}, tokens_acc -> - case Log.fetch_log_by_transaction_hash_and_first_topic( - transaction.hash, + case Log.fetch_log_by_transaction_and_first_topic( + transaction, @transaction_fee_event_signature, @api_true ) do fee_log when not is_nil(fee_log) -> - {:ok, _selector, mapping} = Log.find_and_decode(@transaction_fee_event_abi, fee_log, transaction.hash) + {:ok, _selector, mapping} = Log.find_and_decode(@transaction_fee_event_abi, fee_log) [{"token", "address", false, token_address_hash}, _, _, _, _, _] = mapping diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex index 250d08f6be8..689b9909bb8 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex @@ -5,6 +5,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferView do alias BlockScoutWeb.API.V2.{Helper, TokenView, TransactionView} alias Ecto.Association.NotLoaded alias Explorer.Chain + alias Explorer.Chain.Token.UIMultiplierChange alias Explorer.Chain.{TokenTransfer, Transaction} def render("token_transfer.json", %{token_transfer: nil}) do @@ -27,8 +28,9 @@ defmodule BlockScoutWeb.API.V2.TokenTransferView do }) do %{ "items" => - Enum.map( - token_transfers, + token_transfers + |> UIMultiplierChange.put_ui_multipliers(api?: true) + |> Enum.map( &render("token_transfer.json", %{ token_transfer: &1, decoded_transaction_input: &1.transaction && decoded_transactions_map[&1.transaction.hash], @@ -103,7 +105,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferView do } {:ok, value, decimals} -> - %{"value" => value, "decimals" => decimals} + %{"value" => value, "decimals" => decimals, "ui_multiplier" => token_transfer.ui_multiplier} _ -> nil diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex index 2b093d72ffa..06836e172ed 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex @@ -6,7 +6,7 @@ defmodule BlockScoutWeb.API.V2.TokenView do alias BlockScoutWeb.API.V2.Helper alias BlockScoutWeb.NFTHelper alias Ecto.Association.NotLoaded - alias Explorer.Chain.{Address, BridgedToken} + alias Explorer.Chain.{Address, BridgedToken, Token} alias Explorer.Chain.Token.Instance def render("token.json", %{token: nil = token, contract_address_hash: contract_address_hash}) do @@ -23,7 +23,10 @@ defmodule BlockScoutWeb.API.V2.TokenView do "icon_url" => nil, "circulating_market_cap" => nil, "circulating_supply" => nil, - "reputation" => nil + "reputation" => nil, + "ui_multiplier" => nil, + "new_ui_multiplier" => nil, + "ui_multiplier_effective_at" => nil } |> maybe_append_bridged_info(token) end @@ -46,7 +49,10 @@ defmodule BlockScoutWeb.API.V2.TokenView do "icon_url" => token.icon_url, "circulating_market_cap" => token.circulating_market_cap, "circulating_supply" => token.circulating_supply, - "reputation" => token.reputation + "reputation" => token.reputation, + "ui_multiplier" => Token.effective_ui_multiplier(token), + "new_ui_multiplier" => token.new_ui_multiplier, + "ui_multiplier_effective_at" => token.ui_multiplier_effective_at } |> maybe_append_bridged_info(token) |> chain_type_fields(%{address: token.contract_address, field_prefix: nil}) @@ -62,6 +68,16 @@ defmodule BlockScoutWeb.API.V2.TokenView do } end + def render("ui_multiplier_changes.json", %{ + ui_multiplier_changes: ui_multiplier_changes, + next_page_params: next_page_params + }) do + %{ + "items" => Enum.map(ui_multiplier_changes, &prepare_ui_multiplier_change/1), + "next_page_params" => next_page_params + } + end + def render("token_instance.json", %{token_instance: token_instance, token: token}) do prepare_token_instance(token_instance, token) end @@ -70,13 +86,22 @@ defmodule BlockScoutWeb.API.V2.TokenView do %{"items" => Enum.map(tokens, &render("token.json", %{token: &1})), "next_page_params" => next_page_params} end + def render("tokens_batch.json", %{tokens: tokens}) do + Enum.map(tokens, &render("token.json", %{token: &1})) + end + def render("token_instances.json", %{ token_instances: token_instances, next_page_params: next_page_params, token: token }) do %{ - "items" => Enum.map(token_instances, &render("token_instance.json", %{token_instance: &1, token: token})), + "items" => + Enum.map(token_instances, fn instance -> + instance + |> prepare_token_instance(token) + |> Map.delete("token") + end), "next_page_params" => next_page_params } end @@ -98,6 +123,19 @@ defmodule BlockScoutWeb.API.V2.TokenView do def exchange_rate(%{fiat_value: fiat_value}) when not is_nil(fiat_value), do: to_string(fiat_value) def exchange_rate(_), do: nil + defp prepare_ui_multiplier_change(change) do + %{ + "block_number" => change.block_number, + "block_hash" => to_string(change.block_hash), + "timestamp" => change.timestamp, + "transaction_hash" => change.transaction_hash && to_string(change.transaction_hash), + "log_index" => change.log_index, + "old_multiplier" => change.old_multiplier, + "new_multiplier" => change.new_multiplier, + "effective_at" => change.effective_at + } + end + defp prepare_token_holder(token_balance) do %{ "address" => Helper.address_with_info(nil, token_balance.address, token_balance.address_hash, false), @@ -121,7 +159,9 @@ defmodule BlockScoutWeb.API.V2.TokenView do "is_unique" => instance.is_unique, "thumbnails" => instance.thumbnails, "media_type" => instance.media_type, - "media_url" => Instance.get_media_url_from_metadata_for_nft_media_handler(instance.metadata) + "media_url" => Instance.get_media_url_from_metadata_for_nft_media_handler(instance.metadata), + "image_media_type" => Instance.mime_to_media_category(instance.image_type), + "animation_media_type" => Instance.mime_to_media_category(instance.animation_type) } end 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 f61f9519ef7..b462c59a68a 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 @@ -7,7 +7,16 @@ defmodule BlockScoutWeb.API.V2.TransactionView do chain_identity: [:explorer, :chain_identity], miner_gets_burnt_fees?: [:explorer, [Explorer.Chain.Transaction, :block_miner_gets_burnt_fees?]] - alias BlockScoutWeb.API.V2.{ApiView, Helper, InternalTransactionView, TokenTransferView, TokenView} + alias ABI.FunctionSelector + + alias BlockScoutWeb.API.V2.{ + ApiView, + Helper, + InternalTransactionsPendingStatusHelper, + InternalTransactionView, + TokenTransferView, + TokenView + } alias BlockScoutWeb.Models.{GetAddressTags, GetTransactionTags} alias BlockScoutWeb.{TransactionStateView, TransactionView} @@ -30,6 +39,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Chain.SmartContract.Proxy.Models.Implementation, as: ProxyImplementation + alias Explorer.Chain.Token.UIMultiplierChange alias Explorer.Chain.Transaction.StateChange alias Timex.Duration @@ -126,6 +136,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do %{ "items" => token_transfers + |> UIMultiplierChange.put_ui_multipliers(@api_true) |> Enum.zip(decoded_transactions) |> Enum.map(fn {tt, decoded_input} -> TokenTransferView.prepare_token_transfer(tt, conn, decoded_input) end), "next_page_params" => next_page_params @@ -137,44 +148,64 @@ defmodule BlockScoutWeb.API.V2.TransactionView do Transaction.decode_transactions(Enum.map(token_transfers, fn tt -> tt.transaction end), true, @api_true) token_transfers + |> UIMultiplierChange.put_ui_multipliers(@api_true) |> Enum.zip(decoded_transactions) |> Enum.map(fn {tt, decoded_input} -> TokenTransferView.prepare_token_transfer(tt, conn, decoded_input) end) end def render("token_transfer.json", %{token_transfer: token_transfer, conn: conn}) do [decoded_transaction] = Transaction.decode_transactions([token_transfer.transaction], true, @api_true) - TokenTransferView.prepare_token_transfer(token_transfer, conn, decoded_transaction) + + [token_transfer_with_multiplier] = UIMultiplierChange.put_ui_multipliers([token_transfer], @api_true) + + TokenTransferView.prepare_token_transfer(token_transfer_with_multiplier, conn, decoded_transaction) end - def render("internal_transactions.json", %{ - internal_transactions: internal_transactions, - next_page_params: next_page_params, - block: block - }) do + def render( + "internal_transactions.json", + %{ + internal_transactions: internal_transactions, + next_page_params: next_page_params, + block: block + } = assigns + ) do %{ "items" => Enum.map(internal_transactions, &InternalTransactionView.prepare_internal_transaction(&1, block)), "next_page_params" => next_page_params } + |> put_pending_status(Map.get(assigns, :pending_status?, false)) end - def render("internal_transactions.json", %{ - internal_transactions: internal_transactions, - next_page_params: next_page_params - }) do + def render( + "internal_transactions.json", + %{ + internal_transactions: internal_transactions, + next_page_params: next_page_params + } = assigns + ) do %{ "items" => Enum.map(internal_transactions, &InternalTransactionView.prepare_internal_transaction(&1)), "next_page_params" => next_page_params } + |> put_pending_status(Map.get(assigns, :pending_status?, false)) end - def render("logs.json", %{logs: logs, next_page_params: next_page_params, transaction_hash: transaction_hash}) do + def render( + "logs.json", + %{logs: logs, next_page_params: next_page_params, transaction_hash: transaction_hash} = assigns + ) do decoded_logs = decode_logs(logs, false) + transaction = Map.get(assigns, :transaction) + transaction_or_hash = transaction || transaction_hash + %{ "items" => logs |> Enum.zip(decoded_logs) - |> Enum.map(fn {log, decoded_log} -> prepare_log(log, transaction_hash, decoded_log) end), + |> Enum.map(fn {log, decoded_log} -> + prepare_log(log, transaction_or_hash, decoded_log) + end), "next_page_params" => next_page_params } end @@ -186,7 +217,9 @@ defmodule BlockScoutWeb.API.V2.TransactionView do "items" => logs |> Enum.zip(decoded_logs) - |> Enum.map(fn {log, decoded_log} -> prepare_log(log, log.transaction, decoded_log) end), + |> Enum.map(fn {log, decoded_log} -> + prepare_log(log, log.transaction, decoded_log) + end), "next_page_params" => next_page_params } end @@ -246,6 +279,14 @@ defmodule BlockScoutWeb.API.V2.TransactionView do } end + defp put_pending_status(response, true) do + Map.put(response, "meta", %{"status" => 2, "message" => InternalTransactionsPendingStatusHelper.pending_message()}) + end + + defp put_pending_status(response, _) do + Map.put(response, "meta", %{"status" => 1, "message" => nil}) + end + @doc """ Returns the ABI of a smart contract or an empty list if the smart contract is nil """ @@ -359,7 +400,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do end def prepare_log(log, transaction_or_hash, decoded_log, tags_for_address_needed? \\ false) do - decoded = process_decoded_log(decoded_log) + decoded = process_decoded_log(decoded_log) |> enrich_decoded_with_event_abi(log) %{ "transaction_hash" => get_transaction_hash(transaction_or_hash), @@ -375,7 +416,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do "decoded" => decoded, "smart_contract" => smart_contract_info(transaction_or_hash), "block_number" => log.block_number, - "block_hash" => log.block_hash, + "block_hash" => log.block.hash, "block_timestamp" => case log.block do %Block{timestamp: timestamp} -> timestamp @@ -425,6 +466,32 @@ defmodule BlockScoutWeb.API.V2.TransactionView do end end + defp enrich_decoded_with_event_abi(nil, _log), do: nil + + defp enrich_decoded_with_event_abi(decoded, log) do + proxy_implementations = + case log.address do + %{proxy_implementations: %NotLoaded{}} -> nil + %{proxy_implementations: implementations} -> implementations + _ -> nil + end + + smart_contract = + case log.address do + %{smart_contract: %NotLoaded{}} -> nil + %{smart_contract: sc} -> sc + _ -> nil + end + + full_abi = + (extract_implementations_abi(proxy_implementations) ++ + try_to_get_abi(smart_contract)) + |> Enum.uniq() + + event_abi = extract_event_abi(full_abi, log.first_topic && log.first_topic.bytes) + Map.put(decoded, "abi", event_abi) + end + defp prepare_transactions(transactions, conn, watchlist_names) do block_height = BlockNumber.get_max() decoded_transactions = Transaction.decode_transactions(transactions, true, @api_true) @@ -724,6 +791,30 @@ defmodule BlockScoutWeb.API.V2.TransactionView do end) end + defp extract_event_abi(full_abi, first_topic_bytes) when is_list(full_abi) and is_binary(first_topic_bytes) do + Enum.find(full_abi, fn abi_item -> + abi_item["type"] in ["event", "function"] and abi_selector_matches_topic?(abi_item, first_topic_bytes) + end) + end + + defp extract_event_abi(_full_abi, _first_topic), do: nil + + defp abi_selector_matches_topic?(abi_item, first_topic_bytes) when byte_size(first_topic_bytes) >= 4 do + include_events = abi_item["type"] == "event" + + case ABI.parse_specification([abi_item], include_events?: include_events) do + [%FunctionSelector{method_id: method_id}] when is_binary(method_id) -> + binary_part(first_topic_bytes, 0, byte_size(method_id)) == method_id + + _ -> + false + end + rescue + _ -> false + end + + defp abi_selector_matches_topic?(_abi_item, _first_topic_bytes), do: false + @spec format_status( :pending | :awaiting_internal_transactions @@ -1001,7 +1092,12 @@ defmodule BlockScoutWeb.API.V2.TransactionView do "is_miner" => state_change.miner?, "type" => type, "token" => if(type == "token", do: TokenView.render("token.json", %{token: coin_or_transfer.token})), - "token_id" => state_change.token_id + "token_id" => state_change.token_id, + # the multiplier `balance_before`/`balance_after`/`change` are to be + # displayed with, as of this transaction. `token.ui_multiplier` is the one + # in force now, since that rendering of a token is shared with every other + # place a token shows up + "ui_multiplier" => state_change.ui_multiplier } |> append_balances(state_change.balance_before, state_change.balance_after) |> append_balance_change(state_change, coin_or_transfer) diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex index d161fd84409..98da2c9a9fb 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex @@ -7,6 +7,12 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do alias BlockScoutWeb.API.V2.Helper, as: APIV2Helper + @status_executed_on_l1 "Executed on L1" + @status_validated_on_l1 "Validated on L1" + @status_sent_to_l1 "Sent to L1" + @status_sealed_on_l2 "Sealed on L2" + @status_processed_on_l2 "Processed on L2" + @doc """ Function to render GET requests to `/api/v2/zksync/batches/:batch_number` endpoint. """ @@ -148,7 +154,7 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do out_json |> Map.merge(%{ - "status" => batch_status(zksync_item), + "status" => lifecycle_status(zksync_item), "commit_transaction_hash" => APIV2Helper.get_2map_data(l1_transactions, :commit_transaction, :hash), "commit_transaction_timestamp" => APIV2Helper.get_2map_data(l1_transactions, :commit_transaction, :ts), "prove_transaction_hash" => APIV2Helper.get_2map_data(l1_transactions, :prove_transaction, :hash), @@ -177,30 +183,56 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do end) end - # Inspects L1 transactions of the batch to determine the batch status. + # Inspects L1 transactions of the zksync entity to determine its lifecycle + # status. # # ## Parameters # - `zksync_item`: A batch, transaction, or block. # # ## Returns - # A string with one of predefined statuses - defp batch_status(zksync_item) do + # A string with one of the predefined statuses. + defp lifecycle_status(zksync_item) do cond do - APIV2Helper.specified?(zksync_item.execute_transaction) -> "Executed on L1" - APIV2Helper.specified?(zksync_item.prove_transaction) -> "Validated on L1" - APIV2Helper.specified?(zksync_item.commit_transaction) -> "Sent to L1" + APIV2Helper.specified?(zksync_item.execute_transaction) -> @status_executed_on_l1 + APIV2Helper.specified?(zksync_item.prove_transaction) -> @status_validated_on_l1 + APIV2Helper.specified?(zksync_item.commit_transaction) -> @status_sent_to_l1 # Batch entity itself has no batch_number - not Map.has_key?(zksync_item, :batch_number) -> "Sealed on L2" - not is_nil(zksync_item.batch_number) -> "Sealed on L2" - true -> "Processed on L2" + not Map.has_key?(zksync_item, :batch_number) -> @status_sealed_on_l2 + not is_nil(zksync_item.batch_number) -> @status_sealed_on_l2 + true -> @status_processed_on_l2 end end @doc """ - Returns a list of possible batch statuses. + Returns the full list of lifecycle statuses reachable for rollup blocks and + rollup transactions (i.e., items that *go into* a batch). Includes + `"Processed on L2"`, which is emitted only for entities that carry a + `:batch_number` field whose value is `nil`. + """ + @spec batched_item_lifecycle_status_enum() :: [String.t()] + def batched_item_lifecycle_status_enum do + [ + @status_executed_on_l1, + @status_validated_on_l1, + @status_sent_to_l1, + @status_sealed_on_l2, + @status_processed_on_l2 + ] + end + + @doc """ + Returns the list of lifecycle statuses reachable for an `%Explorer.Chain.ZkSync.TransactionBatch{}` + item — the strict subset of `batched_item_lifecycle_status_enum/0` excluding + `"Processed on L2"`, which cannot apply to a batch (the batch *is* the unit + of sealing). """ - @spec batch_status_enum() :: [String.t()] - def batch_status_enum do - ["Executed on L1", "Validated on L1", "Sent to L1", "Sealed on L2", "Processed on L2"] + @spec batch_lifecycle_status_enum() :: [String.t()] + def batch_lifecycle_status_enum do + [ + @status_executed_on_l1, + @status_validated_on_l1, + @status_sent_to_l1, + @status_sealed_on_l2 + ] end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex b/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex index c9470ae5d4f..17d688e91de 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex @@ -4,7 +4,6 @@ defmodule BlockScoutWeb.LayoutView do alias EthereumJSONRPC.Variant alias Explorer.{Chain, Helper} - alias Poison.Parser import BlockScoutWeb.APIDocsView, only: [blockscout_url: 1] @@ -130,7 +129,7 @@ defmodule BlockScoutWeb.LayoutView do try do :block_scout_web |> Application.get_env(:other_networks) - |> Parser.parse!(%{keys: :atoms!}) + |> Utils.JSON.decode!(keys: :atoms) rescue _ -> [] @@ -196,7 +195,7 @@ defmodule BlockScoutWeb.LayoutView do defp decode_other_explorers_json(nil), do: %{} defp decode_other_explorers_json(data) do - Jason.decode!(~s(#{data})) + Utils.JSON.decode!(~s(#{data})) rescue _ -> %{} end @@ -227,7 +226,7 @@ defmodule BlockScoutWeb.LayoutView do if apps do try do apps - |> Parser.parse!(%{keys: :atoms!}) + |> Utils.JSON.decode!(keys: :atoms) rescue _ -> [] diff --git a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex index 0ad82eef088..9a3037c7078 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex @@ -88,21 +88,31 @@ defmodule BlockScoutWeb.NFTHelper do defp normalize_url(_), do: nil @doc """ - Composes a full IPFS URL from the given image URL. + Composes a full gateway URL from the given resource URL. + + Supports IPFS (`ipfs://`), Arweave (`ar://`), and Swarm (`bzz://`) resource + URLs, resolving them against the corresponding configured gateway. Any other + URL is returned unchanged. ## Parameters - - image_url: The URL of the image to be composed into an IPFS URL. It can be nil. + - image_url: The URL of the resource to be resolved to a gateway URL. It can be nil. ## Returns - - A string representing the full IPFS URL or nil. + - A string representing the full gateway URL, the original URL, or nil. ## Examples iex> compose_resource_url("ipfs://QmTzQ1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1") "https://ipfs.io/ipfs/QmTzQ1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1e1Y1" + iex> compose_resource_url("ar://Ah3vCrgV-9hEkA2Zl4Yq0iL5wGuMD5-Zr9EAF9zjHDU") + "https://arweave.net/Ah3vCrgV-9hEkA2Zl4Yq0iL5wGuMD5-Zr9EAF9zjHDU" + + iex> compose_resource_url("bzz://swarm-devrel.eth/assets/swarm-logo.svg") + "https://gateway.ethswarm.org/bzz/swarm-devrel.eth/assets/swarm-logo.svg" + """ @spec compose_resource_url(String.t() | nil) :: String.t() | nil def compose_resource_url(nil), do: nil @@ -128,6 +138,11 @@ defmodule BlockScoutWeb.NFTHelper do resource_id = image_url |> String.slice(5..-1//1) MetadataRetriever.arweave_link(resource_id) + image_url_downcase =~ ~r/^bzz:\/\// -> + # take resource id after "bzz://" prefix + resource_id = image_url |> String.slice(6..-1//1) + MetadataRetriever.swarm_link(resource_id) + true -> image_url end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex index 58d5d260b47..998c6fbca9f 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex @@ -6,6 +6,7 @@ defmodule BlockScoutWeb.Tokens.Helper do alias BlockScoutWeb.{AddressView, CurrencyHelper} alias Explorer.Chain.{Address, Token} + alias Explorer.Chain.Token.ScaledUIAmount @doc """ Returns the token transfers' amount according to the token's type and decimals. @@ -17,44 +18,56 @@ defmodule BlockScoutWeb.Tokens.Helper do When the token's type is ERC-721, the function will return a string with the token_id that represents the ERC-721 token since this kind of token doesn't have amount and decimals. """ - def token_transfer_amount(%{ - token: token, - token_type: token_type, - amount: amount, - amounts: amounts, - token_ids: token_ids - }) do - do_token_transfer_amount(token, token_type, amount, amounts, token_ids) + def token_transfer_amount( + %{ + token: token, + token_type: token_type, + amount: amount, + amounts: amounts, + token_ids: token_ids + } = token_transfer + ) do + do_token_transfer_amount(token, token_type, scaled(amount, token_transfer), amounts, token_ids) end - def token_transfer_amount(%{token: token, token_type: token_type, amount: amount, token_ids: token_ids}) do - do_token_transfer_amount(token, token_type, amount, nil, token_ids) + def token_transfer_amount( + %{token: token, token_type: token_type, amount: amount, token_ids: token_ids} = token_transfer + ) do + do_token_transfer_amount(token, token_type, scaled(amount, token_transfer), nil, token_ids) end + defp scaled(amount, token_transfer), do: ScaledUIAmount.scale(amount, Map.get(token_transfer, :ui_multiplier)) + # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount(%Token{type: "ERC-20"}, nil, nil, nil, _token_ids) do + defp do_token_transfer_amount(%Token{type: type}, nil, nil, nil, _token_ids) + when type in ["ERC-20", "ERC-8056"] do {:ok, "--"} end - defp do_token_transfer_amount(_token, "ERC-20", nil, nil, _token_ids) do + defp do_token_transfer_amount(_token, token_type, nil, nil, _token_ids) + when token_type in ["ERC-20", "ERC-8056"] do {:ok, "--"} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount(%Token{type: "ERC-20", decimals: nil}, nil, amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{type: type, decimals: nil}, nil, amount, _amounts, _token_ids) + when type in ["ERC-20", "ERC-8056"] do {:ok, CurrencyHelper.format_according_to_decimals(amount, Decimal.new(0))} end - defp do_token_transfer_amount(%Token{decimals: nil}, "ERC-20", amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{decimals: nil}, token_type, amount, _amounts, _token_ids) + when token_type in ["ERC-20", "ERC-8056"] do {:ok, CurrencyHelper.format_according_to_decimals(amount, Decimal.new(0))} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount(%Token{type: "ERC-20", decimals: decimals}, nil, amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{type: type, decimals: decimals}, nil, amount, _amounts, _token_ids) + when type in ["ERC-20", "ERC-8056"] do {:ok, CurrencyHelper.format_according_to_decimals(amount, decimals)} end - defp do_token_transfer_amount(%Token{decimals: decimals}, "ERC-20", amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{decimals: decimals}, token_type, amount, _amounts, _token_ids) + when token_type in ["ERC-20", "ERC-8056"] do {:ok, CurrencyHelper.format_according_to_decimals(amount, decimals)} end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex index cd49ff8c9ae..198c39ece72 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex @@ -4,6 +4,7 @@ defmodule BlockScoutWeb.Tokens.HolderView do alias BlockScoutWeb.Tokens.OverviewView alias Explorer.Chain.{Address, Token} + alias Explorer.Chain.Token.ScaledUIAmount @doc """ Checks if the total supply percentage must be shown. @@ -63,8 +64,11 @@ defmodule BlockScoutWeb.Tokens.HolderView do 1 """ - def format_token_balance_value(value, _id, %Token{type: "ERC-20", decimals: decimals}) do - format_according_to_decimals(value, decimals) + def format_token_balance_value(value, _id, %Token{type: type, decimals: decimals} = token) + when type in ["ERC-20", "ERC-8056"] do + value + |> ScaledUIAmount.scale(Token.effective_ui_multiplier(token)) + |> format_according_to_decimals(decimals) end def format_token_balance_value(value, _id, %Token{type: "ZRC-2", decimals: decimals}) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex index 142e1ececca..eb1cc0c3c14 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex @@ -6,5 +6,5 @@ defmodule BlockScoutWeb.Tokens.Instance.MetadataView do def format_metadata(nil), do: "" - def format_metadata(metadata), do: Poison.encode!(metadata, %{pretty: true}) + def format_metadata(metadata), do: Utils.JSON.encode!(metadata, pretty: true) end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex index 104797f4799..cabea7bb287 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex @@ -6,6 +6,7 @@ defmodule BlockScoutWeb.Tokens.OverviewView do alias Explorer.{Chain, CustomContractsHelper} alias Explorer.Chain.{Address, CurrencyHelper, SmartContract, Token} alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.Token.ScaledUIAmount alias Explorer.SmartContract.{Helper, Writer} import BlockScoutWeb.AddressView, only: [from_address_hash: 1, contract_interaction_disabled?: 0] @@ -82,7 +83,13 @@ defmodule BlockScoutWeb.Tokens.OverviewView do if Map.has_key?(token, :custom_cap) && token.custom_cap do token.custom_cap else - tokens = CurrencyHelper.divide_decimals(token.total_supply, token.decimals) + # `totalSupply()` is raw even for an ERC-8056 token, while `fiat_value` + # prices one displayed unit, so the supply is scaled before it is priced + tokens = + token.total_supply + |> ScaledUIAmount.scale(Token.effective_ui_multiplier(token)) + |> CurrencyHelper.divide_decimals(token.decimals) + price = token.fiat_value Decimal.mult(tokens, price) end diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex index 46944d75304..30801a19bbf 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex @@ -9,7 +9,7 @@ defmodule BlockScoutWeb.TransactionRawTraceView do def raw_traces_with_lines(raw_traces) do raw_traces - |> Jason.encode!(pretty: true) + |> Utils.JSON.encode!(pretty: true) |> String.split("\n") |> Enum.with_index(1) 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 e4a20d908d8..3ec66bee16e 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 @@ -4,6 +4,7 @@ defmodule BlockScoutWeb.TransactionStateView do alias Explorer.Chain alias Explorer.Chain.{Address, Wei} + alias Explorer.Chain.Token.ScaledUIAmount import Explorer.Chain.Transaction.StateChange, only: [fee_payer_loss: 1, from_loss: 1, has_diff?: 1, to_profit: 1] @@ -27,12 +28,20 @@ defmodule BlockScoutWeb.TransactionStateView do 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 + def display_value(balance, coin_or_transfer, token_id, ui_multiplier \\ nil) + + def display_value(balance, :coin, _token_id, _ui_multiplier) do format_wei_value(balance, :ether) end - def display_value(balance, token_transfer, token_id) do - render("_token_balance.html", transfer: token_transfer, balance: balance, token_id: token_id) + # this page renders the final number itself, so unlike the API it applies the + # ERC-8056 multiplier of the transaction rather than handing it over + def display_value(balance, token_transfer, token_id, ui_multiplier) do + render("_token_balance.html", + transfer: token_transfer, + balance: ScaledUIAmount.scale(balance, ui_multiplier), + token_id: token_id + ) end def display_erc_721(token_transfer) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex index 9d10b638fd4..807b0ab4aff 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex @@ -148,6 +148,7 @@ defmodule BlockScoutWeb.TransactionView do amounts: [], token_ids: token_transfer.token_ids, token_type: token_transfer.token_type, + ui_multiplier: token_transfer.ui_multiplier, to_address_hash: token_transfer.to_address_hash, from_address_hash: token_transfer.from_address_hash } @@ -163,6 +164,7 @@ defmodule BlockScoutWeb.TransactionView do amounts: amounts, token_ids: token_transfer.token_ids, token_type: token_transfer.token_type, + ui_multiplier: token_transfer.ui_multiplier, to_address_hash: token_transfer.to_address_hash, from_address_hash: token_transfer.from_address_hash } @@ -177,6 +179,7 @@ defmodule BlockScoutWeb.TransactionView do amounts: [], token_ids: token_transfer.token_ids, token_type: token_transfer.token_type, + ui_multiplier: token_transfer.ui_multiplier, to_address_hash: token_transfer.to_address_hash, from_address_hash: token_transfer.from_address_hash } diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index ab37a5deece..06f8604fdbc 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.3.3", + version: "12.0.0", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, @@ -113,8 +113,6 @@ defmodule BlockScoutWeb.Mixfile do {:hammer_backend_redis, "~> 7.0"}, {:httpoison, "~> 2.0"}, {:indexer, in_umbrella: true, runtime: false}, - # JSON parser and generator - {:jason, "~> 1.3"}, {:junit_formatter, ">= 0.0.0", only: [:test], runtime: false}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, @@ -132,9 +130,6 @@ defmodule BlockScoutWeb.Mixfile do {:prometheus_ex, "~> 5.1.0", override: true}, # use `:cowboy` for WebServer with `:plug` {:plug_cowboy, "~> 2.2"}, - # Waiting for the Pretty Print to be implemented at the Jason lib - # https://github.com/michalmuskala/jason/issues/15 - {:poison, "~> 5.0.0"}, {:postgrex, ">= 0.0.0"}, {:prometheus, "~> 6.0", override: true}, # Expose metrics from URL Prometheus server can scrape diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index f7709985ed2..63fd973272c 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -11,47 +11,57 @@ msgid "" msgstr "" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:9 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:9 #, elixir-autogen, elixir-format msgid " - minimal bytecode implementation that delegates all calls to a known address" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:14 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:14 #, elixir-autogen, elixir-format msgid " is recommended." msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:3 #: lib/block_scout_web/templates/address/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "%{address} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:12 #: lib/block_scout_web/templates/block/overview.html.eex:12 #, elixir-autogen, elixir-format msgid "%{block_type} Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:55 #: lib/block_scout_web/templates/block/overview.html.eex:55 #, elixir-autogen, elixir-format msgid "%{block_type} Height" msgstr "" +#: lib/block_scout_web/templates/block/index.html.eex:7 #: lib/block_scout_web/templates/block/index.html.eex:7 #, elixir-autogen, elixir-format msgid "%{block_type}s" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:85 #: lib/block_scout_web/templates/block/overview.html.eex:85 #, elixir-autogen, elixir-format msgid "%{count} Transaction" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:87 #: lib/block_scout_web/templates/block/overview.html.eex:87 #: lib/block_scout_web/templates/chain/_block.html.eex:11 +#: lib/block_scout_web/templates/chain/_block.html.eex:11 #, elixir-autogen, elixir-format msgid "%{count} Transactions" msgstr "" +#: lib/block_scout_web/views/address_token_balance_view.ex:10 #: lib/block_scout_web/views/address_token_balance_view.ex:10 #, elixir-autogen, elixir-format msgid "%{count} token" @@ -59,6 +69,7 @@ msgid_plural "%{count} tokens" msgstr[0] "" msgstr[1] "" +#: lib/block_scout_web/templates/block/_tile.html.eex:29 #: lib/block_scout_web/templates/block/_tile.html.eex:29 #, elixir-autogen, elixir-format msgid "%{count} transaction" @@ -66,809 +77,1050 @@ msgid_plural "%{count} transactions" msgstr[0] "" msgstr[1] "" +#: lib/block_scout_web/templates/transaction/_actions.html.eex:101 #: lib/block_scout_web/templates/transaction/_actions.html.eex:101 #, elixir-autogen, elixir-format msgid "%{qty} of <span class=\"text-muted\">Token ID [%{link_to_id}]</span>" msgstr "" +#: lib/block_scout_web/templates/chain/_metatags.html.eex:2 #: lib/block_scout_web/templates/chain/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "%{subnetwork} %{network} Explorer" msgstr "" +#: lib/block_scout_web/templates/layout/_default_title.html.eex:2 #: lib/block_scout_web/templates/layout/_default_title.html.eex:2 #, elixir-autogen, elixir-format msgid "%{subnetwork} Explorer - BlockScout" msgstr "" +#: lib/block_scout_web/templates/withdrawal/index.html.eex:11 #: lib/block_scout_web/templates/withdrawal/index.html.eex:11 #, elixir-autogen, elixir-format msgid "%{withdrawals_count} withdrawals processed and %{withdrawals_sum} withdrawn." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:373 #: lib/block_scout_web/views/transaction_view.ex:373 #, elixir-autogen, elixir-format msgid "(Awaiting internal transactions for status)" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:82 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:82 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:104 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:104 #, elixir-autogen, elixir-format msgid "(query)" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #, elixir-autogen, elixir-format msgid ") may be added for each contract. Click the Add Library button to add an additional one." msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:93 #: lib/block_scout_web/templates/layout/app.html.eex:93 #, elixir-autogen, elixir-format msgid "- We're indexing this chain right now. Some of the counts may be inaccurate." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:8 #: lib/block_scout_web/templates/transaction/not_found.html.eex:8 #, elixir-autogen, elixir-format msgid "1. If you have just submitted this transaction please wait for at least 30 seconds before refreshing this page." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:9 #: lib/block_scout_web/templates/transaction/not_found.html.eex:9 #, elixir-autogen, elixir-format msgid "2. It could still be in the TX Pool of a different node, waiting to be broadcasted." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:10 #: lib/block_scout_web/templates/transaction/not_found.html.eex:10 #, elixir-autogen, elixir-format msgid "3. During times when the network is busy (i.e during ICOs) it can take a while for your transaction to propagate through the network and for us to index it." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:11 #: lib/block_scout_web/templates/transaction/not_found.html.eex:11 #, elixir-autogen, elixir-format msgid "4. If it still does not show up after 1 hour, please check with your sender/exchange/wallet/transaction provider for additional information." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:197 #: lib/block_scout_web/templates/block/overview.html.eex:197 #, elixir-autogen, elixir-format msgid "64-bit hash of value verifying proof-of-work (note: null for POA chains)." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:97 #: lib/block_scout_web/templates/block/overview.html.eex:97 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:21 +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:21 #, elixir-autogen, elixir-format msgid "A block producer who successfully included the block onto the blockchain." msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "A confirmation email was sent to" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #, elixir-autogen, elixir-format msgid "A library name called in the .sol file. Multiple libraries (up to " msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:73 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:73 #, elixir-autogen, elixir-format msgid "A string with the name of the action to be invoked." msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:62 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:62 #, elixir-autogen, elixir-format msgid "A string with the name of the module to be invoked." msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:24 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:24 #, elixir-autogen, elixir-format msgid "ABI" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_constructor_args.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_constructor_args.html.eex:3 #, elixir-autogen, elixir-format msgid "ABI-encoded Constructor Arguments (if required by the contract)" msgstr "" +#: lib/block_scout_web/templates/api_docs/index.html.eex:4 #: lib/block_scout_web/templates/api_docs/index.html.eex:4 #, elixir-autogen, elixir-format msgid "API Documentation" msgstr "" +#: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 #, elixir-autogen, elixir-format msgid "API endpoints for the %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/api_docs/_metatags.html.eex:2 #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "API for the %{subnetwork} - BlockScout" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:13 #: lib/block_scout_web/templates/account/api_key/form.html.eex:13 #: lib/block_scout_web/templates/account/api_key/form.html.eex:14 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:14 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:29 #: lib/block_scout_web/templates/account/api_key/index.html.eex:29 #, elixir-autogen, elixir-format msgid "API key" msgstr "" #: lib/block_scout_web/templates/account/api_key/index.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:7 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:16 #: lib/block_scout_web/templates/account/common/_nav.html.eex:16 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:18 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:18 #, elixir-autogen, elixir-format msgid "API keys" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:106 #: lib/block_scout_web/templates/layout/_topnav.html.eex:106 #, elixir-autogen, elixir-format msgid "APIs" msgstr "" #: lib/block_scout_web/templates/account/tag_address/index.html.eex:24 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:24 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:24 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:24 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 #, elixir-autogen, elixir-format msgid "Action" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:25 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:25 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:461 #: lib/block_scout_web/templates/transaction/overview.html.eex:461 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction on L2." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:465 #: lib/block_scout_web/templates/transaction/overview.html.eex:465 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction." msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/layout/_add_chain_to_mm.html.eex:10 +#: lib/block_scout_web/templates/layout/_add_chain_to_mm.html.eex:10 #, elixir-autogen, elixir-format msgid "Add" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:44 #: lib/block_scout_web/templates/account/api_key/index.html.eex:44 #, elixir-autogen, elixir-format msgid "Add API key" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:86 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:86 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:76 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:76 #, elixir-autogen, elixir-format msgid "Add Contract Libraries" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:44 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:44 #, elixir-autogen, elixir-format msgid "Add Custom ABI" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:97 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:97 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:87 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:87 #, elixir-autogen, elixir-format msgid "Add Library" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:38 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:38 #, elixir-autogen, elixir-format msgid "Add address" msgstr "" #: lib/block_scout_web/templates/account/tag_address/form.html.eex:7 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:7 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:37 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Add address tag" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #, elixir-autogen, elixir-format msgid "Add address to the Watch list" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:37 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Add transaction tag" msgstr "" +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:23 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:23 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:23 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:23 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:12 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:12 +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 +#: lib/block_scout_web/templates/tokens/index.html.eex:34 #: lib/block_scout_web/templates/tokens/index.html.eex:34 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 +#: lib/block_scout_web/views/address_view.ex:110 #: lib/block_scout_web/views/address_view.ex:110 #, elixir-autogen, elixir-format msgid "Address" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:240 #: lib/block_scout_web/templates/transaction/overview.html.eex:240 #, elixir-autogen, elixir-format msgid "Address (external or contract) receiving the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:222 #: lib/block_scout_web/templates/transaction/overview.html.eex:222 #, elixir-autogen, elixir-format msgid "Address (external or contract) sending the transaction." msgstr "" #: lib/block_scout_web/templates/account/common/_nav.html.eex:10 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:10 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:7 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:7 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:16 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:16 #, elixir-autogen, elixir-format msgid "Address Tags" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:151 #: lib/block_scout_web/templates/address/overview.html.eex:151 #, elixir-autogen, elixir-format msgid "Address balance in" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:51 #, elixir-autogen, elixir-format msgid "Address of the token contract" msgstr "" +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:7 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:7 #, elixir-autogen, elixir-format msgid "Address used in token mintings and burnings." msgstr "" +#: lib/block_scout_web/templates/address/index.html.eex:5 #: lib/block_scout_web/templates/address/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Addresses" msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:35 #, elixir-autogen, elixir-format msgid "Age" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:26 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:26 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:28 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:28 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:22 #: lib/block_scout_web/templates/address_transaction/index.html.eex:22 #: lib/block_scout_web/templates/layout/_topnav.html.eex:88 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:88 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:20 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:20 #: lib/block_scout_web/views/address_internal_transaction_view.ex:12 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:12 +#: lib/block_scout_web/views/address_token_transfer_view.ex:12 #: lib/block_scout_web/views/address_token_transfer_view.ex:12 #: lib/block_scout_web/views/address_transaction_view.ex:12 +#: lib/block_scout_web/views/address_transaction_view.ex:12 +#: lib/block_scout_web/views/verified_contracts_view.ex:13 #: lib/block_scout_web/views/verified_contracts_view.ex:13 #, elixir-autogen, elixir-format msgid "All" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:13 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:13 #, elixir-autogen, elixir-format msgid "All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #, elixir-autogen, elixir-format msgid "All metadata displayed below is from that contract. In order to verify current contract, click" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:176 #: lib/block_scout_web/templates/address/overview.html.eex:176 #, elixir-autogen, elixir-format msgid "All tokens in the account and total value." msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:41 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:41 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:32 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:32 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/withdrawal/index.html.eex:38 #, elixir-autogen, elixir-format msgid "Amount" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "Amount of" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:238 #: lib/block_scout_web/templates/block/overview.html.eex:238 #, elixir-autogen, elixir-format msgid "Amount of distributed reward. Miners receive a static block reward + Tx fees + uncle fees." msgstr "" +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:8 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:8 #, elixir-autogen, elixir-format msgid "An unexpected error has occurred. Try reloading the page, or come back soon and try again." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:15 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:15 #, elixir-autogen, elixir-format msgid "Anything not in this list is not supported. Click on the method to be taken to the documentation for that method, and check the notes section for any potential differences." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:134 #: lib/block_scout_web/templates/layout/_topnav.html.eex:134 #, elixir-autogen, elixir-format msgid "Apps" msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 #, elixir-autogen, elixir-format msgid "Average" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:104 #: lib/block_scout_web/templates/chain/show.html.eex:104 #, elixir-autogen, elixir-format msgid "Average block time" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:25 #: lib/block_scout_web/templates/account/api_key/form.html.eex:25 #, elixir-autogen, elixir-format msgid "Back to API keys (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:24 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Back to Address Tags (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:30 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:30 #, elixir-autogen, elixir-format msgid "Back to Custom ABI (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:24 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Back to Transaction Tags (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:81 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:81 #, elixir-autogen, elixir-format msgid "Back to Watch list (Cancel)" msgstr "" #: lib/block_scout_web/templates/error422/index.html.eex:9 +#: lib/block_scout_web/templates/error422/index.html.eex:9 +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:9 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:9 #: lib/block_scout_web/templates/page_not_found/index.html.eex:9 +#: lib/block_scout_web/templates/page_not_found/index.html.eex:9 +#: lib/block_scout_web/templates/transaction/not_found.html.eex:13 #: lib/block_scout_web/templates/transaction/not_found.html.eex:13 #, elixir-autogen, elixir-format msgid "Back to home" msgstr "" #: lib/block_scout_web/templates/account/watchlist/show.html.eex:24 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:24 +#: lib/block_scout_web/templates/address/overview.html.eex:152 #: lib/block_scout_web/templates/address/overview.html.eex:152 #: lib/block_scout_web/templates/address_token/overview.html.eex:51 +#: lib/block_scout_web/templates/address_token/overview.html.eex:51 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:63 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:63 #, elixir-autogen, elixir-format msgid "Balance" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:40 #: lib/block_scout_web/templates/transaction_state/index.html.eex:40 #, elixir-autogen, elixir-format msgid "Balance after" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:37 #: lib/block_scout_web/templates/transaction_state/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Balance before" msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:14 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Balances" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:209 #: lib/block_scout_web/templates/block/overview.html.eex:209 #, elixir-autogen, elixir-format msgid "Base Fee per Gas" msgstr "" #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:5 +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:5 +#: lib/block_scout_web/templates/api_docs/index.html.eex:5 #: lib/block_scout_web/templates/api_docs/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Base URL:" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:2 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "Beacon chain withdrawals - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:7 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:7 #, elixir-autogen, elixir-format msgid "Beacon chain, Withdrawals, %{subnetwork}, %{coin}" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:522 #: lib/block_scout_web/templates/transaction/overview.html.eex:522 #, elixir-autogen, elixir-format msgid "Binary data included with the transaction. See input / logs below for additional info." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/_coin_balances.html.eex:8 #: lib/block_scout_web/templates/address_coin_balance/_coin_balances.html.eex:8 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/block/overview.html.eex:29 #: lib/block_scout_web/templates/block/overview.html.eex:29 #: lib/block_scout_web/templates/transaction/overview.html.eex:161 +#: lib/block_scout_web/templates/transaction/overview.html.eex:161 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/withdrawal/index.html.eex:29 #, elixir-autogen, elixir-format msgid "Block" msgstr "" #: lib/block_scout_web/templates/block/_link.html.eex:2 +#: lib/block_scout_web/templates/block/_link.html.eex:2 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:32 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:32 #: lib/block_scout_web/templates/tokens/transfer/_token_transfer.html.eex:43 +#: lib/block_scout_web/templates/tokens/transfer/_token_transfer.html.eex:43 #, elixir-autogen, elixir-format msgid "Block #%{number}" msgstr "" +#: lib/block_scout_web/templates/block/_metatags.html.eex:3 #: lib/block_scout_web/templates/block/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "Block %{block_number} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/block_transaction/404.html.eex:7 #: lib/block_scout_web/templates/block_transaction/404.html.eex:7 #, elixir-autogen, elixir-format msgid "Block Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:53 #: lib/block_scout_web/templates/block/overview.html.eex:53 #, elixir-autogen, elixir-format msgid "Block Height" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:47 #: lib/block_scout_web/templates/layout/app.html.eex:47 #, elixir-autogen, elixir-format msgid "Block Mined, awaiting import..." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:34 #: lib/block_scout_web/views/transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Block Pending" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:158 #: lib/block_scout_web/templates/block/overview.html.eex:158 #, elixir-autogen, elixir-format msgid "Block difficulty for miner, used to calibrate block generation time (Note: constant in POA based networks)." msgstr "" +#: lib/block_scout_web/views/block_transaction_view.ex:19 #: lib/block_scout_web/views/block_transaction_view.ex:19 #, elixir-autogen, elixir-format msgid "Block not found, please try again later." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:203 #: lib/block_scout_web/templates/transaction/overview.html.eex:203 #, elixir-autogen, elixir-format msgid "Block number containing the transaction on L1." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:160 #: lib/block_scout_web/templates/transaction/overview.html.eex:160 #, elixir-autogen, elixir-format msgid "Block number containing the transaction." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:259 #: lib/block_scout_web/templates/address/overview.html.eex:259 #, elixir-autogen, elixir-format msgid "Block number in which the address was updated." msgstr "" +#: lib/block_scout_web/templates/chain/_metatags.html.eex:4 #: lib/block_scout_web/templates/chain/_metatags.html.eex:4 #, elixir-autogen, elixir-format msgid "BlockScout provides analytics data, API, and Smart Contract tools for the %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:29 #: lib/block_scout_web/templates/layout/_topnav.html.eex:29 #, elixir-autogen, elixir-format msgid "Blockchain" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/layout/_topnav.html.eex:34 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:34 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #, elixir-autogen, elixir-format msgid "Blocks" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:46 #: lib/block_scout_web/templates/layout/app.html.eex:46 #, elixir-autogen, elixir-format msgid "Blocks Indexed" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/overview.html.eex:277 +#: lib/block_scout_web/templates/address/overview.html.eex:277 +#: lib/block_scout_web/templates/address_validation/index.html.eex:11 #: lib/block_scout_web/templates/address_validation/index.html.eex:11 #: lib/block_scout_web/views/address_view.ex:362 +#: lib/block_scout_web/views/address_view.ex:362 #, elixir-autogen, elixir-format msgid "Blocks Validated" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:48 #: lib/block_scout_web/templates/layout/app.html.eex:48 #, elixir-autogen, elixir-format msgid "Blocks With Internal Transactions Indexed" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:22 #: lib/block_scout_web/templates/layout/_footer.html.eex:22 #, elixir-autogen, elixir-format msgid "Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks." msgstr "" +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:8 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:8 #, elixir-autogen, elixir-format msgid "Burn address" msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:64 +#: lib/block_scout_web/templates/block/_tile.html.eex:64 +#: lib/block_scout_web/templates/block/overview.html.eex:218 #: lib/block_scout_web/templates/block/overview.html.eex:218 #, elixir-autogen, elixir-format msgid "Burnt Fees" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:65 #: lib/block_scout_web/templates/address_token/overview.html.eex:65 #, elixir-autogen, elixir-format msgid "CRC Worth" msgstr "" +#: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 #: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 #, elixir-autogen, elixir-format msgid "CSV" msgstr "" #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/views/internal_transaction_view.ex:23 #: lib/block_scout_web/views/internal_transaction_view.ex:23 #, elixir-autogen, elixir-format msgid "Call" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:24 #: lib/block_scout_web/views/internal_transaction_view.ex:24 #, elixir-autogen, elixir-format msgid "Call Code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:54 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:54 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:43 #: lib/block_scout_web/templates/transaction_state/index.html.eex:43 #, elixir-autogen, elixir-format msgid "Change" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:43 #: lib/block_scout_web/templates/layout/_footer.html.eex:43 #, elixir-autogen, elixir-format msgid "Chat (#blockscout)" msgstr "" +#: lib/block_scout_web/views/block_view.ex:65 #: lib/block_scout_web/views/block_view.ex:65 #, elixir-autogen, elixir-format msgid "Chore Reward" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:38 #: lib/block_scout_web/templates/tokens/index.html.eex:38 #, elixir-autogen, elixir-format msgid "Circulating Market Cap" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:137 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:137 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:106 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:106 #, elixir-autogen, elixir-format msgid "Clear" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:37 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:37 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:6 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:6 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:14 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:14 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:84 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:84 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:92 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:92 #, elixir-autogen, elixir-format msgid "Close" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:66 +#: lib/block_scout_web/templates/address/_tabs.html.eex:66 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:165 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:165 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 +#: lib/block_scout_web/views/address_view.ex:356 #: lib/block_scout_web/views/address_view.ex:356 #, elixir-autogen, elixir-format msgid "Code" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:42 +#: lib/block_scout_web/templates/address/_tabs.html.eex:42 +#: lib/block_scout_web/views/address_view.ex:361 #: lib/block_scout_web/views/address_view.ex:361 #, elixir-autogen, elixir-format msgid "Coin Balance History" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #, elixir-autogen, elixir-format msgid "Collapse" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 #, elixir-autogen, elixir-format msgid "Compiler" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:142 #: lib/block_scout_web/templates/address_contract/index.html.eex:142 #, elixir-autogen, elixir-format msgid "Compiler Settings" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:71 #: lib/block_scout_web/templates/address_contract/index.html.eex:71 #, elixir-autogen, elixir-format msgid "Compiler version" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:366 #: lib/block_scout_web/views/transaction_view.ex:366 #, elixir-autogen, elixir-format msgid "Confirmed" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:127 #: lib/block_scout_web/templates/transaction/overview.html.eex:127 #, elixir-autogen, elixir-format msgid "Confirmed by " msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:193 #: lib/block_scout_web/templates/transaction/overview.html.eex:193 #, elixir-autogen, elixir-format msgid "Confirmed within" msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:6 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:6 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:6 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:6 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:4 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:16 #, elixir-autogen, elixir-format msgid "Connection Lost" msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:12 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:12 #: lib/block_scout_web/templates/block/index.html.eex:5 +#: lib/block_scout_web/templates/block/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer blocks" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:15 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:15 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer internal transactions" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:11 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:11 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:16 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:16 #: lib/block_scout_web/templates/transaction/index.html.eex:22 +#: lib/block_scout_web/templates/transaction/index.html.eex:22 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer transactions" msgstr "" +#: lib/block_scout_web/templates/address_validation/index.html.eex:10 #: lib/block_scout_web/templates/address_validation/index.html.eex:10 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer validations" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:96 #: lib/block_scout_web/templates/address_contract/index.html.eex:96 #, elixir-autogen, elixir-format msgid "Constructor Arguments" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:78 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:78 #, elixir-autogen, elixir-format msgid "Constructor args" msgstr "" #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 +#: lib/block_scout_web/templates/transaction/overview.html.eex:250 #: lib/block_scout_web/templates/transaction/overview.html.eex:250 #, elixir-autogen, elixir-format msgid "Contract" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:157 #: lib/block_scout_web/templates/address_contract/index.html.eex:157 #, elixir-autogen, elixir-format msgid "Contract ABI" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 #: lib/block_scout_web/views/address_view.ex:108 +#: lib/block_scout_web/views/address_view.ex:108 #, elixir-autogen, elixir-format msgid "Contract Address" msgstr "" +#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 #: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:82 #: lib/block_scout_web/views/address_view.ex:82 #, elixir-autogen, elixir-format msgid "Contract Address Pending" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:495 #: lib/block_scout_web/views/transaction_view.ex:495 #, elixir-autogen, elixir-format msgid "Contract Call" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:492 #: lib/block_scout_web/views/transaction_view.ex:492 #, elixir-autogen, elixir-format msgid "Contract Creation" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:174 +#: lib/block_scout_web/templates/address_contract/index.html.eex:174 +#: lib/block_scout_web/templates/address_contract/index.html.eex:189 #: lib/block_scout_web/templates/address_contract/index.html.eex:189 #, elixir-autogen, elixir-format msgid "Contract Creation Code" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:90 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:90 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:80 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:80 #, elixir-autogen, elixir-format msgid "Contract Libraries" msgstr "" #: lib/block_scout_web/templates/address/overview.html.eex:75 +#: lib/block_scout_web/templates/address/overview.html.eex:75 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_name_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_name_field.html.eex:3 #, elixir-autogen, elixir-format msgid "Contract Name" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_contract/index.html.eex:25 +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:11 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:11 #, elixir-autogen, elixir-format msgid "Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:47 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:47 #, elixir-autogen, elixir-format msgid "Contract name or address" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:63 #: lib/block_scout_web/templates/address_contract/index.html.eex:63 #, elixir-autogen, elixir-format msgid "Contract name:" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:106 #: lib/block_scout_web/templates/address_contract/index.html.eex:106 #, elixir-autogen, elixir-format msgid "Contract source code" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:120 #: lib/block_scout_web/templates/address/overview.html.eex:120 #, elixir-autogen, elixir-format msgid "Contract was precompiled and created at genesis or contract creation transaction is missing" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:5 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:5 #, elixir-autogen, elixir-format msgid "Contracts" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:180 #: lib/block_scout_web/templates/address_contract/index.html.eex:180 #, elixir-autogen, elixir-format msgid "Contracts that self destruct in their constructors have no contract code published and cannot be verified." msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:42 #: lib/block_scout_web/templates/layout/_footer.html.eex:42 #, elixir-autogen, elixir-format msgid "Contribute" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:159 #: lib/block_scout_web/templates/address_contract/index.html.eex:159 #, elixir-autogen, elixir-format msgid "Copy ABI" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #, elixir-autogen, elixir-format @@ -877,24 +1129,38 @@ msgstr "" #: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 #: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 +#: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/address/overview.html.eex:38 #: lib/block_scout_web/templates/address/overview.html.eex:38 #: lib/block_scout_web/templates/address/overview.html.eex:39 +#: lib/block_scout_web/templates/address/overview.html.eex:39 +#: lib/block_scout_web/templates/block/overview.html.eex:104 #: lib/block_scout_web/templates/block/overview.html.eex:104 #: lib/block_scout_web/templates/block/overview.html.eex:105 +#: lib/block_scout_web/templates/block/overview.html.eex:105 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:43 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:43 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:44 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:44 #, elixir-autogen, elixir-format msgid "Copy Address" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:144 #: lib/block_scout_web/templates/address_contract/index.html.eex:144 #, elixir-autogen, elixir-format msgid "Copy Compiler Settings" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #, elixir-autogen, elixir-format @@ -902,734 +1168,952 @@ msgid "Copy Contract Address" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:176 +#: lib/block_scout_web/templates/address_contract/index.html.eex:176 +#: lib/block_scout_web/templates/address_contract/index.html.eex:192 #: lib/block_scout_web/templates/address_contract/index.html.eex:192 #, elixir-autogen, elixir-format msgid "Copy Contract Creation Code" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:213 +#: lib/block_scout_web/templates/address_contract/index.html.eex:213 +#: lib/block_scout_web/templates/address_contract/index.html.eex:223 #: lib/block_scout_web/templates/address_contract/index.html.eex:223 #, elixir-autogen, elixir-format msgid "Copy Deployed ByteCode" msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 +#: lib/block_scout_web/templates/transaction/overview.html.eex:230 #: lib/block_scout_web/templates/transaction/overview.html.eex:230 #: lib/block_scout_web/templates/transaction/overview.html.eex:231 +#: lib/block_scout_web/templates/transaction/overview.html.eex:231 #, elixir-autogen, elixir-format msgid "Copy From Address" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:129 #: lib/block_scout_web/templates/block/overview.html.eex:129 #: lib/block_scout_web/templates/block/overview.html.eex:130 +#: lib/block_scout_web/templates/block/overview.html.eex:130 #, elixir-autogen, elixir-format msgid "Copy Hash" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:20 #: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:20 #, elixir-autogen, elixir-format msgid "Copy Metadata" msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:149 +#: lib/block_scout_web/templates/block/overview.html.eex:149 +#: lib/block_scout_web/templates/block/overview.html.eex:150 #: lib/block_scout_web/templates/block/overview.html.eex:150 #, elixir-autogen, elixir-format msgid "Copy Parent Hash" msgstr "" +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:9 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:9 #, elixir-autogen, elixir-format msgid "Copy Raw Trace" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:120 #: lib/block_scout_web/templates/address_contract/index.html.eex:120 #: lib/block_scout_web/templates/address_contract/index.html.eex:132 +#: lib/block_scout_web/templates/address_contract/index.html.eex:132 #, elixir-autogen, elixir-format msgid "Copy Source Code" msgstr "" +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 +#: lib/block_scout_web/templates/transaction/overview.html.eex:257 #: lib/block_scout_web/templates/transaction/overview.html.eex:257 #: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:265 #: lib/block_scout_web/templates/transaction/overview.html.eex:265 #: lib/block_scout_web/templates/transaction/overview.html.eex:266 +#: lib/block_scout_web/templates/transaction/overview.html.eex:266 #, elixir-autogen, elixir-format msgid "Copy To Address" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:32 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:32 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:33 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:33 #, elixir-autogen, elixir-format msgid "Copy Token ID" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:87 #: lib/block_scout_web/templates/transaction/overview.html.eex:87 #, elixir-autogen, elixir-format msgid "Copy Transaction Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:88 #: lib/block_scout_web/templates/transaction/overview.html.eex:88 #, elixir-autogen, elixir-format msgid "Copy Txn Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:548 #: lib/block_scout_web/templates/transaction/overview.html.eex:548 #, elixir-autogen, elixir-format msgid "Copy Txn Hex Input" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:554 #: lib/block_scout_web/templates/transaction/overview.html.eex:554 #, elixir-autogen, elixir-format msgid "Copy Txn UTF-8 Input" msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 +#: lib/block_scout_web/templates/transaction/overview.html.eex:547 #: lib/block_scout_web/templates/transaction/overview.html.eex:547 #: lib/block_scout_web/templates/transaction/overview.html.eex:553 +#: lib/block_scout_web/templates/transaction/overview.html.eex:553 +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #, elixir-autogen, elixir-format msgid "Copy Value" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:31 #: lib/block_scout_web/views/internal_transaction_view.ex:31 #, elixir-autogen, elixir-format msgid "Create" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:12 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:12 #, elixir-autogen, elixir-format msgid "Create a Custom ABI to interact with contracts." msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:12 #: lib/block_scout_web/templates/account/api_key/index.html.eex:12 #, elixir-autogen, elixir-format msgid "Create an API key to use with your RPC and EthRPC API requests." msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:32 #: lib/block_scout_web/views/internal_transaction_view.ex:32 #, elixir-autogen, elixir-format msgid "Create2" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:102 #: lib/block_scout_web/templates/address/overview.html.eex:102 #, elixir-autogen, elixir-format msgid "Creator" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:146 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:146 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:116 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:116 #, elixir-autogen, elixir-format msgid "Curl" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:97 #: lib/block_scout_web/templates/transaction/overview.html.eex:97 #, elixir-autogen, elixir-format msgid "Current transaction state: Success, Failed (Error), or Pending (In Process)" msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:20 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:20 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:18 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Custom" msgstr "" #: lib/block_scout_web/templates/account/common/_nav.html.eex:19 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:19 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:7 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:19 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:19 #, elixir-autogen, elixir-format msgid "Custom ABI" msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:23 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:23 #, elixir-autogen, elixir-format msgid "Custom ABI from account" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:72 #: lib/block_scout_web/templates/chain/show.html.eex:72 #, elixir-autogen, elixir-format msgid "Daily Transactions" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:98 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:98 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:7 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:7 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:23 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:23 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:130 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:130 #, elixir-autogen, elixir-format msgid "Data" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:70 #: lib/block_scout_web/templates/block/overview.html.eex:70 #, elixir-autogen, elixir-format msgid "Date & time at which block was produced." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:179 #: lib/block_scout_web/templates/transaction/overview.html.eex:179 #, elixir-autogen, elixir-format msgid "Date & time of transaction inclusion, including length of time for confirmation." msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:52 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:52 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:131 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:131 #, elixir-autogen, elixir-format msgid "Decimals" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:32 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:32 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:38 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:38 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:53 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:53 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:43 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:43 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:51 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:51 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:66 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:66 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:82 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:82 #, elixir-autogen, elixir-format msgid "Decoded" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:25 #: lib/block_scout_web/views/internal_transaction_view.ex:25 #, elixir-autogen, elixir-format msgid "Delegate Call" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:211 #: lib/block_scout_web/templates/address_contract/index.html.eex:211 #: lib/block_scout_web/templates/address_contract/index.html.eex:219 +#: lib/block_scout_web/templates/address_contract/index.html.eex:219 #, elixir-autogen, elixir-format msgid "Deployed ByteCode" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:53 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:53 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:188 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:188 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:60 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:60 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:150 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:150 #, elixir-autogen, elixir-format msgid "Description" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 #, elixir-autogen, elixir-format msgid "Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:159 #: lib/block_scout_web/templates/block/overview.html.eex:159 #, elixir-autogen, elixir-format msgid "Difficulty" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:181 #: lib/block_scout_web/templates/address_contract/index.html.eex:181 #, elixir-autogen, elixir-format msgid "Displaying the init data provided of the creating transaction." msgstr "" #: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 +#: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 +#: lib/block_scout_web/templates/csv_export/index.html.eex:33 #: lib/block_scout_web/templates/csv_export/index.html.eex:33 #, elixir-autogen, elixir-format msgid "Download" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Drop all Solidity contract source files into the drop zone." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Drop all Solidity or Yul contract source files into the drop zone." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:18 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:18 #, elixir-autogen, elixir-format msgid "Drop sources and metadata JSON file or click here" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:67 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:67 #, elixir-autogen, elixir-format msgid "Drop sources or click here" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:28 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:28 #, elixir-autogen, elixir-format msgid "Drop the standard input JSON file or click here" msgstr "" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #, elixir-autogen, elixir-format msgid "EIP-1167" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:221 #: lib/block_scout_web/views/transaction_view.ex:221 #, elixir-autogen, elixir-format msgid "ERC-1155 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:219 #: lib/block_scout_web/views/transaction_view.ex:219 #, elixir-autogen, elixir-format msgid "ERC-20 " msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:40 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:40 #, elixir-autogen, elixir-format msgid "ERC-20 tokens (beta)" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:222 #: lib/block_scout_web/views/transaction_view.ex:222 #, elixir-autogen, elixir-format msgid "ERC-404 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:220 #: lib/block_scout_web/views/transaction_view.ex:220 #, elixir-autogen, elixir-format msgid "ERC-721 " msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:53 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:53 #, elixir-autogen, elixir-format msgid "ERC-721, ERC-1155 tokens (NFT) (beta)" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:4 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:4 #, elixir-autogen, elixir-format msgid "ETH RPC API Documentation" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:82 #: lib/block_scout_web/templates/address_contract/index.html.eex:82 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:30 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:30 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:22 #, elixir-autogen, elixir-format msgid "EVM Version" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #, elixir-autogen, elixir-format msgid "EVM version details" msgstr "" #: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:11 #: lib/block_scout_web/views/block_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "Easy Cowboy! This block does not exist yet!" msgstr "" #: lib/block_scout_web/templates/account/api_key/row.html.eex:16 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:16 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:16 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:16 #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:27 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:27 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #, elixir-autogen, elixir-format msgid "Edit Watch list address" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:71 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:71 #, elixir-autogen, elixir-format msgid "Email notifications" msgstr "" +#: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #, elixir-autogen, elixir-format msgid "Emission Contract" msgstr "" +#: lib/block_scout_web/views/block_view.ex:73 #: lib/block_scout_web/views/block_view.ex:73 #, elixir-autogen, elixir-format msgid "Emission Reward" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Enter the Solidity Contract Code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Enter the Vyper Contract Code" msgstr "" +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 #, elixir-autogen, elixir-format msgid "Error" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/transaction/_tile.html.eex:11 #, elixir-autogen, elixir-format msgid "Error in internal transactions" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:33 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:33 #, elixir-autogen, elixir-format msgid "Error rendering value" msgstr "" +#: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:10 #: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:10 #, elixir-autogen, elixir-format msgid "Error trying to fetch balances." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:377 #: lib/block_scout_web/views/transaction_view.ex:377 #, elixir-autogen, elixir-format msgid "Error: %{reason}" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:375 #: lib/block_scout_web/views/transaction_view.ex:375 #, elixir-autogen, elixir-format msgid "Error: (Awaiting internal transactions for reason)" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:120 #: lib/block_scout_web/templates/address/overview.html.eex:120 #, elixir-autogen, elixir-format msgid "Error: Could not determine contract creator." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:120 #: lib/block_scout_web/templates/layout/_topnav.html.eex:120 #, elixir-autogen, elixir-format msgid "Eth RPC" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:211 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:211 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:164 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:164 #, elixir-autogen, elixir-format msgid "Example Value" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:128 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:128 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:99 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:99 #, elixir-autogen, elixir-format msgid "Execute" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #, elixir-autogen, elixir-format msgid "Expand" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:14 #: lib/block_scout_web/templates/csv_export/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Export" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:10 #: lib/block_scout_web/templates/csv_export/index.html.eex:10 #, elixir-autogen, elixir-format msgid "Export Data" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:248 #: lib/block_scout_web/templates/address_contract/index.html.eex:248 #, elixir-autogen, elixir-format msgid "External libraries" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:40 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:40 #, elixir-autogen, elixir-format msgid "Failed to decode input data." msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:35 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:35 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:46 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:46 #, elixir-autogen, elixir-format msgid "Failed to decode log data." msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #, elixir-autogen, elixir-format msgid "Fast" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:249 #: lib/block_scout_web/templates/address/overview.html.eex:249 #, elixir-autogen, elixir-format msgid "Fetching gas used..." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:112 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:112 #, elixir-autogen, elixir-format msgid "Fetching holders..." msgstr "" +#: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:7 #: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:7 #, elixir-autogen, elixir-format msgid "Fetching tokens..." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:196 #: lib/block_scout_web/templates/address/overview.html.eex:196 #: lib/block_scout_web/templates/address/overview.html.eex:204 +#: lib/block_scout_web/templates/address/overview.html.eex:204 #, elixir-autogen, elixir-format msgid "Fetching transactions..." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:223 #: lib/block_scout_web/templates/address/overview.html.eex:223 #: lib/block_scout_web/templates/address/overview.html.eex:231 +#: lib/block_scout_web/templates/address/overview.html.eex:231 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:123 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:123 #, elixir-autogen, elixir-format msgid "Fetching transfers..." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:15 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:15 #, elixir-autogen, elixir-format msgid "Filter by compiler:" msgstr "" +#: lib/block_scout_web/templates/admin/dashboard/index.html.eex:16 #: lib/block_scout_web/templates/admin/dashboard/index.html.eex:16 #, elixir-autogen, elixir-format msgid "For any existing contracts in the database, insert all ABI entries into the contract_methods table. Use this in case you have verified smart contracts before early March 2019 and you want other contracts with the same functions to show those ABI's as candidate matches." msgstr "" +#: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:7 #: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:7 #, elixir-autogen, elixir-format msgid "For contract" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:44 #: lib/block_scout_web/templates/layout/_topnav.html.eex:44 #, elixir-autogen, elixir-format msgid "Forked Blocks (Reorgs)" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:45 #: lib/block_scout_web/templates/layout/_footer.html.eex:45 #, elixir-autogen, elixir-format msgid "Forum" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:34 #: lib/block_scout_web/templates/transaction/overview.html.eex:223 +#: lib/block_scout_web/templates/transaction/overview.html.eex:223 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_token_transfer_view.ex:11 +#: lib/block_scout_web/views/address_token_transfer_view.ex:11 +#: lib/block_scout_web/views/address_transaction_view.ex:11 #: lib/block_scout_web/views/address_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "From" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:18 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:18 #, elixir-autogen, elixir-format msgid "GET" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/overview.html.eex:189 +#: lib/block_scout_web/templates/block/overview.html.eex:189 +#: lib/block_scout_web/templates/transaction/overview.html.eex:407 #: lib/block_scout_web/templates/transaction/overview.html.eex:407 #, elixir-autogen, elixir-format msgid "Gas Limit" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:381 #: lib/block_scout_web/templates/transaction/overview.html.eex:381 #, elixir-autogen, elixir-format msgid "Gas Price" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:242 #: lib/block_scout_web/templates/address/overview.html.eex:242 #: lib/block_scout_web/templates/block/_tile.html.eex:73 +#: lib/block_scout_web/templates/block/_tile.html.eex:73 +#: lib/block_scout_web/templates/block/overview.html.eex:180 #: lib/block_scout_web/templates/block/overview.html.eex:180 #, elixir-autogen, elixir-format msgid "Gas Used" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:466 #: lib/block_scout_web/templates/transaction/overview.html.eex:466 #, elixir-autogen, elixir-format msgid "Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:3 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:3 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:18 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:18 #, elixir-autogen, elixir-format msgid "Gas tracker" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:241 #: lib/block_scout_web/templates/address/overview.html.eex:241 #, elixir-autogen, elixir-format msgid "Gas used by the address." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:60 #: lib/block_scout_web/templates/block/overview.html.eex:60 #, elixir-autogen, elixir-format msgid "Genesis Block" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:24 #: lib/block_scout_web/templates/layout/_footer.html.eex:24 #, elixir-autogen, elixir-format msgid "Github" msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:8 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:8 #, elixir-autogen, elixir-format msgid "Go to" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:110 #: lib/block_scout_web/templates/layout/_topnav.html.eex:110 #, elixir-autogen, elixir-format msgid "GraphQL" msgstr "" #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:11 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:11 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:38 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:38 +#: lib/block_scout_web/views/block_view.ex:22 #: lib/block_scout_web/views/block_view.ex:22 #: lib/block_scout_web/views/wei_helper.ex:81 +#: lib/block_scout_web/views/wei_helper.ex:81 #, elixir-autogen, elixir-format msgid "Gwei" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:123 #: lib/block_scout_web/templates/block/overview.html.eex:123 #, elixir-autogen, elixir-format msgid "Hash" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:534 #: lib/block_scout_web/templates/transaction/overview.html.eex:534 #, elixir-autogen, elixir-format msgid "Hex (Default)" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #, elixir-autogen, elixir-format msgid "Holders" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:48 #: lib/block_scout_web/templates/tokens/index.html.eex:48 #, elixir-autogen, elixir-format msgid "Holders Count" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:11 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:11 #, elixir-autogen, elixir-format msgid "However, in general, the" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:19 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:19 #, elixir-autogen, elixir-format msgid "IMPORTANT: This information is a best guess based on similar functions from other verified contracts." msgstr "" #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:42 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:42 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:92 #: lib/block_scout_web/templates/transaction/_tile.html.eex:92 #, elixir-autogen, elixir-format msgid "IN" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:56 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:56 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:48 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:48 #, elixir-autogen, elixir-format msgid "If you enabled optimization during compilation, select yes." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:135 #: lib/block_scout_web/templates/address/overview.html.eex:135 #, elixir-autogen, elixir-format msgid "Implementation" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:134 #: lib/block_scout_web/templates/address/overview.html.eex:134 #, elixir-autogen, elixir-format msgid "Implementation address of the proxy contract." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:3 #, elixir-autogen, elixir-format msgid "Include nightly builds" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:30 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:30 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:43 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:43 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:56 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:56 #, elixir-autogen, elixir-format msgid "Incoming" msgstr "" #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:29 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:29 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:23 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:23 #: lib/block_scout_web/templates/withdrawal/index.html.eex:23 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:23 #, elixir-autogen, elixir-format msgid "Index" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Index position of Transaction in the block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:251 #: lib/block_scout_web/templates/block/overview.html.eex:251 #, elixir-autogen, elixir-format msgid "Index position(s) of referenced stale blocks." msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:6 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:6 #, elixir-autogen, elixir-format msgid "Indexed?" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:3 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:3 #, elixir-autogen, elixir-format msgid "Input" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:242 #: lib/block_scout_web/templates/transaction/overview.html.eex:242 #, elixir-autogen, elixir-format msgid "Interacted With (To)" msgstr "" +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:7 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:7 #, elixir-autogen, elixir-format msgid "Internal Transaction" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:36 #: lib/block_scout_web/templates/address/_tabs.html.eex:36 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 +#: lib/block_scout_web/views/address_view.ex:353 #: lib/block_scout_web/views/address_view.ex:353 #: lib/block_scout_web/views/transaction_view.ex:550 +#: lib/block_scout_web/views/transaction_view.ex:550 #, elixir-autogen, elixir-format msgid "Internal Transactions" msgstr "" +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:7 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Internal server error" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:27 #: lib/block_scout_web/views/internal_transaction_view.ex:27 #, elixir-autogen, elixir-format msgid "Invalid" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:16 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:16 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:19 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:19 +#: lib/block_scout_web/views/tokens/overview_view.ex:42 #: lib/block_scout_web/views/tokens/overview_view.ex:42 #, elixir-autogen, elixir-format msgid "Inventory" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:3 #, elixir-autogen, elixir-format msgid "Is Yul contract" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:204 #: lib/block_scout_web/templates/transaction/overview.html.eex:204 #, elixir-autogen, elixir-format msgid "L1 Block" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:501 #: lib/block_scout_web/templates/transaction/overview.html.eex:501 #, elixir-autogen, elixir-format msgid "L1 Fee Scalar" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:490 #: lib/block_scout_web/templates/transaction/overview.html.eex:490 #, elixir-autogen, elixir-format msgid "L1 Gas Price" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:479 #: lib/block_scout_web/templates/transaction/overview.html.eex:479 #, elixir-autogen, elixir-format msgid "L1 Gas Used by Transaction" @@ -1640,515 +2124,677 @@ msgstr "" msgid "L1 attr info tx" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:403 #: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format msgid "L2 Gas Limit" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:377 #: lib/block_scout_web/templates/transaction/overview.html.eex:377 #, elixir-autogen, elixir-format msgid "L2 Gas Price" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:462 #: lib/block_scout_web/templates/transaction/overview.html.eex:462 #, elixir-autogen, elixir-format msgid "L2 Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:13 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:13 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:26 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:26 #, elixir-autogen, elixir-format msgid "Last 24h" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:260 #: lib/block_scout_web/templates/address/overview.html.eex:260 #, elixir-autogen, elixir-format msgid "Last Balance Update" msgstr "" #: lib/block_scout_web/templates/account/api_key/index.html.eex:12 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:12 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Learn more" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:49 #: lib/block_scout_web/templates/layout/app.html.eex:49 #, elixir-autogen, elixir-format msgid "Less than" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #, elixir-autogen, elixir-format msgid "Library" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:24 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:24 #, elixir-autogen, elixir-format msgid "License Expires" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:10 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:10 #, elixir-autogen, elixir-format msgid "License ID" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:328 #: lib/block_scout_web/templates/transaction/overview.html.eex:328 #, elixir-autogen, elixir-format msgid "List of ERC-1155 tokens created in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:312 #: lib/block_scout_web/templates/transaction/overview.html.eex:312 #, elixir-autogen, elixir-format msgid "List of token burnt in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:295 #: lib/block_scout_web/templates/transaction/overview.html.eex:295 #, elixir-autogen, elixir-format msgid "List of token minted in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:279 #: lib/block_scout_web/templates/transaction/overview.html.eex:279 #, elixir-autogen, elixir-format msgid "List of token transferred in the transaction." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:18 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Loading chart..." msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:77 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:77 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:109 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:109 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:35 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:35 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:99 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:99 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:49 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:49 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:45 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:45 #: lib/block_scout_web/templates/address_read_contract/index.html.eex:41 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:41 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:49 #: lib/block_scout_web/templates/address_read_contract/index.html.eex:49 #: lib/block_scout_web/templates/address_read_proxy/index.html.eex:12 +#: lib/block_scout_web/templates/address_read_proxy/index.html.eex:12 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:39 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:39 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:47 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:47 +#: lib/block_scout_web/templates/address_write_proxy/index.html.eex:12 #: lib/block_scout_web/templates/address_write_proxy/index.html.eex:12 #: lib/block_scout_web/templates/tokens/contract/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/contract/index.html.eex:17 #, elixir-autogen, elixir-format msgid "Loading..." msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:2 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:2 #, elixir-autogen, elixir-format msgid "Log Data" msgstr "" +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:140 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:140 #, elixir-autogen, elixir-format msgid "Log Index" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:49 +#: lib/block_scout_web/templates/address/_tabs.html.eex:49 +#: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 #: lib/block_scout_web/templates/transaction_log/index.html.eex:8 +#: lib/block_scout_web/templates/transaction_log/index.html.eex:8 +#: lib/block_scout_web/views/address_view.ex:363 #: lib/block_scout_web/views/address_view.ex:363 #: lib/block_scout_web/views/transaction_view.ex:551 +#: lib/block_scout_web/views/transaction_view.ex:551 #, elixir-autogen, elixir-format msgid "Logs" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:54 #: lib/block_scout_web/templates/layout/_footer.html.eex:54 #, elixir-autogen, elixir-format msgid "Main Networks" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/layout/app.html.eex:50 +#: lib/block_scout_web/templates/layout/app.html.eex:50 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 #: lib/block_scout_web/views/address_view.ex:148 +#: lib/block_scout_web/views/address_view.ex:148 #, elixir-autogen, elixir-format msgid "Market Cap" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:84 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:84 #, elixir-autogen, elixir-format msgid "Market cap" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:417 #: lib/block_scout_web/templates/transaction/overview.html.eex:417 #, elixir-autogen, elixir-format msgid "Max Fee per Gas" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:427 #: lib/block_scout_web/templates/transaction/overview.html.eex:427 #, elixir-autogen, elixir-format msgid "Max Priority Fee per Gas" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:329 #: lib/block_scout_web/views/transaction_view.ex:329 #, elixir-autogen, elixir-format msgid "Max of" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:402 #: lib/block_scout_web/templates/transaction/overview.html.eex:402 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction on L2." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:406 #: lib/block_scout_web/templates/transaction/overview.html.eex:406 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:416 #: lib/block_scout_web/templates/transaction/overview.html.eex:416 #, elixir-autogen, elixir-format msgid "Maximum total amount per unit of gas a user is willing to pay for a transaction, including base fee and priority fee." msgstr "" #: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:10 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:10 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:75 +#: lib/block_scout_web/views/tokens/instance/overview_view.ex:75 #, elixir-autogen, elixir-format msgid "Metadata" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:5 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:5 #, elixir-autogen, elixir-format msgid "Method Id" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:41 #: lib/block_scout_web/templates/block/_tile.html.eex:41 #: lib/block_scout_web/templates/block/overview.html.eex:98 +#: lib/block_scout_web/templates/block/overview.html.eex:98 +#: lib/block_scout_web/templates/chain/_block.html.eex:16 #: lib/block_scout_web/templates/chain/_block.html.eex:16 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:22 +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:22 #, elixir-autogen, elixir-format msgid "Miner" msgstr "" #: lib/block_scout_web/views/block_view.ex:63 +#: lib/block_scout_web/views/block_view.ex:63 +#: lib/block_scout_web/views/block_view.ex:68 #: lib/block_scout_web/views/block_view.ex:68 #, elixir-autogen, elixir-format msgid "Miner Reward" msgstr "" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:3 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:3 #, elixir-autogen, elixir-format msgid "Minimal Proxy Contract for" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:208 #: lib/block_scout_web/templates/block/overview.html.eex:208 #, elixir-autogen, elixir-format msgid "Minimum fee required per unit of gas. Fee adjusts based on network congestion." msgstr "" +#: lib/block_scout_web/templates/transaction/_actions.html.eex:92 #: lib/block_scout_web/templates/transaction/_actions.html.eex:92 #, elixir-autogen, elixir-format msgid "Mint of %{address} <span class=\"text-muted\">To</span> %{to}" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:223 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:223 #, elixir-autogen, elixir-format msgid "Model" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #, elixir-autogen, elixir-format msgid "Module" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:12 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:12 #, elixir-autogen, elixir-format msgid "More internal transactions have come in" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:46 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:46 +#: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction/index.html.eex:19 #: lib/block_scout_web/templates/transaction/index.html.eex:19 #, elixir-autogen, elixir-format msgid "More transactions have come in" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:63 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:63 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:74 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:74 #, elixir-autogen, elixir-format msgid "Must be set to:" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Must match the name specified in the code. For example, in <span class=\"tooltip-quote\">contract MyContract {..}</span> <strong>MyContract</strong> is the contract name." msgstr "" +#: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #, elixir-autogen, elixir-format msgid "N/A" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:116 #: lib/block_scout_web/templates/block/overview.html.eex:116 #, elixir-autogen, elixir-format msgid "N/A bytes" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:28 #: lib/block_scout_web/templates/account/api_key/index.html.eex:28 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:28 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:28 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:22 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:22 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:18 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:22 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:22 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:22 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:22 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:19 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:21 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:21 #, elixir-autogen, elixir-format msgid "Name" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:20 #: lib/block_scout_web/templates/account/api_key/form.html.eex:20 #, elixir-autogen, elixir-format msgid "Name this API key" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:14 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:14 #, elixir-autogen, elixir-format msgid "Name this Custom ABI" msgstr "" #: lib/block_scout_web/templates/account/tag_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:20 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:20 #, elixir-autogen, elixir-format msgid "Name this address" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:19 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:19 #, elixir-autogen, elixir-format msgid "Name this transaction" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:44 #: lib/block_scout_web/templates/address_token/overview.html.eex:44 #, elixir-autogen, elixir-format msgid "Net Worth" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:5 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:5 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:9 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification via Standard input JSON" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:5 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:5 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification via metadata JSON" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Solidity Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Solidity/Yul Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Vyper Smart Contract Verification" msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:80 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:80 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:87 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:87 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:95 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:95 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:111 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:111 #, elixir-autogen, elixir-format msgid "Next" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:46 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:46 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:38 #, elixir-autogen, elixir-format msgid "No" msgstr "" +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:15 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:15 #, elixir-autogen, elixir-format msgid "No trace entries found." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:198 +#: lib/block_scout_web/templates/block/overview.html.eex:198 +#: lib/block_scout_web/templates/transaction/overview.html.eex:512 #: lib/block_scout_web/templates/transaction/overview.html.eex:512 #, elixir-autogen, elixir-format msgid "Nonce" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 #, elixir-autogen, elixir-format msgid "Not unique Token" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:107 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:107 #, elixir-autogen, elixir-format msgid "Number of accounts holding the token" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:276 #: lib/block_scout_web/templates/address/overview.html.eex:276 #, elixir-autogen, elixir-format msgid "Number of blocks validated by this validator." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:130 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:130 #, elixir-autogen, elixir-format msgid "Number of digits that come after the decimal place when displaying token value" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:187 #: lib/block_scout_web/templates/address/overview.html.eex:187 #, elixir-autogen, elixir-format msgid "Number of transactions related to this address." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:118 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:118 #, elixir-autogen, elixir-format msgid "Number of transfers for the token" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:214 #: lib/block_scout_web/templates/address/overview.html.eex:214 #, elixir-autogen, elixir-format msgid "Number of transfers to/from this address." msgstr "" #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:40 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:40 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:88 #: lib/block_scout_web/templates/transaction/_tile.html.eex:88 #, elixir-autogen, elixir-format msgid "OUT" msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "Only the first" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:40 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:40 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:32 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:32 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:75 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:75 #, elixir-autogen, elixir-format msgid "Optimization" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:67 #: lib/block_scout_web/templates/address_contract/index.html.eex:67 #, elixir-autogen, elixir-format msgid "Optimization enabled" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:76 #: lib/block_scout_web/templates/address_contract/index.html.eex:76 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:62 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:62 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:54 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:54 #, elixir-autogen, elixir-format msgid "Optimization runs" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:78 #: lib/block_scout_web/templates/layout/_footer.html.eex:78 #, elixir-autogen, elixir-format msgid "Other Explorers" msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:35 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:35 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:48 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:48 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:61 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:61 #, elixir-autogen, elixir-format msgid "Outgoing" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:24 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:24 #, elixir-autogen, elixir-format msgid "Owner Address" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "POA solidity flattener or the" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:19 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:19 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:26 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:26 #, elixir-autogen, elixir-format msgid "POST" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #, elixir-autogen, elixir-format msgid "Page" msgstr "" +#: lib/block_scout_web/templates/page_not_found/index.html.eex:7 #: lib/block_scout_web/templates/page_not_found/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Page not found" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:33 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:33 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:40 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:40 #, elixir-autogen, elixir-format msgid "Parameters" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:139 #: lib/block_scout_web/templates/block/overview.html.eex:139 #, elixir-autogen, elixir-format msgid "Parent Hash" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:63 #: lib/block_scout_web/templates/layout/_topnav.html.eex:63 #: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:417 #: lib/block_scout_web/views/transaction_view.ex:417 #: lib/block_scout_web/views/transaction_view.ex:425 +#: lib/block_scout_web/views/transaction_view.ex:425 #, elixir-autogen, elixir-format msgid "Pending" msgstr "" +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:5 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Pending Transactions" msgstr "" +#: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:9 #: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:9 #: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:13 +#: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:13 #, elixir-autogen, elixir-format msgid "Play" msgstr "" #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Please confirm your email address to use the My Account feature." msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:68 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:68 #, elixir-autogen, elixir-format msgid "Please select notification methods:" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:24 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Please select what types of notifications you will receive:" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Position" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:256 #: lib/block_scout_web/templates/block/overview.html.eex:256 #, elixir-autogen, elixir-format msgid "Position %{index}" @@ -2159,1486 +2805,1919 @@ msgstr "" msgid "Post exec tx" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #, elixir-autogen, elixir-format msgid "Potential matches from contract method database:" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:32 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:32 #, elixir-autogen, elixir-format msgid "Potential matches from our contract method database:" msgstr "" +#: lib/block_scout_web/templates/layout/_search.html.eex:27 #: lib/block_scout_web/templates/layout/_search.html.eex:27 #, elixir-autogen, elixir-format msgid "Press / and focus will be moved to the search field" msgstr "" #: lib/block_scout_web/templates/chain/show.html.eex:44 +#: lib/block_scout_web/templates/chain/show.html.eex:44 +#: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 #, elixir-autogen, elixir-format msgid "Price" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:95 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:95 #, elixir-autogen, elixir-format msgid "Price per token on the exchanges" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:376 #: lib/block_scout_web/templates/transaction/overview.html.eex:376 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender on L2. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:380 #: lib/block_scout_web/templates/transaction/overview.html.eex:380 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:227 +#: lib/block_scout_web/templates/block/overview.html.eex:227 +#: lib/block_scout_web/templates/transaction/overview.html.eex:437 #: lib/block_scout_web/templates/transaction/overview.html.eex:437 #, elixir-autogen, elixir-format msgid "Priority Fee / Tip" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:62 #: lib/block_scout_web/templates/block/_tile.html.eex:62 #, elixir-autogen, elixir-format msgid "Priority Fees" msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:4 #: lib/block_scout_web/templates/account/common/_nav.html.eex:4 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:14 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:14 #, elixir-autogen, elixir-format msgid "Profile" msgstr "" #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 +#: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 #, elixir-autogen, elixir-format msgid "QR Code" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #, elixir-autogen, elixir-format msgid "Query" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:115 #: lib/block_scout_web/templates/layout/_topnav.html.eex:115 #, elixir-autogen, elixir-format msgid "RPC" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:523 #: lib/block_scout_web/templates/transaction/overview.html.eex:523 #, elixir-autogen, elixir-format msgid "Raw Input" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 #: lib/block_scout_web/views/transaction_view.ex:552 +#: lib/block_scout_web/views/transaction_view.ex:552 #, elixir-autogen, elixir-format msgid "Raw Trace" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:81 +#: lib/block_scout_web/templates/address/_tabs.html.eex:81 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 #: lib/block_scout_web/views/address_view.ex:357 +#: lib/block_scout_web/views/address_view.ex:357 +#: lib/block_scout_web/views/tokens/overview_view.ex:41 #: lib/block_scout_web/views/tokens/overview_view.ex:41 #, elixir-autogen, elixir-format msgid "Read Contract" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:88 +#: lib/block_scout_web/templates/address/_tabs.html.eex:88 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 #: lib/block_scout_web/views/address_view.ex:358 +#: lib/block_scout_web/views/address_view.ex:358 #, elixir-autogen, elixir-format msgid "Read Proxy" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "Records" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:13 #: lib/block_scout_web/templates/account/api_key/row.html.eex:13 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:13 #, elixir-autogen, elixir-format msgid "Remove" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:77 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:77 #, elixir-autogen, elixir-format msgid "Remove from Watch list" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #, elixir-autogen, elixir-format msgid "Request URL" msgstr "" +#: lib/block_scout_web/templates/error422/index.html.eex:7 #: lib/block_scout_web/templates/error422/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Request cannot be processed" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Resend verification email" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #, elixir-autogen, elixir-format msgid "Reset" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:173 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:173 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:134 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:134 #, elixir-autogen, elixir-format msgid "Response Body" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:185 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:185 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:147 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:147 #, elixir-autogen, elixir-format msgid "Responses" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:98 #: lib/block_scout_web/templates/transaction/overview.html.eex:98 #, elixir-autogen, elixir-format msgid "Result" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:138 #: lib/block_scout_web/templates/transaction/overview.html.eex:138 #, elixir-autogen, elixir-format msgid "Revert reason" msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:52 +#: lib/block_scout_web/templates/block/_tile.html.eex:52 +#: lib/block_scout_web/templates/chain/_block.html.eex:27 #: lib/block_scout_web/templates/chain/_block.html.eex:27 #: lib/block_scout_web/views/internal_transaction_view.ex:34 +#: lib/block_scout_web/views/internal_transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Reward" msgstr "" +#: lib/block_scout_web/templates/admin/dashboard/index.html.eex:21 #: lib/block_scout_web/templates/admin/dashboard/index.html.eex:21 #, elixir-autogen, elixir-format msgid "Run" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:31 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:31 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:25 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:25 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:25 +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:25 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:83 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:83 #, elixir-autogen, elixir-format msgid "Save" msgstr "" #: lib/block_scout_web/templates/address_logs/index.html.eex:16 +#: lib/block_scout_web/templates/address_logs/index.html.eex:16 +#: lib/block_scout_web/templates/layout/_search.html.eex:34 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format msgid "Search" msgstr "" +#: lib/block_scout_web/templates/search/results.html.eex:17 #: lib/block_scout_web/templates/search/results.html.eex:17 #, elixir-autogen, elixir-format msgid "Search Results" msgstr "" +#: lib/block_scout_web/templates/layout/_search.html.eex:3 #: lib/block_scout_web/templates/layout/_search.html.eex:3 #, elixir-autogen, elixir-format msgid "Search by address, token symbol name, transaction hash, or block number" msgstr "" +#: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:47 #: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:47 #, elixir-autogen, elixir-format msgid "Search tokens" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:19 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:19 #, elixir-autogen, elixir-format msgid "Select Yes if you want to verify Yul contract." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:19 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:19 #, elixir-autogen, elixir-format msgid "Select yes if you want to show nightly builds." msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:33 #: lib/block_scout_web/views/internal_transaction_view.ex:33 #, elixir-autogen, elixir-format msgid "Self-Destruct" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 #, elixir-autogen, elixir-format msgid "Server Response" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:7 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:7 #, elixir-autogen, elixir-format msgid "Show" msgstr "" +#: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:11 #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:11 #, elixir-autogen, elixir-format msgid "Show QR Code" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:52 #: lib/block_scout_web/templates/address_token/overview.html.eex:52 #, elixir-autogen, elixir-format msgid "Shows the current" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:59 #: lib/block_scout_web/templates/address_token/overview.html.eex:59 #, elixir-autogen, elixir-format msgid "Shows the tokens held in the address (includes ERC-20, ERC-721 and ERC-1155)." msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:66 #: lib/block_scout_web/templates/address_token/overview.html.eex:66 #, elixir-autogen, elixir-format msgid "Shows the total CRC balance in the address." msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:45 #: lib/block_scout_web/templates/address_token/overview.html.eex:45 #, elixir-autogen, elixir-format msgid "Shows total assets held in the address" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #, elixir-autogen, elixir-format msgid "Sign in" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #, elixir-autogen, elixir-format msgid "Sign out" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:11 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:11 #, elixir-autogen, elixir-format msgid "Signed in as " msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:114 #: lib/block_scout_web/templates/block/overview.html.eex:114 #, elixir-autogen, elixir-format msgid "Size" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:113 #: lib/block_scout_web/templates/block/overview.html.eex:113 #, elixir-autogen, elixir-format msgid "Size of the block in bytes." msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #, elixir-autogen, elixir-format msgid "Slow" msgstr "" #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/views/verified_contracts_view.ex:10 +#: lib/block_scout_web/views/verified_contracts_view.ex:10 #, elixir-autogen, elixir-format msgid "Solidity" msgstr "" #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:30 +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:30 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_logs/index.html.eex:23 +#: lib/block_scout_web/templates/address_logs/index.html.eex:23 +#: lib/block_scout_web/templates/address_token/index.html.eex:60 #: lib/block_scout_web/templates/address_token/index.html.eex:60 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:58 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:58 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_validation/index.html.eex:20 +#: lib/block_scout_web/templates/address_validation/index.html.eex:20 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 +#: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:23 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:23 +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:23 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:23 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:22 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:22 +#: lib/block_scout_web/templates/transaction/index.html.eex:25 #: lib/block_scout_web/templates/transaction/index.html.eex:25 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_log/index.html.eex:15 #: lib/block_scout_web/templates/transaction_log/index.html.eex:15 #: lib/block_scout_web/templates/transaction_state/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:13 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:14 +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:14 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:51 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:51 #: lib/block_scout_web/templates/withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Something went wrong, click to reload." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:227 #: lib/block_scout_web/templates/chain/show.html.eex:227 #, elixir-autogen, elixir-format msgid "Something went wrong, click to retry." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:6 #: lib/block_scout_web/templates/transaction/not_found.html.eex:6 #, elixir-autogen, elixir-format msgid "Sorry, we are unable to locate this transaction hash" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #, elixir-autogen, elixir-format msgid "Sources *.sol files" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #, elixir-autogen, elixir-format msgid "Sources *.sol or *.yul files" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:14 #, elixir-autogen, elixir-format msgid "Sources and Metadata JSON" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:136 #: lib/block_scout_web/templates/layout/_topnav.html.eex:136 #, elixir-autogen, elixir-format msgid "Stakes" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:24 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:24 #, elixir-autogen, elixir-format msgid "Standard Input JSON" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:6 #: lib/block_scout_web/templates/transaction_state/index.html.eex:6 #: lib/block_scout_web/views/transaction_view.ex:553 +#: lib/block_scout_web/views/transaction_view.ex:553 #, elixir-autogen, elixir-format msgid "State changes" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:26 #: lib/block_scout_web/views/internal_transaction_view.ex:26 #, elixir-autogen, elixir-format msgid "Static Call" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:110 #: lib/block_scout_web/templates/transaction/overview.html.eex:110 #, elixir-autogen, elixir-format msgid "Status" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:41 #: lib/block_scout_web/templates/layout/_footer.html.eex:41 #, elixir-autogen, elixir-format msgid "Submit an Issue" msgstr "" +#: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 #: lib/block_scout_web/views/transaction_view.ex:374 +#: lib/block_scout_web/views/transaction_view.ex:374 #, elixir-autogen, elixir-format msgid "Success" msgstr "" #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21 +#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:52 #: lib/block_scout_web/templates/transaction/_tile.html.eex:52 #, elixir-autogen, elixir-format msgid "TX Fee" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:31 #: lib/block_scout_web/templates/layout/_footer.html.eex:31 #, elixir-autogen, elixir-format msgid "Telegram" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:67 #: lib/block_scout_web/templates/layout/_footer.html.eex:67 #, elixir-autogen, elixir-format msgid "Test Networks" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:9 #, elixir-autogen, elixir-format msgid "The 0x library address. This can be found in the generated json file or Truffle output (if using truffle)." msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #, elixir-autogen, elixir-format msgid "The EVM version the contract is written for. If the bytecode does not match the version, we try to verify using the latest EVM version." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:122 #: lib/block_scout_web/templates/block/overview.html.eex:122 #, elixir-autogen, elixir-format msgid "The SHA256 hash of the block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:51 #: lib/block_scout_web/templates/block/overview.html.eex:51 #, elixir-autogen, elixir-format msgid "The block height of a particular block is defined as the number of blocks preceding it in the blockchain." msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:18 #: lib/block_scout_web/templates/transaction_state/index.html.eex:18 #, elixir-autogen, elixir-format msgid "The changes from this transaction have not yet happened since the transaction is still pending." msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:26 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:26 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:18 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:18 #, elixir-autogen, elixir-format msgid "The compiler version is specified in <span class=\"tooltip-quote\">pragma solidity X.X.X</span>. Use the compiler version rather than the nightly build. If using the Solidity compiler, run <span class=\"tooltip-quote\">solc —version</span> to check." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #, elixir-autogen, elixir-format msgid "The fallback function is executed on a call to the contract if none of the other functions match the given function signature, or if no data was supplied at all and there is no receive Ether function. The fallback function always receives data, but in order to also receive Ether it must be marked payable." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:138 #: lib/block_scout_web/templates/block/overview.html.eex:138 #, elixir-autogen, elixir-format msgid "The hash of the block from which this block was generated." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:74 #: lib/block_scout_web/templates/address/overview.html.eex:74 #, elixir-autogen, elixir-format msgid "The name found in the source code of the Contract." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:85 #: lib/block_scout_web/templates/address/overview.html.eex:85 #, elixir-autogen, elixir-format msgid "The name of the validator." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:79 #: lib/block_scout_web/templates/block/overview.html.eex:79 #, elixir-autogen, elixir-format msgid "The number of transactions in the block." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #, elixir-autogen, elixir-format msgid "The receive function is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:137 #: lib/block_scout_web/templates/transaction/overview.html.eex:137 #, elixir-autogen, elixir-format msgid "The revert reason of the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:109 #: lib/block_scout_web/templates/transaction/overview.html.eex:109 #, elixir-autogen, elixir-format msgid "The status of the transaction: Confirmed or Unconfirmed." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:67 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:67 #, elixir-autogen, elixir-format msgid "The total amount of tokens issued" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:179 #: lib/block_scout_web/templates/block/overview.html.eex:179 #, elixir-autogen, elixir-format msgid "The total gas amount used in the block and its percentage of gas filled in the block." msgstr "" +#: lib/block_scout_web/templates/address_validation/index.html.eex:16 #: lib/block_scout_web/templates/address_validation/index.html.eex:16 #, elixir-autogen, elixir-format msgid "There are no blocks validated by this address." msgstr "" +#: lib/block_scout_web/templates/block/index.html.eex:17 #: lib/block_scout_web/templates/block/index.html.eex:17 #, elixir-autogen, elixir-format msgid "There are no blocks." msgstr "" +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:29 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:29 #, elixir-autogen, elixir-format msgid "There are no holders for this Token." msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:54 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:54 #, elixir-autogen, elixir-format msgid "There are no internal transactions for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:17 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:17 #, elixir-autogen, elixir-format msgid "There are no internal transactions for this transaction." msgstr "" +#: lib/block_scout_web/templates/address_logs/index.html.eex:28 #: lib/block_scout_web/templates/address_logs/index.html.eex:28 #, elixir-autogen, elixir-format msgid "There are no logs for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_log/index.html.eex:20 #: lib/block_scout_web/templates/transaction_log/index.html.eex:20 #, elixir-autogen, elixir-format msgid "There are no logs for this transaction." msgstr "" +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:22 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:22 #, elixir-autogen, elixir-format msgid "There are no pending transactions." msgstr "" +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:53 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:53 #, elixir-autogen, elixir-format msgid "There are no token transfers for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:19 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:19 #, elixir-autogen, elixir-format msgid "There are no token transfers for this transaction" msgstr "" +#: lib/block_scout_web/templates/address_token/index.html.eex:65 #: lib/block_scout_web/templates/address_token/index.html.eex:65 #, elixir-autogen, elixir-format msgid "There are no tokens for this address." msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:28 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:28 #, elixir-autogen, elixir-format msgid "There are no tokens." msgstr "" +#: lib/block_scout_web/templates/address_transaction/index.html.eex:55 #: lib/block_scout_web/templates/address_transaction/index.html.eex:55 #, elixir-autogen, elixir-format msgid "There are no transactions for this address." msgstr "" +#: lib/block_scout_web/templates/block_transaction/index.html.eex:19 #: lib/block_scout_web/templates/block_transaction/index.html.eex:19 #, elixir-autogen, elixir-format msgid "There are no transactions for this block." msgstr "" +#: lib/block_scout_web/templates/transaction/index.html.eex:31 #: lib/block_scout_web/templates/transaction/index.html.eex:31 #, elixir-autogen, elixir-format msgid "There are no transactions." msgstr "" #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:28 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:28 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:28 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:28 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:27 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:27 #, elixir-autogen, elixir-format msgid "There are no transfers for this Token." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:99 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:99 #, elixir-autogen, elixir-format msgid "There are no verified contracts." msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:54 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:54 #, elixir-autogen, elixir-format msgid "There are no withdrawals for this address." msgstr "" +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:45 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:45 #, elixir-autogen, elixir-format msgid "There are no withdrawals for this block." msgstr "" +#: lib/block_scout_web/templates/withdrawal/index.html.eex:53 #: lib/block_scout_web/templates/withdrawal/index.html.eex:53 #, elixir-autogen, elixir-format msgid "There are no withdrawals." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:35 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:35 #, elixir-autogen, elixir-format msgid "There is no coin history for this address." msgstr "" #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:21 +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:21 +#: lib/block_scout_web/templates/chain/show.html.eex:9 #: lib/block_scout_web/templates/chain/show.html.eex:9 #, elixir-autogen, elixir-format msgid "There was a problem loading the chart." msgstr "" +#: lib/block_scout_web/templates/api_docs/index.html.eex:6 #: lib/block_scout_web/templates/api_docs/index.html.eex:6 #, elixir-autogen, elixir-format msgid "This API is provided for developers transitioning their applications from Etherscan to BlockScout. It supports GET and POST requests." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:7 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:7 #, elixir-autogen, elixir-format msgid "This API is provided to support some rpc methods in the exact format specified for ethereum nodes, which can be found " msgstr "" +#: lib/block_scout_web/views/block_transaction_view.ex:15 #: lib/block_scout_web/views/block_transaction_view.ex:15 #, elixir-autogen, elixir-format msgid "This block has not been processed yet." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:47 #: lib/block_scout_web/templates/address_contract/index.html.eex:47 #, elixir-autogen, elixir-format msgid "This contract has been partially verified via Sourcify." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:51 #: lib/block_scout_web/templates/address_contract/index.html.eex:51 #, elixir-autogen, elixir-format msgid "This contract has been verified via Sourcify." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:10 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:10 #, elixir-autogen, elixir-format msgid "This is useful to allow sending requests to blockscout without having to change anything about the request." msgstr "" +#: lib/block_scout_web/templates/page_not_found/index.html.eex:8 #: lib/block_scout_web/templates/page_not_found/index.html.eex:8 #, elixir-autogen, elixir-format msgid "This page is no longer explorable! If you are lost, use the search bar to find what you are looking for." msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:22 #: lib/block_scout_web/templates/transaction_state/index.html.eex:22 #, elixir-autogen, elixir-format msgid "This transaction hasn't changed state." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:64 #: lib/block_scout_web/templates/transaction/overview.html.eex:64 #, elixir-autogen, elixir-format msgid "This transaction is pending confirmation." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:71 +#: lib/block_scout_web/templates/block/overview.html.eex:71 +#: lib/block_scout_web/templates/transaction/overview.html.eex:180 #: lib/block_scout_web/templates/transaction/overview.html.eex:180 #, elixir-autogen, elixir-format msgid "Timestamp" msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:32 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:32 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:28 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:28 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/transaction/overview.html.eex:244 +#: lib/block_scout_web/templates/transaction/overview.html.eex:244 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/views/address_internal_transaction_view.ex:10 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:10 +#: lib/block_scout_web/views/address_token_transfer_view.ex:10 #: lib/block_scout_web/views/address_token_transfer_view.ex:10 #: lib/block_scout_web/views/address_transaction_view.ex:10 +#: lib/block_scout_web/views/address_transaction_view.ex:10 #, elixir-autogen, elixir-format msgid "To" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:20 #, elixir-autogen, elixir-format msgid "To have guaranteed accuracy, use the link above to verify the contract's source code." msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:6 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:6 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:8 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:8 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:6 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:6 #, elixir-autogen, elixir-format msgid "To see accurate decoded input data, the contract must be verified." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:18 #: lib/block_scout_web/templates/layout/_topnav.html.eex:18 #, elixir-autogen, elixir-format msgid "Toggle navigation" msgstr "" #: lib/block_scout_web/templates/address/overview.html.eex:55 +#: lib/block_scout_web/templates/address/overview.html.eex:55 +#: lib/block_scout_web/templates/tokens/index.html.eex:31 #: lib/block_scout_web/templates/tokens/index.html.eex:31 #, elixir-autogen, elixir-format msgid "Token" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 +#: lib/block_scout_web/views/transaction_view.ex:486 #: lib/block_scout_web/views/transaction_view.ex:486 #, elixir-autogen, elixir-format msgid "Token Burning" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 +#: lib/block_scout_web/views/transaction_view.ex:487 #: lib/block_scout_web/views/transaction_view.ex:487 #, elixir-autogen, elixir-format msgid "Token Creation" msgstr "" #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:10 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:10 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:34 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:34 #, elixir-autogen, elixir-format msgid "Token Details" msgstr "" #: lib/block_scout_web/templates/tokens/holder/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:16 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:16 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:17 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:17 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:11 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:11 #: lib/block_scout_web/views/tokens/overview_view.ex:40 +#: lib/block_scout_web/views/tokens/overview_view.ex:40 #, elixir-autogen, elixir-format msgid "Token Holders" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:38 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:38 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:37 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:37 #, elixir-autogen, elixir-format msgid "Token ID" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 +#: lib/block_scout_web/views/transaction_view.ex:485 #: lib/block_scout_web/views/transaction_view.ex:485 #, elixir-autogen, elixir-format msgid "Token Minting" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 #: lib/block_scout_web/views/transaction_view.ex:488 +#: lib/block_scout_web/views/transaction_view.ex:488 #, elixir-autogen, elixir-format msgid "Token Transfer" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:13 #: lib/block_scout_web/templates/address/_tabs.html.eex:13 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:3 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:3 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 +#: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 +#: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 +#: lib/block_scout_web/views/tokens/overview_view.ex:39 #: lib/block_scout_web/views/tokens/overview_view.ex:39 #: lib/block_scout_web/views/transaction_view.ex:549 +#: lib/block_scout_web/views/transaction_view.ex:549 #, elixir-autogen, elixir-format msgid "Token Transfers" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:54 #: lib/block_scout_web/templates/address/overview.html.eex:54 #, elixir-autogen, elixir-format msgid "Token name and symbol." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:142 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:142 #, elixir-autogen, elixir-format msgid "Token type" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:21 #: lib/block_scout_web/templates/address/_tabs.html.eex:21 #: lib/block_scout_web/templates/address/overview.html.eex:177 +#: lib/block_scout_web/templates/address/overview.html.eex:177 +#: lib/block_scout_web/templates/address_token/overview.html.eex:58 #: lib/block_scout_web/templates/address_token/overview.html.eex:58 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/tokens/index.html.eex:10 +#: lib/block_scout_web/templates/tokens/index.html.eex:10 +#: lib/block_scout_web/views/address_view.ex:352 #: lib/block_scout_web/views/address_view.ex:352 #, elixir-autogen, elixir-format msgid "Tokens" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:313 #: lib/block_scout_web/templates/transaction/overview.html.eex:313 #, elixir-autogen, elixir-format msgid "Tokens Burnt" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:329 #: lib/block_scout_web/templates/transaction/overview.html.eex:329 #, elixir-autogen, elixir-format msgid "Tokens Created" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:296 #: lib/block_scout_web/templates/transaction/overview.html.eex:296 #, elixir-autogen, elixir-format msgid "Tokens Minted" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:280 #: lib/block_scout_web/templates/transaction/overview.html.eex:280 #, elixir-autogen, elixir-format msgid "Tokens Transferred" msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:13 #: lib/block_scout_web/templates/address/_metatags.html.eex:13 #, elixir-autogen, elixir-format msgid "Top Accounts - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/address_logs/index.html.eex:14 #: lib/block_scout_web/templates/address_logs/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Topic" msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:68 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:68 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:100 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:100 #, elixir-autogen, elixir-format msgid "Topics" msgstr "" #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:9 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:9 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:22 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:22 #, elixir-autogen, elixir-format msgid "Total" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:170 #: lib/block_scout_web/templates/block/overview.html.eex:170 #, elixir-autogen, elixir-format msgid "Total Difficulty" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:43 #: lib/block_scout_web/templates/tokens/index.html.eex:43 #, elixir-autogen, elixir-format msgid "Total Supply" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:84 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:84 #, elixir-autogen, elixir-format msgid "Total Supply * Price" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:135 #: lib/block_scout_web/templates/chain/show.html.eex:135 #, elixir-autogen, elixir-format msgid "Total blocks" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:169 #: lib/block_scout_web/templates/block/overview.html.eex:169 #, elixir-autogen, elixir-format msgid "Total difficulty of the chain until this block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:188 #: lib/block_scout_web/templates/block/overview.html.eex:188 #, elixir-autogen, elixir-format msgid "Total gas limit provided by all transactions in the block." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:68 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:68 #, elixir-autogen, elixir-format msgid "Total supply" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:360 #: lib/block_scout_web/templates/transaction/overview.html.eex:360 #, elixir-autogen, elixir-format msgid "Total transaction fee." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:114 #: lib/block_scout_web/templates/chain/show.html.eex:114 #, elixir-autogen, elixir-format msgid "Total transactions" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 #: lib/block_scout_web/views/transaction_view.ex:498 +#: lib/block_scout_web/views/transaction_view.ex:498 #, elixir-autogen, elixir-format msgid "Transaction" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:3 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "Transaction %{transaction} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:11 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:11 #, elixir-autogen, elixir-format msgid "Transaction %{transaction}, %{subnetwork} %{transaction}" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:447 #: lib/block_scout_web/templates/transaction/overview.html.eex:447 #, elixir-autogen, elixir-format msgid "Transaction Burnt Fee" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:50 #: lib/block_scout_web/templates/transaction/overview.html.eex:50 #, elixir-autogen, elixir-format msgid "Transaction Details" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:361 #: lib/block_scout_web/templates/transaction/overview.html.eex:361 #, elixir-autogen, elixir-format msgid "Transaction Fee" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:80 #: lib/block_scout_web/templates/transaction/overview.html.eex:80 #, elixir-autogen, elixir-format msgid "Transaction Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:2 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:2 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:19 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:19 #, elixir-autogen, elixir-format msgid "Transaction Inputs" msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:13 #: lib/block_scout_web/templates/account/common/_nav.html.eex:13 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:7 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:17 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:17 #, elixir-autogen, elixir-format msgid "Transaction Tags" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:391 #: lib/block_scout_web/templates/transaction/overview.html.eex:391 #, elixir-autogen, elixir-format msgid "Transaction Type" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:511 #: lib/block_scout_web/templates/transaction/overview.html.eex:511 #, elixir-autogen, elixir-format msgid "Transaction number from the sending address. Each transaction sent from an address increments the nonce by 1." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:390 #: lib/block_scout_web/templates/transaction/overview.html.eex:390 #, elixir-autogen, elixir-format msgid "Transaction type, introduced in EIP-2718." msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:7 #: lib/block_scout_web/templates/address/_tabs.html.eex:7 #: lib/block_scout_web/templates/address/_tile.html.eex:31 +#: lib/block_scout_web/templates/address/_tile.html.eex:31 +#: lib/block_scout_web/templates/address/overview.html.eex:188 #: lib/block_scout_web/templates/address/overview.html.eex:188 #: lib/block_scout_web/templates/address/overview.html.eex:194 +#: lib/block_scout_web/templates/address/overview.html.eex:194 +#: lib/block_scout_web/templates/address/overview.html.eex:202 #: lib/block_scout_web/templates/address/overview.html.eex:202 #: lib/block_scout_web/templates/address_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/overview.html.eex:80 +#: lib/block_scout_web/templates/block/overview.html.eex:80 +#: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/layout/_topnav.html.eex:49 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:49 +#: lib/block_scout_web/views/address_view.ex:354 #: lib/block_scout_web/views/address_view.ex:354 #, elixir-autogen, elixir-format msgid "Transactions" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:101 #: lib/block_scout_web/templates/address/overview.html.eex:101 #, elixir-autogen, elixir-format msgid "Transactions and address of creation." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:215 #: lib/block_scout_web/templates/address/overview.html.eex:215 #: lib/block_scout_web/templates/address/overview.html.eex:221 +#: lib/block_scout_web/templates/address/overview.html.eex:221 +#: lib/block_scout_web/templates/address/overview.html.eex:229 #: lib/block_scout_web/templates/address/overview.html.eex:229 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:50 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:50 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:119 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:119 #, elixir-autogen, elixir-format msgid "Transfers" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:40 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:40 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:47 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:47 #, elixir-autogen, elixir-format msgid "Try it out" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:3 #, elixir-autogen, elixir-format msgid "Try to fetch constructor arguments automatically" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:27 #: lib/block_scout_web/templates/layout/_footer.html.eex:27 #, elixir-autogen, elixir-format msgid "Twitter" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:53 #: lib/block_scout_web/templates/layout/app.html.eex:53 #, elixir-autogen, elixir-format msgid "Tx/day" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:66 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:66 #, elixir-autogen, elixir-format msgid "Txns" msgstr "" #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:5 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:5 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:22 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:22 #, elixir-autogen, elixir-format msgid "Type" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:141 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:141 #, elixir-autogen, elixir-format msgid "Type of the token standard" msgstr "" +#: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:5 #: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:5 #, elixir-autogen, elixir-format msgid "UML diagram" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:537 #: lib/block_scout_web/templates/transaction/overview.html.eex:537 #, elixir-autogen, elixir-format msgid "UTF-8" msgstr "" +#: lib/block_scout_web/views/block_view.ex:77 #: lib/block_scout_web/views/block_view.ex:77 #, elixir-autogen, elixir-format msgid "Uncle Reward" msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:252 +#: lib/block_scout_web/templates/block/overview.html.eex:252 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:41 #: lib/block_scout_web/templates/layout/_topnav.html.eex:41 #, elixir-autogen, elixir-format msgid "Uncles" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:365 #: lib/block_scout_web/views/transaction_view.ex:365 #, elixir-autogen, elixir-format msgid "Unconfirmed" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:9 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:9 #, elixir-autogen, elixir-format msgid "Unique Token" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:79 #: lib/block_scout_web/templates/transaction/overview.html.eex:79 #, elixir-autogen, elixir-format msgid "Unique character string (TxID) assigned to every verified transaction." msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #, elixir-autogen, elixir-format msgid "Update" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:426 #: lib/block_scout_web/templates/transaction/overview.html.eex:426 #, elixir-autogen, elixir-format msgid "User defined maximum fee (tip) per unit of gas paid to validator for transaction prioritization." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:436 #: lib/block_scout_web/templates/transaction/overview.html.eex:436 #, elixir-autogen, elixir-format msgid "User-defined tip sent to validator for transaction priority/inclusion." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:226 #: lib/block_scout_web/templates/block/overview.html.eex:226 #, elixir-autogen, elixir-format msgid "User-defined tips sent to validator for transaction priority/inclusion." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:56 #: lib/block_scout_web/templates/layout/_topnav.html.eex:56 #, elixir-autogen, elixir-format msgid "Validated" msgstr "" +#: lib/block_scout_web/templates/transaction/index.html.eex:12 #: lib/block_scout_web/templates/transaction/index.html.eex:12 #, elixir-autogen, elixir-format msgid "Validated Transactions" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:30 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:30 #, elixir-autogen, elixir-format msgid "Validator Creation Date" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:5 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:5 #, elixir-autogen, elixir-format msgid "Validator Data" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:86 #: lib/block_scout_web/templates/address/overview.html.eex:86 #, elixir-autogen, elixir-format msgid "Validator Name" msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:26 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:26 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:26 #: lib/block_scout_web/templates/withdrawal/index.html.eex:26 #, elixir-autogen, elixir-format msgid "Validator index" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:346 #: lib/block_scout_web/templates/transaction/overview.html.eex:346 #, elixir-autogen, elixir-format msgid "Value" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:345 #: lib/block_scout_web/templates/transaction/overview.html.eex:345 #, elixir-autogen, elixir-format msgid "Value sent in the native token (and USD) if applicable." msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:15 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:15 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:81 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:81 #, elixir-autogen, elixir-format msgid "Verified" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:18 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:18 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:6 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:6 #, elixir-autogen, elixir-format msgid "Verified Contracts" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:88 #: lib/block_scout_web/templates/address_contract/index.html.eex:88 #, elixir-autogen, elixir-format msgid "Verified at" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:69 #: lib/block_scout_web/templates/layout/_topnav.html.eex:69 #, elixir-autogen, elixir-format msgid "Verified contracts" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:2 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "Verified contracts - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:7 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:7 #, elixir-autogen, elixir-format msgid "Verified contracts, %{subnetwork}, %{coin}" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract/index.html.eex:197 #: lib/block_scout_web/templates/address_contract/index.html.eex:197 #: lib/block_scout_web/templates/address_contract/index.html.eex:228 +#: lib/block_scout_web/templates/address_contract/index.html.eex:228 +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:14 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:14 #, elixir-autogen, elixir-format msgid "Verify & Publish" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 #, elixir-autogen, elixir-format msgid "Verify & publish" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #, elixir-autogen, elixir-format msgid "Verify the contract " msgstr "" #: lib/block_scout_web/templates/layout/_footer.html.eex:93 +#: lib/block_scout_web/templates/layout/_footer.html.eex:93 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:72 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:72 #, elixir-autogen, elixir-format msgid "Version" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:33 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:33 #, elixir-autogen, elixir-format msgid "Via Sourcify: Sources and metadata JSON file" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:27 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:27 #, elixir-autogen, elixir-format msgid "Via Standard Input JSON" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Via flattened source code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:40 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:40 #, elixir-autogen, elixir-format msgid "Via multi-part files" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:157 #: lib/block_scout_web/templates/chain/show.html.eex:157 #, elixir-autogen, elixir-format msgid "View All Blocks" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:217 #: lib/block_scout_web/templates/chain/show.html.eex:217 #, elixir-autogen, elixir-format msgid "View All Transactions" msgstr "" #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:20 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:20 #, elixir-autogen, elixir-format msgid "View Contract" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:73 #: lib/block_scout_web/templates/transaction/_tile.html.eex:73 #, elixir-autogen, elixir-format msgid "View Less Transfers" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:72 #: lib/block_scout_web/templates/transaction/_tile.html.eex:72 #, elixir-autogen, elixir-format msgid "View More Transfers" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:39 #: lib/block_scout_web/templates/block/overview.html.eex:39 #, elixir-autogen, elixir-format msgid "View next block" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:23 #: lib/block_scout_web/templates/block/overview.html.eex:23 #, elixir-autogen, elixir-format msgid "View previous block" msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:9 #: lib/block_scout_web/templates/address/_metatags.html.eex:9 #, elixir-autogen, elixir-format msgid "View the account balance, transactions, and other data for %{address} on the %{network}" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:8 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:8 #, elixir-autogen, elixir-format msgid "View the beacon chain withdrawals on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/block/_metatags.html.eex:10 #: lib/block_scout_web/templates/block/_metatags.html.eex:10 #, elixir-autogen, elixir-format msgid "View the transactions, token transfers, and uncles for block number %{block_number}" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:8 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:8 #, elixir-autogen, elixir-format msgid "View the verified contracts on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:10 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:10 #, elixir-autogen, elixir-format msgid "View transaction %{transaction} on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 +#: lib/block_scout_web/views/verified_contracts_view.ex:11 #: lib/block_scout_web/views/verified_contracts_view.ex:11 #, elixir-autogen, elixir-format msgid "Vyper" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:46 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:46 #, elixir-autogen, elixir-format msgid "Vyper contract" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:142 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:142 #, elixir-autogen, elixir-format msgid "WEI" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_pending_contract_write.html.eex:9 #: lib/block_scout_web/templates/smart_contract/_pending_contract_write.html.eex:9 #, elixir-autogen, elixir-format msgid "Waiting for transaction's confirmation..." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:143 #: lib/block_scout_web/templates/chain/show.html.eex:143 #, elixir-autogen, elixir-format msgid "Wallet addresses" msgstr "" +#: lib/block_scout_web/templates/common_components/_changed_bytecode_warning.html.eex:3 #: lib/block_scout_web/templates/common_components/_changed_bytecode_warning.html.eex:3 #, elixir-autogen, elixir-format msgid "Warning! Contract bytecode has been changed and doesn't match the verified one. Therefore, interaction with this smart contract may be risky." msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:7 #: lib/block_scout_web/templates/account/common/_nav.html.eex:7 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:15 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:15 #, elixir-autogen, elixir-format msgid "Watch list" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "We recommend using flattened code. This is necessary if your code utilizes a library or inherits dependencies. Use the" msgstr "" +#: lib/block_scout_web/views/wei_helper.ex:80 #: lib/block_scout_web/views/wei_helper.ex:80 #, elixir-autogen, elixir-format msgid "Wei" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:29 +#: lib/block_scout_web/templates/address/_tabs.html.eex:29 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:13 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:13 #: lib/block_scout_web/templates/block/_tabs.html.eex:13 +#: lib/block_scout_web/templates/block/_tabs.html.eex:13 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:73 #: lib/block_scout_web/templates/layout/_topnav.html.eex:73 #: lib/block_scout_web/templates/withdrawal/index.html.eex:5 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Withdrawals" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #, elixir-autogen, elixir-format msgid "Write" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:95 +#: lib/block_scout_web/templates/address/_tabs.html.eex:95 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 #: lib/block_scout_web/views/address_view.ex:359 +#: lib/block_scout_web/views/address_view.ex:359 #, elixir-autogen, elixir-format msgid "Write Contract" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 +#: lib/block_scout_web/views/address_view.ex:360 #: lib/block_scout_web/views/address_view.ex:360 #, elixir-autogen, elixir-format msgid "Write Proxy" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:43 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:43 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #, elixir-autogen, elixir-format msgid "You can create 3 API keys per account." msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:18 #, elixir-autogen, elixir-format msgid "You can create up to 15 Custom ABIs per account." msgstr "" +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have address tags yet" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:14 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have addresses on you watchlist yet" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:14 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have transaction tags yet" msgstr "" +#: lib/block_scout_web/templates/error422/index.html.eex:8 #: lib/block_scout_web/templates/error422/index.html.eex:8 #, elixir-autogen, elixir-format msgid "Your request contained an error, perhaps a mistyped tx/block/address hash. Try again, and check the developer tools console for more info." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 +#: lib/block_scout_web/views/verified_contracts_view.ex:12 #: lib/block_scout_web/views/verified_contracts_view.ex:12 #, elixir-autogen, elixir-format msgid "Yul" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:111 #: lib/block_scout_web/templates/address/overview.html.eex:111 #, elixir-autogen, elixir-format msgid "at" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:52 #: lib/block_scout_web/templates/address_token/overview.html.eex:52 #, elixir-autogen, elixir-format msgid "balance of the address" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "burnt for this transaction. Equals Block Base Fee per Gas * Gas Used." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:217 #: lib/block_scout_web/templates/block/overview.html.eex:217 #, elixir-autogen, elixir-format msgid "burnt from transactions included in the block (Base fee (per unit of gas) * Gas Used)." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #, elixir-autogen, elixir-format msgid "button" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:253 #: lib/block_scout_web/templates/transaction/overview.html.eex:253 #, elixir-autogen, elixir-format msgid "created" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:12 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:12 #, elixir-autogen, elixir-format msgid "custom RPC" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:151 #: lib/block_scout_web/templates/address/overview.html.eex:151 #, elixir-autogen, elixir-format msgid "doesn't include ERC20, ERC721, ERC1155 tokens)." msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "elements are displayed" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #, elixir-autogen, elixir-format msgid "fallback" msgstr "" +#: lib/block_scout_web/views/address_contract_view.ex:33 #: lib/block_scout_web/views/address_contract_view.ex:33 #, elixir-autogen, elixir-format msgid "false" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:14 #: lib/block_scout_web/templates/csv_export/index.html.eex:14 #, elixir-autogen, elixir-format msgid "for address" msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #, elixir-autogen, elixir-format msgid "here" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:9 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:9 #, elixir-autogen, elixir-format msgid "here." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_function_response.html.eex:3 #: lib/block_scout_web/templates/smart_contract/_function_response.html.eex:3 #, elixir-autogen, elixir-format msgid "method Response" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #, elixir-autogen, elixir-format msgid "of" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "on sign up. Didn’t receive?" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:16 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:16 #, elixir-autogen, elixir-format msgid "page" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #, elixir-autogen, elixir-format msgid "receive" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:81 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:81 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:70 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:70 #, elixir-autogen, elixir-format msgid "required" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #, elixir-autogen, elixir-format msgid "string" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:17 #: lib/block_scout_web/templates/csv_export/index.html.eex:17 #, elixir-autogen, elixir-format msgid "to CSV file" msgstr "" +#: lib/block_scout_web/views/address_contract_view.ex:32 #: lib/block_scout_web/views/address_contract_view.ex:32 #, elixir-autogen, elixir-format msgid "true" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "truffle flattener" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:224 #: lib/block_scout_web/views/transaction_view.ex:224 #, elixir-autogen, elixir-format msgid "ERC-7984 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:223 #: lib/block_scout_web/views/transaction_view.ex:223 #, elixir-autogen, elixir-format msgid "ZRC-2 " diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index aeb47bad4c5..4f799c9b303 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -11,47 +11,57 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:9 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:9 #, elixir-autogen, elixir-format msgid " - minimal bytecode implementation that delegates all calls to a known address" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:14 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:14 #, elixir-autogen, elixir-format msgid " is recommended." msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:3 #: lib/block_scout_web/templates/address/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "%{address} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:12 #: lib/block_scout_web/templates/block/overview.html.eex:12 #, elixir-autogen, elixir-format msgid "%{block_type} Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:55 #: lib/block_scout_web/templates/block/overview.html.eex:55 #, elixir-autogen, elixir-format msgid "%{block_type} Height" msgstr "" +#: lib/block_scout_web/templates/block/index.html.eex:7 #: lib/block_scout_web/templates/block/index.html.eex:7 #, elixir-autogen, elixir-format msgid "%{block_type}s" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:85 #: lib/block_scout_web/templates/block/overview.html.eex:85 #, elixir-autogen, elixir-format msgid "%{count} Transaction" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:87 #: lib/block_scout_web/templates/block/overview.html.eex:87 #: lib/block_scout_web/templates/chain/_block.html.eex:11 +#: lib/block_scout_web/templates/chain/_block.html.eex:11 #, elixir-autogen, elixir-format msgid "%{count} Transactions" msgstr "" +#: lib/block_scout_web/views/address_token_balance_view.ex:10 #: lib/block_scout_web/views/address_token_balance_view.ex:10 #, elixir-autogen, elixir-format msgid "%{count} token" @@ -59,6 +69,7 @@ msgid_plural "%{count} tokens" msgstr[0] "" msgstr[1] "" +#: lib/block_scout_web/templates/block/_tile.html.eex:29 #: lib/block_scout_web/templates/block/_tile.html.eex:29 #, elixir-autogen, elixir-format msgid "%{count} transaction" @@ -66,809 +77,1050 @@ msgid_plural "%{count} transactions" msgstr[0] "" msgstr[1] "" +#: lib/block_scout_web/templates/transaction/_actions.html.eex:101 #: lib/block_scout_web/templates/transaction/_actions.html.eex:101 #, elixir-autogen, elixir-format msgid "%{qty} of <span class=\"text-muted\">Token ID [%{link_to_id}]</span>" msgstr "" +#: lib/block_scout_web/templates/chain/_metatags.html.eex:2 #: lib/block_scout_web/templates/chain/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "%{subnetwork} %{network} Explorer" msgstr "" +#: lib/block_scout_web/templates/layout/_default_title.html.eex:2 #: lib/block_scout_web/templates/layout/_default_title.html.eex:2 #, elixir-autogen, elixir-format msgid "%{subnetwork} Explorer - BlockScout" msgstr "" +#: lib/block_scout_web/templates/withdrawal/index.html.eex:11 #: lib/block_scout_web/templates/withdrawal/index.html.eex:11 #, elixir-autogen, elixir-format msgid "%{withdrawals_count} withdrawals processed and %{withdrawals_sum} withdrawn." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:373 #: lib/block_scout_web/views/transaction_view.ex:373 #, elixir-autogen, elixir-format msgid "(Awaiting internal transactions for status)" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:82 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:82 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:104 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:104 #, elixir-autogen, elixir-format msgid "(query)" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #, elixir-autogen, elixir-format msgid ") may be added for each contract. Click the Add Library button to add an additional one." msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:93 #: lib/block_scout_web/templates/layout/app.html.eex:93 #, elixir-autogen, elixir-format msgid "- We're indexing this chain right now. Some of the counts may be inaccurate." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:8 #: lib/block_scout_web/templates/transaction/not_found.html.eex:8 #, elixir-autogen, elixir-format msgid "1. If you have just submitted this transaction please wait for at least 30 seconds before refreshing this page." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:9 #: lib/block_scout_web/templates/transaction/not_found.html.eex:9 #, elixir-autogen, elixir-format msgid "2. It could still be in the TX Pool of a different node, waiting to be broadcasted." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:10 #: lib/block_scout_web/templates/transaction/not_found.html.eex:10 #, elixir-autogen, elixir-format msgid "3. During times when the network is busy (i.e during ICOs) it can take a while for your transaction to propagate through the network and for us to index it." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:11 #: lib/block_scout_web/templates/transaction/not_found.html.eex:11 #, elixir-autogen, elixir-format msgid "4. If it still does not show up after 1 hour, please check with your sender/exchange/wallet/transaction provider for additional information." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:197 #: lib/block_scout_web/templates/block/overview.html.eex:197 #, elixir-autogen, elixir-format msgid "64-bit hash of value verifying proof-of-work (note: null for POA chains)." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:97 #: lib/block_scout_web/templates/block/overview.html.eex:97 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:21 +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:21 #, elixir-autogen, elixir-format msgid "A block producer who successfully included the block onto the blockchain." msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "A confirmation email was sent to" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:4 #, elixir-autogen, elixir-format msgid "A library name called in the .sol file. Multiple libraries (up to " msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:73 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:73 #, elixir-autogen, elixir-format msgid "A string with the name of the action to be invoked." msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:62 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:62 #, elixir-autogen, elixir-format msgid "A string with the name of the module to be invoked." msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:24 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:24 #, elixir-autogen, elixir-format msgid "ABI" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_constructor_args.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_constructor_args.html.eex:3 #, elixir-autogen, elixir-format msgid "ABI-encoded Constructor Arguments (if required by the contract)" msgstr "" +#: lib/block_scout_web/templates/api_docs/index.html.eex:4 #: lib/block_scout_web/templates/api_docs/index.html.eex:4 #, elixir-autogen, elixir-format msgid "API Documentation" msgstr "" +#: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 #, elixir-autogen, elixir-format msgid "API endpoints for the %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/api_docs/_metatags.html.eex:2 #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "API for the %{subnetwork} - BlockScout" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:13 #: lib/block_scout_web/templates/account/api_key/form.html.eex:13 #: lib/block_scout_web/templates/account/api_key/form.html.eex:14 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:14 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:29 #: lib/block_scout_web/templates/account/api_key/index.html.eex:29 #, elixir-autogen, elixir-format msgid "API key" msgstr "" #: lib/block_scout_web/templates/account/api_key/index.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:7 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:16 #: lib/block_scout_web/templates/account/common/_nav.html.eex:16 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:18 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:18 #, elixir-autogen, elixir-format msgid "API keys" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:106 #: lib/block_scout_web/templates/layout/_topnav.html.eex:106 #, elixir-autogen, elixir-format msgid "APIs" msgstr "" #: lib/block_scout_web/templates/account/tag_address/index.html.eex:24 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:24 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:24 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:24 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 #, elixir-autogen, elixir-format msgid "Action" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:25 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:25 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:461 #: lib/block_scout_web/templates/transaction/overview.html.eex:461 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction on L2." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:465 #: lib/block_scout_web/templates/transaction/overview.html.eex:465 #, elixir-autogen, elixir-format, fuzzy msgid "Actual gas amount used by the transaction." msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/layout/_add_chain_to_mm.html.eex:10 +#: lib/block_scout_web/templates/layout/_add_chain_to_mm.html.eex:10 #, elixir-autogen, elixir-format msgid "Add" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:44 #: lib/block_scout_web/templates/account/api_key/index.html.eex:44 #, elixir-autogen, elixir-format msgid "Add API key" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:86 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:86 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:76 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:76 #, elixir-autogen, elixir-format msgid "Add Contract Libraries" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:44 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:44 #, elixir-autogen, elixir-format msgid "Add Custom ABI" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:97 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:97 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:87 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:87 #, elixir-autogen, elixir-format msgid "Add Library" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:38 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:38 #, elixir-autogen, elixir-format msgid "Add address" msgstr "" #: lib/block_scout_web/templates/account/tag_address/form.html.eex:7 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:7 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:37 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Add address tag" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #, elixir-autogen, elixir-format msgid "Add address to the Watch list" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:7 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:37 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Add transaction tag" msgstr "" +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:23 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:23 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:23 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:23 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:12 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:12 +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:16 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 +#: lib/block_scout_web/templates/tokens/index.html.eex:34 #: lib/block_scout_web/templates/tokens/index.html.eex:34 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 +#: lib/block_scout_web/views/address_view.ex:110 #: lib/block_scout_web/views/address_view.ex:110 #, elixir-autogen, elixir-format msgid "Address" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:240 #: lib/block_scout_web/templates/transaction/overview.html.eex:240 #, elixir-autogen, elixir-format msgid "Address (external or contract) receiving the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:222 #: lib/block_scout_web/templates/transaction/overview.html.eex:222 #, elixir-autogen, elixir-format msgid "Address (external or contract) sending the transaction." msgstr "" #: lib/block_scout_web/templates/account/common/_nav.html.eex:10 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:10 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:7 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:7 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:16 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:16 #, elixir-autogen, elixir-format msgid "Address Tags" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:151 #: lib/block_scout_web/templates/address/overview.html.eex:151 #, elixir-autogen, elixir-format msgid "Address balance in" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:51 #, elixir-autogen, elixir-format msgid "Address of the token contract" msgstr "" +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:7 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:7 #, elixir-autogen, elixir-format msgid "Address used in token mintings and burnings." msgstr "" +#: lib/block_scout_web/templates/address/index.html.eex:5 #: lib/block_scout_web/templates/address/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Addresses" msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:35 #, elixir-autogen, elixir-format msgid "Age" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:26 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:26 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:28 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:28 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:22 #: lib/block_scout_web/templates/address_transaction/index.html.eex:22 #: lib/block_scout_web/templates/layout/_topnav.html.eex:88 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:88 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:20 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:20 #: lib/block_scout_web/views/address_internal_transaction_view.ex:12 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:12 +#: lib/block_scout_web/views/address_token_transfer_view.ex:12 #: lib/block_scout_web/views/address_token_transfer_view.ex:12 #: lib/block_scout_web/views/address_transaction_view.ex:12 +#: lib/block_scout_web/views/address_transaction_view.ex:12 +#: lib/block_scout_web/views/verified_contracts_view.ex:13 #: lib/block_scout_web/views/verified_contracts_view.ex:13 #, elixir-autogen, elixir-format msgid "All" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:13 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:13 #, elixir-autogen, elixir-format msgid "All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #, elixir-autogen, elixir-format msgid "All metadata displayed below is from that contract. In order to verify current contract, click" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:176 #: lib/block_scout_web/templates/address/overview.html.eex:176 #, elixir-autogen, elixir-format msgid "All tokens in the account and total value." msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:41 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:41 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:32 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:32 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:38 #: lib/block_scout_web/templates/withdrawal/index.html.eex:38 #, elixir-autogen, elixir-format, fuzzy msgid "Amount" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "Amount of" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:238 #: lib/block_scout_web/templates/block/overview.html.eex:238 #, elixir-autogen, elixir-format msgid "Amount of distributed reward. Miners receive a static block reward + Tx fees + uncle fees." msgstr "" +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:8 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:8 #, elixir-autogen, elixir-format msgid "An unexpected error has occurred. Try reloading the page, or come back soon and try again." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:15 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:15 #, elixir-autogen, elixir-format msgid "Anything not in this list is not supported. Click on the method to be taken to the documentation for that method, and check the notes section for any potential differences." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:134 #: lib/block_scout_web/templates/layout/_topnav.html.eex:134 #, elixir-autogen, elixir-format msgid "Apps" msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 #, elixir-autogen, elixir-format msgid "Average" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:104 #: lib/block_scout_web/templates/chain/show.html.eex:104 #, elixir-autogen, elixir-format msgid "Average block time" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:25 #: lib/block_scout_web/templates/account/api_key/form.html.eex:25 #, elixir-autogen, elixir-format msgid "Back to API keys (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:24 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Back to Address Tags (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:30 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:30 #, elixir-autogen, elixir-format msgid "Back to Custom ABI (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:24 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Back to Transaction Tags (Cancel)" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:81 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:81 #, elixir-autogen, elixir-format msgid "Back to Watch list (Cancel)" msgstr "" #: lib/block_scout_web/templates/error422/index.html.eex:9 +#: lib/block_scout_web/templates/error422/index.html.eex:9 +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:9 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:9 #: lib/block_scout_web/templates/page_not_found/index.html.eex:9 +#: lib/block_scout_web/templates/page_not_found/index.html.eex:9 +#: lib/block_scout_web/templates/transaction/not_found.html.eex:13 #: lib/block_scout_web/templates/transaction/not_found.html.eex:13 #, elixir-autogen, elixir-format msgid "Back to home" msgstr "" #: lib/block_scout_web/templates/account/watchlist/show.html.eex:24 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:24 +#: lib/block_scout_web/templates/address/overview.html.eex:152 #: lib/block_scout_web/templates/address/overview.html.eex:152 #: lib/block_scout_web/templates/address_token/overview.html.eex:51 +#: lib/block_scout_web/templates/address_token/overview.html.eex:51 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:63 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:63 #, elixir-autogen, elixir-format msgid "Balance" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:40 #: lib/block_scout_web/templates/transaction_state/index.html.eex:40 #, elixir-autogen, elixir-format msgid "Balance after" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:37 #: lib/block_scout_web/templates/transaction_state/index.html.eex:37 #, elixir-autogen, elixir-format msgid "Balance before" msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:14 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Balances" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:209 #: lib/block_scout_web/templates/block/overview.html.eex:209 #, elixir-autogen, elixir-format msgid "Base Fee per Gas" msgstr "" #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:5 +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:5 +#: lib/block_scout_web/templates/api_docs/index.html.eex:5 #: lib/block_scout_web/templates/api_docs/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Base URL:" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:2 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "Beacon chain withdrawals - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:7 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:7 #, elixir-autogen, elixir-format msgid "Beacon chain, Withdrawals, %{subnetwork}, %{coin}" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:522 #: lib/block_scout_web/templates/transaction/overview.html.eex:522 #, elixir-autogen, elixir-format msgid "Binary data included with the transaction. See input / logs below for additional info." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/_coin_balances.html.eex:8 #: lib/block_scout_web/templates/address_coin_balance/_coin_balances.html.eex:8 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:35 +#: lib/block_scout_web/templates/block/overview.html.eex:29 #: lib/block_scout_web/templates/block/overview.html.eex:29 #: lib/block_scout_web/templates/transaction/overview.html.eex:161 +#: lib/block_scout_web/templates/transaction/overview.html.eex:161 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/withdrawal/index.html.eex:29 #, elixir-autogen, elixir-format msgid "Block" msgstr "" #: lib/block_scout_web/templates/block/_link.html.eex:2 +#: lib/block_scout_web/templates/block/_link.html.eex:2 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:32 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:32 #: lib/block_scout_web/templates/tokens/transfer/_token_transfer.html.eex:43 +#: lib/block_scout_web/templates/tokens/transfer/_token_transfer.html.eex:43 #, elixir-autogen, elixir-format msgid "Block #%{number}" msgstr "" +#: lib/block_scout_web/templates/block/_metatags.html.eex:3 #: lib/block_scout_web/templates/block/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "Block %{block_number} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/block_transaction/404.html.eex:7 #: lib/block_scout_web/templates/block_transaction/404.html.eex:7 #, elixir-autogen, elixir-format msgid "Block Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:53 #: lib/block_scout_web/templates/block/overview.html.eex:53 #, elixir-autogen, elixir-format msgid "Block Height" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:47 #: lib/block_scout_web/templates/layout/app.html.eex:47 #, elixir-autogen, elixir-format msgid "Block Mined, awaiting import..." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:34 #: lib/block_scout_web/views/transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Block Pending" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:158 #: lib/block_scout_web/templates/block/overview.html.eex:158 #, elixir-autogen, elixir-format msgid "Block difficulty for miner, used to calibrate block generation time (Note: constant in POA based networks)." msgstr "" +#: lib/block_scout_web/views/block_transaction_view.ex:19 #: lib/block_scout_web/views/block_transaction_view.ex:19 #, elixir-autogen, elixir-format msgid "Block not found, please try again later." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:203 #: lib/block_scout_web/templates/transaction/overview.html.eex:203 #, elixir-autogen, elixir-format, fuzzy msgid "Block number containing the transaction on L1." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:160 #: lib/block_scout_web/templates/transaction/overview.html.eex:160 #, elixir-autogen, elixir-format msgid "Block number containing the transaction." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:259 #: lib/block_scout_web/templates/address/overview.html.eex:259 #, elixir-autogen, elixir-format msgid "Block number in which the address was updated." msgstr "" +#: lib/block_scout_web/templates/chain/_metatags.html.eex:4 #: lib/block_scout_web/templates/chain/_metatags.html.eex:4 #, elixir-autogen, elixir-format msgid "BlockScout provides analytics data, API, and Smart Contract tools for the %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:29 #: lib/block_scout_web/templates/layout/_topnav.html.eex:29 #, elixir-autogen, elixir-format msgid "Blockchain" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/layout/_topnav.html.eex:34 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:34 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #, elixir-autogen, elixir-format msgid "Blocks" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:46 #: lib/block_scout_web/templates/layout/app.html.eex:46 #, elixir-autogen, elixir-format msgid "Blocks Indexed" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/overview.html.eex:277 +#: lib/block_scout_web/templates/address/overview.html.eex:277 +#: lib/block_scout_web/templates/address_validation/index.html.eex:11 #: lib/block_scout_web/templates/address_validation/index.html.eex:11 #: lib/block_scout_web/views/address_view.ex:362 +#: lib/block_scout_web/views/address_view.ex:362 #, elixir-autogen, elixir-format msgid "Blocks Validated" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:48 #: lib/block_scout_web/templates/layout/app.html.eex:48 #, elixir-autogen, elixir-format msgid "Blocks With Internal Transactions Indexed" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:22 #: lib/block_scout_web/templates/layout/_footer.html.eex:22 #, elixir-autogen, elixir-format msgid "Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks." msgstr "" +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:8 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:8 #, elixir-autogen, elixir-format msgid "Burn address" msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:64 +#: lib/block_scout_web/templates/block/_tile.html.eex:64 +#: lib/block_scout_web/templates/block/overview.html.eex:218 #: lib/block_scout_web/templates/block/overview.html.eex:218 #, elixir-autogen, elixir-format msgid "Burnt Fees" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:65 #: lib/block_scout_web/templates/address_token/overview.html.eex:65 #, elixir-autogen, elixir-format msgid "CRC Worth" msgstr "" +#: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 #: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 #, elixir-autogen, elixir-format msgid "CSV" msgstr "" #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/views/internal_transaction_view.ex:23 #: lib/block_scout_web/views/internal_transaction_view.ex:23 #, elixir-autogen, elixir-format msgid "Call" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:24 #: lib/block_scout_web/views/internal_transaction_view.ex:24 #, elixir-autogen, elixir-format msgid "Call Code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:54 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:54 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:43 #: lib/block_scout_web/templates/transaction_state/index.html.eex:43 #, elixir-autogen, elixir-format msgid "Change" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:43 #: lib/block_scout_web/templates/layout/_footer.html.eex:43 #, elixir-autogen, elixir-format msgid "Chat (#blockscout)" msgstr "" +#: lib/block_scout_web/views/block_view.ex:65 #: lib/block_scout_web/views/block_view.ex:65 #, elixir-autogen, elixir-format msgid "Chore Reward" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:38 #: lib/block_scout_web/templates/tokens/index.html.eex:38 #, elixir-autogen, elixir-format msgid "Circulating Market Cap" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:137 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:137 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:106 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:106 #, elixir-autogen, elixir-format msgid "Clear" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:37 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:37 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:6 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:6 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:14 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:14 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:84 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:84 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:92 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:92 #, elixir-autogen, elixir-format msgid "Close" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:66 +#: lib/block_scout_web/templates/address/_tabs.html.eex:66 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:165 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:165 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 +#: lib/block_scout_web/views/address_view.ex:356 #: lib/block_scout_web/views/address_view.ex:356 #, elixir-autogen, elixir-format msgid "Code" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:42 +#: lib/block_scout_web/templates/address/_tabs.html.eex:42 +#: lib/block_scout_web/views/address_view.ex:361 #: lib/block_scout_web/views/address_view.ex:361 #, elixir-autogen, elixir-format msgid "Coin Balance History" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #, elixir-autogen, elixir-format msgid "Collapse" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 #, elixir-autogen, elixir-format msgid "Compiler" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:142 #: lib/block_scout_web/templates/address_contract/index.html.eex:142 #, elixir-autogen, elixir-format msgid "Compiler Settings" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:71 #: lib/block_scout_web/templates/address_contract/index.html.eex:71 #, elixir-autogen, elixir-format msgid "Compiler version" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:366 #: lib/block_scout_web/views/transaction_view.ex:366 #, elixir-autogen, elixir-format msgid "Confirmed" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:127 #: lib/block_scout_web/templates/transaction/overview.html.eex:127 #, elixir-autogen, elixir-format msgid "Confirmed by " msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:193 #: lib/block_scout_web/templates/transaction/overview.html.eex:193 #, elixir-autogen, elixir-format msgid "Confirmed within" msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:6 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:6 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:2 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:6 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:6 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:4 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:16 #, elixir-autogen, elixir-format msgid "Connection Lost" msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:12 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:12 #: lib/block_scout_web/templates/block/index.html.eex:5 +#: lib/block_scout_web/templates/block/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer blocks" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:15 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:15 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer internal transactions" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:11 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:11 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:16 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:16 #: lib/block_scout_web/templates/transaction/index.html.eex:22 +#: lib/block_scout_web/templates/transaction/index.html.eex:22 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer transactions" msgstr "" +#: lib/block_scout_web/templates/address_validation/index.html.eex:10 #: lib/block_scout_web/templates/address_validation/index.html.eex:10 #, elixir-autogen, elixir-format msgid "Connection Lost, click to load newer validations" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:96 #: lib/block_scout_web/templates/address_contract/index.html.eex:96 #, elixir-autogen, elixir-format msgid "Constructor Arguments" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:78 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:78 #, elixir-autogen, elixir-format msgid "Constructor args" msgstr "" #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 +#: lib/block_scout_web/templates/transaction/overview.html.eex:250 #: lib/block_scout_web/templates/transaction/overview.html.eex:250 #, elixir-autogen, elixir-format msgid "Contract" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:157 #: lib/block_scout_web/templates/address_contract/index.html.eex:157 #, elixir-autogen, elixir-format msgid "Contract ABI" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 #: lib/block_scout_web/views/address_view.ex:108 +#: lib/block_scout_web/views/address_view.ex:108 #, elixir-autogen, elixir-format msgid "Contract Address" msgstr "" +#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 #: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:82 #: lib/block_scout_web/views/address_view.ex:82 #, elixir-autogen, elixir-format msgid "Contract Address Pending" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:495 #: lib/block_scout_web/views/transaction_view.ex:495 #, elixir-autogen, elixir-format msgid "Contract Call" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:492 #: lib/block_scout_web/views/transaction_view.ex:492 #, elixir-autogen, elixir-format msgid "Contract Creation" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:174 +#: lib/block_scout_web/templates/address_contract/index.html.eex:174 +#: lib/block_scout_web/templates/address_contract/index.html.eex:189 #: lib/block_scout_web/templates/address_contract/index.html.eex:189 #, elixir-autogen, elixir-format msgid "Contract Creation Code" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:90 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:90 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:80 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:80 #, elixir-autogen, elixir-format msgid "Contract Libraries" msgstr "" #: lib/block_scout_web/templates/address/overview.html.eex:75 +#: lib/block_scout_web/templates/address/overview.html.eex:75 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_name_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_name_field.html.eex:3 #, elixir-autogen, elixir-format msgid "Contract Name" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_contract/index.html.eex:25 +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:11 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:11 #, elixir-autogen, elixir-format msgid "Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:47 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:47 #, elixir-autogen, elixir-format msgid "Contract name or address" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:63 #: lib/block_scout_web/templates/address_contract/index.html.eex:63 #, elixir-autogen, elixir-format msgid "Contract name:" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:106 #: lib/block_scout_web/templates/address_contract/index.html.eex:106 #, elixir-autogen, elixir-format msgid "Contract source code" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:120 #: lib/block_scout_web/templates/address/overview.html.eex:120 #, elixir-autogen, elixir-format msgid "Contract was precompiled and created at genesis or contract creation transaction is missing" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:5 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:5 #, elixir-autogen, elixir-format msgid "Contracts" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:180 #: lib/block_scout_web/templates/address_contract/index.html.eex:180 #, elixir-autogen, elixir-format msgid "Contracts that self destruct in their constructors have no contract code published and cannot be verified." msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:42 #: lib/block_scout_web/templates/layout/_footer.html.eex:42 #, elixir-autogen, elixir-format msgid "Contribute" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:159 #: lib/block_scout_web/templates/address_contract/index.html.eex:159 #, elixir-autogen, elixir-format msgid "Copy ABI" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #: lib/block_scout_web/templates/account/api_key/row.html.eex:6 #, elixir-autogen, elixir-format @@ -877,24 +1129,38 @@ msgstr "" #: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 #: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_address/row.html.eex:8 +#: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 +#: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/row.html.eex:11 #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/address/overview.html.eex:38 #: lib/block_scout_web/templates/address/overview.html.eex:38 #: lib/block_scout_web/templates/address/overview.html.eex:39 +#: lib/block_scout_web/templates/address/overview.html.eex:39 +#: lib/block_scout_web/templates/block/overview.html.eex:104 #: lib/block_scout_web/templates/block/overview.html.eex:104 #: lib/block_scout_web/templates/block/overview.html.eex:105 +#: lib/block_scout_web/templates/block/overview.html.eex:105 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:43 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:43 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:44 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:44 #, elixir-autogen, elixir-format msgid "Copy Address" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:144 #: lib/block_scout_web/templates/address_contract/index.html.eex:144 #, elixir-autogen, elixir-format msgid "Copy Compiler Settings" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:6 #, elixir-autogen, elixir-format @@ -902,734 +1168,952 @@ msgid "Copy Contract Address" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:176 +#: lib/block_scout_web/templates/address_contract/index.html.eex:176 +#: lib/block_scout_web/templates/address_contract/index.html.eex:192 #: lib/block_scout_web/templates/address_contract/index.html.eex:192 #, elixir-autogen, elixir-format msgid "Copy Contract Creation Code" msgstr "" #: lib/block_scout_web/templates/address_contract/index.html.eex:213 +#: lib/block_scout_web/templates/address_contract/index.html.eex:213 +#: lib/block_scout_web/templates/address_contract/index.html.eex:223 #: lib/block_scout_web/templates/address_contract/index.html.eex:223 #, elixir-autogen, elixir-format msgid "Copy Deployed ByteCode" msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 +#: lib/block_scout_web/templates/transaction/overview.html.eex:230 #: lib/block_scout_web/templates/transaction/overview.html.eex:230 #: lib/block_scout_web/templates/transaction/overview.html.eex:231 +#: lib/block_scout_web/templates/transaction/overview.html.eex:231 #, elixir-autogen, elixir-format msgid "Copy From Address" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:129 #: lib/block_scout_web/templates/block/overview.html.eex:129 #: lib/block_scout_web/templates/block/overview.html.eex:130 +#: lib/block_scout_web/templates/block/overview.html.eex:130 #, elixir-autogen, elixir-format msgid "Copy Hash" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:20 #: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:20 #, elixir-autogen, elixir-format msgid "Copy Metadata" msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:149 +#: lib/block_scout_web/templates/block/overview.html.eex:149 +#: lib/block_scout_web/templates/block/overview.html.eex:150 #: lib/block_scout_web/templates/block/overview.html.eex:150 #, elixir-autogen, elixir-format msgid "Copy Parent Hash" msgstr "" +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:9 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:9 #, elixir-autogen, elixir-format msgid "Copy Raw Trace" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:120 #: lib/block_scout_web/templates/address_contract/index.html.eex:120 #: lib/block_scout_web/templates/address_contract/index.html.eex:132 +#: lib/block_scout_web/templates/address_contract/index.html.eex:132 #, elixir-autogen, elixir-format msgid "Copy Source Code" msgstr "" +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 +#: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 +#: lib/block_scout_web/templates/transaction/overview.html.eex:257 #: lib/block_scout_web/templates/transaction/overview.html.eex:257 #: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:265 #: lib/block_scout_web/templates/transaction/overview.html.eex:265 #: lib/block_scout_web/templates/transaction/overview.html.eex:266 +#: lib/block_scout_web/templates/transaction/overview.html.eex:266 #, elixir-autogen, elixir-format msgid "Copy To Address" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:32 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:32 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:33 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:33 #, elixir-autogen, elixir-format msgid "Copy Token ID" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:87 #: lib/block_scout_web/templates/transaction/overview.html.eex:87 #, elixir-autogen, elixir-format msgid "Copy Transaction Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:88 #: lib/block_scout_web/templates/transaction/overview.html.eex:88 #, elixir-autogen, elixir-format msgid "Copy Txn Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:548 #: lib/block_scout_web/templates/transaction/overview.html.eex:548 #, elixir-autogen, elixir-format msgid "Copy Txn Hex Input" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:554 #: lib/block_scout_web/templates/transaction/overview.html.eex:554 #, elixir-autogen, elixir-format msgid "Copy Txn UTF-8 Input" msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 +#: lib/block_scout_web/templates/transaction/overview.html.eex:547 #: lib/block_scout_web/templates/transaction/overview.html.eex:547 #: lib/block_scout_web/templates/transaction/overview.html.eex:553 +#: lib/block_scout_web/templates/transaction/overview.html.eex:553 +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #, elixir-autogen, elixir-format msgid "Copy Value" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:31 #: lib/block_scout_web/views/internal_transaction_view.ex:31 #, elixir-autogen, elixir-format msgid "Create" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:12 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:12 #, elixir-autogen, elixir-format msgid "Create a Custom ABI to interact with contracts." msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:12 #: lib/block_scout_web/templates/account/api_key/index.html.eex:12 #, elixir-autogen, elixir-format, fuzzy msgid "Create an API key to use with your RPC and EthRPC API requests." msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:32 #: lib/block_scout_web/views/internal_transaction_view.ex:32 #, elixir-autogen, elixir-format msgid "Create2" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:102 #: lib/block_scout_web/templates/address/overview.html.eex:102 #, elixir-autogen, elixir-format msgid "Creator" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:146 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:146 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:116 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:116 #, elixir-autogen, elixir-format msgid "Curl" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:97 #: lib/block_scout_web/templates/transaction/overview.html.eex:97 #, elixir-autogen, elixir-format msgid "Current transaction state: Success, Failed (Error), or Pending (In Process)" msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:20 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:20 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:18 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Custom" msgstr "" #: lib/block_scout_web/templates/account/common/_nav.html.eex:19 +#: lib/block_scout_web/templates/account/common/_nav.html.eex:19 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:7 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:19 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:19 #, elixir-autogen, elixir-format msgid "Custom ABI" msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:25 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:23 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:23 #, elixir-autogen, elixir-format msgid "Custom ABI from account" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:72 #: lib/block_scout_web/templates/chain/show.html.eex:72 #, elixir-autogen, elixir-format msgid "Daily Transactions" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:98 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:98 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:7 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:7 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:23 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:23 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:130 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:130 #, elixir-autogen, elixir-format msgid "Data" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:70 #: lib/block_scout_web/templates/block/overview.html.eex:70 #, elixir-autogen, elixir-format msgid "Date & time at which block was produced." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:179 #: lib/block_scout_web/templates/transaction/overview.html.eex:179 #, elixir-autogen, elixir-format msgid "Date & time of transaction inclusion, including length of time for confirmation." msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:52 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:52 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:131 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:131 #, elixir-autogen, elixir-format msgid "Decimals" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:32 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:32 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:38 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:38 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:53 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:53 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:43 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:43 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:51 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:51 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:66 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:66 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:82 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:82 #, elixir-autogen, elixir-format msgid "Decoded" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:25 #: lib/block_scout_web/views/internal_transaction_view.ex:25 #, elixir-autogen, elixir-format msgid "Delegate Call" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:211 #: lib/block_scout_web/templates/address_contract/index.html.eex:211 #: lib/block_scout_web/templates/address_contract/index.html.eex:219 +#: lib/block_scout_web/templates/address_contract/index.html.eex:219 #, elixir-autogen, elixir-format msgid "Deployed ByteCode" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:53 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:53 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:188 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:188 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:60 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:60 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:150 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:150 #, elixir-autogen, elixir-format msgid "Description" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 #, elixir-autogen, elixir-format msgid "Details" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:159 #: lib/block_scout_web/templates/block/overview.html.eex:159 #, elixir-autogen, elixir-format msgid "Difficulty" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:181 #: lib/block_scout_web/templates/address_contract/index.html.eex:181 #, elixir-autogen, elixir-format msgid "Displaying the init data provided of the creating transaction." msgstr "" #: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 +#: lib/block_scout_web/templates/common_components/_csv_export_button.html.eex:4 +#: lib/block_scout_web/templates/csv_export/index.html.eex:33 #: lib/block_scout_web/templates/csv_export/index.html.eex:33 #, elixir-autogen, elixir-format msgid "Download" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Drop all Solidity contract source files into the drop zone." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Drop all Solidity or Yul contract source files into the drop zone." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:18 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:18 #, elixir-autogen, elixir-format msgid "Drop sources and metadata JSON file or click here" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:67 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:67 #, elixir-autogen, elixir-format msgid "Drop sources or click here" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:28 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:28 #, elixir-autogen, elixir-format msgid "Drop the standard input JSON file or click here" msgstr "" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #, elixir-autogen, elixir-format msgid "EIP-1167" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:221 #: lib/block_scout_web/views/transaction_view.ex:221 #, elixir-autogen, elixir-format msgid "ERC-1155 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:219 #: lib/block_scout_web/views/transaction_view.ex:219 #, elixir-autogen, elixir-format msgid "ERC-20 " msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:40 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:40 #, elixir-autogen, elixir-format msgid "ERC-20 tokens (beta)" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:222 #: lib/block_scout_web/views/transaction_view.ex:222 #, elixir-autogen, elixir-format, fuzzy msgid "ERC-404 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:220 #: lib/block_scout_web/views/transaction_view.ex:220 #, elixir-autogen, elixir-format msgid "ERC-721 " msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:53 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:53 #, elixir-autogen, elixir-format msgid "ERC-721, ERC-1155 tokens (NFT) (beta)" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:4 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:4 #, elixir-autogen, elixir-format msgid "ETH RPC API Documentation" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:82 #: lib/block_scout_web/templates/address_contract/index.html.eex:82 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:30 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:30 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:22 #, elixir-autogen, elixir-format msgid "EVM Version" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #, elixir-autogen, elixir-format msgid "EVM version details" msgstr "" #: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:11 #: lib/block_scout_web/views/block_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "Easy Cowboy! This block does not exist yet!" msgstr "" #: lib/block_scout_web/templates/account/api_key/row.html.eex:16 +#: lib/block_scout_web/templates/account/api_key/row.html.eex:16 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:16 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:16 #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:27 +#: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:27 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:7 #, elixir-autogen, elixir-format msgid "Edit Watch list address" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:71 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:71 #, elixir-autogen, elixir-format msgid "Email notifications" msgstr "" +#: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:5 #, elixir-autogen, elixir-format msgid "Emission Contract" msgstr "" +#: lib/block_scout_web/views/block_view.ex:73 #: lib/block_scout_web/views/block_view.ex:73 #, elixir-autogen, elixir-format msgid "Emission Reward" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:72 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:72 #, elixir-autogen, elixir-format msgid "Enter the Solidity Contract Code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Enter the Vyper Contract Code" msgstr "" +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 #, elixir-autogen, elixir-format msgid "Error" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:11 #: lib/block_scout_web/templates/transaction/_tile.html.eex:11 #, elixir-autogen, elixir-format msgid "Error in internal transactions" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:33 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:33 #, elixir-autogen, elixir-format msgid "Error rendering value" msgstr "" +#: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:10 #: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:10 #, elixir-autogen, elixir-format msgid "Error trying to fetch balances." msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:377 #: lib/block_scout_web/views/transaction_view.ex:377 #, elixir-autogen, elixir-format msgid "Error: %{reason}" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:375 #: lib/block_scout_web/views/transaction_view.ex:375 #, elixir-autogen, elixir-format msgid "Error: (Awaiting internal transactions for reason)" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:120 #: lib/block_scout_web/templates/address/overview.html.eex:120 #, elixir-autogen, elixir-format msgid "Error: Could not determine contract creator." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:120 #: lib/block_scout_web/templates/layout/_topnav.html.eex:120 #, elixir-autogen, elixir-format msgid "Eth RPC" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:211 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:211 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:164 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:164 #, elixir-autogen, elixir-format msgid "Example Value" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:128 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:128 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:99 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:99 #, elixir-autogen, elixir-format msgid "Execute" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:54 #, elixir-autogen, elixir-format msgid "Expand" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:14 #: lib/block_scout_web/templates/csv_export/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Export" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:10 #: lib/block_scout_web/templates/csv_export/index.html.eex:10 #, elixir-autogen, elixir-format msgid "Export Data" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:248 #: lib/block_scout_web/templates/address_contract/index.html.eex:248 #, elixir-autogen, elixir-format msgid "External libraries" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:40 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:40 #, elixir-autogen, elixir-format msgid "Failed to decode input data." msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:35 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:35 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:46 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:46 #, elixir-autogen, elixir-format msgid "Failed to decode log data." msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #, elixir-autogen, elixir-format msgid "Fast" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:249 #: lib/block_scout_web/templates/address/overview.html.eex:249 #, elixir-autogen, elixir-format msgid "Fetching gas used..." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:112 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:112 #, elixir-autogen, elixir-format msgid "Fetching holders..." msgstr "" +#: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:7 #: lib/block_scout_web/templates/address/_balance_dropdown.html.eex:7 #, elixir-autogen, elixir-format msgid "Fetching tokens..." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:196 #: lib/block_scout_web/templates/address/overview.html.eex:196 #: lib/block_scout_web/templates/address/overview.html.eex:204 +#: lib/block_scout_web/templates/address/overview.html.eex:204 #, elixir-autogen, elixir-format msgid "Fetching transactions..." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:223 #: lib/block_scout_web/templates/address/overview.html.eex:223 #: lib/block_scout_web/templates/address/overview.html.eex:231 +#: lib/block_scout_web/templates/address/overview.html.eex:231 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:123 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:123 #, elixir-autogen, elixir-format msgid "Fetching transfers..." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:15 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:15 #, elixir-autogen, elixir-format msgid "Filter by compiler:" msgstr "" +#: lib/block_scout_web/templates/admin/dashboard/index.html.eex:16 #: lib/block_scout_web/templates/admin/dashboard/index.html.eex:16 #, elixir-autogen, elixir-format msgid "For any existing contracts in the database, insert all ABI entries into the contract_methods table. Use this in case you have verified smart contracts before early March 2019 and you want other contracts with the same functions to show those ABI's as candidate matches." msgstr "" +#: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:7 #: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:7 #, elixir-autogen, elixir-format msgid "For contract" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:44 #: lib/block_scout_web/templates/layout/_topnav.html.eex:44 #, elixir-autogen, elixir-format msgid "Forked Blocks (Reorgs)" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:45 #: lib/block_scout_web/templates/layout/_footer.html.eex:45 #, elixir-autogen, elixir-format msgid "Forum" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:34 #: lib/block_scout_web/templates/transaction/overview.html.eex:223 +#: lib/block_scout_web/templates/transaction/overview.html.eex:223 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_token_transfer_view.ex:11 +#: lib/block_scout_web/views/address_token_transfer_view.ex:11 +#: lib/block_scout_web/views/address_transaction_view.ex:11 #: lib/block_scout_web/views/address_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "From" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:18 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:18 #, elixir-autogen, elixir-format msgid "GET" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/overview.html.eex:189 +#: lib/block_scout_web/templates/block/overview.html.eex:189 +#: lib/block_scout_web/templates/transaction/overview.html.eex:407 #: lib/block_scout_web/templates/transaction/overview.html.eex:407 #, elixir-autogen, elixir-format msgid "Gas Limit" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:381 #: lib/block_scout_web/templates/transaction/overview.html.eex:381 #, elixir-autogen, elixir-format, fuzzy msgid "Gas Price" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:242 #: lib/block_scout_web/templates/address/overview.html.eex:242 #: lib/block_scout_web/templates/block/_tile.html.eex:73 +#: lib/block_scout_web/templates/block/_tile.html.eex:73 +#: lib/block_scout_web/templates/block/overview.html.eex:180 #: lib/block_scout_web/templates/block/overview.html.eex:180 #, elixir-autogen, elixir-format msgid "Gas Used" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:466 #: lib/block_scout_web/templates/transaction/overview.html.eex:466 #, elixir-autogen, elixir-format, fuzzy msgid "Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:3 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:3 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:18 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:18 #, elixir-autogen, elixir-format msgid "Gas tracker" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:241 #: lib/block_scout_web/templates/address/overview.html.eex:241 #, elixir-autogen, elixir-format msgid "Gas used by the address." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:60 #: lib/block_scout_web/templates/block/overview.html.eex:60 #, elixir-autogen, elixir-format msgid "Genesis Block" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:24 #: lib/block_scout_web/templates/layout/_footer.html.eex:24 #, elixir-autogen, elixir-format msgid "Github" msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:8 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:8 #, elixir-autogen, elixir-format msgid "Go to" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:110 #: lib/block_scout_web/templates/layout/_topnav.html.eex:110 #, elixir-autogen, elixir-format msgid "GraphQL" msgstr "" #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:11 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:11 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:21 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:22 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:38 +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:38 +#: lib/block_scout_web/views/block_view.ex:22 #: lib/block_scout_web/views/block_view.ex:22 #: lib/block_scout_web/views/wei_helper.ex:81 +#: lib/block_scout_web/views/wei_helper.ex:81 #, elixir-autogen, elixir-format msgid "Gwei" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:123 #: lib/block_scout_web/templates/block/overview.html.eex:123 #, elixir-autogen, elixir-format msgid "Hash" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:534 #: lib/block_scout_web/templates/transaction/overview.html.eex:534 #, elixir-autogen, elixir-format msgid "Hex (Default)" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #, elixir-autogen, elixir-format msgid "Holders" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:48 #: lib/block_scout_web/templates/tokens/index.html.eex:48 #, elixir-autogen, elixir-format msgid "Holders Count" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:11 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:11 #, elixir-autogen, elixir-format msgid "However, in general, the" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:19 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:19 #, elixir-autogen, elixir-format msgid "IMPORTANT: This information is a best guess based on similar functions from other verified contracts." msgstr "" #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:42 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:42 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:92 #: lib/block_scout_web/templates/transaction/_tile.html.eex:92 #, elixir-autogen, elixir-format msgid "IN" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:56 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:56 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:48 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:48 #, elixir-autogen, elixir-format msgid "If you enabled optimization during compilation, select yes." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:135 #: lib/block_scout_web/templates/address/overview.html.eex:135 #, elixir-autogen, elixir-format msgid "Implementation" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:134 #: lib/block_scout_web/templates/address/overview.html.eex:134 #, elixir-autogen, elixir-format msgid "Implementation address of the proxy contract." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:3 #, elixir-autogen, elixir-format msgid "Include nightly builds" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:30 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:30 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:43 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:43 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:56 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:56 #, elixir-autogen, elixir-format msgid "Incoming" msgstr "" #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:29 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:29 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:23 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:23 #: lib/block_scout_web/templates/withdrawal/index.html.eex:23 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:23 #, elixir-autogen, elixir-format, fuzzy msgid "Index" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Index position of Transaction in the block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:251 #: lib/block_scout_web/templates/block/overview.html.eex:251 #, elixir-autogen, elixir-format msgid "Index position(s) of referenced stale blocks." msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:6 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:6 #, elixir-autogen, elixir-format msgid "Indexed?" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:3 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:3 #, elixir-autogen, elixir-format msgid "Input" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:242 #: lib/block_scout_web/templates/transaction/overview.html.eex:242 #, elixir-autogen, elixir-format msgid "Interacted With (To)" msgstr "" +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:7 #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:7 #, elixir-autogen, elixir-format msgid "Internal Transaction" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:36 #: lib/block_scout_web/templates/address/_tabs.html.eex:36 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 +#: lib/block_scout_web/views/address_view.ex:353 #: lib/block_scout_web/views/address_view.ex:353 #: lib/block_scout_web/views/transaction_view.ex:550 +#: lib/block_scout_web/views/transaction_view.ex:550 #, elixir-autogen, elixir-format msgid "Internal Transactions" msgstr "" +#: lib/block_scout_web/templates/internal_server_error/index.html.eex:7 #: lib/block_scout_web/templates/internal_server_error/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Internal server error" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:27 #: lib/block_scout_web/views/internal_transaction_view.ex:27 #, elixir-autogen, elixir-format msgid "Invalid" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:16 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:16 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:19 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:19 +#: lib/block_scout_web/views/tokens/overview_view.ex:42 #: lib/block_scout_web/views/tokens/overview_view.ex:42 #, elixir-autogen, elixir-format msgid "Inventory" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:3 #, elixir-autogen, elixir-format msgid "Is Yul contract" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:204 #: lib/block_scout_web/templates/transaction/overview.html.eex:204 #, elixir-autogen, elixir-format, fuzzy msgid "L1 Block" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:501 #: lib/block_scout_web/templates/transaction/overview.html.eex:501 #, elixir-autogen, elixir-format msgid "L1 Fee Scalar" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:490 #: lib/block_scout_web/templates/transaction/overview.html.eex:490 #, elixir-autogen, elixir-format, fuzzy msgid "L1 Gas Price" msgstr "" #: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:479 #: lib/block_scout_web/templates/transaction/overview.html.eex:479 #, elixir-autogen, elixir-format msgid "L1 Gas Used by Transaction" @@ -1640,515 +2124,677 @@ msgstr "" msgid "L1 attr info tx" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:403 #: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format, fuzzy msgid "L2 Gas Limit" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:377 #: lib/block_scout_web/templates/transaction/overview.html.eex:377 #, elixir-autogen, elixir-format, fuzzy msgid "L2 Gas Price" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:462 #: lib/block_scout_web/templates/transaction/overview.html.eex:462 #, elixir-autogen, elixir-format msgid "L2 Gas Used by Transaction" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:13 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:13 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:26 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:26 #, elixir-autogen, elixir-format msgid "Last 24h" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:260 #: lib/block_scout_web/templates/address/overview.html.eex:260 #, elixir-autogen, elixir-format msgid "Last Balance Update" msgstr "" #: lib/block_scout_web/templates/account/api_key/index.html.eex:12 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:12 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Learn more" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:49 #: lib/block_scout_web/templates/layout/app.html.eex:49 #, elixir-autogen, elixir-format msgid "Less than" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_address.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #, elixir-autogen, elixir-format msgid "Library" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:24 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:24 #, elixir-autogen, elixir-format msgid "License Expires" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:10 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:10 #, elixir-autogen, elixir-format msgid "License ID" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:328 #: lib/block_scout_web/templates/transaction/overview.html.eex:328 #, elixir-autogen, elixir-format msgid "List of ERC-1155 tokens created in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:312 #: lib/block_scout_web/templates/transaction/overview.html.eex:312 #, elixir-autogen, elixir-format msgid "List of token burnt in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:295 #: lib/block_scout_web/templates/transaction/overview.html.eex:295 #, elixir-autogen, elixir-format msgid "List of token minted in the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:279 #: lib/block_scout_web/templates/transaction/overview.html.eex:279 #, elixir-autogen, elixir-format msgid "List of token transferred in the transaction." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:18 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:18 #, elixir-autogen, elixir-format msgid "Loading chart..." msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:77 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:77 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:109 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:109 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:35 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:35 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:99 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:99 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:49 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:49 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:45 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:45 #: lib/block_scout_web/templates/address_read_contract/index.html.eex:41 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:41 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:49 #: lib/block_scout_web/templates/address_read_contract/index.html.eex:49 #: lib/block_scout_web/templates/address_read_proxy/index.html.eex:12 +#: lib/block_scout_web/templates/address_read_proxy/index.html.eex:12 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:39 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:39 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:47 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:47 +#: lib/block_scout_web/templates/address_write_proxy/index.html.eex:12 #: lib/block_scout_web/templates/address_write_proxy/index.html.eex:12 #: lib/block_scout_web/templates/tokens/contract/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/contract/index.html.eex:17 #, elixir-autogen, elixir-format msgid "Loading..." msgstr "" +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:2 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:2 #, elixir-autogen, elixir-format msgid "Log Data" msgstr "" +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:140 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:140 #, elixir-autogen, elixir-format msgid "Log Index" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:49 +#: lib/block_scout_web/templates/address/_tabs.html.eex:49 +#: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 #: lib/block_scout_web/templates/transaction_log/index.html.eex:8 +#: lib/block_scout_web/templates/transaction_log/index.html.eex:8 +#: lib/block_scout_web/views/address_view.ex:363 #: lib/block_scout_web/views/address_view.ex:363 #: lib/block_scout_web/views/transaction_view.ex:551 +#: lib/block_scout_web/views/transaction_view.ex:551 #, elixir-autogen, elixir-format msgid "Logs" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:54 #: lib/block_scout_web/templates/layout/_footer.html.eex:54 #, elixir-autogen, elixir-format msgid "Main Networks" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/layout/app.html.eex:50 +#: lib/block_scout_web/templates/layout/app.html.eex:50 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 #: lib/block_scout_web/views/address_view.ex:148 +#: lib/block_scout_web/views/address_view.ex:148 #, elixir-autogen, elixir-format msgid "Market Cap" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:84 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:84 #, elixir-autogen, elixir-format msgid "Market cap" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:417 #: lib/block_scout_web/templates/transaction/overview.html.eex:417 #, elixir-autogen, elixir-format msgid "Max Fee per Gas" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:427 #: lib/block_scout_web/templates/transaction/overview.html.eex:427 #, elixir-autogen, elixir-format msgid "Max Priority Fee per Gas" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:329 #: lib/block_scout_web/views/transaction_view.ex:329 #, elixir-autogen, elixir-format msgid "Max of" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:402 #: lib/block_scout_web/templates/transaction/overview.html.eex:402 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction on L2." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:406 #: lib/block_scout_web/templates/transaction/overview.html.eex:406 #, elixir-autogen, elixir-format, fuzzy msgid "Maximum gas amount approved for the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:416 #: lib/block_scout_web/templates/transaction/overview.html.eex:416 #, elixir-autogen, elixir-format msgid "Maximum total amount per unit of gas a user is willing to pay for a transaction, including base fee and priority fee." msgstr "" #: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/instance/metadata/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:10 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:10 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:75 +#: lib/block_scout_web/views/tokens/instance/overview_view.ex:75 #, elixir-autogen, elixir-format msgid "Metadata" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:5 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:5 #, elixir-autogen, elixir-format msgid "Method Id" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:41 #: lib/block_scout_web/templates/block/_tile.html.eex:41 #: lib/block_scout_web/templates/block/overview.html.eex:98 +#: lib/block_scout_web/templates/block/overview.html.eex:98 +#: lib/block_scout_web/templates/chain/_block.html.eex:16 #: lib/block_scout_web/templates/chain/_block.html.eex:16 #: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:22 +#: lib/block_scout_web/templates/transaction_state/_state_change.html.eex:22 #, elixir-autogen, elixir-format msgid "Miner" msgstr "" #: lib/block_scout_web/views/block_view.ex:63 +#: lib/block_scout_web/views/block_view.ex:63 +#: lib/block_scout_web/views/block_view.ex:68 #: lib/block_scout_web/views/block_view.ex:68 #, elixir-autogen, elixir-format msgid "Miner Reward" msgstr "" +#: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:3 #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:3 #, elixir-autogen, elixir-format msgid "Minimal Proxy Contract for" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:208 #: lib/block_scout_web/templates/block/overview.html.eex:208 #, elixir-autogen, elixir-format msgid "Minimum fee required per unit of gas. Fee adjusts based on network congestion." msgstr "" +#: lib/block_scout_web/templates/transaction/_actions.html.eex:92 #: lib/block_scout_web/templates/transaction/_actions.html.eex:92 #, elixir-autogen, elixir-format msgid "Mint of %{address} <span class=\"text-muted\">To</span> %{to}" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:223 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:223 #, elixir-autogen, elixir-format msgid "Model" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #, elixir-autogen, elixir-format msgid "Module" msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:12 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:12 #, elixir-autogen, elixir-format msgid "More internal transactions have come in" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:46 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:46 +#: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction/index.html.eex:19 #: lib/block_scout_web/templates/transaction/index.html.eex:19 #, elixir-autogen, elixir-format msgid "More transactions have come in" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:63 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:63 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:74 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:74 #, elixir-autogen, elixir-format msgid "Must be set to:" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Must match the name specified in the code. For example, in <span class=\"tooltip-quote\">contract MyContract {..}</span> <strong>MyContract</strong> is the contract name." msgstr "" +#: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #, elixir-autogen, elixir-format msgid "N/A" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:116 #: lib/block_scout_web/templates/block/overview.html.eex:116 #, elixir-autogen, elixir-format msgid "N/A bytes" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:19 +#: lib/block_scout_web/templates/account/api_key/index.html.eex:28 #: lib/block_scout_web/templates/account/api_key/index.html.eex:28 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:28 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:28 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:22 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:22 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:18 +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:18 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:22 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:22 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:22 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:22 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:19 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_name.html.eex:4 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:52 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:59 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:4 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:21 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:21 #, elixir-autogen, elixir-format msgid "Name" msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:20 #: lib/block_scout_web/templates/account/api_key/form.html.eex:20 #, elixir-autogen, elixir-format msgid "Name this API key" msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:14 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:14 #, elixir-autogen, elixir-format msgid "Name this Custom ABI" msgstr "" #: lib/block_scout_web/templates/account/tag_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:19 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:20 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:20 #, elixir-autogen, elixir-format msgid "Name this address" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:19 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:19 #, elixir-autogen, elixir-format msgid "Name this transaction" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:44 #: lib/block_scout_web/templates/address_token/overview.html.eex:44 #, elixir-autogen, elixir-format msgid "Net Worth" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:5 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:5 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:9 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification via Standard input JSON" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:5 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:5 #, elixir-autogen, elixir-format msgid "New Smart Contract Verification via metadata JSON" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Solidity Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Solidity/Yul Smart Contract Verification" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:7 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:7 #, elixir-autogen, elixir-format msgid "New Vyper Smart Contract Verification" msgstr "" #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:80 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:80 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:87 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:87 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:95 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:95 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:111 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:111 #, elixir-autogen, elixir-format msgid "Next" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:9 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:46 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:46 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:38 #, elixir-autogen, elixir-format msgid "No" msgstr "" +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:15 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:15 #, elixir-autogen, elixir-format msgid "No trace entries found." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:198 +#: lib/block_scout_web/templates/block/overview.html.eex:198 +#: lib/block_scout_web/templates/transaction/overview.html.eex:512 #: lib/block_scout_web/templates/transaction/overview.html.eex:512 #, elixir-autogen, elixir-format msgid "Nonce" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 #, elixir-autogen, elixir-format msgid "Not unique Token" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:107 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:107 #, elixir-autogen, elixir-format msgid "Number of accounts holding the token" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:276 #: lib/block_scout_web/templates/address/overview.html.eex:276 #, elixir-autogen, elixir-format msgid "Number of blocks validated by this validator." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:130 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:130 #, elixir-autogen, elixir-format msgid "Number of digits that come after the decimal place when displaying token value" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:187 #: lib/block_scout_web/templates/address/overview.html.eex:187 #, elixir-autogen, elixir-format msgid "Number of transactions related to this address." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:118 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:118 #, elixir-autogen, elixir-format msgid "Number of transfers for the token" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:214 #: lib/block_scout_web/templates/address/overview.html.eex:214 #, elixir-autogen, elixir-format msgid "Number of transfers to/from this address." msgstr "" #: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:40 +#: lib/block_scout_web/templates/internal_transaction/_tile.html.eex:40 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:88 #: lib/block_scout_web/templates/transaction/_tile.html.eex:88 #, elixir-autogen, elixir-format msgid "OUT" msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "Only the first" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:40 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:40 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:32 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:32 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:75 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:75 #, elixir-autogen, elixir-format msgid "Optimization" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:67 #: lib/block_scout_web/templates/address_contract/index.html.eex:67 #, elixir-autogen, elixir-format msgid "Optimization enabled" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:76 #: lib/block_scout_web/templates/address_contract/index.html.eex:76 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:62 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:62 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:54 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:54 #, elixir-autogen, elixir-format msgid "Optimization runs" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:78 #: lib/block_scout_web/templates/layout/_footer.html.eex:78 #, elixir-autogen, elixir-format msgid "Other Explorers" msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:35 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:35 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:48 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:48 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:61 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:61 #, elixir-autogen, elixir-format msgid "Outgoing" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:24 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:24 #, elixir-autogen, elixir-format msgid "Owner Address" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "POA solidity flattener or the" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:19 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:19 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:26 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:26 #, elixir-autogen, elixir-format msgid "POST" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #, elixir-autogen, elixir-format msgid "Page" msgstr "" +#: lib/block_scout_web/templates/page_not_found/index.html.eex:7 #: lib/block_scout_web/templates/page_not_found/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Page not found" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:33 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:33 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:40 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:40 #, elixir-autogen, elixir-format msgid "Parameters" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:139 #: lib/block_scout_web/templates/block/overview.html.eex:139 #, elixir-autogen, elixir-format msgid "Parent Hash" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:63 #: lib/block_scout_web/templates/layout/_topnav.html.eex:63 #: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:417 #: lib/block_scout_web/views/transaction_view.ex:417 #: lib/block_scout_web/views/transaction_view.ex:425 +#: lib/block_scout_web/views/transaction_view.ex:425 #, elixir-autogen, elixir-format msgid "Pending" msgstr "" +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:5 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Pending Transactions" msgstr "" +#: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:9 #: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:9 #: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:13 +#: lib/block_scout_web/templates/address/_custom_view_df_title.html.eex:13 #, elixir-autogen, elixir-format msgid "Play" msgstr "" #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Please confirm your email address to use the My Account feature." msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:68 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:68 #, elixir-autogen, elixir-format msgid "Please select notification methods:" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:24 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:24 #, elixir-autogen, elixir-format msgid "Please select what types of notifications you will receive:" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Position" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:256 #: lib/block_scout_web/templates/block/overview.html.eex:256 #, elixir-autogen, elixir-format msgid "Position %{index}" @@ -2159,1486 +2805,1919 @@ msgstr "" msgid "Post exec tx" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:18 #, elixir-autogen, elixir-format msgid "Potential matches from contract method database:" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:32 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:32 #, elixir-autogen, elixir-format msgid "Potential matches from our contract method database:" msgstr "" +#: lib/block_scout_web/templates/layout/_search.html.eex:27 #: lib/block_scout_web/templates/layout/_search.html.eex:27 #, elixir-autogen, elixir-format msgid "Press / and focus will be moved to the search field" msgstr "" #: lib/block_scout_web/templates/chain/show.html.eex:44 +#: lib/block_scout_web/templates/chain/show.html.eex:44 +#: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 #, elixir-autogen, elixir-format msgid "Price" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:95 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:95 #, elixir-autogen, elixir-format msgid "Price per token on the exchanges" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:376 #: lib/block_scout_web/templates/transaction/overview.html.eex:376 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender on L2. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:380 #: lib/block_scout_web/templates/transaction/overview.html.eex:380 #, elixir-autogen, elixir-format, fuzzy msgid "Price per unit of gas specified by the sender. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:227 +#: lib/block_scout_web/templates/block/overview.html.eex:227 +#: lib/block_scout_web/templates/transaction/overview.html.eex:437 #: lib/block_scout_web/templates/transaction/overview.html.eex:437 #, elixir-autogen, elixir-format msgid "Priority Fee / Tip" msgstr "" +#: lib/block_scout_web/templates/block/_tile.html.eex:62 #: lib/block_scout_web/templates/block/_tile.html.eex:62 #, elixir-autogen, elixir-format msgid "Priority Fees" msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:4 #: lib/block_scout_web/templates/account/common/_nav.html.eex:4 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:14 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:14 #, elixir-autogen, elixir-format msgid "Profile" msgstr "" #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 +#: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 +#: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 #, elixir-autogen, elixir-format msgid "QR Code" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #, elixir-autogen, elixir-format msgid "Query" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:115 #: lib/block_scout_web/templates/layout/_topnav.html.eex:115 #, elixir-autogen, elixir-format msgid "RPC" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:523 #: lib/block_scout_web/templates/transaction/overview.html.eex:523 #, elixir-autogen, elixir-format msgid "Raw Input" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 +#: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 #: lib/block_scout_web/views/transaction_view.ex:552 +#: lib/block_scout_web/views/transaction_view.ex:552 #, elixir-autogen, elixir-format msgid "Raw Trace" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:81 +#: lib/block_scout_web/templates/address/_tabs.html.eex:81 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 #: lib/block_scout_web/views/address_view.ex:357 +#: lib/block_scout_web/views/address_view.ex:357 +#: lib/block_scout_web/views/tokens/overview_view.ex:41 #: lib/block_scout_web/views/tokens/overview_view.ex:41 #, elixir-autogen, elixir-format msgid "Read Contract" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:88 +#: lib/block_scout_web/templates/address/_tabs.html.eex:88 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 #: lib/block_scout_web/views/address_view.ex:358 +#: lib/block_scout_web/views/address_view.ex:358 #, elixir-autogen, elixir-format msgid "Read Proxy" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "Records" msgstr "" +#: lib/block_scout_web/templates/account/api_key/row.html.eex:13 #: lib/block_scout_web/templates/account/api_key/row.html.eex:13 #: lib/block_scout_web/templates/account/custom_abi/row.html.eex:13 +#: lib/block_scout_web/templates/account/custom_abi/row.html.eex:13 #, elixir-autogen, elixir-format msgid "Remove" msgstr "" +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:77 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:77 #, elixir-autogen, elixir-format msgid "Remove from Watch list" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:155 #, elixir-autogen, elixir-format msgid "Request URL" msgstr "" +#: lib/block_scout_web/templates/error422/index.html.eex:7 #: lib/block_scout_web/templates/error422/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Request cannot be processed" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Resend verification email" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #, elixir-autogen, elixir-format msgid "Reset" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:173 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:173 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:134 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:134 #, elixir-autogen, elixir-format msgid "Response Body" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:185 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:185 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:147 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:147 #, elixir-autogen, elixir-format msgid "Responses" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:98 #: lib/block_scout_web/templates/transaction/overview.html.eex:98 #, elixir-autogen, elixir-format msgid "Result" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:138 #: lib/block_scout_web/templates/transaction/overview.html.eex:138 #, elixir-autogen, elixir-format msgid "Revert reason" msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:52 +#: lib/block_scout_web/templates/block/_tile.html.eex:52 +#: lib/block_scout_web/templates/chain/_block.html.eex:27 #: lib/block_scout_web/templates/chain/_block.html.eex:27 #: lib/block_scout_web/views/internal_transaction_view.ex:34 +#: lib/block_scout_web/views/internal_transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Reward" msgstr "" +#: lib/block_scout_web/templates/admin/dashboard/index.html.eex:21 #: lib/block_scout_web/templates/admin/dashboard/index.html.eex:21 #, elixir-autogen, elixir-format msgid "Run" msgstr "" #: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#: lib/block_scout_web/templates/account/api_key/form.html.eex:26 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:31 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:31 #: lib/block_scout_web/templates/account/tag_address/form.html.eex:25 +#: lib/block_scout_web/templates/account/tag_address/form.html.eex:25 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:25 +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:25 +#: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:83 #: lib/block_scout_web/templates/account/watchlist_address/form.html.eex:83 #, elixir-autogen, elixir-format msgid "Save" msgstr "" #: lib/block_scout_web/templates/address_logs/index.html.eex:16 +#: lib/block_scout_web/templates/address_logs/index.html.eex:16 +#: lib/block_scout_web/templates/layout/_search.html.eex:34 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format msgid "Search" msgstr "" +#: lib/block_scout_web/templates/search/results.html.eex:17 #: lib/block_scout_web/templates/search/results.html.eex:17 #, elixir-autogen, elixir-format msgid "Search Results" msgstr "" +#: lib/block_scout_web/templates/layout/_search.html.eex:3 #: lib/block_scout_web/templates/layout/_search.html.eex:3 #, elixir-autogen, elixir-format msgid "Search by address, token symbol name, transaction hash, or block number" msgstr "" +#: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:47 #: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:47 #, elixir-autogen, elixir-format msgid "Search tokens" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:19 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:19 #, elixir-autogen, elixir-format msgid "Select Yes if you want to verify Yul contract." msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:19 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:19 #, elixir-autogen, elixir-format msgid "Select yes if you want to show nightly builds." msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:33 #: lib/block_scout_web/views/internal_transaction_view.ex:33 #, elixir-autogen, elixir-format msgid "Self-Destruct" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 #, elixir-autogen, elixir-format msgid "Server Response" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:7 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:7 #, elixir-autogen, elixir-format msgid "Show" msgstr "" +#: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:11 #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:11 #, elixir-autogen, elixir-format msgid "Show QR Code" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:52 #: lib/block_scout_web/templates/address_token/overview.html.eex:52 #, elixir-autogen, elixir-format msgid "Shows the current" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:59 #: lib/block_scout_web/templates/address_token/overview.html.eex:59 #, elixir-autogen, elixir-format msgid "Shows the tokens held in the address (includes ERC-20, ERC-721 and ERC-1155)." msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:66 #: lib/block_scout_web/templates/address_token/overview.html.eex:66 #, elixir-autogen, elixir-format msgid "Shows the total CRC balance in the address." msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:45 #: lib/block_scout_web/templates/address_token/overview.html.eex:45 #, elixir-autogen, elixir-format msgid "Shows total assets held in the address" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #, elixir-autogen, elixir-format msgid "Sign in" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #, elixir-autogen, elixir-format msgid "Sign out" msgstr "" +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:11 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:11 #, elixir-autogen, elixir-format msgid "Signed in as " msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:114 #: lib/block_scout_web/templates/block/overview.html.eex:114 #, elixir-autogen, elixir-format msgid "Size" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:113 #: lib/block_scout_web/templates/block/overview.html.eex:113 #, elixir-autogen, elixir-format msgid "Size of the block in bytes." msgstr "" +#: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #: lib/block_scout_web/templates/chain/gas_price_oracle_legend_item.html.eex:20 #, elixir-autogen, elixir-format msgid "Slow" msgstr "" #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/views/verified_contracts_view.ex:10 +#: lib/block_scout_web/views/verified_contracts_view.ex:10 #, elixir-autogen, elixir-format msgid "Solidity" msgstr "" #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:30 +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:30 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_logs/index.html.eex:23 +#: lib/block_scout_web/templates/address_logs/index.html.eex:23 +#: lib/block_scout_web/templates/address_token/index.html.eex:60 #: lib/block_scout_web/templates/address_token/index.html.eex:60 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:58 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:58 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_validation/index.html.eex:20 +#: lib/block_scout_web/templates/address_validation/index.html.eex:20 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 +#: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:23 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:23 +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:23 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:23 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:22 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:22 +#: lib/block_scout_web/templates/transaction/index.html.eex:25 #: lib/block_scout_web/templates/transaction/index.html.eex:25 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_log/index.html.eex:15 #: lib/block_scout_web/templates/transaction_log/index.html.eex:15 #: lib/block_scout_web/templates/transaction_state/index.html.eex:13 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:13 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:14 +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:14 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:51 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:51 #: lib/block_scout_web/templates/withdrawal/index.html.eex:14 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Something went wrong, click to reload." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:227 #: lib/block_scout_web/templates/chain/show.html.eex:227 #, elixir-autogen, elixir-format msgid "Something went wrong, click to retry." msgstr "" +#: lib/block_scout_web/templates/transaction/not_found.html.eex:6 #: lib/block_scout_web/templates/transaction/not_found.html.eex:6 #, elixir-autogen, elixir-format msgid "Sorry, we are unable to locate this transaction hash" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #, elixir-autogen, elixir-format msgid "Sources *.sol files" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:63 #, elixir-autogen, elixir-format msgid "Sources *.sol or *.yul files" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:14 #, elixir-autogen, elixir-format msgid "Sources and Metadata JSON" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:136 #: lib/block_scout_web/templates/layout/_topnav.html.eex:136 #, elixir-autogen, elixir-format msgid "Stakes" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:24 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:24 #, elixir-autogen, elixir-format msgid "Standard Input JSON" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 +#: lib/block_scout_web/templates/transaction_state/index.html.eex:6 #: lib/block_scout_web/templates/transaction_state/index.html.eex:6 #: lib/block_scout_web/views/transaction_view.ex:553 +#: lib/block_scout_web/views/transaction_view.ex:553 #, elixir-autogen, elixir-format msgid "State changes" msgstr "" +#: lib/block_scout_web/views/internal_transaction_view.ex:26 #: lib/block_scout_web/views/internal_transaction_view.ex:26 #, elixir-autogen, elixir-format msgid "Static Call" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:110 #: lib/block_scout_web/templates/transaction/overview.html.eex:110 #, elixir-autogen, elixir-format msgid "Status" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:41 #: lib/block_scout_web/templates/layout/_footer.html.eex:41 #, elixir-autogen, elixir-format msgid "Submit an Issue" msgstr "" +#: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 #: lib/block_scout_web/views/transaction_view.ex:374 +#: lib/block_scout_web/views/transaction_view.ex:374 #, elixir-autogen, elixir-format msgid "Success" msgstr "" #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21 +#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21 +#: lib/block_scout_web/templates/transaction/_tile.html.eex:52 #: lib/block_scout_web/templates/transaction/_tile.html.eex:52 #, elixir-autogen, elixir-format msgid "TX Fee" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:31 #: lib/block_scout_web/templates/layout/_footer.html.eex:31 #, elixir-autogen, elixir-format msgid "Telegram" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:67 #: lib/block_scout_web/templates/layout/_footer.html.eex:67 #, elixir-autogen, elixir-format msgid "Test Networks" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:9 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_library_first.html.eex:9 #, elixir-autogen, elixir-format msgid "The 0x library address. This can be found in the generated json file or Truffle output (if using truffle)." msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:34 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:26 #, elixir-autogen, elixir-format msgid "The EVM version the contract is written for. If the bytecode does not match the version, we try to verify using the latest EVM version." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:122 #: lib/block_scout_web/templates/block/overview.html.eex:122 #, elixir-autogen, elixir-format msgid "The SHA256 hash of the block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:51 #: lib/block_scout_web/templates/block/overview.html.eex:51 #, elixir-autogen, elixir-format msgid "The block height of a particular block is defined as the number of blocks preceding it in the blockchain." msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:18 #: lib/block_scout_web/templates/transaction_state/index.html.eex:18 #, elixir-autogen, elixir-format msgid "The changes from this transaction have not yet happened since the transaction is still pending." msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:26 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:26 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:18 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:18 #, elixir-autogen, elixir-format msgid "The compiler version is specified in <span class=\"tooltip-quote\">pragma solidity X.X.X</span>. Use the compiler version rather than the nightly build. If using the Solidity compiler, run <span class=\"tooltip-quote\">solc —version</span> to check." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #, elixir-autogen, elixir-format msgid "The fallback function is executed on a call to the contract if none of the other functions match the given function signature, or if no data was supplied at all and there is no receive Ether function. The fallback function always receives data, but in order to also receive Ether it must be marked payable." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:138 #: lib/block_scout_web/templates/block/overview.html.eex:138 #, elixir-autogen, elixir-format msgid "The hash of the block from which this block was generated." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:74 #: lib/block_scout_web/templates/address/overview.html.eex:74 #, elixir-autogen, elixir-format msgid "The name found in the source code of the Contract." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:85 #: lib/block_scout_web/templates/address/overview.html.eex:85 #, elixir-autogen, elixir-format msgid "The name of the validator." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:79 #: lib/block_scout_web/templates/block/overview.html.eex:79 #, elixir-autogen, elixir-format msgid "The number of transactions in the block." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #, elixir-autogen, elixir-format msgid "The receive function is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:137 #: lib/block_scout_web/templates/transaction/overview.html.eex:137 #, elixir-autogen, elixir-format msgid "The revert reason of the transaction." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:109 #: lib/block_scout_web/templates/transaction/overview.html.eex:109 #, elixir-autogen, elixir-format msgid "The status of the transaction: Confirmed or Unconfirmed." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:67 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:67 #, elixir-autogen, elixir-format msgid "The total amount of tokens issued" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:179 #: lib/block_scout_web/templates/block/overview.html.eex:179 #, elixir-autogen, elixir-format msgid "The total gas amount used in the block and its percentage of gas filled in the block." msgstr "" +#: lib/block_scout_web/templates/address_validation/index.html.eex:16 #: lib/block_scout_web/templates/address_validation/index.html.eex:16 #, elixir-autogen, elixir-format msgid "There are no blocks validated by this address." msgstr "" +#: lib/block_scout_web/templates/block/index.html.eex:17 #: lib/block_scout_web/templates/block/index.html.eex:17 #, elixir-autogen, elixir-format msgid "There are no blocks." msgstr "" +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:29 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:29 #, elixir-autogen, elixir-format msgid "There are no holders for this Token." msgstr "" +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:54 #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:54 #, elixir-autogen, elixir-format msgid "There are no internal transactions for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:17 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:17 #, elixir-autogen, elixir-format msgid "There are no internal transactions for this transaction." msgstr "" +#: lib/block_scout_web/templates/address_logs/index.html.eex:28 #: lib/block_scout_web/templates/address_logs/index.html.eex:28 #, elixir-autogen, elixir-format msgid "There are no logs for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_log/index.html.eex:20 #: lib/block_scout_web/templates/transaction_log/index.html.eex:20 #, elixir-autogen, elixir-format msgid "There are no logs for this transaction." msgstr "" +#: lib/block_scout_web/templates/pending_transaction/index.html.eex:22 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:22 #, elixir-autogen, elixir-format msgid "There are no pending transactions." msgstr "" +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:53 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:53 #, elixir-autogen, elixir-format msgid "There are no token transfers for this address." msgstr "" +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:19 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:19 #, elixir-autogen, elixir-format msgid "There are no token transfers for this transaction" msgstr "" +#: lib/block_scout_web/templates/address_token/index.html.eex:65 #: lib/block_scout_web/templates/address_token/index.html.eex:65 #, elixir-autogen, elixir-format msgid "There are no tokens for this address." msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:28 #: lib/block_scout_web/templates/tokens/inventory/index.html.eex:28 #, elixir-autogen, elixir-format msgid "There are no tokens." msgstr "" +#: lib/block_scout_web/templates/address_transaction/index.html.eex:55 #: lib/block_scout_web/templates/address_transaction/index.html.eex:55 #, elixir-autogen, elixir-format msgid "There are no transactions for this address." msgstr "" +#: lib/block_scout_web/templates/block_transaction/index.html.eex:19 #: lib/block_scout_web/templates/block_transaction/index.html.eex:19 #, elixir-autogen, elixir-format msgid "There are no transactions for this block." msgstr "" +#: lib/block_scout_web/templates/transaction/index.html.eex:31 #: lib/block_scout_web/templates/transaction/index.html.eex:31 #, elixir-autogen, elixir-format msgid "There are no transactions." msgstr "" #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:28 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:28 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:28 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:28 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:27 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:27 #, elixir-autogen, elixir-format msgid "There are no transfers for this Token." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:99 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:99 #, elixir-autogen, elixir-format msgid "There are no verified contracts." msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:54 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:54 #, elixir-autogen, elixir-format msgid "There are no withdrawals for this address." msgstr "" +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:45 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:45 #, elixir-autogen, elixir-format msgid "There are no withdrawals for this block." msgstr "" +#: lib/block_scout_web/templates/withdrawal/index.html.eex:53 #: lib/block_scout_web/templates/withdrawal/index.html.eex:53 #, elixir-autogen, elixir-format msgid "There are no withdrawals." msgstr "" +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:35 #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:35 #, elixir-autogen, elixir-format msgid "There is no coin history for this address." msgstr "" #: lib/block_scout_web/templates/address_coin_balance/index.html.eex:21 +#: lib/block_scout_web/templates/address_coin_balance/index.html.eex:21 +#: lib/block_scout_web/templates/chain/show.html.eex:9 #: lib/block_scout_web/templates/chain/show.html.eex:9 #, elixir-autogen, elixir-format msgid "There was a problem loading the chart." msgstr "" +#: lib/block_scout_web/templates/api_docs/index.html.eex:6 #: lib/block_scout_web/templates/api_docs/index.html.eex:6 #, elixir-autogen, elixir-format msgid "This API is provided for developers transitioning their applications from Etherscan to BlockScout. It supports GET and POST requests." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:7 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:7 #, elixir-autogen, elixir-format msgid "This API is provided to support some rpc methods in the exact format specified for ethereum nodes, which can be found " msgstr "" +#: lib/block_scout_web/views/block_transaction_view.ex:15 #: lib/block_scout_web/views/block_transaction_view.ex:15 #, elixir-autogen, elixir-format msgid "This block has not been processed yet." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:47 #: lib/block_scout_web/templates/address_contract/index.html.eex:47 #, elixir-autogen, elixir-format msgid "This contract has been partially verified via Sourcify." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:51 #: lib/block_scout_web/templates/address_contract/index.html.eex:51 #, elixir-autogen, elixir-format msgid "This contract has been verified via Sourcify." msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:10 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:10 #, elixir-autogen, elixir-format msgid "This is useful to allow sending requests to blockscout without having to change anything about the request." msgstr "" +#: lib/block_scout_web/templates/page_not_found/index.html.eex:8 #: lib/block_scout_web/templates/page_not_found/index.html.eex:8 #, elixir-autogen, elixir-format msgid "This page is no longer explorable! If you are lost, use the search bar to find what you are looking for." msgstr "" +#: lib/block_scout_web/templates/transaction_state/index.html.eex:22 #: lib/block_scout_web/templates/transaction_state/index.html.eex:22 #, elixir-autogen, elixir-format msgid "This transaction hasn't changed state." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:64 #: lib/block_scout_web/templates/transaction/overview.html.eex:64 #, elixir-autogen, elixir-format msgid "This transaction is pending confirmation." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:71 +#: lib/block_scout_web/templates/block/overview.html.eex:71 +#: lib/block_scout_web/templates/transaction/overview.html.eex:180 #: lib/block_scout_web/templates/transaction/overview.html.eex:180 #, elixir-autogen, elixir-format msgid "Timestamp" msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:32 +#: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:32 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:28 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:28 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 #: lib/block_scout_web/templates/transaction/overview.html.eex:244 +#: lib/block_scout_web/templates/transaction/overview.html.eex:244 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/views/address_internal_transaction_view.ex:10 +#: lib/block_scout_web/views/address_internal_transaction_view.ex:10 +#: lib/block_scout_web/views/address_token_transfer_view.ex:10 #: lib/block_scout_web/views/address_token_transfer_view.ex:10 #: lib/block_scout_web/views/address_transaction_view.ex:10 +#: lib/block_scout_web/views/address_transaction_view.ex:10 #, elixir-autogen, elixir-format msgid "To" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:20 #, elixir-autogen, elixir-format msgid "To have guaranteed accuracy, use the link above to verify the contract's source code." msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:6 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:6 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:8 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:8 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:6 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:6 #, elixir-autogen, elixir-format msgid "To see accurate decoded input data, the contract must be verified." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:18 #: lib/block_scout_web/templates/layout/_topnav.html.eex:18 #, elixir-autogen, elixir-format msgid "Toggle navigation" msgstr "" #: lib/block_scout_web/templates/address/overview.html.eex:55 +#: lib/block_scout_web/templates/address/overview.html.eex:55 +#: lib/block_scout_web/templates/tokens/index.html.eex:31 #: lib/block_scout_web/templates/tokens/index.html.eex:31 #, elixir-autogen, elixir-format msgid "Token" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 +#: lib/block_scout_web/views/transaction_view.ex:486 #: lib/block_scout_web/views/transaction_view.ex:486 #, elixir-autogen, elixir-format msgid "Token Burning" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 +#: lib/block_scout_web/views/transaction_view.ex:487 #: lib/block_scout_web/views/transaction_view.ex:487 #, elixir-autogen, elixir-format msgid "Token Creation" msgstr "" #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:10 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:10 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:34 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:34 #, elixir-autogen, elixir-format msgid "Token Details" msgstr "" #: lib/block_scout_web/templates/tokens/holder/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/holder/index.html.eex:17 +#: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:16 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:16 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:17 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:17 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:11 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:11 #: lib/block_scout_web/views/tokens/overview_view.ex:40 +#: lib/block_scout_web/views/tokens/overview_view.ex:40 #, elixir-autogen, elixir-format msgid "Token Holders" msgstr "" +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:38 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:38 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:37 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:37 #, elixir-autogen, elixir-format msgid "Token ID" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 +#: lib/block_scout_web/views/transaction_view.ex:485 #: lib/block_scout_web/views/transaction_view.ex:485 #, elixir-autogen, elixir-format msgid "Token Minting" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 +#: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 #: lib/block_scout_web/views/transaction_view.ex:488 +#: lib/block_scout_web/views/transaction_view.ex:488 #, elixir-autogen, elixir-format msgid "Token Transfer" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:13 #: lib/block_scout_web/templates/address/_tabs.html.eex:13 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:3 #: lib/block_scout_web/templates/tokens/instance/overview/_tabs.html.eex:3 #: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/transfer/index.html.eex:16 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 +#: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 +#: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 +#: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 +#: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 +#: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 +#: lib/block_scout_web/views/tokens/overview_view.ex:39 #: lib/block_scout_web/views/tokens/overview_view.ex:39 #: lib/block_scout_web/views/transaction_view.ex:549 +#: lib/block_scout_web/views/transaction_view.ex:549 #, elixir-autogen, elixir-format msgid "Token Transfers" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:54 #: lib/block_scout_web/templates/address/overview.html.eex:54 #, elixir-autogen, elixir-format msgid "Token name and symbol." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:142 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:142 #, elixir-autogen, elixir-format msgid "Token type" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:21 #: lib/block_scout_web/templates/address/_tabs.html.eex:21 #: lib/block_scout_web/templates/address/overview.html.eex:177 +#: lib/block_scout_web/templates/address/overview.html.eex:177 +#: lib/block_scout_web/templates/address_token/overview.html.eex:58 #: lib/block_scout_web/templates/address_token/overview.html.eex:58 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 +#: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/tokens/index.html.eex:10 +#: lib/block_scout_web/templates/tokens/index.html.eex:10 +#: lib/block_scout_web/views/address_view.ex:352 #: lib/block_scout_web/views/address_view.ex:352 #, elixir-autogen, elixir-format msgid "Tokens" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:313 #: lib/block_scout_web/templates/transaction/overview.html.eex:313 #, elixir-autogen, elixir-format msgid "Tokens Burnt" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:329 #: lib/block_scout_web/templates/transaction/overview.html.eex:329 #, elixir-autogen, elixir-format msgid "Tokens Created" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:296 #: lib/block_scout_web/templates/transaction/overview.html.eex:296 #, elixir-autogen, elixir-format msgid "Tokens Minted" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:280 #: lib/block_scout_web/templates/transaction/overview.html.eex:280 #, elixir-autogen, elixir-format msgid "Tokens Transferred" msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:13 #: lib/block_scout_web/templates/address/_metatags.html.eex:13 #, elixir-autogen, elixir-format msgid "Top Accounts - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/address_logs/index.html.eex:14 #: lib/block_scout_web/templates/address_logs/index.html.eex:14 #, elixir-autogen, elixir-format msgid "Topic" msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:68 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:68 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:100 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:100 #, elixir-autogen, elixir-format msgid "Topics" msgstr "" #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:9 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:9 +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:22 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:22 #, elixir-autogen, elixir-format msgid "Total" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:170 #: lib/block_scout_web/templates/block/overview.html.eex:170 #, elixir-autogen, elixir-format msgid "Total Difficulty" msgstr "" +#: lib/block_scout_web/templates/tokens/index.html.eex:43 #: lib/block_scout_web/templates/tokens/index.html.eex:43 #, elixir-autogen, elixir-format msgid "Total Supply" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:84 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:84 #, elixir-autogen, elixir-format msgid "Total Supply * Price" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:135 #: lib/block_scout_web/templates/chain/show.html.eex:135 #, elixir-autogen, elixir-format msgid "Total blocks" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:169 #: lib/block_scout_web/templates/block/overview.html.eex:169 #, elixir-autogen, elixir-format msgid "Total difficulty of the chain until this block." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:188 #: lib/block_scout_web/templates/block/overview.html.eex:188 #, elixir-autogen, elixir-format msgid "Total gas limit provided by all transactions in the block." msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:68 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:68 #, elixir-autogen, elixir-format msgid "Total supply" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:360 #: lib/block_scout_web/templates/transaction/overview.html.eex:360 #, elixir-autogen, elixir-format msgid "Total transaction fee." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:114 #: lib/block_scout_web/templates/chain/show.html.eex:114 #, elixir-autogen, elixir-format msgid "Total transactions" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 #: lib/block_scout_web/views/transaction_view.ex:498 +#: lib/block_scout_web/views/transaction_view.ex:498 #, elixir-autogen, elixir-format msgid "Transaction" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:3 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:3 #, elixir-autogen, elixir-format msgid "Transaction %{transaction} - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:11 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:11 #, elixir-autogen, elixir-format msgid "Transaction %{transaction}, %{subnetwork} %{transaction}" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:447 #: lib/block_scout_web/templates/transaction/overview.html.eex:447 #, elixir-autogen, elixir-format msgid "Transaction Burnt Fee" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:50 #: lib/block_scout_web/templates/transaction/overview.html.eex:50 #, elixir-autogen, elixir-format msgid "Transaction Details" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:361 #: lib/block_scout_web/templates/transaction/overview.html.eex:361 #, elixir-autogen, elixir-format msgid "Transaction Fee" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:80 #: lib/block_scout_web/templates/transaction/overview.html.eex:80 #, elixir-autogen, elixir-format msgid "Transaction Hash" msgstr "" +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:2 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:2 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:19 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:19 #, elixir-autogen, elixir-format msgid "Transaction Inputs" msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:13 #: lib/block_scout_web/templates/account/common/_nav.html.eex:13 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:7 +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:17 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:17 #, elixir-autogen, elixir-format msgid "Transaction Tags" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:391 #: lib/block_scout_web/templates/transaction/overview.html.eex:391 #, elixir-autogen, elixir-format msgid "Transaction Type" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:511 #: lib/block_scout_web/templates/transaction/overview.html.eex:511 #, elixir-autogen, elixir-format msgid "Transaction number from the sending address. Each transaction sent from an address increments the nonce by 1." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:390 #: lib/block_scout_web/templates/transaction/overview.html.eex:390 #, elixir-autogen, elixir-format msgid "Transaction type, introduced in EIP-2718." msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:7 #: lib/block_scout_web/templates/address/_tabs.html.eex:7 #: lib/block_scout_web/templates/address/_tile.html.eex:31 +#: lib/block_scout_web/templates/address/_tile.html.eex:31 +#: lib/block_scout_web/templates/address/overview.html.eex:188 #: lib/block_scout_web/templates/address/overview.html.eex:188 #: lib/block_scout_web/templates/address/overview.html.eex:194 +#: lib/block_scout_web/templates/address/overview.html.eex:194 +#: lib/block_scout_web/templates/address/overview.html.eex:202 #: lib/block_scout_web/templates/address/overview.html.eex:202 #: lib/block_scout_web/templates/address_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:13 +#: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/overview.html.eex:80 +#: lib/block_scout_web/templates/block/overview.html.eex:80 +#: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/layout/_topnav.html.eex:49 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:49 +#: lib/block_scout_web/views/address_view.ex:354 #: lib/block_scout_web/views/address_view.ex:354 #, elixir-autogen, elixir-format msgid "Transactions" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:101 #: lib/block_scout_web/templates/address/overview.html.eex:101 #, elixir-autogen, elixir-format msgid "Transactions and address of creation." msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:215 #: lib/block_scout_web/templates/address/overview.html.eex:215 #: lib/block_scout_web/templates/address/overview.html.eex:221 +#: lib/block_scout_web/templates/address/overview.html.eex:221 +#: lib/block_scout_web/templates/address/overview.html.eex:229 #: lib/block_scout_web/templates/address/overview.html.eex:229 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:50 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:50 +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:119 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:119 #, elixir-autogen, elixir-format msgid "Transfers" msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:40 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:40 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:47 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:47 #, elixir-autogen, elixir-format msgid "Try it out" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:3 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:3 #, elixir-autogen, elixir-format msgid "Try to fetch constructor arguments automatically" msgstr "" +#: lib/block_scout_web/templates/layout/_footer.html.eex:27 #: lib/block_scout_web/templates/layout/_footer.html.eex:27 #, elixir-autogen, elixir-format msgid "Twitter" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:53 #: lib/block_scout_web/templates/layout/app.html.eex:53 #, elixir-autogen, elixir-format msgid "Tx/day" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:66 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:66 #, elixir-autogen, elixir-format msgid "Txns" msgstr "" #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:5 +#: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:5 +#: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:22 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:22 #, elixir-autogen, elixir-format msgid "Type" msgstr "" +#: lib/block_scout_web/templates/tokens/overview/_details.html.eex:141 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:141 #, elixir-autogen, elixir-format msgid "Type of the token standard" msgstr "" +#: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:5 #: lib/block_scout_web/templates/visualize_sol2uml/index.html.eex:5 #, elixir-autogen, elixir-format msgid "UML diagram" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:537 #: lib/block_scout_web/templates/transaction/overview.html.eex:537 #, elixir-autogen, elixir-format msgid "UTF-8" msgstr "" +#: lib/block_scout_web/views/block_view.ex:77 #: lib/block_scout_web/views/block_view.ex:77 #, elixir-autogen, elixir-format msgid "Uncle Reward" msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:252 +#: lib/block_scout_web/templates/block/overview.html.eex:252 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:41 #: lib/block_scout_web/templates/layout/_topnav.html.eex:41 #, elixir-autogen, elixir-format msgid "Uncles" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:365 #: lib/block_scout_web/views/transaction_view.ex:365 #, elixir-autogen, elixir-format msgid "Unconfirmed" msgstr "" +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:9 #: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:9 #, elixir-autogen, elixir-format msgid "Unique Token" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:79 #: lib/block_scout_web/templates/transaction/overview.html.eex:79 #, elixir-autogen, elixir-format msgid "Unique character string (TxID) assigned to every verified transaction." msgstr "" +#: lib/block_scout_web/templates/account/api_key/form.html.eex:7 #: lib/block_scout_web/templates/account/api_key/form.html.eex:7 #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 +#: lib/block_scout_web/templates/account/custom_abi/form.html.eex:8 #, elixir-autogen, elixir-format msgid "Update" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:426 #: lib/block_scout_web/templates/transaction/overview.html.eex:426 #, elixir-autogen, elixir-format msgid "User defined maximum fee (tip) per unit of gas paid to validator for transaction prioritization." msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:436 #: lib/block_scout_web/templates/transaction/overview.html.eex:436 #, elixir-autogen, elixir-format msgid "User-defined tip sent to validator for transaction priority/inclusion." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:226 #: lib/block_scout_web/templates/block/overview.html.eex:226 #, elixir-autogen, elixir-format msgid "User-defined tips sent to validator for transaction priority/inclusion." msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:56 #: lib/block_scout_web/templates/layout/_topnav.html.eex:56 #, elixir-autogen, elixir-format msgid "Validated" msgstr "" +#: lib/block_scout_web/templates/transaction/index.html.eex:12 #: lib/block_scout_web/templates/transaction/index.html.eex:12 #, elixir-autogen, elixir-format msgid "Validated Transactions" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:30 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:30 #, elixir-autogen, elixir-format msgid "Validator Creation Date" msgstr "" +#: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:5 #: lib/block_scout_web/templates/address/_validator_metadata_modal.html.eex:5 #, elixir-autogen, elixir-format msgid "Validator Data" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:86 #: lib/block_scout_web/templates/address/overview.html.eex:86 #, elixir-autogen, elixir-format msgid "Validator Name" msgstr "" +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:32 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:26 +#: lib/block_scout_web/templates/block_withdrawal/index.html.eex:26 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:26 #: lib/block_scout_web/templates/withdrawal/index.html.eex:26 #, elixir-autogen, elixir-format, fuzzy msgid "Validator index" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:346 #: lib/block_scout_web/templates/transaction/overview.html.eex:346 #, elixir-autogen, elixir-format msgid "Value" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:345 #: lib/block_scout_web/templates/transaction/overview.html.eex:345 #, elixir-autogen, elixir-format msgid "Value sent in the native token (and USD) if applicable." msgstr "" #: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 +#: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 +#: lib/block_scout_web/templates/address_write_contract/index.html.eex:15 #: lib/block_scout_web/templates/address_write_contract/index.html.eex:15 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:81 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:81 #, elixir-autogen, elixir-format msgid "Verified" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:18 #: lib/block_scout_web/templates/verified_contracts/_stats.html.eex:18 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:6 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:6 #, elixir-autogen, elixir-format msgid "Verified Contracts" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:88 #: lib/block_scout_web/templates/address_contract/index.html.eex:88 #, elixir-autogen, elixir-format msgid "Verified at" msgstr "" +#: lib/block_scout_web/templates/layout/_topnav.html.eex:69 #: lib/block_scout_web/templates/layout/_topnav.html.eex:69 #, elixir-autogen, elixir-format msgid "Verified contracts" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:2 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:2 #, elixir-autogen, elixir-format msgid "Verified contracts - %{subnetwork} Explorer" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:7 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:7 #, elixir-autogen, elixir-format msgid "Verified contracts, %{subnetwork}, %{coin}" msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract/index.html.eex:29 +#: lib/block_scout_web/templates/address_contract/index.html.eex:197 #: lib/block_scout_web/templates/address_contract/index.html.eex:197 #: lib/block_scout_web/templates/address_contract/index.html.eex:228 +#: lib/block_scout_web/templates/address_contract/index.html.eex:228 +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:14 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:14 #, elixir-autogen, elixir-format msgid "Verify & Publish" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 +#: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 +#: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 +#: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 #, elixir-autogen, elixir-format msgid "Verify & publish" msgstr "" +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #, elixir-autogen, elixir-format msgid "Verify the contract " msgstr "" #: lib/block_scout_web/templates/layout/_footer.html.eex:93 +#: lib/block_scout_web/templates/layout/_footer.html.eex:93 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:72 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:72 #, elixir-autogen, elixir-format msgid "Version" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:33 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:33 #, elixir-autogen, elixir-format msgid "Via Sourcify: Sources and metadata JSON file" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:27 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:27 #, elixir-autogen, elixir-format msgid "Via Standard Input JSON" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:22 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:22 #, elixir-autogen, elixir-format msgid "Via flattened source code" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:40 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:40 #, elixir-autogen, elixir-format msgid "Via multi-part files" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:157 #: lib/block_scout_web/templates/chain/show.html.eex:157 #, elixir-autogen, elixir-format msgid "View All Blocks" msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:217 #: lib/block_scout_web/templates/chain/show.html.eex:217 #, elixir-autogen, elixir-format msgid "View All Transactions" msgstr "" #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:16 +#: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:20 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:20 #, elixir-autogen, elixir-format msgid "View Contract" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:73 #: lib/block_scout_web/templates/transaction/_tile.html.eex:73 #, elixir-autogen, elixir-format msgid "View Less Transfers" msgstr "" +#: lib/block_scout_web/templates/transaction/_tile.html.eex:72 #: lib/block_scout_web/templates/transaction/_tile.html.eex:72 #, elixir-autogen, elixir-format msgid "View More Transfers" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:39 #: lib/block_scout_web/templates/block/overview.html.eex:39 #, elixir-autogen, elixir-format msgid "View next block" msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:23 #: lib/block_scout_web/templates/block/overview.html.eex:23 #, elixir-autogen, elixir-format msgid "View previous block" msgstr "" +#: lib/block_scout_web/templates/address/_metatags.html.eex:9 #: lib/block_scout_web/templates/address/_metatags.html.eex:9 #, elixir-autogen, elixir-format msgid "View the account balance, transactions, and other data for %{address} on the %{network}" msgstr "" +#: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:8 #: lib/block_scout_web/templates/withdrawal/_metatags.html.eex:8 #, elixir-autogen, elixir-format msgid "View the beacon chain withdrawals on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/block/_metatags.html.eex:10 #: lib/block_scout_web/templates/block/_metatags.html.eex:10 #, elixir-autogen, elixir-format msgid "View the transactions, token transfers, and uncles for block number %{block_number}" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:8 #: lib/block_scout_web/templates/verified_contracts/_metatags.html.eex:8 #, elixir-autogen, elixir-format msgid "View the verified contracts on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/transaction/_metatags.html.eex:10 #: lib/block_scout_web/templates/transaction/_metatags.html.eex:10 #, elixir-autogen, elixir-format msgid "View transaction %{transaction} on %{subnetwork}" msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 +#: lib/block_scout_web/views/verified_contracts_view.ex:11 #: lib/block_scout_web/views/verified_contracts_view.ex:11 #, elixir-autogen, elixir-format msgid "Vyper" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification/new.html.eex:46 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:46 #, elixir-autogen, elixir-format msgid "Vyper contract" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:142 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:142 #, elixir-autogen, elixir-format msgid "WEI" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_pending_contract_write.html.eex:9 #: lib/block_scout_web/templates/smart_contract/_pending_contract_write.html.eex:9 #, elixir-autogen, elixir-format msgid "Waiting for transaction's confirmation..." msgstr "" +#: lib/block_scout_web/templates/chain/show.html.eex:143 #: lib/block_scout_web/templates/chain/show.html.eex:143 #, elixir-autogen, elixir-format msgid "Wallet addresses" msgstr "" +#: lib/block_scout_web/templates/common_components/_changed_bytecode_warning.html.eex:3 #: lib/block_scout_web/templates/common_components/_changed_bytecode_warning.html.eex:3 #, elixir-autogen, elixir-format msgid "Warning! Contract bytecode has been changed and doesn't match the verified one. Therefore, interaction with this smart contract may be risky." msgstr "" +#: lib/block_scout_web/templates/account/common/_nav.html.eex:7 #: lib/block_scout_web/templates/account/common/_nav.html.eex:7 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:7 +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:7 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:15 #: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:15 #, elixir-autogen, elixir-format msgid "Watch list" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "We recommend using flattened code. This is necessary if your code utilizes a library or inherits dependencies. Use the" msgstr "" +#: lib/block_scout_web/views/wei_helper.ex:80 #: lib/block_scout_web/views/wei_helper.ex:80 #, elixir-autogen, elixir-format msgid "Wei" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:29 +#: lib/block_scout_web/templates/address/_tabs.html.eex:29 +#: lib/block_scout_web/templates/address_withdrawal/index.html.eex:13 #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:13 #: lib/block_scout_web/templates/block/_tabs.html.eex:13 +#: lib/block_scout_web/templates/block/_tabs.html.eex:13 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:73 #: lib/block_scout_web/templates/layout/_topnav.html.eex:73 #: lib/block_scout_web/templates/withdrawal/index.html.eex:5 +#: lib/block_scout_web/templates/withdrawal/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Withdrawals" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:106 #, elixir-autogen, elixir-format msgid "Write" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:95 +#: lib/block_scout_web/templates/address/_tabs.html.eex:95 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 #: lib/block_scout_web/views/address_view.ex:359 +#: lib/block_scout_web/views/address_view.ex:359 #, elixir-autogen, elixir-format msgid "Write Contract" msgstr "" +#: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 +#: lib/block_scout_web/views/address_view.ex:360 #: lib/block_scout_web/views/address_view.ex:360 #, elixir-autogen, elixir-format msgid "Write Proxy" msgstr "" #: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_fetch_constructor_args.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_include_nightly_builds_field.html.eex:14 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex:14 +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:43 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:43 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" +#: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #: lib/block_scout_web/templates/account/api_key/index.html.eex:18 #, elixir-autogen, elixir-format msgid "You can create 3 API keys per account." msgstr "" +#: lib/block_scout_web/templates/account/custom_abi/index.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:18 #, elixir-autogen, elixir-format msgid "You can create up to 15 Custom ABIs per account." msgstr "" +#: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have address tags yet" msgstr "" +#: lib/block_scout_web/templates/account/watchlist/show.html.eex:14 #: lib/block_scout_web/templates/account/watchlist/show.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have addresses on you watchlist yet" msgstr "" +#: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:14 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have transaction tags yet" msgstr "" +#: lib/block_scout_web/templates/error422/index.html.eex:8 #: lib/block_scout_web/templates/error422/index.html.eex:8 #, elixir-autogen, elixir-format msgid "Your request contained an error, perhaps a mistyped tx/block/address hash. Try again, and check the developer tools console for more info." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 +#: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 +#: lib/block_scout_web/views/verified_contracts_view.ex:12 #: lib/block_scout_web/views/verified_contracts_view.ex:12 #, elixir-autogen, elixir-format msgid "Yul" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:111 #: lib/block_scout_web/templates/address/overview.html.eex:111 #, elixir-autogen, elixir-format msgid "at" msgstr "" +#: lib/block_scout_web/templates/address_token/overview.html.eex:52 #: lib/block_scout_web/templates/address_token/overview.html.eex:52 #, elixir-autogen, elixir-format msgid "balance of the address" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "burnt for this transaction. Equals Block Base Fee per Gas * Gas Used." msgstr "" +#: lib/block_scout_web/templates/block/overview.html.eex:217 #: lib/block_scout_web/templates/block/overview.html.eex:217 #, elixir-autogen, elixir-format msgid "burnt from transactions included in the block (Base fee (per unit of gas) * Gas Used)." msgstr "" +#: lib/block_scout_web/templates/address_contract/index.html.eex:27 #: lib/block_scout_web/templates/address_contract/index.html.eex:27 #, elixir-autogen, elixir-format msgid "button" msgstr "" +#: lib/block_scout_web/templates/transaction/overview.html.eex:253 #: lib/block_scout_web/templates/transaction/overview.html.eex:253 #, elixir-autogen, elixir-format msgid "created" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:12 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:12 #, elixir-autogen, elixir-format msgid "custom RPC" msgstr "" +#: lib/block_scout_web/templates/address/overview.html.eex:151 #: lib/block_scout_web/templates/address/overview.html.eex:151 #, elixir-autogen, elixir-format msgid "doesn't include ERC20, ERC721, ERC1155 tokens)." msgstr "" +#: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #: lib/block_scout_web/templates/common_components/_rap_pagination_container.html.eex:13 #, elixir-autogen, elixir-format msgid "elements are displayed" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:44 #, elixir-autogen, elixir-format msgid "fallback" msgstr "" +#: lib/block_scout_web/views/address_contract_view.ex:33 #: lib/block_scout_web/views/address_contract_view.ex:33 #, elixir-autogen, elixir-format msgid "false" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:14 #: lib/block_scout_web/templates/csv_export/index.html.eex:14 #, elixir-autogen, elixir-format msgid "for address" msgstr "" #: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 +#: lib/block_scout_web/templates/address_logs/_logs.html.eex:10 +#: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 #: lib/block_scout_web/templates/transaction/_decoded_input.html.eex:12 #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 +#: lib/block_scout_web/templates/transaction_log/_logs.html.eex:10 #, elixir-autogen, elixir-format msgid "here" msgstr "" +#: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:9 #: lib/block_scout_web/templates/api_docs/eth_rpc.html.eex:9 #, elixir-autogen, elixir-format msgid "here." msgstr "" +#: lib/block_scout_web/templates/smart_contract/_function_response.html.eex:3 #: lib/block_scout_web/templates/smart_contract/_function_response.html.eex:3 #, elixir-autogen, elixir-format msgid "method Response" msgstr "" +#: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #: lib/block_scout_web/templates/common_components/_pagination_container.html.eex:41 #, elixir-autogen, elixir-format msgid "of" msgstr "" +#: lib/block_scout_web/templates/layout/app.html.eex:100 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "on sign up. Didn’t receive?" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:16 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:16 #, elixir-autogen, elixir-format msgid "page" msgstr "" +#: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #: lib/block_scout_web/templates/smart_contract/_functions.html.eex:46 #, elixir-autogen, elixir-format msgid "receive" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:58 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:69 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:81 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:81 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:70 +#: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:70 #, elixir-autogen, elixir-format msgid "required" msgstr "" +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:59 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 +#: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:70 #, elixir-autogen, elixir-format msgid "string" msgstr "" +#: lib/block_scout_web/templates/csv_export/index.html.eex:17 #: lib/block_scout_web/templates/csv_export/index.html.eex:17 #, elixir-autogen, elixir-format msgid "to CSV file" msgstr "" +#: lib/block_scout_web/views/address_contract_view.ex:32 #: lib/block_scout_web/views/address_contract_view.ex:32 #, elixir-autogen, elixir-format msgid "true" msgstr "" +#: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:77 #, elixir-autogen, elixir-format msgid "truffle flattener" msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:224 #: lib/block_scout_web/views/transaction_view.ex:224 #, elixir-autogen, elixir-format, fuzzy msgid "ERC-7984 " msgstr "" +#: lib/block_scout_web/views/transaction_view.ex:223 #: lib/block_scout_web/views/transaction_view.ex:223 #, elixir-autogen, elixir-format, fuzzy msgid "ZRC-2 " diff --git a/apps/block_scout_web/test/block_scout_web/chain_test.exs b/apps/block_scout_web/test/block_scout_web/chain_test.exs index 6ba2fcacd50..70ab79defa2 100644 --- a/apps/block_scout_web/test/block_scout_web/chain_test.exs +++ b/apps/block_scout_web/test/block_scout_web/chain_test.exs @@ -3,76 +3,114 @@ defmodule BlockScoutWeb.ChainTest do use Explorer.DataCase alias Explorer.Chain.{Address, Block, Transaction, TokenTransfer} + alias Explorer.PagingOptions alias BlockScoutWeb.Chain - describe "next_page_params/4" do - # https://github.com/blockscout/blockscout/issues/12984 - test "does not return duplicated keys" do - assert Chain.next_page_params([nil], [%{id: 123}], %{"id" => 178}, false, fn x -> x end) == %{ - items_count: 1, - id: 123 - } + describe "paginate_list/3" do + test "returns all items and nil when list is shorter than page_size" do + paging_options = %PagingOptions{page_size: 11} + items = Enum.to_list(1..5) + + assert {^items, nil} = Chain.paginate_list(items, %{}, paging_options) + end + + test "splits list and generates next_page_params when list has overflow" do + paging_options = %PagingOptions{page_size: 3} + items = [%{id: 3}, %{id: 2}, %{id: 1}] + + {result, next_page_params} = Chain.paginate_list(items, %{}, paging_options, paging_function: fn item -> item end) + + assert result == [%{id: 3}, %{id: 2}] + assert next_page_params == %{id: 2} + end + + test "returns empty list and nil for empty input" do + paging_options = %PagingOptions{page_size: 11} + assert {[], nil} = Chain.paginate_list([], %{}, paging_options) + end + + test "strips items_count from next_page_params" do + paging_options = %PagingOptions{page_size: 2} + items = [%{id: 2}, %{id: 1}] + + {_result, next_page_params} = + Chain.paginate_list(items, %{items_count: 10}, paging_options, paging_function: fn item -> item end) + + refute Map.has_key?(next_page_params, :items_count) + refute Map.has_key?(next_page_params, "items_count") end end describe "token_transfers_next_page_params/3" do # https://github.com/blockscout/blockscout/issues/12984 test "does not return duplicated keys" do - assert Chain.token_transfers_next_page_params( - [%TokenTransfer{block_number: 1, log_index: 3}], - [%TokenTransfer{block_number: 1, log_index: 2}], - %{ - "block_number" => 5, - "index" => 4 - } - ) == %{ + paging_options = %PagingOptions{page_size: 2} + + {_items, next_page_params} = + Chain.token_transfers_next_page_params( + [ + %TokenTransfer{block_number: 1, log_index: 2}, + %TokenTransfer{block_number: 1, log_index: 3} + ], + %{ + "block_number" => 5, + "index" => 4 + }, + paging_options + ) + + assert next_page_params == %{ block_number: 1, index: 2 } end test "does not return duplicated keys with batch transfer" do - assert Chain.token_transfers_next_page_params( - [ - %TokenTransfer{ - block_number: 1, - log_index: 2, - block_hash: "0x123", - transaction_hash: "0x456", - index_in_batch: 1 - } - ], - [ - %TokenTransfer{ - block_number: 1, - log_index: 2, - block_hash: "0xabc", - transaction_hash: "0xdef", - index_in_batch: 3 - }, - %TokenTransfer{ - block_number: 1, - log_index: 2, - block_hash: "0x123", - transaction_hash: "0x456", - index_in_batch: 2 - } - ], - %{ - "block_number" => 5, - "index" => 4, - "batch_log_index" => 3, - "batch_block_hash" => "0x789", - "batch_transaction_hash" => "0xabc", - "index_in_batch" => 2 - } - ) == %{ - :block_number => 1, - :index => 2, - :batch_block_hash => "0x123", - :batch_log_index => 2, - :batch_transaction_hash => "0x456", - :index_in_batch => 2 + paging_options = %PagingOptions{page_size: 3} + + {_items, next_page_params} = + Chain.token_transfers_next_page_params( + [ + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0xabc", + transaction_hash: "0xdef", + index_in_batch: 3 + }, + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0x123", + transaction_hash: "0x456", + index_in_batch: 2 + }, + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0x123", + transaction_hash: "0x456", + index_in_batch: 1 + } + ], + %{ + "block_number" => 5, + "index" => 4, + "batch_log_index" => 3, + "batch_block_hash" => "0x789", + "batch_transaction_hash" => "0xabc", + "index_in_batch" => 2 + }, + paging_options + ) + + assert next_page_params == %{ + block_number: 1, + index: 2, + batch_block_hash: "0x123", + batch_log_index: 2, + batch_transaction_hash: "0x456", + index_in_batch: 2 } end end @@ -131,11 +169,11 @@ defmodule BlockScoutWeb.ChainTest do end end - describe "Jason.encode!" do + describe "Utils.JSON.encode!" do test "correctly encodes decimal values" do val = Decimal.from_float(5.55) - assert "\"5.55\"" == Jason.encode!(val) + assert "\"5.55\"" == Utils.JSON.encode!(val) end end end diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs index 87361d3c607..f67f5f755de 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs @@ -67,7 +67,7 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "names" => %{ Address.checksum(miner.hash) => "miner.eth" } @@ -79,7 +79,7 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "addresses" => %{ Address.checksum(miner.hash) => %{ "tags" => [] diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs index 448a4f616f8..ec2d92306eb 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs @@ -41,6 +41,7 @@ defmodule BlockScoutWeb.V2.WebsocketTest do params: [ %{ block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + block_number: 37, address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", first_topic: first_topic, @@ -48,10 +49,12 @@ defmodule BlockScoutWeb.V2.WebsocketTest do third_topic: third_topic, fourth_topic: nil, index: 0, - transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" + transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 0 }, %{ block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + block_number: 37, address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", first_topic: first_topic, @@ -59,10 +62,12 @@ defmodule BlockScoutWeb.V2.WebsocketTest do third_topic: third_topic, fourth_topic: nil, index: 1, - transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" + transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 0 }, %{ block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + block_number: 37, address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", first_topic: first_topic, @@ -70,7 +75,8 @@ defmodule BlockScoutWeb.V2.WebsocketTest do third_topic: third_topic, fourth_topic: nil, index: 2, - transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" + transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 0 } ], timeout: 5 @@ -302,10 +308,10 @@ defmodule BlockScoutWeb.V2.WebsocketTest do }, :timer.seconds(5) - transaction_1 = transaction_1 |> Jason.encode!() |> Jason.decode!() + transaction_1 = transaction_1 |> Utils.JSON.encode!() |> Utils.JSON.decode!() compare_item(Repo.get_by(Transaction, %{hash: transaction_1["hash"]}), transaction_1) - transaction_2 = transaction_2 |> Jason.encode!() |> Jason.decode!() + transaction_2 = transaction_2 |> Utils.JSON.encode!() |> Utils.JSON.decode!() compare_item(Repo.get_by(Transaction, %{hash: transaction_2["hash"]}), transaction_2) assert_receive %Phoenix.Socket.Message{ @@ -315,10 +321,10 @@ defmodule BlockScoutWeb.V2.WebsocketTest do }, :timer.seconds(5) - transaction_1 = transaction_1 |> Jason.encode!() |> Jason.decode!() + transaction_1 = transaction_1 |> Utils.JSON.encode!() |> Utils.JSON.decode!() compare_item(Repo.get_by(Transaction, %{hash: transaction_1["hash"]}), transaction_1) - transaction_2 = transaction_2 |> Jason.encode!() |> Jason.decode!() + transaction_2 = transaction_2 |> Utils.JSON.encode!() |> Utils.JSON.decode!() compare_item(Repo.get_by(Transaction, %{hash: transaction_2["hash"]}), transaction_2) end @@ -352,7 +358,9 @@ defmodule BlockScoutWeb.V2.WebsocketTest do token_transfers |> Enum.zip(transfers) - |> Enum.each(fn {transfer, json} -> compare_item(transfer, json |> Jason.encode!() |> Jason.decode!()) end) + |> Enum.each(fn {transfer, json} -> + compare_item(transfer, json |> Utils.JSON.encode!() |> Utils.JSON.decode!()) + end) assert_receive %Phoenix.Socket.Message{ payload: %{token_transfers: [_, _, _] = transfers}, @@ -363,7 +371,9 @@ defmodule BlockScoutWeb.V2.WebsocketTest do token_transfers |> Enum.zip(transfers) - |> Enum.each(fn {transfer, json} -> compare_item(transfer, json |> Jason.encode!() |> Jason.decode!()) end) + |> Enum.each(fn {transfer, json} -> + compare_item(transfer, json |> Utils.JSON.encode!() |> Utils.JSON.decode!()) + end) end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs index b26e75f5d89..106e4959b02 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs @@ -34,7 +34,7 @@ defmodule BlockScoutWeb.AddressControllerTest do AddressesCount.consolidate() conn = get(conn, address_path(conn, :index, %{type: "JSON"})) - {:ok, %{"items" => items}} = Poison.decode(conn.resp_body) + {:ok, %{"items" => items}} = Utils.JSON.decode(conn.resp_body) assert Enum.count(items) == Enum.count(address_hashes) end @@ -48,7 +48,7 @@ defmodule BlockScoutWeb.AddressControllerTest do conn = get(conn, address_path(conn, :index, %{type: "JSON"})) - {:ok, %{"items" => [item]}} = Poison.decode(conn.resp_body) + {:ok, %{"items" => [item]}} = Utils.JSON.decode(conn.resp_body) assert String.contains?(item, "POA Wallet") end @@ -73,7 +73,7 @@ defmodule BlockScoutWeb.AddressControllerTest do conn = get(conn, "/address-counters", %{"id" => Address.checksum(address.hash)}) assert conn.status == 200 - {:ok, response} = Jason.decode(conn.resp_body) + {:ok, response} = Utils.JSON.decode(conn.resp_body) assert %{ "transaction_count" => 0, diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs index 229eba81563..32f005356e2 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs @@ -492,8 +492,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do address_internal_transaction_path(BlockScoutWeb.Endpoint, :index, address.hash, %{ block_number: number, index: 11, - transaction_index: transaction_index, - items_count: "50" + transaction_index: transaction_index }) assert expected_response == json_response(conn, 200)["next_page_path"] diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs index 8afa1128093..00f141e12c9 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs @@ -66,7 +66,7 @@ defmodule BlockScoutWeb.AddressTokenControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert json_response(conn, 200) assert Enum.any?(items, fn item -> String.contains?(item, to_string(token1.contract_address_hash)) end) @@ -113,7 +113,7 @@ defmodule BlockScoutWeb.AddressTokenControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.any?(items, fn item -> Enum.any?(second_page_tokens, fn token_name -> String.contains?(item, token_name) end) @@ -202,7 +202,7 @@ defmodule BlockScoutWeb.AddressTokenControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert next_page_path end @@ -216,7 +216,7 @@ defmodule BlockScoutWeb.AddressTokenControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() refute next_page_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs index 02bf36eb1f3..78dc081ca9c 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs @@ -210,8 +210,7 @@ defmodule BlockScoutWeb.AddressTokenTransferControllerTest do Address.checksum(address.hash), Address.checksum(token.contract_address_hash), index: page_last_transfer.index, - block_number: page_last_transfer.block_number, - items_count: "50" + block_number: page_last_transfer.block_number ) assert Map.get(json_response(conn, 200), "next_page_path") == expected_path diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs index 4b904b5c411..8a171dfef2b 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs @@ -3,7 +3,6 @@ defmodule BlockScoutWeb.API.Legacy.BlockControllerTest do use BlockScoutWeb.ConnCase alias BlockScoutWeb.Chain - alias Explorer.Chain.Cache.BlockNumber describe "GET /api/legacy/block/get-block-number-by-time" do test "missing timestamp param", %{conn: conn} do @@ -154,97 +153,4 @@ defmodule BlockScoutWeb.API.Legacy.BlockControllerTest do assert v1_response == v2_response end end - - describe "GET /api/legacy/block/eth-block-number" do - setup do - Supervisor.terminate_child(Explorer.Supervisor, BlockNumber.child_id()) - Supervisor.restart_child(Explorer.Supervisor, BlockNumber.child_id()) - :ok - end - - test "default id (omitted) — returns integer id 1", %{conn: conn} do - insert(:block) - - response = - conn - |> get("/api/legacy/block/eth-block-number") - |> json_response(200) - - assert response["jsonrpc"] == "2.0" - assert is_binary(response["result"]) - assert String.starts_with?(response["result"], "0x") - # When id is omitted the v1 controller defaults to integer 1 - assert response["id"] == 1 - end - - test "integer id (?id=7) — echoed back as string (query strings are strings)", %{conn: conn} do - insert(:block) - - response = - conn - |> get("/api/legacy/block/eth-block-number", %{"id" => "7"}) - |> json_response(200) - - assert response["jsonrpc"] == "2.0" - assert is_binary(response["result"]) - # id=7 comes in as the string "7"; sanitize_id emits it quoted → "7" - assert response["id"] == "7" - end - - test "string id (?id=hello) — echoed back as string", %{conn: conn} do - insert(:block) - - response = - conn - |> get("/api/legacy/block/eth-block-number", %{"id" => "hello"}) - |> json_response(200) - - assert response["jsonrpc"] == "2.0" - assert response["id"] == "hello" - end - - test "empty database — result is \"0x0\"", %{conn: conn} do - # No blocks inserted. BlockNumber.get_max/0 delegates to - # Block.fetch_max_block_number/0 which returns Repo.one(query) || 0, so the - # result is 0, not nil. encode_quantity(0) → "0x0". - response = - conn - |> get("/api/legacy/block/eth-block-number") - |> json_response(200) - - assert response["jsonrpc"] == "2.0" - assert response["result"] == "0x0" - end - - # Parity invariant: response body must be byte-identical to the v1 endpoint. - test "parity with v1 /api?module=block&action=eth_block_number — default id", %{conn: conn} do - insert(:block) - - v1_response = - conn - |> get("/api", %{"module" => "block", "action" => "eth_block_number"}) - |> json_response(200) - - v2_response = - conn - |> get("/api/legacy/block/eth-block-number") - |> json_response(200) - - assert v1_response == v2_response - end - - test "parity with v1 /api?module=block&action=eth_block_number — empty database", %{conn: conn} do - v1_response = - conn - |> get("/api", %{"module" => "block", "action" => "eth_block_number"}) - |> json_response(200) - - v2_response = - conn - |> get("/api/legacy/block/eth-block-number") - |> json_response(200) - - assert v1_response == v2_response - end - end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/eth_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/eth_controller_test.exs new file mode 100644 index 00000000000..9935186fa48 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/eth_controller_test.exs @@ -0,0 +1,627 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.EthControllerTest do + @moduledoc """ + Tests for the JSON-RPC method republication endpoints under `/api/legacy/eth/*`. + + Parity claims against the v1 endpoint `/api/eth-rpc` are valid for + single-request, non-batch inputs only. The legacy endpoints reject array + bodies; the v1 endpoint dispatches batches. + """ + + use BlockScoutWeb.ConnCase, async: false + + import Mox + + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime} + alias Explorer.Chain.Transaction + + setup do + mocked_json_rpc_named_arguments = [ + transport: EthereumJSONRPC.Mox, + transport_options: [] + ] + + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + start_supervised!(AverageBlockTime) + + Indexer.Fetcher.OnDemand.CoinBalance.Supervisor.Case.start_supervised!( + json_rpc_named_arguments: mocked_json_rpc_named_arguments + ) + + start_supervised!(AddressesCount) + + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) + end) + + :ok + end + + defp jsonrpc_body(method, params, id \\ 0) do + %{"jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params} + end + + defp post_json(conn, path, body) do + conn + |> put_req_header("content-type", "application/json") + |> post(path, Utils.JSON.encode!(body)) + end + + describe "CSRF protection" do + # `Phoenix.ConnTest` skips CSRF checks by default, so a route that runs + # `:protect_from_forgery` still passes tests. Turn the check on to make sure + # that API clients without a CSRF token are not rejected with 403. + @csrf_paths [ + {"/api/legacy/eth/eth-call", "eth_call"}, + {"/api/legacy/eth/eth-get-balance", "eth_getBalance"}, + {"/api/legacy/eth/eth-get-storage-at", "eth_getStorageAt"}, + {"/api/legacy/eth/eth-send-raw-transaction", "eth_sendRawTransaction"}, + {"/api/legacy/eth/eth-block-number", "eth_blockNumber"}, + {"/api/legacy/eth/eth-get-logs", "eth_getLogs"} + ] + + for {path, expected_method} <- @csrf_paths do + test "POST #{path} without a CSRF token is not rejected", %{conn: conn} do + path = unquote(path) + expected_method = unquote(expected_method) + + response = + conn + |> put_private(:plug_skip_csrf_protection, false) + |> post_json(path, jsonrpc_body("unexpected_method", [], 7)) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 7 + assert response["error"] =~ "must be `#{expected_method}`" + end + end + end + + describe "API v2 disabled" do + setup do + api_v2_env = Application.get_env(:block_scout_web, BlockScoutWeb.API.V2) + Application.put_env(:block_scout_web, BlockScoutWeb.API.V2, Keyword.put(api_v2_env, :enabled, false)) + + on_exit(fn -> Application.put_env(:block_scout_web, BlockScoutWeb.API.V2, api_v2_env) end) + + :ok + end + + test "POST /api/legacy/eth/eth-block-number returns 404", %{conn: conn} do + response = + conn + |> post_json("/api/legacy/eth/eth-block-number", jsonrpc_body("eth_blockNumber", [], 1)) + |> json_response(404) + + assert response == %{"message" => "API V2 is disabled"} + end + end + + describe "POST /api/legacy/eth/eth-get-balance" do + test "with a valid address that has a balance", %{conn: conn} do + block = insert(:block) + address = insert(:address, fetched_coin_balance: 1, fetched_coin_balance_block_number: block.number) + + response = + conn + |> post("/api/legacy/eth/eth-get-balance", jsonrpc_body("eth_getBalance", [to_string(address.hash)])) + |> json_response(200) + + assert response == %{"jsonrpc" => "2.0", "id" => 0, "result" => "0x1"} + end + + test "with an invalid address — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post("/api/legacy/eth/eth-get-balance", jsonrpc_body("eth_getBalance", ["badHash"])) + |> json_response(200) + + assert response == %{ + "jsonrpc" => "2.0", + "id" => 0, + "error" => %{"code" => -32602, "message" => "Query parameter 'address' is invalid"} + } + end + + test "method mismatch — returns JSON-RPC error envelope with same id", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-get-balance", + jsonrpc_body("eth_call", [%{"to" => "0x0000000000000000000000000000000000000000"}, "latest"], 42) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 42 + assert response["error"] =~ "must be `eth_getBalance`" + end + + test "batch body rejected — JSON-RPC error envelope", %{conn: conn} do + address = insert(:address, fetched_coin_balance: 1, fetched_coin_balance_block_number: 1) + + batch = [ + jsonrpc_body("eth_getBalance", [to_string(address.hash)], 0), + jsonrpc_body("eth_getBalance", [to_string(address.hash)], 1) + ] + + response = + conn + |> post_json("/api/legacy/eth/eth-get-balance", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + + test "parity with v1 /api/eth-rpc — single-request success", %{conn: conn} do + block = insert(:block) + address = insert(:address, fetched_coin_balance: 1, fetched_coin_balance_block_number: block.number) + + body = jsonrpc_body("eth_getBalance", [to_string(address.hash)]) + + v1_response = + conn + |> post("/api/eth-rpc", body) + |> json_response(200) + + legacy_response = + conn + |> post("/api/legacy/eth/eth-get-balance", body) + |> json_response(200) + + assert v1_response == legacy_response + end + + test "parity with v1 /api/eth-rpc — single-request invalid address", %{conn: conn} do + body = jsonrpc_body("eth_getBalance", ["badHash"]) + + v1_response = conn |> post("/api/eth-rpc", body) |> json_response(200) + legacy_response = conn |> post("/api/legacy/eth/eth-get-balance", body) |> json_response(200) + + assert v1_response == legacy_response + end + end + + # The remaining three methods are proxy methods — they short-circuit on + # parameter validation before any upstream JSON-RPC call. We exercise the + # wrapper-specific behaviors (method match, batch rejection, validation + # error envelope shape) without standing up a JSON-RPC node. + + describe "POST /api/legacy/eth/eth-call" do + test "success — forwards to JSON-RPC node and returns the result", %{conn: conn} do + to_address = "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + input = "0xd4aae0c4" + result_hex = "0x0000000000000000000000001dd91b354ebd706ab3ac7c727455c7baa164945a" + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [ + %{ + jsonrpc: "2.0", + id: 0, + method: "eth_call", + params: [%{"to" => ^to_address, "input" => ^input}, "latest"] + } + ], + _options -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: result_hex}]} + end + ) + + response = + conn + |> post( + "/api/legacy/eth/eth-call", + jsonrpc_body("eth_call", [%{"to" => to_address, "input" => input}, "latest"]) + ) + |> json_response(200) + + assert response == %{"jsonrpc" => "2.0", "id" => 0, "result" => result_hex} + end + + test "success — forwards all documented call-object fields to JSON-RPC node", %{conn: conn} do + to_address = "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + from_address = "0x0000000000000000000000000000000000000007" + gas = "0x5208" + gas_price = "0x3b9aca00" + value = "0x0" + input = "0xd4aae0c4" + result_hex = "0x0000000000000000000000001dd91b354ebd706ab3ac7c727455c7baa164945a" + + call_object = %{ + "to" => to_address, + "from" => from_address, + "gas" => gas, + "gasPrice" => gas_price, + "value" => value, + "input" => input + } + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [ + %{ + jsonrpc: "2.0", + id: 0, + method: "eth_call", + params: [ + %{ + "to" => ^to_address, + "from" => ^from_address, + "gas" => ^gas, + "gasPrice" => ^gas_price, + "value" => ^value, + "input" => ^input + }, + "latest" + ] + } + ], + _options -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: result_hex}]} + end + ) + + response = + conn + |> post( + "/api/legacy/eth/eth-call", + jsonrpc_body("eth_call", [call_object, "latest"]) + ) + |> json_response(200) + + assert response == %{"jsonrpc" => "2.0", "id" => 0, "result" => result_hex} + end + + test "method mismatch — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-call", + jsonrpc_body("eth_chainId", [], 1) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert response["error"] =~ "must be `eth_call`" + end + + test "missing required `to` in eth_call object — validation error envelope", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-call", + jsonrpc_body("eth_call", [%{"from" => "0x0000000000000000000000000000000000000001"}, "latest"], 2) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 2 + assert response["error"]["code"] == -32602 + assert response["error"]["message"] =~ ~r/Missed.*to.*address/i + end + + test "batch body rejected", %{conn: conn} do + batch = [jsonrpc_body("eth_call", [%{"to" => "0x0000000000000000000000000000000000000000"}, "latest"], 0)] + + response = + conn + |> post_json("/api/legacy/eth/eth-call", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + end + + describe "POST /api/legacy/eth/eth-get-storage-at" do + test "success — forwards to JSON-RPC node and returns the result", %{conn: conn} do + address = "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + slot = "0x0" + result_hex = "0x0000000000000000000000000000000000000000000000000000000000000000" + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [%{jsonrpc: "2.0", id: id, method: "eth_getStorageAt", params: [^address, ^slot, "latest"]}], _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: result_hex}]} + end + ) + + response = + conn + |> post( + "/api/legacy/eth/eth-get-storage-at", + jsonrpc_body("eth_getStorageAt", [address, slot, "latest"], 4) + ) + |> json_response(200) + + assert response == %{"jsonrpc" => "2.0", "id" => 4, "result" => result_hex} + end + + test "method mismatch — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post("/api/legacy/eth/eth-get-storage-at", jsonrpc_body("eth_blockNumber", [], 3)) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 3 + assert response["error"] =~ "must be `eth_getStorageAt`" + end + + test "invalid address — validation error envelope", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-get-storage-at", + jsonrpc_body("eth_getStorageAt", ["badHash", "0x0", "latest"], 4) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 4 + assert response["error"]["code"] == -32602 + assert response["error"]["message"] =~ "Invalid address" + end + + test "batch body rejected", %{conn: conn} do + batch = [ + jsonrpc_body("eth_getStorageAt", ["0x0000000000000000000000000000000000000000", "0x0", "latest"], 0) + ] + + response = + conn + |> post_json("/api/legacy/eth/eth-get-storage-at", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + + test "parity with v1 /api/eth-rpc — single-request success", %{conn: conn} do + address = "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" + slot = "0x0" + result_hex = "0x0000000000000000000000000000000000000000000000000000000000000000" + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + 2, + fn [%{jsonrpc: "2.0", method: "eth_getStorageAt", params: [^address, ^slot, "latest"]}], _options -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: result_hex}]} + end + ) + + body = jsonrpc_body("eth_getStorageAt", [address, slot, "latest"]) + + v1_response = conn |> post("/api/eth-rpc", body) |> json_response(200) + legacy_response = conn |> post("/api/legacy/eth/eth-get-storage-at", body) |> json_response(200) + + assert v1_response == legacy_response + end + + test "parity with v1 /api/eth-rpc — single-request invalid address", %{conn: conn} do + body = jsonrpc_body("eth_getStorageAt", ["badHash", "0x0", "latest"]) + + v1_response = conn |> post("/api/eth-rpc", body) |> json_response(200) + legacy_response = conn |> post("/api/legacy/eth/eth-get-storage-at", body) |> json_response(200) + + assert v1_response == legacy_response + end + end + + describe "POST /api/legacy/eth/eth-send-raw-transaction" do + test "success — forwards signed transaction to JSON-RPC node and returns the hash", %{conn: conn} do + raw_tx = "0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675" + tx_hash = "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331e" + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [%{jsonrpc: "2.0", id: 0, method: "eth_sendRawTransaction", params: [^raw_tx]}], _options -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: tx_hash}]} + end + ) + + response = + conn + |> post( + "/api/legacy/eth/eth-send-raw-transaction", + jsonrpc_body("eth_sendRawTransaction", [raw_tx]) + ) + |> json_response(200) + + assert response == %{"jsonrpc" => "2.0", "id" => 0, "result" => tx_hash} + end + + test "method mismatch — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post("/api/legacy/eth/eth-send-raw-transaction", jsonrpc_body("eth_blockNumber", [], "abc")) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == "abc" + assert response["error"] =~ "must be `eth_sendRawTransaction`" + end + + test "invalid raw transaction hex — validation error envelope", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-send-raw-transaction", + jsonrpc_body("eth_sendRawTransaction", ["not-hex"], 5) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 5 + assert response["error"]["code"] == -32602 + assert response["error"]["message"] =~ "Invalid hex data" + end + + test "batch body rejected", %{conn: conn} do + batch = [jsonrpc_body("eth_sendRawTransaction", ["0xdead"], 0)] + + response = + conn + |> post_json("/api/legacy/eth/eth-send-raw-transaction", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + end + + describe "POST /api/legacy/eth/eth-block-number" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, BlockNumber.child_id()) + Supervisor.restart_child(Explorer.Supervisor, BlockNumber.child_id()) + :ok + end + + test "returns hex-encoded latest block number", %{conn: conn} do + insert(:block) + + response = + conn + |> post_json("/api/legacy/eth/eth-block-number", %{"jsonrpc" => "2.0", "method" => "eth_blockNumber", "id" => 1}) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert is_binary(response["result"]) + assert String.starts_with?(response["result"], "0x") + end + + test "empty database — result is \"0x0\"", %{conn: conn} do + response = + conn + |> post_json("/api/legacy/eth/eth-block-number", %{"jsonrpc" => "2.0", "method" => "eth_blockNumber", "id" => 1}) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["result"] == "0x0" + end + + test "method mismatch — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post_json("/api/legacy/eth/eth-block-number", jsonrpc_body("eth_chainId", [], 7)) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 7 + assert response["error"] =~ "must be `eth_blockNumber`" + end + + test "batch body rejected — JSON-RPC error envelope", %{conn: conn} do + batch = [%{"jsonrpc" => "2.0", "method" => "eth_blockNumber", "id" => 0}] + + response = + conn + |> post_json("/api/legacy/eth/eth-block-number", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + + test "parity with v1 /api/eth-rpc", %{conn: conn} do + insert(:block) + + body = %{"jsonrpc" => "2.0", "method" => "eth_blockNumber", "id" => 1} + + v1_response = conn |> post("/api/eth-rpc", body) |> json_response(200) + legacy_response = conn |> post_json("/api/legacy/eth/eth-block-number", body) |> json_response(200) + + assert v1_response == legacy_response + end + end + + describe "POST /api/legacy/eth/eth-get-logs" do + test "method mismatch — JSON-RPC error envelope", %{conn: conn} do + response = + conn + |> post( + "/api/legacy/eth/eth-get-logs", + jsonrpc_body("eth_getBalance", ["0x0000000000000000000000000000000000000001"], 3) + ) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 3 + assert response["error"] =~ "must be `eth_getLogs`" + end + + test "batch body rejected — JSON-RPC error envelope", %{conn: conn} do + filter = %{"fromBlock" => "0x1", "toBlock" => "0xa"} + batch = [jsonrpc_body("eth_getLogs", [filter], 0)] + + response = + conn + |> post_json("/api/legacy/eth/eth-get-logs", batch) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["error"] =~ "Batch requests are not supported" + end + + test "success — returns logs matching filter in JSON-RPC 2.0 format", %{conn: conn} do + contract_address = insert(:contract_address) + + %Transaction{block: block} = + transaction = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + insert(:log, + address: contract_address, + transaction: transaction, + block: block, + block_number: block.number + ) + + filter = %{ + "fromBlock" => "0x" <> Integer.to_string(block.number, 16), + "toBlock" => "0x" <> Integer.to_string(block.number, 16), + "address" => to_string(contract_address.hash) + } + + response = + conn + |> post("/api/legacy/eth/eth-get-logs", jsonrpc_body("eth_getLogs", [filter], 1)) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert is_list(response["result"]) + assert length(response["result"]) == 1 + [log_entry] = response["result"] + assert log_entry["address"] == to_string(contract_address.hash) + assert log_entry["transactionHash"] == to_string(transaction.hash) + assert Map.has_key?(log_entry, "blockHash") + assert Map.has_key?(log_entry, "removed") + end + + test "parity with v1 /api/eth-rpc — filter with no results", %{conn: conn} do + filter = %{"fromBlock" => "0x1", "toBlock" => "0xa", "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"} + body = jsonrpc_body("eth_getLogs", [filter], 0) + + v1_response = conn |> post("/api/eth-rpc", body) |> json_response(200) + legacy_response = conn |> post("/api/legacy/eth/eth-get-logs", body) |> json_response(200) + + assert v1_response == legacy_response + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs deleted file mode 100644 index 42a0f79c277..00000000000 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs +++ /dev/null @@ -1,263 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule BlockScoutWeb.API.Legacy.LogsControllerTest do - use BlockScoutWeb.ConnCase - - alias Explorer.Chain.Transaction - - describe "GET /api/legacy/logs/get-logs" do - test "missing fromBlock, toBlock, address, and topic{x}", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs") - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] =~ "Required query parameters missing" - assert Map.has_key?(response, "result") - refute response["result"] - end - - test "missing fromBlock", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "toBlock" => "10", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" - }) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] =~ "fromBlock" - assert Map.has_key?(response, "result") - refute response["result"] - end - - test "missing toBlock", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "5", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" - }) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] =~ "toBlock" - assert Map.has_key?(response, "result") - refute response["result"] - end - - test "missing address and topic{x}", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{"fromBlock" => "5", "toBlock" => "10"}) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] =~ "address and/or topic{x}" - assert Map.has_key?(response, "result") - refute response["result"] - end - - test "invalid fromBlock format", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "abc", - "toBlock" => "10", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" - }) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] =~ "Invalid fromBlock format" - assert Map.has_key?(response, "result") - refute response["result"] - end - - test "no logs found returns empty result array", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "5", - "toBlock" => "10", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" - }) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] == "No logs found" - assert response["result"] == [] - end - - test "fromBlock=latest and toBlock=latest", %{conn: conn} do - contract_address = insert(:contract_address) - - transaction = - %Transaction{block: block} = - :transaction - |> insert(to_address: contract_address) - |> with_block() - - insert(:log, - address: contract_address, - transaction: transaction, - block: block, - block_number: block.number - ) - - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "latest", - "toBlock" => "latest", - "address" => "#{contract_address.hash}" - }) - |> json_response(200) - - assert response["status"] == "1" - assert response["message"] == "OK" - assert is_list(response["result"]) - assert length(response["result"]) == 1 - end - - test "success with logs returned", %{conn: conn} do - contract_address = insert(:contract_address) - - transaction = - %Transaction{block: block} = - :transaction - |> insert(to_address: contract_address) - |> with_block() - - log = - insert(:log, - address: contract_address, - transaction: transaction, - block: block, - block_number: transaction.block_number - ) - - params = %{ - "fromBlock" => "#{block.number}", - "toBlock" => "#{block.number}", - "address" => "#{contract_address.hash}" - } - - response = - conn - |> get("/api/legacy/logs/get-logs", params) - |> json_response(200) - - assert response["status"] == "1" - assert response["message"] == "OK" - assert [found_log] = response["result"] - assert found_log["address"] == "#{contract_address.hash}" - assert found_log["transactionHash"] == "#{transaction.hash}" - assert found_log["blockNumber"] == integer_to_hex(log.block_number) - end - - test "two topics set, required topicA_B_opr missing", %{conn: conn} do - conditions = %{ - ["topic0", "topic1"] => "topic0_1_opr", - ["topic0", "topic2"] => "topic0_2_opr", - ["topic0", "topic3"] => "topic0_3_opr", - ["topic1", "topic2"] => "topic1_2_opr", - ["topic1", "topic3"] => "topic1_3_opr", - ["topic2", "topic3"] => "topic2_3_opr" - } - - for {[key1, key2], expectation} <- conditions do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "5", - "toBlock" => "10", - key1 => "some topic", - key2 => "some other topic" - }) - |> json_response(200) - - assert response["status"] == "0" - assert response["message"] == "Required query parameters missing: #{expectation}" - assert Map.has_key?(response, "result") - refute response["result"] - end - end - - test "four topics set, all six topic*_opr missing", %{conn: conn} do - response = - conn - |> get("/api/legacy/logs/get-logs", %{ - "fromBlock" => "5", - "toBlock" => "10", - "topic0" => "some topic", - "topic1" => "some other topic", - "topic2" => "some extra topic", - "topic3" => "some different topic" - }) - |> json_response(200) - - assert response["status"] == "0" - - assert response["message"] =~ - "Required query parameters missing: " <> - "topic0_1_opr, topic0_2_opr, topic0_3_opr, topic1_2_opr, topic1_3_opr, topic2_3_opr" - - assert Map.has_key?(response, "result") - refute response["result"] - end - - # Parity invariant: response body must be byte-identical to the v1 endpoint. - test "parity with v1 /api?module=logs&action=getLogs — success", %{conn: conn} do - contract_address = insert(:contract_address) - - transaction = - %Transaction{block: block} = - :transaction - |> insert(to_address: contract_address) - |> with_block() - - insert(:log, - address: contract_address, - transaction: transaction, - block: block, - block_number: transaction.block_number - ) - - params = %{ - "fromBlock" => "#{block.number}", - "toBlock" => "#{block.number}", - "address" => "#{contract_address.hash}" - } - - v1_response = - conn - |> get("/api", Map.merge(params, %{"module" => "logs", "action" => "getLogs"})) - |> json_response(200) - - v2_response = - conn - |> get("/api/legacy/logs/get-logs", params) - |> json_response(200) - - assert v1_response == v2_response - end - - test "parity with v1 /api?module=logs&action=getLogs — error (missing params)", %{conn: conn} do - v1_response = - conn - |> get("/api", %{"module" => "logs", "action" => "getLogs"}) - |> json_response(200) - - v2_response = - conn - |> get("/api/legacy/logs/get-logs") - |> json_response(200) - - assert v1_response == v2_response - end - end - - defp integer_to_hex(integer), do: "0x" <> String.downcase(Integer.to_string(integer, 16)) -end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs index 26390a19be8..474687983ab 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs @@ -2693,82 +2693,58 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - if Application.compile_env(:explorer, :chain_type) not in [:rsk, :filecoin] do - test "via on-demand fetcher", %{conn: conn} do - original_config = Application.get_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions) + test "via on-demand fetcher", %{conn: conn} do + original_config = Application.get_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions) - Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, - enabled: true, - storage_period: 0 - ) + Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, + enabled: true, + storage_period: 0 + ) - on_exit(fn -> - Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, original_config) - end) + on_exit(fn -> + Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, original_config) + end) - transaction = :transaction |> insert() |> with_block() + transaction = :transaction |> insert() |> with_block() - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn - [%{id: id, params: _}], _ -> - {:ok, - [ - %{ - id: id, - result: %{ - "type" => "create", - "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", - "to" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", - "value" => "0x0", - "gas" => "0x106f5", - "gasUsed" => "0x106f5", - "input" => - "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033", - "output" => - "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" - } - } - ]} - end) - - Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + mock_contract_creation_trace_fetching(transaction) - expected_result = [ - %{ - "blockNumber" => "#{transaction.block_number}", - "callType" => "", - "contractAddress" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", - "errCode" => "", - "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", - "gas" => "67317", - "gasUsed" => "67317", - "index" => "0", - "input" => "", - "isError" => "0", - "timeStamp" => "#{DateTime.to_unix(transaction.block.timestamp)}", - "to" => "", - "transactionHash" => "#{transaction.hash}", - "type" => "create", - "value" => "0" - } - ] - - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "#{transaction.hash}", - "include_zero_value" => "true" + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "callType" => "", + "contractAddress" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "errCode" => "", + "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", + "gas" => "67317", + "gasUsed" => "67317", + "index" => "0", + "input" => "", + "isError" => "0", + "timeStamp" => "#{DateTime.to_unix(transaction.block.timestamp)}", + "to" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "transactionHash" => "#{transaction.hash}", + "type" => "create", + "value" => "0" } + ] - assert response = - conn - |> get("/api/v1", params) - |> json_response(200) + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}", + "include_zero_value" => "true" + } - assert response["result"] == expected_result - assert response["status"] == "1" - assert response["message"] == "OK" - assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) - end + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end end @@ -5332,6 +5308,86 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do end end + @contract_creation_from "0x117b358218da5a4f647072ddb50ded038ed63d17" + @contract_creation_address "0x205a6b72ce16736c9d87172568a9c0cb9304de0d" + @contract_creation_gas "0x106f5" + @contract_creation_init "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" + @contract_creation_code "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" + + case Application.compile_env(:explorer, :chain_type) do + chain_type when chain_type in [:rsk, :filecoin] -> + # `EthereumJSONRPC.RSK` and `EthereumJSONRPC.Filecoin` variants don't support tracing of a + # single transaction, so the on-demand fetcher falls back to `trace_block` for them + defp mock_contract_creation_trace_fetching(transaction) do + block_number = transaction.block_number + block_quantity = EthereumJSONRPC.integer_to_quantity(block_number) + transaction_hash = to_string(transaction.hash) + transaction_index = transaction.index + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, method: "trace_block", params: [^block_quantity]}], _ -> + {:ok, + [ + %{ + id: id, + result: [ + %{ + "type" => "create", + "subtraces" => 0, + "traceAddress" => [], + "action" => %{ + "from" => @contract_creation_from, + "gas" => @contract_creation_gas, + "value" => "0x0", + "init" => @contract_creation_init + }, + "result" => %{ + "address" => @contract_creation_address, + "gasUsed" => @contract_creation_gas, + "code" => @contract_creation_code + }, + "blockNumber" => block_number, + "transactionHash" => transaction_hash, + "transactionPosition" => transaction_index + } + ] + } + ]} + end) + end + + _ -> + defp mock_contract_creation_trace_fetching(_transaction) do + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, params: _}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "type" => "create", + "from" => @contract_creation_from, + "to" => @contract_creation_address, + "value" => "0x0", + "gas" => @contract_creation_gas, + "gasUsed" => @contract_creation_gas, + "input" => @contract_creation_init, + "output" => @contract_creation_code + } + } + ]} + end) + + original_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, original_config) + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + end + end + defp pending_head_tolerance do Application.get_env(:block_scout_web, AddressController)[:internal_transactions_pending_head_tolerance] end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs index 0d53977bba0..1ad0cf405c9 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs @@ -62,7 +62,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do def result(contract) do %{ - "ABI" => Jason.encode!(contract.abi), + "ABI" => Utils.JSON.encode!(contract.abi), "Address" => to_string(contract.address_hash), "CompilerVersion" => contract.compiler_version, "ContractName" => contract.name, @@ -370,7 +370,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do |> get("/api", params) |> json_response(200) - assert response["result"] == Jason.encode!(contract.abi) + assert response["result"] == Utils.JSON.encode!(contract.abi) assert response["status"] == "1" assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(getabi_schema(), response) @@ -458,7 +458,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do %{ "Address" => to_string(contract.address_hash), "SourceCode" => contract.contract_source_code, - "ABI" => Jason.encode!(contract.abi), + "ABI" => Utils.JSON.encode!(contract.abi), "ContractName" => contract.name, "CompilerVersion" => contract.compiler_version, # The contract's optimization value is true, so the expected value @@ -496,7 +496,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do insert(:smart_contract, name: "Implementation", external_libraries: [], - constructor_arguments: "", + constructor_arguments: nil, abi: [ %{ "type" => "constructor", @@ -681,7 +681,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do %{ "Address" => to_string(proxy_contract.address_hash), "SourceCode" => proxy_contract.contract_source_code, - "ABI" => Jason.encode!(proxy_contract.abi), + "ABI" => Utils.JSON.encode!(proxy_contract.abi), "ContractName" => proxy_contract.name, "CompilerVersion" => proxy_contract.compiler_version, "FileName" => "", @@ -711,7 +711,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do optimization_runs: @optimization_runs, evm_version: "default", constructor_arguments: - "00000000000000000000000008e7592ce0d7ebabf42844b62ee6a878d4e1913e000000000000000000000000e1b6037da5f1d756499e184ca15254a981c92546", + "0x00000000000000000000000008e7592ce0d7ebabf42844b62ee6a878d4e1913e000000000000000000000000e1b6037da5f1d756499e184ca15254a981c92546", contract_code_md5: "123" ) @@ -725,14 +725,14 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do %{ "Address" => to_string(contract.address_hash), "SourceCode" => contract.contract_source_code, - "ABI" => Jason.encode!(contract.abi), + "ABI" => Utils.JSON.encode!(contract.abi), "ContractName" => contract.name, "CompilerVersion" => contract.compiler_version, "OptimizationUsed" => "true", "OptimizationRuns" => @optimization_runs, "EVMVersion" => "default", "ConstructorArguments" => - "00000000000000000000000008e7592ce0d7ebabf42844b62ee6a878d4e1913e000000000000000000000000e1b6037da5f1d756499e184ca15254a981c92546", + "0x00000000000000000000000008e7592ce0d7ebabf42844b62ee6a878d4e1913e000000000000000000000000e1b6037da5f1d756499e184ca15254a981c92546", "FileName" => "", "IsProxy" => "false" } @@ -831,7 +831,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do %{ "Address" => to_string(contract.address_hash), "SourceCode" => contract.contract_source_code, - "ABI" => Jason.encode!(contract.abi), + "ABI" => Utils.JSON.encode!(contract.abi), "ContractName" => contract.name, "CompilerVersion" => contract.compiler_version, "OptimizationUsed" => "true", @@ -955,7 +955,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do # "SourceCode" => # "/**\n* Submitted for verification at blockscout.com on #{verified_contract.inserted_at}\n*/\n" <> # contract_code_info.source_code, - # "ABI" => Jason.encode!(contract_code_info.abi), + # "ABI" => Utils.JSON.encode!(contract_code_info.abi), # "ContractName" => contract_code_info.name, # "CompilerVersion" => contract_code_info.version, # "OptimizationUsed" => "false", @@ -973,7 +973,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do # contract_data = # "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" # |> File.read!() - # |> Jason.decode!() + # |> Utils.JSON.decode!() # |> List.first() # # %{ diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs index 9a727307a9a..626b363420e 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs @@ -72,7 +72,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [%{"address" => "badhash"}])) |> json_response(200) - assert %{"error" => "invalid address"} = response + assert %{"error" => %{"message" => "invalid address"}} = response end test "address with no logs", %{conn: conn, api_params: api_params} do @@ -598,7 +598,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, ["badHash"])) |> json_response(200) - assert %{"error" => "Query parameter 'address' is invalid"} = response + assert %{"error" => %{"message" => "Query parameter 'address' is invalid"}} = response end test "with a valid address that has no balance", %{conn: conn, api_params: api_params} do @@ -609,7 +609,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [to_string(address.hash)])) |> json_response(200) - assert %{"error" => "Balance not found"} = response + assert %{"error" => %{"message" => "Balance not found"}} = response end test "with a valid address that has a balance", %{conn: conn, api_params: api_params} do @@ -635,7 +635,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "earliest"])) |> json_response(200) - assert response["error"] == "Balance not found" + assert response["error"]["message"] == "Balance not found" end test "with a valid address that has an earliest balance", %{conn: conn, api_params: api_params} do @@ -663,7 +663,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "pending"])) |> json_response(200) - assert response["error"] == "Balance not found" + assert response["error"]["message"] == "Balance not found" end test "with a valid address and a pending balance", %{conn: conn, api_params: api_params} do @@ -733,7 +733,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "0xnonsense"])) |> json_response(200) - assert response["error"] == "Query parameter 'block' is invalid" + assert response["error"]["message"] == "Query parameter 'block' is invalid" end test "with a block provided and no balance", %{conn: conn, api_params: api_params} do @@ -746,7 +746,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do |> post("/api/eth-rpc", params(api_params, [to_string(address.hash), "2"])) |> json_response(200) - assert response["error"] == "Balance not found" + assert response["error"]["message"] == "Balance not found" end test "with a batch of requests", %{conn: conn} do @@ -765,7 +765,7 @@ defmodule BlockScoutWeb.API.RPC.EthControllerTest do assert response = conn |> put_req_header("content-type", "application/json") - |> post("/api/eth-rpc", Jason.encode!(params)) + |> post("/api/eth-rpc", Utils.JSON.encode!(params)) |> json_response(200) assert [ diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs index 6a6793216f1..dedd036f41d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs @@ -486,6 +486,47 @@ defmodule BlockScoutWeb.API.RPC.TransactionControllerTest do assert response1["result"]["logs"] != response2["result"]["logs"] end + test "paginates logs with items_count", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + address = insert(:address) + + Enum.each(1..3, fn _ -> + insert(:log, + address: address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + end) + + params = %{ + "module" => "transaction", + "action" => "gettxinfo", + "txhash" => "#{transaction.hash}", + "items_count" => "2" + } + + assert response1 = + conn + |> get("/api", params) + |> json_response(200) + + assert Enum.count(response1["result"]["logs"]) == 2 + assert %{"index" => _} = next_page_params = response1["result"]["next_page_params"] + + assert response2 = + conn + |> get("/api", Map.put(next_page_params, "items_count", "2")) + |> json_response(200) + + assert Enum.count(response2["result"]["logs"]) == 1 + assert is_nil(response2["result"]["next_page_params"]) + end + test "with a txhash with ok status", %{conn: conn} do block = insert(:block) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs index 9ba9c7d5fd7..9e3f9faa097 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs @@ -49,7 +49,7 @@ defmodule BlockScoutWeb.API.V1.HealthControllerTest do "message" => "There are no blocks in the DB." } - decoded_response = request.resp_body |> Jason.decode!() + decoded_response = request.resp_body |> Utils.JSON.decode!() assert decoded_response["metadata"]["blocks"]["healthy"] == false assert decoded_response["metadata"]["blocks"]["error"] == expected_error @@ -83,7 +83,7 @@ defmodule BlockScoutWeb.API.V1.HealthControllerTest do "message" => "There are no new blocks in the DB for the last 3000 mins. Check the healthiness of the JSON RPC archive node or the DB." } - } == Poison.decode!(request.resp_body)["metadata"]["blocks"] + } == Utils.JSON.decode!(request.resp_body)["metadata"]["blocks"] end test "returns ok when last block is not stale", %{conn: conn, current_block_number: current_block_number} do @@ -96,7 +96,7 @@ defmodule BlockScoutWeb.API.V1.HealthControllerTest do request = get(conn, api_health_path(conn, :health)) - result = Poison.decode!(request.resp_body) + result = Utils.JSON.decode!(request.resp_body) assert %{ "latest_block" => %{ @@ -151,7 +151,7 @@ defmodule BlockScoutWeb.API.V1.HealthControllerTest do "message" => "There are no new blocks in the DB for the last 3000 mins. Check the healthiness of the JSON RPC archive node or the DB." } - } == Poison.decode!(request.resp_body)["metadata"]["blocks"] + } == Utils.JSON.decode!(request.resp_body)["metadata"]["blocks"] end defp api_health_path(conn, action) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs index a5a5b6fde67..6760e7680e5 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs @@ -25,7 +25,7 @@ defmodule BlockScoutWeb.API.V1.VerifiedControllerTest do # response = post(conn, api_v1_verified_smart_contract_path(conn, :create), params) # assert response.status == 201 - # assert Jason.decode!(response.resp_body) == %{"status" => "success"} + # assert Utils.JSON.decode!(response.resp_body) == %{"status" => "success"} # end # flaky test @@ -33,7 +33,7 @@ defmodule BlockScoutWeb.API.V1.VerifiedControllerTest do # contract_data = # "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" # |> File.read!() - # |> Jason.decode!() + # |> Utils.JSON.decode!() # |> List.first() # %{ @@ -72,7 +72,7 @@ defmodule BlockScoutWeb.API.V1.VerifiedControllerTest do # response = post(conn, api_v1_verified_smart_contract_path(conn, :create), params_with_external_libraries) # assert response.status == 201 - # assert Jason.decode!(response.resp_body) == %{"status" => "success"} + # assert Utils.JSON.decode!(response.resp_body) == %{"status" => "success"} # end # defp api_v1_verified_smart_contract_path(conn, action) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs index 5e0f0f99dfb..67804782999 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs @@ -3,7 +3,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do use BlockScoutWeb.ConnCase use EthereumJSONRPC.Case, async: false use BlockScoutWeb.ChannelCase - use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] alias ABI.{TypeDecoder, TypeEncoder} alias Explorer.{Chain, Repo, TestHelper} @@ -232,7 +235,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:smart_contract, name: "Implementation", external_libraries: [], - constructor_arguments: "", + constructor_arguments: nil, abi: [ %{ "type" => "constructor", @@ -1171,7 +1174,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "names" => %{ to_string(to_address) => "test.eth" } @@ -1183,7 +1186,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "addresses" => %{ to_string(to_address) => %{ "tags" => [ @@ -2297,6 +2300,65 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(internal_transaction_to, Enum.at(response["items"], 0)) end + test "returns pending status when scope includes pending operations", %{conn: conn} do + address = insert(:address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 1 + assert is_nil(response["meta"]["message"]) + + block = insert(:block) + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + + request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "returns pending status for pending transaction operation", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + from_address: insert(:address) + ) + + request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 1 + assert is_nil(response["meta"]["message"]) + + insert(:pending_transaction_operation, transaction_hash: transaction.hash) + + request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + test "returns gas_limit as 0 for selfdestruct without gas", %{conn: conn} do address = insert(:address) @@ -2396,6 +2458,46 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do check_paginated_response(response, response_2nd_page, internal_transactions_from) end + + test "include_zero_value=false excludes zero-value call internal transactions", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + to_address: address, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + to_address: address, + type: :call, + value: Decimal.new(1) + ) + + request = + get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions", %{"include_zero_value" => "false"}) + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert List.first(response["items"])["index"] == 2 + + request_default = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + assert response_default = json_response(request_default, 200) + assert Enum.count(response_default["items"]) == 2 + end end describe "/addresses/{address_hash}/blocks-validated" do @@ -2630,35 +2732,37 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(acb, acb_json) end - test "get coin balance with internal transaction", %{conn: conn} do - transaction = - :transaction - |> insert() - |> with_block() + if @chain_type != :arc do + test "get coin balance with internal transaction", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() - address = insert(:address) + address = insert(:address) - insert(:internal_transaction, - type: "call", - call_type: "call", - transaction: transaction, - transaction_index: transaction.index, - block: transaction.block, - to_address: address, - value: 123, - block_number: transaction.block_number, - index: 1 - ) + insert(:internal_transaction, + type: "call", + call_type: "call", + transaction: transaction, + transaction_index: transaction.index, + block: transaction.block, + to_address: address, + value: 123, + block_number: transaction.block_number, + index: 1 + ) - insert(:address_coin_balance) - acb = insert(:address_coin_balance, address: address, block_number: transaction.block_number) + insert(:address_coin_balance) + acb = insert(:address_coin_balance, address: address, block_number: transaction.block_number) - request = get(conn, "/api/v2/addresses/#{address.hash}/coin-balance-history") + request = get(conn, "/api/v2/addresses/#{address.hash}/coin-balance-history") - assert %{"items" => [acb_json], "next_page_params" => nil} = json_response(request, 200) - assert acb_json["transaction_hash"] == to_string(transaction.hash) + assert %{"items" => [acb_json], "next_page_params" => nil} = json_response(request, 200) + assert acb_json["transaction_hash"] == to_string(transaction.hash) - compare_item(acb, acb_json) + compare_item(acb, acb_json) + end end test "coin balance history can paginate", %{conn: conn} do @@ -2771,6 +2875,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, transaction: transaction, + transaction_index: transaction.index, index: 1, block: transaction.block, block_number: transaction.block_number, @@ -2799,6 +2904,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:log, transaction: transaction, + transaction_index: transaction.index, index: x, block: transaction.block, block_number: transaction.block_number, @@ -2826,6 +2932,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, transaction: transaction, + transaction_index: transaction.index, index: 1, block: transaction.block, block_number: transaction.block_number, @@ -2858,7 +2965,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "names" => %{ to_string(address) => "test.eth" } @@ -2870,7 +2977,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "addresses" => %{ to_string(address) => %{ "tags" => [ @@ -2930,6 +3037,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, transaction: transaction, + transaction_index: transaction.index, index: 1, block: transaction.block, block_number: transaction.block_number, @@ -2969,13 +3077,13 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Bypass.expect_once(bypass, "POST", "api/v1/addresses:batch_resolve", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) + decoded = Utils.JSON.decode!(body) assert decoded["protocols"] == "ens" Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "names" => %{ to_string(address) => "test.eth" } @@ -2987,7 +3095,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "addresses" => %{ to_string(address) => %{ "tags" => [ @@ -3028,6 +3136,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:log, transaction: transaction, + transaction_index: transaction.index, index: x, block: transaction.block, block_number: transaction.block_number, @@ -3043,6 +3152,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, transaction: transaction, + transaction_index: transaction.index, block: transaction.block, block_number: transaction.block_number, address: address, @@ -3092,7 +3202,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, + block: transaction.block, + block_number: transaction.block_number, transaction: transaction, + transaction_index: transaction.index, first_topic: TestHelper.topic(topic1), second_topic: TestHelper.topic(topic2), third_topic: nil, @@ -3117,7 +3230,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(log, log_from_api) assert not is_nil(log_from_api["decoded"]) - assert log_from_api["decoded"] == %{ + assert Map.drop(log_from_api["decoded"], ["abi"]) == %{ "method_call" => "OptionSettled(uint256 indexed accountId, address option, uint256 subId, int256 amount, int256 value)", "method_id" => "d20a68b2", @@ -3154,6 +3267,9 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do } ] } + + assert log_from_api["decoded"]["abi"]["name"] == "OptionSettled" + assert log_from_api["decoded"]["abi"]["type"] == "event" end test "test corner case, when preload functions face absent smart contract", %{conn: conn} do @@ -3172,7 +3288,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do log = insert(:log, + block: transaction.block, + block_number: transaction.block_number, transaction: transaction, + transaction_index: transaction.index, first_topic: TestHelper.topic(topic1), second_topic: TestHelper.topic(topic2), third_topic: nil, @@ -3223,7 +3342,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do transaction = :transaction |> insert() |> with_block() insert(:log, + block: transaction.block, + block_number: transaction.block_number, transaction: transaction, + transaction_index: transaction.index, first_topic: nil, second_topic: nil, third_topic: nil, @@ -3233,7 +3355,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do ) insert(:log, + block: transaction.block, + block_number: transaction.block_number, transaction: transaction, + transaction_index: transaction.index, first_topic: TestHelper.topic("0x0000000000000000000000000000000000000000000000000000000000005d19"), second_topic: nil, third_topic: nil, @@ -3246,10 +3371,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Bypass.expect_once(bypass, "POST", "/api/v1/abi/events%3Abatch-get", fn conn -> # cspell:enable {:ok, body, conn} = Plug.Conn.read_body(conn) - body = Jason.decode!(body) + body = Utils.JSON.decode!(body) assert Enum.count(body["requests"]) == 1 - Conn.resp(conn, 200, Jason.encode!([])) + Conn.resp(conn, 200, Utils.JSON.encode!([])) end) request = get(conn, "/api/v2/addresses/#{address.hash}/logs") @@ -3259,6 +3384,49 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Bypass.down(bypass) end + + test "includes called method ABI and arguments", %{conn: conn} do + event_abi = %{ + "name" => "Set", + "type" => "event", + "inputs" => [%{"name" => "x", "type" => "uint256", "indexed" => false, "internalType" => "uint256"}], + "anonymous" => false + } + + contract_address = insert(:contract_address) + insert(:smart_contract, address_hash: contract_address.hash, abi: [event_abi]) + + topic1_bytes = ExKeccak.hash_256("Set(uint256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + + log_data = "0x0000000000000000000000000000000000000000000000000000000000000032" + + transaction = :transaction |> insert() |> with_block() + + insert(:log, + transaction: transaction, + transaction_index: transaction.index, + block: transaction.block, + block_number: transaction.block_number, + address: contract_address, + first_topic: TestHelper.topic(topic1), + data: log_data + ) + + request = get(conn, "/api/v2/addresses/#{contract_address.hash}/logs") + + assert response = json_response(request, 200) + assert [log_from_api] = response["items"] + + assert log_from_api["decoded"]["abi"]["name"] == "Set" + assert log_from_api["decoded"]["abi"]["type"] == "event" + + assert log_from_api["decoded"]["abi"]["inputs"] == [ + %{"indexed" => false, "internalType" => "uint256", "name" => "x", "type" => "uint256"} + ] + + refute Map.has_key?(log_from_api, "called_method") + end end describe "/addresses/{address_hash}/tokens" do @@ -3928,22 +4096,191 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end describe "/addresses/{address_hash}/tabs-counters" do + test "token_balances_count excludes scam tokens when hide_scam_addresses is true", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + legit_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-20", + token_id: Enum.random(1..100_000) + ) + + scam_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-20", + token_id: Enum.random(1..100_000) + ) + + insert(:scam_badge_to_address, address_hash: scam_balance.token_contract_address_hash) + + response = + conn + |> get("/api/v2/addresses/#{address.hash}/tabs-counters") + |> json_response(200) + + assert response["token_balances_count"] == 1 + + tokens_response = + conn + |> get("/api/v2/addresses/#{address.hash}/tokens?type=ERC-20") + |> json_response(200) + + assert Enum.count(tokens_response["items"]) == 1 + + assert List.first(tokens_response["items"])["token"]["address_hash"] == + Address.checksum(legit_balance.token_contract_address_hash) + end + + test "token_transfers_count excludes scam tokens when hide_scam_addresses is true", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction) |> with_block() + + legit_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + to_address: address + ) + + scam_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + to_address: address + ) + + insert(:scam_badge_to_address, address_hash: scam_transfer.token_contract_address_hash) + + response = + conn + |> get("/api/v2/addresses/#{address.hash}/tabs-counters") + |> json_response(200) + + assert response["token_transfers_count"] == 1 + + token_transfers_response = + conn + |> get("/api/v2/addresses/#{address.hash}/token-transfers") + |> json_response(200) + + assert Enum.count(token_transfers_response["items"]) == 1 + + assert List.first(token_transfers_response["items"])["token"]["address_hash"] == + Address.checksum(legit_transfer.token_contract_address_hash) + end + + test "token_balances_count includes scam tokens when show_scam_tokens cookie is true even if hide_scam_addresses is true", + %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-20", + token_id: Enum.random(1..100_000) + ) + + scam_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-20", + token_id: Enum.random(1..100_000) + ) + + insert(:scam_badge_to_address, address_hash: scam_balance.token_contract_address_hash) + + response = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/tabs-counters") + |> json_response(200) + + assert response["token_balances_count"] == 2 + end + + test "token_transfers_count includes scam tokens when show_scam_tokens cookie is true even if hide_scam_addresses is true", + %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction) |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + to_address: address + ) + + scam_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + to_address: address + ) + + insert(:scam_badge_to_address, address_hash: scam_transfer.token_contract_address_hash) + + response = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/tabs-counters") + |> json_response(200) + + assert response["token_transfers_count"] == 2 + end + test "get 200 on non existing address", %{conn: conn} do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") response = json_response(request, 200) - assert %{ - "validations_count" => 0, - "transactions_count" => 0, - "token_transfers_count" => 0, - "token_balances_count" => 0, - "logs_count" => 0, - "withdrawals_count" => 0, - "internal_transactions_count" => 0, - "celo_election_rewards_count" => 0 - } = response + expected_response = + %{ + "validations_count" => 0, + "transactions_count" => 0, + "token_transfers_count" => 0, + "token_balances_count" => 0, + "logs_count" => 0, + "withdrawals_count" => 0, + "internal_transactions_count" => 0 + } + |> then(fn expected_response -> + case @chain_identity do + {:optimism, :celo} -> Map.put(expected_response, "celo_election_rewards_count", 0) + _ -> expected_response + end + end) + |> then(fn expected_response -> + if @chain_type == :ethereum do + Map.put(expected_response, "beacon_deposits_count", 0) + else + expected_response + end + end) + + assert expected_response == response end test "get 422 on invalid address", %{conn: conn} do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs index 8e08e654111..c2aa6016b85 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs @@ -116,6 +116,23 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert json_response(request, 200) end + test "items_count sets the page size", %{conn: conn} do + insert_list(3, :transaction) |> with_block() + + request = get(conn, "/api/v2/advanced-filters", %{"items_count" => "2"}) + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 2 + refute is_nil(response["next_page_params"]) + refute Map.has_key?(response["next_page_params"], "items_count") + + request_2nd_page = + get(conn, "/api/v2/advanced-filters", Map.put(response["next_page_params"], "items_count", "2")) + + assert response_2nd_page = json_response(request_2nd_page, 200) + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + end + test "get and paginate advanced filter (transactions split between pages)", %{conn: conn} do first_transaction = :transaction |> insert() |> with_block() insert_list(3, :token_transfer, transaction: first_transaction) @@ -425,7 +442,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert response = json_response(request, 200) assert Enum.all?(response["items"], fn item -> - is_nil(item["to"]) and not is_nil(item["created_contract"]) + not is_nil(item["created_contract"]) end) request_2nd_page = @@ -440,7 +457,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert Enum.count(response_2nd_page["items"]) == 12 assert Enum.all?(response_2nd_page["items"], fn item -> - is_nil(item["to"]) and not is_nil(item["created_contract"]) + not is_nil(item["created_contract"]) end) check_paginated_response( diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs index 38abc7f5ce7..27356b1436f 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs @@ -1016,6 +1016,93 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do check_paginated_response(response, response_2nd_page, internal_transactions) end + + test "returns pending status when block is in pending_block_operations", %{conn: conn} do + block = insert(:block) + + request = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 1 + assert is_nil(response["meta"]["message"]) + + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + + request = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "returns pending status when block has pending transaction operations", %{conn: conn} do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block) + + request = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 1 + assert is_nil(response["meta"]["message"]) + + insert(:pending_transaction_operation, transaction_hash: transaction.hash) + + request = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "include_zero_value=false excludes zero-value call internal transactions", %{conn: conn} do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + request = + get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions", %{"include_zero_value" => "false"}) + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert List.first(response["items"])["index"] == 2 + + request_default = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") + assert response_default = json_response(request_default, 200) + assert Enum.count(response_default["items"]) == 2 + end end if @chain_type == :ethereum do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs index 9deb089cc84..f524752b9ee 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs @@ -7,6 +7,7 @@ defmodule BlockScoutWeb.API.V2.CeloControllerTest do chain_identity: [:explorer, :chain_identity] if @chain_identity == {:optimism, :celo} do + alias Explorer.Chain.Address alias Explorer.Chain.Celo.ElectionReward setup do @@ -78,6 +79,51 @@ defmodule BlockScoutWeb.API.V2.CeloControllerTest do assert item["end_block_number"] == epoch.end_block_number assert item["is_finalized"] == true end + + test "returns epochs ordered by number descending", %{conn: conn} do + for i <- 1..3 do + insert(:celo_epoch, + number: i, + fetched?: true, + start_block_number: (i - 1) * 17_280, + end_block_number: i * 17_280 - 1 + ) + end + + request = get(conn, "/api/v2/celo/epochs") + assert response = json_response(request, 200) + numbers = Enum.map(response["items"], & &1["number"]) + assert numbers == [3, 2, 1] + end + + test "paginates epochs across two pages", %{conn: conn} do + for i <- 1..51 do + insert(:celo_epoch, + number: i, + fetched?: true, + start_block_number: (i - 1) * 17_280, + end_block_number: i * 17_280 - 1 + ) + end + + request = get(conn, "/api/v2/celo/epochs") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + + assert Enum.at(response["items"], 0)["number"] == 51 + assert Enum.at(response["items"], 49)["number"] == 2 + + request_2nd_page = + get(conn, "/api/v2/celo/epochs", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + assert Enum.at(response_2nd_page["items"], 0)["number"] == 1 + end end describe "/api/v2/celo/epochs/:number" do @@ -233,6 +279,67 @@ defmodule BlockScoutWeb.API.V2.CeloControllerTest do request = get(conn, "/api/v2/celo/epochs/1/election-rewards/delegated_payment") assert %{"items" => []} = json_response(request, 200) end + + test "returns reward with account and associated_account info", %{conn: conn} do + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279 + ) + + account_address = insert(:address) + associated_account_address = insert(:address) + + insert(:celo_election_reward, + epoch_number: 1, + type: :voter, + amount: 500, + account_address_hash: account_address.hash, + associated_account_address_hash: associated_account_address.hash + ) + + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/voter") + assert response = json_response(request, 200) + assert [item] = response["items"] + + assert item["amount"] == "500" + assert item["account"]["hash"] == Address.checksum(account_address.hash) + assert item["associated_account"]["hash"] == Address.checksum(associated_account_address.hash) + end + + test "filters rewards by type", %{conn: conn} do + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279 + ) + + for type <- ElectionReward.types() do + account_address = insert(:address) + associated_account_address = insert(:address) + + insert(:celo_election_reward, + epoch_number: 1, + type: type, + amount: 100, + account_address_hash: account_address.hash, + associated_account_address_hash: associated_account_address.hash + ) + end + + for type <- ElectionReward.types() do + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/#{type}") + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + end + end + + test "returns 422 for invalid reward type", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/invalid_type") + assert json_response(request, 422) + end end end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs index f1bda445744..f5531aab9df 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs @@ -53,6 +53,63 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do assert conn.status == 429 end + test "accepts application/csv on token-transfers CSV endpoint", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert(from_address: address) + |> with_block() + + insert(:token_transfer, transaction: transaction, from_address: address, block_number: transaction.block_number) + + {:ok, now} = DateTime.now("Etc/UTC") + + conn = + conn + |> put_req_header("accept", "application/csv") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => DateTime.add(now, -1, :minute) |> DateTime.to_iso8601(), + "to_period" => DateTime.to_iso8601(now) + }) + + assert conn.status == 200 + + assert Enum.any?(get_resp_header(conn, "content-type"), fn type -> + String.contains?(type, "application/csv") + end) + + assert conn.resp_body =~ to_string(transaction.hash) + end + + test "returns 422 when from_period or to_period is omitted", %{conn: conn} do + address = insert(:address) + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => "2023-01-01" + }) + + assert conn.status == 422 + assert %{"errors" => [%{"source" => %{"pointer" => "/to_period"}} | _]} = json_response(conn, 422) + end + + test "returns JSON 404 for unknown address when Accept: application/csv is sent", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/csv") + |> get("/api/v2/addresses/0x000000000000000000000000000000000000dEaD/token-transfers/csv", %{ + "from_period" => "2023-01-01", + "to_period" => "2023-01-02" + }) + + assert conn.status == 404 + + assert Enum.any?(get_resp_header(conn, "content-type"), fn type -> + String.contains?(type, "application/json") + end) + end + test "do not export token transfers to csv after rate limit is reached without recaptcha passed", %{ conn: conn, v2_secret_key: recaptcha_secret_key @@ -65,7 +122,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(%{"success" => false}) + body: Utils.JSON.encode!(%{"success" => false}) }} end ) @@ -159,7 +216,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "success" => true, "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] }) @@ -247,7 +304,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "success" => true, "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] }) @@ -344,7 +401,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "success" => true, "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] }) @@ -694,11 +751,41 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do }) assert conn.status == 202 - body = Jason.decode!(conn.resp_body) + body = Utils.JSON.decode!(conn.resp_body) assert Map.has_key?(body, "request_id") assert is_binary(body["request_id"]) end + test "returns 202 with request_id for token transfers from token export when async enabled", %{conn: conn} do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "TKN") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(1_000) + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/tokens/#{Address.checksum(token.contract_address_hash)}/transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + test "returns 202 with request_id for token holders export when async enabled", %{conn: conn} do token = insert(:token, type: "ERC-20", decimals: 18) @@ -830,6 +917,125 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do end end + describe "GET /api/v2/tokens/:hash/transfers/csv" do + setup do + result = csv_setup() + + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + config = (original_config || []) |> Keyword.put(:async?, false) + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + result + end + + test "exports token transfers to csv in sync mode", %{conn: conn} do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "TKN") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(1_000) + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/tokens/#{Address.checksum(token.contract_address_hash)}/transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body =~ "TxHash" + assert conn.resp_body =~ "TokensTransferred" + end + + test "exports multiple token transfers to csv", %{conn: conn} do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "TKN") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(1_000) + ) + + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(2_000) + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/tokens/#{Address.checksum(token.contract_address_hash)}/transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 + end + + test "returns 404 for non-existent token", %{conn: conn} do + fake_hash = "0x0000000000000000000000000000000000000001" + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/tokens/#{fake_hash}/transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"message" => "Not found"} = json_response(conn, 404) + end + + test "returns 422 for invalid token hash", %{conn: conn} do + conn = get(conn, "/api/v2/tokens/not-a-valid-hash/transfers/csv") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(conn, 422) + end + end + describe "GET logs_csv/2" do setup do csv_setup() @@ -850,6 +1056,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do address: address, index: 0, transaction: transaction, + transaction_index: transaction.index, block: transaction.block, block_number: transaction.block_number ) @@ -881,7 +1088,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "success" => true, "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] }) @@ -900,6 +1107,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do address: address, index: 3, transaction: transaction_1, + transaction_index: transaction_1.index, block: transaction_1.block, block_number: transaction_1.block_number ) @@ -913,6 +1121,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do address: address, index: 1, transaction: transaction_2, + transaction_index: transaction_2.index, block: transaction_2.block, block_number: transaction_2.block_number ) @@ -926,6 +1135,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do address: address, index: 2, transaction: transaction_3, + transaction_index: transaction_3.index, block: transaction_3.block, block_number: transaction_3.block_number ) @@ -969,6 +1179,7 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do address: address, index: 3, transaction: transaction, + transaction_index: transaction.index, block: transaction.block, block_number: transaction.block_number ) @@ -1083,6 +1294,12 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do recaptcha_to_bypass_429: true, bucket_key_prefix: "api/v2/tokens/:param/holders/csv_", isolate_rate_limit?: true + }, + ["api", "v2", "tokens", ":param", "transfers", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/tokens/:param/transfers/csv_", + isolate_rate_limit?: true } } } diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs index ec56b20105d..5a03f7638b5 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs @@ -138,6 +138,15 @@ defmodule BlockScoutWeb.API.V2.ImportControllerTest do id: id, result: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" } + + # the ERC-165 probe, read along with the base metadata of an ERC-20 + # token and reverting on one that does not implement ERC-8056 + %{id: id, method: "eth_call", params: [%{data: _, to: _}, "latest"]} -> + %{ + id: id, + error: %{code: -32015, data: "something", message: "some error"}, + jsonrpc: "2.0" + } end)} end ) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs index 6075d5afe95..2888bdd20d5 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs @@ -22,6 +22,42 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionControllerTest do assert response["next_page_params"] == nil end + test "returns pending status when internal transaction scope is pending", %{conn: conn} do + request = get(conn, "/api/v2/internal-transactions") + assert %{"items" => []} = json_response(request, 200) + + block = insert(:block) + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + + request = get(conn, "/api/v2/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "returns pending status when transaction hash is in pending_transaction_operations", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:pending_transaction_operation, transaction_hash: transaction.hash) + + request = get(conn, "/api/v2/internal-transactions", %{"transaction_hash" => to_string(transaction.hash)}) + + assert response = json_response(request, 200) + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + test "non empty list", %{conn: conn} do tx = :transaction @@ -168,6 +204,40 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionControllerTest do assert Enum.count(response_2nd_page["items"]) == 1 assert response_2nd_page["next_page_params"] == nil end + + test "include_zero_value=false excludes zero-value call internal transactions", %{conn: conn} do + tx = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: tx, + transaction_index: tx.index, + block_number: tx.block_number, + index: 1, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: tx, + transaction_index: tx.index, + block_number: tx.block_number, + index: 2, + type: :call, + value: Decimal.new(1) + ) + + request = get(conn, "/api/v2/internal-transactions", %{"include_zero_value" => "false"}) + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert List.first(response["items"])["index"] == 2 + + request_default = get(conn, "/api/v2/internal-transactions") + assert response_default = json_response(request_default, 200) + assert Enum.count(response_default["items"]) == 2 + end end defp compare_item(%InternalTransaction{} = internal_transaction, json) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs index 896242b3646..e666016a454 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs @@ -27,6 +27,27 @@ defmodule BlockScoutWeb.API.V2.OptimismControllerTest do end end + describe "/optimism/deposits/count" do + if @chain_type == :optimism do + test "returns cached deposits count", %{conn: conn} do + insert_list(5, :op_deposit) + + Explorer.Chain.Cache.Counters.Optimism.DepositsCount.consolidate() + + request = get(conn, "/api/v2/optimism/deposits/count") + assert json_response(request, 200) == 5 + end + + test "returns an integer estimate when the cache is cold", %{conn: conn} do + insert_list(2, :op_deposit) + + request = get(conn, "/api/v2/optimism/deposits/count") + assert count = json_response(request, 200) + assert is_integer(count) + end + end + end + describe "/optimism/interop/messages" do if @chain_type == :optimism do test "handles message with 0x prefixed payload", %{conn: conn} do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs index 1dd356d4ec4..69141635121 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs @@ -131,7 +131,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionControllerTest do "GET", "/api/v1/userOps/#{operation_hash}", fn conn -> - Plug.Conn.resp(conn, 404, Jason.encode!(%{"error" => "Not found"})) + Plug.Conn.resp(conn, 404, Utils.JSON.encode!(%{"error" => "Not found"})) end ) @@ -163,7 +163,9 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionControllerTest do logs = insert_list(51, :token_transfer_log, transaction: transaction, - block: transaction.block + transaction_index: transaction.index, + block: transaction.block, + block_number: transaction.block_number ) for log <- logs do @@ -180,7 +182,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionControllerTest do "GET", "/api/v1/userOps/#{operation_hash}", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(user_op)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(user_op)) end ) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs index a38d5ab33df..89548d05a95 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs @@ -704,8 +704,8 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert conn.params["name"] == name case conn.params["page_token"] do - nil -> Plug.Conn.resp(conn, 200, Jason.encode!(metadata_response_1)) - ^page_token_1 -> Plug.Conn.resp(conn, 200, Jason.encode!(metadata_response_2)) + nil -> Plug.Conn.resp(conn, 200, Utils.JSON.encode!(metadata_response_1)) + ^page_token_1 -> Plug.Conn.resp(conn, 200, Utils.JSON.encode!(metadata_response_2)) _ -> raise "Unexpected page_token" end end @@ -1062,8 +1062,8 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert conn.params["name"] == name case conn.params["page_token"] do - nil -> Plug.Conn.resp(conn, 200, Jason.encode!(metadata_response_1)) - ^page_token_1 -> Plug.Conn.resp(conn, 200, Jason.encode!(metadata_response_2)) + nil -> Plug.Conn.resp(conn, 200, Utils.JSON.encode!(metadata_response_1)) + ^page_token_1 -> Plug.Conn.resp(conn, 200, Utils.JSON.encode!(metadata_response_2)) _ -> raise "Unexpected page_token" end end @@ -1880,6 +1880,74 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do } == json_response(next_page_request, 200) end end + + test "finds ens domain with partial query (without dot)", %{conn: conn} do + bypass = Bypass.open() + bens_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + partial_name = "vitalik" + full_name = "vitalik.eth" + ens_address = insert(:address) + + ens_response = """ + { + "items": [ + { + "id": "0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835", + "name": "#{full_name}", + "resolved_address": { + "hash": "#{to_string(ens_address)}" + }, + "owner": { + "hash": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + }, + "wrapped_owner": null, + "registration_date": "2017-06-18T08:39:14.000Z", + "expiry_date": null, + "protocol": { + "id": "ens", + "short_name": "ENS", + "title": "Ethereum Name Service" + } + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/1/domains%3Alookup", + fn conn -> + assert conn.params["name"] == partial_name + Plug.Conn.resp(conn, 200, ens_response) + end + ) + + request = get(conn, "/api/v2/search?q=#{partial_name}") + assert response = json_response(request, 200) + + assert Enum.at(response["items"], 0)["type"] == "ens_domain" + assert Enum.at(response["items"], 0)["ens_info"]["name"] == full_name + assert Enum.at(response["items"], 0)["address_hash"] == to_string(ens_address) + end end describe "/search/check-redirect" do @@ -1904,8 +1972,6 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do test "finds non-consensus block by hash", %{conn: conn} do %Block{hash: hash} = insert(:block, consensus: false) - conn = get(conn, "/search?q=#{hash}") - hash = to_string(hash) request = get(conn, "/api/v2/search/check-redirect?q=#{hash}") @@ -1978,6 +2044,73 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do %{"redirect" => false, "type" => nil, "parameter" => nil} = json_response(request, 200) end + + test "finds resolved address of ens domain with partial query (without dot)", %{conn: conn} do + bypass = Bypass.open() + bens_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + partial_name = "vitalik" + full_name = "vitalik.eth" + ens_address = insert(:address) + ens_address_hash_string = to_string(ens_address) + + ens_response = """ + { + "items": [ + { + "id": "0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835", + "name": "#{full_name}", + "resolved_address": { + "hash": "#{ens_address_hash_string}" + }, + "owner": { + "hash": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + }, + "wrapped_owner": null, + "registration_date": "2017-06-18T08:39:14.000Z", + "expiry_date": null, + "protocol": { + "id": "ens", + "short_name": "ENS", + "title": "Ethereum Name Service" + } + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/1/domains%3Alookup", + fn conn -> + assert conn.params["name"] == partial_name + Plug.Conn.resp(conn, 200, ens_response) + end + ) + + request = get(conn, "/api/v2/search/check-redirect?q=#{partial_name}") + + assert %{"redirect" => true, "type" => "address", "parameter" => ^ens_address_hash_string} = + json_response(request, 200) + end end describe "/search/quick" do @@ -2162,7 +2295,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do fn conn -> assert conn.params["name"] == name - Plug.Conn.resp(conn, 200, Jason.encode!(metadata_response)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(metadata_response)) end ) @@ -2248,6 +2381,75 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end) end + test "finds ens domain with partial query (without dot)", %{conn: conn} do + bypass = Bypass.open() + bens_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + partial_name = "vitalik" + full_name = "vitalik.eth" + ens_address = insert(:address) + + ens_response = """ + { + "items": [ + { + "id": "0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835", + "name": "#{full_name}", + "resolved_address": { + "hash": "#{to_string(ens_address)}" + }, + "owner": { + "hash": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + }, + "wrapped_owner": null, + "registration_date": "2017-06-18T08:39:14.000Z", + "expiry_date": null, + "protocol": { + "id": "ens", + "short_name": "ENS", + "title": "Ethereum Name Service" + } + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/1/domains%3Alookup", + fn conn -> + assert conn.params["name"] == partial_name + Plug.Conn.resp(conn, 200, ens_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{partial_name}") + response = json_response(request, 200) + + ens_result = response |> Enum.find(fn item -> item["type"] == "ens_domain" end) + assert ens_result != nil + assert ens_result["ens_info"]["name"] == full_name + assert ens_result["address_hash"] == to_string(ens_address) + end + if @chain_type == :default do test "finds a TAC operation", %{conn: conn} do bypass = Bypass.open() diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/shibarium_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/shibarium_controller_test.exs new file mode 100644 index 00000000000..a74bbccc302 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/shibarium_controller_test.exs @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ShibariumControllerTest do + use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + alias Explorer.Chain.Shibarium.Bridge + + if @chain_type == :shibarium do + describe "/api/v2/shibarium/deposits" do + test "returns empty list when no deposits exist", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/deposits") + assert response = json_response(request, 200) + + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns deposits with next_page_params and paginates", %{conn: conn} do + deposits = insert_list(51, :address) |> Enum.map(&insert_shibarium_deposit(&1.hash)) + + request = get(conn, "/api/v2/shibarium/deposits") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + assert response["next_page_params"]["block_number"] != nil + refute response["next_page_params"]["items_count"] + + compare_deposit(Enum.at(deposits, 50), Enum.at(response["items"], 0)) + compare_deposit(Enum.at(deposits, 1), Enum.at(response["items"], 49)) + + request_2nd_page = get(conn, "/api/v2/shibarium/deposits", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + + compare_deposit(Enum.at(deposits, 0), Enum.at(response_2nd_page["items"], 0)) + end + + test "renders user as Address even when no address row exists", %{conn: conn} do + orphan_hash = address_hash() + insert_shibarium_deposit(orphan_hash) + + request = get(conn, "/api/v2/shibarium/deposits") + assert response = json_response(request, 200) + + [item] = response["items"] + assert is_map(item["user"]) + assert String.downcase(item["user"]["hash"]) == String.downcase(to_string(orphan_hash)) + end + + test "rejects malformed block_number with 422", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/deposits", %{"block_number" => "not-a-number"}) + assert json_response(request, 422) + end + end + + describe "/api/v2/shibarium/deposits/count" do + test "returns 0 when no deposits exist", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/deposits/count") + assert json_response(request, 200) == 0 + end + + test "returns deposits count", %{conn: conn} do + insert_list(3, :address) |> Enum.each(&insert_shibarium_deposit(&1.hash)) + + request = get(conn, "/api/v2/shibarium/deposits/count") + assert response = json_response(request, 200) + assert is_integer(response) and response >= 0 + end + + test "rejects unexpected query parameter with 422", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/deposits/count", %{"unknown" => "x"}) + assert json_response(request, 422) + end + end + + describe "/api/v2/shibarium/withdrawals" do + test "returns empty list when no withdrawals exist", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/withdrawals") + assert response = json_response(request, 200) + + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns withdrawals with next_page_params and paginates", %{conn: conn} do + withdrawals = insert_list(51, :address) |> Enum.map(&insert_shibarium_withdrawal(&1.hash)) + + request = get(conn, "/api/v2/shibarium/withdrawals") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + assert response["next_page_params"]["block_number"] != nil + refute response["next_page_params"]["items_count"] + + compare_withdrawal(Enum.at(withdrawals, 50), Enum.at(response["items"], 0)) + compare_withdrawal(Enum.at(withdrawals, 1), Enum.at(response["items"], 49)) + + request_2nd_page = get(conn, "/api/v2/shibarium/withdrawals", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + + compare_withdrawal(Enum.at(withdrawals, 0), Enum.at(response_2nd_page["items"], 0)) + end + + test "renders user as Address even when no address row exists", %{conn: conn} do + orphan_hash = address_hash() + insert_shibarium_withdrawal(orphan_hash) + + request = get(conn, "/api/v2/shibarium/withdrawals") + assert response = json_response(request, 200) + + [item] = response["items"] + assert is_map(item["user"]) + assert String.downcase(item["user"]["hash"]) == String.downcase(to_string(orphan_hash)) + end + + test "rejects malformed block_number with 422", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/withdrawals", %{"block_number" => "not-a-number"}) + assert json_response(request, 422) + end + end + + describe "/api/v2/shibarium/withdrawals/count" do + test "returns 0 when no withdrawals exist", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/withdrawals/count") + assert json_response(request, 200) == 0 + end + + test "returns withdrawals count", %{conn: conn} do + insert_list(3, :address) |> Enum.each(&insert_shibarium_withdrawal(&1.hash)) + + request = get(conn, "/api/v2/shibarium/withdrawals/count") + assert response = json_response(request, 200) + assert is_integer(response) and response >= 0 + end + + test "rejects unexpected query parameter with 422", %{conn: conn} do + request = get(conn, "/api/v2/shibarium/withdrawals/count", %{"unknown" => "x"}) + assert json_response(request, 422) + end + end + + defp insert_shibarium_withdrawal(user_hash) do + l2_block_number = block_number() + + {:ok, bridge} = + %Bridge{} + |> Bridge.changeset(%{ + user: user_hash, + operation_hash: transaction_hash(), + operation_type: :withdrawal, + token_type: :bone, + l1_transaction_hash: transaction_hash(), + l1_block_number: block_number(), + l2_transaction_hash: transaction_hash(), + l2_block_number: l2_block_number, + timestamp: DateTime.utc_now() + }) + |> Explorer.Repo.insert() + + bridge + end + + defp compare_withdrawal(%Bridge{} = item, json) do + assert item.l2_block_number == json["l2_block_number"] + assert to_string(item.l2_transaction_hash) == json["l2_transaction_hash"] + assert to_string(item.l1_transaction_hash) == json["l1_transaction_hash"] + assert to_string(item.user) == json["user"]["hash"] |> String.downcase() + assert DateTime.to_iso8601(item.timestamp) == json["timestamp"] + end + + defp insert_shibarium_deposit(user_hash) do + l1_block_number = block_number() + + {:ok, bridge} = + %Bridge{} + |> Bridge.changeset(%{ + user: user_hash, + operation_hash: transaction_hash(), + operation_type: :deposit, + token_type: :bone, + l1_transaction_hash: transaction_hash(), + l1_block_number: l1_block_number, + l2_transaction_hash: transaction_hash(), + l2_block_number: block_number(), + timestamp: DateTime.utc_now() + }) + |> Explorer.Repo.insert() + + bridge + end + + defp compare_deposit(%Bridge{} = item, json) do + assert item.l1_block_number == json["l1_block_number"] + assert to_string(item.l1_transaction_hash) == json["l1_transaction_hash"] + assert to_string(item.l2_transaction_hash) == json["l2_transaction_hash"] + assert to_string(item.user) == json["user"]["hash"] |> String.downcase() + assert DateTime.to_iso8601(item.timestamp) == json["timestamp"] + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs index d2693fac567..2f8a85bda76 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs @@ -35,6 +35,15 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do :ok end + defp expected_constructor_args(nil), do: nil + defp expected_constructor_args(""), do: "" + defp expected_constructor_args("0x" <> _ = constructor_arguments), do: constructor_arguments + + defp expected_constructor_args(constructor_arguments) when is_binary(constructor_arguments), + do: "0x" <> constructor_arguments + + defp expected_constructor_args(constructor_arguments), do: to_string(constructor_arguments) + describe "/smart-contracts/{address_hash}" do setup do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) @@ -204,7 +213,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "additional_sources" => [], "compiler_settings" => target_contract.compiler_settings, "external_libraries" => [%{"name" => "ABC", "address_hash" => Address.checksum(lib_address)}], - "constructor_args" => target_contract.constructor_arguments, + "constructor_args" => expected_constructor_args(target_contract.constructor_arguments), "decoded_constructor_args" => nil, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", @@ -311,7 +320,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "additional_sources" => [], "compiler_settings" => target_contract.compiler_settings, "external_libraries" => [%{"name" => "ABC", "address_hash" => Address.checksum(lib_address)}], - "constructor_args" => target_contract.constructor_arguments, + "constructor_args" => expected_constructor_args(target_contract.constructor_arguments), "decoded_constructor_args" => [ ["0x0000000000000000000000000000000000000000", %{"name" => "_proxyStorage", "type" => "address"}], [ @@ -498,7 +507,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do implementation_contract = insert(:smart_contract, external_libraries: [], - constructor_arguments: "", + constructor_arguments: nil, abi: [ %{ "type" => "constructor", @@ -623,7 +632,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "additional_sources" => [], "compiler_settings" => target_contract.compiler_settings, "external_libraries" => target_contract.external_libraries, - "constructor_args" => target_contract.constructor_arguments, + "constructor_args" => expected_constructor_args(target_contract.constructor_arguments), "decoded_constructor_args" => nil, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", @@ -662,7 +671,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do implementation_contract = insert(:smart_contract, external_libraries: [], - constructor_arguments: "", + constructor_arguments: nil, abi: [ %{ "type" => "constructor", @@ -979,13 +988,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert %{"is_partially_verified" => true} = response assert %{"is_fully_verified" => false} = response - smart_contract = Jason.decode!(eth_bytecode_response)["ethBytecodeDbSources"] |> List.first() - assert response["compiler_settings"] == Jason.decode!(smart_contract["compilerSettings"]) + smart_contract = Utils.JSON.decode!(eth_bytecode_response)["ethBytecodeDbSources"] |> List.first() + assert response["compiler_settings"] == Utils.JSON.decode!(smart_contract["compilerSettings"]) assert response["name"] == smart_contract["contractName"] assert response["compiler_version"] == smart_contract["compilerVersion"] assert response["file_path"] == smart_contract["fileName"] - assert response["constructor_args"] == smart_contract["constructorArguments"] - assert response["abi"] == Jason.decode!(smart_contract["abi"]) + assert response["constructor_args"] == expected_constructor_args(smart_contract["constructorArguments"]) + assert response["abi"] == Utils.JSON.decode!(smart_contract["abi"]) assert response["decoded_constructor_args"] == [ [ @@ -1157,7 +1166,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") assert response = json_response(request, 200) - smart_contract = Jason.decode!(eth_bytecode_response)["sourcifySources"] |> List.first() + smart_contract = Utils.JSON.decode!(eth_bytecode_response)["sourcifySources"] |> List.first() assert %{"is_verified" => true} = response assert %{"is_verified_via_eth_bytecode_db" => true} = response assert %{"is_verified_via_sourcify" => true} = response @@ -1337,13 +1346,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert %{"is_verified_via_verifier_alliance" => true} = response assert %{"is_fully_verified" => false} = response - smart_contract = Jason.decode!(eth_bytecode_response)["allianceSources"] |> List.first() - assert response["compiler_settings"] == Jason.decode!(smart_contract["compilerSettings"]) + smart_contract = Utils.JSON.decode!(eth_bytecode_response)["allianceSources"] |> List.first() + assert response["compiler_settings"] == Utils.JSON.decode!(smart_contract["compilerSettings"]) assert response["name"] == smart_contract["contractName"] assert response["compiler_version"] == smart_contract["compilerVersion"] assert response["file_path"] == smart_contract["fileName"] - assert response["constructor_args"] == smart_contract["constructorArguments"] - assert response["abi"] == Jason.decode!(smart_contract["abi"]) + assert response["constructor_args"] == expected_constructor_args(smart_contract["constructorArguments"]) + assert response["abi"] == Utils.JSON.decode!(smart_contract["abi"]) assert response["source_code"] == smart_contract["sourceFiles"][smart_contract["fileName"]] @@ -1456,13 +1465,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert %{"is_verified_via_verifier_alliance" => false} = response assert %{"is_fully_verified" => true} = response - smart_contract = Jason.decode!(eth_bytecode_response)["sourcifySources"] |> List.first() - assert response["compiler_settings"] == Jason.decode!(smart_contract["compilerSettings"]) + smart_contract = Utils.JSON.decode!(eth_bytecode_response)["sourcifySources"] |> List.first() + assert response["compiler_settings"] == Utils.JSON.decode!(smart_contract["compilerSettings"]) assert response["name"] == smart_contract["contractName"] assert response["compiler_version"] == smart_contract["compilerVersion"] assert response["file_path"] == smart_contract["fileName"] - assert response["constructor_args"] == smart_contract["constructorArguments"] - assert response["abi"] == Jason.decode!(smart_contract["abi"]) + assert response["constructor_args"] == expected_constructor_args(smart_contract["constructorArguments"]) + assert response["abi"] == Utils.JSON.decode!(smart_contract["abi"]) assert response["source_code"] == smart_contract["sourceFiles"][smart_contract["fileName"]] @@ -1575,13 +1584,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert %{"is_verified_via_verifier_alliance" => false} = response assert %{"is_fully_verified" => true} = response - smart_contract = Jason.decode!(eth_bytecode_response)["ethBytecodeDbSources"] |> List.first() - assert response["compiler_settings"] == Jason.decode!(smart_contract["compilerSettings"]) + smart_contract = Utils.JSON.decode!(eth_bytecode_response)["ethBytecodeDbSources"] |> List.first() + assert response["compiler_settings"] == Utils.JSON.decode!(smart_contract["compilerSettings"]) assert response["name"] == smart_contract["contractName"] assert response["compiler_version"] == smart_contract["compilerVersion"] assert response["file_path"] == smart_contract["fileName"] - assert response["constructor_args"] == smart_contract["constructorArguments"] - assert response["abi"] == Jason.decode!(smart_contract["abi"]) + assert response["constructor_args"] == expected_constructor_args(smart_contract["constructorArguments"]) + assert response["abi"] == Utils.JSON.decode!(smart_contract["abi"]) assert response["source_code"] == smart_contract["sourceFiles"][smart_contract["fileName"]] diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs index 6162c188542..9dcb1545fab 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs @@ -10,7 +10,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do alias Explorer.{Repo, TestHelper} - alias Explorer.Chain.{Address, Block, Token, Token.Instance, TokenTransfer} + alias Explorer.Chain.{Address, Block, Token, Token.Instance, Token.UIMultiplierChange, TokenTransfer} alias Explorer.Chain.Address.CurrentTokenBalance alias Explorer.Chain.Cache.Counters.TokenCountersConsolidator alias Explorer.Chain.Events.Subscriber @@ -18,6 +18,9 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do alias Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, as: TokenInstanceMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.NFTCollectionMetadataRefetch, as: NFTCollectionMetadataRefetchOnDemand + @one Decimal.new("1000000000000000000") + @two Decimal.new("2000000000000000000") + describe "/tokens/{address_hash}" do test "get 404 on non existing address", %{conn: conn} do token = build(:token) @@ -50,6 +53,46 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do compare_item(token, response) end + + test "get token without ERC-8056 support", %{conn: conn} do + token = insert(:token) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}") + + assert %{"ui_multiplier" => nil, "new_ui_multiplier" => nil, "ui_multiplier_effective_at" => nil} = + json_response(request, 200) + end + + test "get ERC-8056 token with a pending multiplier change", %{conn: conn} do + token = + insert(:token, + ui_multiplier: Decimal.new("1000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: DateTime.add(DateTime.utc_now(), 1, :hour) + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}") + + assert %{ + "ui_multiplier" => "1000000000000000000", + "new_ui_multiplier" => "2000000000000000000" + } = json_response(request, 200) + end + + test "get ERC-8056 token whose scheduled multiplier change has matured", %{conn: conn} do + # the change takes effect with no transaction on chain and no write to the + # database: the response has to follow the clock, not the stored column + token = + insert(:token, + ui_multiplier: Decimal.new("1000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: DateTime.add(DateTime.utc_now(), -1, :hour) + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}") + + assert %{"ui_multiplier" => "2000000000000000000"} = json_response(request, 200) + end end describe "/tokens/{address_hash}/counters" do @@ -84,6 +127,30 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert response["transfers_count"] == "0" assert response["token_holders_count"] == "0" + assert response["ui_multiplier_changes_count"] == "0" + end + + test "counts the ERC-8056 multiplier changes of the token", %{conn: conn} do + token = insert(:token, ui_multiplier: Decimal.new("2000000000000000000")) + block = insert(:block, number: 100) + reorged = insert(:block, number: 101, consensus: false) + + for {block, log_index} <- [{block, 0}, {block, 1}, {reorged, 0}] do + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: log_index, + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + end + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/counters") + + # the change of the reorged block is not listed, so it is not counted + assert %{"ui_multiplier_changes_count" => "2"} = json_response(request, 200) end test "get not zero counters", %{conn: conn} do @@ -156,6 +223,41 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) end + test "total carries the ERC-8056 multiplier as of the transfer, not the current one", %{conn: conn} do + # the token doubled on 2026-06-01; a transfer from before that has to keep + # showing the multiplier its holders saw at the time + token = + insert(:token, + ui_multiplier: Decimal.new("2000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/transfers") + + assert %{"items" => [%{"total" => total}]} = json_response(request, 200) + assert total["ui_multiplier"] == "1000000000000000000" + end + test "check pagination", %{conn: conn} do token = insert(:token) @@ -471,6 +573,152 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do end end + describe "/tokens/{address_hash}/ui-multiplier-changes" do + test "get 404 on non existing address", %{conn: conn} do + token = build(:token) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/tokens/0x/ui-multiplier-changes") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "is empty for a token without ERC-8056 support", %{conn: conn} do + token = insert(:token) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + end + + test "lists the history newest first, with the transaction and the moment of each change", %{conn: conn} do + token = insert(:token, ui_multiplier: @two) + block = insert(:block, number: 100, timestamp: ~U[2026-03-01 00:00:00.000000Z]) + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: 3, + transaction_hash: transaction.hash, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-05 00:00:00.000000Z] + ) + + older = insert(:block, number: 50, timestamp: ~U[2026-01-01 00:00:00.000000Z]) + + insert(:token_ui_multiplier_change, + token: token, + block: older, + block_number: older.number, + log_index: 0, + old_multiplier: @one, + new_multiplier: @one, + effective_at: ~U[2026-01-05 00:00:00.000000Z] + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + + assert %{"items" => [newest, oldest], "next_page_params" => nil} = json_response(request, 200) + + assert %{ + "block_number" => 100, + "log_index" => 3, + "old_multiplier" => "1000000000000000000", + "new_multiplier" => "2000000000000000000" + } = newest + + assert newest["block_hash"] == to_string(block.hash) + assert newest["transaction_hash"] == to_string(transaction.hash) + assert newest["timestamp"] == "2026-03-01T00:00:00.000000Z" + assert newest["effective_at"] == "2026-03-05T00:00:00.000000Z" + + assert %{"block_number" => 50, "log_index" => 0, "transaction_hash" => nil} = oldest + end + + test "keeps a change that is announced but not yet in force", %{conn: conn} do + token = insert(:token, ui_multiplier: @one) + block = insert(:block, number: 100) + effective_at = DateTime.add(DateTime.utc_now(), 1, :hour) + + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: 0, + old_multiplier: @one, + new_multiplier: @two, + effective_at: effective_at + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + + assert %{"items" => [%{"new_multiplier" => "2000000000000000000"}]} = json_response(request, 200) + end + + test "leaves out a change whose block lost consensus", %{conn: conn} do + token = insert(:token, ui_multiplier: @two) + reorged = insert(:block, number: 100, consensus: false) + + insert(:token_ui_multiplier_change, + token: token, + block: reorged, + block_number: reorged.number, + log_index: 0, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-05 00:00:00.000000Z] + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + end + + test "check pagination", %{conn: conn} do + token = insert(:token, ui_multiplier: @two) + block = insert(:block, number: 100) + + changes = + for log_index <- 0..50 do + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: log_index, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-05 00:00:00.000000Z] + ) + end + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes") + assert response = json_response(request, 200) + + request_2nd_page = + get(conn, "/api/v2/tokens/#{token.contract_address.hash}/ui-multiplier-changes", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, changes) + end + end + describe "/tokens" do setup do initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) @@ -759,6 +1007,30 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do check_tokens_pagination(erc_7984_tokens |> Enum.reverse(), conn, %{"type" => "ERC-7984"}) end + test "ERC-8056 is a type of its own, kept apart from ERC-20", %{conn: conn} do + erc_20_token = insert(:token) + + erc_8056_token = + insert(:token, type: "ERC-8056", ui_multiplier: Decimal.new("2000000000000000000")) + + erc_20_hash = Address.checksum(erc_20_token.contract_address_hash) + erc_8056_hash = Address.checksum(erc_8056_token.contract_address_hash) + + # asking for ERC-20 must not widen to ERC-8056 + assert %{"items" => [%{"address_hash" => ^erc_20_hash, "type" => "ERC-20"}]} = + conn |> get("/api/v2/tokens", %{"type" => "ERC-20"}) |> json_response(200) + + assert %{"items" => [%{"address_hash" => ^erc_8056_hash, "type" => "ERC-8056"}]} = + conn |> get("/api/v2/tokens", %{"type" => "ERC-8056"}) |> json_response(200) + + # a caller after every fungible token has to ask for both + assert %{"items" => both} = + conn |> get("/api/v2/tokens", %{"type" => "ERC-20,ERC-8056"}) |> json_response(200) + + assert both |> Enum.map(& &1["address_hash"]) |> Enum.sort() == + Enum.sort([erc_20_hash, erc_8056_hash]) + end + test "tokens are filtered by multiple type", %{conn: conn} do erc_20_tokens = for i <- 11..36 do @@ -1185,6 +1457,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert data = json_response(request, 200) assert compare_item(instance, data) assert Address.checksum(instance.owner_address_hash) == data["owner"]["hash"] + assert data["token"]["address_hash"] == Address.checksum(token.contract_address_hash) + assert data["token"]["type"] == token.type end test "get 404 on token instance which is not presented in DB", %{conn: conn} do @@ -1307,6 +1581,97 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert instance.error == "blacklist" assert instance.skip_metadata_url == false end + + test "preloads ENS and metadata for owner address", %{conn: conn} do + owner = insert(:address) + token = insert(:token, type: "ERC-721") + + insert(:token_instance, + token_id: 0, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: owner.hash, + skip_metadata_url: true + ) + + bypass = Bypass.open() + + old_tesla_adapter = Application.get_env(:tesla, :adapter) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + old_env_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_env_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, old_tesla_adapter) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_env_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_env_metadata) + Bypass.down(bypass) + end) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + owner_hash_string = Address.checksum(owner.hash) + + Bypass.expect_once(bypass, "POST", "api/v1/#{chain_id}/addresses:batch_resolve_names", fn conn -> + Plug.Conn.resp( + conn, + 200, + Utils.JSON.encode!(%{"names" => %{owner_hash_string => "owner.eth"}}) + ) + end) + + Bypass.expect_once(bypass, "GET", "api/v1/metadata", fn conn -> + Plug.Conn.resp( + conn, + 200, + Utils.JSON.encode!(%{ + "addresses" => %{ + owner_hash_string => %{ + "tags" => [ + %{ + "name" => "Known Address", + "ordinal" => 0, + "slug" => "known-address", + "tagType" => "generic", + "meta" => "{}" + } + ] + } + } + }) + ) + end) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/0") + + assert data = json_response(request, 200) + assert data["owner"]["ens_domain_name"] == "owner.eth" + + assert data["owner"]["metadata"] == %{ + "tags" => [ + %{ + "name" => "Known Address", + "ordinal" => 0, + "slug" => "known-address", + "tagType" => "generic", + "meta" => %{} + } + ] + } + end end describe "/tokens/{address_hash}/instances/{token_id}/transfers" do @@ -2038,7 +2403,11 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do |> subscribe_and_join(topic) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + patch( + conn, + "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata?scoped_recaptcha_bypass_token=#{scoped_bypass_token}", + %{} + ) assert %{"message" => "OK"} = json_response(request, 200) @@ -2078,8 +2447,10 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do request = Phoenix.ConnTest.build_conn() |> put_req_header("user-agent", "test-agent") - |> put_req_header("scoped-recaptcha-bypass-token", scoped_bypass_token) - |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + |> patch( + "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata?scoped_recaptcha_bypass_token=#{scoped_bypass_token}", + %{} + ) assert %{"message" => "OK"} = json_response(request, 200) @@ -2104,6 +2475,40 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert(token_instance_from_db) assert token_instance_from_db.metadata == metadata + + # Verify header-based scoped bypass token also works (prevent regression) + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert json_response(request, 429) + + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + + token_instance_success_metadata_expectation(url, metadata) + + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("scoped-recaptcha-bypass-token", scoped_bypass_token) + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert %{"message" => "OK"} = json_response(request, 200) + + :timer.sleep(100) + + assert_receive( + {:chain_event, :fetched_token_instance_metadata, :on_demand, + [^token_contract_address_hash_string, ^token_id, ^metadata]} + ) + + assert_receive %Phoenix.Socket.Message{ + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, + event: "fetched_token_instance_metadata", + topic: ^topic + }, + :timer.seconds(1) end test "falls back to normal reCAPTCHA when incorrect scoped bypass api key is supplied", %{ @@ -2399,16 +2804,21 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do compare_item(Repo.preload(ctb, [{:token, :contract_address}]).token, json["token"]) end + def compare_item(%UIMultiplierChange{} = change, json) do + assert change.block_number == json["block_number"] + assert change.log_index == json["log_index"] + assert to_string(change.block_hash) == json["block_hash"] + assert to_string(change.old_multiplier) == json["old_multiplier"] + assert to_string(change.new_multiplier) == json["new_multiplier"] + end + def compare_item(%Instance{token: %Token{} = token} = instance, json) do - token_type = token.type value = to_string(value(token.type, instance)) id = to_string(instance.token_id) metadata = instance.metadata - token_address_hash = Address.checksum(token.contract_address_hash) app_url = instance.metadata["external_url"] animation_url = instance.metadata["animation_url"] image_url = instance.metadata["image_url"] - token_name = token.name owner_address_hash = Address.checksum(instance.owner.hash) is_contract = !is_nil(instance.owner.contract_code) is_unique = value == "1" @@ -2416,7 +2826,6 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert %{ "id" => ^id, "metadata" => ^metadata, - "token" => %{"address_hash" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, "external_app_url" => ^app_url, "animation_url" => ^animation_url, "image_url" => ^image_url, @@ -2433,9 +2842,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do def compare_item(%Instance{} = instance, json) do assert to_string(instance.token_id) == json["id"] - assert Jason.decode!(Jason.encode!(instance.metadata)) == json["metadata"] + assert Utils.JSON.decode!(Utils.JSON.encode!(instance.metadata)) == json["metadata"] assert json["is_unique"] - compare_item(Repo.preload(instance, [{:token, :contract_address}]).token, json["token"]) end defp value("ERC-721", _), do: 1 @@ -2706,7 +3114,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(metadata) + body: Utils.JSON.encode!(metadata) }} end ) @@ -2720,7 +3128,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "success" => true, "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] }) @@ -2728,4 +3136,181 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do end ) end + + describe "POST /tokens/batch" do + test "get token info for a batch of addresses", %{conn: conn} do + token1 = insert(:token) + token2 = insert(:token) + + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{ + "address_hashes" => [ + to_string(token1.contract_address.hash), + to_string(token2.contract_address.hash) + ] + }) + + assert response = json_response(request, 200) + assert is_list(response) + assert length(response) == 2 + + hashes = Enum.map(response, & &1["address_hash"]) + assert Address.checksum(token1.contract_address.hash) in hashes + assert Address.checksum(token2.contract_address.hash) in hashes + + Enum.each(response, fn item -> + token = + if item["address_hash"] == Address.checksum(token1.contract_address.hash), + do: token1, + else: token2 + + compare_item(token, item) + end) + end + + test "returns empty list for non-existing tokens", %{conn: conn} do + address = build(:address) + + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{ + "address_hashes" => [to_string(address.hash)] + }) + + assert json_response(request, 200) == [] + end + + test "rejects invalid address hashes", %{conn: conn} do + token = insert(:token) + + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{ + "address_hashes" => [ + to_string(token.contract_address.hash), + "0xinvalid", + "not_a_hash" + ] + }) + + assert %{"errors" => [_ | _]} = Phoenix.ConnTest.json_response(request, 422) + end + + test "returns empty list for empty input", %{conn: conn} do + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{"address_hashes" => []}) + + assert json_response(request, 200) == [] + end + + test "deduplicates address hashes", %{conn: conn} do + token = insert(:token) + hash = to_string(token.contract_address.hash) + + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{ + "address_hashes" => [hash, hash, hash] + }) + + assert response = json_response(request, 200) + assert length(response) == 1 + compare_item(token, List.first(response)) + end + + test "rejects batch exceeding max size", %{conn: conn} do + hashes = for i <- 1..51, do: "0x" <> String.pad_leading(Integer.to_string(i), 40, "0") + + request = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v2/tokens/batch", %{"address_hashes" => hashes}) + + assert %{"errors" => [%{"detail" => detail}]} = Phoenix.ConnTest.json_response(request, 422) + assert detail =~ "maxItems" + end + end + + describe "/tokens/{address_hash}/instances/{token_id}/media-type" do + test "get 404 on non existing address", %{conn: conn} do + token = build(:token) + + request = get(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/0/media-type") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/tokens/0x/instances/0/media-type") + + assert %{"errors" => _} = json_response(request, 422) + end + + test "returns already fetched media types without re-fetching", %{conn: conn} do + token = insert(:token, type: "ERC-721") + + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + token_id: 1, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "image/png", + animation_type: "" + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address_hash}/instances/1/media-type") + response = json_response(request, 200) + + assert response["image_media_type"] == "image" + assert response["animation_media_type"] == nil + end + + test "returns 422 when metadata is nil", %{conn: conn} do + token = insert(:token, type: "ERC-721") + + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + token_id: 1, + metadata: nil, + image_type: nil, + animation_type: nil + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address_hash}/instances/1/media-type") + + assert %{"message" => "Metadata is not fetched yet"} = Phoenix.ConnTest.json_response(request, 422) + end + + test "fetches and returns media types for instance with metadata", %{conn: conn} do + token = insert(:token, type: "ERC-721") + + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + token_id: 1, + metadata: %{"image_url" => "https://example.com/image.png", "animation_url" => "https://example.com/anim.mp4"}, + image_type: nil, + animation_type: nil + ) + + request = get(conn, "/api/v2/tokens/#{token.contract_address_hash}/instances/1/media-type") + response = json_response(request, 200) + + assert response["image_media_type"] == "image" + assert response["animation_media_type"] == "video" + end + + test "get 404 for non-existing instance", %{conn: conn} do + token = insert(:token, type: "ERC-721") + + request = get(conn, "/api/v2/tokens/#{token.contract_address_hash}/instances/999/media-type") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + end 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 be79004eb44..7e7a6a80ef0 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 @@ -44,6 +44,51 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert response["next_page_params"] == nil end + test "items_count=10 returns 10 items with next_page_params", %{conn: conn} do + 15 + |> insert_list(:transaction) + |> with_block() + + request = get(conn, "/api/v2/transactions", %{"items_count" => "10"}) + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 10 + assert response["next_page_params"] != nil + + request_2nd_page = + get(conn, "/api/v2/transactions", Map.merge(response["next_page_params"], %{"items_count" => "10"})) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 5 + assert response_2nd_page["next_page_params"] == nil + end + + test "items_count=1 returns 1 item with valid cursor", %{conn: conn} do + 3 + |> insert_list(:transaction) + |> with_block() + + request = get(conn, "/api/v2/transactions", %{"items_count" => "1"}) + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] != nil + refute Map.has_key?(response["next_page_params"], "items_count") + end + + test "absent items_count returns default 50", %{conn: conn} do + 51 + |> insert_list(:transaction) + |> with_block() + + request = get(conn, "/api/v2/transactions") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + end + # `token_creation` is reported off `created_contract_address.token`, which is # only there while the participant preload keeps loading `:token`. test "reports token_creation for a transaction that created a token", %{conn: conn} do @@ -255,6 +300,17 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert response["authorization_list"] == [] end + test "token transfers carry the ERC-8056 multiplier as of the transaction, not the current one", %{conn: conn} do + # the token transfers previewed inside a transaction are loaded without + # their block, which must not leave the multiplier unresolved + transaction = insert_erc_8056_transfer_before_split() + + request = get(conn, "/api/v2/transactions/#{transaction.hash}") + + assert %{"token_transfers" => [%{"total" => total}]} = json_response(request, 200) + assert total["ui_multiplier"] == "1000000000000000000" + end + test "get token-transfers with ok reputation", %{conn: conn} do init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) Application.put_env(:block_scout_web, :hide_scam_addresses, true) @@ -728,6 +784,91 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do check_paginated_response(response, response_2nd_page, internal_transactions) end + + test "returns pending status when transaction block is pending", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "returns pending status when transaction is in pending_transaction_operations", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:pending_transaction_operation, transaction_hash: transaction.hash) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + assert response["meta"]["status"] == 2 + + assert response["meta"]["message"] == + "Some internal transactions within this block range have not yet been processed" + end + + test "include_zero_value=false excludes zero-value call internal transactions", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: 0, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + request = + get( + conn, + "/api/v2/transactions/#{to_string(transaction.hash)}/internal-transactions", + %{"include_zero_value" => "false"} + ) + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert List.first(response["items"])["index"] == 2 + + request_default = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/internal-transactions") + assert response_default = json_response(request_default, 200) + assert Enum.count(response_default["items"]) == 2 + end end describe "/transactions/{transaction_hash}/logs" do @@ -829,9 +970,63 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do check_paginated_response(response, response_2nd_page, logs) end + + test "includes called method ABI and arguments", %{conn: conn} do + event_abi = %{ + "name" => "Set", + "type" => "event", + "inputs" => [%{"name" => "x", "type" => "uint256", "indexed" => false, "internalType" => "uint256"}], + "anonymous" => false + } + + contract_address = insert(:contract_address) + insert(:smart_contract, address_hash: contract_address.hash, abi: [event_abi]) + + topic1_bytes = ExKeccak.hash_256("Set(uint256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + + log_data = "0x0000000000000000000000000000000000000000000000000000000000000032" + + transaction = :transaction |> insert() |> with_block() + + insert(:log, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + address: contract_address, + first_topic: TestHelper.topic(topic1), + data: log_data + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/logs") + + assert response = json_response(request, 200) + assert [log_from_api] = response["items"] + + assert log_from_api["decoded"]["abi"]["name"] == "Set" + assert log_from_api["decoded"]["abi"]["type"] == "event" + + assert log_from_api["decoded"]["abi"]["inputs"] == [ + %{"indexed" => false, "internalType" => "uint256", "name" => "x", "type" => "uint256"} + ] + + refute Map.has_key?(log_from_api["decoded"], "called_method") + end end describe "/transactions/{transaction_hash}/token-transfers" do + test "total carries the ERC-8056 multiplier as of the transfer, not the current one", %{conn: conn} do + # the token doubled on 2026-06-01 and the transfer predates that; the + # transfers of a transaction are loaded without their block, which must + # not leave the multiplier unresolved + transaction = insert_erc_8056_transfer_before_split() + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/token-transfers") + + assert %{"items" => [%{"total" => total}]} = json_response(request, 200) + assert total["ui_multiplier"] == "1000000000000000000" + end + test "get token-transfers with ok reputation", %{conn: conn} do init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) Application.put_env(:block_scout_web, :hide_scam_addresses, true) @@ -1622,7 +1817,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do |> with_block(status: :ok) request = - get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes?items_count=50&state_changes=null") + get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes?state_changes_count=50") assert %{} = json_response(request, 200) end @@ -1901,6 +2096,160 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert token_data["reputation"] == "ok" end + test "balances of an ERC-8056 token carry the multiplier of the transaction, not the current one", %{conn: conn} do + one = Decimal.new("1000000000000000000") + two = Decimal.new("2000000000000000000") + + # the token doubled on 2026-06-01, and the transaction below predates that, + # so its balances have to keep the multiplier they were seen with + token = + insert(:token, + type: "ERC-8056", + ui_multiplier: two, + new_ui_multiplier: two, + ui_multiplier_effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + announcement = insert(:block, number: 100, timestamp: ~U[2026-03-01 00:00:00.000000Z]) + + insert(:token_ui_multiplier_change, + token: token, + block: announcement, + block_number: announcement.number, + log_index: 0, + old_multiplier: one, + new_multiplier: two, + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + block_before = insert(:block, number: 149, timestamp: ~U[2026-04-30 00:00:00.000000Z]) + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + + transaction = :transaction |> insert() |> with_block(block, status: :ok) + + from_address = insert(:address) + to_address = insert(:address) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address, + from_address: from_address, + to_address: to_address, + amount: Decimal.new(100), + token_ids: nil + ) + + for address <- [transaction.from_address, transaction.to_address, block.miner] do + insert(:address_coin_balance, + address: address, + address_hash: address.hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + end + + for {address, value} <- [{from_address, Decimal.new(1000)}, {to_address, Decimal.new(0)}] do + insert(:address_current_token_balance, + address: address, + address_hash: address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: value + ) + end + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") + + assert response = json_response(request, 200) + + assert [_ | _] = token_state_changes = Enum.filter(response["items"], &(&1["type"] == "token")) + + for state_change <- token_state_changes do + assert state_change["ui_multiplier"] == "1000000000000000000" + # the shared rendering of the token keeps saying what is in force now + assert state_change["token"]["ui_multiplier"] == "2000000000000000000" + end + + assert Enum.all?(response["items"], &(&1["type"] == "token" or is_nil(&1["ui_multiplier"]))) + end + + test "balances of an ERC-8056 token account for a change the transaction itself announced", %{conn: conn} do + one = Decimal.new("1000000000000000000") + two = Decimal.new("2000000000000000000") + + token = + insert(:token, + type: "ERC-8056", + ui_multiplier: two, + new_ui_multiplier: two, + ui_multiplier_effective_at: ~U[2026-05-01 00:00:00.000000Z] + ) + + block_before = insert(:block, number: 149, timestamp: ~U[2026-04-30 00:00:00.000000Z]) + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + + transaction = :transaction |> insert() |> with_block(block, status: :ok) + + from_address = insert(:address) + to_address = insert(:address) + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address, + from_address: from_address, + to_address: to_address, + amount: Decimal.new(100), + token_ids: nil + ) + + # announced by the same transaction, but after the transfer the balances + # were derived from, and effective right away + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: token_transfer.log_index + 1, + transaction_hash: transaction.hash, + old_multiplier: one, + new_multiplier: two, + effective_at: ~U[2026-05-01 00:00:00.000000Z] + ) + + for address <- [transaction.from_address, transaction.to_address, block.miner] do + insert(:address_coin_balance, + address: address, + address_hash: address.hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + end + + for {address, value} <- [{from_address, Decimal.new(1000)}, {to_address, Decimal.new(0)}] do + insert(:address_current_token_balance, + address: address, + address_hash: address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: value + ) + end + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") + + assert response = json_response(request, 200) + + assert [_ | _] = token_state_changes = Enum.filter(response["items"], &(&1["type"] == "token")) + + for state_change <- token_state_changes do + assert state_change["ui_multiplier"] == "2000000000000000000" + end + end + test "return state changes with scam token reputation properly set", %{conn: conn} do init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) Application.put_env(:block_scout_web, :hide_scam_addresses, true) @@ -2671,6 +3020,39 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do defp check_total(_, _, _), do: true + # An ERC-8056 token that doubled on 2026-06-01, and a transaction that moved + # it a month before, while the multiplier was still 1.0. + defp insert_erc_8056_transfer_before_split do + token = + insert(:token, + type: "ERC-8056", + ui_multiplier: Decimal.new("2000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address + ) + + transaction + end + describe "/transactions/{transaction_hash}/summary?just_request_body=true" do setup do original_config = @@ -3039,7 +3421,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do log_from_api = Enum.at(response["logs_data"]["items"], 0) assert not is_nil(log_from_api["decoded"]) - assert log_from_api["decoded"] == %{ + assert Map.drop(log_from_api["decoded"], ["abi"]) == %{ "method_call" => "OptionSettled(uint256 indexed accountId, address option, uint256 subId, int256 amount, int256 value)", "method_id" => "d20a68b2", @@ -3076,6 +3458,9 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do } ] } + + assert log_from_api["decoded"]["abi"]["name"] == "OptionSettled" + assert log_from_api["decoded"]["abi"]["type"] == "event" end test "test corner case, when preload functions face absent smart contract", %{conn: conn} do @@ -3238,6 +3623,34 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do Bypass.down(bypass) end + test "returns an error when the interpreter response can't be decoded", %{conn: conn} do + bypass = Bypass.open() + + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:#{bypass.port}" + ) + + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + Bypass.expect_once(bypass, "GET", "/cache/#{to_string(transaction.hash)}", fn conn -> + Plug.Conn.resp(conn, 404, "Not Found") + end) + + Bypass.expect_once(bypass, "POST", "/transactions/summary", fn conn -> + Plug.Conn.resp(conn, 200, "{not a json") + end) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary") + + assert json_response(request, 500) == %{"error" => "Error while transaction interpreter response decoding"} + + Bypass.down(bypass) + end + test "success preload template variables when scam token", %{conn: conn} do init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) Application.put_env(:block_scout_web, :hide_scam_addresses, true) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs index 38bc05fbc20..03a9a2c14fc 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs @@ -151,6 +151,11 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do check_paginated_response(response, response_2nd_page, validators) end + + test "returns 422 for an invalid query parameter", %{conn: conn} do + request = get(conn, "/api/v2/validators/stability", %{"order" => "bogus"}) + assert json_response(request, 422) + end end describe "/validators/stability/counters" do @@ -186,6 +191,25 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do "validators_count" => "9" } = json_response(request, 200) end + + test "returns null percentage when there are no validators", %{conn: conn} do + ValidatorsCount.consolidate() + :timer.sleep(500) + + request = get(conn, "/api/v2/validators/stability/counters") + + assert %{ + "active_validators_count" => "0", + "active_validators_percentage" => nil, + "new_validators_count_24h" => "0", + "validators_count" => "0" + } = json_response(request, 200) + end + + test "returns 422 for an unexpected query parameter", %{conn: conn} do + request = get(conn, "/api/v2/validators/stability/counters", %{"bogus" => "1"}) + assert json_response(request, 422) + end end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/zksync_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/zksync_controller_test.exs new file mode 100644 index 00000000000..59f6503b006 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/zksync_controller_test.exs @@ -0,0 +1,295 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ZkSyncControllerTest do + use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + if @chain_type == :zksync do + describe "/zksync/batches/:batch_number_param" do + test "returns batch by number with sealed status when no lifecycle transactions", %{conn: conn} do + batch = insert(:zksync_transaction_batch) + + request = get(conn, "/api/v2/zksync/batches/#{batch.number}") + assert response = json_response(request, 200) + + assert response["number"] == batch.number + assert response["root_hash"] == to_string(batch.root_hash) + assert response["l1_transactions_count"] == batch.l1_transaction_count + assert response["l2_transactions_count"] == batch.l2_transaction_count + assert response["l1_gas_price"] == to_string(batch.l1_gas_price.value) + assert response["l2_fair_gas_price"] == to_string(batch.l2_fair_gas_price.value) + assert response["start_block_number"] == batch.start_block + assert response["end_block_number"] == batch.end_block + assert response["status"] == "Sealed on L2" + assert response["commit_transaction_hash"] == nil + assert response["commit_transaction_timestamp"] == nil + assert response["prove_transaction_hash"] == nil + assert response["prove_transaction_timestamp"] == nil + assert response["execute_transaction_hash"] == nil + assert response["execute_transaction_timestamp"] == nil + end + + test "returns batch with sent status when only commit transaction present", %{conn: conn} do + commit_tx = insert(:zksync_lifecycle_transaction) + batch = insert(:zksync_transaction_batch, commit_id: commit_tx.id) + + request = get(conn, "/api/v2/zksync/batches/#{batch.number}") + assert response = json_response(request, 200) + + assert response["status"] == "Sent to L1" + assert response["commit_transaction_hash"] == to_string(commit_tx.hash) + assert response["commit_transaction_timestamp"] == DateTime.to_iso8601(commit_tx.timestamp) + assert response["prove_transaction_hash"] == nil + assert response["execute_transaction_hash"] == nil + end + + test "returns batch with validated status when commit and prove transactions present", %{conn: conn} do + commit_tx = insert(:zksync_lifecycle_transaction) + prove_tx = insert(:zksync_lifecycle_transaction) + batch = insert(:zksync_transaction_batch, commit_id: commit_tx.id, prove_id: prove_tx.id) + + request = get(conn, "/api/v2/zksync/batches/#{batch.number}") + assert response = json_response(request, 200) + + assert response["status"] == "Validated on L1" + assert response["commit_transaction_hash"] == to_string(commit_tx.hash) + assert response["prove_transaction_hash"] == to_string(prove_tx.hash) + assert response["prove_transaction_timestamp"] == DateTime.to_iso8601(prove_tx.timestamp) + assert response["execute_transaction_hash"] == nil + end + + test "returns batch with executed status when all lifecycle transactions present", %{conn: conn} do + commit_tx = insert(:zksync_lifecycle_transaction) + prove_tx = insert(:zksync_lifecycle_transaction) + execute_tx = insert(:zksync_lifecycle_transaction) + + batch = + insert(:zksync_transaction_batch, + commit_id: commit_tx.id, + prove_id: prove_tx.id, + execute_id: execute_tx.id + ) + + request = get(conn, "/api/v2/zksync/batches/#{batch.number}") + assert response = json_response(request, 200) + + assert response["status"] == "Executed on L1" + assert response["commit_transaction_hash"] == to_string(commit_tx.hash) + assert response["commit_transaction_timestamp"] == DateTime.to_iso8601(commit_tx.timestamp) + assert response["prove_transaction_hash"] == to_string(prove_tx.hash) + assert response["prove_transaction_timestamp"] == DateTime.to_iso8601(prove_tx.timestamp) + assert response["execute_transaction_hash"] == to_string(execute_tx.hash) + assert response["execute_transaction_timestamp"] == DateTime.to_iso8601(execute_tx.timestamp) + end + + test "returns 404 when batch is not found", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches/999999") + assert json_response(request, 404) + end + + test "returns 422 when batch number is not an integer", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches/not-a-number") + assert json_response(request, 422) + end + end + + describe "/zksync/batches" do + test "returns an empty list when there are no batches", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches") + assert response = json_response(request, 200) + + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns batches with all lifecycle status values and aggregated transactions count", + %{conn: conn} do + sealed_batch = insert(:zksync_transaction_batch) + + commit_tx = insert(:zksync_lifecycle_transaction) + sent_batch = insert(:zksync_transaction_batch, commit_id: commit_tx.id) + + prove_tx = insert(:zksync_lifecycle_transaction) + + validated_batch = + insert(:zksync_transaction_batch, commit_id: commit_tx.id, prove_id: prove_tx.id) + + execute_tx = insert(:zksync_lifecycle_transaction) + + executed_batch = + insert(:zksync_transaction_batch, + commit_id: commit_tx.id, + prove_id: prove_tx.id, + execute_id: execute_tx.id + ) + + request = get(conn, "/api/v2/zksync/batches") + assert response = json_response(request, 200) + + # Order is desc: number — executed_batch was inserted last, so it appears first. + assert response["next_page_params"] == nil + assert length(response["items"]) == 4 + + items_by_number = Enum.into(response["items"], %{}, &{&1["number"], &1}) + + sealed_item = Map.fetch!(items_by_number, sealed_batch.number) + assert sealed_item["status"] == "Sealed on L2" + assert sealed_item["commit_transaction_hash"] == nil + assert sealed_item["prove_transaction_hash"] == nil + assert sealed_item["execute_transaction_hash"] == nil + + assert sealed_item["transactions_count"] == + sealed_batch.l1_transaction_count + sealed_batch.l2_transaction_count + + sent_item = Map.fetch!(items_by_number, sent_batch.number) + assert sent_item["status"] == "Sent to L1" + assert sent_item["commit_transaction_hash"] == to_string(commit_tx.hash) + assert sent_item["commit_transaction_timestamp"] == DateTime.to_iso8601(commit_tx.timestamp) + + validated_item = Map.fetch!(items_by_number, validated_batch.number) + assert validated_item["status"] == "Validated on L1" + assert validated_item["prove_transaction_hash"] == to_string(prove_tx.hash) + + executed_item = Map.fetch!(items_by_number, executed_batch.number) + assert executed_item["status"] == "Executed on L1" + assert executed_item["execute_transaction_hash"] == to_string(execute_tx.hash) + + assert executed_item["execute_transaction_timestamp"] == + DateTime.to_iso8601(execute_tx.timestamp) + end + + test "paginates batches with next_page_params on second page", %{conn: conn} do + batches = insert_list(51, :zksync_transaction_batch) + + request = get(conn, "/api/v2/zksync/batches") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + + request_2nd = get(conn, "/api/v2/zksync/batches", response["next_page_params"]) + assert response_2nd = json_response(request_2nd, 200) + + assert Enum.count(response_2nd["items"]) == 1 + assert response_2nd["next_page_params"] == nil + + # Order is desc: number — last inserted appears first; oldest batch is on the second page. + assert Enum.at(response_2nd["items"], 0)["number"] == Enum.at(batches, 0).number + end + + test "returns 422 when items_count is not a valid integer", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches", %{"items_count" => "foo"}) + assert json_response(request, 422) + end + end + + describe "/zksync/batches/count" do + test "returns 0 when there are no batches", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches/count") + assert json_response(request, 200) == 0 + end + + test "returns the total count of batches when batches exist", %{conn: conn} do + insert_list(3, :zksync_transaction_batch) + + request = get(conn, "/api/v2/zksync/batches/count") + assert json_response(request, 200) == 3 + end + + test "returns 422 when an undeclared query parameter is supplied", %{conn: conn} do + request = get(conn, "/api/v2/zksync/batches/count", %{"unknown" => "1"}) + assert json_response(request, 422) + end + end + + describe "/main-page/zksync/batches/confirmed" do + test "returns an empty list when there are no committed batches", %{conn: conn} do + request = get(conn, "/api/v2/main-page/zksync/batches/confirmed") + assert response = json_response(request, 200) + + assert response["items"] == [] + end + + test "returns only batches that have a commit transaction, newest first", %{conn: conn} do + # Sealed (no commit) — should be excluded. + _sealed_batch = insert(:zksync_transaction_batch) + + commit_tx = insert(:zksync_lifecycle_transaction) + sent_batch = insert(:zksync_transaction_batch, commit_id: commit_tx.id) + + prove_tx = insert(:zksync_lifecycle_transaction) + + validated_batch = + insert(:zksync_transaction_batch, commit_id: commit_tx.id, prove_id: prove_tx.id) + + execute_tx = insert(:zksync_lifecycle_transaction) + + executed_batch = + insert(:zksync_transaction_batch, + commit_id: commit_tx.id, + prove_id: prove_tx.id, + execute_id: execute_tx.id + ) + + request = get(conn, "/api/v2/main-page/zksync/batches/confirmed") + assert response = json_response(request, 200) + + items_by_number = Enum.into(response["items"], %{}, &{&1["number"], &1}) + + assert Map.keys(items_by_number) |> Enum.sort() == + Enum.sort([sent_batch.number, validated_batch.number, executed_batch.number]) + + sent_item = Map.fetch!(items_by_number, sent_batch.number) + assert sent_item["status"] == "Sent to L1" + assert sent_item["commit_transaction_hash"] == to_string(commit_tx.hash) + assert sent_item["commit_transaction_timestamp"] == DateTime.to_iso8601(commit_tx.timestamp) + + assert sent_item["transactions_count"] == + sent_batch.l1_transaction_count + sent_batch.l2_transaction_count + + validated_item = Map.fetch!(items_by_number, validated_batch.number) + assert validated_item["status"] == "Validated on L1" + assert validated_item["prove_transaction_hash"] == to_string(prove_tx.hash) + + executed_item = Map.fetch!(items_by_number, executed_batch.number) + assert executed_item["status"] == "Executed on L1" + assert executed_item["execute_transaction_hash"] == to_string(execute_tx.hash) + end + + test "caps the response at ten batches", %{conn: conn} do + commit_tx = insert(:zksync_lifecycle_transaction) + insert_list(11, :zksync_transaction_batch, commit_id: commit_tx.id) + + request = get(conn, "/api/v2/main-page/zksync/batches/confirmed") + assert response = json_response(request, 200) + + assert length(response["items"]) == 10 + end + + test "returns 422 when an undeclared query parameter is supplied", %{conn: conn} do + request = get(conn, "/api/v2/main-page/zksync/batches/confirmed", %{"unknown" => "1"}) + assert json_response(request, 422) + end + end + + describe "/main-page/zksync/batches/latest-number" do + test "returns 0 when there are no batches", %{conn: conn} do + request = get(conn, "/api/v2/main-page/zksync/batches/latest-number") + assert json_response(request, 200) == 0 + end + + test "returns the highest batch number when batches exist", %{conn: conn} do + insert(:zksync_transaction_batch, number: 42) + insert(:zksync_transaction_batch, number: 100) + insert(:zksync_transaction_batch, number: 7) + + request = get(conn, "/api/v2/main-page/zksync/batches/latest-number") + assert json_response(request, 200) == 100 + end + + test "returns 422 when an undeclared query parameter is supplied", %{conn: conn} do + request = get(conn, "/api/v2/main-page/zksync/batches/latest-number", %{"unknown" => "1"}) + assert json_response(request, 422) + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs index 5dc44aafd5e..dc9050efc53 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs @@ -99,7 +99,7 @@ defmodule BlockScoutWeb.BlockControllerTest do conn = get(conn, blocks_path(conn, :index), %{"type" => "JSON"}) - expected_path = blocks_path(conn, :index, block_number: number, items_count: "50", block_type: "Block") + expected_path = blocks_path(conn, :index, block_number: number, block_type: "Block") assert Map.get(json_response(conn, 200), "next_page_path") == expected_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs index 08c30b068da..89533368fa8 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs @@ -47,7 +47,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.count(items) == 2 end @@ -90,7 +90,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.count(items) == 1 end @@ -107,7 +107,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.empty?(items) end @@ -134,7 +134,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.empty?(items) end @@ -149,7 +149,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.empty?(items) end @@ -165,7 +165,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert next_page_path end @@ -181,7 +181,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() refute next_page_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs index 63b6f87553f..54d43080ef8 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs @@ -40,7 +40,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.count(items) == 3 end @@ -92,7 +92,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.empty?(items) end @@ -104,7 +104,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert next_page_path end @@ -116,7 +116,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() refute next_page_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs index 0d608cd070b..f13fa412f47 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs @@ -44,8 +44,8 @@ defmodule BlockScoutWeb.Chain.TransactionHistoryChartControllerTest do |> TransactionHistoryChartController.show([]) # turn conn.resp_body into a map using JSON - response_data = Jason.decode!(conn.resp_body, keys: :atoms) - history_data = Jason.decode!(response_data.history_data, keys: :atoms) + response_data = Utils.JSON.decode!(conn.resp_body, keys: :atoms) + history_data = Utils.JSON.decode!(response_data.history_data, keys: :atoms) history_data_with_elixir_dates = Enum.map(history_data, fn stat -> diff --git a/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs index 3dcfdd48de6..62027c8d4bf 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs @@ -59,7 +59,7 @@ defmodule BlockScoutWeb.PendingTransactionControllerTest do "hash" => Hash.to_string(hash) }) - {:ok, %{"items" => pending_transactions}} = Poison.decode(conn.resp_body) + {:ok, %{"items" => pending_transactions}} = Utils.JSON.decode(conn.resp_body) assert length(pending_transactions) == length(second_page_hashes) end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs index 275a49aa2f3..2479a214411 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs @@ -76,7 +76,7 @@ defmodule BlockScoutWeb.Tokens.InventoryControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.count(items) == Enum.count(second_page_token_balances) end @@ -109,7 +109,7 @@ defmodule BlockScoutWeb.Tokens.InventoryControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert next_page_path end @@ -134,7 +134,7 @@ defmodule BlockScoutWeb.Tokens.InventoryControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() refute next_page_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs index dd309d7f77e..3c5613cccae 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs @@ -32,7 +32,7 @@ defmodule BlockScoutWeb.Tokens.TokenControllerTest do # conn = get(conn, "/token-counters", %{"id" => Address.checksum(contract_address.hash)}) # assert conn.status == 200 - # {:ok, response} = Jason.decode(conn.resp_body) + # {:ok, response} = Utils.JSON.decode(conn.resp_body) # assert %{"token_holder_count" => 0, "transfer_count" => 1} == response # end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs index 3f9d78742a6..23360751ee2 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs @@ -61,7 +61,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert json_response(conn, 200) @@ -144,7 +144,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do {:ok, %{"items" => items}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert Enum.count(items) == Enum.count(second_page_indexes) end @@ -175,7 +175,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() assert next_page_path end @@ -204,7 +204,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do {:ok, %{"next_page_path" => next_page_path}} = conn.resp_body - |> Poison.decode() + |> Utils.JSON.decode() refute next_page_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs index 6ab0a63e3ab..334c7f3b861 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs @@ -41,7 +41,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do conn = get(conn, transaction_log_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() first_log = List.first(items) assert String.contains?(first_log, Address.checksum(address.hash)) @@ -64,7 +64,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do conn = get(conn, transaction_log_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() first_log = List.first(items) assert String.contains?(first_log, Address.checksum(address.hash)) @@ -76,7 +76,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do conn = get(conn, path, %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() assert Enum.empty?(items) end @@ -113,7 +113,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do "type" => "JSON" }) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() assert Enum.count(items) == Enum.count(second_page_indexes) end @@ -136,7 +136,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do conn = get(conn, transaction_log_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"next_page_path" => path}} = conn.resp_body |> Poison.decode() + {:ok, %{"next_page_path" => path}} = conn.resp_body |> Utils.JSON.decode() assert path end @@ -149,7 +149,7 @@ defmodule BlockScoutWeb.TransactionLogControllerTest do conn = get(conn, transaction_log_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"next_page_path" => path}} = conn.resp_body |> Poison.decode() + {:ok, %{"next_page_path" => path}} = conn.resp_body |> Utils.JSON.decode() refute path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs index 844f9afd98f..6eef3e0855e 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs @@ -83,7 +83,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do insert(:transaction, from_address: address, to_address: to_address) |> with_block(block, status: :ok) conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() assert(items |> Enum.filter(fn item -> item != nil end) |> length() == 2) end @@ -112,7 +112,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do ) conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() assert(items |> Enum.filter(fn item -> item != nil end) |> length() == 2) end @@ -177,7 +177,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do get(conn, transaction_state_path(conn, :index, transaction)) conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() full_text = Enum.join(items) assert(String.contains?(full_text, format_wei_value(%Wei{value: Decimal.new(1, 1, 18)}, :ether))) @@ -247,7 +247,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() full_text = Enum.join(items) assert(length(items) == 3) @@ -255,7 +255,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do 1 |> :timer.seconds() |> :timer.sleep() conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() full_text = Enum.join(items) assert(String.contains?(full_text, format_wei_value(%Wei{value: Decimal.new(123)}, :ether))) assert(length(items) == 3) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs index 0a4d75d2f37..778121a2d3a 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs @@ -72,7 +72,7 @@ defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do assert json_response(conn, 200) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() assert Enum.count(items) == 2 end @@ -104,7 +104,7 @@ defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do "type" => "JSON" }) - {:ok, %{"items" => items}} = conn.resp_body |> Poison.decode() + {:ok, %{"items" => items}} = conn.resp_body |> Utils.JSON.decode() refute Enum.count(items) == 3 end @@ -129,7 +129,7 @@ defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do conn = get(conn, transaction_token_transfer_path(BlockScoutWeb.Endpoint, :index, transaction.hash), %{type: "JSON"}) - {:ok, %{"next_page_path" => path}} = conn.resp_body |> Poison.decode() + {:ok, %{"next_page_path" => path}} = conn.resp_body |> Utils.JSON.decode() assert path end @@ -154,7 +154,7 @@ defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do conn = get(conn, transaction_token_transfer_path(BlockScoutWeb.Endpoint, :index, transaction.hash), %{type: "JSON"}) - {:ok, %{"next_page_path" => path}} = conn.resp_body |> Poison.decode() + {:ok, %{"next_page_path" => path}} = conn.resp_body |> Utils.JSON.decode() refute path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs index 5c1ecdc1c0b..a85558f8474 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs @@ -64,11 +64,7 @@ defmodule BlockScoutWeb.VerifiedContractsControllerTest do conn = get(conn, verified_contracts_path(conn, :index), %{"type" => "JSON"}) - expected_path = - verified_contracts_path(conn, :index, - id: id, - items_count: "50" - ) + expected_path = verified_contracts_path(conn, :index, id: id) assert Map.get(json_response(conn, 200), "next_page_path") == expected_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs index 47dd685ab6c..0960cdd1f96 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs @@ -41,7 +41,7 @@ defmodule BlockScoutWeb.WithdrawalControllerTest do conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) - expected_path = withdrawal_path(conn, :index, index: index, items_count: "50") + expected_path = withdrawal_path(conn, :index, index: index) assert Map.get(json_response(conn, 200), "next_page_path") == expected_path end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs index ba66558b114..9804b4b16d8 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs @@ -90,7 +90,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.AddressTest do "compiler_version" => smart_contract.compiler_version, "optimization" => smart_contract.optimization, "contract_source_code" => smart_contract.contract_source_code, - "abi" => Jason.encode!(smart_contract.abi), + "abi" => Utils.JSON.encode!(smart_contract.abi), "address_hash" => to_string(address.hash) } } diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs index 11248e4f327..d2b0189f385 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs @@ -95,7 +95,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.AddressesTest do "compiler_version" => smart_contract.compiler_version, "optimization" => smart_contract.optimization, "contract_source_code" => smart_contract.contract_source_code, - "abi" => Jason.encode!(smart_contract.abi), + "abi" => Utils.JSON.encode!(smart_contract.abi), "address_hash" => to_string(address.hash) } } diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs index d88f9ff6806..430b91b4e7b 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs @@ -83,7 +83,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do id = %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} - |> Jason.encode!() + |> Utils.JSON.encode!() |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() |> Base.encode64() @@ -127,7 +127,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do id = %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} - |> Jason.encode!() + |> Utils.JSON.encode!() |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() |> Base.encode64() @@ -158,7 +158,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do id = %{transaction_hash: to_string(token_transfer.transaction_hash), log_index: token_transfer.log_index} - |> Jason.encode!() + |> Utils.JSON.encode!() |> (fn unique_id -> "TokenTransfer:#{unique_id}" end).() |> Base.encode64() @@ -194,7 +194,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do id = %{transaction_hash: to_string(transaction.hash), log_index: 0} - |> Jason.encode!() + |> Utils.JSON.encode!() |> (fn unique_id -> "TokenTransfer:#{unique_id}" end).() |> Base.encode64() diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs index a8d1609e63e..e72b0f132a0 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs @@ -325,4 +325,79 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TokenTransfersTest do assert Enum.all?(page3["edges"], &(&1["node"]["transaction_hash"] == to_string(transaction1.hash))) end end + + describe "token_transfers field with scam tokens" do + setup do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + + # the global GraphQL rate limit (10 req/s by default) is shared by all GraphQL tests + # and this file alone issues more requests than that within a second + graphql_config = Application.get_env(:block_scout_web, Api.GraphQL) + Application.put_env(:block_scout_web, Api.GraphQL, Keyword.put(graphql_config, :rate_limit_disabled?, true)) + + on_exit(fn -> + Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) + Application.put_env(:block_scout_web, Api.GraphQL, graphql_config) + end) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address) + insert(:scam_badge_to_address, address_hash: token_address.hash) + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction, token_contract_address: token_address) + + query = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first) { + edges { + node { + transaction_hash + } + } + } + } + """ + + variables = %{"token_contract_address_hash" => to_string(token_address.hash), "first" => 10} + + %{query: query, variables: variables, token_transfer: token_transfer} + end + + test "hides scam token transfers by default", %{conn: conn, query: query, variables: variables} do + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{"data" => %{"token_transfers" => %{"edges" => []}}} + end + + test "shows scam token transfers when show-scam-tokens header is set", %{ + conn: conn, + query: query, + variables: variables, + token_transfer: token_transfer + } do + conn = + conn + |> put_req_header("show-scam-tokens", "true") + |> post("/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"token_transfers" => %{"edges" => [%{"node" => node}]}}} = json_response(conn, 200) + assert node["transaction_hash"] == to_string(token_transfer.transaction_hash) + end + + test "shows scam token transfers when show_scam_tokens cookie is set", %{ + conn: conn, + query: query, + variables: variables, + token_transfer: token_transfer + } do + conn = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> post("/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"token_transfers" => %{"edges" => [%{"node" => node}]}}} = json_response(conn, 200) + assert node["transaction_hash"] == to_string(token_transfer.transaction_hash) + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs index 0492a49ab4d..f9c213a2163 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs @@ -211,7 +211,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do "created_contract_address_hash" => to_string(internal_transaction.created_contract_address_hash), "from_address_hash" => to_string(internal_transaction.from_address_hash), - "to_address_hash" => nil, + "to_address_hash" => to_string(internal_transaction.created_contract_address_hash), "transaction_hash" => to_string(internal_transaction.transaction.hash) } } diff --git a/apps/block_scout_web/test/block_scout_web/paging_helper_test.exs b/apps/block_scout_web/test/block_scout_web/paging_helper_test.exs new file mode 100644 index 00000000000..0c08f71874f --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/paging_helper_test.exs @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.PagingHelperTest do + use ExUnit.Case, async: true + + alias BlockScoutWeb.PagingHelper + alias Explorer.PagingOptions + + describe "paging_options/2 items_count support" do + test "overrides page_size when items_count is present" do + params = %{items_count: 10, block_number: 100, index: 5} + [paging_options: paging_options] = PagingHelper.paging_options(params, [:validated]) + + assert %PagingOptions{page_size: 11} = paging_options + end + + test "clamps items_count to max_page_size" do + params = %{items_count: 99999, block_number: 100, index: 5} + [paging_options: paging_options] = PagingHelper.paging_options(params, [:validated]) + + expected_page_size = PagingOptions.max_page_size() + 1 + assert %PagingOptions{page_size: ^expected_page_size} = paging_options + end + + test "uses default page_size when items_count is absent" do + params = %{block_number: 100, index: 5} + [paging_options: paging_options] = PagingHelper.paging_options(params, [:validated]) + + assert %PagingOptions{page_size: 51} = paging_options + end + + test "ignores items_count when zero or negative" do + params = %{items_count: 0, block_number: 100, index: 5} + [paging_options: paging_options] = PagingHelper.paging_options(params, [:validated]) + + assert %PagingOptions{page_size: 51} = paging_options + end + + test "works with catch-all clause" do + params = %{items_count: 25} + [paging_options: paging_options] = PagingHelper.paging_options(params, [:validated]) + + assert %PagingOptions{page_size: 26} = paging_options + end + end + + describe "delete_parameters_from_next_page_params/1" do + test "removes :key (atom) from params" do + params = %{key: "mysecret", block_number: 42, index: 0} + result = PagingHelper.delete_parameters_from_next_page_params(params) + refute Map.has_key?(result, :key) + assert Map.has_key?(result, :block_number) + end + + test "removes \"key\" (string) from params" do + params = %{"key" => "mysecret", "block_number" => "42", "index" => "0"} + result = PagingHelper.delete_parameters_from_next_page_params(params) + refute Map.has_key?(result, "key") + assert Map.has_key?(result, "block_number") + end + + test "removes :apikey (atom) from params" do + params = %{apikey: "myapikey", block_number: 42} + result = PagingHelper.delete_parameters_from_next_page_params(params) + refute Map.has_key?(result, :apikey) + assert Map.has_key?(result, :block_number) + end + + test "removes \"apikey\" (string) from params" do + params = %{"apikey" => "myapikey", "block_number" => "42"} + result = PagingHelper.delete_parameters_from_next_page_params(params) + refute Map.has_key?(result, "apikey") + assert Map.has_key?(result, "block_number") + end + + test "removes both :key and :apikey when both present" do + params = %{key: "mysecret", apikey: "myapikey", block_number: 42} + result = PagingHelper.delete_parameters_from_next_page_params(params) + refute Map.has_key?(result, :key) + refute Map.has_key?(result, :apikey) + assert Map.has_key?(result, :block_number) + end + + test "preserves unrelated pagination params" do + params = %{"block_number" => "100", "index" => "5"} + result = PagingHelper.delete_parameters_from_next_page_params(params) + assert result == params + end + + test "returns nil for non-map input" do + assert PagingHelper.delete_parameters_from_next_page_params(nil) == nil + assert PagingHelper.delete_parameters_from_next_page_params("string") == nil + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs b/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs index 096d80ea2ff..9fe3170baab 100644 --- a/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs +++ b/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs @@ -410,6 +410,108 @@ defmodule BlockScoutWeb.Plug.RateLimitTest do assert get_resp_header(request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 end + test "multiplies IP limit for recaptcha_to_bypass_429 endpoint when recaptcha is disabled", %{conn: conn} do + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :is_disabled, true) + ) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :recaptcha_disabled_limit_multiplier, 2) + ) + + config = %{ + static_match: %{ + "api/v2/addresses" => %{ + ip: %{period: 60_000, limit: 2}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "test_recaptcha_disabled_" + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # Effective limit = 2 * 2 = 4; first four requests should be allowed + for _ <- 1..4 do + resp = conn |> put_req_header("user-agent", "test-agent") |> get("/api/v2/addresses") + assert resp.status == 200 + assert get_resp_header(resp, "bypass-429-option") == ["no_bypass"] + end + + # Fifth request should be denied + denied = conn |> put_req_header("user-agent", "test-agent") |> get("/api/v2/addresses") + assert denied.status == 429 + end + + test "does not advertise recaptcha bypass when recaptcha is disabled", %{conn: conn} do + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :is_disabled, true) + ) + + config = %{ + static_match: %{ + "api/v2/addresses" => %{ + ip: %{period: 60_000, limit: 100}, + recaptcha_to_bypass_429: true + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + resp = conn |> put_req_header("user-agent", "test-agent") |> get("/api/v2/addresses") + assert resp.status == 200 + assert get_resp_header(resp, "bypass-429-option") == ["no_bypass"] + end + + test "multiplies IP limit for temporary_token endpoint when recaptcha is disabled", %{conn: conn} do + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :is_disabled, true) + ) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :recaptcha_disabled_limit_multiplier, 2) + ) + + config = %{ + static_match: %{ + "api/v2/transactions" => %{ + ip: %{period: 60_000, limit: 2}, + temporary_token: true, + bucket_key_prefix: "test_temp_token_disabled_" + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # Effective limit = 2 * 2 = 4; first four requests should be allowed + for _ <- 1..4 do + resp = conn |> put_req_header("user-agent", "test-agent") |> get("/api/v2/transactions") + assert resp.status == 200 + end + + # Fifth request should be denied + denied = conn |> put_req_header("user-agent", "test-agent") |> get("/api/v2/transactions") + assert denied.status == 429 + end + test "rate limit requests with isolated rate limit", %{conn: conn} do config = %{ static_match: %{ diff --git a/apps/block_scout_web/test/block_scout_web/routers/api_router_pipelines_test.exs b/apps/block_scout_web/test/block_scout_web/routers/api_router_pipelines_test.exs new file mode 100644 index 00000000000..c14a743f101 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/routers/api_router_pipelines_test.exs @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Routers.ApiRouterPipelinesTest do + @moduledoc """ + Guards the pipeline assignment of routes in `BlockScoutWeb.Routers.ApiRouter`. + + `Phoenix.ConnTest` skips CSRF checks, so a request test cannot notice that a + non-GET route runs through a pipeline with `:protect_from_forgery`. API + clients send no CSRF token, and such a route returns 403 to all of them (see + issue #14878). This test checks the router definition instead. + """ + + use ExUnit.Case, async: true + + use Utils.CompileTimeEnvHelper, + reading_enabled: [:block_scout_web, [BlockScoutWeb.Routers.ApiRouter, :reading_enabled]] + + alias BlockScoutWeb.Routers.ApiRouter + + @csrf_protected_pipelines [:api_v2, :api_v2_csv] + + test "no non-GET route goes through a CSRF-protected pipeline" do + offending = + routes_with_pipelines() + |> Enum.filter(fn {route, _pipelines} -> route.verb != :get end) + |> Enum.filter(fn {_route, pipelines} -> Enum.any?(pipelines, &(&1 in @csrf_protected_pipelines)) end) + |> Enum.map(fn {route, pipelines} -> {route.verb, route.path, pipelines} end) + + assert offending == [], + "Non-GET routes on a pipeline with :protect_from_forgery return 403 to API clients: #{inspect(offending)}" + end + + # The /legacy routes are compiled only when API_V1_READ_METHODS_DISABLED is + # not `true`. + if @reading_enabled do + test "all /legacy routes go through :api_v2_no_session" do + legacy_routes = + Enum.filter(routes_with_pipelines(), fn {route, _pipelines} -> String.starts_with?(route.path, "/legacy") end) + + assert legacy_routes != [], "Expected /legacy routes to be compiled" + + for {route, pipelines} <- legacy_routes do + assert pipelines == [:api_v2_no_session], + "Expected #{route.verb} #{route.path} to use :api_v2_no_session, got: #{inspect(pipelines)}" + end + end + end + + # `__routes__/0` does not carry the pipelines in this Phoenix version, so the + # pipelines are read through `Phoenix.Router.route_info/4` for a concrete + # path that matches each route. `forward` entries carry the verb `:*` and + # delegate to another router with its own pipelines, so they are skipped. + defp routes_with_pipelines do + ApiRouter.__routes__() + |> Enum.reject(&(&1.verb == :*)) + |> Enum.map(fn route -> + method = route.verb |> to_string() |> String.upcase() + info = Phoenix.Router.route_info(ApiRouter, method, concrete_path(route.path), "") + + assert %{route: matched_path, pipe_through: pipelines} = info, + "Expected #{method} #{route.path} to be routable, got: #{inspect(info)}" + + assert matched_path == route.path, + "Expected #{method} #{route.path} to match itself, but #{matched_path} matched first" + + {route, pipelines} + end) + end + + # Replaces `:param` and `*glob` segments with a literal so that the path is + # matched by the router. + defp concrete_path(path) do + path + |> String.split("/") + |> Enum.map_join("/", fn + ":" <> _ -> "x" + "*" <> _ -> "x" + segment -> segment + end) + end +end diff --git a/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs b/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs index f4cf42cff1f..e0b2e660484 100644 --- a/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs +++ b/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs @@ -2,58 +2,82 @@ defmodule BlockScoutWeb.Specs.PublicLegacyTagTest do use ExUnit.Case, async: true + use Utils.CompileTimeEnvHelper, + reading_enabled: [:block_scout_web, [BlockScoutWeb.Routers.ApiRouter, :reading_enabled]] + @legacy_paths [ - "/legacy/logs/get-logs", - "/legacy/block/get-block-number-by-time", - "/legacy/block/eth-block-number" + {:get, "/api/legacy/block/get-block-number-by-time"}, + {:post, "/api/legacy/eth/eth-call"}, + {:post, "/api/legacy/eth/eth-get-balance"}, + {:post, "/api/legacy/eth/eth-get-storage-at"}, + {:post, "/api/legacy/eth/eth-send-raw-transaction"}, + {:post, "/api/legacy/eth/eth-block-number"}, + {:post, "/api/legacy/eth/eth-get-logs"} ] setup_all do {:ok, spec: BlockScoutWeb.Specs.Public.spec()} end - for path <- @legacy_paths do - describe "path #{path}" do - test "exists in spec.paths", %{spec: spec} do - path = unquote(path) - assert Map.has_key?(spec.paths, path), "Expected path #{path} to be present in spec.paths" - end + if @reading_enabled do + test "spec declares the legacy tag", %{spec: spec} do + assert Enum.any?(spec.tags, &(&1.name == "legacy")) + end + else + test "spec does not declare the legacy tag when API_V1_READ_METHODS_DISABLED=true", %{spec: spec} do + refute Enum.any?(spec.tags, &(&1.name == "legacy")) + end + end - test "GET operation carries tags: [\"legacy\"]", %{spec: spec} do - path = unquote(path) - path_item = Map.fetch!(spec.paths, path) - operation = path_item.get + if @reading_enabled do + for {method, path} <- @legacy_paths do + describe "#{String.upcase(to_string(method))} #{path}" do + test "path exists in spec.paths", %{spec: spec} do + path = unquote(path) + assert Map.has_key?(spec.paths, path), "Expected path #{path} to be present in spec.paths" + end - assert operation != nil, "Expected a GET operation for #{path}" - assert operation.tags == ["legacy"], "Expected tags [\"legacy\"] on #{path}, got: #{inspect(operation.tags)}" - end + test "operation carries tags: [\"legacy\"]", %{spec: spec} do + path = unquote(path) + method = unquote(method) + operation = operation_for(spec, path, method) - test "GET operation has a declared 200 response", %{spec: spec} do - path = unquote(path) - path_item = Map.fetch!(spec.paths, path) - operation = path_item.get + assert operation != nil, "Expected a #{method} operation for #{path}" + assert operation.tags == ["legacy"], "Expected tags [\"legacy\"] on #{path}, got: #{inspect(operation.tags)}" + end - assert operation != nil, "Expected a GET operation for #{path}" + test "operation has a declared 200 response", %{spec: spec} do + path = unquote(path) + method = unquote(method) + operation = operation_for(spec, path, method) - response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) + assert operation != nil, "Expected a #{method} operation for #{path}" - assert response != nil, - "Expected a 200 response on #{path}, got keys: #{inspect(Map.keys(operation.responses))}" - end + response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) + + assert response != nil, + "Expected a 200 response on #{path}, got keys: #{inspect(Map.keys(operation.responses))}" + end - test "GET 200 response has an application/json schema", %{spec: spec} do - path = unquote(path) - path_item = Map.fetch!(spec.paths, path) - operation = path_item.get + test "200 response has an application/json schema", %{spec: spec} do + path = unquote(path) + method = unquote(method) + operation = operation_for(spec, path, method) - assert operation != nil, "Expected a GET operation for #{path}" + assert operation != nil, "Expected a #{method} operation for #{path}" - response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) - assert response != nil, "Expected a 200 response on #{path}" + response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) + assert response != nil, "Expected a 200 response on #{path}" - schema = get_in(response, [Access.key!(:content), "application/json", Access.key!(:schema)]) - assert schema != nil, "Expected an application/json schema in the 200 response of #{path}" + schema = get_in(response, [Access.key!(:content), "application/json", Access.key!(:schema)]) + assert schema != nil, "Expected an application/json schema in the 200 response of #{path}" + end end end + + defp operation_for(spec, path, method) do + path_item = Map.fetch!(spec.paths, path) + Map.get(path_item, method) + end end end diff --git a/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs b/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs index 296a0066a3f..84bfa646809 100644 --- a/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs @@ -32,7 +32,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -82,7 +82,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -116,7 +116,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -138,7 +138,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -164,7 +164,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -195,7 +195,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -242,7 +242,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() @@ -285,7 +285,7 @@ defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect_once(bypass, "GET", "/config", fn conn -> - Plug.Conn.resp(conn, 200, Jason.encode!(config)) + Plug.Conn.resp(conn, 200, Utils.JSON.encode!(config)) end) RateLimitConfigHelper.store_rate_limit_config() diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs index 635f9451637..d47145f2448 100644 --- a/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs @@ -31,4 +31,74 @@ defmodule BlockScoutWeb.API.V2.TokenViewTest do assert TokenView.render("token.json", %{token: nil}) == nil end end + + describe "prepare_token_instance/2" do + test "includes image_media_type and animation_media_type fields" do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "image/png", + animation_type: "video/mp4" + ) + + result = TokenView.prepare_token_instance(instance, token) + + assert result["image_media_type"] == "image" + assert result["animation_media_type"] == "video" + end + + test "returns nil for media categories when types are nil" do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: nil, + animation_type: nil + ) + + result = TokenView.prepare_token_instance(instance, token) + + assert result["image_media_type"] == nil + assert result["animation_media_type"] == nil + end + + test "returns nil for media categories when types are empty strings" do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "", + animation_type: "" + ) + + result = TokenView.prepare_token_instance(instance, token) + + assert result["image_media_type"] == nil + assert result["animation_media_type"] == nil + end + + test "maps text/html to html category" do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "text/html", + animation_type: "" + ) + + result = TokenView.prepare_token_instance(instance, token) + + assert result["image_media_type"] == "html" + assert result["animation_media_type"] == nil + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs index aa31f0696df..39f01f0db23 100644 --- a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs @@ -185,5 +185,26 @@ defmodule BlockScoutWeb.NFTHelperTest do assert "https://ipfs.io/ipfs/baFybeid4ed2ua7fwupv4nx2ziczr3edhygl7ws3yx6y2juon7xakgj6cfm/51.json" == NFTHelper.compose_resource_url(url) end + + test "transforms bzz link with host and path" do + url = "bzz://swarm-devrel.eth/assets/swarm-logo.svg" + + assert "https://gateway.ethswarm.org/bzz/swarm-devrel.eth/assets/swarm-logo.svg" == + NFTHelper.compose_resource_url(url) + end + + test "transforms bzz link with bare hash" do + hash = "1234abcd" <> String.duplicate("0", 56) + url = "bzz://" <> hash + + assert "https://gateway.ethswarm.org/bzz/#{hash}/" == NFTHelper.compose_resource_url(url) + end + + test "transforms bzz link in different case" do + url = "BzZ://swarm-devrel.eth/assets/swarm-logo.svg" + + assert "https://gateway.ethswarm.org/bzz/swarm-devrel.eth/assets/swarm-logo.svg" == + NFTHelper.compose_resource_url(url) + end end end diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs index 54c8de8613e..42f7e66f5c5 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs @@ -27,7 +27,7 @@ defmodule BlockScoutWeb.Tokens.Instance.OverviewViewTest do } """ - data = Jason.decode!(json) + data = Utils.JSON.decode!(json) assert OverviewView.media_src(%{metadata: data}) == "https://img.paoditu.com/images/cut_trace_images/6b/5f/5b754f6b5f3b5_500_500.jpg" @@ -72,7 +72,7 @@ defmodule BlockScoutWeb.Tokens.Instance.OverviewViewTest do } """ - data = Jason.decode!(json) + data = Utils.JSON.decode!(json) assert OverviewView.media_src(%{metadata: data}) == "https://storage.googleapis.com/poapmedia/manreclaimed-at-metazoo-international-2021-logo-1615826139072.png" @@ -93,7 +93,7 @@ defmodule BlockScoutWeb.Tokens.Instance.OverviewViewTest do } """ - data = Jason.decode!(json) + data = Utils.JSON.decode!(json) assert OverviewView.media_src(%{metadata: data}, true) == "https://assets.cargo.build/611a883b0d039100261bfe79/b89cf189-13e9-47ed-b801-a1f6aa15a7bf/376db72d-f8dc-44bb-b6ac-0e8a31fc6164-comp-1_8mp4.mp4" @@ -114,7 +114,7 @@ defmodule BlockScoutWeb.Tokens.Instance.OverviewViewTest do } """ - data = Jason.decode!(json) + data = Utils.JSON.decode!(json) assert OverviewView.media_src(%{metadata: data}) == "https://assets.cargo.build/611a883b0d039100261bfe79/b89cf189-13e9-47ed-b801-a1f6aa15a7bf/a0784ea0-45be-41cd-9cdd-cc40ad20f20d-zombiepngpng.png" diff --git a/apps/block_scout_web/test/support/api_schema_assertions.ex b/apps/block_scout_web/test/support/api_schema_assertions.ex index 0f2a42de597..77cdcef81a9 100644 --- a/apps/block_scout_web/test/support/api_schema_assertions.ex +++ b/apps/block_scout_web/test/support/api_schema_assertions.ex @@ -52,19 +52,14 @@ defmodule BlockScoutWeb.TestApiSchemaAssertions do defp maybe_assert_schema(_conn, _status_code, _json), do: :ok defp find_path_item(specs, request_path) do - api_relative = strip_api_prefix(request_path) - Enum.reduce_while(specs, :error, fn %{paths: paths} = spec, acc -> - case match_template_path(paths, api_relative) do + case match_template_path(paths, request_path) do {:ok, {_, path_item}} -> {:halt, {:ok, spec, path_item}} _ -> {:cont, acc} end end) end - defp strip_api_prefix("/api" <> rest), do: rest - defp strip_api_prefix(path), do: path - defp match_template_path(paths_map, actual_path) do actual_segments = split_path(actual_path) diff --git a/apps/block_scout_web/test/test_helper.exs b/apps/block_scout_web/test/test_helper.exs index 40f35cfbb9b..153f4053103 100644 --- a/apps/block_scout_web/test/test_helper.exs +++ b/apps/block_scout_web/test/test_helper.exs @@ -37,7 +37,6 @@ Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Beacon, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Stability, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.BridgedTokens, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Filecoin, :manual) -Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Mud, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.ShrunkInternalTransactions, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, :manual) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex index d67809e9b10..c1f715fff3f 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex @@ -196,8 +196,10 @@ defmodule EthereumJSONRPC.Contract do el el -> - {int, _} = Integer.parse(el) - int + case Integer.parse(el) do + {int, ""} -> int + _ -> {:error, :invalid_integer} + end end) end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex index 680ff41471f..ac406a3d7ba 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex @@ -8,6 +8,10 @@ defmodule EthereumJSONRPC.ERC20 do Currently supports fetching token properties like name, symbol and decimals through direct contract calls. + Some older tokens (for example Maker/MKR) return `bytes32` for `name()` / + `symbol()` instead of `string`. The ABI below includes both return types so + decoding can fall back to `bytes32` when the dynamic-string decode fails. + ## Examples iex> EthereumJSONRPC.ERC20.fetch_token_properties( @@ -32,6 +36,8 @@ defmodule EthereumJSONRPC.ERC20 do @selector_name "06fdde03" # symbol() @selector_symbol "95d89b41" + # `string` variants must come before `bytes32` so `Encoder.decode_result/3` + # prefers the standard ERC-20 return type when both decode successfully. @erc20_contract_abi [ %{ "inputs" => [], @@ -59,6 +65,19 @@ defmodule EthereumJSONRPC.ERC20 do "stateMutability" => "view", "type" => "function" }, + %{ + "inputs" => [], + "name" => "name", + "outputs" => [ + %{ + "internalType" => "bytes32", + "name" => "", + "type" => "bytes32" + } + ], + "stateMutability" => "view", + "type" => "function" + }, %{ "inputs" => [], "name" => "symbol", @@ -66,13 +85,24 @@ defmodule EthereumJSONRPC.ERC20 do %{ "internalType" => "string", "name" => "", - # TODO: Research the compatibility of this ABI - # with tokens that return bytes32 for the symbol method "type" => "string" } ], "stateMutability" => "view", "type" => "function" + }, + %{ + "inputs" => [], + "name" => "symbol", + "outputs" => [ + %{ + "internalType" => "bytes32", + "name" => "", + "type" => "bytes32" + } + ], + "stateMutability" => "view", + "type" => "function" } ] @@ -119,7 +149,8 @@ defmodule EthereumJSONRPC.ERC20 do |> Enum.zip(method_ids) |> Enum.reduce(%{}, fn {{:ok, [response]}, method_id}, retval -> - Map.put(retval, atomized_erc20_selector(method_id), response) + property = atomized_erc20_selector(method_id) + Map.put(retval, property, normalize_property(property, response)) {{:error, reason}, method_id}, retval -> Logger.error("[EthereumJSONRPC.ERC20] Failed to fetch token property! \ @@ -146,4 +177,17 @@ defmodule EthereumJSONRPC.ERC20 do defp atomized_erc20_selector(@selector_decimals), do: :decimals defp atomized_erc20_selector(@selector_name), do: :name defp atomized_erc20_selector(@selector_symbol), do: :symbol + + # `bytes32` name/symbol values are null-padded; strip padding to a display string. + defp normalize_property(property, value) when property in [:name, :symbol] and is_binary(value) do + cleaned = String.replace(value, <<0>>, "") + + if cleaned != "" and String.valid?(cleaned) do + cleaned + else + nil + end + end + + defp normalize_property(_property, value), do: value end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex index 8358bcb8c41..7995a203638 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex @@ -184,13 +184,13 @@ defmodule EthereumJSONRPC.HTTP do end end - defp encode_json(data), do: Jason.encode_to_iodata!(data) + defp encode_json(data), do: Utils.JSON.encode_to_iodata!(data) defp decode_json(named_arguments) when is_list(named_arguments) do response = Keyword.fetch!(named_arguments, :response) response_body = Keyword.fetch!(response, :body) - with {:error, _} <- Jason.decode(response_body) do + with {:error, _} <- Utils.JSON.decode(response_body) do case Keyword.fetch!(response, :status_code) do # CloudFlare protected server return HTML errors for 502, so the JSON decode will fail 502 -> @@ -326,15 +326,6 @@ defmodule EthereumJSONRPC.HTTP do end defp headers do - gzip_enabled? = Application.get_env(:ethereum_jsonrpc, __MODULE__)[:gzip_enabled?] - - additional_headers = - if gzip_enabled? do - [{"Accept-Encoding", "gzip"}] - else - [] - end - - Application.get_env(:ethereum_jsonrpc, __MODULE__)[:headers] ++ additional_headers + Application.get_env(:ethereum_jsonrpc, __MODULE__)[:headers] end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex index 0664905f6cf..1c20d4c6f78 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex @@ -23,7 +23,7 @@ defmodule EthereumJSONRPC.HTTP.Helper do """ @spec decode_requests(binary()) :: [map()] def decode_requests(json_string) do - case Jason.decode(json_string) do + case Utils.JSON.decode(json_string) do {:ok, decoded} -> decoded |> List.wrap() |> Enum.filter(&is_map/1) _ -> [] end @@ -127,9 +127,10 @@ defmodule EthereumJSONRPC.HTTP.Helper do """ @spec try_unzip(binary(), [{binary(), binary()}]) :: binary() def try_unzip(body, headers) do - gzip_enabled? = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP)[:gzip_enabled?] + request_compression_all_methods_enabled? = + Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP)[:request_compression_all_methods_enabled?] - if gzip_enabled? do + if request_compression_all_methods_enabled? do do_unzip(body, headers) else body @@ -158,4 +159,50 @@ defmodule EthereumJSONRPC.HTTP.Helper do body end end + + @doc """ + Determines whether HTTP request compression should be enabled for a given JSON-RPC method. + + Compression is enabled when either the all-methods flag is true, or when the + heavy-methods flag is true and the method is classified as heavy. Heavy methods + include trace_*, debug_*, and eth_getBlockReceipts. + + ## Parameters + - `method`: The JSON-RPC method name (binary) or term + - `config`: Optional keyword list of configuration options (defaults to application environment) + + ## Returns + - `true` if request compression should be enabled, `false` otherwise + """ + @spec request_compression_enabled?(binary() | term(), keyword() | nil) :: boolean() + def request_compression_enabled?(method, config \\ Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, [])) do + request_compression_all_methods_enabled? = + Keyword.get(config, :request_compression_all_methods_enabled?, false) + + request_compression_heavy_methods_enabled? = + Keyword.get(config, :request_compression_heavy_methods_enabled?, true) + + request_compression_all_methods_enabled? || + (request_compression_heavy_methods_enabled? && heavy_request_method?(method)) + end + + @doc """ + Determines whether a JSON-RPC method is classified as a "heavy" request. + + Heavy methods are those with high payload or computational requirements, + specifically methods starting with trace_ or debug_ prefixes, or the + eth_getBlockReceipts method. + + ## Parameters + - `method`: The JSON-RPC method name (binary) or any term + + ## Returns + - `true` if the method is classified as heavy, `false` otherwise + """ + @spec heavy_request_method?(binary() | term()) :: boolean() + def heavy_request_method?(method) when is_binary(method) do + String.starts_with?(method, ["trace_", "debug_"]) || method == "eth_getBlockReceipts" + end + + def heavy_request_method?(_method), do: false end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex index 37b20c7f2b6..4a24495a7ee 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex @@ -23,7 +23,7 @@ defmodule EthereumJSONRPC.HTTP.HTTPoison do case HTTPoison.post(url, json, headers, HTTPoisonHelper.request_opts(options)) do {:ok, %HTTPoison.Response{body: body, status_code: status_code, headers: headers}} -> - with {:ok, decoded_body} <- Jason.decode(body), + with {:ok, decoded_body} <- Utils.JSON.decode(body), true <- Helper.response_body_has_error?(decoded_body) do Instrumenter.json_rpc_errors(method, l1?) end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex index f7154c7fe82..2b15c9f9dbe 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex @@ -18,19 +18,22 @@ defmodule EthereumJSONRPC.HTTP.Tesla do requests = Helper.decode_requests(json) method = requests |> List.first(%{}) |> Map.get("method") l1? = Keyword.get(options, :layer) == :l1 + request_compression_enabled? = Helper.request_compression_enabled?(method) Instrumenter.json_rpc_requests(method, l1?) Helper.track_json_rpc_calls(requests, l1?) Helper.track_eth_call_methods(requests, l1?) - case do_post(url, json, headers, options) do + case do_post(url, request_compression_enabled?, json, headers, options) do {:ok, %Tesla.Env{body: body, status: status_code, headers: headers}} -> - with {:ok, decoded_body} <- Jason.decode(body), + with {:ok, decoded_body} <- Utils.JSON.decode(body), true <- Helper.response_body_has_error?(decoded_body) do Instrumenter.json_rpc_errors(method, l1?) end - {:ok, %{body: Helper.try_unzip(body, headers), status_code: status_code}} + response_body = if request_compression_enabled?, do: body, else: Helper.try_unzip(body, headers) + + {:ok, %{body: response_body, status_code: status_code}} {:error, error} -> Instrumenter.json_rpc_errors(method, l1?) @@ -41,8 +44,10 @@ defmodule EthereumJSONRPC.HTTP.Tesla do def json_rpc(url, _json, _headers, _options) when is_nil(url), do: {:error, "URL is nil"} - defp do_post(url, json, headers, options) do - Tesla.post(TeslaHelper.client(options), url, json, + defp do_post(url, request_compression_enabled?, json, headers, options) do + client = TeslaHelper.client(options, compression_middleware(request_compression_enabled?)) + + Tesla.post(client, url, json, headers: headers, opts: TeslaHelper.request_opts(options) ) @@ -70,7 +75,17 @@ defmodule EthereumJSONRPC.HTTP.Tesla do defp log_normalized_timeout(source, url, headers, options) do Logger.warning( - "Normalized timeout in do_post/4 source=#{source} url=#{inspect(url)} timeout=#{inspect(options[:timeout])} recv_timeout=#{inspect(options[:recv_timeout])} headers_count=#{length(headers)}" + "Normalized timeout in do_post/5 source=#{source} url=#{inspect(url)} timeout=#{inspect(options[:timeout])} recv_timeout=#{inspect(options[:recv_timeout])} headers_count=#{length(headers)}" ) end + + defp compression_middleware(true) do + max_body_size = + Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, []) + |> Keyword.get(:response_decompression_max_body_size, 100 * 1024 * 1024) + + [{Tesla.Middleware.Compression, format: "gzip", max_body_size: max_body_size}] + end + + defp compression_middleware(false), do: [] end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex index 5cd19112669..3717faa6381 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex @@ -64,8 +64,8 @@ defmodule EthereumJSONRPC.IPC do # Client def request(pid, payload) do - with {:ok, response} <- post(pid, Jason.encode!(payload)), - {:ok, decoded_body} <- Jason.decode(response) do + with {:ok, response} <- post(pid, Utils.JSON.encode!(payload)), + {:ok, decoded_body} <- Utils.JSON.decode(response) do case decoded_body do %{"error" => error} -> {:error, error} @@ -83,7 +83,7 @@ defmodule EthereumJSONRPC.IPC do {:ok, Map.get(result, "result")} end else - {:error, %Jason.DecodeError{data: ""}} -> {:error, :empty_response} + {:error, %JSON.DecodeError{data: ""}} -> {:error, :empty_response} {:error, error} -> {:error, {:invalid_json, error}} end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex index 4e5f2215e0e..7750794ca63 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex @@ -5,7 +5,6 @@ defmodule EthereumJSONRPC.Receipt do [`eth_getTransactionReceipt`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_gettransactionreceipt). """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - use Utils.RuntimeEnvHelper, op_isthmus_timestamp: [:indexer, [Indexer.Fetcher.Optimism, :isthmus_timestamp_l2]] import EthereumJSONRPC, only: [quantity_to_integer: 1] @@ -133,8 +132,8 @@ defmodule EthereumJSONRPC.Receipt do l1_fee_scalar: 0,\ l1_gas_price: 0,\ l1_gas_used: 0,\ - operator_fee_scalar: nil,\ - operator_fee_constant: nil,\ + operator_fee_scalar: 0,\ + operator_fee_constant: 0,\ da_footprint_gas_scalar: nil\ """ :scroll -> """ @@ -188,8 +187,8 @@ defmodule EthereumJSONRPC.Receipt do l1_fee_scalar: 0,\ l1_gas_price: 0,\ l1_gas_used: 0,\ - operator_fee_scalar: nil,\ - operator_fee_constant: nil,\ + operator_fee_scalar: 0,\ + operator_fee_constant: 0,\ da_footprint_gas_scalar: nil\ """ :scroll -> """ @@ -254,21 +253,14 @@ defmodule EthereumJSONRPC.Receipt do :optimism -> defp chain_type_fields(params, elixir) do - {operator_fee_scalar_default, operator_fee_constant_default} = - if is_nil(op_isthmus_timestamp()) do - {nil, nil} - else - {0, 0} - end - params |> Map.merge(%{ l1_fee: Map.get(elixir, "l1Fee", 0), l1_fee_scalar: Map.get(elixir, "l1FeeScalar", 0), l1_gas_price: Map.get(elixir, "l1GasPrice", 0), l1_gas_used: Map.get(elixir, "l1GasUsed", 0), - operator_fee_scalar: Map.get(elixir, "operatorFeeScalar", operator_fee_scalar_default), - operator_fee_constant: Map.get(elixir, "operatorFeeConstant", operator_fee_constant_default), + operator_fee_scalar: Map.get(elixir, "operatorFeeScalar", 0), + operator_fee_constant: Map.get(elixir, "operatorFeeConstant", 0), da_footprint_gas_scalar: Map.get(elixir, "daFootprintGasScalar") }) end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex index 7ff22385111..ec379d378ff 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex @@ -113,8 +113,8 @@ defmodule EthereumJSONRPC.Receipts do l1_fee_scalar: 0,\ l1_gas_price: 0,\ l1_gas_used: 0,\ - operator_fee_scalar: nil,\ - operator_fee_constant: nil,\ + operator_fee_scalar: 0,\ + operator_fee_constant: 0,\ da_footprint_gas_scalar: nil\ """ :scroll -> """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex index 9cd09d0d47e..f3a1dad78f9 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex @@ -56,6 +56,7 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityChecker do alias EthereumJSONRPC.Utility.EndpointAvailabilityObserver @check_interval :timer.seconds(1) + @sync_interval :timer.minutes(1) def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) @@ -64,6 +65,7 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityChecker do def init(_) do if Application.get_env(:ethereum_jsonrpc, __MODULE__)[:enabled] do schedule_next_check() + schedule_sync() {:ok, %{unavailable_endpoints_arguments: []}} else @@ -133,6 +135,19 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityChecker do {:noreply, %{state | unavailable_endpoints_arguments: new_unavailable_endpoints}} end + def handle_info(:sync, %{unavailable_endpoints_arguments: unavailable_endpoints_arguments} = state) do + unavailable_endpoints_arguments + |> Enum.map(fn {json_rpc_named_arguments, url_type} -> + [url] = json_rpc_named_arguments[:transport_options][:urls] + {url_type, url} + end) + |> EndpointAvailabilityObserver.sync_unavailable_urls() + + schedule_sync() + + {:noreply, state} + end + @doc """ Retrieves the latest block number from an Ethereum node to check endpoint availability. @@ -159,4 +174,8 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityChecker do defp schedule_next_check do Process.send_after(self(), :check, @check_interval) end + + defp schedule_sync do + Process.send_after(self(), :sync, @sync_interval) + end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex index e434525a928..b6c965ea06e 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex @@ -73,6 +73,7 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityObserver do @max_error_count 3 @window_duration 3 @cleaning_interval :timer.seconds(1) + @empty_unavailable_endpoints %{ws: [], trace: [], http: [], eth_call: []} @type url_type :: :ws | :trace | :http | :eth_call @@ -83,7 +84,7 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityObserver do def init(_) do schedule_next_cleaning() - {:ok, %{error_counts: %{}, unavailable_endpoints: %{ws: [], trace: [], http: [], eth_call: []}}} + {:ok, %{error_counts: %{}, unavailable_endpoints: @empty_unavailable_endpoints}} end @doc """ @@ -134,6 +135,20 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityObserver do GenServer.call(__MODULE__, {:filter_unavailable_urls, urls, url_type}) end + @doc """ + Sets the actual unavailable endpoints to provided params. + + ## Parameters + - `urls_with_types`: List of `{url_type, url}` tuples + + ## Returns + - `:ok` + """ + @spec sync_unavailable_urls([{url_type(), String.t()}]) :: :ok + def sync_unavailable_urls(urls_with_types) do + GenServer.cast(__MODULE__, {:sync_unavailable_urls, urls_with_types}) + end + @doc """ Checks if the given endpoint is marked as unavailable and if it is, replaces it with a fallback URL if provided. @@ -270,6 +285,15 @@ defmodule EthereumJSONRPC.Utility.EndpointAvailabilityObserver do %{state | unavailable_endpoints: %{unavailable_endpoints | url_type => unavailable_endpoints[url_type] -- [url]}}} end + def handle_cast({:sync_unavailable_urls, urls_with_types}, state) do + unavailable_endpoints = + urls_with_types + |> Enum.group_by(fn {url_type, _} -> url_type end, fn {_, url} -> url end) + |> Map.new() + + {:noreply, %{state | unavailable_endpoints: Map.merge(@empty_unavailable_endpoints, unavailable_endpoints)}} + end + # For each URL and URL type combination, checks if the last error timestamp is # within the window duration (an error happened recently). If not, removes those # error records. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex index c78f94133a6..168b81849c7 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex @@ -140,7 +140,7 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClient do end def handle_frame({:text, text}, state) do - case Jason.decode(text) do + case Utils.JSON.decode(text) do {:ok, json} -> handle_response(json, state) @@ -284,7 +284,7 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClient do end defp frame(request) do - {:text, Jason.encode!(request)} + {:text, Utils.JSON.encode!(request)} end defp handle_response( diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex index 8eb381d0384..8063e00e0b2 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex @@ -52,7 +52,7 @@ defmodule EthereumJSONRPC.Zilliqa.Helper do @spec legacy_bit_vector_to_signers(binary()) :: Zilliqa.signers() def legacy_bit_vector_to_signers(bit_list_json_string) do bit_list_json_string - |> Jason.decode!() + |> Utils.JSON.decode!() |> Enum.with_index() |> Enum.filter(fn {bit, _} -> bit == 1 end) |> Enum.map(fn {_, index} -> index end) diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 34e3556dd9f..84a99a9cbd4 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.3.3" + version: "12.0.0" ] end @@ -64,8 +64,7 @@ defmodule EthereumJSONRPC.MixProject do {:ex_keccak, "~> 0.7.5"}, # JSONRPC HTTP Post calls {:httpoison, "~> 2.0"}, - # Decode/Encode JSON for JSONRPC - {:jason, "~> 1.3"}, + # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, {:logger_json, "~> 7.0"}, diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/erc20_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/erc20_test.exs new file mode 100644 index 00000000000..48576bff682 --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/erc20_test.exs @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.ERC20Test do + use ExUnit.Case, async: true + + import Mox + + alias EthereumJSONRPC.ERC20 + + setup :verify_on_exit! + + setup do + %{ + json_rpc_named_arguments: [ + transport: EthereumJSONRPC.Mox, + transport_options: [] + ] + } + end + + describe "fetch_token_properties/3" do + test "decodes standard string name and symbol", %{json_rpc_named_arguments: json_rpc_named_arguments} do + token_address = "0xdac17f958d2ee523a2206206994597c13d831ec7" + + expect(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_call", params: [%{data: "0x313ce567", to: ^token_address}, "latest"]} -> + %{id: id, result: "0x0000000000000000000000000000000000000000000000000000000000000006"} + + %{id: id, method: "eth_call", params: [%{data: "0x06fdde03", to: ^token_address}, "latest"]} -> + %{id: id, result: abi_encoded_string("Tether USD")} + + %{id: id, method: "eth_call", params: [%{data: "0x95d89b41", to: ^token_address}, "latest"]} -> + %{id: id, result: abi_encoded_string("USDT")} + end)} + end) + + assert ERC20.fetch_token_properties( + token_address, + [:decimals, :name, :symbol], + json_rpc_named_arguments + ) == %{ + decimals: 6, + name: "Tether USD", + symbol: "USDT" + } + end + + test "falls back to bytes32 name and symbol", %{json_rpc_named_arguments: json_rpc_named_arguments} do + token_address = "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2" + + expect(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_call", params: [%{data: "0x313ce567", to: ^token_address}, "latest"]} -> + %{id: id, result: "0x0000000000000000000000000000000000000000000000000000000000000012"} + + %{id: id, method: "eth_call", params: [%{data: "0x06fdde03", to: ^token_address}, "latest"]} -> + %{ + id: id, + result: "0x4d616b6572000000000000000000000000000000000000000000000000000000" + } + + %{id: id, method: "eth_call", params: [%{data: "0x95d89b41", to: ^token_address}, "latest"]} -> + %{ + id: id, + result: "0x4d4b520000000000000000000000000000000000000000000000000000000000" + } + end)} + end) + + assert ERC20.fetch_token_properties( + token_address, + [:decimals, :name, :symbol], + json_rpc_named_arguments + ) == %{ + decimals: 18, + name: "Maker", + symbol: "MKR" + } + end + end + + defp abi_encoded_string(str) do + encoded = + ABI.TypeEncoder.encode([str], %ABI.FunctionSelector{ + function: nil, + types: [:string] + }) + + "0x" <> Base.encode16(encoded, case: :lower) + end +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs index 0d55feef46b..fb7bdbc5816 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs @@ -7,7 +7,8 @@ defmodule EthereumJSONRPC.Geth.TracerTest do describe "replay/3" do test "same as callTracer" do - struct_logs = File.read!(File.cwd!() <> "/test/support/fixture/geth/trace/struct_logger.json") |> Jason.decode!() + struct_logs = + File.read!(File.cwd!() <> "/test/support/fixture/geth/trace/struct_logger.json") |> Utils.JSON.decode!() transaction = "0xa0a5c30c5c5ec22b3346e0ae5ce09f8f41faf54f68a2a113eb15e363af90e9ab" @@ -29,7 +30,7 @@ defmodule EthereumJSONRPC.Geth.TracerTest do calls = File.read!(File.cwd!() <> "/test/support/fixture/geth/trace/calltracer.json") - |> Jason.decode!() + |> Utils.JSON.decode!() |> Map.get("result") ct_calls = diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/helper_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/helper_test.exs new file mode 100644 index 00000000000..62723b19dec --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/helper_test.exs @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.HTTP.HelperTest do + use ExUnit.Case, async: true + + alias EthereumJSONRPC.HTTP.Helper + + describe "heavy_request_method?/1" do + test "returns true for trace methods" do + assert Helper.heavy_request_method?("trace_block") + end + + test "returns true for debug methods" do + assert Helper.heavy_request_method?("debug_traceTransaction") + end + + test "returns true for eth_getBlockReceipts" do + assert Helper.heavy_request_method?("eth_getBlockReceipts") + end + + test "returns false for non-heavy methods" do + refute Helper.heavy_request_method?("eth_getBlockByNumber") + refute Helper.heavy_request_method?(nil) + refute Helper.heavy_request_method?({:error, :invalid_json}) + end + end + + describe "request_compression_enabled?/2" do + test "enables compression for heavy methods by default" do + config = [request_compression_heavy_methods_enabled?: true, request_compression_all_methods_enabled?: false] + + assert Helper.request_compression_enabled?("trace_block", config) + assert Helper.request_compression_enabled?("debug_traceBlockByNumber", config) + assert Helper.request_compression_enabled?("eth_getBlockReceipts", config) + end + + test "does not enable non-heavy methods by default" do + config = [request_compression_heavy_methods_enabled?: true, request_compression_all_methods_enabled?: false] + + refute Helper.request_compression_enabled?("eth_blockNumber", config) + end + + test "enables all methods when all-methods flag is enabled" do + config = [request_compression_heavy_methods_enabled?: false, request_compression_all_methods_enabled?: true] + + assert Helper.request_compression_enabled?("eth_blockNumber", config) + assert Helper.request_compression_enabled?("trace_block", config) + end + + test "disables all methods when both flags are disabled" do + config = [request_compression_heavy_methods_enabled?: false, request_compression_all_methods_enabled?: false] + + refute Helper.request_compression_enabled?("eth_blockNumber", config) + refute Helper.request_compression_enabled?("trace_block", config) + end + end +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs index bdf832ab7ef..ac30731cfb7 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs @@ -66,7 +66,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do |> Enum.map(fn id -> %{jsonrpc: "2.0", id: id, result: %{number: EthereumJSONRPC.integer_to_quantity(id)}} end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -82,7 +82,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do |> Enum.map(fn id -> %{jsonrpc: "2.0", id: id, result: %{number: EthereumJSONRPC.integer_to_quantity(id)}} end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -149,7 +149,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do ] } end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -181,7 +181,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do ] } end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -241,7 +241,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do ] } end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -273,7 +273,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do ] } end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -307,14 +307,14 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do if json_rpc_named_arguments[:transport_options][:http] == EthereumJSONRPC.HTTP.Mox do EthereumJSONRPC.HTTP.Mox |> expect(:json_rpc, 5, fn _url, json, _headers, _options -> - assert [%{}, %{}] = decoded = Jason.decode!(json) + assert [%{}, %{}] = decoded = Utils.JSON.decode!(json) body = decoded |> Enum.map(fn %{"id" => id} -> %{jsonrpc: "2.0", id: id, result: %{number: EthereumJSONRPC.integer_to_quantity(id)}} end) - |> Jason.encode!() + |> Utils.JSON.encode!() {:ok, %{body: body, status_code: 200}} end) @@ -433,7 +433,7 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do http = Keyword.fetch!(transport_options, :http) url = transport_options |> Keyword.fetch!(:urls) |> List.first() - json = Jason.encode_to_iodata!(payload) + json = Utils.JSON.encode_to_iodata!(payload) http_options = Keyword.fetch!(transport_options, :http_options) assert {:ok, %{body: body, status_code: 413}} = http.json_rpc(url, json, [], http_options) diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/tesla_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/tesla_test.exs new file mode 100644 index 00000000000..2784674bbc9 --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/tesla_test.exs @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.HTTP.TeslaTest do + use ExUnit.Case, async: false + + import Mox + + alias EthereumJSONRPC.HTTP.Tesla + + setup :verify_on_exit! + + setup do + original_http_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, []) + original_tesla_adapter = Application.get_env(:tesla, :adapter) + + Application.put_env(:tesla, :adapter, EthereumJSONRPC.TeslaAdapter.Mox) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, original_http_config) + Application.put_env(:tesla, :adapter, original_tesla_adapter) + end) + + :ok + end + + describe "json_rpc/4 request compression" do + test "compresses request body for heavy methods by default" do + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, + request_compression_heavy_methods_enabled?: true, + request_compression_all_methods_enabled?: false + ) + + response_body = ~s({"jsonrpc":"2.0","id":1,"result":"0x1"}) + url = "http://example.com" + json = ~s({"jsonrpc":"2.0","id":1,"method":"debug_traceTransaction","params":["0xabc",{}]}) + + EthereumJSONRPC.TeslaAdapter.Mox + |> expect(:call, fn %Elixir.Tesla.Env{} = env, _opts -> + assert env.url == url + assert header_value(env.headers, "content-encoding") == "gzip" + assert header_value(env.headers, "accept-encoding") == "gzip, deflate, identity" + + assert :zlib.gunzip(env.body) == json + + {:ok, + %Elixir.Tesla.Env{ + env + | status: 200, + body: response_body, + headers: [{"content-type", "application/json"}] + }} + end) + + assert {:ok, %{status_code: 200, body: ^response_body}} = Tesla.json_rpc(url, json, [], []) + end + + test "does not compress non-heavy method when all-methods flag is disabled" do + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, + request_compression_heavy_methods_enabled?: true, + request_compression_all_methods_enabled?: false + ) + + response_body = ~s({"jsonrpc":"2.0","id":1,"result":"0x10"}) + url = "http://example.com" + json = ~s({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}) + + EthereumJSONRPC.TeslaAdapter.Mox + |> expect(:call, fn %Elixir.Tesla.Env{} = env, _opts -> + assert env.url == url + refute header_value(env.headers, "content-encoding") + refute header_value(env.headers, "accept-encoding") + assert env.body == json + + {:ok, + %Elixir.Tesla.Env{ + env + | status: 200, + body: response_body, + headers: [{"content-type", "application/json"}] + }} + end) + + assert {:ok, %{status_code: 200, body: ^response_body}} = Tesla.json_rpc(url, json, [], []) + end + + test "compresses non-heavy method when all-methods flag is enabled" do + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, + request_compression_heavy_methods_enabled?: false, + request_compression_all_methods_enabled?: true + ) + + response_body = ~s({"jsonrpc":"2.0","id":1,"result":"0x10"}) + url = "http://example.com" + json = ~s({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}) + + EthereumJSONRPC.TeslaAdapter.Mox + |> expect(:call, fn %Elixir.Tesla.Env{} = env, _opts -> + assert env.url == url + assert header_value(env.headers, "content-encoding") == "gzip" + assert header_value(env.headers, "accept-encoding") == "gzip, deflate, identity" + assert :zlib.gunzip(env.body) == json + + {:ok, + %Elixir.Tesla.Env{ + env + | status: 200, + body: response_body, + headers: [{"content-type", "application/json"}] + }} + end) + + assert {:ok, %{status_code: 200, body: ^response_body}} = Tesla.json_rpc(url, json, [], []) + end + end + + defp header_value(headers, key) do + normalized_key = String.downcase(key) + + headers + |> Enum.find_value(fn {header_key, header_value} -> + if String.downcase(header_key) == normalized_key do + header_value + end + end) + end +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs index cfc762a44c1..4fae0512779 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs @@ -50,14 +50,14 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClientTest do describe "handle_frame/2" do setup :example_state - test "Jason.decode errors are broadcast to all subscribers", %{state: %WebSocketClient{url: url} = state} do + test "JSON decode errors are broadcast to all subscribers", %{state: %WebSocketClient{url: url} = state} do subscription_id = 1 subscription_reference = make_ref() subscription = subscription(%{url: url, reference: subscription_reference}) state = put_subscription(state, subscription_id, subscription) assert {:ok, ^state} = WebSocketClient.handle_frame({:text, ""}, state) - assert_receive {^subscription, {:error, %Jason.DecodeError{}}} + assert_receive {^subscription, {:error, {:unexpected_end, 0}}} end end diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex index 0862d0a2874..f0100df521f 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex @@ -18,13 +18,13 @@ defmodule EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler do %__MODULE__{subscription_id_set: subscription_id_set, new_heads_timer_reference: new_heads_timer_reference} = state ) do - json = Jason.decode!(text) + json = Utils.JSON.decode!(text) case json do %{"id" => id, "method" => "eth_subscribe", "params" => ["newHeads"]} -> subscription_id = :erlang.unique_integer() response = %{id: id, result: subscription_id} - frame = {:text, Jason.encode!(response)} + frame = {:text, Utils.JSON.encode!(response)} new_heads_timer_reference = case new_heads_timer_reference do @@ -45,7 +45,7 @@ defmodule EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler do %{"id" => id, "method" => "echo", "params" => params} -> response = %{id: id, result: params} - frame = {:text, Jason.encode!(response)} + frame = {:text, Utils.JSON.encode!(response)} {:reply, frame, state} end end @@ -55,7 +55,7 @@ defmodule EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler do frames = Enum.map(subscription_id_set, fn subscription_id -> response = %{method: "eth_subscription", params: %{result: %{}, subscription: subscription_id}} - {:text, Jason.encode!(response)} + {:text, Utils.JSON.encode!(response)} end) {:reply, frames, state} diff --git a/apps/ethereum_jsonrpc/test/test_helper.exs b/apps/ethereum_jsonrpc/test/test_helper.exs index decbd968582..5a52935d074 100644 --- a/apps/ethereum_jsonrpc/test/test_helper.exs +++ b/apps/ethereum_jsonrpc/test/test_helper.exs @@ -12,6 +12,8 @@ Mox.defmock(EthereumJSONRPC.Mox, for: EthereumJSONRPC.Transport) Mox.defmock(EthereumJSONRPC.HTTP.Mox, for: EthereumJSONRPC.HTTP) # for when we need to simulate WebSocket-specific stuff Mox.defmock(EthereumJSONRPC.WebSocket.Mox, for: EthereumJSONRPC.WebSocket) +# for when we need to assert Tesla request/response adapter-level behavior +Mox.defmock(EthereumJSONRPC.TeslaAdapter.Mox, for: Tesla.Adapter) ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) ExUnit.start() diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 6225ba37de8..43fd0e0aa97 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -106,8 +106,6 @@ config :explorer, Explorer.TokenInstanceOwnerAddressMigration.Supervisor, enable config :explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, enabled: false -config :explorer, Explorer.Chain.Mud, enabled: ConfigHelper.parse_bool_env_var("MUD_INDEXER_ENABLED") - config :explorer, Explorer.Utility.VersionConstantsUpdater, enabled: true config :explorer, Explorer.Utility.VersionUpgrade, enabled: true @@ -120,6 +118,8 @@ for migrator <- [ Explorer.Migrator.SanitizeMissingBlockRanges, Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers, Explorer.Migrator.TokenTransferTokenType, + Explorer.Migrator.BackfillScaledUIAmountTokens, + Explorer.Migrator.SanitizeScaledUIAmountTokenTransferTypes, Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers, Explorer.Migrator.TransactionBlockConsensus, Explorer.Migrator.TokenTransferBlockConsensus, @@ -130,6 +130,7 @@ for migrator <- [ Explorer.Migrator.SanitizeDuplicatedLogIndexLogs, Explorer.Migrator.RefetchContractCodes, Explorer.Migrator.BackfillMultichainSearchDB, + Explorer.Migrator.BackfillMultichainSearchDbCurrentTokenBalances, Explorer.Migrator.SanitizeVerifiedAddresses, Explorer.Migrator.SanitizeEmptyContractCodeAddresses, Explorer.Migrator.BackfillMetadataURL, @@ -142,6 +143,10 @@ for migrator <- [ Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, + Explorer.Migrator.TransactionHasTokenTransfers, + Explorer.Migrator.DeleteNonConsensusLogs, + Explorer.Migrator.FillLogsOptimizedFields, + Explorer.Migrator.FillLogsCompressedData, Explorer.Migrator.BackfillAddressCounters, Explorer.Migrator.BackfillTokenCounters ] do @@ -175,6 +180,8 @@ for index_operation <- [ Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsToAddressHashWithPendingIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountDescPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateUnfetchedAddressTokenBalancesV3Index, + Explorer.Migrator.HeavyDbIndexOperation.DropUnfetchedAddressTokenBalancesV2Index, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBlockNumberTransactionIndexNotNull, @@ -191,13 +198,30 @@ for index_operation <- [ Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsAddressHashes, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, - Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesAddressHashBlockNumberIndex + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesAddressHashBlockNumberIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsTokenTransferMethodIdOrderedIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsBlockNumberTransactionIndexNotNull, + Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdBlockNumberDescIndexDescIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndexWithUpdatedPk, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashBlockNumberDescIndexDescIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicBlockNumberIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsDepositsWithdrawalsIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsFirstTopicIdFkey, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesTokenHoldersIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropAddressCurrentTokenBalancesTokenContractAddressHashValueIndex ] do config :explorer, index_operation, enabled: true end diff --git a/apps/explorer/config/dev.exs b/apps/explorer/config/dev.exs index b2551e30a81..be069a65943 100644 --- a/apps/explorer/config/dev.exs +++ b/apps/explorer/config/dev.exs @@ -23,7 +23,6 @@ for repo <- [ Explorer.Repo.Celo, Explorer.Repo.Eden, Explorer.Repo.Filecoin, - Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, Explorer.Repo.RSK, diff --git a/apps/explorer/config/prod.exs b/apps/explorer/config/prod.exs index e942be53214..398d00d7328 100644 --- a/apps/explorer/config/prod.exs +++ b/apps/explorer/config/prod.exs @@ -24,7 +24,6 @@ for repo <- [ Explorer.Repo.Celo, Explorer.Repo.Eden, Explorer.Repo.Filecoin, - Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, Explorer.Repo.RSK, diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index ebec4ea9864..f9803b0706e 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -23,6 +23,7 @@ config :explorer, Explorer.Chain.Cache.Counters.TokenCountersConsolidator, enabl for counter <- [ Explorer.Chain.Cache.Counters.AddressesCount, Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount, + Explorer.Chain.Cache.Counters.Optimism.DepositsCount, Explorer.Chain.Cache.Counters.Transactions24hCount, Explorer.Chain.Cache.Counters.NewPendingTransactionsCount, Explorer.Chain.Cache.Counters.ContractsCount, @@ -87,6 +88,10 @@ for migrator <- [ Explorer.Migrator.DeleteZeroValueInternalTransactions, Explorer.Migrator.EmptyInternalTransactionsData, Explorer.Migrator.FillInternalTransactionsAddressIds, + Explorer.Migrator.TransactionHasTokenTransfers, + Explorer.Migrator.DeleteNonConsensusLogs, + Explorer.Migrator.FillLogsOptimizedFields, + Explorer.Migrator.FillLogsCompressedData, Explorer.Migrator.BackfillAddressCounters, Explorer.Migrator.BackfillTokenCounters, @@ -132,13 +137,28 @@ for migrator <- [ Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsAddressHashes, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, - Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesAddressHashBlockNumberIndex + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesAddressHashBlockNumberIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsTokenTransferMethodIdOrderedIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsBlockNumberTransactionIndexNotNull, + Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdBlockNumberDescIndexDescIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndexWithUpdatedPk, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashBlockNumberDescIndexDescIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicBlockNumberIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropLogsDepositsWithdrawalsIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsFirstTopicIdFkey, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex ] do config :explorer, migrator, enabled: false end diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 7cdfe5e4ec4..04a4d90f966 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -75,7 +75,6 @@ for repo <- [ Explorer.Repo.Celo, Explorer.Repo.Eden, Explorer.Repo.Filecoin, - Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, Explorer.Repo.RSK, diff --git a/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex b/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex index 6228da8ba76..ad25eb7a245 100644 --- a/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex +++ b/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex @@ -253,7 +253,7 @@ defmodule Explorer.Account.Auth0ToKeycloakMigration do text |> String.split("\n", trim: true) |> Map.new(fn line -> - {:ok, %{"user_id" => user_id} = user} = Jason.decode(line) + {:ok, %{"user_id" => user_id} = user} = Utils.JSON.decode(line) {user_id, user} end) @@ -725,9 +725,9 @@ defmodule Explorer.Account.Auth0ToKeycloakMigration do with {:ok, token} <- get_keycloak_admin_token() do url = keycloak_url("/admin/realms/#{URI.encode(keycloak_realm())}/partialImport") - case HttpClient.post(url, Jason.encode!(body), keycloak_auth_headers(token) ++ @json_headers) do + case HttpClient.post(url, Utils.JSON.encode!(body), keycloak_auth_headers(token) ++ @json_headers) do {:ok, %{status_code: 200, body: resp_body}} -> - Jason.decode(resp_body) + Utils.JSON.decode(resp_body) {:ok, %{status_code: status, body: resp_body}} -> {:error, "HTTP #{status}: #{resp_body}"} @@ -765,7 +765,7 @@ defmodule Explorer.Account.Auth0ToKeycloakMigration do case HttpClient.post(url, body, [{"content-type", "application/x-www-form-urlencoded"}]) do {:ok, %{status_code: 200, body: resp_body}} -> - case Jason.decode(resp_body) do + case Utils.JSON.decode(resp_body) do {:ok, %{"access_token" => token, "expires_in" => ttl}} -> Process.put(:keycloak_admin_token, {token, System.system_time(:second) + ttl - 30}) {:ok, token} diff --git a/apps/explorer/lib/explorer/account/custom_abi.ex b/apps/explorer/lib/explorer/account/custom_abi.ex index a63c17ac1a5..6f0f771c28b 100644 --- a/apps/explorer/lib/explorer/account/custom_abi.ex +++ b/apps/explorer/lib/explorer/account/custom_abi.ex @@ -87,7 +87,7 @@ defmodule Explorer.Account.CustomABI do defp validate_custom_abi(custom_abi), do: custom_abi defp check_is_abi_valid?(%{abi: abi} = custom_abi) when is_binary(abi) do - with {:ok, decoded} <- Jason.decode(abi), + with {:ok, decoded} <- Utils.JSON.decode(abi), true <- is_list(decoded) do custom_abi |> Map.put(:abi, decoded) @@ -113,7 +113,7 @@ defmodule Explorer.Account.CustomABI do custom_abi |> Map.put(:abi, "") |> (&if(is_nil(given_abi), - do: Map.put(&1, :given_abi, Jason.encode!(abi)), + do: Map.put(&1, :given_abi, Utils.JSON.encode!(abi)), else: Map.put(&1, :given_abi, given_abi) )).() |> Map.put(:abi_validating_error, "ABI must contain functions") diff --git a/apps/explorer/lib/explorer/account/identity.ex b/apps/explorer/lib/explorer/account/identity.ex index a50ffdf003c..4dfa7eee140 100644 --- a/apps/explorer/lib/explorer/account/identity.ex +++ b/apps/explorer/lib/explorer/account/identity.ex @@ -6,7 +6,6 @@ defmodule Explorer.Account.Identity do use Explorer.Schema require Logger - require Poison alias BlockScoutWeb.Chain alias Ecto.Multi diff --git a/apps/explorer/lib/explorer/account/notifier/email.ex b/apps/explorer/lib/explorer/account/notifier/email.ex index 6eab3f9f0c5..8d7532b1317 100644 --- a/apps/explorer/lib/explorer/account/notifier/email.ex +++ b/apps/explorer/lib/explorer/account/notifier/email.ex @@ -56,6 +56,9 @@ defmodule Explorer.Account.Notifier.Email do "ERC-20" -> amount + "ERC-8056" -> + amount + "ZRC-2" -> amount diff --git a/apps/explorer/lib/explorer/account/notifier/notify.ex b/apps/explorer/lib/explorer/account/notifier/notify.ex index 7bbaf6a0696..9e384fc5cf6 100644 --- a/apps/explorer/lib/explorer/account/notifier/notify.ex +++ b/apps/explorer/lib/explorer/account/notifier/notify.ex @@ -148,6 +148,9 @@ defmodule Explorer.Account.Notifier.Notify do {"COIN", :outgoing} -> address.watch_coin_output {"ERC-20", :incoming} -> address.watch_erc_20_input {"ERC-20", :outgoing} -> address.watch_erc_20_output + # ERC-8056 tokens are ERC-20 tokens, so they follow the same watch flags + {"ERC-8056", :incoming} -> address.watch_erc_20_input + {"ERC-8056", :outgoing} -> address.watch_erc_20_output {"ERC-721", :incoming} -> address.watch_erc_721_input {"ERC-721", :outgoing} -> address.watch_erc_721_output {"ERC-1155", :incoming} -> address.watch_erc_1155_input diff --git a/apps/explorer/lib/explorer/account/notifier/summary.ex b/apps/explorer/lib/explorer/account/notifier/summary.ex index 827efeedd14..6b1061f829f 100644 --- a/apps/explorer/lib/explorer/account/notifier/summary.ex +++ b/apps/explorer/lib/explorer/account/notifier/summary.ex @@ -9,7 +9,8 @@ defmodule Explorer.Account.Notifier.Summary do alias Explorer alias Explorer.Account.Notifier.Summary alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Transaction, Wei} + alias Explorer.Chain.{Token, Transaction, Wei} + alias Explorer.Chain.Token.ScaledUIAmount @unknown "Unknown" @@ -101,14 +102,14 @@ defmodule Explorer.Account.Notifier.Summary do %Chain.TokenTransfer{} = transfer ) do case transfer.token.type do - type when type in ["ERC-20", "ZRC-2"] -> + type when type in ["ERC-20", "ERC-8056", "ZRC-2"] -> %Summary{ transaction_hash: transaction.hash, method: method(transfer), from_address_hash: transfer.from_address_hash, to_address_hash: transfer.to_address_hash, block_number: transfer.block_number, - amount: amount(transfer), + amount: amount(transfer, transaction.block_timestamp), subject: transfer.token.type, transaction_fee: fee(transaction), name: token_name(transfer), @@ -147,7 +148,7 @@ defmodule Explorer.Account.Notifier.Summary do token_ids_string = token_ids(transfer) %Summary{ - amount: amount(transfer), + amount: amount(transfer, transaction.block_timestamp), transaction_hash: transaction.hash, method: method(transfer), from_address_hash: transfer.from_address_hash, @@ -195,9 +196,9 @@ defmodule Explorer.Account.Notifier.Summary do Wei.to(transaction.value, :ether) end - defp amount(%Chain.TokenTransfer{amount: amount}) when is_nil(amount), do: nil + defp amount(%Chain.TokenTransfer{amount: amount}, _at) when is_nil(amount), do: nil - defp amount(%Chain.TokenTransfer{amount: amount} = transfer) do + defp amount(%Chain.TokenTransfer{amount: amount} = transfer, at) do decimals = Decimal.new( Integer.pow( @@ -206,10 +207,9 @@ defmodule Explorer.Account.Notifier.Summary do ) ) - Decimal.div( - amount, - decimals - ) + amount + |> ScaledUIAmount.scale(Token.effective_ui_multiplier(transfer.token, at || DateTime.utc_now())) + |> Decimal.div(decimals) end defp token_ids(%Chain.TokenTransfer{token_ids: nil}), do: "" diff --git a/apps/explorer/lib/explorer/account/tag_transaction.ex b/apps/explorer/lib/explorer/account/tag_transaction.ex index 301e173ddaf..4335cf4e822 100644 --- a/apps/explorer/lib/explorer/account/tag_transaction.ex +++ b/apps/explorer/lib/explorer/account/tag_transaction.ex @@ -264,8 +264,8 @@ defmodule Explorer.Account.TagTransaction do end end -defimpl Jason.Encoder, for: Explorer.Account.TagTransaction do - def encode(transaction_tag, opts) do - Jason.Encode.string(transaction_tag.name, opts) +defimpl JSON.Encoder, for: Explorer.Account.TagTransaction do + def encode(transaction_tag, encoder) do + JSON.Encoder.encode(transaction_tag.name, encoder) end end diff --git a/apps/explorer/lib/explorer/account/watchlist.ex b/apps/explorer/lib/explorer/account/watchlist.ex index af21a665a89..fb8ab02d5cd 100644 --- a/apps/explorer/lib/explorer/account/watchlist.ex +++ b/apps/explorer/lib/explorer/account/watchlist.ex @@ -11,7 +11,7 @@ defmodule Explorer.Account.Watchlist do alias Ecto.Multi alias Explorer.Account.{Identity, WatchlistAddress} - @derive {Jason.Encoder, only: [:name, :watchlist_addresses]} + @derive {JSON.Encoder, only: [:name, :watchlist_addresses]} typed_schema "account_watchlists" do field(:name, :string, null: false) belongs_to(:identity, Identity) diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 94ce4674c03..6479d703ea8 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -186,6 +186,9 @@ defmodule Explorer.Application do Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount |> configure_mode_dependent_process(:indexer) |> configure_chain_type_dependent_process(:optimism), + Explorer.Chain.Cache.Counters.Optimism.DepositsCount + |> configure_mode_dependent_process(:indexer) + |> configure_chain_type_dependent_process(:optimism), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.NewPendingTransactionsCount, :indexer), configure_mode_dependent_process(Explorer.Chain.Cache.Counters.Transactions24hCount, :indexer), configure_mode_dependent_process(Explorer.Validator.MetadataProcessor, :indexer), @@ -207,6 +210,8 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.SanitizeMissingBlockRanges, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers, :indexer), configure_mode_dependent_process(Explorer.Migrator.TokenTransferTokenType, :indexer), + configure_mode_dependent_process(Explorer.Migrator.BackfillScaledUIAmountTokens, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeScaledUIAmountTokenTransferTypes, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers, :indexer), configure_mode_dependent_process(Explorer.Migrator.TransactionBlockConsensus, :indexer), configure_mode_dependent_process(Explorer.Migrator.TokenTransferBlockConsensus, :indexer), @@ -218,9 +223,13 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.CeloAccounts, :indexer), configure_mode_dependent_process(Explorer.Migrator.CeloAggregatedElectionRewards, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds, :indexer), + configure_mode_dependent_process(Explorer.Migrator.TransactionHasTokenTransfers, :indexer), Explorer.Migrator.BackfillMultichainSearchDB |> configure_mode_dependent_process(:indexer) |> configure_multichain_search_microservice(), + Explorer.Migrator.BackfillMultichainSearchDbCurrentTokenBalances + |> configure_mode_dependent_process(:indexer) + |> configure_multichain_search_microservice(), configure_mode_dependent_process(Explorer.Migrator.ArbitrumDaRecordsNormalization, :indexer), configure_mode_dependent_process(Explorer.Migrator.ShrinkInternalTransactions, :indexer), Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount @@ -258,6 +267,7 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.DeleteZeroValueInternalTransactions, :indexer), configure_mode_dependent_process(Explorer.Migrator.EmptyInternalTransactionsData, :indexer), configure_mode_dependent_process(Explorer.Migrator.FillInternalTransactionsAddressIds, :indexer), + configure_mode_dependent_process(Explorer.Migrator.DeleteNonConsensusLogs, :indexer), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedIndex, :indexer @@ -360,6 +370,14 @@ defmodule Explorer.Application do Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountDescPartialIndex, :indexer ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateUnfetchedAddressTokenBalancesV3Index, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropUnfetchedAddressTokenBalancesV2Index, + :indexer + ), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, :indexer @@ -440,6 +458,10 @@ defmodule Explorer.Application do Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, :indexer ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsAddressHashes, + :indexer + ), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, :indexer @@ -468,6 +490,72 @@ defmodule Explorer.Application do Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesAddressHashBlockNumberIndex, :indexer ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsTokenTransferMethodIdOrderedIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdBlockNumberDescIndexDescIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex, + :indexer + ), + configure_mode_dependent_process(Explorer.Migrator.FillLogsOptimizedFields, :indexer), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsBlockNumberTransactionIndexNotNull, + :indexer + ), + configure_mode_dependent_process(Explorer.Migrator.FillLogsCompressedData, :indexer), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashBlockNumberDescIndexDescIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicBlockNumberIndexIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropLogsDepositsWithdrawalsIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsFirstTopicIdFkey, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndexWithUpdatedPk, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesTokenHoldersIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropAddressCurrentTokenBalancesTokenContractAddressHashValueIndex, + :indexer + ), Explorer.Migrator.RefetchContractCodes |> configure_mode_dependent_process(:indexer) |> configure_chain_type_dependent_process(:zksync), @@ -487,7 +575,7 @@ defmodule Explorer.Application do ] |> List.flatten() - repos_by_chain_type() ++ account_repo() ++ mud_repo() ++ event_notification_repo() ++ configurable_children_set + repos_by_chain_type() ++ account_repo() ++ event_notification_repo() ++ configurable_children_set end defp repos_by_chain_type do @@ -523,14 +611,6 @@ defmodule Explorer.Application do end end - defp mud_repo do - if Application.get_env(:explorer, Explorer.Chain.Mud)[:enabled] || Mix.env() == :test do - [Explorer.Repo.Mud] - else - [] - end - end - defp event_notification_repo do if Application.get_env(:explorer, :realtime_events_sender) == Explorer.Chain.Events.DBSender || Mix.env() == :test do [Explorer.Repo.EventNotifications] diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index bfa82500c46..f9b2bebc9a4 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -74,6 +74,7 @@ defmodule Explorer.Chain do alias Explorer.Chain.Cache.Counters.Helper, as: CacheCountersHelper alias Explorer.Chain.Health.Helper, as: HealthHelper alias Explorer.Chain.SmartContract.Proxy.Models.Implementation + alias Explorer.Chain.Token.ScaledUIAmount alias Explorer.Helper, as: ExplorerHelper alias Explorer.Market.MarketHistoryCache @@ -140,6 +141,7 @@ defmodule Explorer.Chain do @type ip :: {:ip, String.t()} @type show_scam_tokens? :: {:show_scam_tokens?, true | false} @type timeout_option :: {:timeout, timeout() | nil} + @type address_preloads_option :: {:address_preloads, Keyword.t()} def wrapped_union_subquery(query) do from( @@ -196,11 +198,11 @@ defmodule Explorer.Chain do |> select_repo(options).all() end - @spec address_to_logs(Hash.Address.t(), [paging_options | necessity_by_association_option | api? | timeout_option]) :: + @spec address_to_logs(Hash.Address.t(), [paging_options | api? | timeout_option | address_preloads_option]) :: [Log.t()] def address_to_logs(address_hash, csv_export?, options \\ []) when is_list(options) do paging_options = Keyword.get(options, :paging_options) || %PagingOptions{page_size: 50} - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + transaction_preloads_from_options = Keyword.get(options, :transaction_preloads, []) timeout = Keyword.get(options, :timeout) case paging_options do @@ -211,48 +213,64 @@ defmodule Explorer.Chain do from_block = from_block(options) to_block = to_block(options) - base = - from(log in Log, - order_by: [desc: log.block_number, desc: log.index], - where: log.address_hash == ^address_hash, - limit: ^paging_options.page_size, - select: log, - inner_join: block in Block, - on: block.hash == log.block_hash, - where: block.consensus == true + query = + Log.address_match_union_query( + address_hash, + fn address_match_dynamic -> + Log + |> where(^address_match_dynamic) + |> join(:inner, [log], block in Block, on: block.number == log.block_number) + |> where([_l, block], block.consensus == true) + |> page_logs(paging_options) + |> filter_topic(Keyword.get(options, :topic)) + |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) + end, + fn query -> + query + |> order_by([log], desc: log.block_number, desc: log.index) + |> limit(^paging_options.page_size) + end ) - preloaded_query = + transaction_preloads = if csv_export? do - base + transaction_preloads_from_options else - base - |> preload( - transaction: [ - from_address: ^Implementation.proxy_implementations_association(), - to_address: ^Implementation.proxy_implementations_association() - ] + Keyword.merge( + [ + from_address: Implementation.proxy_implementations_association(), + to_address: Implementation.proxy_implementations_association() + ], + transaction_preloads_from_options ) end - preloaded_query - |> page_logs(paging_options) - |> filter_topic(Keyword.get(options, :topic)) - |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) - |> join_associations(necessity_by_association) + query |> select_repo(options).all(ExplorerHelper.maybe_timeout(timeout)) |> Enum.take(paging_options.page_size) + |> Log.preload_block(select_repo(options)) + |> Log.preload_transaction(transaction_preloads, select_repo(options)) + |> Log.preload_address(options, select_repo(options)) + |> Log.prepare_data() + |> Log.prepare_first_topic() end end defp filter_topic(base_query, null) when null in [nil, "", "null"], do: base_query defp filter_topic(base_query, topic) do - from(log in base_query, - where: - log.first_topic == ^topic or log.second_topic == ^topic or log.third_topic == ^topic or - log.fourth_topic == ^topic - ) + dynamic = + dynamic( + [log], + ^Log.filter_by_topic_dynamic([:first_topic, :second_topic, :third_topic, :fourth_topic], [ + [topic], + [topic], + [topic], + [topic] + ]) + ) + + from(log in base_query, where: ^dynamic) end @doc """ @@ -1589,6 +1607,10 @@ defmodule Explorer.Chain do @doc """ Return the balance in usd corresponding to this token. Return nil if the fiat_value of the token is not present. + + `fiat_value` prices one displayed unit of the token, so an ERC-8056 balance is + scaled by the multiplier in force before it is priced — the same base + `Explorer.Chain.Address.CurrentTokenBalance.fiat_value_query/0` computes in SQL. """ def balance_in_fiat(%{fiat_value: fiat_value} = token_balance) when not is_nil(fiat_value) do token_balance.fiat_value @@ -1598,8 +1620,12 @@ defmodule Explorer.Chain do nil end - def balance_in_fiat(%{token: %{fiat_value: fiat_value, decimals: decimals}} = token_balance) do - tokens = CurrencyHelper.divide_decimals(token_balance.value, decimals) + def balance_in_fiat(%{token: %{fiat_value: fiat_value, decimals: decimals} = token} = token_balance) do + tokens = + token_balance.value + |> ScaledUIAmount.scale(Token.effective_ui_multiplier(token)) + |> CurrencyHelper.divide_decimals(decimals) + Decimal.mult(tokens, fiat_value) end @@ -1980,24 +2006,6 @@ defmodule Explorer.Chain do def string_to_full_hash(_), do: :error - @doc """ - Constructs the base query `Ecto.Query.t()/0` to create requests to the transaction logs - - ## Returns - - * The query to the Log table with the joined associated transactions. - - """ - @spec log_with_transactions_query() :: Ecto.Query.t() - def log_with_transactions_query do - from(log in Log, - inner_join: transaction in Transaction, - on: - transaction.block_hash == log.block_hash and transaction.block_number == log.block_number and - transaction.hash == log.transaction_hash - ) - end - @doc """ Finds all `t:Explorer.Chain.Log.t/0`s for `t:Explorer.Chain.Transaction.t/0`. @@ -2011,18 +2019,22 @@ defmodule Explorer.Chain do the `index` that are passed. """ - @spec transaction_to_logs(Hash.Full.t(), [paging_options | necessity_by_association_option | api?]) :: [Log.t()] + @spec transaction_to_logs(Hash.Full.t(), [paging_options | api? | address_preloads_option]) :: [Log.t()] def transaction_to_logs(transaction_hash, options \\ []) when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) paging_options = Keyword.get(options, :paging_options, @default_paging_options) + transaction_preloads = Keyword.get(options, :transaction_preloads, []) - log_with_transactions_query() + Log.join_transaction_query() |> where([_, transaction], transaction.hash == ^transaction_hash) |> page_transaction_logs(paging_options) |> limit(^paging_options.page_size) |> order_by([log], asc: log.index) - |> join_associations(necessity_by_association) |> select_repo(options).all() + |> Log.preload_block(select_repo(options)) + |> Log.preload_transaction(transaction_preloads, select_repo(options)) + |> Log.preload_address(options, select_repo(options)) + |> Log.prepare_data() + |> Log.prepare_first_topic() end @doc """ @@ -2954,7 +2966,7 @@ defmodule Explorer.Chain do if token_transfer do case token_transfer.token do - %Token{type: "ERC-20"} -> :erc20 + %Token{type: type} when type in ["ERC-20", "ERC-8056"] -> :erc20 %Token{type: "ERC-721"} -> :erc721 %Token{type: "ERC-1155"} -> :erc1155 %Token{type: "ERC-404"} -> :erc404 @@ -2979,7 +2991,7 @@ defmodule Explorer.Chain do defp erc_20_token_type?(type) do case type do - "ERC-20" -> true + type when type in ["ERC-20", "ERC-8056"] -> true _ -> false end end diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index eea836d4032..81347b54407 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -211,17 +211,7 @@ defmodule Explorer.Chain.Address do """ @type hash :: Hash.t() - @derive {Poison.Encoder, - except: [ - :__meta__, - :smart_contract, - :token, - :contract_creation_internal_transaction, - :contract_creation_transaction, - :names - ]} - - @derive {Jason.Encoder, + @derive {JSON.Encoder, except: [ :__meta__, :smart_contract, diff --git a/apps/explorer/lib/explorer/chain/address/coin_balance.ex b/apps/explorer/lib/explorer/chain/address/coin_balance.ex index 6933b82bb9a..6ca7631d805 100644 --- a/apps/explorer/lib/explorer/chain/address/coin_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/coin_balance.ex @@ -7,8 +7,12 @@ defmodule Explorer.Chain.Address.CoinBalance do use Explorer.Schema + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + arc_native_token_address: [:indexer, [:arc, :arc_native_token_address]] + alias Explorer.{Chain, Helper, PagingOptions, Repo} - alias Explorer.Chain.{Address, Block, Hash, InternalTransaction, Transaction, Wei} + alias Explorer.Chain.{Address, Block, Hash, InternalTransaction, TokenTransfer, Transaction, Wei} alias Explorer.Chain.Address.CoinBalance @optional_fields ~w(value value_fetched_at)a @@ -168,7 +172,14 @@ defmodule Explorer.Chain.Address.CoinBalance do def get_coin_balance(address_hash, block_number, options \\ []) do query = fetch_coin_balance(address_hash, block_number) - Chain.select_repo(options).one(query) + case Chain.select_repo(options).one(query) do + nil -> + nil + + balance -> + [balance] = preload_transactions([balance], options) + balance + end end @doc """ @@ -288,14 +299,37 @@ defmodule Explorer.Chain.Address.CoinBalance do |> Chain.select_repo(options).one() if is_nil(transaction_hash) do - balance - |> preload_internal_transaction_query() - |> Chain.select_repo(options).one() + if chain_type() == :arc do + balance + |> preload_arc_native_token_transfer_query() + |> Chain.select_repo(options).one() + else + balance + |> preload_internal_transaction_query() + |> Chain.select_repo(options).one() + end else transaction_hash end end + # On Arc, native-coin movement is emitted via EIP-7708 / NativeCoin* logs that + # don't show up in transaction `value` or internal transactions; the indexer + # normalizes them into `TokenTransfer` rows under the synthetic native token. + defp preload_arc_native_token_transfer_query(balance) do + {:ok, native_token_address_hash} = Chain.string_to_address_hash(arc_native_token_address()) + + TokenTransfer + |> where( + [tt], + tt.block_number == ^balance.block_number and + tt.token_contract_address_hash == ^native_token_address_hash and + (tt.from_address_hash == ^balance.address_hash or tt.to_address_hash == ^balance.address_hash) + ) + |> select([tt], tt.transaction_hash) + |> limit(1) + end + defp preload_transaction_query(balance) do Transaction |> where( @@ -323,11 +357,8 @@ defmodule Explorer.Chain.Address.CoinBalance do (is_nil(coalesce(type(internal_transaction.call_type_enum, :string), internal_transaction.call_type)) or coalesce(type(internal_transaction.call_type_enum, :string), internal_transaction.call_type) == ^"call") and internal_transaction.value > ^0 and is_nil(internal_transaction.error_id) and - (internal_transaction.to_address_hash == ^balance.address_hash or - as(:to_address_mapping).address_hash == ^balance.address_hash or - internal_transaction.from_address_hash == ^balance.address_hash or + (as(:to_address_mapping).address_hash == ^balance.address_hash or as(:from_address_mapping).address_hash == ^balance.address_hash or - internal_transaction.created_contract_address_hash == ^balance.address_hash or as(:created_contract_address_mapping).address_hash == ^balance.address_hash) ) |> select([_internal_transaction, transaction], transaction.hash) diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 82734d4b087..2ae144cc521 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -6,7 +6,7 @@ defmodule Explorer.Chain.Address.Counters do use Utils.RuntimeEnvHelper, chain_identity: [:explorer, :chain_identity] - import Ecto.Query, only: [from: 2, limit: 2, select: 3, union_all: 2, where: 3] + import Ecto.Query import Explorer.Chain, only: [select_repo: 1, wrapped_union_subquery: 1] @@ -30,6 +30,9 @@ defmodule Explorer.Chain.Address.Counters do alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward + alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Repo.LockTimeout + require Logger @typep counter :: non_neg_integer() | nil @@ -48,7 +51,7 @@ defmodule Explorer.Chain.Address.Counters do @transactions_types [:transactions_from, :transactions_to, :transactions_contract] defp address_hash_to_logs_query(address_hash) do - from(l in Log, where: l.address_hash == ^address_hash) + Log.address_match_union_query(address_hash, fn address_match_dynamic -> where(Log, ^address_match_dynamic) end) end defp address_hash_to_validated_blocks_query(address_hash) do @@ -60,7 +63,11 @@ defmodule Explorer.Chain.Address.Counters do end def check_if_logs_at_address(address_hash, options \\ []) do - select_repo(options).exists?(address_hash_to_logs_query(address_hash)) + address_hash + |> Log.address_match_dynamics() + |> Enum.any?(fn address_match_dynamic -> + select_repo(options).exists?(where(Log, ^address_match_dynamic)) + end) end def check_if_token_transfers_at_address(address_hash, options \\ []) do @@ -69,7 +76,7 @@ defmodule Explorer.Chain.Address.Counters do end def check_if_tokens_at_address(address_hash, options \\ []) do - select_repo(options).exists?(address_hash_to_token_balances_query(address_hash)) + select_repo(options).exists?(address_hash_to_token_balances_query(address_hash, options)) end @spec check_if_withdrawals_at_address(Hash.Address.t()) :: boolean() @@ -79,9 +86,25 @@ defmodule Explorer.Chain.Address.Counters do |> select_repo(options).exists?() end + @existence_checks_unavailable %{ + has_validated_blocks: false, + has_logs: false, + has_tokens: false, + has_token_transfers: false, + has_beacon_chain_withdrawals: false + } + @doc """ Performs all existence checks needed by the address view in a single database round trip: `SELECT exists(...), exists(...), ...`. + + The checks are optional data of the address page, so they must not make + the page hang while one of the checked tables is locked exclusively (e.g. + by `VACUUM FULL` or a heavy migration). The query fails fast in that case + (see `Explorer.Repo.LockTimeout`) and degrades: the `logs` table is by far + the biggest and the most likely one to be under maintenance, so the checks + are retried without it with `has_logs: false`; if that fails too, every + check is reported as `false`. """ @spec address_existence_checks(Hash.Address.t(), Keyword.t()) :: %{ has_validated_blocks: boolean(), @@ -91,8 +114,21 @@ defmodule Explorer.Chain.Address.Counters do has_beacon_chain_withdrawals: boolean() } def address_existence_checks(address_hash, options \\ []) do + repo = select_repo(options) + + with {:error, :lock_timeout} <- run_address_existence_checks(repo, address_hash, true), + {:error, :lock_timeout} <- run_address_existence_checks(repo, address_hash, false) do + Logger.warning(fn -> "Existence checks for address #{address_hash} are unavailable: a table is locked" end) + + @existence_checks_unavailable + else + {:ok, %{has_logs: _} = checks} -> checks + {:ok, checks} -> Map.put(checks, :has_logs, false) + end + end + + defp run_address_existence_checks(repo, address_hash, check_logs?) 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) @@ -102,14 +138,26 @@ defmodule Explorer.Chain.Address.Counters do 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) + query = + if check_logs? do + # the select has to be applied inside every union branch, see `Log.address_match_union_query/3` + logs_query = + Log.address_match_union_query(address_hash, fn address_match_dynamic -> + Log |> where(^address_match_dynamic) |> select([_], %{one: 1}) + end) + + select_merge(query, [_], %{has_logs: exists(logs_query)}) + else + query + end + + LockTimeout.run(repo, fn repo -> repo.one(query) end) end @doc """ @@ -184,15 +232,22 @@ defmodule Explorer.Chain.Address.Counters do @spec address_to_token_transfer_count_query( Hash.Address.t(), Block.block_number() | nil, - Block.block_number() | nil + Block.block_number() | nil, + Keyword.t() ) :: Ecto.Query.t() - def address_to_token_transfer_count_query(address_hash, from_block_number \\ nil, to_block_number \\ nil) do + def address_to_token_transfer_count_query( + address_hash, + from_block_number \\ nil, + to_block_number \\ nil, + options \\ [] + ) do TokenTransfer |> where( [token_transfer], token_transfer.to_address_hash == ^address_hash or token_transfer.from_address_hash == ^address_hash ) |> where_block_number_in_range(from_block_number, to_block_number) + |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) end defp where_block_number_in_range(query, from_block_number, to_block_number) do @@ -205,12 +260,13 @@ defmodule Explorer.Chain.Address.Counters do end) end - def address_hash_to_token_balances_query(address_hash) do + def address_hash_to_token_balances_query(address_hash, options \\ []) do from( tb in CurrentTokenBalance, where: tb.address_hash == ^address_hash, where: tb.value > 0 or tb.token_type == "ERC-7984" ) + |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) end defp address_hash_to_internal_transactions_limited_count_query(address_hash, options) do @@ -243,10 +299,15 @@ defmodule Explorer.Chain.Address.Counters do end @spec address_limited_counters(Hash.t(), Keyword.t()) :: %{atom() => counter} + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def address_limited_counters(address_hash, options) do + show_scam_tokens? = options[:show_scam_tokens?] || false + cached_counters = Enum.reduce(@types, %{}, fn type, acc -> - case AddressTabsElementsCount.get_counter(type, address_hash) do + scam_flag = if type in [:token_transfers, :token_balances], do: show_scam_tokens?, else: false + + case AddressTabsElementsCount.get_counter(type, address_hash, scam_flag) do {_datetime, counter, status} -> Map.put(acc, type, {status, counter}) @@ -345,7 +406,7 @@ defmodule Explorer.Chain.Address.Counters do configure_task( :token_transfers, cached_counters, - address_to_token_transfer_count_query(address_hash), + address_to_token_transfer_count_query(address_hash, nil, nil, options), address_hash, options ) @@ -354,7 +415,7 @@ defmodule Explorer.Chain.Address.Counters do configure_task( :token_balances, cached_counters, - address_hash_to_token_balances_query(address_hash), + address_hash_to_token_balances_query(address_hash, options), address_hash, options ) @@ -475,11 +536,24 @@ defmodule Explorer.Chain.Address.Counters do end end + defp run_or_ignore({ok, _counter}, _type, _address_hash, _show_scam_tokens?, _fun) + when ok in [:up_to_date, :limit_value], + do: nil + + defp run_or_ignore(_, type, address_hash, show_scam_tokens?, fun) do + if !AddressTabsElementsCount.get_task(type, address_hash, show_scam_tokens?) do + AddressTabsElementsCount.set_task(type, address_hash, show_scam_tokens?) + + Task.async(fun) + end + end + defp configure_task(counter_type, cache, query, address_hash, options) do address_hash = to_string(address_hash) + show_scam_tokens? = options[:show_scam_tokens?] || false start = System.monotonic_time() - run_or_ignore(cache[counter_type], counter_type, address_hash, fn -> + run_or_ignore(cache[counter_type], counter_type, address_hash, show_scam_tokens?, fn -> result = query |> count(options, counter_type) @@ -489,8 +563,8 @@ defmodule Explorer.Chain.Address.Counters do Logger.debug("Time consumed for #{counter_type} counter task for #{address_hash} is #{diff}ms") - AddressTabsElementsCount.set_counter(counter_type, address_hash, result) - AddressTabsElementsCount.drop_task(counter_type, address_hash) + AddressTabsElementsCount.set_counter(counter_type, address_hash, result, show_scam_tokens?) + AddressTabsElementsCount.drop_task(counter_type, address_hash, show_scam_tokens?) {counter_type, result} end) diff --git a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex index 6f2450f6548..fe077db4b45 100644 --- a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex @@ -173,8 +173,49 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do ) end + @doc """ + Builds a `t:Ecto.Query.dynamic_expr/0` for the fiat value of a token balance. + + `tokens.fiat_value` is the price of one *displayed* unit of the token, which + for an [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) token is not the + same as one raw unit: what trades, and what a price source therefore quotes, + is the UI amount. So the raw balance is scaled by the multiplier before it is + priced, and the `CASE` falls back to `1e18` — a multiplier of exactly one — + for every token that does not implement the standard. + + The branch mirrors `Explorer.Chain.Token.effective_ui_multiplier/2`: a + scheduled change takes effect with nothing happening on chain and nothing + written to the database, so which of the two stored values applies has to be + decided against the clock. It is the database clock here rather than the + application's, which keeps the expression free of a timestamp parameter; both + are UTC and the difference between them is far below the resolution at which + a multiplier change matters. + + Expects the `tokens` table as the second binding. + """ + @spec fiat_value_query() :: Ecto.Query.dynamic_expr() def fiat_value_query do - dynamic([ctb, t], ctb.value * t.fiat_value / fragment("10 ^ ?", t.decimals)) + dynamic( + [ctb, t], + fragment( + """ + ? * CASE + WHEN ? IS NULL THEN 1000000000000000000 + WHEN ? IS NOT NULL AND ? IS NOT NULL AND ? <= (now() AT TIME ZONE 'utc') THEN ? + ELSE ? + END * ? / (10 ^ ?) / 1000000000000000000 + """, + ctb.value, + t.ui_multiplier, + t.new_ui_multiplier, + t.ui_multiplier_effective_at, + t.ui_multiplier_effective_at, + t.new_ui_multiplier, + t.ui_multiplier, + t.fiat_value, + t.decimals + ) + ) end @doc """ @@ -373,6 +414,7 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do ctb in __MODULE__, where: ((ctb.address_hash != ^@burn_address_hash and ctb.token_type == "ERC-721") or ctb.token_type == "ERC-20" or + ctb.token_type == "ERC-8056" or ctb.token_type == "ZRC-2" or ctb.token_type == "ERC-1155" or ctb.token_type == "ERC-404") and (is_nil(ctb.value_fetched_at) or is_nil(ctb.value)) and @@ -384,7 +426,8 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do join: t in Token, on: ctb.token_contract_address_hash == t.contract_address_hash, where: - ((ctb.address_hash != ^@burn_address_hash and t.type == "ERC-721") or t.type == "ERC-20" or t.type == "ZRC-2" or + ((ctb.address_hash != ^@burn_address_hash and t.type == "ERC-721") or t.type == "ERC-20" or + t.type == "ERC-8056" or t.type == "ZRC-2" or t.type == "ERC-1155" or t.type == "ERC-404") and (is_nil(ctb.value_fetched_at) or is_nil(ctb.value)) and (is_nil(ctb.refetch_after) or ctb.refetch_after < ^Timex.now()) diff --git a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex index f4d2f929c2d..1fd21cbb421 100644 --- a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex +++ b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex @@ -9,6 +9,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do alias Explorer.Chain.{ Address, Address.CurrentTokenBalance, + AdvancedFilter, Beacon.Deposit, Block, InternalTransaction, @@ -23,9 +24,11 @@ defmodule Explorer.Chain.Address.MetadataPreloader do @type supported_types :: Address.t() + | AdvancedFilter.t() | Block.t() | CurrentTokenBalance.t() | InternalTransaction.t() + | Instance.t() | Log.t() | TokenTransfer.t() | Transaction.t() @@ -185,6 +188,24 @@ defmodule Explorer.Chain.Address.MetadataPreloader do transaction_with_metadata end + @doc """ + Preloads ENS name to Instance.t() + """ + @spec preload_ens_to_instance(Instance.t()) :: Instance.t() + def preload_ens_to_instance(instance) do + [instance_with_ens] = preload_ens_to_list([instance]) + instance_with_ens + end + + @doc """ + Preloads metadata to Instance.t() + """ + @spec preload_metadata_to_instance(Instance.t()) :: Instance.t() + def preload_metadata_to_instance(instance) do + [instance_with_metadata] = preload_metadata_to_list([instance]) + instance_with_metadata + end + @doc """ Preload ENS info to search result, using get_address/1 """ @@ -310,6 +331,16 @@ defmodule Explorer.Chain.Address.MetadataPreloader do [to_string(to_address_hash), to_string(from_address_hash)] end + defp item_to_address_hash_strings(%AdvancedFilter{ + from_address_hash: from_address_hash, + to_address_hash: to_address_hash, + created_contract_address_hash: created_contract_address_hash + }) do + [from_address_hash, to_address_hash, created_contract_address_hash] + |> Enum.reject(&is_nil/1) + |> Enum.map(&to_string/1) + end + defp item_to_address_hash_strings(%Log{address_hash: address_hash}) do [to_string(address_hash)] end @@ -418,6 +449,29 @@ defmodule Explorer.Chain.Address.MetadataPreloader do } end + defp put_meta_to_item( + %AdvancedFilter{ + from_address_hash: from_address_hash, + to_address_hash: to_address_hash, + created_contract_address_hash: created_contract_address_hash + } = advanced_filter, + names, + field_to_put_info + ) do + %AdvancedFilter{ + advanced_filter + | from_address: alter_address(advanced_filter.from_address, from_address_hash, names, field_to_put_info), + to_address: alter_address(advanced_filter.to_address, to_address_hash, names, field_to_put_info), + created_contract_address: + alter_address( + advanced_filter.created_contract_address, + created_contract_address_hash, + names, + field_to_put_info + ) + } + end + defp put_meta_to_item(%Log{address_hash: address_hash} = log, names, field_to_put_info) do %Log{log | address: alter_address(log.address, address_hash, names, field_to_put_info)} end diff --git a/apps/explorer/lib/explorer/chain/address/reputation.ex b/apps/explorer/lib/explorer/chain/address/reputation.ex index 75717cf375b..85faa0037fb 100644 --- a/apps/explorer/lib/explorer/chain/address/reputation.ex +++ b/apps/explorer/lib/explorer/chain/address/reputation.ex @@ -39,8 +39,8 @@ defmodule Explorer.Chain.Address.Reputation do end end -defimpl Jason.Encoder, for: Explorer.Chain.Address.Reputation do - def encode(reputation, opts) do - Jason.Encode.string(reputation.reputation, opts) +defimpl JSON.Encoder, for: Explorer.Chain.Address.Reputation do + def encode(reputation, encoder) do + JSON.Encoder.encode(reputation.reputation, encoder) end end diff --git a/apps/explorer/lib/explorer/chain/address/token_balance.ex b/apps/explorer/lib/explorer/chain/address/token_balance.ex index 3ec65663964..e3ffbcf6e04 100644 --- a/apps/explorer/lib/explorer/chain/address/token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/token_balance.ex @@ -79,9 +79,9 @@ defmodule Explorer.Chain.Address.TokenBalance do def unfetched_token_balances do token_types = if Application.get_env(:explorer, :chain_type) == :zilliqa do - ["ERC-20", "ZRC-2", "ERC-1155", "ERC-404"] + ["ERC-20", "ERC-8056", "ZRC-2", "ERC-1155", "ERC-404"] else - ["ERC-20", "ERC-1155", "ERC-404"] + ["ERC-20", "ERC-8056", "ERC-1155", "ERC-404"] end if BackgroundMigrations.get_tb_token_type_finished() do diff --git a/apps/explorer/lib/explorer/chain/advanced_filter.ex b/apps/explorer/lib/explorer/chain/advanced_filter.ex index 37a9e9884b5..eea309342b0 100644 --- a/apps/explorer/lib/explorer/chain/advanced_filter.ex +++ b/apps/explorer/lib/explorer/chain/advanced_filter.ex @@ -904,7 +904,7 @@ defmodule Explorer.Chain.AdvancedFilter do end) query = - if "CONTRACT_INTERACTION" in types or "CONTRACT_CREATION" in types do + if "CONTRACT_INTERACTION" in types do InternalTransaction.join_address_query(query, :to_address) else query @@ -931,11 +931,10 @@ defmodule Explorer.Chain.AdvancedFilter do do: dynamic([t], is_nil(t.to_address_hash) or ^dynamic_condition) defp filter_internal_transaction_by_type("CONTRACT_CREATION", nil), - do: dynamic([it], is_nil(as(:to_address_mapping).address_hash) and is_nil(it.to_address_hash)) + do: dynamic([it], not is_nil(it.created_contract_address_id)) defp filter_internal_transaction_by_type("CONTRACT_CREATION", dynamic_condition), - do: - dynamic([it], (is_nil(as(:to_address_mapping).address_hash) and is_nil(it.to_address_hash)) or ^dynamic_condition) + do: dynamic([it], not is_nil(it.created_contract_address_id) or ^dynamic_condition) defp filter_internal_transaction_by_type(type, dynamic_condition), do: filter_transaction_by_type(type, dynamic_condition) @@ -972,7 +971,22 @@ defmodule Explorer.Chain.AdvancedFilter do cross_lateral_join: token_transfer in subquery( query - |> where(fragment("substring(? FOR 4)", as(:transaction).input) == parent_as(:method_ids).method_id) + |> then(fn q -> + if DenormalizationHelper.transaction_has_token_transfers_finished?() do + where( + q, + as(:transaction).has_token_transfers == true and + fragment("substring(? FOR 4)", as(:transaction).input) == + parent_as(:method_ids).method_id + ) + else + where( + q, + fragment("substring(? FOR 4)", as(:transaction).input) == + parent_as(:method_ids).method_id + ) + end + end) |> exclude(:order_by) |> order_by( desc: as(:transaction).block_number, @@ -993,7 +1007,18 @@ defmodule Explorer.Chain.AdvancedFilter do fn query, unnested? -> query - |> where(fragment("substring(? FOR 4)", as(:transaction).input) in ^prepared_methods) + |> then(fn q -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if DenormalizationHelper.transaction_has_token_transfers_finished?() do + where( + q, + as(:transaction).has_token_transfers == true and + fragment("substring(? FOR 4)", as(:transaction).input) in ^prepared_methods + ) + else + where(q, fragment("substring(? FOR 4)", as(:transaction).input) in ^prepared_methods) + end + end) |> query_function.(unnested?) end end @@ -1286,13 +1311,9 @@ defmodule Explorer.Chain.AdvancedFilter do defp do_filter_internal_transactions_by_address(query, {:exclude, addresses}, binding, order_by, options) do address_ids = AddressIdToAddressHash.hashes_to_ids(addresses, options) address_id_field = String.to_existing_atom("#{binding}_id") - address_hash_field = String.to_existing_atom("#{binding}_hash") query - |> where( - [it], - field(it, ^address_id_field) not in ^address_ids and field(it, ^address_hash_field) not in ^addresses - ) + |> where([it], is_nil(field(it, ^address_id_field)) or field(it, ^address_id_field) not in ^address_ids) |> order_by.() end @@ -1481,7 +1502,7 @@ defmodule Explorer.Chain.AdvancedFilter do |> Enum.map(fn from_address -> query |> InternalTransaction.where_address_match_by_hash(:from_address, from_address, options) - |> where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + |> where([it], is_nil(it.to_address_id) or it.to_address_id not in ^to_address_ids) |> order_by.() end) |> map_first(&subquery/1) @@ -1505,7 +1526,7 @@ defmodule Explorer.Chain.AdvancedFilter do |> Enum.map(fn from_address -> query |> InternalTransaction.where_address_match_by_hash(:from_address, from_address, options) - |> or_where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + |> or_where([it], is_nil(it.to_address_id) or it.to_address_id not in ^to_address_ids) |> order_by.() end) |> map_first(&subquery/1) @@ -1529,7 +1550,7 @@ defmodule Explorer.Chain.AdvancedFilter do |> Enum.map(fn to_address -> query |> InternalTransaction.where_address_match_by_hash(:to_address, to_address, options) - |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) + |> where([it], is_nil(it.from_address_id) or it.from_address_id not in ^from_address_ids) |> order_by.() end) |> map_first(&subquery/1) @@ -1553,7 +1574,7 @@ defmodule Explorer.Chain.AdvancedFilter do |> Enum.map(fn to_address -> query |> InternalTransaction.where_address_match_by_hash(:to_address, to_address, options) - |> or_where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) + |> or_where([it], is_nil(it.from_address_id) or it.from_address_id not in ^from_address_ids) |> order_by.() end) |> map_first(&subquery/1) @@ -1574,8 +1595,8 @@ defmodule Explorer.Chain.AdvancedFilter do to_address_ids = AddressIdToAddressHash.hashes_to_ids(to, options) query - |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) - |> where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + |> where([it], is_nil(it.from_address_id) or it.from_address_id not in ^from_address_ids) + |> where([it], is_nil(it.to_address_id) or it.to_address_id not in ^to_address_ids) |> order_by.() end @@ -1594,8 +1615,8 @@ defmodule Explorer.Chain.AdvancedFilter do |> where(as(:from_address).hash not in ^from or as(:to_address).hash not in ^to) |> where( [it], - (it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) or - (it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + is_nil(it.from_address_id) or it.from_address_id not in ^from_address_ids or is_nil(it.to_address_id) or + it.to_address_id not in ^to_address_ids ) |> order_by.() end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex index 09539e97053..cbcfa7031da 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex @@ -17,7 +17,6 @@ defmodule Explorer.Chain.Arbitrum.Reader.API.General do import Ecto.Query, only: [order_by: 2, where: 3] import Explorer.Chain, only: [select_repo: 1] - alias Explorer.Chain alias Explorer.Chain.{Hash, Log} @api_true [api?: true] @@ -37,8 +36,9 @@ defmodule Explorer.Chain.Arbitrum.Reader.API.General do """ @spec transaction_to_logs_by_topic0(Hash.Full.t(), binary()) :: [Log.t()] def transaction_to_logs_by_topic0(transaction_hash, topic0) do - Chain.log_with_transactions_query() - |> where([log, transaction], transaction.hash == ^transaction_hash and log.first_topic == ^topic0) + Log.join_transaction_query() + |> where([log, transaction], transaction.hash == ^transaction_hash) + |> Log.filter_by_topic_query(:first_topic, topic0) |> order_by(asc: :index) |> select_repo(@api_true).all() end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex index 5d52bc38bfb..07e308b336e 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex @@ -11,7 +11,7 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do - Tracking of L2-to-L1 message executions on L1 """ - import Ecto.Query, only: [dynamic: 2, from: 2, limit: 2, order_by: 2, select: 3, where: 3] + import Ecto.Query alias Explorer.Chain.Arbitrum.{ L1Execution, @@ -369,27 +369,29 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do # Otherwise, the join condition must be extended with # fragment("encode(l0.fourth_topic, 'hex') = LPAD(TO_HEX(a1.message_id::BIGINT), 64, '0')") base_condition = - dynamic([log, msg], log.transaction_hash == msg.originating_transaction_hash and msg.direction == :from_l2) + dynamic( + [_log, transaction, msg], + transaction.hash == msg.originating_transaction_hash and msg.direction == :from_l2 + ) join_condition = if is_nil(start_block) or is_nil(end_block) do base_condition else dynamic( - [_, msg], + [_, _, msg], ^base_condition and msg.originating_transaction_block_number >= ^start_block and msg.originating_transaction_block_number <= ^end_block ) end - from(log in Log, - left_join: msg in Message, - on: ^join_condition, - where: - log.address_hash == ^arbsys_contract and log.first_topic == ^l2_to_l1_event and - is_nil(msg.originating_transaction_hash) - ) + Log + |> Log.join_transaction_query() + |> Log.address_match_query(arbsys_contract) + |> join(:left, [_log], msg in Message, on: ^join_condition) + |> Log.filter_by_topic_query(:first_topic, l2_to_l1_event) + |> where([_log, _t, msg], is_nil(msg.originating_transaction_hash)) end @doc """ diff --git a/apps/explorer/lib/explorer/chain/beacon/deposit.ex b/apps/explorer/lib/explorer/chain/beacon/deposit.ex index d96ed7fc6da..fa0b1e38be4 100644 --- a/apps/explorer/lib/explorer/chain/beacon/deposit.ex +++ b/apps/explorer/lib/explorer/chain/beacon/deposit.ex @@ -262,6 +262,7 @@ defmodule Explorer.Chain.Beacon.Deposit do ) :: [ %{ first_topic: Hash.Full.t(), + first_topic_id: integer(), data: Data.t(), index: non_neg_integer(), block_number: non_neg_integer(), @@ -273,24 +274,37 @@ defmodule Explorer.Chain.Beacon.Deposit do ] def get_logs_with_deposits(deposit_contract_address_hash, log_block_number, log_index, limit) do query = - from(log in Log, - join: transaction in assoc(log, :transaction), - where: transaction.block_consensus == true, - where: log.block_hash == transaction.block_hash, - where: log.address_hash == ^deposit_contract_address_hash, - where: log.first_topic == ^@deposit_event_signature, - where: {log.block_number, log.index} > {^log_block_number, ^log_index}, - limit: ^limit, - select: - map( - log, - ^~w(first_topic data index block_number block_hash transaction_hash)a - ), - order_by: [asc: log.block_number, asc: log.index], - select_merge: map(transaction, ^~w(from_address_hash block_timestamp)a) + Log.address_match_union_query( + deposit_contract_address_hash, + fn address_match_dynamic -> + Log + |> Log.join_transaction_query() + |> where(^address_match_dynamic) + |> where(as(:transaction).block_consensus == true) + |> Log.filter_by_topic_query(:first_topic, @deposit_event_signature) + |> where([log], {log.block_number, log.index} > {^log_block_number, ^log_index}) + |> select([log], %{ + first_topic: log.first_topic, + first_topic_id: log.first_topic_id, + data: log.data, + index: log.index, + block_number: log.block_number, + block_hash: as(:transaction).block_hash, + transaction_hash: as(:transaction).hash, + from_address_hash: as(:transaction).from_address_hash, + block_timestamp: as(:transaction).block_timestamp + }) + end, + fn query -> + query + |> order_by([log], asc: log.block_number, asc: log.index) + |> limit(^limit) + end ) - Repo.all(query) + query + |> Repo.all() + |> Log.prepare_first_topic() end defp put_withdrawal_address_hash(deposit) do diff --git a/apps/explorer/lib/explorer/chain/blackfort/validator.ex b/apps/explorer/lib/explorer/chain/blackfort/validator.ex index 79ed06cbf41..bdcb3578eee 100644 --- a/apps/explorer/lib/explorer/chain/blackfort/validator.ex +++ b/apps/explorer/lib/explorer/chain/blackfort/validator.ex @@ -151,7 +151,7 @@ defmodule Explorer.Chain.Blackfort.Validator do with {:url, true} <- {:url, UtilsConfigHelper.valid_url?(url)}, {:ok, %{status_code: 200, body: body}} <- HttpClient.get(validator_url(), [], follow_redirect: true) do - body |> Jason.decode() |> parse_validators_info() + body |> Utils.JSON.decode() |> parse_validators_info() else {:url, false} -> :error diff --git a/apps/explorer/lib/explorer/chain/block.ex b/apps/explorer/lib/explorer/chain/block.ex index cc46861395a..3a086abc11a 100644 --- a/apps/explorer/lib/explorer/chain/block.ex +++ b/apps/explorer/lib/explorer/chain/block.ex @@ -243,7 +243,9 @@ defmodule Explorer.Chain.Block do alias Explorer.Chain.Address.CoinBalance alias Explorer.Chain.Block.{EmissionReward, Reward, SecondDegreeRelation} alias Explorer.Chain.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue + alias Explorer.Chain.Optimism.SuperchainConfig alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Utility.AddressIdToAddressHash alias Explorer.Utility.MissingBlockRange @optional_attrs ~w(size refetch_needed total_difficulty difficulty base_fee_per_gas)a @@ -353,6 +355,22 @@ defmodule Explorer.Chain.Block do ) end + @doc """ + Adds a filter by block numbers to the given query. + """ + @spec by_numbers_query(Ecto.Queryable.t(), [block_number()]) :: Ecto.Query.t() + def by_numbers_query(query \\ __MODULE__, block_numbers) do + where(query, [b], b.number in ^block_numbers) + end + + @doc """ + Adds a consensus filter to the given block query. + """ + @spec consensus_query(Ecto.Queryable.t(), boolean()) :: Ecto.Query.t() + def consensus_query(query \\ __MODULE__, consensus) do + where(query, [b], b.consensus == ^consensus) + end + @doc """ Returns a query that filters blocks where consensus is true. @@ -505,7 +523,8 @@ defmodule Explorer.Chain.Block do The config is put into the `eip1559_config` virtual field, so that `gas_target/1` and `next_block_base_fee_per_gas/1` don't query the config per block. Does nothing (and makes no queries) unless the chain type is `optimism`, where the config is dynamic and read from the - `op_eip1559_config_updates` table. + `op_eip1559_config_updates` table, with the superchain config as the default for the blocks + preceding the first update. The default is resolved once for the whole batch. ## Parameters - `blocks`: The blocks to resolve the config for. @@ -522,8 +541,11 @@ defmodule Explorer.Chain.Block do # credo:disable-for-next-line Credo.Check.Design.AliasUsage |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_configs_for_blocks() + # The default is read from the database as well, so it is resolved at most once per batch + default_config = if Enum.any?(Map.values(configs), &is_nil/1), do: default_eip1559_config() + Enum.map(blocks, fn block -> - %{block | eip1559_config: eip1559_config_or_default(Map.fetch!(configs, block.number))} + %{block | eip1559_config: eip1559_config_or_default(Map.fetch!(configs, block.number), default_config)} end) else blocks @@ -531,7 +553,7 @@ defmodule Explorer.Chain.Block do end # Gets EIP-1559 config actual for the given block, preferring the one resolved by `preload_eip1559_config/1`. - # If not found, returns EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR and EIP_1559_ELASTICITY_MULTIPLIER env values. + # If not found, returns the default config (see `default_eip1559_config/0`). # # ## Parameters # - `block`: The given block. @@ -546,17 +568,33 @@ defmodule Explorer.Chain.Block do block.number # credo:disable-for-next-line Credo.Check.Design.AliasUsage |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_config_for_block() - |> eip1559_config_or_default() + |> eip1559_config_or_default(nil) else - eip1559_config_or_default(nil) + default_eip1559_config() end end - defp eip1559_config_or_default({denominator, multiplier, _min_base_fee}), do: {denominator, multiplier} + # Converts an `op_eip1559_config_updates` record into the `{denominator, multiplier}` config, or returns + # `default_config` when there is no record. A `nil` `default_config` is resolved on demand. + defp eip1559_config_or_default({denominator, multiplier, _min_base_fee}, _default_config), + do: {denominator, multiplier} + + defp eip1559_config_or_default(nil, nil), do: default_eip1559_config() + defp eip1559_config_or_default(nil, default_config), do: default_config - defp eip1559_config_or_default(nil) do - {Application.get_env(:explorer, :base_fee_max_change_denominator), - Application.get_env(:explorer, :elasticity_multiplier)} + # The EIP-1559 config to use when no `op_eip1559_config_updates` record applies to a block. + # + # On Optimism, it comes from the superchain config stored in the `constants` table (two queries), falling back + # to the EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR and EIP_1559_ELASTICITY_MULTIPLIER env values. Other chain + # types never write those constants, so they read the env values directly without querying. + @spec default_eip1559_config() :: {non_neg_integer(), non_neg_integer()} + defp default_eip1559_config do + if Application.get_env(:explorer, :chain_type) == :optimism do + {SuperchainConfig.eip1559_base_fee_max_change_denominator(), SuperchainConfig.eip1559_elasticity_multiplier()} + else + {Application.get_env(:explorer, :base_fee_max_change_denominator), + Application.get_env(:explorer, :elasticity_multiplier)} + end end @doc """ @@ -865,17 +903,23 @@ defmodule Explorer.Chain.Block do |> Repo.all() |> List.flatten() - internal_transaction_address_hashes = + # `InternalTransaction` stores participant addresses as `address_id` foreign keys into + # `address_ids_to_address_hashes`, and exposes the hashes only as virtual fields, so the ids + # are collected here and resolved to hashes in bulk. + internal_transaction_address_ids = from(internal_transaction in InternalTransaction, where: internal_transaction.block_number in ^block_numbers, select: [ - internal_transaction.from_address_hash, - internal_transaction.to_address_hash, - internal_transaction.created_contract_address_hash + internal_transaction.from_address_id, + internal_transaction.to_address_id, + internal_transaction.created_contract_address_id ] ) |> Repo.all() |> List.flatten() + |> Enum.reject(&is_nil/1) + + internal_transaction_address_hashes = AddressIdToAddressHash.ids_to_hashes(internal_transaction_address_ids) transaction_address_hashes = Enum.flat_map(transactions, &[&1.from_address_hash, &1.to_address_hash, &1.created_contract_address_hash]) diff --git a/apps/explorer/lib/explorer/chain/bridged_token.ex b/apps/explorer/lib/explorer/chain/bridged_token.ex index c34b42d14cd..39b390b7a09 100644 --- a/apps/explorer/lib/explorer/chain/bridged_token.ex +++ b/apps/explorer/lib/explorer/chain/bridged_token.ex @@ -41,15 +41,7 @@ defmodule Explorer.Chain.BridgedToken do # keccak 256 from token1() @token1_signature "0xd21220a7" - @derive {Poison.Encoder, - except: [ - :__meta__, - :home_token_contract_address, - :inserted_at, - :updated_at - ]} - - @derive {Jason.Encoder, + @derive {JSON.Encoder, except: [ :__meta__, :home_token_contract_address, @@ -308,8 +300,7 @@ defmodule Explorer.Chain.BridgedToken do |> where([it], it.transaction_index == ^transaction_index) |> where( [it], - it.to_address_hash == ^omni_bridge_mediator_hash or - as(:to_address_mapping).address_hash == ^omni_bridge_mediator_hash + as(:to_address_mapping).address_hash == ^omni_bridge_mediator_hash ) created_by_amb_mediator = diff --git a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex index 7b70f201d45..3057bc17a86 100644 --- a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex +++ b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex @@ -28,16 +28,20 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :tt_denormalization_finished, key: :sanitize_duplicated_log_index_logs_finished, key: :backfill_multichain_search_db_finished, + key: :backfill_multichain_search_db_current_token_balances_finished, key: :arbitrum_da_records_normalization_finished, key: :sanitize_verified_addresses_finished, key: :backfill_call_type_enum_finished, + key: :transaction_has_token_transfers_finished, key: :heavy_indexes_create_logs_block_hash_index_finished, key: :heavy_indexes_drop_logs_block_number_asc_index_asc_index_finished, key: :heavy_indexes_create_logs_address_hash_block_number_desc_index_desc_index_finished, + key: :heavy_indexes_create_logs_address_id_block_number_desc_index_desc_index_finished, key: :heavy_indexes_drop_logs_address_hash_index_finished, key: :heavy_indexes_drop_logs_address_hash_transaction_hash_index_finished, key: :heavy_indexes_drop_logs_index_index_finished, key: :heavy_indexes_create_logs_address_hash_first_topic_block_number_index_index_finished, + key: :heavy_indexes_create_logs_address_id_first_topic_id_block_number_index_index_finished, key: :heavy_indexes_drop_token_transfers_block_number_asc_log_index_asc_index_finished, key: :heavy_indexes_drop_token_transfers_from_address_hash_transaction_hash_index_finished, key: :heavy_indexes_drop_token_transfers_to_address_hash_transaction_hash_index_finished, @@ -55,6 +59,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :heavy_indexes_drop_transactions_from_address_hash_with_pending_index_finished, key: :heavy_indexes_drop_transactions_to_address_hash_with_pending_index_finished, key: :heavy_indexes_create_logs_deposits_withdrawals_index_finished, + key: :heavy_indexes_create_logs_deposits_withdrawals_index_with_updated_pk_finished, key: :heavy_indexes_create_addresses_transactions_count_desc_partial_index_finished, key: :heavy_indexes_create_addresses_transactions_count_asc_coin_balance_desc_hash_partial_index_finished, key: :heavy_indexes_drop_token_instances_token_id_index_finished, @@ -64,6 +69,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :heavy_indexes_create_idx_tokens_ord_fiat_holder_name_finished, key: :heavy_indexes_create_idx_tokens_ord_holder_name_finished, key: :heavy_indexes_update_internal_transactions_primary_key_finished, + key: :heavy_indexes_update_logs_primary_key_finished, key: :empty_internal_transactions_data_finished, key: :heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished, key: :heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished, @@ -73,7 +79,10 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :backfill_address_counters_finished, key: :backfill_token_counters_finished, key: :heavy_indexes_create_logs_address_hash_first_topic_second_topic_block_number_index_finished, - key: :heavy_indexes_create_address_current_token_balances_address_hash_block_number_index_finished + key: :heavy_indexes_create_address_current_token_balances_address_hash_block_number_index_finished, + key: :heavy_indexes_create_transactions_token_transfer_method_id_ordered_index_finished, + key: :create_logs_block_number_transaction_index_index_unique_index_finished, + key: :fill_logs_optimized_fields_finished @dialyzer :no_match @@ -83,11 +92,14 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ArbitrumDaRecordsNormalization, BackfillAddressCounters, BackfillMultichainSearchDB, + BackfillMultichainSearchDbCurrentTokenBalances, BackfillTokenCounters, EmptyInternalTransactionsData, FillInternalTransactionsAddressIds, + FillLogsOptimizedFields, SanitizeDuplicatedLogIndexLogs, TokenTransferTokenType, + TransactionHasTokenTransfers, TransactionsDenormalization } @@ -105,14 +117,19 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do CreateLogsAddressHashBlockNumberDescIndexDescIndex, CreateLogsAddressHashFirstTopicBlockNumberIndexIndex, CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + CreateLogsAddressIdBlockNumberDescIndexDescIndex, + CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, CreateLogsBlockHashIndex, + CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, CreateLogsDepositsWithdrawalsIndex, + CreateLogsDepositsWithdrawalsIndexWithUpdatedPk, CreateSmartContractsLanguageIndex, CreateTokensNamePartialFtsIndex, CreateTokensOrdFiatHolderNameIndex, CreateTokensOrdHolderNameIndex, CreateTokensOrdMcapFiatHolderNameIndex, CreateTransactionsCreatedContractAddressHashWPendingIndex, + CreateTransactionsTokenTransferMethodIdOrderedIndex, DropInternalTransactionsCreatedContractAddressHashPartialIndex, DropInternalTransactionsFromAddressHashIndex, DropLogsAddressHashIndex, @@ -129,7 +146,8 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do DropTransactionsCreatedContractAddressHashWithPendingIndexA, DropTransactionsFromAddressHashWithPendingIndex, DropTransactionsToAddressHashWithPendingIndex, - UpdateInternalTransactionsPrimaryKey + UpdateInternalTransactionsPrimaryKey, + UpdateLogsPrimaryKey } defp handle_fallback(:transactions_denormalization_finished) do @@ -174,6 +192,20 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:backfill_multichain_search_db_current_token_balances_finished) do + set_and_return_migration_status( + BackfillMultichainSearchDbCurrentTokenBalances, + &set_backfill_multichain_search_db_current_token_balances_finished/1 + ) + end + + defp handle_fallback(:transaction_has_token_transfers_finished) do + set_and_return_migration_status( + TransactionHasTokenTransfers, + &set_transaction_has_token_transfers_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_create_logs_block_hash_index_finished) do set_and_return_migration_status( CreateLogsBlockHashIndex, @@ -195,6 +227,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:heavy_indexes_create_logs_address_id_block_number_desc_index_desc_index_finished) do + set_and_return_migration_status( + CreateLogsAddressIdBlockNumberDescIndexDescIndex, + &set_heavy_indexes_create_logs_address_id_block_number_desc_index_desc_index_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_drop_logs_address_hash_index_finished) do set_and_return_migration_status( DropLogsAddressHashIndex, @@ -223,6 +262,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:heavy_indexes_create_logs_address_id_first_topic_id_block_number_index_index_finished) do + set_and_return_migration_status( + CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, + &set_heavy_indexes_create_logs_address_id_first_topic_id_block_number_index_index_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_drop_token_transfers_block_number_asc_log_index_asc_index_finished) do set_and_return_migration_status( DropTokenTransfersBlockNumberAscLogIndexAscIndex, @@ -337,6 +383,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:heavy_indexes_create_logs_deposits_withdrawals_index_with_updated_pk_finished) do + set_and_return_migration_status( + CreateLogsDepositsWithdrawalsIndexWithUpdatedPk, + &set_heavy_indexes_create_logs_deposits_withdrawals_index_with_updated_pk_finished/1 + ) + end + defp handle_fallback(:arbitrum_da_records_normalization_finished) do set_and_return_migration_status( ArbitrumDaRecordsNormalization, @@ -416,6 +469,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:heavy_indexes_update_logs_primary_key_finished) do + set_and_return_migration_status( + UpdateLogsPrimaryKey, + &set_heavy_indexes_update_logs_primary_key_finished/1 + ) + end + defp handle_fallback(:empty_internal_transactions_data_finished) do set_and_return_migration_status( EmptyInternalTransactionsData, @@ -451,6 +511,20 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:create_logs_block_number_transaction_index_index_unique_index_finished) do + set_and_return_migration_status( + CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + &set_create_logs_block_number_transaction_index_index_unique_index_finished/1 + ) + end + + defp handle_fallback(:fill_logs_optimized_fields_finished) do + set_and_return_migration_status( + FillLogsOptimizedFields, + &set_fill_logs_optimized_fields_finished/1 + ) + end + defp handle_fallback(:heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished) do set_and_return_migration_status( CreateTransactionsCreatedContractAddressHashWPendingIndex, @@ -479,6 +553,13 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do ) end + defp handle_fallback(:heavy_indexes_create_transactions_token_transfer_method_id_ordered_index_finished) do + set_and_return_migration_status( + CreateTransactionsTokenTransferMethodIdOrderedIndex, + &set_heavy_indexes_create_transactions_token_transfer_method_id_ordered_index_finished/1 + ) + end + defp handle_fallback(:sanitize_verified_addresses_finished) do {:return, false} end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex index f6ee0c32bcb..c0024445913 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex @@ -23,31 +23,33 @@ defmodule Explorer.Chain.Cache.Counters.AddressTabsElementsCount do | :beacon_deposits @typep response_status :: :limit_value | :stale | :up_to_date - @spec get_counter(counter_type, String.t()) :: {DateTime.t(), non_neg_integer(), response_status} | nil - def get_counter(counter_type, address_hash) do - @cache_name |> fetch_from_ets_cache(address_hash |> cache_key(counter_type), nil) |> check_staleness() + @spec get_counter(counter_type, String.t(), boolean()) :: {DateTime.t(), non_neg_integer(), response_status} | nil + def get_counter(counter_type, address_hash, show_scam_tokens? \\ false) do + @cache_name + |> fetch_from_ets_cache(cache_key(address_hash, counter_type, show_scam_tokens?), nil) + |> check_staleness() end - @spec set_counter(counter_type, String.t(), non_neg_integer()) :: :ok - def set_counter(counter_type, address_hash, counter) do - :ets.insert(@cache_name, {cache_key(address_hash, counter_type), {DateTime.utc_now(), counter}}) + @spec set_counter(counter_type, String.t(), non_neg_integer(), boolean()) :: :ok + def set_counter(counter_type, address_hash, counter, show_scam_tokens? \\ false) do + :ets.insert(@cache_name, {cache_key(address_hash, counter_type, show_scam_tokens?), {DateTime.utc_now(), counter}}) :ok end - @spec set_task(atom, String.t()) :: true - def set_task(counter_type, address_hash) do - :ets.insert(@cache_name, {task_cache_key(address_hash, counter_type), true}) + @spec set_task(atom, String.t(), boolean()) :: true + def set_task(counter_type, address_hash, show_scam_tokens? \\ false) do + :ets.insert(@cache_name, {task_cache_key(address_hash, counter_type, show_scam_tokens?), true}) end - @spec drop_task(atom, String.t()) :: true - def drop_task(counter_type, address_hash) do - :ets.delete(@cache_name, task_cache_key(address_hash, counter_type)) + @spec drop_task(atom, String.t(), boolean()) :: true + def drop_task(counter_type, address_hash, show_scam_tokens? \\ false) do + :ets.delete(@cache_name, task_cache_key(address_hash, counter_type, show_scam_tokens?)) end - @spec get_task(atom, String.t()) :: true | nil - def get_task(counter_type, address_hash) do - @cache_name |> fetch_from_ets_cache(address_hash |> task_cache_key(counter_type), nil) + @spec get_task(atom, String.t(), boolean()) :: true | nil + def get_task(counter_type, address_hash, show_scam_tokens? \\ false) do + @cache_name |> fetch_from_ets_cache(task_cache_key(address_hash, counter_type, show_scam_tokens?), nil) end def save_transactions_counter_progress(address_hash, results) do @@ -135,6 +137,9 @@ defmodule Explorer.Chain.Cache.Counters.AddressTabsElementsCount do defp ttl, do: Application.get_env(:explorer, Explorer.Chain.Cache.Counters.AddressTabsElementsCount)[:ttl] defp lowercased_string(str), do: str |> to_string() |> String.downcase() - defp cache_key(address_hash, counter_type), do: {lowercased_string(address_hash), counter_type} - defp task_cache_key(address_hash, counter_type), do: {:task, lowercased_string(address_hash), counter_type} + defp cache_key(address_hash, counter_type, show_scam_tokens?), + do: {lowercased_string(address_hash), counter_type, show_scam_tokens?} + + defp task_cache_key(address_hash, counter_type, show_scam_tokens?), + do: {:task, lowercased_string(address_hash), counter_type, show_scam_tokens?} end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/optimism/deposits_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/optimism/deposits_count.ex new file mode 100644 index 00000000000..fda3fe286f8 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/optimism/deposits_count.ex @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.Optimism.DepositsCount do + @moduledoc """ + Caches the number of Optimism deposits displayed in the deposits list + (i.e. deposits which have a successful L2 transaction). + + It loads the count asynchronously and in a time interval of :cache_period (default to 1 hour). + """ + + use GenServer + + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.Optimism.Deposit + + @counter_type "optimism_deposits_count" + + @doc """ + Starts a process to periodically update the counter. + """ + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl true + def init(_args) do + {:ok, %{consolidate?: enable_consolidation?()}, {:continue, :ok}} + end + + defp schedule_next_consolidation do + Process.send_after(self(), :consolidate, cache_interval()) + end + + @impl true + def handle_continue(:ok, %{consolidate?: true} = state) do + consolidate() + schedule_next_consolidation() + + {:noreply, state} + end + + @impl true + def handle_continue(:ok, state) do + {:noreply, state} + end + + @impl true + def handle_info(:consolidate, state) do + consolidate() + schedule_next_consolidation() + + {:noreply, state} + end + + @doc """ + Fetches the value for a `#{@counter_type}` counter type from the `last_fetched_counters` table. + """ + def fetch(options) do + LastFetchedCounter.get(@counter_type, options |> Keyword.put_new(:nullable, true)) + end + + @doc """ + Consolidates the info by populating the `last_fetched_counters` table with the current database information. + """ + def consolidate do + count = Deposit.count(timeout: :infinity) + + LastFetchedCounter.upsert(%{ + counter_type: @counter_type, + value: count + }) + end + + @doc """ + Returns a boolean that indicates whether consolidation is enabled + + In order to choose whether or not to enable the scheduler and the initial + consolidation, change the following Explorer config: + + `config :explorer, #{__MODULE__}, enable_consolidation: true` + + to: + + `config :explorer, #{__MODULE__}, enable_consolidation: false` + """ + def enable_consolidation?, do: Application.get_env(:explorer, __MODULE__)[:enable_consolidation] + + defp cache_interval, do: Application.get_env(:explorer, __MODULE__)[:cache_period] +end diff --git a/apps/explorer/lib/explorer/chain/celo/epoch.ex b/apps/explorer/lib/explorer/chain/celo/epoch.ex index f4a20da66d4..8d042961a40 100644 --- a/apps/explorer/lib/explorer/chain/celo/epoch.ex +++ b/apps/explorer/lib/explorer/chain/celo/epoch.ex @@ -447,14 +447,25 @@ defmodule Explorer.Chain.Celo.Epoch do @spec block_range_to_epoch_range(integer(), integer(), Keyword.t()) :: {integer(), integer()} | nil def block_range_to_epoch_range(from_block, to_block, options \\ []) do query = - from(e in __MODULE__, - where: e.end_block_number >= ^from_block and e.end_block_number < ^to_block, - select: {min(e.number), max(e.number)} - ) + from(e in __MODULE__, select: {min(e.number), max(e.number)}) + |> where_end_block_number_from(from_block) + |> where_end_block_number_to(to_block) case Chain.select_repo(options).one(query) do {nil, nil} -> nil res -> res end end + + defp where_end_block_number_from(query, nil), do: query + + defp where_end_block_number_from(query, from_block) do + from(e in query, where: e.end_block_number >= ^from_block) + end + + defp where_end_block_number_to(query, nil), do: query + + defp where_end_block_number_to(query, to_block) do + from(e in query, where: e.end_block_number < ^to_block) + end end diff --git a/apps/explorer/lib/explorer/chain/compressed_data.ex b/apps/explorer/lib/explorer/chain/compressed_data.ex new file mode 100644 index 00000000000..879f807a428 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/compressed_data.ex @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CompressedData do + @moduledoc """ + Ecto type for storing `Explorer.Chain.Data` in LZ4-compressed `bytea` format. + + The Elixir-side representation is the same as `Explorer.Chain.Data.t/0`, while + dumped database value is compressed binary data. + """ + + alias Explorer.Chain.Data + + use Ecto.Type + + @type t :: Data.t() + + @impl Ecto.Type + @spec type() :: :binary + def type, do: :binary + + @impl Ecto.Type + @spec cast(term()) :: {:ok, t()} | :error + def cast(term), do: Data.cast(term) + + @impl Ecto.Type + @spec dump(term()) :: {:ok, binary()} | :error + def dump(term) do + with {:ok, data} <- Data.cast(term), + {:ok, bytes} <- Data.dump(data) do + {:ok, compress(bytes)} + end + end + + @impl Ecto.Type + @spec load(term()) :: {:ok, t()} | :error + def load(<<_::binary>> = compressed_bytes) do + case decompress(compressed_bytes) do + {:ok, bytes} -> Data.load(bytes) + _ -> :error + end + end + + def load(_), do: :error + + defp compress(bytes) do + <<byte_size(bytes)::32>> <> NimbleLZ4.compress(bytes) + end + + defp decompress(<<uncompressed_size::32, compressed_binary::binary>>) do + case NimbleLZ4.decompress(compressed_binary, uncompressed_size) do + {:ok, bytes} when is_binary(bytes) -> {:ok, bytes} + _ -> :error + end + end +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex b/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex index 2871fe4f999..f4152385822 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex @@ -49,10 +49,10 @@ defmodule Explorer.Chain.CsvExport.Address.Logs do logs |> Stream.map(fn log -> [ - to_string(log.transaction_hash), + to_string(log.transaction.hash), log.index, log.block_number, - log.block_hash, + log.block.hash, Address.checksum(log.address_hash), to_string(log.data), to_string(log.first_topic), diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex b/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex index bb647d74336..ca4f0e6943e 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex @@ -14,6 +14,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do alias Explorer.Chain.{Address, DenormalizationHelper, Hash, TokenTransfer, Transaction} alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} + alias Explorer.Chain.Token.UIMultiplierChange alias Explorer.Helper, as: ExplorerHelper alias Explorer.{PagingOptions, Repo} @@ -26,6 +27,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do address_hash |> fetch_all_token_transfers(from_block, to_block, filter_type, filter_value, paging_options, options) + |> UIMultiplierChange.put_ui_multipliers() |> to_csv_format(address_hash) |> Helper.dump_to_stream() end @@ -63,6 +65,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do "TokenDecimals", "TokenSymbol", "TokensTransferred", + "UIMultiplier", "TransactionFee", "Status", "ErrCode" @@ -82,6 +85,10 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do token_transfer.token.decimals, token_transfer.token.symbol, token_transfer.amount, + # amounts are exported raw, `TokenDecimals` and `UIMultiplier` being + # the separate columns they are displayed through, so that the export + # loses nothing + token_transfer.ui_multiplier, fee(token_transfer.transaction), token_transfer.transaction.status, token_transfer.transaction.error diff --git a/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex b/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex index 4c8b9ceba18..e60937d224a 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex @@ -128,7 +128,7 @@ defmodule Explorer.Chain.CsvExport.AsyncHelper do ) with {:ok, %{status_code: 200, body: body}} <- result, - {:ok, %{"FileInfo" => %{"Id" => file_id, "ExpireAt" => expires_at_unix_timestamp}}} <- Jason.decode(body), + {:ok, %{"FileInfo" => %{"Id" => file_id, "ExpireAt" => expires_at_unix_timestamp}}} <- Utils.JSON.decode(body), {:ok, expires_at} <- DateTime.from_unix(expires_at_unix_timestamp) do {:ok, file_id, expires_at} else diff --git a/apps/explorer/lib/explorer/chain/csv_export/helper.ex b/apps/explorer/lib/explorer/chain/csv_export/helper.ex index 72a5183adad..9c0540d61bf 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/helper.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/helper.ex @@ -44,6 +44,7 @@ defmodule Explorer.Chain.CsvExport.Helper do ## Returns - A tuple `{from_block, to_block}` where `from_block` is the minimum block number from the `from_period` and `to_block` is the maximum block number from the `to_period`. + A missing (`nil` or empty) period yields `nil` for the corresponding bound, meaning "no bound". ## Examples @@ -53,8 +54,12 @@ defmodule Explorer.Chain.CsvExport.Helper do iex> block_from_period("2023-01-01", "2023-12-31") {1000, 2000} + iex> block_from_period(nil, "") + {nil, nil} + """ - @spec block_from_period(String.t(), String.t()) :: {Block.block_number(), Block.block_number()} + @spec block_from_period(String.t() | nil, String.t() | nil) :: + {Block.block_number() | nil, Block.block_number() | nil} def block_from_period(from_period, to_period) do from_block = convert_date_string_to_block(from_period, :after, :from) to_block = convert_date_string_to_block(to_period, :before, :to) @@ -62,7 +67,10 @@ defmodule Explorer.Chain.CsvExport.Helper do {from_block, to_block} end - @spec convert_date_string_to_block(String.t(), :before | :after, :from | :to) :: integer() + @spec convert_date_string_to_block(String.t() | nil, :before | :after, :from | :to) :: integer() | nil + defp convert_date_string_to_block(nil, _direction, _range_type), do: nil + defp convert_date_string_to_block("", _direction, _range_type), do: nil + defp convert_date_string_to_block(date_string, direction, range_type) do with {:ok, timestamp, _utc_offset} <- date_string_to_timestamp(date_string, range_type), {:ok, block} <- BlockGeneralReader.timestamp_to_block_number(timestamp, direction, true) do diff --git a/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex b/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex index 98a7a8055eb..c0390a30f5f 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex @@ -9,6 +9,7 @@ defmodule Explorer.Chain.CsvExport.Token.Holders do alias Explorer.Chain.Address.CurrentTokenBalance alias Explorer.Chain.CsvExport.AsyncHelper alias Explorer.Chain.CsvExport.Helper, as: CsvHelper + alias Explorer.Chain.Token.ScaledUIAmount @spec export(Hash.Address.t(), any(), any(), any(), any(), any()) :: Enumerable.t() @@ -46,12 +47,20 @@ defmodule Explorer.Chain.CsvExport.Token.Holders do "Balance" ] + # Balances are exported the way the holders page shows them rather than raw, + # so the ERC-8056 multiplier belongs in the number itself — the current one, + # since a current balance is a statement about now. + ui_multiplier = Token.effective_ui_multiplier(token) + holders_list = holders |> Stream.map(fn ctb -> [ Address.checksum(ctb.address_hash), - ctb.value |> CurrencyHelper.divide_decimals(token.decimals) |> Decimal.to_string(:xsd) + ctb.value + |> ScaledUIAmount.scale(ui_multiplier) + |> CurrencyHelper.divide_decimals(token.decimals) + |> Decimal.to_string(:xsd) ] end) diff --git a/apps/explorer/lib/explorer/chain/csv_export/token/transfers.ex b/apps/explorer/lib/explorer/chain/csv_export/token/transfers.ex new file mode 100644 index 00000000000..6898b5bcca9 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/token/transfers.ex @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Token.Transfers do + @moduledoc """ + Exports token transfers for a specific token to a csv file. + """ + + import Ecto.Query, + only: [ + limit: 2, + order_by: 3, + preload: 2, + where: 3 + ] + + alias Explorer.Chain + alias Explorer.Chain.{Address, DenormalizationHelper, Hash, Token, TokenTransfer, Transaction} + alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} + alias Explorer.Chain.Token.UIMultiplierChange + alias Explorer.{PagingOptions, Repo} + + @doc """ + Exports token transfers for a specific token to CSV format within the given time period. + """ + @spec export(Hash.Address.t(), String.t(), String.t(), Keyword.t(), String.t() | nil, String.t() | nil) :: + Enumerable.t() + def export(token_address_hash, from_period, to_period, _options, _filter_type, _filter_value) do + {from_block, to_block} = Helper.block_from_period(from_period, to_period) + + {:ok, token} = Chain.token_from_address_hash(token_address_hash, api?: true) + + paging_options = %PagingOptions{Helper.paging_options() | asc_order: true} + + token_address_hash + |> fetch_all_token_transfers(from_block, to_block, paging_options) + |> with_ui_multipliers(token) + |> to_csv_format(token) + |> Helper.dump_to_stream() + end + + # The transfers are loaded without their token, which is read once for the + # whole export, while resolving the ERC-8056 multiplier of a transfer takes + # the token it belongs to. + defp with_ui_multipliers(token_transfers, token) do + token_transfers + |> Enum.map(&%{&1 | token: token}) + |> UIMultiplierChange.put_ui_multipliers() + end + + defp fetch_all_token_transfers(token_address_hash, from_block, to_block, paging_options) do + token_address_hash + |> token_transfers_query(from_block, to_block) + |> order_by([tt], asc: tt.block_number, asc: tt.log_index) + |> TokenTransfer.page_token_transfer(paging_options) + |> limit(^paging_options.page_size) + |> preload(^DenormalizationHelper.extend_transaction_preload([:transaction])) + |> Repo.replica().all(timeout: AsyncHelper.db_timeout()) + end + + defp token_transfers_query(token_address_hash, from_block, to_block) do + TokenTransfer.only_consensus_transfers_query() + |> where([tt], tt.token_contract_address_hash == ^token_address_hash and not is_nil(tt.block_number)) + |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) + end + + @spec to_csv_format([TokenTransfer.t()], Token.t()) :: Enumerable.t() + defp to_csv_format(token_transfers, token) do + row_names = [ + "TxHash", + "BlockNumber", + "UnixTimestamp", + "FromAddress", + "ToAddress", + "TokenContractAddress", + "TokenDecimals", + "TokenSymbol", + "TokensTransferred", + "UIMultiplier", + "TransactionFee", + "Status", + "ErrCode" + ] + + token_transfer_lists = + token_transfers + |> Stream.map(fn token_transfer -> + [ + to_string(token_transfer.transaction_hash), + token_transfer.transaction.block_number, + Transaction.block_timestamp(token_transfer.transaction), + Address.checksum(token_transfer.from_address_hash), + Address.checksum(token_transfer.to_address_hash), + Address.checksum(token_transfer.token_contract_address_hash), + token.decimals, + token.symbol, + token_transfer.amount, + # amounts are exported raw, `TokenDecimals` and `UIMultiplier` being + # the separate columns they are displayed through, so that the export + # loses nothing + token_transfer.ui_multiplier, + fee(token_transfer.transaction), + token_transfer.transaction.status, + token_transfer.transaction.error + ] + end) + + Stream.concat([row_names], token_transfer_lists) + end + + @spec fee(Transaction.t()) :: non_neg_integer() | String.t() + defp fee(transaction) do + transaction + |> Transaction.fee(:wei) + |> case do + {:actual, value} -> value + {:maximum, value} -> "Max of #{value}" + end + end +end diff --git a/apps/explorer/lib/explorer/chain/data.ex b/apps/explorer/lib/explorer/chain/data.ex index c04939351ec..5ceab5d9cfa 100644 --- a/apps/explorer/lib/explorer/chain/data.ex +++ b/apps/explorer/lib/explorer/chain/data.ex @@ -8,7 +8,6 @@ defmodule Explorer.Chain.Data do """ alias Explorer.Chain.Data - alias Poison.Encoder.BitString use Ecto.Type @@ -411,21 +410,11 @@ defmodule Explorer.Chain.Data do end end - defimpl Poison.Encoder do - def encode(data, options) do + defimpl JSON.Encoder do + def encode(data, encoder) do data |> to_string() - |> BitString.encode(options) - end - end - - defimpl Jason.Encoder do - alias Jason.Encode - - def encode(data, opts) do - data - |> to_string() - |> Encode.string(opts) + |> JSON.Encoder.encode(encoder) end end end diff --git a/apps/explorer/lib/explorer/chain/denormalization_helper.ex b/apps/explorer/lib/explorer/chain/denormalization_helper.ex index 91f36c70940..4ad20777224 100644 --- a/apps/explorer/lib/explorer/chain/denormalization_helper.ex +++ b/apps/explorer/lib/explorer/chain/denormalization_helper.ex @@ -50,4 +50,20 @@ defmodule Explorer.Chain.DenormalizationHelper do def transactions_denormalization_finished?, do: BackgroundMigrations.get_transactions_denormalization_finished() def tt_denormalization_finished?, do: BackgroundMigrations.get_tt_denormalization_finished() + + @doc """ + Checks if the `CreateTransactionsTokenTransferMethodIdOrderedIndex` heavy DB + index operation has been completed. + + This index is created on the transactions table after the `has_token_transfers` + column is fully backfilled, making it a reliable signal that the column can be + safely used as an early filter in queries. + + ## Returns + - `true` if the index has been created. + - `false` if the operation is still ongoing or the status is unknown. + """ + @spec transaction_has_token_transfers_finished?() :: boolean() + def transaction_has_token_transfers_finished?, + do: BackgroundMigrations.get_heavy_indexes_create_transactions_token_transfer_method_id_ordered_index_finished() end diff --git a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex index 5136fd5c459..bbd04bc157f 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex @@ -16,7 +16,7 @@ defmodule Explorer.Chain.Fetcher.AddressesBlacklist.Blockaid do case HttpClient.get(AddressesBlacklist.url(), [], recv_timeout: @timeout, timeout: @timeout) do {:ok, %{status_code: 200, body: body}} -> body - |> Jason.decode() + |> Utils.JSON.decode() |> parse_blacklist() _ -> diff --git a/apps/explorer/lib/explorer/chain/filecoin/id.ex b/apps/explorer/lib/explorer/chain/filecoin/id.ex index 823c51510cc..426c8552016 100644 --- a/apps/explorer/lib/explorer/chain/filecoin/id.ex +++ b/apps/explorer/lib/explorer/chain/filecoin/id.ex @@ -5,7 +5,6 @@ defmodule Explorer.Chain.Filecoin.IDAddress do """ alias Explorer.Chain.Filecoin.NativeAddress - alias Poison.Encoder.BitString require Integer @@ -138,21 +137,11 @@ defmodule Explorer.Chain.Filecoin.IDAddress do end end - defimpl Poison.Encoder do - def encode(address, options) do + defimpl JSON.Encoder do + def encode(address, encoder) do address |> to_string() - |> BitString.encode(options) - end - end - - defimpl Jason.Encoder do - alias Jason.Encode - - def encode(address, opts) do - address - |> to_string() - |> Encode.string(opts) + |> JSON.Encoder.encode(encoder) end end end diff --git a/apps/explorer/lib/explorer/chain/filecoin/native_address.ex b/apps/explorer/lib/explorer/chain/filecoin/native_address.ex index d35261c29bd..e98bcac39be 100644 --- a/apps/explorer/lib/explorer/chain/filecoin/native_address.ex +++ b/apps/explorer/lib/explorer/chain/filecoin/native_address.ex @@ -39,7 +39,6 @@ defmodule Explorer.Chain.Filecoin.NativeAddress do """ alias Explorer.Chain.Hash - alias Poison.Encoder.BitString alias Varint.LEB128 use Ecto.Type @@ -458,21 +457,11 @@ defmodule Explorer.Chain.Filecoin.NativeAddress do end end - defimpl Poison.Encoder do - def encode(hash, options) do + defimpl JSON.Encoder do + def encode(hash, encoder) do hash |> to_string() - |> BitString.encode(options) - end - end - - defimpl Jason.Encoder do - alias Jason.Encode - - def encode(hash, opts) do - hash - |> to_string() - |> Encode.string(opts) + |> JSON.Encoder.encode(encoder) end end end diff --git a/apps/explorer/lib/explorer/chain/hash.ex b/apps/explorer/lib/explorer/chain/hash.ex index b868a582850..d2f45d30766 100644 --- a/apps/explorer/lib/explorer/chain/hash.ex +++ b/apps/explorer/lib/explorer/chain/hash.ex @@ -5,7 +5,6 @@ defmodule Explorer.Chain.Hash do """ import Bitwise - alias Poison.Encoder.BitString @bits_per_byte 8 @hexadecimal_digits_per_byte 2 @@ -227,21 +226,11 @@ defmodule Explorer.Chain.Hash do end end - defimpl Poison.Encoder do - def encode(hash, options) do + defimpl JSON.Encoder do + def encode(hash, encoder) do hash |> to_string() - |> BitString.encode(options) - end - end - - defimpl Jason.Encoder do - alias Jason.Encode - - def encode(hash, opts) do - hash - |> to_string() - |> Encode.string(opts) + |> JSON.Encoder.encode(encoder) end end end diff --git a/apps/explorer/lib/explorer/chain/hash/trimmed.ex b/apps/explorer/lib/explorer/chain/hash/trimmed.ex new file mode 100644 index 00000000000..248a2457926 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/hash/trimmed.ex @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Hash.Trimmed do + @moduledoc """ + A 32-byte hash that is stored in the database without leading zero bytes. + + This type keeps the public representation compatible with `Explorer.Chain.Hash.Full`: values are cast and loaded + as 32-byte hashes, but dumped to the database in a compact `bytea` format with leading zero bytes removed. + """ + + alias Explorer.Chain.Hash + alias Explorer.Chain.Hash.Full + + use Ecto.Type + + @byte_count 32 + @hexadecimal_digit_count Hash.hexadecimal_digits_per_byte() * @byte_count + + @typedoc """ + A #{@byte_count}-byte hash stored in the database without leading zero bytes. + """ + @type t :: %Hash{byte_count: unquote(@byte_count), bytes: <<_::unquote(@byte_count * Hash.bits_per_byte())>>} + + @doc """ + Casts `term` to `t:t/0` using `Explorer.Chain.Hash.Full.cast/1`. + + If the `term` is already in `t:t/0`, then it is returned. + + iex> Explorer.Chain.Hash.Trimmed.cast( + ...> %Explorer.Chain.Hash{ + ...> byte_count: 32, + ...> bytes: <<0x00000000000000000000000000000000000000000000000000000000000000ff :: + ...> big-integer-size(32)-unit(8)>> + ...> } + ...> ) + { + :ok, + %Explorer.Chain.Hash{ + byte_count: 32, + bytes: <<0x00000000000000000000000000000000000000000000000000000000000000ff :: big-integer-size(32)-unit(8)>> + } + } + + If the `term` is a `String.t` that starts with `0x`, then it is converted to `t:t/0`. + + iex> Explorer.Chain.Hash.Trimmed.cast("0x00000000000000000000000000000000000000000000000000000000000000ff") + { + :ok, + %Explorer.Chain.Hash{ + byte_count: 32, + bytes: <<0x00000000000000000000000000000000000000000000000000000000000000ff :: big-integer-size(32)-unit(8)>> + } + } + + `String.t` format must always have #{@hexadecimal_digit_count} hexadecimal digits after the `0x` base prefix. + + iex> Explorer.Chain.Hash.Trimmed.cast("0xff") + :error + + """ + @impl Ecto.Type + @spec cast(term()) :: {:ok, t()} | :error + def cast(term), do: Full.cast(term) + + @doc """ + Dumps the hash to compact `:binary` (`bytea`) format used in database. + + Leading zero bytes are removed before storing the value. + + iex> Explorer.Chain.Hash.Trimmed.dump( + ...> %Explorer.Chain.Hash{ + ...> byte_count: 32, + ...> bytes: <<0x00000000000000000000000000000000000000000000000000000000000000ff :: + ...> big-integer-size(32)-unit(8)>> + ...> } + ...> ) + {:ok, <<0xff>>} + + A zero hash is stored as an empty binary. + + iex> Explorer.Chain.Hash.Trimmed.dump( + ...> %Explorer.Chain.Hash{ + ...> byte_count: 32, + ...> bytes: <<0::256>> + ...> } + ...> ) + {:ok, <<>>} + + If the field from the struct is an incorrect format such as `t:Explorer.Chain.Hash.Address.t/0`, `:error` is returned. + + iex> Explorer.Chain.Hash.Trimmed.dump( + ...> %Explorer.Chain.Hash{ + ...> byte_count: 20, + ...> bytes: <<0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed :: big-integer-size(20)-unit(8)>> + ...> } + ...> ) + :error + + """ + @impl Ecto.Type + @spec dump(term()) :: {:ok, binary()} | :error + def dump(%Hash{byte_count: @byte_count, bytes: bytes}) do + {:ok, trim_leading_zeroes(bytes)} + end + + def dump(term), do: Full.dump(term) + + @doc """ + Loads the compact binary hash from the database. + + The binary is left-padded with zero bytes up to #{@byte_count} bytes and loaded as `Explorer.Chain.Hash.Full`. + + iex> Explorer.Chain.Hash.Trimmed.load(<<0xff>>) + { + :ok, + %Explorer.Chain.Hash{ + byte_count: 32, + bytes: <<0x00000000000000000000000000000000000000000000000000000000000000ff :: big-integer-size(32)-unit(8)>> + } + } + + An empty binary is loaded as a zero hash. + + iex> Explorer.Chain.Hash.Trimmed.load(<<>>) + { + :ok, + %Explorer.Chain.Hash{ + byte_count: 32, + bytes: <<0::256>> + } + } + + If the binary hash is longer than #{@byte_count} bytes, `:error` is returned. + + iex> Explorer.Chain.Hash.Trimmed.load(<<0::264>>) + :error + + """ + @impl Ecto.Type + @spec load(term()) :: {:ok, t()} | :error + def load(bytes) when is_binary(bytes) and byte_size(bytes) <= @byte_count do + padding_size = @byte_count - byte_size(bytes) + padded = <<0::size(padding_size)-unit(8), bytes::binary>> + + Full.load(padded) + end + + def load(_), do: :error + + @doc """ + The underlying database type: `binary`. `binary` is used because stored values have variable byte length. + """ + @impl Ecto.Type + @spec type() :: :binary + def type, do: :binary + + defp trim_leading_zeroes(<<0, rest::binary>>), do: trim_leading_zeroes(rest) + + defp trim_leading_zeroes(bytes), do: bytes +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index 6de85f66eaf..a67b3c467f2 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -6,7 +6,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do require Ecto.Query - import Ecto.Query, only: [dynamic: 1, dynamic: 2, from: 2, where: 3, subquery: 1] + import Ecto.Query import Explorer.Chain.Import.Runner.Helper, only: [chain_identity_dependent_import: 3] import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] @@ -20,6 +20,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do BlockNumberHelper, DenormalizationHelper, Import, + Log, PendingOperationsHelper, SmartContract, Token, @@ -45,6 +46,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward alias Explorer.Chain.Celo.Epoch, as: CeloEpoch alias Explorer.Chain.Celo.EpochReward, as: CeloEpochReward + alias Explorer.Utility.LogHelper @behaviour Runner @@ -260,6 +262,14 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :save_internal_transactions_for_delete ) end) + |> Multi.run(:delete_logs, fn repo, %{lose_consensus: non_consensus_blocks} -> + Instrumenter.block_import_stage_runner( + fn -> delete_logs(repo, non_consensus_blocks, insert_options) end, + :address_referencing, + :blocks, + :delete_logs + ) + end) |> Multi.run(:update_token_instances_owner, fn repo, %{derive_transaction_forks: transactions} -> Instrumenter.block_import_stage_runner( fn -> update_token_instances_owner(repo, transactions, insert_options) end, @@ -791,82 +801,94 @@ defmodule Explorer.Chain.Import.Runner.Blocks do |> Enum.map(fn {number, _hash, _previous_consensus} -> number end) |> recent_block_number() - repo.update_all( - from( - transaction in Transaction, - join: s in subquery(acquire_query), - on: transaction.block_hash == s.hash, - # we don't want to remove consensus from blocks that will be upserted - where: transaction.block_hash not in ^consensus_hashes - ), - [set: [block_consensus: false, updated_at: updated_at]], - timeout: timeout - ) + if Enum.empty?(removed_consensus_blocks) do + removed_consensus_block_numbers + |> Enum.reject(&Enum.member?(consensus_block_numbers, &1)) + |> MissingBlockRange.add_ranges_by_block_numbers() - repo.update_all( - from( - token_transfer in TokenTransfer, - join: s in subquery(acquire_query), - on: token_transfer.block_number == s.number and token_transfer.block_hash == s.hash, - # we don't want to remove consensus from blocks that will be upserted - where: token_transfer.block_hash not in ^consensus_hashes - ), - [set: [block_consensus: false, updated_at: updated_at]], - timeout: timeout - ) - - # Query to find addresses created in lost consensus blocks - created_contract_addresses_query = - from( - t in Transaction, - join: s in subquery(acquire_query), - on: t.block_hash == s.hash, - # we don't want to remove contract code from blocks that will be upserted - where: t.block_hash not in ^consensus_hashes, - where: not is_nil(t.created_contract_address_hash), - select: t.created_contract_address_hash + {:ok, + %{ + nonconsensus_blocks: removed_consensus_blocks, + beacon_deposit_reorg_block_number: beacon_deposit_reorg_block_number + }} + else + repo.update_all( + from( + transaction in Transaction, + join: s in subquery(acquire_query), + on: transaction.block_hash == s.hash, + # we don't want to remove consensus from blocks that will be upserted + where: transaction.block_hash not in ^consensus_hashes + ), + [set: [block_consensus: false, updated_at: updated_at]], + timeout: timeout ) - # Delete smart contracts for addresses created in lost consensus blocks - repo.delete_all( - from( - sc in SmartContract, - where: sc.address_hash in subquery(created_contract_addresses_query) - ), - timeout: timeout - ) + repo.update_all( + from( + token_transfer in TokenTransfer, + join: s in subquery(acquire_query), + on: token_transfer.block_number == s.number and token_transfer.block_hash == s.hash, + # we don't want to remove consensus from blocks that will be upserted + where: token_transfer.block_hash not in ^consensus_hashes + ), + [set: [block_consensus: false, updated_at: updated_at]], + timeout: timeout + ) - # Clear contract code from addresses created in lost consensus blocks - repo.update_all( - from( - address in Address, - where: address.hash in subquery(created_contract_addresses_query) - ), - [set: [contract_code: nil, updated_at: updated_at]], - timeout: timeout - ) + # Query to find addresses created in lost consensus blocks + created_contract_addresses_query = + from( + t in Transaction, + join: s in subquery(acquire_query), + on: t.block_hash == s.hash, + # we don't want to remove contract code from blocks that will be upserted + where: t.block_hash not in ^consensus_hashes, + where: not is_nil(t.created_contract_address_hash), + select: t.created_contract_address_hash + ) - if Application.get_env(:explorer, :chain_type) == :zilliqa do + # Delete smart contracts for addresses created in lost consensus blocks repo.delete_all( from( - zrc2_token_transfer in Zrc2TokenTransfer, - join: s in subquery(acquire_query), - on: zrc2_token_transfer.block_number == s.number and zrc2_token_transfer.block_hash == s.hash, - where: zrc2_token_transfer.block_hash not in ^consensus_hashes + sc in SmartContract, + where: sc.address_hash in subquery(created_contract_addresses_query) ), timeout: timeout ) - end - removed_consensus_block_numbers - |> Enum.reject(&Enum.member?(consensus_block_numbers, &1)) - |> MissingBlockRange.add_ranges_by_block_numbers() + # Clear contract code from addresses created in lost consensus blocks + repo.update_all( + from( + address in Address, + where: address.hash in subquery(created_contract_addresses_query) + ), + [set: [contract_code: nil, updated_at: updated_at]], + timeout: timeout + ) - {:ok, - %{ - nonconsensus_blocks: removed_consensus_blocks, - beacon_deposit_reorg_block_number: beacon_deposit_reorg_block_number - }} + if Application.get_env(:explorer, :chain_type) == :zilliqa do + repo.delete_all( + from( + zrc2_token_transfer in Zrc2TokenTransfer, + join: s in subquery(acquire_query), + on: zrc2_token_transfer.block_number == s.number and zrc2_token_transfer.block_hash == s.hash, + where: zrc2_token_transfer.block_hash not in ^consensus_hashes + ), + timeout: timeout + ) + end + + removed_consensus_block_numbers + |> Enum.reject(&Enum.member?(consensus_block_numbers, &1)) + |> MissingBlockRange.add_ranges_by_block_numbers() + + {:ok, + %{ + nonconsensus_blocks: removed_consensus_blocks, + beacon_deposit_reorg_block_number: beacon_deposit_reorg_block_number + }} + end rescue postgrex_error in Postgrex.Error -> {:error, %{exception: postgrex_error}} @@ -1189,6 +1211,40 @@ defmodule Explorer.Chain.Import.Runner.Blocks do {:ok, Enum.map(result, & &1.block_number)} end + defp delete_logs(_, [], _), do: {:ok, []} + + defp delete_logs(repo, non_consensus_blocks, %{timeout: timeout}) do + non_consensus_block_numbers = Enum.map(non_consensus_blocks, fn {number, _hash} -> number end) + + ordered_query = + Log + |> where([l], l.block_number in ^non_consensus_block_numbers) + |> select([l], select_ctid(l)) + |> then(fn query -> + if LogHelper.primary_key_updated?() do + order_by(query, [l], [l.transaction_index, l.index, l.block_number]) + else + order_by(query, [l], [l.transaction_hash, l.index, l.block_hash]) + end + end) + |> lock("FOR UPDATE") + + query = + from(l in Log, + inner_join: ordered_log in subquery(ordered_query), + on: join_on_ctid(l, ordered_log) + ) + + try do + {_count, deleted_logs} = repo.delete_all(query, timeout: timeout) + + {:ok, deleted_logs} + rescue + postgrex_error in Postgrex.Error -> + {:error, %{exception: postgrex_error, block_numbers: non_consensus_block_numbers}} + end + end + defp update_token_instances_owner(_, [], _), do: {:ok, []} defp update_token_instances_owner(repo, forked_transaction_hashes, options) do diff --git a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex index 05871720e7f..cc696e34176 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex @@ -36,6 +36,12 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do # milliseconds @timeout 60_000 + # Maximum number of characters kept from a trace error message. Error messages are stored in + # `transaction_errors.message` and copied to `transactions.error`; both columns are `varchar(255)`, + # and some clients (custom precompiles, L2 system contracts) return much longer errors that would + # otherwise fail the whole import batch with `value too long for type character varying(255)`. + @max_error_length 255 + @type imported :: [InternalTransaction.t()] @impl Runner @@ -588,14 +594,19 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end end + # Makes a trace error message storable: non-printable binaries are escaped via `inspect/2`, then the + # result is capped at `@max_error_length` code points. PostgreSQL `varchar(n)` counts code points, not + # bytes and not graphemes, so `String.slice/3` (grapheme-based) is not enough: a grapheme made of a base + # character plus combining marks is several code points. The cap is applied after escaping, since + # escaping can only make the message longer. defp sanitize_error(entry) do error = Map.get(entry, :error) sanitized_error = - if is_binary(error) and not String.printable?(error) do + if is_binary(error) do error - |> inspect(binaries: :as_strings) - |> String.trim("\"") + |> escape_non_printable_error() + |> truncate_code_points(@max_error_length) else error end @@ -603,6 +614,23 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do Map.put(entry, :error, sanitized_error) end + defp truncate_code_points(string, max_code_points) do + string + |> String.codepoints() + |> Enum.take(max_code_points) + |> Enum.join() + end + + defp escape_non_printable_error(error) do + if String.printable?(error) do + error + else + error + |> inspect(binaries: :as_strings) + |> String.trim("\"") + end + end + # Shifts the `created_contract_address_id` value to `to_address_id` when applicable. # This function handles the migration of contract creation data by copying the diff --git a/apps/explorer/lib/explorer/chain/import/runner/logs.ex b/apps/explorer/lib/explorer/chain/import/runner/logs.ex index f41af369600..0eef8a1e2d1 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/logs.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/logs.ex @@ -12,6 +12,7 @@ defmodule Explorer.Chain.Import.Runner.Logs do alias Ecto.{Changeset, Multi, Repo} alias Explorer.Chain.{Import, Log} alias Explorer.Prometheus.Instrumenter + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic, LogHelper} import Ecto.Query, only: [from: 2] @@ -55,6 +56,34 @@ defmodule Explorer.Chain.Import.Runner.Logs do end) end + @impl Import.Runner + def prepare_data(logs_params) do + address_hash_to_address_id_map = + logs_params + |> Enum.map(& &1.address_hash) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> AddressIdToAddressHash.find_or_create_multiple() + + first_topic_to_id_map = + logs_params + |> Enum.map(&Map.get(&1, :first_topic)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> LogFirstTopic.find_or_create_multiple() + + Enum.map(logs_params, fn entry -> + entry + |> Map.drop([:address_hash, :first_topic, :data]) + |> Map.put(:address_id, address_hash_to_address_id_map[String.downcase(to_string(entry[:address_hash]))]) + |> Map.put( + :first_topic_id, + entry[:first_topic] && first_topic_to_id_map[String.downcase(to_string(entry[:first_topic]))] + ) + |> Map.put(:compressed_data, entry[:data]) + end) + end + @impl Import.Runner def timeout, do: @timeout @@ -72,16 +101,30 @@ defmodule Explorer.Chain.Import.Runner.Logs do {ordered_changes_list, conflict_target} = case chain_identity() do {:optimism, :celo} -> - { - Enum.sort_by(changes_list, &{&1.block_hash, &1.index}), - [:index, :block_hash] - } + if LogHelper.primary_key_updated?() do + { + Enum.sort_by(changes_list, &{&1.block_number, &1.index}), + [:index, :block_number] + } + else + { + Enum.sort_by(changes_list, &{&1.block_number, &1.index}), + [:index, :block_hash] + } + end _ -> - { - Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.index}), - [:transaction_hash, :index, :block_hash] - } + if LogHelper.primary_key_updated?() do + { + Enum.sort_by(changes_list, &{&1.block_number, &1.transaction_index, &1.index}), + [:transaction_index, :index, :block_number] + } + else + { + Enum.sort_by(changes_list, &{&1.block_number, &1.transaction_index, &1.index}), + [:transaction_hash, :index, :block_hash] + } + end end {:ok, _} = @@ -100,63 +143,128 @@ defmodule Explorer.Chain.Import.Runner.Logs do defp default_on_conflict do case chain_identity() do {:optimism, :celo} -> - from( - log in Log, - update: [ - set: [ - address_hash: fragment("EXCLUDED.address_hash"), - data: fragment("EXCLUDED.data"), - first_topic: fragment("EXCLUDED.first_topic"), - second_topic: fragment("EXCLUDED.second_topic"), - third_topic: fragment("EXCLUDED.third_topic"), - fourth_topic: fragment("EXCLUDED.fourth_topic"), - # Don't update `index` as it is part of the composite primary key and used for the conflict target - transaction_hash: fragment("EXCLUDED.transaction_hash"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.address_hash, EXCLUDED.data, EXCLUDED.first_topic, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic, EXCLUDED.transaction_hash) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", - log.address_hash, - log.data, - log.first_topic, - log.second_topic, - log.third_topic, - log.fourth_topic, - log.transaction_hash - ) - ) + if LogHelper.primary_key_updated?() do + from( + log in Log, + update: [ + set: [ + address_id: fragment("EXCLUDED.address_id"), + compressed_data: fragment("EXCLUDED.compressed_data"), + first_topic_id: fragment("EXCLUDED.first_topic_id"), + second_topic: fragment("EXCLUDED.second_topic"), + third_topic: fragment("EXCLUDED.third_topic"), + fourth_topic: fragment("EXCLUDED.fourth_topic"), + # Don't update `index` as it is part of the composite primary key and used for the conflict target + transaction_index: fragment("EXCLUDED.transaction_index"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.address_id, EXCLUDED.compressed_data, EXCLUDED.first_topic_id, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic, EXCLUDED.transaction_index) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + log.address_id, + log.compressed_data, + log.first_topic_id, + log.second_topic, + log.third_topic, + log.fourth_topic, + log.transaction_index + ) + ) + else + from( + log in Log, + update: [ + set: [ + address_id: fragment("EXCLUDED.address_id"), + compressed_data: fragment("EXCLUDED.compressed_data"), + first_topic_id: fragment("EXCLUDED.first_topic_id"), + second_topic: fragment("EXCLUDED.second_topic"), + third_topic: fragment("EXCLUDED.third_topic"), + fourth_topic: fragment("EXCLUDED.fourth_topic"), + # Don't update `index` as it is part of the composite primary key and used for the conflict target + transaction_hash: fragment("EXCLUDED.transaction_hash"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.address_id, EXCLUDED.compressed_data, EXCLUDED.first_topic_id, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic, EXCLUDED.transaction_hash) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + log.address_id, + log.compressed_data, + log.first_topic_id, + log.second_topic, + log.third_topic, + log.fourth_topic, + log.transaction_hash + ) + ) + end _ -> - from( - log in Log, - update: [ - set: [ - address_hash: fragment("EXCLUDED.address_hash"), - data: fragment("EXCLUDED.data"), - first_topic: fragment("EXCLUDED.first_topic"), - second_topic: fragment("EXCLUDED.second_topic"), - third_topic: fragment("EXCLUDED.third_topic"), - fourth_topic: fragment("EXCLUDED.fourth_topic"), - # Don't update `index` as it is part of the composite primary key and used for the conflict target - # Don't update `transaction_hash` as it is part of the composite primary key and used for the conflict target - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.address_hash, EXCLUDED.data, EXCLUDED.first_topic, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic) IS DISTINCT FROM (?, ?, ?, ?, ?, ?)", - log.address_hash, - log.data, - log.first_topic, - log.second_topic, - log.third_topic, - log.fourth_topic - ) - ) + if LogHelper.primary_key_updated?() do + from( + log in Log, + update: [ + set: [ + address_id: fragment("EXCLUDED.address_id"), + compressed_data: fragment("EXCLUDED.compressed_data"), + first_topic_id: fragment("EXCLUDED.first_topic_id"), + second_topic: fragment("EXCLUDED.second_topic"), + third_topic: fragment("EXCLUDED.third_topic"), + fourth_topic: fragment("EXCLUDED.fourth_topic"), + # Don't update `index` as it is part of the composite primary key and used for the conflict target + # Don't update `transaction_index` as it is part of the composite primary key and used for the conflict target + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.address_id, EXCLUDED.compressed_data, EXCLUDED.first_topic_id, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic) IS DISTINCT FROM (?, ?, ?, ?, ?, ?)", + log.address_id, + log.compressed_data, + log.first_topic_id, + log.second_topic, + log.third_topic, + log.fourth_topic + ) + ) + else + from( + log in Log, + update: [ + set: [ + address_id: fragment("EXCLUDED.address_id"), + compressed_data: fragment("EXCLUDED.compressed_data"), + first_topic_id: fragment("EXCLUDED.first_topic_id"), + second_topic: fragment("EXCLUDED.second_topic"), + third_topic: fragment("EXCLUDED.third_topic"), + fourth_topic: fragment("EXCLUDED.fourth_topic"), + # Don't update `index` as it is part of the composite primary key and used for the conflict target + # `transaction_index` is not a part of the conflict target yet, so it has to be updated in order to + # backfill the rows inserted before the `transaction_index` column was introduced. Otherwise such rows + # violate the `logs_transaction_index_not_null` constraint on update. + transaction_index: fragment("EXCLUDED.transaction_index"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", log.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", log.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.address_id, EXCLUDED.compressed_data, EXCLUDED.first_topic_id, EXCLUDED.second_topic, EXCLUDED.third_topic, EXCLUDED.fourth_topic, EXCLUDED.transaction_index) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + log.address_id, + log.compressed_data, + log.first_topic_id, + log.second_topic, + log.third_topic, + log.fourth_topic, + log.transaction_index + ) + ) + end end end end diff --git a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex index ccec1b64b04..9f7045e92ab 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex @@ -296,10 +296,14 @@ defmodule Explorer.Chain.Import.Runner.Tokens do This function provides the standard set of fields that require updates when processing market data operations. + `:total_supply` is deliberately excluded: it holds the raw on-chain + `totalSupply()` value maintained by the token total supply fetchers, not + market data. + ## Returns - List of atoms representing the market data fields to update: `:name`, - `:symbol`, `:type`, `:fiat_value`, `:circulating_market_cap`, and - `:volume_24h` + `:symbol`, `:type`, `:fiat_value`, `:circulating_market_cap`, + `:volume_24h`, `:circulating_supply` and `:decimals` """ @spec market_data_fields_to_update() :: [ :name @@ -312,7 +316,16 @@ defmodule Explorer.Chain.Import.Runner.Tokens do | :decimals ] def market_data_fields_to_update do - [:name, :symbol, :type, :fiat_value, :circulating_market_cap, :volume_24h, :circulating_supply, :decimals] + [ + :name, + :symbol, + :type, + :fiat_value, + :circulating_market_cap, + :volume_24h, + :circulating_supply, + :decimals + ] end @doc """ diff --git a/apps/explorer/lib/explorer/chain/import/runner/transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/transactions.ex index a3ef1f9b104..6783b4d4fc2 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/transactions.ex @@ -95,7 +95,14 @@ defmodule Explorer.Chain.Import.Runner.Transactions do defp token_transfer_transaction_hash_set(options) do token_transfers_params = options[TokenTransfers.option_key()][:params] || [] - MapSet.new(token_transfers_params, & &1.transaction_hash) + token_transfers_params + |> Enum.flat_map(fn %{transaction_hash: transaction_hash} -> + case Hash.Full.cast(transaction_hash) do + {:ok, hash} -> [hash] + _ -> [] + end + end) + |> MapSet.new() end @spec insert(Repo.t(), [map()], %{ @@ -109,7 +116,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do changes_list, %{ timeout: timeout, - timestamps: timestamps + timestamps: timestamps, + token_transfer_transaction_hash_set: token_transfer_transaction_hash_set } = options ) when is_list(changes_list) do @@ -118,6 +126,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do # Enforce Transaction ShareLocks order (see docs: sharelocks.md) ordered_changes_list = changes_list + |> put_has_token_transfers(token_transfer_transaction_hash_set) |> Enum.sort_by(&{&1.hash, Map.get(&1, :index)}) |> Enum.uniq_by(& &1.hash) @@ -187,6 +196,16 @@ defmodule Explorer.Chain.Import.Runner.Transactions do end end + defp put_has_token_transfers(changes_list, token_transfer_transaction_hash_set) do + Enum.map(changes_list, fn change -> + Map.put( + change, + :has_token_transfers, + MapSet.member?(token_transfer_transaction_hash_set, change.hash) + ) + end) + end + # todo: avoid code duplication case @chain_identity do {:suave, nil} -> @@ -236,6 +255,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do wrapped_r: fragment("EXCLUDED.wrapped_r"), wrapped_s: fragment("EXCLUDED.wrapped_s"), wrapped_hash: fragment("EXCLUDED.wrapped_hash"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -243,7 +263,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.execution_node_hash, EXCLUDED.wrapped_type, EXCLUDED.wrapped_nonce, EXCLUDED.wrapped_to_address_hash, EXCLUDED.wrapped_gas, EXCLUDED.wrapped_gas_price, EXCLUDED.wrapped_max_priority_fee_per_gas, EXCLUDED.wrapped_max_fee_per_gas, EXCLUDED.wrapped_value, EXCLUDED.wrapped_input, EXCLUDED.wrapped_v, EXCLUDED.wrapped_r, EXCLUDED.wrapped_s, EXCLUDED.wrapped_hash) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.execution_node_hash, EXCLUDED.wrapped_type, EXCLUDED.wrapped_nonce, EXCLUDED.wrapped_to_address_hash, EXCLUDED.wrapped_gas, EXCLUDED.wrapped_gas_price, EXCLUDED.wrapped_max_priority_fee_per_gas, EXCLUDED.wrapped_max_fee_per_gas, EXCLUDED.wrapped_value, EXCLUDED.wrapped_input, EXCLUDED.wrapped_v, EXCLUDED.wrapped_r, EXCLUDED.wrapped_s, EXCLUDED.wrapped_hash, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -282,7 +302,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.wrapped_v, transaction.wrapped_r, transaction.wrapped_s, - transaction.wrapped_hash + transaction.wrapped_hash, + transaction.has_token_transfers ) ) end @@ -403,6 +424,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do operator_fee_scalar: fragment("EXCLUDED.operator_fee_scalar"), operator_fee_constant: fragment("EXCLUDED.operator_fee_constant"), da_footprint_gas_scalar: fragment("EXCLUDED.da_footprint_gas_scalar"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -410,7 +432,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.operator_fee_scalar, EXCLUDED.operator_fee_constant, EXCLUDED.da_footprint_gas_scalar) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.operator_fee_scalar, EXCLUDED.operator_fee_constant, EXCLUDED.da_footprint_gas_scalar, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -444,7 +466,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.l1_block_number, transaction.operator_fee_scalar, transaction.operator_fee_constant, - transaction.da_footprint_gas_scalar + transaction.da_footprint_gas_scalar, + transaction.has_token_transfers ) ) end @@ -483,6 +506,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do max_fee_per_gas: fragment("EXCLUDED.max_fee_per_gas"), type: fragment("EXCLUDED.type"), gas_used_for_l1: fragment("EXCLUDED.gas_used_for_l1"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -490,7 +514,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.gas_used_for_l1) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.gas_used_for_l1, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -516,7 +540,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.max_priority_fee_per_gas, transaction.max_fee_per_gas, transaction.type, - transaction.gas_used_for_l1 + transaction.gas_used_for_l1, + transaction.has_token_transfers ) ) end @@ -556,6 +581,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do type: fragment("EXCLUDED.type"), l1_fee: fragment("EXCLUDED.l1_fee"), queue_index: fragment("EXCLUDED.queue_index"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -563,7 +589,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.queue_index) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.queue_index, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -590,7 +616,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.max_fee_per_gas, transaction.type, transaction.l1_fee, - transaction.queue_index + transaction.queue_index, + transaction.has_token_transfers ) ) end @@ -634,6 +661,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do l1_gas_used: fragment("EXCLUDED.l1_gas_used"), l1_transaction_origin: fragment("EXCLUDED.l1_transaction_origin"), l1_block_number: fragment("EXCLUDED.l1_block_number"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at), @@ -645,7 +673,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.gas_token_contract_address_hash, EXCLUDED.gas_fee_recipient_address_hash, EXCLUDED.gateway_fee) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.gas_token_contract_address_hash, EXCLUDED.gas_fee_recipient_address_hash, EXCLUDED.gateway_fee, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -679,7 +707,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.l1_block_number, transaction.gas_token_contract_address_hash, transaction.gas_fee_recipient_address_hash, - transaction.gateway_fee + transaction.gateway_fee, + transaction.has_token_transfers ) ) end @@ -717,6 +746,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do max_priority_fee_per_gas: fragment("EXCLUDED.max_priority_fee_per_gas"), max_fee_per_gas: fragment("EXCLUDED.max_fee_per_gas"), type: fragment("EXCLUDED.type"), + has_token_transfers: fragment("EXCLUDED.has_token_transfers"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -724,7 +754,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.has_token_transfers) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -749,7 +779,8 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.revert_reason, transaction.max_priority_fee_per_gas, transaction.max_fee_per_gas, - transaction.type + transaction.type, + transaction.has_token_transfers ) ) end diff --git a/apps/explorer/lib/explorer/chain/internal_transaction.ex b/apps/explorer/lib/explorer/chain/internal_transaction.ex index 0e3496a5ad8..d52cb2a08d2 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction.ex @@ -75,6 +75,9 @@ defmodule Explorer.Chain.InternalTransaction do field(:transaction, :map, virtual: true) field(:transaction_hash, Hash.Full, virtual: true) + field(:created_contract_address_hash, Hash.Address, virtual: true) + field(:from_address_hash, Hash.Address, virtual: true) + field(:to_address_hash, Hash.Address, virtual: true) timestamps() @@ -86,15 +89,6 @@ defmodule Explorer.Chain.InternalTransaction do has_one(:created_contract_address, through: [:created_contract_address_mapping, :address]) - # TODO: remove after migration to address ids is done - belongs_to( - :created_contract_address_by_hash, - Address, - foreign_key: :created_contract_address_hash, - references: :hash, - type: Hash.Address - ) - belongs_to(:from_address_mapping, AddressIdToAddressHash, foreign_key: :from_address_id, references: :address_id, @@ -103,15 +97,6 @@ defmodule Explorer.Chain.InternalTransaction do has_one(:from_address, through: [:from_address_mapping, :address]) - # TODO: remove after migration to address ids is done - belongs_to( - :from_address_by_hash, - Address, - foreign_key: :from_address_hash, - references: :hash, - type: Hash.Address - ) - belongs_to(:to_address_mapping, AddressIdToAddressHash, foreign_key: :to_address_id, references: :address_id, @@ -120,15 +105,6 @@ defmodule Explorer.Chain.InternalTransaction do has_one(:to_address, through: [:to_address_mapping, :address]) - # TODO: remove after migration to address ids is done - belongs_to( - :to_address_by_hash, - Address, - foreign_key: :to_address_hash, - references: :hash, - type: Hash.Address - ) - belongs_to(:block, Block, foreign_key: :block_number, references: :number, @@ -512,43 +488,12 @@ defmodule Explorer.Chain.InternalTransaction do address_id = AddressIdToAddressHash.hash_to_id(address_hash, options) case direction do - :to -> - if BackgroundMigrations.get_empty_internal_transactions_data_finished() do - where_address_match(query, :to_address, address_hash, address_id) - else - where( - query, - [it], - ^to_direction_match_dynamic(address_hash, address_id) - ) - end - - :from -> - where_address_match(query, :from_address, address_hash, address_id) - - :to_address_hash -> - if BackgroundMigrations.get_empty_internal_transactions_data_finished() do - where( - query, - [it], - ^to_address_hash_match_dynamic(address_hash, address_id) - ) - else - where_address_match(query, :to_address, address_hash, address_id) - end - - :from_address_hash -> - where_address_match(query, :from_address, address_hash, address_id) - - :created_contract_address_hash -> - where_address_match(query, :created_contract_address, address_hash, address_id) - - _ -> - where( - query, - [it], - ^all_address_fields_match_dynamic(address_hash, address_id) - ) + :to -> do_where_address_match(query, :to_address, address_id) + :from -> do_where_address_match(query, :from_address, address_id) + :to_address_hash -> where(query, [it], ^to_address_hash_match_dynamic(address_id)) + :from_address_hash -> do_where_address_match(query, :from_address, address_id) + :created_contract_address_hash -> do_where_address_match(query, :created_contract_address, address_id) + _ -> where(query, [it], ^all_address_fields_match_dynamic(address_id)) end end @@ -586,85 +531,49 @@ defmodule Explorer.Chain.InternalTransaction do address_hashes = List.wrap(address_hash_or_hashes) address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes, options) - where_address_match(query, address_field, address_hashes, address_ids) + where(query, [it], ^address_match_dynamic(address_field, address_ids)) end - @spec where_address_match( - Ecto.Query.t() | module(), - :from_address | :to_address | :created_contract_address, - Hash.Address.t() | [Hash.Address.t()], - integer() | [integer()] | nil - ) :: Ecto.Query.t() - def where_address_match(query, address_field, address_hash_or_hashes, address_id_or_ids) do - address_hashes = List.wrap(address_hash_or_hashes) - address_ids = List.wrap(address_id_or_ids) - - where(query, [it], ^address_match_dynamic(address_field, address_hashes, address_ids)) + defp do_where_address_match(query, address_field, address_ids) when is_list(address_ids) do + where(query, [it], ^address_match_dynamic(address_field, address_ids)) end - defp to_direction_match_dynamic(address_hash, address_id) do - to_match = address_match_dynamic(:to_address, address_hash, address_id) - created_contract_match = address_match_dynamic(:created_contract_address, address_hash, address_id) + defp do_where_address_match(query, address_field, address_id) do + address_ids = + address_id + |> List.wrap() + |> Enum.reject(&is_nil/1) - dynamic( - [it], - (^to_match and is_nil(it.created_contract_address_hash) and is_nil(it.created_contract_address_id)) or - (is_nil(it.to_address_hash) and is_nil(it.to_address_id) and ^created_contract_match) - ) + do_where_address_match(query, address_field, address_ids) end - defp to_address_hash_match_dynamic(address_hash, address_id) do - to_match = address_match_dynamic(:to_address, address_hash, address_id) + defp to_address_hash_match_dynamic(nil), do: dynamic(false) - dynamic( - [it], - ^to_match and is_nil(it.created_contract_address_hash) and is_nil(it.created_contract_address_id) - ) + defp to_address_hash_match_dynamic(address_id) do + dynamic([it], it.to_address_id == ^address_id and is_nil(it.created_contract_address_id)) end - defp all_address_fields_match_dynamic(address_hash, address_id) do - to_match = address_match_dynamic(:to_address, address_hash, address_id) - from_match = address_match_dynamic(:from_address, address_hash, address_id) - created_contract_match = address_match_dynamic(:created_contract_address, address_hash, address_id) + defp all_address_fields_match_dynamic(nil), do: dynamic(false) + defp all_address_fields_match_dynamic(address_id) do dynamic( [it], - ^to_match or ^from_match or ^created_contract_match + it.to_address_id == ^address_id or it.from_address_id == ^address_id or + it.created_contract_address_id == ^address_id ) end - defp address_match_dynamic(address_field, address_hash_or_hashes, address_id_or_ids) do - address_hashes = List.wrap(address_hash_or_hashes) - address_hash_field = String.to_existing_atom("#{address_field}_hash") - address_ids = List.wrap(address_id_or_ids) - address_id_field = String.to_existing_atom("#{address_field}_id") - - cond do - not address_ids_indexes_exists?() -> - adjust_address_match_dynamic(address_hash_field, address_hashes) + defp address_match_dynamic(_address_field, []), do: dynamic(false) - address_ids_filled?() -> - adjust_address_match_dynamic(address_id_field, address_ids) + defp address_match_dynamic(address_field, address_ids) do + address_id_field = String.to_existing_atom("#{address_field}_id") - true -> - dynamic( - [it], - field(it, ^address_id_field) in ^address_ids or field(it, ^address_hash_field) in ^address_hashes - ) - end + adjust_address_match_dynamic(address_id_field, address_ids) end defp adjust_address_match_dynamic(field, [value]), do: dynamic([it], field(it, ^field) == ^value) defp adjust_address_match_dynamic(field, values), do: dynamic([it], field(it, ^field) in ^values) - defp address_ids_indexes_exists? do - BackgroundMigrations.get_heavy_indexes_create_address_ids_internal_transactions_indexes_finished() - end - - defp address_ids_filled? do - BackgroundMigrations.get_fill_internal_transactions_address_ids_finished() - end - def where_is_different_from_parent_transaction(query) do where( query, @@ -744,12 +653,14 @@ defmodule Explorer.Chain.InternalTransaction do def transaction_to_internal_transactions(hash, options \\ []) when is_list(options) do necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) paging_options = Keyword.get(options, :paging_options, @default_paging_options) + include_zero_value? = Keyword.get(options, :include_zero_value, true) __MODULE__ |> for_parent_transaction(hash) |> Chain.join_associations(necessity_by_association) |> where_is_different_from_parent_transaction() |> where_nonpending_operation() + |> include_zero_value(include_zero_value?) |> page_internal_transaction(paging_options) |> limit(^paging_options.page_size) |> order_by([internal_transaction], asc: internal_transaction.index) @@ -771,12 +682,14 @@ defmodule Explorer.Chain.InternalTransaction do paging_options = Keyword.get(options, :paging_options, @default_paging_options) type_filter = Keyword.get(options, :type, []) call_type_filter = Keyword.get(options, :call_type, []) + include_zero_value? = Keyword.get(options, :include_zero_value, true) __MODULE__ |> where([internal_transaction], internal_transaction.block_number == ^block_number) |> Chain.join_associations(necessity_by_association) |> where_is_different_from_parent_transaction() |> where_nonpending_operation() + |> include_zero_value(include_zero_value?) |> page_block_internal_transaction(paging_options) |> filter_by_type(type_filter, call_type_filter) |> filter_by_call_type(call_type_filter) @@ -883,6 +796,7 @@ defmodule Explorer.Chain.InternalTransaction do necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) direction = Keyword.get(options, :direction) timeout = Keyword.get(options, :timeout) + include_zero_value? = Keyword.get(options, :include_zero_value, true) from_block = Chain.from_block(options) to_block = Chain.to_block(options) @@ -896,6 +810,7 @@ defmodule Explorer.Chain.InternalTransaction do |> where_address_fields_match(hash, :to, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() + |> include_zero_value(include_zero_value?) |> common_where_limit_order(paging_options) |> Chain.wrapped_union_subquery() @@ -905,6 +820,7 @@ defmodule Explorer.Chain.InternalTransaction do |> where_address_fields_match(hash, :from_address_hash, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() + |> include_zero_value(include_zero_value?) |> common_where_limit_order(paging_options) |> Chain.wrapped_union_subquery() @@ -925,6 +841,7 @@ defmodule Explorer.Chain.InternalTransaction do |> where_address_fields_match(hash, direction, options) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> where_is_different_from_parent_transaction() + |> include_zero_value(include_zero_value?) |> common_where_limit_order(paging_options) |> preload(:block) |> Chain.join_associations(necessity_by_association) @@ -997,7 +914,6 @@ defmodule Explorer.Chain.InternalTransaction do mapping_binding = String.to_existing_atom("#{address_field}_mapping") address_binding = address_field address_id_field = String.to_existing_atom("#{address_field}_id") - address_hash_field = String.to_existing_atom("#{address_field}_hash") query |> with_named_binding(mapping_binding, fn query, binding -> @@ -1009,9 +925,7 @@ defmodule Explorer.Chain.InternalTransaction do |> with_named_binding(address_binding, fn query, binding -> join(query, join_type, [it], a in Address, as: ^binding, - on: - a.hash == as(^mapping_binding).address_hash or - (is_nil(as(^mapping_binding).address_hash) and a.hash == field(it, ^address_hash_field)) + on: a.hash == as(^mapping_binding).address_hash ) end) end @@ -1221,6 +1135,7 @@ defmodule Explorer.Chain.InternalTransaction do def fetch(options) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) exclude_zero_index_internal_transaction = Keyword.get(options, :exclude_origin_internal_transaction, false) + include_zero_value? = Keyword.get(options, :include_zero_value, true) case paging_options do %PagingOptions{key: {0, 0}} -> @@ -1239,6 +1154,7 @@ defmodule Explorer.Chain.InternalTransaction do __MODULE__ |> where_nonpending_operation() |> maybe_filter_origin_transaction(exclude_zero_index_internal_transaction) + |> include_zero_value(include_zero_value?) |> page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) |> where_internal_transactions_by_transaction_hash(Keyword.get(options, :transaction_hash)) |> order_by([internal_transaction], @@ -1604,72 +1520,6 @@ defmodule Explorer.Chain.InternalTransaction do preloads = Keyword.merge(@default_address_preloads, Keyword.get(options, :address_preloads, [])) repo = repo || Chain.select_repo(options) - indexed_transactions = Enum.with_index(internal_transactions) - - {migrated_indexed, not_migrated_indexed} = - Enum.split_with( - indexed_transactions, - fn {it, _idx} -> - is_nil(it.from_address_hash) and is_nil(it.to_address_hash) and is_nil(it.created_contract_address_hash) - end - ) - - migrated = Enum.map(migrated_indexed, &elem(&1, 0)) - not_migrated = Enum.map(not_migrated_indexed, &elem(&1, 0)) - - not_migrated_preloaded = preload_addresses_for_not_migrated_internal_transactions(not_migrated, preloads, repo) - migrated_preloaded = preload_addresses_for_migrated_internal_transactions(migrated, preloads, repo) - - migrated_with_idx = Enum.zip(migrated_preloaded, Enum.map(migrated_indexed, &elem(&1, 1))) - not_migrated_with_idx = Enum.zip(not_migrated_preloaded, Enum.map(not_migrated_indexed, &elem(&1, 1))) - - (migrated_with_idx ++ not_migrated_with_idx) - |> Enum.sort_by(&elem(&1, 1)) - |> Enum.map(&elem(&1, 0)) - end - - def preload_addresses(internal_transaction, options, repo) do - [internal_transaction] - |> preload_addresses(options, repo) - |> List.first() - end - - defp preload_addresses_for_not_migrated_internal_transactions([], _preloads, _repo), do: [] - - defp preload_addresses_for_not_migrated_internal_transactions(internal_transactions, preloads, repo) do - unified_preloads = - preloads - |> List.wrap() - |> Enum.map(fn - preload when is_atom(preload) -> {String.to_existing_atom("#{preload}_by_hash"), []} - {preload, fields} -> {String.to_existing_atom("#{preload}_by_hash"), fields} - end) - - internal_transactions - |> repo.preload(unified_preloads) - |> Enum.map(fn internal_transaction -> - Enum.reduce( - [ - {:from_address_by_hash, :from_address_hash, :from_address}, - {:to_address_by_hash, :to_address_hash, :to_address}, - {:created_contract_address_by_hash, :created_contract_address_hash, :created_contract_address} - ], - internal_transaction, - fn {source_field, hash_field, address_field}, acc -> - corresponding_address = Map.get(acc, source_field) - - Map.merge(acc, %{ - hash_field => (corresponding_address && corresponding_address.hash) || Map.get(acc, hash_field), - address_field => corresponding_address - }) - end - ) - end) - end - - defp preload_addresses_for_migrated_internal_transactions([], _preloads, _repo), do: [] - - defp preload_addresses_for_migrated_internal_transactions(internal_transactions, preloads, repo) do internal_transactions |> repo.preload(preloads) |> Enum.map(fn internal_transaction -> @@ -1682,9 +1532,15 @@ defmodule Explorer.Chain.InternalTransaction do internal_transaction, fn {hash_field, address_field}, acc -> corresponding_address = Map.get(acc, address_field) - Map.put(acc, hash_field, corresponding_address && corresponding_address.hash) + Map.put(acc, hash_field, Map.get(acc, hash_field) || (corresponding_address && corresponding_address.hash)) end ) end) end + + def preload_addresses(internal_transaction, options, repo) do + [internal_transaction] + |> preload_addresses(options, repo) + |> List.first() + end end diff --git a/apps/explorer/lib/explorer/chain/log.ex b/apps/explorer/lib/explorer/chain/log.ex index fa3d4056200..1028adaaea4 100644 --- a/apps/explorer/lib/explorer/chain/log.ex +++ b/apps/explorer/lib/explorer/chain/log.ex @@ -7,11 +7,14 @@ defmodule Explorer.Chain.Log.Schema do alias Explorer.Chain.{ Address, Block, + CompressedData, Data, Hash, Transaction } + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic} + # In certain situations, like on Polygon, multiple logs may share the same # index within a single block due to a RPC node bug. To prevent system crashes # due to not unique primary keys, we've included `transaction_hash` in the @@ -22,28 +25,20 @@ defmodule Explorer.Chain.Log.Schema do # violates the primary key constraint. To resolve this issue, we've excluded # `transaction_hash` from the composite primary key when dealing with # `:optimism-celo` chain type. + # + # UPD: `transaction_hash` migrated to `transaction_index` but logic above remains the same @transaction_field (case @chain_identity do {:optimism, :celo} -> quote do [ - belongs_to(:transaction, Transaction, - foreign_key: :transaction_hash, - references: :hash, - type: Hash.Full - ) + field(:transaction_index, :integer) ] end _ -> quote do [ - belongs_to(:transaction, Transaction, - foreign_key: :transaction_hash, - primary_key: true, - references: :hash, - type: Hash.Full, - null: false - ) + field(:transaction_index, :integer, primary_key: true) ] end end) @@ -52,26 +47,48 @@ defmodule Explorer.Chain.Log.Schema do quote do @primary_key false typed_schema "logs" do - field(:data, Data, null: false) + field(:data, Data) + field(:compressed_data, CompressedData) field(:first_topic, Hash.Full) - field(:second_topic, Hash.Full) - field(:third_topic, Hash.Full) - field(:fourth_topic, Hash.Full) + field(:second_topic, Hash.Trimmed) + field(:third_topic, Hash.Trimmed) + field(:fourth_topic, Hash.Trimmed) field(:index, :integer, primary_key: true, null: false) - field(:block_number, :integer) + field(:block_number, :integer, primary_key: true) timestamps() - belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address, null: false) + belongs_to(:address_mapping, AddressIdToAddressHash, + foreign_key: :address_id, + references: :address_id, + type: :integer + ) + + has_one(:address, through: [:address_mapping, :address]) + + # TODO: remove after migration to address id is done + belongs_to( + :address_by_hash, + Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address + ) belongs_to(:block, Block, foreign_key: :block_hash, - primary_key: true, references: :hash, - type: Hash.Full, - null: false + type: Hash.Full ) + belongs_to(:transaction, Transaction, + foreign_key: :transaction_hash, + references: :hash, + type: Hash.Full + ) + + belongs_to(:log_first_topic, LogFirstTopic, foreign_key: :first_topic_id, references: :id) + unquote_splicing(@transaction_field) end end @@ -91,20 +108,21 @@ defmodule Explorer.Chain.Log do alias Explorer.{Chain, Repo} alias Explorer.Chain.{Block, ContractMethod, Hash, Log, TokenTransfer, Transaction} alias Explorer.SmartContract.SigProviderInterface + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic, LogHelper} - @required_attrs ~w(address_hash data block_hash index)a + @required_attrs ~w(address_id compressed_data index)a |> (&(case @chain_identity do {:optimism, :celo} -> &1 _ -> - [:transaction_hash | &1] + [:transaction_index, :transaction_hash | &1] end)).() - @optional_attrs ~w(first_topic second_topic third_topic fourth_topic block_number)a + @optional_attrs ~w(block_hash first_topic first_topic_id second_topic third_topic fourth_topic block_number)a |> (&(case @chain_identity do {:optimism, :celo} -> - [:transaction_hash | &1] + [:transaction_index, :transaction_hash | &1] _ -> &1 @@ -114,29 +132,28 @@ defmodule Explorer.Chain.Log do * `address` - address of contract that generate the event * `block_hash` - hash of the block * `block_number` - The block number that the transfer took place. - * `address_hash` - foreign key for `address` - * `data` - non-indexed log parameters. + * `address_id` - foreign key for `address_ids_to_address_hashes` + * `compressed_data` - non-indexed log parameters. * `first_topic` - `topics[0]` * `second_topic` - `topics[1]` * `third_topic` - `topics[2]` * `fourth_topic` - `topics[3]` * `transaction` - transaction for which `log` is * `transaction_hash` - foreign key for `transaction`. + * `transaction_index` - index of the related transaction within the block * `index` - index of the log entry within the block """ Explorer.Chain.Log.Schema.generate() @doc """ - `address_hash` and `transaction_hash` are converted to `t:Explorer.Chain.Hash.t/0`. - iex> changeset = Explorer.Chain.Log.changeset( ...> %Explorer.Chain.Log{}, ...> %{ - ...> address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> data: "0x000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef", + ...> address_id: 1, + ...> compressed_data: "0x000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef", ...> first_topic: "0x600bcf04a13e752d1e3670a5a9f1c21177ca2a93c6f5391d4f1298d098097c22", ...> fourth_topic: nil, + ...> transaction_index: 0, ...> index: 0, ...> second_topic: nil, ...> third_topic: nil, @@ -145,17 +162,6 @@ defmodule Explorer.Chain.Log do ...> ) iex> changeset.valid? true - iex> changeset.changes.address_hash - %Explorer.Chain.Hash{ - byte_count: 20, - bytes: <<139, 243, 141, 71, 100, 146, 144, 100, 242, 212, 211, 165, 101, 32, 167, 106, 179, 223, 65, 91>> - } - iex> changeset.changes.transaction_hash - %Explorer.Chain.Hash{ - byte_count: 32, - bytes: <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, - 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } """ def changeset(%__MODULE__{} = log, attrs \\ %{}) do @@ -183,7 +189,7 @@ defmodule Explorer.Chain.Log do events_acc \\ %{} ) do with {:no_abi, false} <- {:no_abi, is_nil(full_abi)}, - {:ok, selector, mapping} <- find_and_decode(full_abi, log, transaction.hash), + {:ok, selector, mapping} <- find_and_decode(full_abi, log), identifier <- Base.encode16(selector.method_id, case: :lower), text <- function_call(selector.function, mapping) do {{:ok, identifier, text, mapping}, events_acc} @@ -224,7 +230,7 @@ defmodule Explorer.Chain.Log do case error do {:error, _reason} -> with {{:error, :contract_not_verified, candidates}, events_acc} <- - find_method_candidates(log, transaction, db_options, events_acc), + find_method_candidates(log, db_options, events_acc), {true, events_acc} <- {is_list(candidates), events_acc}, {false, events_acc} <- {Enum.empty?(candidates), events_acc} do {{:error, :contract_not_verified, candidates}, events_acc} @@ -235,16 +241,16 @@ defmodule Explorer.Chain.Log do end end - defp find_method_candidates(log, transaction, options, events_acc) do + defp find_method_candidates(log, options, events_acc) do if is_nil(log.first_topic) do {{:error, :could_not_decode}, events_acc} else <<method_id::binary-size(4), _rest::binary>> = log.first_topic.bytes if Map.has_key?(events_acc, method_id) do - {find_and_decode_in_candidates(events_acc[method_id], log, transaction), events_acc} + {find_and_decode_in_candidates(events_acc[method_id], log), events_acc} else - {result, event_candidates} = find_method_candidates_from_db(method_id, log, transaction, options) + {result, event_candidates} = find_method_candidates_from_db(method_id, log, options) {result, Map.put(events_acc, method_id, event_candidates)} end end @@ -255,26 +261,26 @@ defmodule Explorer.Chain.Log do if decoding_from_list? do mark_events_to_decode_later_via_sig_provider_in_batch(log, transaction.hash) else - decode_event_via_sig_provider(log, transaction.hash, skip_sig_provider?) + decode_event_via_sig_provider(log, skip_sig_provider?) end {result, events_acc} end - defp find_method_candidates_from_db(method_id, log, transaction, options) do + defp find_method_candidates_from_db(method_id, log, options) do event_candidates = method_id |> ContractMethod.find_contract_method_query(3) |> Chain.select_repo(options).all() - {find_and_decode_in_candidates(event_candidates, log, transaction), event_candidates} + {find_and_decode_in_candidates(event_candidates, log), event_candidates} end - defp find_and_decode_in_candidates(event_candidates, log, transaction) do + defp find_and_decode_in_candidates(event_candidates, log) do result = event_candidates |> Enum.flat_map(fn contract_method -> - case find_and_decode([contract_method.abi], log, transaction.hash) do + case find_and_decode([contract_method.abi], log) do {:ok, selector, mapping} -> identifier = Base.encode16(selector.method_id, case: :lower) text = function_call(selector.function, mapping) @@ -290,9 +296,9 @@ defmodule Explorer.Chain.Log do {:error, :contract_not_verified, result} end - @spec find_and_decode([map()], __MODULE__.t(), Hash.t()) :: + @spec find_and_decode([map()], __MODULE__.t()) :: {:error, any} | {:ok, ABI.FunctionSelector.t(), any} - def find_and_decode(abi, log, transaction_hash) do + def find_and_decode(abi, log) do # For events, the method_id (signature) is 32 bytes, whereas for methods and # errors it is 4 bytes. To avoid complications with different sizes, we # always take only the first 4 bytes of the hash. @@ -313,8 +319,8 @@ defmodule Explorer.Chain.Log do e -> Logger.debug(fn -> [ - "Could not decode input data for log from transaction hash: ", - Hash.to_iodata(transaction_hash), + "Could not decode input data for log: ", + inspect(log), Exception.format(:error, e, __STACKTRACE__) ] end) @@ -370,7 +376,6 @@ defmodule Explorer.Chain.Log do ## Parameters - `log`: The log containing the event data and topics. - - `transaction_hash`: The hash of the transaction containing the log. - `skip_sig_provider?`: A boolean indicating whether to skip using the signature provider. ## Returns @@ -386,13 +391,11 @@ defmodule Explorer.Chain.Log do """ @spec decode_event_via_sig_provider( __MODULE__.t(), - Hash.t(), boolean() ) :: {:error, :could_not_decode} | {:error, :contract_not_verified, list()} def decode_event_via_sig_provider( log, - transaction_hash, skip_sig_provider? ) do with true <- SigProviderInterface.enabled?(), @@ -411,7 +414,7 @@ defmodule Explorer.Chain.Log do true <- is_list(result), false <- Enum.empty?(result), abi <- [result |> List.first() |> Map.put("type", "event")], - {:ok, selector, mapping} <- find_and_decode(abi, log, transaction_hash), + {:ok, selector, mapping} <- find_and_decode(abi, log), identifier <- Base.encode16(selector.method_id, case: :lower), text <- function_call(selector.function, mapping) do {:error, :contract_not_verified, [{:ok, identifier, text, mapping}]} @@ -460,12 +463,8 @@ defmodule Explorer.Chain.Log do false <- Enum.empty?(result) do input |> Enum.zip(result) - |> Enum.map(fn {{index, - %{ - :log => log, - :transaction_hash => transaction_hash - }}, %{"abi" => abi}} -> - decode_sig_provider_batch_item(index, abi, log, transaction_hash) + |> Enum.map(fn {{index, %{log: log}}, %{"abi" => abi}} -> + decode_sig_provider_batch_item(index, abi, log) end) else _ -> @@ -474,13 +473,13 @@ defmodule Explorer.Chain.Log do end end - defp decode_sig_provider_batch_item(index, abi, log, transaction_hash) do + defp decode_sig_provider_batch_item(index, abi, log) do abi_first_item = List.first(abi) if is_map(abi_first_item) do normalized_abi = [Map.put(abi_first_item, "type", "event")] - case find_and_decode(normalized_abi, log, transaction_hash) do + case find_and_decode(normalized_abi, log) do {:ok, selector, mapping} -> identifier = Base.encode16(selector.method_id, case: :lower) text = function_call(selector.function, mapping) @@ -514,9 +513,18 @@ defmodule Explorer.Chain.Log do end) end - def fetch_log_by_transaction_hash_and_first_topic(transaction_hash, first_topic, options \\ []) do + @doc """ + Fetches a log by transaction and first topic. + """ + @spec fetch_log_by_transaction_and_first_topic(Transaction.t(), Hash.Full.t() | binary(), Keyword.t()) :: t() | nil + def fetch_log_by_transaction_and_first_topic(transaction, first_topic, options \\ []) + + def fetch_log_by_transaction_and_first_topic(%{block_number: nil}, _first_topic, _options), do: nil + + def fetch_log_by_transaction_and_first_topic(transaction, first_topic, options) do __MODULE__ - |> where([l], l.transaction_hash == ^transaction_hash and l.first_topic == ^first_topic) + |> by_transaction_query(transaction.hash, transaction.block_number, transaction.index) + |> filter_by_topic_query(:first_topic, first_topic) |> limit(1) |> Chain.select_repo(options).one() end @@ -525,17 +533,46 @@ defmodule Explorer.Chain.Log do Fetches logs by user operation. """ @spec user_op_to_logs(map(), Keyword.t()) :: [t()] + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def user_op_to_logs(user_op, options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) limit = Keyword.get(options, :limit, 50) __MODULE__ - |> where([log], log.block_hash == ^user_op["block_hash"] and log.transaction_hash == ^user_op["transaction_hash"]) + |> then(fn query -> + case Transaction.get_transactions_by_hashes([user_op["transaction_hash"]]) do + [%Transaction{hash: transaction_hash, block_number: block_number, index: transaction_index}] -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + where(query, [log], log.block_number == ^block_number and log.transaction_index == ^transaction_index) + + LogHelper.fill_optimized_fields_migration_started?() -> + where( + query, + [log], + (log.block_hash == ^user_op["block_hash"] and log.transaction_hash == ^transaction_hash) or + (log.block_number == ^block_number and log.transaction_index == ^transaction_index) + ) + + true -> + where( + query, + [log], + log.block_hash == ^user_op["block_hash"] and log.transaction_hash == ^transaction_hash + ) + end + + _ -> + where(query, [log], false) + end + end) |> where([log], log.index >= ^user_op["user_logs_start_index"]) |> order_by([log], asc: log.index) |> limit(^min(user_op["user_logs_count"], limit)) - |> Chain.join_associations(necessity_by_association) |> Chain.select_repo(options).all() + |> preload_block(Chain.select_repo(options)) + |> preload_transaction([], Chain.select_repo(options)) + |> preload_address(options, Chain.select_repo(options)) end @doc """ @@ -550,12 +587,13 @@ defmodule Explorer.Chain.Log do base_query = from(log in __MODULE__, as: :log) base_query - |> where([log], log.address_hash in ^env[:whitelisted_weth_contracts]) + |> address_match_query(env[:whitelisted_weth_contracts]) |> where(^first_topic_is_deposit_or_withdrawal_signature()) - |> join(:left, [log], tt in TokenTransfer, - on: log.block_hash == tt.block_hash and log.transaction_hash == tt.transaction_hash and log.index == tt.log_index + |> join_transaction_query() + |> join(:left, [log, t], tt in TokenTransfer, + on: log.block_number == tt.block_number and t.hash == tt.transaction_hash and log.index == tt.log_index ) - |> where([log, tt], is_nil(tt.transaction_hash)) + |> where([log, _t, tt], is_nil(tt.transaction_hash)) |> select([log], log) |> Repo.stream_each(each_fun) end @@ -571,12 +609,36 @@ defmodule Explorer.Chain.Log do - An `Ecto.Query.dynamic()` expression that can be used in Ecto queries. """ - @spec first_topic_is_deposit_or_withdrawal_signature() :: Ecto.Query.dynamic_expr() - def first_topic_is_deposit_or_withdrawal_signature do - dynamic( - [log: log], - log.first_topic in [^TokenTransfer.weth_deposit_signature(), ^TokenTransfer.weth_withdrawal_signature()] - ) + @spec first_topic_is_deposit_or_withdrawal_signature(atom()) :: Ecto.Query.dynamic_expr() + def first_topic_is_deposit_or_withdrawal_signature(binding \\ :first) + + def first_topic_is_deposit_or_withdrawal_signature(:first) do + topic_filter_dynamic(:first_topic, [ + TokenTransfer.weth_deposit_signature(), + TokenTransfer.weth_withdrawal_signature() + ]) + end + + def first_topic_is_deposit_or_withdrawal_signature(:log) do + topic_values = [ + TokenTransfer.weth_deposit_signature(), + TokenTransfer.weth_withdrawal_signature() + ] + + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic([log: l], l.first_topic_id in ^first_topic_ids) + + LogHelper.fill_optimized_fields_migration_started?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic([log: l], l.first_topic_id in ^first_topic_ids or l.first_topic in ^topic_values) + + true -> + dynamic([log: l], l.first_topic in ^topic_values) + end end @doc """ @@ -592,10 +654,26 @@ defmodule Explorer.Chain.Log do """ @spec first_topic_is_not_deposit_or_withdrawal_signature() :: Ecto.Query.dynamic_expr() def first_topic_is_not_deposit_or_withdrawal_signature do - dynamic( - [log: log], - log.first_topic not in [^TokenTransfer.weth_deposit_signature(), ^TokenTransfer.weth_withdrawal_signature()] - ) + topic_values = [TokenTransfer.weth_deposit_signature(), TokenTransfer.weth_withdrawal_signature()] + + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic([log: l], is_nil(l.first_topic_id) or l.first_topic_id not in ^first_topic_ids) + + LogHelper.fill_optimized_fields_migration_started?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic( + [log: l], + (is_nil(l.first_topic_id) or l.first_topic_id not in ^first_topic_ids) and + (is_nil(l.first_topic) or l.first_topic not in ^topic_values) + ) + + true -> + dynamic([log: l], is_nil(l.first_topic) or l.first_topic not in ^topic_values) + end end @doc """ @@ -631,8 +709,605 @@ defmodule Explorer.Chain.Log do from( log in __MODULE__, inner_join: block in Block, - on: block.hash == log.block_hash and block.consensus == true, + on: block.number == log.block_number and block.consensus == true, select: log ) end + + # Above this number of values for a single topic, fall back to + # `= ANY(...)` to keep query size and planning time bounded. + @max_topic_union_branches 16 + + # With `union_multiple_values: true`, a single topic with multiple values + # is combined with UNION ALL (one equality branch per value) instead of + # `topic = ANY(...)`: a scalar-array condition on a topic column prevents + # PostgreSQL from returning rows in `(block_number, index)` order from the + # (address_hash, first_topic, block_number, index) index, forcing it to + # materialize and sort every match in the block range before applying the + # LIMIT. With UNION ALL each branch is an ordered index scan, so the + # planner can merge branches and stop at the LIMIT. The resulting + # combination query must be wrapped in `subquery/1` by the caller before + # any further composition. + @doc """ + Filters logs by one or multiple topic fields. + + When `topic_name` is an atom, filters the query by the given topic field and + values. `topic_values` can be a single value or a list of values. + + When `topic_names` and `topic_values_list` are lists, builds an `OR` condition + where each topic field is matched against the corresponding list of values. + """ + @spec filter_by_topic_query(Ecto.Queryable.t(), atom() | [atom()], any() | [[any()]]) :: Ecto.Query.t() + def filter_by_topic_query(query, topic_name, topic_values, opts \\ []) + + def filter_by_topic_query(query, topic_name, topic_values, opts) when is_atom(topic_name) do + if Keyword.get(opts, :union_multiple_values, false) and is_list(topic_values) and + length(topic_values) <= @max_topic_union_branches do + topic_values + |> Enum.map(fn value -> where(query, [l], ^topic_filter_dynamic(topic_name, [value])) end) + |> Enum.reduce(fn branch, acc -> union_all(acc, ^branch) end) + else + where(query, ^topic_filter_dynamic(topic_name, List.wrap(topic_values))) + end + end + + def filter_by_topic_query(query, topic_names, topic_values_list, _opts) + when is_list(topic_names) and is_list(topic_values_list) do + where(query, [l], ^filter_by_topic_dynamic(topic_names, topic_values_list)) + end + + @doc """ + Builds a dynamic `OR` condition for filtering logs by multiple topic fields. + + Each topic name is paired with the corresponding list of topic values from + `topic_values_list`. The resulting dynamic expression matches logs where any + of the given topic fields matches its values. + """ + @spec filter_by_topic_dynamic([atom()], [[any()]]) :: Ecto.Query.dynamic_expr() + def filter_by_topic_dynamic(topic_names, topic_values_list) do + topic_names + |> Enum.zip(topic_values_list) + |> Enum.reduce(dynamic(false), fn {topic_name, topic_values}, dynamic -> + dynamic( + [l], + ^dynamic or ^topic_filter_dynamic(topic_name, topic_values) + ) + end) + end + + @doc """ + Builds a dynamic condition for filtering logs by a topic field. + + For `:first_topic`, the condition uses `first_topic_id` when the optimized + fields migration has finished, and falls back to matching `first_topic` while + the migration is still in progress. + + For other topic fields, the condition matches trimmed topic values after the + optimized fields migration has finished. Before the migration is finished, it + also matches full 32-byte topic values to support both stored formats. + """ + @spec topic_filter_dynamic(atom(), [any()]) :: Ecto.Query.dynamic_expr() + def topic_filter_dynamic(:first_topic, [topic_value]) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + first_topic_id = LogFirstTopic.value_to_id(topic_value) + + (first_topic_id && dynamic([l], l.first_topic_id == ^first_topic_id)) || dynamic(false) + + LogHelper.fill_optimized_fields_migration_started?() -> + first_topic_id = LogFirstTopic.value_to_id(topic_value) + + (first_topic_id && dynamic([l], l.first_topic_id == ^first_topic_id or l.first_topic == ^topic_value)) || + dynamic([l], l.first_topic == ^topic_value) + + true -> + dynamic([l], l.first_topic == ^topic_value) + end + end + + def topic_filter_dynamic(:first_topic, topic_values) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic([l], l.first_topic_id in ^first_topic_ids) + + LogHelper.fill_optimized_fields_migration_started?() -> + first_topic_ids = LogFirstTopic.values_to_ids(topic_values) + + dynamic([l], l.first_topic_id in ^first_topic_ids or l.first_topic in ^topic_values) + + true -> + dynamic([l], l.first_topic in ^topic_values) + end + end + + def topic_filter_dynamic(topic_name, [topic_value]) do + if LogHelper.fill_optimized_fields_migration_finished?() do + dynamic([l], field(l, ^topic_name) == ^topic_value) + else + case Hash.Full.dump(topic_value) do + {:ok, extended} -> + dynamic( + [l], + field(l, ^topic_name) == ^topic_value or field(l, ^topic_name) == type(^extended, :binary) + ) + + :error -> + dynamic([l], field(l, ^topic_name) == ^topic_value) + end + end + end + + def topic_filter_dynamic(topic_name, topic_values) do + if LogHelper.fill_optimized_fields_migration_finished?() do + dynamic([l], field(l, ^topic_name) in ^topic_values) + else + extended_values = + topic_values + |> Enum.flat_map(fn topic -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + case Hash.Full.dump(topic) do + {:ok, extended} -> [extended] + :error -> [] + end + end) + + dynamic( + [l], + field(l, ^topic_name) in ^topic_values or field(l, ^topic_name) in type(^extended_values, {:array, :binary}) + ) + end + end + + defp by_transaction_query(query, transaction_hash, block_number, transaction_index) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + where(query, [l], l.block_number == ^block_number and l.transaction_index == ^transaction_index) + + LogHelper.fill_optimized_fields_migration_started?() -> + where( + query, + [l], + l.transaction_hash == ^transaction_hash or + (l.block_number == ^block_number and l.transaction_index == ^transaction_index) + ) + + true -> + where(query, [l], l.transaction_hash == ^transaction_hash) + end + end + + @doc """ + Joins transactions to the given logs query. + """ + @spec join_transaction_query(Ecto.Queryable.t()) :: Ecto.Query.t() + def join_transaction_query(query \\ __MODULE__) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + join(query, :inner, [l], t in Transaction, + on: l.block_number == t.block_number and l.transaction_index == t.index, + as: :transaction + ) + + LogHelper.fill_optimized_fields_migration_started?() -> + join(query, :inner, [l], t in Transaction, + on: l.transaction_hash == t.hash or (l.block_number == t.block_number and l.transaction_index == t.index), + as: :transaction + ) + + true -> + join(query, :inner, [l], t in Transaction, on: l.transaction_hash == t.hash, as: :transaction) + end + end + + @doc """ + Joins logs to the given token transfers query. + """ + @spec join_to_token_transfer_query(Ecto.Queryable.t()) :: Ecto.Query.t() + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def join_to_token_transfer_query(query) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + query + |> join(:left, [tt], t in assoc(tt, :transaction)) + |> join(:left, [tt, t], l in __MODULE__, + on: tt.block_number == l.block_number and t.index == l.transaction_index and tt.log_index == l.index, + as: :log + ) + + LogHelper.fill_optimized_fields_migration_started?() -> + query + |> join(:left, [tt], t in assoc(tt, :transaction)) + |> join(:left, [tt, t], l in __MODULE__, + on: + ((tt.block_number == l.block_number and t.index == l.transaction_index) or + tt.transaction_hash == l.transaction_hash) and tt.log_index == l.index, + as: :log + ) + + true -> + join(query, :left, [tt], l in __MODULE__, + on: tt.block_hash == l.block_hash and tt.transaction_hash == l.transaction_hash and tt.log_index == l.index, + as: :log + ) + end + end + + @doc """ + Joins the address mapping table to the given logs query. + + The join is added with the named binding `:address_mapping` and is skipped when + this binding is already present in the query. + """ + @spec join_address_mapping_query(Ecto.Queryable.t(), atom()) :: Ecto.Query.t() + def join_address_mapping_query(query, join_type \\ :left) do + with_named_binding(query, :address_mapping, fn query, binding -> + join(query, join_type, [it], m in AddressIdToAddressHash, + as: ^binding, + on: it.address_id == m.address_id + ) + end) + end + + @doc """ + Filters logs by one or multiple address hashes. + + After the optimized fields migration has finished, address hashes are converted + to address ids and matched through `address_id`. While the migration is in + progress, the condition matches both `address_id` and the legacy + `address_hash` field with `OR`. Queries that fetch a page of logs of a single + address should use `address_match_union_query/3` instead. + """ + @spec address_match_query(Ecto.Queryable.t(), Hash.Address.t() | [Hash.Address.t()] | binary() | [binary()]) :: + Ecto.Query.t() + def address_match_query(query, address_hashes) when is_list(address_hashes) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes) + where(query, [l], l.address_id in ^address_ids) + + LogHelper.fill_optimized_fields_migration_started?() -> + address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes) + where(query, [l], l.address_id in ^address_ids or l.address_hash in ^address_hashes) + + true -> + where(query, [l], l.address_hash in ^address_hashes) + end + end + + def address_match_query(query, address_hash) do + where(query, ^address_match_dynamic(address_hash)) + end + + @doc """ + Returns the list of independent conditions matching logs of the given address. + + While the optimized fields migration is in progress, logs of the same address + are stored either with the new `address_id` or with the legacy `address_hash`, + so two conditions are returned. In all other cases the list holds a single + condition. + + The conditions are meant to be combined with `UNION ALL`, one query branch per + condition (see `address_match_union_query/3`), rather than with `OR`: a single + `OR` condition doesn't let + PostgreSQL use the `address_id` and `address_hash` indexes to return already + ordered rows, forcing it to collect and sort every log of the address before + applying the `LIMIT`. With `UNION ALL` each branch is an ordered index scan, + so the planner can merge the branches and stop at the `LIMIT`. + + The conditions never match the same log twice, so that `UNION ALL` doesn't + produce duplicates: the legacy condition is limited to the logs which have no + `address_id` yet. Both fields can be filled at once, since a log re-imported + before it is migrated keeps its `address_hash` while the upsert fills + `address_id`. + """ + @spec address_match_dynamics(Hash.Address.t() | binary()) :: [Ecto.Query.dynamic_expr()] + def address_match_dynamics(address_hash) do + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + address_id = AddressIdToAddressHash.hash_to_id(address_hash) + + [address_id_match_dynamic(address_id)] + + LogHelper.fill_optimized_fields_migration_started?() -> + address_id = AddressIdToAddressHash.hash_to_id(address_hash) + + if address_id do + [ + dynamic([l], l.address_id == ^address_id), + dynamic([l], is_nil(l.address_id) and l.address_hash == ^address_hash) + ] + else + [dynamic([l], l.address_hash == ^address_hash)] + end + + true -> + [dynamic([l], l.address_hash == ^address_hash)] + end + end + + defp address_match_dynamic(address_hash) do + address_hash + |> address_match_dynamics() + |> Enum.reduce(fn match_dynamic, acc -> dynamic([l], ^acc or ^match_dynamic) end) + end + + @doc """ + Builds a logs query for the given address hash, combining one query branch per + `address_match_dynamics/1` condition with `UNION ALL`. + + `branch_fun` receives an address match condition and must return the complete + branch: every filter, join and select has to be applied inside it, since + `where/3`, `join/5` and `select/3` applied to a combination query would only + affect its first branch. + + `paging_fun` applies the ordering and the limit. It is applied to every branch + and then to the whole combination, so that each branch stays an ordered index + scan bounded by the limit instead of being fully materialized and sorted. + Queries without ordering can omit it: an outer `LIMIT` alone already stops + `UNION ALL` branches early. + + A single condition, which is the case for every address once the optimized + fields migration has finished, produces no combination at all. + """ + @spec address_match_union_query( + Hash.Address.t() | binary(), + (Ecto.Query.dynamic_expr() -> Ecto.Queryable.t()), + (Ecto.Queryable.t() -> Ecto.Queryable.t()) + ) :: Ecto.Query.t() + def address_match_union_query(address_hash, branch_fun, paging_fun \\ fn query -> query end) do + branches = + address_hash + |> address_match_dynamics() + |> Enum.map(fn address_match_dynamic -> address_match_dynamic |> branch_fun.() |> paging_fun.() end) + + case branches do + [branch] -> + branch + + branches -> + branches + |> Enum.map(&Chain.wrapped_union_subquery/1) + |> Enum.reduce(fn branch, acc -> union_all(acc, ^branch) end) + |> Chain.wrapped_union_subquery() + |> paging_fun.() + end + end + + defp address_id_match_dynamic(nil), do: dynamic(false) + defp address_id_match_dynamic(address_id), do: dynamic([l], l.address_id == ^address_id) + + @doc """ + Joins the log first topic lookup table to the given logs query. + + The join is added with the named binding `:first_topic` and is skipped when + this binding is already present in the query. + """ + @spec join_first_topic_query(Ecto.Queryable.t()) :: Ecto.Query.t() + def join_first_topic_query(query) do + with_named_binding(query, :first_topic, fn query, binding -> + join(query, :left, [l], ft in LogFirstTopic, + as: ^binding, + on: l.first_topic_id == ft.id + ) + end) + end + + @spec preload_block(map() | [map()] | nil, module(), [Block.t()] | nil) :: __MODULE__.t() | [__MODULE__.t()] | nil + def preload_block(logs, repo \\ Repo, blocks \\ nil) + + def preload_block(nil, _repo, _blocks), do: nil + def preload_block([], _repo, _blocks), do: [] + + def preload_block(logs, repo, nil) when is_list(logs) do + blocks_map = + logs + |> Enum.map(& &1.block_number) + |> Block.by_numbers_query() + |> Block.consensus_query(true) + |> repo.all() + |> Map.new(&{&1.number, &1}) + + Enum.map(logs, fn log -> + block = blocks_map[log.block_number] + Map.merge(log, %{block_hash: (block && block.hash) || log.block_hash, block: block}) + end) + end + + def preload_block(logs, _repo, existing_blocks) when is_list(logs) do + blocks_map = Map.new(existing_blocks, &{&1.number, &1}) + + Enum.map(logs, fn log -> + block = blocks_map[log.block_number] + Map.merge(log, %{block_hash: (block && block.hash) || log.block_hash, block: block}) + end) + end + + def preload_block(log, repo, existing_blocks) do + [log] + |> preload_block(repo, existing_blocks) + |> List.first() + end + + @doc """ + Preloads transactions for log records. + + When a list of logs is provided and `transactions` is `nil`, + the function fetches the corresponding parent transactions by + `{block_number, transaction_index}` and attaches each transaction to the + `:transaction` field of the matching log. + + It also ensures that `:transaction_hash` is populated from the loaded + transaction when available, while preserving the existing value if no + transaction is found. + + ## Parameters + - `logs`: A single log, a list of logs, or `nil` + - `repo`: The repo used to fetch transactions when they are not passed explicitly + - `transactions`: Optional preloaded transactions to reuse instead of + querying the database + + ## Returns + - `nil` when `logs` is `nil` + - A list of logs with the `:transaction` field populated + - A single log with the `:transaction` field populated + """ + @spec preload_transaction(map() | [map()] | nil, [any()], module(), [Transaction.t()] | nil) :: + __MODULE__.t() | [__MODULE__.t()] | nil + def preload_transaction(logs, preloads \\ [], repo \\ Repo, transactions \\ nil) + + def preload_transaction(nil, _preloads, _repo, _transactions), do: nil + def preload_transaction([], _preloads, _repo, _transactions), do: [] + + def preload_transaction(logs, preloads, repo, existing_transactions) when is_list(logs) do + transactions = + case existing_transactions do + nil -> + logs + |> Enum.map(&{&1.block_number, &1.transaction_index}) + |> Enum.uniq() + |> Transaction.by_block_number_index_query() + |> repo.all() + |> repo.preload(preloads) + + transactions -> + List.wrap(transactions) + end + + block_number_index_to_transaction_map = Map.new(transactions, &{{&1.block_number, &1.index}, &1}) + + Enum.map(logs, fn it -> + transaction = block_number_index_to_transaction_map[{it.block_number, it.transaction_index}] + + Map.merge(it, %{ + transaction: transaction, + transaction_hash: (transaction && transaction.hash) || it.transaction_hash + }) + end) + end + + def preload_transaction(log, preloads, repo, transactions) do + [log] + |> preload_transaction(preloads, repo, transactions) + |> List.first() + end + + @doc """ + Preloads addresses for log records. + + Logs that still have the legacy `address_hash` field are preloaded through + `:address_by_hash`. Migrated logs are preloaded through the address mapping + association. The original log order is preserved. + """ + @spec preload_address(map() | [map()] | nil, Keyword.t(), module()) :: __MODULE__.t() | [__MODULE__.t()] | nil + def preload_address(logs, options \\ [], repo \\ Repo) + + def preload_address(nil, _options, _repo), do: nil + def preload_address([], _options, _repo), do: [] + + def preload_address(logs, options, repo) when is_list(logs) do + preloads = [address: Keyword.get(options, :address_preloads, [])] + repo = repo || Chain.select_repo(options) + + indexed_logs = Enum.with_index(logs) + + {migrated_indexed, not_migrated_indexed} = + Enum.split_with(indexed_logs, fn {l, _idx} -> is_nil(l.address_hash) end) + + migrated = Enum.map(migrated_indexed, &elem(&1, 0)) + not_migrated = Enum.map(not_migrated_indexed, &elem(&1, 0)) + + not_migrated_preloaded = preload_addresses_for_not_migrated_logs(not_migrated, preloads, repo) + migrated_preloaded = preload_addresses_for_migrated_logs(migrated, preloads, repo) + + migrated_with_idx = Enum.zip(migrated_preloaded, Enum.map(migrated_indexed, &elem(&1, 1))) + not_migrated_with_idx = Enum.zip(not_migrated_preloaded, Enum.map(not_migrated_indexed, &elem(&1, 1))) + + (migrated_with_idx ++ not_migrated_with_idx) + |> Enum.sort_by(&elem(&1, 1)) + |> Enum.map(&elem(&1, 0)) + end + + def preload_address(log, options, repo) do + [log] + |> preload_address(options, repo) + |> List.first() + end + + @doc """ + Ensures that the `data` field is populated from `compressed_data` when needed. + """ + @spec prepare_data(map() | [map()] | nil) :: __MODULE__.t() | [__MODULE__.t()] | nil + def prepare_data(nil), do: nil + + def prepare_data(logs) when is_list(logs) do + Enum.map(logs, &%{&1 | data: &1.data || &1.compressed_data}) + end + + def prepare_data(log) do + [log] + |> prepare_data() + |> List.first() + end + + @doc """ + Ensures that `first_topic` is populated from `first_topic_id` when needed. + + Topic values are loaded from `log_first_topics` in batch for list inputs. + """ + @spec prepare_first_topic(map() | [map()] | nil) :: map() | [map()] | nil + def prepare_first_topic(nil), do: nil + + def prepare_first_topic(logs) when is_list(logs) do + first_topics_map = + logs + |> Enum.map(& &1.first_topic_id) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + |> LogFirstTopic.fetch_by_ids() + |> Map.new(&{&1.id, &1.value}) + + Enum.map(logs, &%{&1 | first_topic: &1.first_topic || first_topics_map[&1.first_topic_id]}) + end + + def prepare_first_topic(log) do + [log] + |> prepare_first_topic() + |> List.first() + end + + defp preload_addresses_for_not_migrated_logs([], _preloads, _repo), do: [] + + defp preload_addresses_for_not_migrated_logs(logs, preloads, repo) do + unified_preloads = + preloads + |> List.wrap() + |> Enum.map(fn + :address -> {:address_by_hash, []} + {:address, fields} -> {:address_by_hash, fields} + end) + + logs + |> repo.preload(unified_preloads) + |> Enum.map(fn log -> + address = Map.get(log, :address_by_hash) + + Map.merge(log, %{ + address_hash: (address && address.hash) || Map.get(log, :address_hash), + address: address + }) + end) + end + + defp preload_addresses_for_migrated_logs([], _preloads, _repo), do: [] + + defp preload_addresses_for_migrated_logs(logs, preloads, repo) do + logs + |> repo.preload(preloads) + |> Enum.map(fn log -> + address = Map.get(log, :address) + Map.put(log, :address_hash, address && address.hash) + end) + end end diff --git a/apps/explorer/lib/explorer/chain/map_cache.ex b/apps/explorer/lib/explorer/chain/map_cache.ex index 38ab88308c5..52877a7a982 100644 --- a/apps/explorer/lib/explorer/chain/map_cache.ex +++ b/apps/explorer/lib/explorer/chain/map_cache.ex @@ -124,6 +124,7 @@ defmodule Explorer.Chain.MapCache do # credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks quote do require Logger + alias Explorer.Chain.MapCache @behaviour MapCache diff --git a/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex b/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex index cfa2ba93750..b4175a39a4e 100644 --- a/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex +++ b/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex @@ -18,7 +18,10 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do alias Explorer.Repo @doc """ - Query to get the number of missing block numbers in the DB + Query to get the number of missing block numbers in the DB. + + On the Filecoin chain type, heights recorded in `null_round_heights` are not counted: + a null round has no block by design, so it can never be indexed. """ # sobelow_skip ["SQL"] @spec missing_blocks_count() :: integer() @@ -28,6 +31,8 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do if block_ranges == [] do 0 else + missing_condition = missing_block_condition() + {sql_parts, params} = Enum.reduce(block_ranges, {[], []}, fn first..last//_, {parts, acc_params} -> @@ -39,9 +44,7 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do part = """ SELECT COUNT(*) AS missing_count FROM generate_series($#{param_index_from}::bigint, $#{param_index_to}::bigint) AS num(number) - WHERE NOT EXISTS ( - SELECT 1 FROM blocks b WHERE b.number = num.number AND b.consensus - ) + WHERE #{missing_condition} """ {[part | parts], [to, from | acc_params]} @@ -55,9 +58,7 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do $#{param_index}::bigint, (SELECT COALESCE(MAX(number), $#{param_index}) FROM blocks)::bigint ) AS num(number) - WHERE NOT EXISTS ( - SELECT 1 FROM blocks b WHERE b.number = num.number AND b.consensus - ) + WHERE #{missing_condition} """ {[part | parts], [start_from | acc_params]} @@ -79,6 +80,27 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do end end + # SQL predicate over `num.number` from `generate_series` telling whether the number is + # a missing block. On Filecoin, heights recorded as null rounds are excluded as well. + defp missing_block_condition do + base = """ + NOT EXISTS ( + SELECT 1 FROM blocks b WHERE b.number = num.number AND b.consensus + ) + """ + + if Application.get_env(:explorer, :chain_type) == :filecoin do + base <> + """ + AND NOT EXISTS ( + SELECT 1 FROM null_round_heights nrh WHERE nrh.height = num.number + ) + """ + else + base + end + end + defp normalize_missing_blocks_count(nil), do: 0 defp normalize_missing_blocks_count(%Decimal{} = value), do: Decimal.to_integer(value) defp normalize_missing_blocks_count(value), do: value diff --git a/apps/explorer/lib/explorer/chain/mud.ex b/apps/explorer/lib/explorer/chain/mud.ex deleted file mode 100644 index 6ba99b7ace1..00000000000 --- a/apps/explorer/lib/explorer/chain/mud.ex +++ /dev/null @@ -1,649 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Mud do - @moduledoc """ - Represents a MUD framework database record. - """ - use Explorer.Schema - - import Ecto.Query, - only: [ - distinct: 2, - order_by: 3, - select: 3, - where: 3, - limit: 2, - join: 5 - ] - - alias ABI.{FunctionSelector, TypeDecoder} - alias Explorer.{Chain, PagingOptions, Repo, SortingHelper} - - alias Explorer.Chain.{ - Address, - Block, - Data, - Hash, - MethodIdentifier, - Mud, - Mud.Schema, - Mud.Schema.FieldSchema, - SmartContract - } - - require Logger - - @store_tables_table_id Base.decode16!("746273746f72650000000000000000005461626c657300000000000000000000", - case: :lower - ) - - # https://github.com/latticexyz/mud/blob/cc4f4246e52982354e398113c46442910f9b04bb/packages/store/src/codegen/tables/Tables.sol#L34-L42 - @store_tables_table_schema %Schema{ - key_schema: FieldSchema.from("0x002001005f000000000000000000000000000000000000000000000000000000"), - value_schema: FieldSchema.from("0x006003025f5f5fc4c40000000000000000000000000000000000000000000000"), - key_names: ["tableId"], - value_names: ["fieldLayout", "keySchema", "valueSchema", "abiEncodedKeyNames", "abiEncodedValueNames"] - } - - @world_system_registry_table_id Base.decode16!("7462776f726c6400000000000000000053797374656d52656769737472790000", - case: :lower - ) - - # https://github.com/latticexyz/mud/blob/5a6c03c6bc02c980ca051dadd8e20560ac25c771/packages/world/src/codegen/tables/SystemRegistry.sol#L24-L32 - @world_system_registry_schema %Schema{ - key_schema: FieldSchema.from("0x0014010061000000000000000000000000000000000000000000000000000000"), - value_schema: FieldSchema.from("0x002001005f000000000000000000000000000000000000000000000000000000"), - key_names: ["system"], - value_names: ["systemId"] - } - - @world_function_selector_table_id Base.decode16!("7462776f726c6400000000000000000046756e6374696f6e53656c6563746f72", - case: :lower - ) - - # https://github.com/latticexyz/mud/blob/5a6c03c6bc02c980ca051dadd8e20560ac25c771/packages/world/src/codegen/tables/FunctionSelectors.sol#L24-L32 - @world_function_selector_schema %Schema{ - key_schema: FieldSchema.from("0x0004010043000000000000000000000000000000000000000000000000000000"), - value_schema: FieldSchema.from("0x002402005f430000000000000000000000000000000000000000000000000000"), - key_names: ["worldFunctionSelector"], - value_names: ["systemId", "systemFunctionSelector"] - } - - @world_function_signature_table_id Base.decode16!("6f74776f726c6400000000000000000046756e6374696f6e5369676e61747572", - case: :lower - ) - - # https://github.com/latticexyz/mud/blob/5a6c03c6bc02c980ca051dadd8e20560ac25c771/packages/world/src/codegen/tables/FunctionSignatures.sol#L21-L29 - @world_function_signature_schema %Schema{ - key_schema: FieldSchema.from("0x0004010043000000000000000000000000000000000000000000000000000000"), - value_schema: FieldSchema.from("0x00000001c5000000000000000000000000000000000000000000000000000000"), - key_names: ["functionSelector"], - value_names: ["functionSignature"] - } - - @primary_key false - typed_schema "records" do - field(:address, Hash.Address, null: false) - field(:table_id, Hash.Full, null: false) - field(:key_bytes, Data) - field(:key0, Hash.Full) - field(:key1, Hash.Full) - field(:static_data, Data) - field(:encoded_lengths, Data) - field(:dynamic_data, Data) - field(:is_deleted, :boolean, null: false) - field(:block_number, :decimal, null: false) - field(:log_index, :decimal, null: false) - end - - def enabled? do - Application.get_env(:explorer, __MODULE__)[:enabled] - end - - @doc """ - Returns the paginated list of registered MUD world addresses. - """ - @spec worlds_list(Keyword.t()) :: [Mud.t()] - def worlds_list(options \\ []) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - Mud - |> distinct(true) - |> select([r], r.address) - |> where([r], r.table_id == ^@store_tables_table_id) - |> page_worlds(paging_options) - |> limit(^paging_options.page_size) - |> Repo.Mud.all() - end - - defp page_worlds(query, %PagingOptions{key: %{world: world}}) do - query |> where([item], item.address > ^world) - end - - defp page_worlds(query, _), do: query - - @doc """ - Returns the total number of registered MUD worlds. - """ - @spec worlds_count() :: non_neg_integer() - def worlds_count do - Mud - |> select([r], r.address) - |> distinct(true) - |> Repo.Mud.aggregate(:count) - end - - @doc """ - Returns the decoded MUD table schema by world address and table ID. - """ - @spec world_table_schema(Hash.Address.t(), Hash.Full.t()) :: {:ok, Schema.t()} | {:error, :not_found} - def world_table_schema(world, table_id) do - Mud - |> where([r], r.address == ^world and r.table_id == ^@store_tables_table_id and r.key0 == ^table_id) - |> Repo.Mud.one() - |> case do - nil -> - {:error, :not_found} - - r -> - {:ok, decode_schema(r)} - end - end - - @doc """ - Returns the paginated list of registered MUD tables in the given world, optionally filtered by namespace or table name. - Each returned table in the resulting list is represented as a tuple of its ID and decoded schema. - """ - @spec world_tables(Hash.Address.t(), Keyword.t()) :: [{Hash.Full.t(), Schema.t()}] - def world_tables(world, options \\ []) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - filter_namespace = Keyword.get(options, :filter_namespace, nil) - filter_search = Keyword.get(options, :filter_search, nil) - - Mud - |> where([r], r.address == ^world and r.table_id == ^@store_tables_table_id) - |> filter_tables_by_namespace(filter_namespace) - |> filter_tables_by_search(filter_search) - |> page_tables(paging_options) - |> order_by([r], asc: r.key0) - |> limit(^paging_options.page_size) - |> Repo.Mud.all() - |> Enum.map(&{&1.key0, decode_schema(&1)}) - end - - defp page_tables(query, %PagingOptions{key: %{table_id: table_id}}) do - query |> where([item], item.key0 > ^table_id) - end - - defp page_tables(query, _), do: query - - @doc """ - Returns the number of registered MUD tables in the given world. - """ - @spec world_tables_count(Hash.Address.t(), Keyword.t()) :: non_neg_integer() - def world_tables_count(world, options \\ []) do - filter_namespace = Keyword.get(options, :filter_namespace, nil) - filter_search = Keyword.get(options, :filter_search, nil) - - Mud - |> where([r], r.address == ^world and r.table_id == ^@store_tables_table_id) - |> filter_tables_by_namespace(filter_namespace) - |> filter_tables_by_search(filter_search) - |> Repo.Mud.aggregate(:count) - end - - defp filter_tables_by_namespace(query, nil), do: query - - defp filter_tables_by_namespace(query, :error), do: query |> where([tb], false) - - defp filter_tables_by_namespace(query, namespace) do - query |> where([tb], fragment("substring(? FROM 3 FOR 14)", tb.key0) == ^namespace) - end - - defp filter_tables_by_search(query, %Hash{} = table_id) do - query |> where([tb], tb.key0 == ^table_id) - end - - defp filter_tables_by_search(query, search_string) when is_binary(search_string) do - query |> where([tb], ilike(fragment("encode(?, 'escape')", tb.key0), ^"%#{search_string}%")) - end - - defp filter_tables_by_search(query, _), do: query - - @default_sorting [ - asc: :key_bytes - ] - - @doc """ - Returns the paginated list of raw MUD records in the given world table. - Resulting records can be sorted or filtered by any of the first 2 key columns. - """ - @spec world_table_records(Hash.Address.t(), Hash.Full.t(), Keyword.t()) :: [Mud.t()] - def world_table_records(world, table_id, options \\ []) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - sorting = Keyword.get(options, :sorting, []) - - Mud - |> where([r], r.address == ^world and r.table_id == ^table_id and r.is_deleted == false) - |> filter_records(:key0, Keyword.get(options, :filter_key0)) - |> filter_records(:key1, Keyword.get(options, :filter_key1)) - |> SortingHelper.apply_sorting(sorting, @default_sorting) - |> SortingHelper.page_with_sorting(paging_options, sorting, @default_sorting) - |> Repo.Mud.all() - end - - @doc """ - Returns the list of first 1000 MUD systems registered for the given world. - """ - @spec world_systems(Hash.Address.t()) :: [{Hash.Full.t(), Hash.Address.t()}] - def world_systems(world) do - Mud - |> where([r], r.address == ^world and r.table_id == ^@world_system_registry_table_id and r.is_deleted == false) - |> limit(1000) - |> Repo.Mud.all() - |> Enum.map(&decode_record(&1, @world_system_registry_schema)) - |> Enum.map(fn s -> - with {:ok, system_id} <- Hash.Full.cast(s["systemId"]), - {:ok, system} <- Hash.Address.cast(s["system"]) do - {system_id, system} - end - end) - |> Enum.reject(&(&1 == :error)) - end - - @doc """ - Returns reconstructed ABI of the MUD system in the given world. - """ - @spec world_system(Hash.Address.t(), Hash.Address.t(), Keyword.t()) :: - {:ok, Hash.Full.t(), [FunctionSelector.t()]} | {:error, :not_found} - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - def world_system(world, system, options \\ []) do - # pad to 32 bytes - padded_system_address_hash = %Data{bytes: <<0::size(96)>> <> system.bytes} - - # If we were to access MUD tables in SQL, it would look like: - # SELECT sr.*, fsl.*, fsg.* - # FROM tb.world.SystemRegistry sr - # JOIN tb.world.FunctionSelector fsl ON fsl.systemId = sr.systemId - # JOIN tb.world.FunctionSignature fsg ON fsg.functionSelector = fsl.worldFunctionSelector - # WHERE sr.system = $1 - {system_records, function_selector_signature_records} = - Mud - |> where( - [r], - r.address == ^world and r.table_id == ^@world_system_registry_table_id and r.is_deleted == false and - r.key_bytes == ^padded_system_address_hash - ) - |> join( - :left, - [r], - r2 in Mud, - on: - r2.address == ^world and r2.table_id == ^@world_function_selector_table_id and r2.is_deleted == false and - fragment("substring(? FOR 32)", r2.static_data) == r.static_data - ) - |> join( - :left, - [r, r2], - r3 in Mud, - on: - r3.address == ^world and r3.table_id == ^@world_function_signature_table_id and r3.is_deleted == false and - r3.key_bytes == r2.key_bytes - ) - |> select([r, r2, r3], {r, {r2, r3}}) - |> limit(1000) - |> Repo.Mud.all() - |> Enum.unzip() - - with false <- Enum.empty?(system_records), - system_record = Enum.at(system_records, 0), - {:ok, system_id} <- Hash.Full.cast(system_record.static_data.bytes) do - {:ok, system_id, reconstruct_system_abi(system, function_selector_signature_records, options)} - else - _ -> {:error, :not_found} - end - end - - @spec reconstruct_system_abi(Hash.Address.t(), [{Mud.t(), Mud.t()}], Keyword.t()) :: [FunctionSelector.t()] - defp reconstruct_system_abi(system, function_selector_signature_records, options) do - system_contract = SmartContract.address_hash_to_smart_contract(system, options) - - # fetch verified contract ABI, if any - verified_system_abi = - ((system_contract && system_contract.abi) || []) - |> ABI.parse_specification() - |> Enum.filter(&(&1.type == :function)) - |> Enum.into(%{}, fn selector -> - {:ok, method_id} = MethodIdentifier.cast(selector.method_id) - {to_string(method_id), selector} - end) - - function_selector_signature_records - |> Enum.reject(&(&1 == {nil, nil})) - |> Enum.map(fn {function_selector_record, function_signature_record} -> - function_selector = function_selector_record |> decode_record(@world_function_selector_schema) - - # if the external world function selector is present in the verified ABI, we use it - world_function_selector = - verified_system_abi |> Map.get(function_selector |> Map.get("worldFunctionSelector")) - - if world_function_selector do - world_function_selector - else - abi_method = parse_function_signature(function_signature_record) - - # if the internal system function selector is present in the verified ABI, - # then it has the same arguments as the external world function, but a different name, - # so we use it after replacing the function name accordingly. - # in case neither of the selectors were found in the verified ABI, we use the ABI crafted from the method signature - verified_system_abi - |> Map.get(function_selector |> Map.get("systemFunctionSelector"), abi_method) - |> Map.put(:function, abi_method.function) - end - end) - end - - @spec parse_function_signature(Mud.t()) :: FunctionSelector.t() - defp parse_function_signature(function_signature_record) do - raw_abi_method = - function_signature_record - |> decode_record(@world_function_signature_schema) - |> Map.get("functionSignature") - |> FunctionSelector.decode() - - raw_abi_method - |> Map.put(:type, :function) - |> Map.put(:state_mutability, :payable) - |> Map.put(:input_names, 0..(Enum.count(raw_abi_method.types) - 1) |> Enum.map(&"arg#{&1}")) - end - - @doc """ - Preloads last modification timestamps for the list of raw MUD records. - - Returns a map of block numbers to timestamps. - """ - @spec preload_records_timestamps([Mud.t()]) :: %{non_neg_integer() => DateTime.t()} - def preload_records_timestamps(records, options \\ []) do - block_numbers = records |> Enum.map(&(&1.block_number |> Decimal.to_integer())) |> Enum.uniq() - - Block - |> where([b], b.number in ^block_numbers) - |> select([b], {b.number, b.timestamp}) - |> Chain.select_repo(options).all() - |> Enum.into(%{}) - end - - @doc """ - Returns the number of MUD records in the given world table. - """ - @spec world_table_records_count(Hash.Address.t(), Hash.Full.t(), Keyword.t()) :: non_neg_integer() - def world_table_records_count(world, table_id, options \\ []) do - Mud - |> where([r], r.address == ^world and r.table_id == ^table_id and r.is_deleted == false) - |> filter_records(:key0, Keyword.get(options, :filter_key0)) - |> filter_records(:key1, Keyword.get(options, :filter_key1)) - |> Repo.Mud.aggregate(:count) - end - - defp filter_records(query, _key_name, nil), do: query - - defp filter_records(query, _key_name, :error), do: query |> where([r], false) - - defp filter_records(query, :key0, key), do: query |> where([r], r.key0 == ^key) - - defp filter_records(query, :key1, key), do: query |> where([r], r.key1 == ^key) - - @doc """ - Returns the raw MUD record from the given world table by its ID. - """ - @spec world_table_record(Hash.Address.t(), Hash.Full.t(), Data.t()) :: {:ok, Mud.t()} | {:error, :not_found} - def world_table_record(world, table_id, record_id) do - Mud - |> where([r], r.address == ^world and r.table_id == ^table_id and r.key_bytes == ^record_id) - |> Repo.Mud.one() - |> case do - nil -> - {:error, :not_found} - - r -> - {:ok, r} - end - end - - defp decode_schema(nil), do: nil - - defp decode_schema(record) do - schema_record = decode_record(record, @store_tables_table_schema) - - %Schema{ - key_schema: schema_record["keySchema"] |> FieldSchema.from(), - value_schema: schema_record["valueSchema"] |> FieldSchema.from(), - key_names: schema_record["abiEncodedKeyNames"] |> decode_abi_encoded_strings(), - value_names: schema_record["abiEncodedValueNames"] |> decode_abi_encoded_strings() - } - end - - defp decode_abi_encoded_strings("0x" <> hex_encoded) do - hex_encoded - |> Base.decode16!(case: :mixed) - |> TypeDecoder.decode_raw([{:array, :string}]) - |> Enum.at(0) - end - - @doc """ - Decodes a given raw MUD record according to table schema. - - Returns a JSON-like map with decoded field names and values. - """ - @spec decode_record(Mud.t() | nil, Schema.t() | nil) :: map() | nil - def decode_record(nil, _schema), do: nil - - def decode_record(_record, nil), do: nil - - def decode_record(record, schema) do - key = decode_key_tuple(record.key_bytes.bytes, schema.key_names, schema.key_schema) - - value = - if record.is_deleted do - schema.value_names |> Enum.into(%{}, &{&1, nil}) - else - decode_fields( - record.static_data, - record.encoded_lengths, - record.dynamic_data, - schema.value_names, - schema.value_schema - ) - end - - key |> Map.merge(value) - end - - defp decode_key_tuple(key_bytes, fields, layout_schema) do - {_, types} = Schema.decode_types(layout_schema) - - fields - |> Enum.zip(types) - |> Enum.reduce({%{}, key_bytes}, fn {field, type}, {acc, data} -> - type_size = static_type_size(type) - <<word::binary-size(32), rest::binary>> = data - - enc = - if type < 64 or type >= 96 do - :binary.part(word, 32 - type_size, type_size) - else - :binary.part(word, 0, type_size) - end - - decoded = decode_type(type, enc) - - {Map.put(acc, field, decoded), rest} - end) - |> elem(0) - end - - defp decode_fields(static_data, encoded_lengths, dynamic_data, fields, layout_schema) do - {static_fields_count, types} = Schema.decode_types(layout_schema) - - {static_types, dynamic_types} = Enum.split(types, static_fields_count) - - {static_fields, dynamic_fields} = Enum.split(fields, static_fields_count) - - res = - static_fields - |> Enum.zip(static_types) - |> Enum.reduce({%{}, (static_data && static_data.bytes) || <<>>}, fn {field, type}, {acc, data} -> - type_size = static_type_size(type) - {enc, rest} = split_binary(data, type_size) - - decoded = decode_type(type, enc) - {Map.put(acc, field, decoded), rest} - end) - |> elem(0) - - if encoded_lengths == nil or byte_size(encoded_lengths.bytes) == 0 do - res - else - dynamic_type_lengths = - encoded_lengths.bytes - |> :binary.bin_to_list(0, 25) - |> Enum.chunk_every(5) - |> Enum.reverse() - |> Enum.map(&(&1 |> :binary.list_to_bin() |> :binary.decode_unsigned())) - - [dynamic_fields, dynamic_types, dynamic_type_lengths] - |> Enum.zip() - |> Enum.reduce({res, (dynamic_data && dynamic_data.bytes) || <<>>}, fn {field, type, length}, {acc, data} -> - {enc, rest} = split_binary(data, length) - decoded = decode_type(type, enc) - - {Map.put(acc, field, decoded), rest} - end) - |> elem(0) - end - end - - defp static_type_size(type) do - case type do - _ when type < 97 -> rem(type, 32) + 1 - 97 -> 20 - _ -> 0 - end - end - - defp split_binary(binary, size) do - if byte_size(binary) >= size, - do: :erlang.split_binary(binary, size), - else: {<<0::size(size * 8)>>, <<>>} - end - - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp decode_type(type, raw) do - case type do - _ when type < 32 -> - raw |> :binary.decode_unsigned() |> Integer.to_string() - - _ when type < 64 -> - size = static_type_size(type) - <<int::signed-integer-size(size * 8)>> = raw - int |> Integer.to_string() - - _ when type < 96 or type == 196 -> - %Data{bytes: raw} |> to_string() - - 96 -> - raw == <<1>> - - 97 -> - Address.checksum(raw) - - _ when type < 196 -> - raw - |> :binary.bin_to_list() - |> Enum.chunk_every(static_type_size(type - 98)) - |> Enum.map(&decode_type(type - 98, :binary.list_to_bin(&1))) - - 197 -> - raw - - _ -> - raise "Unknown type: #{type}" - end - end -end - -defimpl Jason.Encoder, for: ABI.FunctionSelector do - alias Jason.Encode - - def encode(data, opts) do - function_inputs = encode_arguments(data.types, data.input_names) - - inputs = - if data.inputs_indexed do - function_inputs - |> Enum.zip(data.inputs_indexed) - |> Enum.map(fn {r, indexed} -> Map.put(r, "indexed", indexed) end) - else - function_inputs - end - - Encode.map( - %{ - "type" => data.type, - "name" => data.function, - "inputs" => inputs, - "outputs" => encode_arguments(data.returns, data.return_names), - "stateMutability" => encode_state_mutability(data.state_mutability) - }, - opts - ) - end - - defp encode_arguments(types, names) do - types - |> Enum.zip(names) - |> Enum.map(fn {type, name} -> - %{ - "type" => encode_type(type), - "name" => name, - "components" => encode_components(type) - } - |> Map.reject(fn {key, value} -> {key, value} == {"components", nil} end) - end) - end - - defp encode_type(:bool), do: "bool" - defp encode_type(:string), do: "string" - defp encode_type(:bytes), do: "bytes" - defp encode_type(:address), do: "address" - defp encode_type(:function), do: "function" - defp encode_type({:int, size}), do: "int#{size}" - defp encode_type({:uint, size}), do: "uint#{size}" - defp encode_type({:fixed, element_count, precision}), do: "fixed#{element_count}x#{precision}" - defp encode_type({:ufixed, element_count, precision}), do: "ufixed#{element_count}x#{precision}" - defp encode_type({:bytes, size}), do: "bytes#{size}" - defp encode_type({:array, type}), do: "#{encode_type(type)}[]" - defp encode_type({:array, type, element_count}), do: "#{encode_type(type)}[#{element_count}]" - defp encode_type({:tuple, _types}), do: "tuple" - - defp encode_components({:array, type}), do: encode_components(type) - defp encode_components({:array, type, _element_count}), do: encode_components(type) - - defp encode_components({:tuple, types}), - do: - types - |> Enum.with_index() - |> Enum.map(fn {type, index} -> - %{ - "type" => encode_type(type), - "name" => "arg#{index}" - } - end) - - defp encode_components(_), do: nil - - defp encode_state_mutability(:pure), do: "pure" - defp encode_state_mutability(:view), do: "view" - defp encode_state_mutability(:non_payable), do: "nonpayable" - defp encode_state_mutability(:payable), do: "payable" - defp encode_state_mutability(_), do: nil -end diff --git a/apps/explorer/lib/explorer/chain/mud/schema.ex b/apps/explorer/lib/explorer/chain/mud/schema.ex deleted file mode 100644 index 84406f24274..00000000000 --- a/apps/explorer/lib/explorer/chain/mud/schema.ex +++ /dev/null @@ -1,111 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Mud.Schema do - @moduledoc """ - Represents a MUD framework database record schema. - """ - - defmodule FieldSchema do - @moduledoc """ - Represents a MUD framework database record field schema. Describes number of columns and their types. - """ - - defstruct [:word] - - @typedoc """ - The MUD field schema. - * `word` - The field schema as 32-byte value. - """ - @type t :: %__MODULE__{ - word: <<_::256>> - } - - @doc """ - Decodes field schema type from raw binary or hex-encoded string. - """ - @spec from(binary()) :: t() | :error - def from(<<bin::binary-size(32)>>), do: %__MODULE__{word: bin} - - def from("0x" <> <<hex::binary-size(64)>>) do - with {:ok, bin} <- Base.decode16(hex, case: :mixed) do - %__MODULE__{word: bin} - end - end - - def from(_), do: :error - - @doc """ - Tells the type of the field at index `index` in the field schema. - """ - @spec type_of(t(), non_neg_integer()) :: non_neg_integer() - def type_of(%FieldSchema{word: word}, index), do: :binary.at(word, index + 4) - end - - @enforce_keys [:key_schema, :value_schema, :key_names, :value_names] - defstruct [:key_schema, :value_schema, :key_names, :value_names] - - @typedoc """ - The MUD table schema. Describe column types and names for the given MUD table. - * `key_schema` - The field schema for the key columns. - * `value_schema` - The field schema for the value columns. - * `key_names` - The names of the key columns. - * `value_names` - The names of the value columns. - """ - @type t :: %__MODULE__{ - key_schema: FieldSchema.t(), - value_schema: FieldSchema.t(), - key_names: [String.t()], - value_names: [String.t()] - } - - defimpl Jason.Encoder, for: Explorer.Chain.Mud.Schema do - alias Explorer.Chain.Mud.Schema - alias Jason.Encode - - def encode(data, opts) do - Encode.map( - %{ - "key_types" => data.key_schema |> Schema.decode_type_names(), - "value_types" => data.value_schema |> Schema.decode_type_names(), - "key_names" => data.key_names, - "value_names" => data.value_names - }, - opts - ) - end - end - - @doc """ - Tells the number of static fields in the schema and the list of raw type IDs of all fields in the schema. - """ - @spec decode_types(FieldSchema.t()) :: {non_neg_integer(), [non_neg_integer()]} - def decode_types(layout_schema) do - static_fields_count = :binary.at(layout_schema.word, 2) - dynamic_fields_count = :binary.at(layout_schema.word, 3) - - {static_fields_count, :binary.bin_to_list(layout_schema.word, 4, static_fields_count + dynamic_fields_count)} - end - - @doc """ - Tells the list of decoded type names for all fields in the schema. - """ - @spec decode_type_names(FieldSchema.t()) :: [String.t()] - def decode_type_names(layout_schema) do - {_, types} = decode_types(layout_schema) - types |> Enum.map(&encode_type_name/1) - end - - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp encode_type_name(type) do - case type do - _ when type < 32 -> "uint" <> Integer.to_string((type + 1) * 8) - _ when type < 64 -> "int" <> Integer.to_string((type - 31) * 8) - _ when type < 96 -> "bytes" <> Integer.to_string(type - 63) - 96 -> "bool" - 97 -> "address" - _ when type < 196 -> encode_type_name(type - 98) <> "[]" - 196 -> "bytes" - 197 -> "string" - _ -> "unknown_type_" <> Integer.to_string(type) - end - end -end diff --git a/apps/explorer/lib/explorer/chain/mud/table.ex b/apps/explorer/lib/explorer/chain/mud/table.ex deleted file mode 100644 index 2670cabc67e..00000000000 --- a/apps/explorer/lib/explorer/chain/mud/table.ex +++ /dev/null @@ -1,89 +0,0 @@ -# SPDX-License-Identifier: LicenseRef-Blockscout -defmodule Explorer.Chain.Mud.Table do - @moduledoc """ - Represents a decoded MUD framework database table ID. - """ - - alias Explorer.Chain.Hash - - @enforce_keys [:table_id, :table_full_name, :table_type, :table_namespace, :table_name] - @derive Jason.Encoder - defstruct [:table_id, :table_full_name, :table_type, :table_namespace, :table_name] - - @typedoc """ - Decoded MUD table name struct. - * `table_id` - The 32-bytes raw MUD table ID. - * `table_full_name` - The decoded table full name. - * `table_type` - The decoded table type: "offchain" or "onchain". - * `table_namespace` - The decoded table namespace. - * `table_name` - The decoded table name. - """ - @type t :: %__MODULE__{ - table_id: Hash.Full.t(), - table_full_name: String.t(), - table_type: String.t(), - table_namespace: String.t(), - table_name: String.t() - } - - @doc """ - Decodes table type, namespace and name information from raw MUD table ID. - """ - @spec from(Hash.Full.t()) :: t() - def from(%Hash{byte_count: 32, bytes: raw} = table_id) do - <<prefix::binary-size(2), namespace::binary-size(14), table_name::binary-size(16)>> = raw - - trimmed_namespace = String.trim_trailing(namespace, <<0>>) - trimmed_table_name = String.trim_trailing(table_name, <<0>>) - - table_full_name = - if String.length(trimmed_namespace) > 0 do - prefix <> "." <> trimmed_namespace <> "." <> trimmed_table_name - else - prefix <> "." <> trimmed_table_name - end - - table_type = - case prefix do - "ot" -> "offchain" - "tb" -> "onchain" - _ -> "unknown" - end - - %__MODULE__{ - table_id: table_id, - table_full_name: table_full_name, - table_type: table_type, - table_namespace: trimmed_namespace, - table_name: trimmed_table_name - } - end - - @doc """ - Encodes table full name as a raw MUD table ID. - """ - @spec table_full_name_to_table_id(String.t()) :: {:ok, Hash.Full.t()} | :error - def table_full_name_to_table_id(full_name) do - parts = - case String.split(full_name, ".") do - [prefix, name] -> [prefix, "", name] - [prefix, namespace, name] -> [prefix, namespace, name] - _ -> :error - end - - with [prefix, namespace, name] <- parts, - {:ok, prefix} <- normalize_length(prefix, 2), - {:ok, namespace} <- normalize_length(namespace, 14), - {:ok, name} <- normalize_length(name, 16) do - Hash.Full.cast(prefix <> namespace <> name) - end - end - - defp normalize_length(str, len) do - if String.length(str) <= len do - {:ok, String.pad_trailing(str, len, <<0>>)} - else - :error - end - end -end diff --git a/apps/explorer/lib/explorer/chain/optimism/deposit.ex b/apps/explorer/lib/explorer/chain/optimism/deposit.ex index 79048471193..86f6142a559 100644 --- a/apps/explorer/lib/explorer/chain/optimism/deposit.ex +++ b/apps/explorer/lib/explorer/chain/optimism/deposit.ex @@ -111,6 +111,7 @@ defmodule Explorer.Chain.Optimism.Deposit do ## Parameters - `options`: A keyword list of options: - `:api?` - Whether the function is being called from an API context. + - `:timeout` - Timeout for the database query (defaults to the repo's default timeout). ## Returns - A total number of deposits. @@ -124,7 +125,7 @@ defmodule Explorer.Chain.Optimism.Deposit do on: t.hash == d.l2_transaction_hash and t.status == :ok ) - select_repo(options).aggregate(query, :count) + select_repo(options).aggregate(query, :count, Keyword.take(options, [:timeout])) end defp page_deposits(query, %PagingOptions{key: nil}), do: query diff --git a/apps/explorer/lib/explorer/chain/optimism/interop_message.ex b/apps/explorer/lib/explorer/chain/optimism/interop_message.ex index a22c3d22b3b..797a567f318 100644 --- a/apps/explorer/lib/explorer/chain/optimism/interop_message.ex +++ b/apps/explorer/lib/explorer/chain/optimism/interop_message.ex @@ -805,7 +805,7 @@ defmodule Explorer.Chain.Optimism.InteropMessage do with {:ok, %{body: body, status: 200}} <- Tesla.get(client, url, opts: [adapter: [timeout: recv_timeout, transport_opts: [timeout: connect_timeout]]]), - {:ok, response} <- Jason.decode(body), + {:ok, response} <- Utils.JSON.decode(body), explorer = response |> Map.get("explorers", []) |> Enum.at(0), false <- is_nil(explorer), explorer_url = Map.get(explorer, "url"), diff --git a/apps/explorer/lib/explorer/chain/optimism/superchain_config.ex b/apps/explorer/lib/explorer/chain/optimism/superchain_config.ex new file mode 100644 index 00000000000..0cdf95d6754 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/optimism/superchain_config.ex @@ -0,0 +1,431 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Optimism.SuperchainConfig do + @moduledoc """ + Loads selected Optimism chain parameters from a Superchain TOML file, + persists them to the `constants` table, and serves DB-backed values + with env-based fallback. + """ + + require Logger + + import Ecto.Query + + alias Explorer.Application.Constants + alias Explorer.Repo + + @holocene_timestamp_key "optimism_l2_holocene_timestamp" + @isthmus_timestamp_key "optimism_l2_isthmus_timestamp" + @jovian_timestamp_key "optimism_l2_jovian_timestamp" + @eip1559_denominator_key "optimism_eip1559_base_fee_max_change_denominator" + @eip1559_elasticity_key "optimism_eip1559_elasticity_multiplier" + @system_config_contract_key "optimism_l1_system_config_contract" + @portal_contract_key "optimism_l1_portal_contract" + @batch_submitter_key "optimism_l1_batch_submitter" + @batch_inbox_key "optimism_l1_batch_inbox" + @batch_start_block_key "optimism_l1_batch_start_block" + @batch_genesis_l2_block_key "optimism_l2_batch_genesis_block_number" + + @spec refresh() :: :ok + def refresh do + toml_values = + case load_superchain_toml_values() do + {:ok, values} -> + values + + {:error, reason} -> + Logger.warning( + "Cannot load Superchain TOML config. Falling back to dedicated env vars. Reason: #{inspect(reason)}" + ) + + %{} + end + + Repo.transaction(fn -> + set_integer( + @holocene_timestamp_key, + toml_values[{"hardforks", "holocene_time"}], + holocene_timestamp_l2_fallback() + ) + + set_integer( + @isthmus_timestamp_key, + toml_values[{"hardforks", "isthmus_time"}], + isthmus_timestamp_l2_fallback() + ) + + set_integer(@jovian_timestamp_key, toml_values[{"hardforks", "jovian_time"}], jovian_timestamp_l2_fallback()) + + set_integer( + @eip1559_denominator_key, + toml_values[{"optimism", "eip1559_denominator"}], + eip1559_base_fee_max_change_denominator_fallback() + ) + + set_integer( + @eip1559_elasticity_key, + toml_values[{"optimism", "eip1559_elasticity"}], + eip1559_elasticity_multiplier_fallback() + ) + + set_address( + @system_config_contract_key, + toml_values[{"addresses", "SystemConfigProxy"}], + optimism_l1_system_config_contract_fallback() + ) + + set_address( + @portal_contract_key, + toml_values[{"addresses", "OptimismPortalProxy"}], + optimism_l1_portal_contract_fallback() + ) + + set_address( + @batch_submitter_key, + toml_values[{"genesis.system_config", "batcherAddress"}], + optimism_l1_batch_submitter_fallback() + ) + + set_address( + @batch_inbox_key, + toml_values[{"genesis.system_config", "batch_inbox_addr"}] || toml_values[{nil, "batch_inbox_addr"}], + optimism_l1_batch_inbox_fallback() + ) + + set_integer( + @batch_start_block_key, + toml_values[{"genesis.l1", "number"}], + optimism_l1_batch_start_block_fallback() + ) + + set_integer( + @batch_genesis_l2_block_key, + toml_values[{"genesis.l2", "number"}], + optimism_l2_batch_genesis_block_number_fallback() + ) + end) + + :ok + end + + @spec holocene_timestamp_l2() :: non_neg_integer() | nil + def holocene_timestamp_l2 do + get_integer(@holocene_timestamp_key, holocene_timestamp_l2_fallback()) + end + + @spec isthmus_timestamp_l2() :: non_neg_integer() | nil + def isthmus_timestamp_l2 do + get_integer(@isthmus_timestamp_key, isthmus_timestamp_l2_fallback()) + end + + @spec jovian_timestamp_l2() :: non_neg_integer() | nil + def jovian_timestamp_l2 do + get_integer(@jovian_timestamp_key, jovian_timestamp_l2_fallback()) + end + + @spec eip1559_base_fee_max_change_denominator() :: non_neg_integer() | nil + def eip1559_base_fee_max_change_denominator do + get_integer(@eip1559_denominator_key, eip1559_base_fee_max_change_denominator_fallback()) + end + + @spec eip1559_elasticity_multiplier() :: non_neg_integer() | nil + def eip1559_elasticity_multiplier do + get_integer(@eip1559_elasticity_key, eip1559_elasticity_multiplier_fallback()) + end + + @spec optimism_l1_system_config_contract() :: String.t() | nil + def optimism_l1_system_config_contract do + get_address(@system_config_contract_key, optimism_l1_system_config_contract_fallback()) + end + + @spec optimism_l1_portal_contract() :: String.t() | nil + def optimism_l1_portal_contract do + get_address(@portal_contract_key, optimism_l1_portal_contract_fallback()) + end + + @spec optimism_l1_batch_submitter() :: String.t() | nil + def optimism_l1_batch_submitter do + get_address(@batch_submitter_key, optimism_l1_batch_submitter_fallback()) + end + + @spec optimism_l1_batch_start_block() :: non_neg_integer() | nil + def optimism_l1_batch_start_block do + get_integer(@batch_start_block_key, optimism_l1_batch_start_block_fallback()) + end + + @spec optimism_l1_batch_inbox() :: String.t() | nil + def optimism_l1_batch_inbox do + get_address(@batch_inbox_key, optimism_l1_batch_inbox_fallback()) + end + + @spec optimism_l2_batch_genesis_block_number() :: non_neg_integer() | nil + def optimism_l2_batch_genesis_block_number do + get_integer(@batch_genesis_l2_block_key, optimism_l2_batch_genesis_block_number_fallback()) + end + + @spec eip1559_constants() :: {non_neg_integer() | nil, non_neg_integer() | nil} + def eip1559_constants do + {eip1559_base_fee_max_change_denominator(), eip1559_elasticity_multiplier()} + end + + defp load_superchain_toml_values do + case superchain_config_file_path() do + nil -> + {:error, :superchain_config_file_path_not_set} + + file_path -> + with {:ok, content} <- read_superchain_file(file_path) do + parse_toml(content) + end + end + end + + defp superchain_config_file_path do + Application.get_env(:indexer, Indexer.Fetcher.Optimism, [])[:superchain_config_file_path] + |> blank_to_nil() + end + + # sobelow_skip ["Traversal.FileModule"] + defp read_superchain_file(file_path) do + if String.starts_with?(file_path, ["http://", "https://"]) do + read_remote_superchain_file(file_path) + else + File.read(file_path) + end + end + + defp read_remote_superchain_file(url) do + request_url = normalize_github_blob_url(url) + http_options = [autoredirect: true, timeout: 15_000, connect_timeout: 5_000] + + :inets.start() + :ssl.start() + + case :httpc.request(:get, {String.to_charlist(request_url), []}, http_options, body_format: :binary) do + {:ok, {{_http_version, 200, _status_text}, _headers, body}} -> + {:ok, body} + + {:ok, {{_http_version, status, _status_text}, _headers, _body}} -> + {:error, {:http_error, status, request_url}} + + {:error, reason} -> + {:error, {:http_request_failed, reason, request_url}} + end + end + + defp normalize_github_blob_url(url) do + Regex.replace( + ~r/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+)$/, + url, + "https://raw.githubusercontent.com/\\1/\\2/\\3/\\4" + ) + end + + defp parse_toml(content) do + values = + content + |> String.split("\n") + |> Enum.reduce({nil, %{}}, fn line, {section, acc} -> + cleaned_line = clean_toml_line(line) + + cond do + cleaned_line == "" -> + {section, acc} + + String.starts_with?(cleaned_line, "[") and String.ends_with?(cleaned_line, "]") -> + new_section = + cleaned_line + |> String.trim_leading("[") + |> String.trim_trailing("]") + + {new_section, acc} + + String.contains?(cleaned_line, "=") -> + [raw_key, raw_value] = String.split(cleaned_line, "=", parts: 2) + key = String.trim(raw_key) + parsed_value = parse_toml_value(raw_value) + {section, Map.put(acc, {section, key}, parsed_value)} + + true -> + {section, acc} + end + end) + |> elem(1) + + {:ok, values} + rescue + error -> {:error, {:toml_parse_failed, error}} + end + + defp clean_toml_line(line) do + line + |> strip_toml_comment() + |> String.trim() + end + + # Removes a TOML line comment (everything from the first '#' that is outside + # a quoted string), handling escaped backslashes and quotes inside strings. + defp strip_toml_comment(line) do + strip_toml_comment(line, _in_quote = false, _acc = []) + end + + defp strip_toml_comment(<<>>, _in_quote, acc), do: IO.iodata_to_binary(Enum.reverse(acc)) + defp strip_toml_comment(<<?#, _rest::binary>>, false, acc), do: IO.iodata_to_binary(Enum.reverse(acc)) + + defp strip_toml_comment(<<?\\, ?\\, rest::binary>>, true, acc), + do: strip_toml_comment(rest, true, [?\\, ?\\ | acc]) + + defp strip_toml_comment(<<?\\, ?", rest::binary>>, true, acc), + do: strip_toml_comment(rest, true, [?", ?\\ | acc]) + + defp strip_toml_comment(<<?", rest::binary>>, in_quote, acc), + do: strip_toml_comment(rest, !in_quote, [?" | acc]) + + defp strip_toml_comment(<<char, rest::binary>>, in_quote, acc), + do: strip_toml_comment(rest, in_quote, [char | acc]) + + defp parse_toml_value(raw_value) do + value = String.trim(raw_value) + + if String.starts_with?(value, "\"") and String.ends_with?(value, "\"") do + value + |> String.trim_leading("\"") + |> String.trim_trailing("\"") + else + case Integer.parse(value) do + {integer, ""} -> integer + _ -> value + end + end + end + + defp set_integer(key, value_from_toml, fallback_value) do + value = + cond do + is_integer(value_from_toml) and value_from_toml >= 0 -> value_from_toml + is_integer(fallback_value) and fallback_value >= 0 -> fallback_value + true -> nil + end + + case value do + integer when is_integer(integer) -> Constants.set_constant_value(key, Integer.to_string(integer)) + _ -> clear_constant_value(key) + end + end + + defp set_address(key, value_from_toml, fallback_value) do + value = + cond do + address?(value_from_toml) -> String.downcase(value_from_toml) + address?(fallback_value) -> String.downcase(fallback_value) + true -> nil + end + + case value do + address when is_binary(address) -> Constants.set_constant_value(key, address) + _ -> clear_constant_value(key) + end + end + + defp clear_constant_value(key) do + from(c in Constants, where: c.key == ^key) + |> Repo.delete_all() + + :ok + end + + defp get_integer(key, fallback) do + key + |> Constants.get_constant_value() + |> parse_integer_or_nil() + |> case do + nil -> fallback + integer -> integer + end + end + + defp get_address(key, fallback) do + case Constants.get_constant_value(key) do + nil -> + normalize_fallback_address(fallback) + + value -> + if address?(value), do: String.downcase(value), else: normalize_fallback_address(fallback) + end + end + + defp normalize_fallback_address(fallback) when is_binary(fallback) do + if address?(fallback), do: String.downcase(fallback), else: nil + end + + defp normalize_fallback_address(_), do: nil + + defp parse_integer_or_nil(nil), do: nil + + defp parse_integer_or_nil(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} when integer >= 0 -> integer + _ -> nil + end + end + + defp blank_to_nil(nil), do: nil + + defp blank_to_nil(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp address?(value) when is_binary(value) do + Regex.match?(~r/^0x[0-9a-fA-F]{40}$/, value) + end + + defp address?(_), do: false + + defp holocene_timestamp_l2_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism.EIP1559ConfigUpdate, [])[:holocene_timestamp_l2] + end + + defp isthmus_timestamp_l2_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism, [])[:isthmus_timestamp_l2] + end + + defp jovian_timestamp_l2_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism.EIP1559ConfigUpdate, [])[:jovian_timestamp_l2] + end + + defp eip1559_base_fee_max_change_denominator_fallback do + Application.get_env(:explorer, :base_fee_max_change_denominator) + end + + defp eip1559_elasticity_multiplier_fallback do + Application.get_env(:explorer, :elasticity_multiplier) + end + + defp optimism_l1_system_config_contract_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism, [])[:optimism_l1_system_config] + end + + defp optimism_l1_portal_contract_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism, [])[:portal] + end + + defp optimism_l1_batch_submitter_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism.TransactionBatch, [])[:submitter] + end + + defp optimism_l1_batch_inbox_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism.TransactionBatch, [])[:inbox] + end + + defp optimism_l1_batch_start_block_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism, [])[:start_block_l1] + end + + defp optimism_l2_batch_genesis_block_number_fallback do + Application.get_env(:indexer, Indexer.Fetcher.Optimism.TransactionBatch, [])[:genesis_block_l2] + end +end diff --git a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex index d98df84e9b2..d0f6d4f8fc0 100644 --- a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex +++ b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex @@ -10,8 +10,10 @@ defmodule Explorer.Chain.Optimism.Withdrawal do alias Explorer.Chain alias Explorer.Chain.{Block, Hash, Log, Transaction} alias Explorer.Chain.Cache.OptimismFinalizationPeriod - alias Explorer.Chain.Optimism.{DisputeGame, OutputRoot, WithdrawalEvent} + alias Explorer.Chain.Optimism.{DisputeGame, OutputRoot, SuperchainConfig, WithdrawalEvent} alias Explorer.{Helper, PagingOptions, Repo} + alias Explorer.Repo.LockTimeout + alias Explorer.Utility.{LogFirstTopic, LogHelper} @game_status_defender_wins 2 @@ -61,6 +63,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do """ @spec list :: [__MODULE__.t()] + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def list(options \\ []) do paging_options = Keyword.get(options, :paging_options, default_paging_options()) @@ -69,34 +72,58 @@ defmodule Explorer.Chain.Optimism.Withdrawal do [] _ -> - base_query = - from(w in __MODULE__, - order_by: [desc: w.msg_nonce], - left_join: l2_transaction in Transaction, - on: w.l2_transaction_hash == l2_transaction.hash, - left_join: l2_block in Block, - on: w.l2_block_number == l2_block.number, - left_join: we in WithdrawalEvent, - on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized, - left_join: log in Log, - on: - log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and - log.second_topic == fragment("numeric_to_bytea32(msg_nonce)"), - select: %{ - msg_nonce: w.msg_nonce, - hash: w.hash, - l2_block_number: w.l2_block_number, - l2_timestamp: l2_block.timestamp, - l2_transaction_hash: w.l2_transaction_hash, - l1_transaction_hash: we.l1_transaction_hash, - from: l2_transaction.from_address_hash, - msg_log_sender_address_hash: log.third_topic, - msg_log_target_address_hash: log.fourth_topic, - msg_log_data: log.data - } - ) - - base_query + __MODULE__ + |> order_by([w], desc: w.msg_nonce) + |> join(:left, [w], l2_transaction in Transaction, on: w.l2_transaction_hash == l2_transaction.hash) + |> join(:left, [w], l2_block in Block, on: w.l2_block_number == l2_block.number and l2_block.consensus == true) + |> join(:left, [w], we in WithdrawalEvent, + on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized + ) + |> then(fn query -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + first_topic_id = LogFirstTopic.value_to_id(@message_passed_event) + + join(query, :left, [w, l2_transaction], log in Log, + on: + log.block_number == w.l2_block_number and log.transaction_index == l2_transaction.index and + log.first_topic_id == ^first_topic_id and + log.second_topic == fragment("bytea_ltrim_zeroes(numeric_to_bytea32(msg_nonce))") + ) + + LogHelper.fill_optimized_fields_migration_started?() -> + first_topic_id = LogFirstTopic.value_to_id(@message_passed_event) + + join(query, :left, [w, l2_transaction], log in Log, + on: + (log.transaction_hash == w.l2_transaction_hash or + (log.block_number == w.l2_block_number and log.transaction_index == l2_transaction.index)) and + (log.first_topic_id == ^first_topic_id or log.first_topic == ^@message_passed_event) and + (log.second_topic == fragment("numeric_to_bytea32(msg_nonce)") or + log.second_topic == fragment("bytea_ltrim_zeroes(numeric_to_bytea32(msg_nonce))")) + ) + + true -> + join(query, :left, [w, l2_transaction], log in Log, + on: + log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and + log.second_topic == fragment("numeric_to_bytea32(msg_nonce)") + ) + end + end) + |> select([w, l2_transaction, l2_block, we, log], %{ + msg_nonce: w.msg_nonce, + hash: w.hash, + l2_block_number: w.l2_block_number, + l2_timestamp: l2_block.timestamp, + l2_transaction_hash: w.l2_transaction_hash, + l1_transaction_hash: we.l1_transaction_hash, + from: l2_transaction.from_address_hash, + msg_log_sender_address_hash: log.third_topic, + msg_log_target_address_hash: log.fourth_topic, + msg_log_data: log.data + }) |> page_optimism_withdrawals(paging_options) |> limit(^paging_options.page_size) |> select_repo(options).all(timeout: :infinity) @@ -156,40 +183,91 @@ defmodule Explorer.Chain.Optimism.Withdrawal do @spec transaction_statuses(Hash.t()) :: [{non_neg_integer(), String.t(), map()}] def transaction_statuses(l2_transaction_hash) do query = - from(w in __MODULE__, - where: w.l2_transaction_hash == ^l2_transaction_hash, - left_join: l2_block in Block, - on: w.l2_block_number == l2_block.number and l2_block.consensus == true, - left_join: we in WithdrawalEvent, - on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized, - left_join: log in Log, - on: - log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and - log.second_topic == fragment("numeric_to_bytea32(msg_nonce)"), - select: %{ - hash: w.hash, - l2_block_number: w.l2_block_number, - l2_timestamp: l2_block.timestamp, - l1_transaction_hash: we.l1_transaction_hash, - msg_nonce: w.msg_nonce, - msg_log_sender_address_hash: log.third_topic, - msg_log_target_address_hash: log.fourth_topic, - msg_log_data: log.data - } + __MODULE__ + |> where([w], w.l2_transaction_hash == ^l2_transaction_hash) + |> join(:left, [w], l2_transaction in Transaction, on: w.l2_transaction_hash == l2_transaction.hash) + |> join(:left, [w], l2_block in Block, on: w.l2_block_number == l2_block.number and l2_block.consensus == true) + |> join(:left, [w], we in WithdrawalEvent, + on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized ) + |> join_message_passed_log() + |> select([w, _l2_transaction, l2_block, we, log], %{ + hash: w.hash, + l2_block_number: w.l2_block_number, + l2_timestamp: l2_block.timestamp, + l1_transaction_hash: we.l1_transaction_hash, + msg_nonce: w.msg_nonce, + msg_log_sender_address_hash: log.third_topic, + msg_log_target_address_hash: log.fourth_topic, + msg_log_data: log.data + }) + + # The withdrawals are optional data of the transaction page, which must not + # hang while the joined `logs` table is locked exclusively (e.g. by + # `VACUUM FULL`): the query fails fast and no withdrawals are shown instead. + case LockTimeout.run(Repo.replica(), fn repo -> repo.all(query) end, timeout: :infinity) do + {:ok, withdrawals} -> + Enum.map(withdrawals, fn w -> + msg_nonce = + Bitwise.band( + Decimal.to_integer(w.msg_nonce), + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + ) + + {status, _} = status(w, nil, @api_true) + {msg_nonce, status, w} + end) + + {:error, :lock_timeout} -> + [] + end + end - query - |> Repo.replica().all(timeout: :infinity) - |> Enum.map(fn w -> - msg_nonce = - Bitwise.band( - Decimal.to_integer(w.msg_nonce), - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + defp join_message_passed_log(query) do + first_topic_id = LogFirstTopic.value_to_id(@message_passed_event) + migration_finished? = LogHelper.fill_optimized_fields_migration_finished?() + migration_started? = LogHelper.fill_optimized_fields_migration_started?() + + cond do + migration_finished? and is_nil(first_topic_id) -> + join(query, :left, [w, l2_transaction], log in Log, on: false) + + migration_finished? -> + join(query, :left, [w, l2_transaction], log in Log, + on: + log.block_number == w.l2_block_number and log.transaction_index == l2_transaction.index and + log.first_topic_id == ^first_topic_id and + log.second_topic == fragment("bytea_ltrim_zeroes(numeric_to_bytea32(msg_nonce))") ) - {status, _} = status(w, nil, @api_true) - {msg_nonce, status, w} - end) + migration_started? and is_nil(first_topic_id) -> + join(query, :left, [w, l2_transaction], log in Log, + on: + (log.transaction_hash == w.l2_transaction_hash or + (log.block_number == w.l2_block_number and log.transaction_index == l2_transaction.index)) and + log.first_topic == ^@message_passed_event and + (log.second_topic == fragment("numeric_to_bytea32(msg_nonce)") or + log.second_topic == fragment("bytea_ltrim_zeroes(numeric_to_bytea32(msg_nonce))")) + ) + + migration_started? -> + join(query, :left, [w, l2_transaction], log in Log, + on: + (log.transaction_hash == w.l2_transaction_hash or + (log.block_number == w.l2_block_number and log.transaction_index == l2_transaction.index)) and + (log.first_topic_id == ^first_topic_id or log.first_topic == ^@message_passed_event) and + (log.second_topic == fragment("numeric_to_bytea32(msg_nonce)") or + log.second_topic == fragment("bytea_ltrim_zeroes(numeric_to_bytea32(msg_nonce))")) + ) + + true -> + join(query, :left, [w, l2_transaction], log in Log, + on: + log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and + log.second_topic == fragment("numeric_to_bytea32(msg_nonce)") + ) + end end @doc """ @@ -511,18 +589,6 @@ defmodule Explorer.Chain.Optimism.Withdrawal do """ @spec portal_contract_address() :: String.t() | nil def portal_contract_address do - portal_address = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism][:portal] - - if Indexer.Helper.address_correct?(portal_address) do - portal_address - else - Constants.get_constant_value(portal_contract_address_constant(), @api_true) - end + SuperchainConfig.optimism_l1_portal_contract() end - - @doc """ - Returns the name of the optimism_portal_contract_address constant. - """ - @spec portal_contract_address_constant() :: binary() - def portal_contract_address_constant, do: "optimism_portal_contract_address" end diff --git a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex index 1bb6cd4222e..fb454dc5f60 100644 --- a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex +++ b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex @@ -6,6 +6,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do alias Explorer.Chain.{Block, Hash, PendingBlockOperation, PendingTransactionOperation, Transaction} alias Explorer.{Helper, Repo} + alias Indexer.Fetcher.InternalTransaction, as: InternalTransactionFetcher defp transactions_batch_size, do: @@ -70,8 +71,21 @@ defmodule Explorer.Chain.PendingOperationsHelper do defp do_transfuse(transfuse_function) do case Repo.transaction(transfuse_function) do - {:ok, :finish} -> :ok - {:ok, :continue} -> do_transfuse(transfuse_function) + {:ok, :finish} -> + :ok + + {:ok, {:continue, {block_numbers, transactions}}} -> + async_fetch_internal_transactions(block_numbers, transactions) + do_transfuse(transfuse_function) + end + end + + @spec async_fetch_internal_transactions([Block.block_number()], [map()]) :: :ok + defp async_fetch_internal_transactions(block_numbers, transactions) do + if is_nil(Process.whereis(InternalTransactionFetcher)) do + :ok + else + InternalTransactionFetcher.async_fetch(block_numbers, transactions, false) end end @@ -123,7 +137,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do |> where([pto], pto.transaction_hash in ^transaction_hashes) |> Repo.delete_all() - :continue + {:continue, {pbo_params |> Enum.map(& &1.block_number) |> Enum.uniq(), []}} end end @@ -142,12 +156,15 @@ defmodule Explorer.Chain.PendingOperationsHelper do :finish pbo_block_numbers -> - pto_params = + transactions = Transaction |> where([t], t.block_number in ^pbo_block_numbers) - |> select([t], %{hash: t.hash, type: t.type}) + |> select([t], %{block_number: t.block_number, hash: t.hash, index: t.index, type: t.type}) |> Repo.all() |> Transaction.filter_non_traceable_transactions() + + pto_params = + transactions |> Enum.map(&%{transaction_hash: &1.hash}) |> Helper.add_timestamps() @@ -155,7 +172,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do :ok -> delete_pending_block_operations(pbo_block_numbers) - :continue + {:continue, {[], transactions}} {:error, :too_many_parameters} when blocks_batch_size > 1 -> from_blocks_to_transactions_function(max(div(blocks_batch_size, 2), 1)) @@ -164,7 +181,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do Repo.safe_insert_all(PendingTransactionOperation, pto_params, on_conflict: :nothing) delete_pending_block_operations(pbo_block_numbers) - :continue + {:continue, {[], transactions}} end end end @@ -214,6 +231,28 @@ defmodule Explorer.Chain.PendingOperationsHelper do ) end + @doc """ + Generates a query to find pending block operations that match any of the given block numbers. + """ + @spec block_number_in_query([non_neg_integer()]) :: Ecto.Query.t() + def block_number_in_query(block_numbers) do + from( + pending_ops in PendingBlockOperation, + where: pending_ops.block_number in ^block_numbers + ) + end + + @doc """ + Generates a query to find pending transaction operations that match any of the given transaction hashes. + """ + @spec transaction_hash_in_query([Hash.Full.t()]) :: Ecto.Query.t() + def transaction_hash_in_query(transaction_hashes) do + from( + pending_ops in PendingTransactionOperation, + where: pending_ops.transaction_hash in ^transaction_hashes + ) + end + @doc """ Checks if a block with the given hash is pending. A block is considered pending if there exists a corresponding entry in the `PendingBlockOperation` table. @@ -225,6 +264,30 @@ defmodule Explorer.Chain.PendingOperationsHelper do |> Repo.exists?() end + @doc """ + Checks if at least one block number from the provided list is pending. + """ + @spec block_numbers_pending?([non_neg_integer()]) :: boolean() + def block_numbers_pending?([]), do: false + + def block_numbers_pending?(block_numbers) do + block_numbers + |> block_number_in_query() + |> Repo.exists?() + end + + @doc """ + Checks if at least one transaction hash from the provided list is pending. + """ + @spec transaction_hashes_pending?([Hash.Full.t()]) :: boolean() + def transaction_hashes_pending?([]), do: false + + def transaction_hashes_pending?(transaction_hashes) do + transaction_hashes + |> transaction_hash_in_query() + |> Repo.exists?() + end + # Generates a query to find pending block operations within a specified range of block numbers. @spec block_range_in_query(non_neg_integer() | nil, non_neg_integer() | nil) :: Ecto.Query.t() defp block_range_in_query(min_block_number, max_block_number) @@ -254,6 +317,37 @@ defmodule Explorer.Chain.PendingOperationsHelper do ) end + @spec transaction_block_range_in_query(non_neg_integer() | nil, non_neg_integer() | nil) :: Ecto.Query.t() + defp transaction_block_range_in_query(min_block_number, max_block_number) + when is_integer(min_block_number) and is_integer(max_block_number) do + from( + pending_ops in PendingTransactionOperation, + join: t in assoc(pending_ops, :transaction), + where: t.block_number >= ^min_block_number and t.block_number <= ^max_block_number + ) + end + + defp transaction_block_range_in_query(min_block_number, max_block_number) + when is_nil(min_block_number) and is_nil(max_block_number) do + from(pending_ops in PendingTransactionOperation) + end + + defp transaction_block_range_in_query(min_block_number, max_block_number) when is_nil(min_block_number) do + from( + pending_ops in PendingTransactionOperation, + join: t in assoc(pending_ops, :transaction), + where: t.block_number <= ^max_block_number + ) + end + + defp transaction_block_range_in_query(min_block_number, max_block_number) when is_nil(max_block_number) do + from( + pending_ops in PendingTransactionOperation, + join: t in assoc(pending_ops, :transaction), + where: t.block_number >= ^min_block_number + ) + end + @doc """ Checks if there are any pending blocks within the specified range of block numbers. A block is considered pending if there exists a corresponding entry in the `PendingBlockOperation` @@ -343,4 +437,61 @@ defmodule Explorer.Chain.PendingOperationsHelper do defp add_priority(params, priority) do Enum.map(params, &Map.merge(&1, %{priority: priority})) end + + @doc """ + Checks if there are any pending transactions within the specified block range. + """ + @spec transactions_pending_in_block_range?(non_neg_integer() | nil, non_neg_integer() | nil) :: boolean() + def transactions_pending_in_block_range?(min_block_number, max_block_number) do + min_block_number + |> transaction_block_range_in_query(max_block_number) + |> Repo.exists?() + end + + @doc """ + Checks if there are any pending block or transaction operations within the specified block range. + """ + @spec pending_operations_in_block_range?(non_neg_integer() | nil, non_neg_integer() | nil) :: boolean() + def pending_operations_in_block_range?(min_block_number, max_block_number) do + blocks_pending?(min_block_number, max_block_number) || + transactions_pending_in_block_range?(min_block_number, max_block_number) + end + + @doc """ + Checks if there are any pending block or transaction operations in the system. + """ + @spec any_pending_operations?() :: boolean() + def any_pending_operations? do + Repo.exists?(PendingBlockOperation) || Repo.exists?(PendingTransactionOperation) + end + + @doc """ + Checks if there are pending operations for any of the provided block numbers or transaction hashes. + """ + @spec pending_operations_for_blocks_or_transactions?([non_neg_integer()], [Hash.Full.t()]) :: boolean() + def pending_operations_for_blocks_or_transactions?(block_numbers, transaction_hashes) do + block_numbers_pending?(block_numbers) || transaction_hashes_pending?(transaction_hashes) + end + + @doc """ + Checks if there are pending operations in a block-number range or among provided transaction hashes. + """ + @spec pending_operations_for_block_range_or_transactions?( + non_neg_integer() | nil, + non_neg_integer() | nil, + [Hash.Full.t()] + ) :: boolean() + def pending_operations_for_block_range_or_transactions?(min_block_number, max_block_number, transaction_hashes) do + pending_operations_in_block_range?(min_block_number, max_block_number) || + transaction_hashes_pending?(transaction_hashes) + end + + @doc """ + Checks if there are pending operations for a single transaction scope. + """ + @spec pending_operations_for_transaction?(Hash.Full.t(), non_neg_integer() | nil) :: boolean() + def pending_operations_for_transaction?(transaction_hash, block_number \\ nil) do + transaction_hashes_pending?([transaction_hash]) || + if(is_nil(block_number), do: false, else: blocks_pending?(block_number, block_number)) + end end diff --git a/apps/explorer/lib/explorer/chain/search.ex b/apps/explorer/lib/explorer/chain/search.ex index 7e6e719b2f9..b15051fcf13 100644 --- a/apps/explorer/lib/explorer/chain/search.ex +++ b/apps/explorer/lib/explorer/chain/search.ex @@ -1035,8 +1035,10 @@ defmodule Explorer.Chain.Search do def search_ens_name_in_bens(search_query) do trimmed_query = String.trim(search_query) - with true <- Regex.match?(~r/\w+\.\w+/, trimmed_query), - %{address_hash: address_hash_string_or_nil} = result <- ens_domain_name_lookup(search_query), + with true <- trimmed_query != "", + true <- String.length(trimmed_query) >= @min_query_length, + true <- Regex.match?(~r/[A-Za-z0-9-]+/, trimmed_query), + %{address_hash: address_hash_string_or_nil} = result <- ens_domain_name_lookup(trimmed_query), address_hash <- address_hash_string_or_nil && Chain.string_to_address_hash_or_nil(address_hash_string_or_nil) do {result, address_hash} else diff --git a/apps/explorer/lib/explorer/chain/smart_contract.ex b/apps/explorer/lib/explorer/chain/smart_contract.ex index 9f48cebfa27..0dde12892b0 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract.ex @@ -10,6 +10,7 @@ defmodule Explorer.Chain.SmartContract.Schema do alias Explorer.Chain.{ Address, Address.Reputation, + Data, Hash, SmartContractAdditionalSource } @@ -43,7 +44,7 @@ defmodule Explorer.Chain.SmartContract.Schema do field(:compiler_version, :string, null: false) field(:optimization, :boolean, null: false) field(:contract_source_code, :string, null: false) - field(:constructor_arguments, :string) + field(:constructor_arguments, Data) field(:evm_version, :string) embeds_many(:external_libraries, ExternalLibrary, on_replace: :delete) field(:abi, {:array, :map}) @@ -125,6 +126,7 @@ defmodule Explorer.Chain.SmartContract do alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Helper, as: ExplorerHelper alias Explorer.SmartContract.Helper + alias Explorer.SmartContract.Solidity.Publisher, as: SolidityPublisher alias Explorer.SmartContract.Solidity.Verifier @typep api? :: {:api?, true | false} @@ -852,6 +854,7 @@ defmodule Explorer.Chain.SmartContract do smart_contract ) do if args = Verifier.parse_constructor_arguments_for_sourcify_contract(address_hash, smart_contract.abi) do + args = SolidityPublisher.clear_constructor_arguments(args) smart_contract |> __MODULE__.changeset(%{constructor_arguments: args}) |> Repo.update() %__MODULE__{smart_contract | constructor_arguments: args} else @@ -868,6 +871,7 @@ defmodule Explorer.Chain.SmartContract do ) do if args = Verifier.parse_constructor_arguments_for_sourcify_contract(address_hash, smart_contract.abi, deployed_bytecode) do + args = SolidityPublisher.clear_constructor_arguments(args) smart_contract |> __MODULE__.changeset(%{constructor_arguments: args}) |> Repo.update() %Address{address | smart_contract: %__MODULE__{smart_contract | constructor_arguments: args}} else diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex index 1dab607c87b..e4c63ef6dd7 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex @@ -65,6 +65,7 @@ defmodule Explorer.Chain.SmartContract.Proxy do @zero_address_hash_string "0x0000000000000000000000000000000000000000" @zero_bytes32_string "0x0000000000000000000000000000000000000000000000000000000000000000" + @revert_error_message_markers ["execution reverted", "vm execution error"] @type options :: [{:api?, true | false}] @@ -258,8 +259,8 @@ defmodule Explorer.Chain.SmartContract.Proxy do ## Returns - `{:ok, [{any(), ResolverBehaviour.fetched_values()}]}` if all of the values are fetched successfully, - map can contain nil values for failed/reverted eth_call requests. - - `:error` if the prefetching failed. + map can contain nil values for reverted eth_call requests. + - `:error` if the prefetching failed or an eth_call failed with a non-revert RPC error. """ @spec prefetch_values([{any(), ResolverBehaviour.fetch_requirements()}], Hash.Address.t()) :: {:ok, [{any(), ResolverBehaviour.fetched_values()}]} | :error @@ -295,8 +296,8 @@ defmodule Explorer.Chain.SmartContract.Proxy do ## Returns - `{:ok, prefetched_values()}` if all of the values are fetched successfully, - map can contain nil values for failed/reverted eth_call requests. - - `:error` if the prefetching failed. + map can contain nil values for reverted eth_call requests. + - `:error` if the prefetching failed or an eth_call failed with a non-revert RPC error. """ @spec fetch_values([ResolverBehaviour.fetch_requirement()], Hash.Address.t()) :: {:ok, %{ResolverBehaviour.fetch_requirement() => String.t() | nil}} | :error @@ -333,15 +334,15 @@ defmodule Explorer.Chain.SmartContract.Proxy do @doc """ Fetches value for the given eth_getStorageAt or eth_call request for a given address hash. - The eth_call request is allowed to fail/revert, nil will be returned in such case. + Reverted eth_call requests return nil; other RPC failures return `:error`. ## Parameters - `req`: The eth_getStorageAt or eth_call request to fetch the value for. - `address_hash`: The address hash to fetch the value for. ## Returns - - `{:ok, String.t() | nil}` if the value is fetched successfully. - - `:error` if the fetch request failed. + - `{:ok, String.t() | nil}` if the value is fetched successfully or the eth_call reverted. + - `:error` if the fetch request failed with a non-revert RPC error. """ @spec fetch_value(ResolverBehaviour.fetch_requirement(), Hash.Address.t()) :: {:ok, String.t() | nil} | :error def fetch_value(req, address_hash) do @@ -361,10 +362,28 @@ defmodule Explorer.Chain.SmartContract.Proxy do defp handle_response({:storage, _}, %{result: result}) when is_binary(result), do: {:ok, result} defp handle_response({:call, _}, %{result: result}) when is_binary(result), do: {:ok, result} - # TODO: it'll be better to return nil only for the revert-related errors - defp handle_response({:call, _}, %{error: _}), do: {:ok, nil} + + defp handle_response({:call, _}, %{error: error}) do + if revert_error?(error), do: {:ok, nil}, else: :error + end + defp handle_response(_, _), do: :error + # error code 3 is the standard EIP-1474 code for reverted execution + defp revert_error?(%{code: 3}), do: true + + defp revert_error?(error) do + # revert message casing differs between node clients (e.g. Geth "execution + # reverted" vs Besu "Execution reverted"), so match case-insensitively + message = error |> error_message() |> String.downcase() + + Enum.any?(@revert_error_message_markers, &String.contains?(message, &1)) + end + + defp error_message(%{message: message}) when is_binary(message), do: message + defp error_message(error) when is_binary(error) or is_atom(error), do: to_string(error) + defp error_message(error), do: inspect(error) + @doc """ Returns combined ABI from proxy and implementation smart-contracts """ diff --git a/apps/explorer/lib/explorer/chain/stability/validator.ex b/apps/explorer/lib/explorer/chain/stability/validator.ex index a46aaa41c39..58e8cd4589c 100644 --- a/apps/explorer/lib/explorer/chain/stability/validator.ex +++ b/apps/explorer/lib/explorer/chain/stability/validator.ex @@ -232,7 +232,7 @@ defmodule Explorer.Chain.Stability.Validator do """ @spec next_page_params(t()) :: map() def next_page_params(%__MODULE__{state: state, address_hash: address_hash, blocks_validated: blocks_validated}) do - %{"state" => state, "address_hash" => address_hash, "blocks_validated" => blocks_validated} + %{state: state, address_hash: address_hash, blocks_validated: blocks_validated} end @doc """ diff --git a/apps/explorer/lib/explorer/chain/token.ex b/apps/explorer/lib/explorer/chain/token.ex index d290a3b4a93..f9f1f849a0c 100644 --- a/apps/explorer/lib/explorer/chain/token.ex +++ b/apps/explorer/lib/explorer/chain/token.ex @@ -38,6 +38,9 @@ defmodule Explorer.Chain.Token.Schema do field(:circulating_supply, :decimal) field(:transfer_count, :integer) field(:counters_updated_at, :integer) + field(:ui_multiplier, :decimal) + field(:new_ui_multiplier, :decimal) + field(:ui_multiplier_effective_at, :utc_datetime_usec) belongs_to( :contract_address, @@ -73,6 +76,7 @@ defmodule Explorer.Chain.Token do * ERC-404 * ZRC-2 (for Zilliqa chain type) * ERC-7984 + * ERC-8056 ## Token Specifications @@ -83,6 +87,7 @@ defmodule Explorer.Chain.Token do * [ERC-404](https://github.com/Pandora-Labs-Org/erc404) * [ZRC-2](https://github.com/Zilliqa/ZRC/blob/main/zrcs/zrc-2.md) * [ERC-7984](https://github.com/ethereum/ERCs/blob/39197cde3e32d8fc7fde74c7d0ce5e67ad4de409/ERCS/erc-7984.md) + * [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) """ require Logger @@ -110,16 +115,7 @@ defmodule Explorer.Chain.Token do asc: :contract_address_hash ] - @derive {Poison.Encoder, - except: [ - :__meta__, - :contract_address, - :inserted_at, - :updated_at, - :metadata_updated_at - ]} - - @derive {Jason.Encoder, + @derive {JSON.Encoder, except: [ :__meta__, :contract_address, @@ -144,11 +140,18 @@ defmodule Explorer.Chain.Token do * `circulating_market_cap` - The circulating market cap of a token in a configured currency (USD by default). * `icon_url` - URL of the token's icon. * `is_verified_via_admin_panel` - is token verified via admin panel. + * `ui_multiplier` - ERC-8056 multiplier currently in effect, with 18 decimals + of precision. `nil` for tokens that do not implement ERC-8056. + * `new_ui_multiplier` - ERC-8056 multiplier scheduled to replace + `ui_multiplier` at `ui_multiplier_effective_at`. + * `ui_multiplier_effective_at` - moment `new_ui_multiplier` takes effect. Use + `effective_ui_multiplier/2` instead of reading `ui_multiplier` directly, as + the switch happens with no on-chain event to index. """ Explorer.Chain.Token.Schema.generate() @required_attrs ~w(contract_address_hash type)a - @optional_attrs ~w(cataloged decimals name symbol total_supply skip_metadata total_supply_updated_at_block metadata_updated_at updated_at fiat_value circulating_market_cap circulating_supply icon_url is_verified_via_admin_panel volume_24h)a + @optional_attrs ~w(cataloged decimals name symbol total_supply skip_metadata total_supply_updated_at_block metadata_updated_at updated_at fiat_value circulating_market_cap circulating_supply icon_url is_verified_via_admin_panel volume_24h ui_multiplier new_ui_multiplier ui_multiplier_effective_at)a @doc """ Returns the **ordered** list of allowed NFT type labels. @@ -161,6 +164,28 @@ defmodule Explorer.Chain.Token do "ERC-404" ] + @doc """ + Returns the [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) multiplier the + amounts of the token have to be displayed with at `at` (now by default), or + `nil` for tokens that do not implement ERC-8056. + """ + @spec effective_ui_multiplier(t(), DateTime.t()) :: Decimal.t() | nil + def effective_ui_multiplier(token, at \\ DateTime.utc_now()) + + def effective_ui_multiplier(%__MODULE__{ui_multiplier: nil}, _at), do: nil + + def effective_ui_multiplier(%__MODULE__{new_ui_multiplier: nil} = token, _at), do: token.ui_multiplier + + def effective_ui_multiplier(%__MODULE__{ui_multiplier_effective_at: nil} = token, _at), do: token.ui_multiplier + + def effective_ui_multiplier(%__MODULE__{} = token, at) do + if Timex.before?(at, token.ui_multiplier_effective_at) do + token.ui_multiplier + else + token.new_ui_multiplier + end + end + @doc false def changeset(%Token{} = token, params \\ %{}) do additional_attrs = if BridgedToken.enabled?(), do: [:bridged], else: [] @@ -344,17 +369,24 @@ defmodule Explorer.Chain.Token do symbol: fragment("COALESCE(EXCLUDED.symbol, ?)", token.symbol), total_supply: fragment("COALESCE(EXCLUDED.total_supply, ?)", token.total_supply), decimals: fragment("COALESCE(EXCLUDED.decimals, ?)", token.decimals), + ui_multiplier: fragment("COALESCE(EXCLUDED.ui_multiplier, ?)", token.ui_multiplier), + new_ui_multiplier: fragment("COALESCE(EXCLUDED.new_ui_multiplier, ?)", token.new_ui_multiplier), + ui_multiplier_effective_at: + fragment("COALESCE(EXCLUDED.ui_multiplier_effective_at, ?)", token.ui_multiplier_effective_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at), metadata_updated_at: fragment("GREATEST(?, EXCLUDED.metadata_updated_at)", token.metadata_updated_at) ] ], where: fragment( - "(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.total_supply, EXCLUDED.decimals) IS DISTINCT FROM (?, ?, ?, ?)", + "(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.total_supply, EXCLUDED.decimals, EXCLUDED.ui_multiplier, EXCLUDED.new_ui_multiplier, EXCLUDED.ui_multiplier_effective_at) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", token.name, token.symbol, token.total_supply, - token.decimals + token.decimals, + token.ui_multiplier, + token.new_ui_multiplier, + token.ui_multiplier_effective_at ) ) end diff --git a/apps/explorer/lib/explorer/chain/token/instance.ex b/apps/explorer/lib/explorer/chain/token/instance.ex index 53ae31fad00..06ca0829762 100644 --- a/apps/explorer/lib/explorer/chain/token/instance.ex +++ b/apps/explorer/lib/explorer/chain/token/instance.ex @@ -32,10 +32,12 @@ defmodule Explorer.Chain.Token.Instance do * `retries_count` - number of times the token instance has been retried * `is_banned` - if the token instance is banned * `thumbnails` - info for deriving thumbnails urls. Stored as array: [file_path, sizes, original_uploaded?] - * `media_type` - mime type of media + * `media_type` - mime type of original media that was processed to create thumbnails * `cdn_upload_error` - error while processing(resizing)/uploading media to CDN * `metadata_url` - URL where metadata is fetched from * `skip_metadata_url` - bool flag indicating if metadata_url intentionally skipped + * `image_type` - mime type of image URL ("image/*", "video/*", "text/*", nil or ""). "" means that failed to determine the media type. nil means that the media type is not determined yet. + * `animation_type` - mime type of animation URL ("image/*", "video/*", "text/*", nil or ""). "" means that failed to determine the media type. nil means that the media type is not determined yet. """ @primary_key false typed_schema "token_instances" do @@ -54,6 +56,8 @@ defmodule Explorer.Chain.Token.Instance do field(:cdn_upload_error, :string) field(:metadata_url, :string) field(:skip_metadata_url, :boolean) + field(:image_type, :string) + field(:animation_type, :string) belongs_to(:owner, Address, foreign_key: :owner_address_hash, references: :hash, type: Hash.Address) @@ -87,7 +91,9 @@ defmodule Explorer.Chain.Token.Instance do :media_type, :cdn_upload_error, :metadata_url, - :skip_metadata_url + :skip_metadata_url, + :image_type, + :animation_type ]) |> validate_required([:token_id, :token_contract_address_hash]) |> foreign_key_constraint(:token_contract_address_hash) @@ -727,6 +733,8 @@ defmodule Explorer.Chain.Token.Instance do thumbnails: nil, media_type: nil, cdn_upload_error: nil, + image_type: nil, + animation_type: nil, skip_metadata_url: skip_metadata_url, metadata_url: result[:metadata_url] ] @@ -827,6 +835,35 @@ defmodule Explorer.Chain.Token.Instance do def get_media_url_from_metadata_for_nft_media_handler(nil), do: nil + @doc """ + Extracts image URL from NFT metadata. Checks `image_url`, `image`, and `properties.image` fields in order. + """ + @spec get_image_url_from_metadata(nil | map()) :: nil | binary() + def get_image_url_from_metadata(metadata) when is_map(metadata) do + result = + cond do + is_binary(metadata["image_url"]) -> metadata["image_url"] + is_binary(metadata["image"]) -> metadata["image"] + is_map(metadata["properties"]) && is_binary(metadata["properties"]["image"]) -> metadata["properties"]["image"] + true -> nil + end + + if result && String.trim(result) == "", do: nil, else: result + end + + def get_image_url_from_metadata(nil), do: nil + + @doc """ + Extracts `animation_url` from NFT metadata. Returns `nil` if absent or blank. + """ + @spec get_animation_url_from_metadata(nil | map()) :: nil | binary() + def get_animation_url_from_metadata(metadata) when is_map(metadata) do + url = metadata["animation_url"] + if is_binary(url) && String.trim(url) != "", do: url, else: nil + end + + def get_animation_url_from_metadata(nil), do: nil + @spec batch_upsert_cdn_results([map()]) :: [t()] def batch_upsert_cdn_results([]), do: [] @@ -873,6 +910,17 @@ defmodule Explorer.Chain.Token.Instance do "#{type}/#{subtype}" end + @doc """ + Maps a MIME type string to a high-level media category: `"image"`, `"video"`, `"html"`, or `nil`. + """ + @spec mime_to_media_category(String.t() | nil) :: String.t() | nil + def mime_to_media_category(nil), do: nil + def mime_to_media_category(""), do: nil + def mime_to_media_category("image" <> _), do: "image" + def mime_to_media_category("video" <> _), do: "video" + def mime_to_media_category("text/html"), do: "html" + def mime_to_media_category(_), do: nil + @doc """ Preloads NFTs for a list of `TokenTransfer` structs. @@ -1034,6 +1082,8 @@ defmodule Explorer.Chain.Token.Instance do thumbnails: nil, media_type: nil, cdn_upload_error: nil, + image_type: nil, + animation_type: nil, is_banned: false, retries_count: 0, refetch_after: nil, @@ -1080,6 +1130,57 @@ defmodule Explorer.Chain.Token.Instance do |> Repo.stream_reduce(initial, reducer) end + @doc """ + Streams token instances that have metadata but missing `image_type` or `animation_type`. + """ + @spec stream_token_instances_with_unfetched_media_type( + initial :: accumulator, + reducer :: (entry :: map(), accumulator -> accumulator) + ) :: {:ok, accumulator} + when accumulator: term() + def stream_token_instances_with_unfetched_media_type(initial, reducer) when is_function(reducer, 2) do + __MODULE__ + |> where( + [instance], + not is_nil(instance.metadata) and (is_nil(instance.image_type) or is_nil(instance.animation_type)) + ) + |> select([instance], %{ + contract_address_hash: instance.token_contract_address_hash, + token_id: instance.token_id + }) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Upserts `image_type` and `animation_type` for a batch of token instances. + """ + @spec batch_update_media_types([map()]) :: {non_neg_integer(), nil} + def batch_update_media_types([]), do: {0, nil} + + def batch_update_media_types(updates) do + now = DateTime.utc_now() + + params = + Enum.map(updates, fn update -> + %{ + token_contract_address_hash: update.token_contract_address_hash, + token_id: update.token_id, + image_type: update.image_type, + animation_type: update.animation_type, + inserted_at: now, + updated_at: now + } + end) + + {count, _} = + Repo.insert_all(__MODULE__, params, + on_conflict: {:replace, [:image_type, :animation_type, :updated_at]}, + conflict_target: [:token_id, :token_contract_address_hash] + ) + + {count, nil} + end + @doc """ Checks if a token instance with the given `token_id` and `token_contract_address` has unfetched metadata. diff --git a/apps/explorer/lib/explorer/chain/token/scaled_ui_amount.ex b/apps/explorer/lib/explorer/chain/token/scaled_ui_amount.ex new file mode 100644 index 00000000000..ff4e9afad3c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/token/scaled_ui_amount.ex @@ -0,0 +1,257 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Token.ScaledUIAmount do + @moduledoc """ + Constants of [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) — Scaled UI + Amount Extension for ERC-20 Tokens. + + On chain ERC-8056 is not a standard of its own but a display-only extension of + ERC-20: raw balances, `totalSupply` and `Transfer` stay untouched, and the + token merely exposes an updatable multiplier with 18 decimals of precision + (`1e18` is `1.0`) that integrators apply when rendering amounts: + + uiAmount = rawAmount * uiMultiplier / 1e18 + + It exists to represent stock splits of tokenized real-world assets without + minting to every holder. + + Blockscout still models it as a token type of its own, `ERC-8056`, so that it + can be filtered like any other. It is the only type that no log can reveal — + such a token emits the plain ERC-20 `Transfer` — so it is assigned by reading + `uiMultiplier()` off the contract instead. + + A change of the multiplier is scheduled, not instant. The contract keeps a + triple — current value, pending value and the timestamp the pending value + becomes effective — and `uiMultiplier()` picks between the first two by + comparing `block.timestamp` with `effectiveAt()`. Nothing happens on chain at + the moment a scheduled change takes effect, so there is no log to index for + it: `Explorer.Chain.Token.effective_ui_multiplier/2` mirrors that branch + locally instead. + + The standard is still in Draft, so all of its selectors and topics live here + rather than being spread over the callers. + """ + + require Logger + + import Explorer.Helper, only: [decode_data: 2] + + # 01ffc9a7 = keccak256(supportsInterface(bytes4)), ERC-165 + @supports_interface_signature "01ffc9a7" + # ERC-165 interface id of `IScaledUIAmount`: the XOR of the selectors of its + # functions, and since `uiMultiplier()` is the only one, that selector itself + @interface_id <<0xA6, 0x0B, 0xF1, 0x3D>> + + # a60bf13d = keccak256(uiMultiplier()) + @ui_multiplier_signature "a60bf13d" + # dc767007 = keccak256(newUIMultiplier()) + @new_ui_multiplier_signature "dc767007" + # 97a4064f = keccak256(effectiveAt()) + @effective_at_signature "97a4064f" + + # keccak256(UIMultiplierUpdated(uint256,uint256,uint256)) + @ui_multiplier_updated_event "0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055" + # keccak256(TransferWithUIAmount(address,address,uint256,uint256)) + @transfer_with_ui_amount_event "0x0226a2f5c1ae0e071aeec3d4ebafcefdc5c549be11f40ed27e76e802acccf374" + + @contract_abi [ + %{ + "constant" => true, + "inputs" => [%{"name" => "interfaceId", "type" => "bytes4"}], + "name" => "supportsInterface", + "outputs" => [%{"name" => "", "type" => "bool"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + %{ + "constant" => true, + "inputs" => [], + "name" => "uiMultiplier", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + %{ + "constant" => true, + "inputs" => [], + "name" => "newUIMultiplier", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + %{ + "constant" => true, + "inputs" => [], + "name" => "effectiveAt", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + ] + + @precision Decimal.new("1000000000000000000") + + @doc """ + Applies an ERC-8056 multiplier to a raw amount, giving the amount to display. + + A `nil` multiplier leaves the amount untouched, which covers every token that + does not implement the standard as well as an amount whose multiplier could + not be resolved. + + Callers that hand the raw amount to a client — the API does — must not use + this: the client needs the raw value and the multiplier separately. It exists + for the places that render a final number themselves. + """ + @spec scale(Decimal.t() | nil, Decimal.t() | nil) :: Decimal.t() | nil + def scale(nil, _multiplier), do: nil + def scale(amount, nil), do: amount + + def scale(amount, multiplier) do + amount + |> Decimal.new() + |> Decimal.mult(multiplier) + |> Decimal.div(@precision) + end + + @doc """ + Method id of ERC-165 `supportsInterface(bytes4)`. + + The standard makes ERC-165 detection mandatory, and it is the only reliable + way to tell an ERC-8056 token apart: a contract may well expose a function + named `uiMultiplier()` while meaning something else entirely, and taking that + for ERC-8056 would relabel a perfectly ordinary ERC-20. + """ + @spec supports_interface_signature() :: String.t() + def supports_interface_signature, do: @supports_interface_signature + + @doc """ + ERC-165 interface id of `IScaledUIAmount`, to be passed to `supportsInterface(bytes4)`. + """ + @spec interface_id() :: binary() + def interface_id, do: @interface_id + + @doc """ + Method id of `uiMultiplier()`, the currently effective multiplier. + """ + @spec ui_multiplier_signature() :: String.t() + def ui_multiplier_signature, do: @ui_multiplier_signature + + @doc """ + Method id of `newUIMultiplier()`, the multiplier scheduled to take effect at `effectiveAt()`. + """ + @spec new_ui_multiplier_signature() :: String.t() + def new_ui_multiplier_signature, do: @new_ui_multiplier_signature + + @doc """ + Method id of `effectiveAt()`, the Unix timestamp the pending multiplier becomes effective at. + """ + @spec effective_at_signature() :: String.t() + def effective_at_signature, do: @effective_at_signature + + @doc """ + Method ids of the ERC-8056 getters, in the order they are read. + """ + @spec signatures() :: [String.t()] + def signatures, do: [@ui_multiplier_signature, @new_ui_multiplier_signature, @effective_at_signature] + + @doc """ + First topic of `UIMultiplierUpdated(uint256,uint256,uint256)`. + + The standard requires it to be emitted on every change of the multiplier, so + it is the trigger to re-read the getters of a token. + """ + @spec ui_multiplier_updated_event() :: String.t() + def ui_multiplier_updated_event, do: @ui_multiplier_updated_event + + @doc """ + First topic of `TransferWithUIAmount(address,address,uint256,uint256)`. + + Deliberately not parsed as a token transfer: the standard emits it *in + addition to* the regular ERC-20 `Transfer`, so treating it as a transfer of + its own would double count every movement of an ERC-8056 token. It is kept + here to document that and to guard the behaviour with a test. + """ + @spec transfer_with_ui_amount_event() :: String.t() + def transfer_with_ui_amount_event, do: @transfer_with_ui_amount_event + + @doc """ + ABI of the ERC-8056 getters, to be appended to the ABI token metadata is read with. + """ + @spec contract_abi() :: [map()] + def contract_abi, do: @contract_abi + + @doc """ + Decodes a `UIMultiplierUpdated` log into `t:Explorer.Chain.Token.UIMultiplierChange.t/0` parameters, or returns `nil` for any other log and for one that cannot be decoded. + + All three parameters of the event are non-indexed, so a single log carries the + whole new state of the schedule and no `eth_call` is needed to record it. + """ + @spec parse_ui_multiplier_updated(map()) :: map() | nil + def parse_ui_multiplier_updated(%{first_topic: @ui_multiplier_updated_event} = log), + do: change_params(log) + + def parse_ui_multiplier_updated(_log), do: nil + + @doc """ + Same as `parse_ui_multiplier_updated/1` but without checking the topic, for a + caller that already selected `UIMultiplierUpdated` logs — a query filtering on + the first topic, say, where the topic is a `t:Explorer.Chain.Hash.Full.t/0` + rather than the string this module keeps. + """ + @spec parse_known_ui_multiplier_updated(map()) :: map() | nil + def parse_known_ui_multiplier_updated(log), do: change_params(log) + + @doc """ + Converts the Unix timestamp `effectiveAt()` answers with into the datetime the schema stores. + + Microsecond precision is forced on purpose: `DateTime.from_unix/1` yields + second precision, which a `:utc_datetime_usec` field rejects outright on + `Repo.insert_all/3` — that path dumps values instead of casting them the way a + changeset does. + """ + @spec effective_at_from_unix(integer()) :: {:ok, DateTime.t()} | {:error, atom()} + def effective_at_from_unix(timestamp) do + with {:ok, effective_at} <- DateTime.from_unix(timestamp) do + {:ok, %{effective_at | microsecond: {0, 6}}} + end + end + + defp change_params(log) do + [old_multiplier, new_multiplier, effective_at_timestamp] = + decode_data(log.data, [{:uint, 256}, {:uint, 256}, {:uint, 256}]) + + case effective_at_from_unix(effective_at_timestamp) do + {:ok, effective_at} -> + %{ + token_contract_address_hash: log.address_hash, + block_number: log.block_number, + block_hash: log.block_hash, + transaction_hash: log.transaction_hash, + log_index: log.index, + old_multiplier: Decimal.new(old_multiplier), + new_multiplier: Decimal.new(new_multiplier), + effective_at: effective_at + } + + {:error, _reason} -> + Logger.error(fn -> + "Invalid effectiveAt #{effective_at_timestamp} in UIMultiplierUpdated of #{log.address_hash}" + end) + + nil + end + rescue + error -> + Logger.error(fn -> + [ + "Unknown UIMultiplierUpdated format: #{inspect(log)}, error: ", + Exception.format(:error, error, __STACKTRACE__) + ] + end) + + nil + end +end diff --git a/apps/explorer/lib/explorer/chain/token/ui_multiplier_change.ex b/apps/explorer/lib/explorer/chain/token/ui_multiplier_change.ex new file mode 100644 index 00000000000..9aa59e788d3 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/token/ui_multiplier_change.ex @@ -0,0 +1,494 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Token.UIMultiplierChange do + @moduledoc """ + History of the [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) multiplier + of a token, one row per `UIMultiplierUpdated` log. + + `Explorer.Chain.Token` only carries the multiplier a token has *now*, which is + all that is needed to display balances. Amounts that belong to a moment in the + past — the amount of a token transfer, above all — have to be displayed with + the multiplier that was in force back then, and that is what this table + answers. + + Resolving a moment takes both coordinates of a change, because a change is + announced before it applies: + + * the log that announced it fixes *from when the schedule is known*, ordered + by `{block_number, log_index}`; + * `effective_at` fixes *from when the new value applies*. + + So the multiplier at some point is the value of the last change announced at + or before that point, picking `new_multiplier` once `effective_at` has passed + and `old_multiplier` until then — the same branch the contract itself takes. + Keeping both values also makes a change that gets superseded before maturing + resolve correctly: it is simply never the last one announced. + + For an honest token the history is a handful of rows — the multiplier exists + for rare events such as stock splits — so `for_tokens/2` loads whole histories + at once and `at/4` resolves in memory rather than querying per token transfer. + Nothing on chain enforces that restraint, though, so a token is refused past + `@max_changes_per_token` rows and no longer resolved at all: without the cap a + contract could emit the event in a loop and make every API page holding one of + its transfers load an arbitrarily large history. + + Rows carry the hash of the block their log came from and are only resolved + while that block is consensus, so a reorg that takes the event away takes its + effect on displayed amounts with it. + """ + + use Explorer.Schema + + require Logger + + alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.Chain.{Block, Hash, Token, TokenTransfer, Transaction} + + @typedoc """ + One row of the history as `paginated_for_token/2` returns it: the change + itself plus the timestamp of the block that announced it. + """ + @type page_entry :: %{ + block_number: Block.block_number(), + block_hash: Hash.Full.t(), + timestamp: DateTime.t(), + transaction_hash: Hash.Full.t() | nil, + log_index: non_neg_integer(), + old_multiplier: Decimal.t(), + new_multiplier: Decimal.t(), + effective_at: DateTime.t() + } + + @max_changes_per_token 1_000 + + @required_attrs ~w(token_contract_address_hash block_number block_hash log_index old_multiplier new_multiplier effective_at)a + + # The hash of the transaction the log came from is not part of what resolves a + # multiplier, it is carried for display only, and rows recorded before the + # column existed do not have it — hence optional rather than required. + @optional_attrs ~w(transaction_hash)a + + @primary_key false + typed_schema "token_ui_multiplier_changes" do + field(:block_number, :integer, primary_key: true, null: false) :: Block.block_number() + field(:log_index, :integer, primary_key: true, null: false) + field(:transaction_hash, Hash.Full, null: true) + field(:old_multiplier, :decimal, null: false) + field(:new_multiplier, :decimal, null: false) + field(:effective_at, :utc_datetime_usec, null: false) + + belongs_to( + :block, + Block, + foreign_key: :block_hash, + references: :hash, + type: Hash.Full, + null: false + ) + + belongs_to( + :token, + Token, + foreign_key: :token_contract_address_hash, + primary_key: true, + references: :contract_address_hash, + type: Hash.Address, + null: false + ) + + timestamps() + end + + @doc false + @spec changeset(t() | Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = change, attrs \\ %{}) do + change + |> cast(attrs, @required_attrs ++ @optional_attrs) + |> validate_required(@required_attrs) + |> unique_constraint([:token_contract_address_hash, :block_number, :log_index]) + end + + @doc """ + Records the given changes, as parsed by + `Explorer.Chain.Token.ScaledUIAmount.parse_ui_multiplier_updated/1`. + + Replaying a log, or a reorg putting a different one at the same position, has + to converge on the current content of the block, so an existing row is + overwritten rather than kept. + + The hashes of a change may arrive either as `0x`-prefixed strings or as + already cast structs, depending on which producer parsed the log, and both + shapes are accepted. + """ + @spec insert_changes([map()]) :: {non_neg_integer(), nil} + def insert_changes([]), do: {0, nil} + + def insert_changes(changes) do + now = DateTime.utc_now() + + changes + |> Enum.flat_map(&cast_hashes/1) + |> reject_over_cap() + |> Enum.map(&Map.merge(&1, %{inserted_at: now, updated_at: now})) + |> insert_entries() + end + + defp insert_entries([]), do: {0, nil} + + defp insert_entries(entries) do + Repo.safe_insert_all(__MODULE__, entries, + on_conflict: + {:replace, [:block_hash, :transaction_hash, :old_multiplier, :new_multiplier, :effective_at, :updated_at]}, + conflict_target: [:token_contract_address_hash, :block_number, :log_index] + ) + end + + # `Indexer.Transform.TokenTransfers` takes the hashes straight off the log, + # where they are still the strings the node answered with, while + # `Explorer.Migrator.BackfillScaledUIAmountTokens` reads its logs through Ecto + # and gets them as structs. `insert_all/3` dumps values rather than casting + # them the way a changeset would, and dumping a string raises — taking the + # caller down with it, along with everything else it was about to write — so + # both shapes are cast here, at the one point both producers go through. + defp cast_hashes(change) do + with {:ok, token_contract_address_hash} <- Hash.Address.cast(change.token_contract_address_hash), + {:ok, block_hash} <- Hash.Full.cast(change.block_hash), + {:ok, transaction_hash} <- cast_transaction_hash(change) do + [ + Map.merge(change, %{ + token_contract_address_hash: token_contract_address_hash, + block_hash: block_hash, + transaction_hash: transaction_hash + }) + ] + else + :error -> + Logger.error(fn -> + "Refusing an ERC-8056 multiplier change that carries an unparsable hash: #{inspect(change)}" + end) + + [] + end + end + + # A log of a chain that keeps some of them outside a transaction — Celo does — + # has no transaction hash, and the column it is displayed from is nullable for + # that reason as much as for the rows recorded before it existed. A change + # that does not carry the key at all is treated the same way rather than + # taking down the batch it arrived in. + defp cast_transaction_hash(%{transaction_hash: transaction_hash}) when not is_nil(transaction_hash), + do: Hash.Full.cast(transaction_hash) + + defp cast_transaction_hash(_change), do: {:ok, nil} + + defp reject_over_cap(changes) do + counts = recorded_counts(changes) + + Enum.reject(changes, &over_cap?(&1, counts)) + end + + defp over_cap?(change, counts) do + if Map.get(counts, change.token_contract_address_hash, 0) >= @max_changes_per_token do + Logger.warning(fn -> + "Refusing an ERC-8056 multiplier change of #{change.token_contract_address_hash}: " <> + "already at the #{@max_changes_per_token} row cap" + end) + + true + else + false + end + end + + defp recorded_counts(changes) do + hashes = changes |> Enum.map(& &1.token_contract_address_hash) |> Enum.uniq() + + __MODULE__ + |> where([change], change.token_contract_address_hash in ^hashes) + |> group_by([change], change.token_contract_address_hash) + |> select([change], {change.token_contract_address_hash, count(change.log_index)}) + |> Repo.all() + |> Map.new() + end + + @doc """ + Loads the full multiplier history of the given token contracts, grouped by + contract and ordered the way `at/4` expects. + + Returns an empty map without touching the database when no contract is given, + which is the case for every page that has no ERC-8056 token on it. + """ + @spec for_tokens([Hash.Address.t()], keyword()) :: %{Hash.Address.t() => [t()]} + def for_tokens(token_contract_address_hashes, options \\ []) + + def for_tokens([], _options), do: %{} + + def for_tokens(token_contract_address_hashes, options) do + repo = Chain.select_repo(options) + + case resolvable_tokens(token_contract_address_hashes, repo) do + [] -> + %{} + + hashes -> + __MODULE__ + |> join(:inner, [change], block in assoc(change, :block)) + |> where([change, block], change.token_contract_address_hash in ^hashes and block.consensus == true) + |> order_by([change], asc: change.block_number, asc: change.log_index) + |> repo.all() + |> Enum.group_by(& &1.token_contract_address_hash) + end + end + + defp resolvable_tokens(token_contract_address_hashes, repo) do + __MODULE__ + |> where([change], change.token_contract_address_hash in ^token_contract_address_hashes) + |> group_by([change], change.token_contract_address_hash) + |> having([change], count(change.log_index) <= @max_changes_per_token) + |> select([change], change.token_contract_address_hash) + |> repo.all() + end + + @doc """ + Number of recorded changes past which a token stops being resolved at all. + + See the moduledoc for why the ceiling exists; it is exposed so that the API + can spell out what happens to the amounts of a token that crosses it. + """ + @spec max_changes_per_token() :: pos_integer() + def max_changes_per_token, do: @max_changes_per_token + + @doc """ + Returns one page of the multiplier history of a single token, newest first, + each row carrying the timestamp of the block that announced it. + + Unlike `for_tokens/2` this is a plain listing: the cap that stops a token from + being *resolved* does not apply, since nothing here is loaded per amount, and + the whole history remains visible however long it grows. + + Changes that are announced but not yet in force are part of the answer — the + schedule is exactly what makes this history worth showing. + + `:paging_options` pages with the `{block_number, log_index}` of the last row + of the previous page. + """ + @spec paginated_for_token(Hash.Address.t(), keyword()) :: [page_entry()] + def paginated_for_token(token_contract_address_hash, options \\ []) do + paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) + + token_contract_address_hash + |> consensus_changes() + |> page_changes(paging_options) + |> order_by([change], desc: change.block_number, desc: change.log_index) + |> limit(^paging_options.page_size) + |> select([change, block], %{ + block_number: change.block_number, + block_hash: change.block_hash, + timestamp: block.timestamp, + transaction_hash: change.transaction_hash, + log_index: change.log_index, + old_multiplier: change.old_multiplier, + new_multiplier: change.new_multiplier, + effective_at: change.effective_at + }) + |> Chain.select_repo(options).all() + end + + @doc """ + Counts the multiplier changes of a token that `paginated_for_token/2` lists. + + Counted on request rather than kept in a cached counter: the rows sit behind + the leading column of the primary key and a token has at most a handful of + them, `@max_changes_per_token` being the hard ceiling. + """ + @spec count_for_token(Hash.Address.t(), keyword()) :: non_neg_integer() + def count_for_token(token_contract_address_hash, options \\ []) do + token_contract_address_hash + |> consensus_changes() + |> select([change], count(change.log_index)) + |> Chain.select_repo(options).one() + end + + # Rows are left in place when the block that carried the log is reorged out, + # so consensus is what separates what happened from what was rolled back. + defp consensus_changes(token_contract_address_hash) do + __MODULE__ + |> join(:inner, [change], block in assoc(change, :block)) + |> where( + [change, block], + change.token_contract_address_hash == ^token_contract_address_hash and block.consensus == true + ) + end + + defp page_changes(query, %PagingOptions{key: nil}), do: query + + defp page_changes(query, %PagingOptions{key: {block_number, log_index}}) do + where( + query, + [change], + fragment("(?, ?) < (?, ?)", change.block_number, change.log_index, ^block_number, ^log_index) + ) + end + + defp page_changes(query, _paging_options), do: query + + @doc """ + Returns the multiplier that was in force at the given point of the chain, or + `nil` when it cannot be told. + + `changes` is the history of one token as returned by `for_tokens/2`, and + `block_number`/`log_index` locate the log the amount belongs to, so that a + change announced earlier in the very same block is already accounted for. + + A point that precedes every known change resolves to the value the earliest + change replaced, which is exactly the multiplier the token had since it was + deployed — provided the history reaches that far back. On a chain indexed + before ERC-8056 support existed that holds once + `Explorer.Migrator.BackfillScaledUIAmountTokens` has worked through the + `UIMultiplierUpdated` logs already stored. + """ + @spec at([t()], Block.block_number(), non_neg_integer(), DateTime.t() | nil) :: Decimal.t() | nil + def at(changes, block_number, log_index, timestamp) do + resolve(changes, &announced_by?(&1, block_number, log_index), timestamp) + end + + @doc """ + Returns the multiplier that was in force at the end of the given transaction, + or `nil` when it cannot be told. + + A balance belongs to the whole transaction rather than to a single log of it, + so a change the transaction itself announced counts even when it was announced + after the last log this is resolved from. `log_index` is the position of that + last known log — the transfers the balances were derived from — and it is what + places the transaction among the changes of its own block; the hash then + closes the gap between that log and the end of the transaction, since a change + announced by this very transaction carries it. + + Changes recorded before the hash was stored fall back to `log_index` alone, + which is what `at/4` would have answered. + """ + @spec at_end_of_transaction([t()], Transaction.t(), non_neg_integer()) :: Decimal.t() | nil + def at_end_of_transaction(changes, %Transaction{} = transaction, log_index) do + resolve(changes, &announced_by_end_of?(&1, transaction, log_index), timestamp_of(transaction)) + end + + defp resolve([], _announced?, _timestamp), do: nil + + # without knowing when the amount happened there is no way to tell whether a + # scheduled change had already matured, and a guess would be silently wrong + defp resolve(_changes, _announced?, nil), do: nil + + defp resolve(changes, announced?, timestamp) do + case Enum.take_while(changes, announced?) do + [] -> changes |> hd() |> Map.fetch!(:old_multiplier) + announced -> announced |> List.last() |> in_force_at(timestamp) + end + end + + @doc """ + Fills the virtual `ui_multiplier` of the given token transfers with the + multiplier that was in force when each of them happened. + + Costs one query for the whole collection, and none at all — the usual case — + when it holds no transfer of an ERC-8056 token. Requires `:token` to be + preloaded. + + The moment of a transfer decides whether a scheduled change had already + matured by then. It is taken from `:block` or `:transaction` when either is + preloaded; transfers that come without both — those listed under a single + transaction, which the page carries once rather than per transfer — cost one + more query, for the timestamps of their blocks. + + `nil` entries are passed through, so a collection where a token transfer is + optional can be handed over as is and zipped back afterwards. + """ + @spec put_ui_multipliers([TokenTransfer.t() | nil], keyword()) :: [TokenTransfer.t() | nil] + def put_ui_multipliers(token_transfers, options \\ []) do + scaled_token_hashes = + token_transfers + |> Enum.filter(&match?(%TokenTransfer{token: %Token{ui_multiplier: %Decimal{}}}, &1)) + |> Enum.map(& &1.token.contract_address_hash) + |> Enum.uniq() + + case for_tokens(scaled_token_hashes, options) do + changes_by_token when map_size(changes_by_token) == 0 -> + token_transfers + + changes_by_token -> + block_timestamps = missing_block_timestamps(token_transfers, changes_by_token, options) + + Enum.map(token_transfers, &put_ui_multiplier(&1, changes_by_token, block_timestamps)) + end + end + + defp put_ui_multiplier(%TokenTransfer{token: %Token{} = token} = token_transfer, changes_by_token, block_timestamps) do + case changes_by_token[token.contract_address_hash] do + nil -> + token_transfer + + changes -> + timestamp = timestamp_of(token_transfer) || Map.get(block_timestamps, token_transfer.block_hash) + multiplier = at(changes, token_transfer.block_number, token_transfer.log_index, timestamp) + + %{token_transfer | ui_multiplier: multiplier} + end + end + + defp put_ui_multiplier(token_transfer, _changes_by_token, _block_timestamps), do: token_transfer + + # Reads the timestamps of the blocks of the transfers that are about to be + # resolved but carry no moment of their own, so that a caller loading + # transfers without their block or transaction is not silently answered with + # `nil`. Nothing is queried when every such transfer already has one. + defp missing_block_timestamps(token_transfers, changes_by_token, options) do + token_transfers + |> Enum.filter(&(resolvable?(&1, changes_by_token) and is_nil(timestamp_of(&1)))) + |> Enum.map(& &1.block_hash) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> case do + [] -> + %{} + + block_hashes -> + Block + |> where([block], block.hash in ^block_hashes) + |> select([block], {block.hash, block.timestamp}) + |> Chain.select_repo(options).all() + |> Map.new() + end + end + + defp resolvable?(%TokenTransfer{token: %Token{contract_address_hash: contract_address_hash}}, changes_by_token), + do: Map.has_key?(changes_by_token, contract_address_hash) + + defp resolvable?(_token_transfer, _changes_by_token), do: false + + defp timestamp_of(%TokenTransfer{block: %Block{timestamp: timestamp}}), do: timestamp + + defp timestamp_of(%TokenTransfer{transaction: %Transaction{block_timestamp: timestamp}}) when not is_nil(timestamp), + do: timestamp + + defp timestamp_of(%TokenTransfer{transaction: %Transaction{block: %Block{timestamp: timestamp}}}), do: timestamp + + defp timestamp_of(%Transaction{block_timestamp: timestamp}) when not is_nil(timestamp), do: timestamp + + defp timestamp_of(%Transaction{block: %Block{timestamp: timestamp}}), do: timestamp + + defp timestamp_of(_token_transfer), do: nil + + defp announced_by?(change, block_number, log_index) do + {change.block_number, change.log_index} <= {block_number, log_index} + end + + defp announced_by_end_of?(change, transaction, log_index) do + announced_by?(change, transaction.block_number, log_index) or change.transaction_hash == transaction.hash + end + + defp in_force_at(change, timestamp) do + if DateTime.compare(timestamp, change.effective_at) == :lt do + change.old_multiplier + else + change.new_multiplier + end + end +end diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index 2e34fb99a03..0a8a3dce773 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -59,6 +59,9 @@ defmodule Explorer.Chain.TokenTransfer.Schema do field(:index_in_batch, :integer, virtual: true) field(:reverse_index_in_batch, :integer, virtual: true) field(:token_decimals, :decimal, virtual: true) + # ERC-8056 multiplier in force when this transfer happened, resolved by + # `Explorer.Chain.Token.UIMultiplierChange.put_ui_multipliers/2` + field(:ui_multiplier, :decimal, virtual: true) field(:token_type, :string) field(:block_consensus, :boolean) field(:token_instance, :any, virtual: true) :: Instance.t() | nil @@ -746,26 +749,30 @@ defmodule Explorer.Chain.TokenTransfer do Repo.all(query, timeout: :infinity) end - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp token_transfer_log_filter_dynamic do base_filter = - dynamic( - [log: l], - l.first_topic == ^@constant or - l.first_topic == ^@erc1155_single_transfer_signature or - l.first_topic == ^@erc1155_batch_transfer_signature - ) + Log.topic_filter_dynamic(:first_topic, [ + @constant, + @erc1155_single_transfer_signature, + @erc1155_batch_transfer_signature + ]) case chain_type() do :arc -> + erc20_transfer_filter = Log.topic_filter_dynamic(:first_topic, [@constant]) + + arc_native_coin_filter = + Log.topic_filter_dynamic(:first_topic, [ + @arc_native_coin_transferred_event, + @arc_native_coin_minted_event, + @arc_native_coin_burned_event + ]) + dynamic( [log: l], (^base_filter and - not (l.first_topic == ^@constant and l.address_hash == ^arc_native_token_address())) or - ((l.first_topic == ^@arc_native_coin_transferred_event or - l.first_topic == ^@arc_native_coin_minted_event or - l.first_topic == ^@arc_native_coin_burned_event) and - l.address_hash == ^arc_native_token_system_address()) + not (^erc20_transfer_filter and l.address_hash == ^arc_native_token_address())) or + (^arc_native_coin_filter and l.address_hash == ^arc_native_token_system_address()) ) _ -> @@ -889,32 +896,36 @@ defmodule Explorer.Chain.TokenTransfer do end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount_for_api(%Token{type: "ERC-20"}, nil, nil, nil, _token_ids) do + defp do_token_transfer_amount_for_api(%Token{type: type}, nil, nil, nil, _token_ids) + when type in ["ERC-20", "ERC-8056"] do {:ok, nil} end - defp do_token_transfer_amount_for_api(_token, "ERC-20", nil, nil, _token_ids) do + defp do_token_transfer_amount_for_api(_token, token_type, nil, nil, _token_ids) + when token_type in ["ERC-20", "ERC-8056"] do {:ok, nil} end # TODO: remove this clause along with token transfer denormalization defp do_token_transfer_amount_for_api( - %Token{type: "ERC-20", decimals: decimals}, + %Token{type: type, decimals: decimals}, nil, amount, _amounts, _token_ids - ) do + ) + when type in ["ERC-20", "ERC-8056"] do {:ok, amount, decimals} end defp do_token_transfer_amount_for_api( %Token{decimals: decimals}, - "ERC-20", + token_type, amount, _amounts, _token_ids - ) do + ) + when token_type in ["ERC-20", "ERC-8056"] do {:ok, amount, decimals} end diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 7b6ccdbc0a0..6942ef8ea98 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -244,7 +244,7 @@ defmodule Explorer.Chain.Transaction.Schema do field(:type, :integer) field(:has_error_in_internal_transactions, :boolean) field(:fhe_operations_count, :integer) - field(:has_token_transfers, :boolean, virtual: true) + field(:has_token_transfers, :boolean) field(:internal_transactions, {:array, :map}, virtual: true) # stability virtual fields @@ -430,28 +430,7 @@ defmodule Explorer.Chain.Transaction do """ @type wei_per_gas :: Wei.t() - @derive {Poison.Encoder, - only: [ - :block_number, - :block_timestamp, - :cumulative_gas_used, - :error, - :gas, - :gas_price, - :gas_used, - :index, - :created_contract_code_indexed_at, - :input, - :nonce, - :r, - :s, - :v, - :status, - :value, - :revert_reason - ]} - - @derive {Jason.Encoder, + @derive {JSON.Encoder, only: [ :block_number, :block_timestamp, @@ -904,97 +883,37 @@ defmodule Explorer.Chain.Transaction do ) end - # if to_address's smart_contract is nil reduce to the case when to_address is not loaded + # if to_address's smart_contract is nil, decode without contract ABI def decoded_input_data( - %__MODULE__{ - to_address: %{smart_contract: nil}, - input: input, - hash: hash - }, + %__MODULE__{to_address: %{smart_contract: nil}, input: input, hash: hash}, skip_sig_provider?, options, methods_map, - smart_contract_full_abi_map + _smart_contract_full_abi_map ) do - decoded_input_data( - %__MODULE__{ - to_address: %NotLoaded{}, - input: input, - hash: hash - }, - skip_sig_provider?, - options, - methods_map, - smart_contract_full_abi_map - ) + decode_without_contract_abi(input, hash, skip_sig_provider?, options, methods_map) end - # if to_address's smart_contract is not loaded reduce to the case when to_address is not loaded + # if to_address's smart_contract is not loaded, decode without contract ABI def decoded_input_data( - %__MODULE__{ - to_address: %{smart_contract: %NotLoaded{}}, - input: input, - hash: hash - }, + %__MODULE__{to_address: %{smart_contract: %NotLoaded{}}, input: input, hash: hash}, skip_sig_provider?, options, methods_map, - smart_contract_full_abi_map + _smart_contract_full_abi_map ) do - decoded_input_data( - %__MODULE__{ - to_address: %NotLoaded{}, - input: input, - hash: hash - }, - skip_sig_provider?, - options, - methods_map, - smart_contract_full_abi_map - ) + decode_without_contract_abi(input, hash, skip_sig_provider?, options, methods_map) end - # if to_address is not loaded try decoding by method candidates in the DB + # if to_address is not loaded, decode without contract ABI def decoded_input_data( - %__MODULE__{ - to_address: %NotLoaded{}, - input: %{bytes: <<method_id::binary-size(4), _::binary>> = data} = input, - hash: hash - }, + %__MODULE__{to_address: %NotLoaded{}, input: input, hash: hash}, skip_sig_provider?, options, methods_map, _smart_contract_full_abi_map ) do - {:ok, method_id} = MethodIdentifier.cast(method_id) - methods = check_methods_cache(method_id, methods_map, options) - - candidates = - methods - |> Enum.flat_map(fn candidate -> - case do_decoded_input_data( - data, - [candidate.abi], - hash - ) do - {:ok, _, _, _} = decoded -> [decoded] - _ -> [] - end - end) - - {:error, :contract_not_verified, - if(candidates == [], do: decode_function_call_via_sig_provider(input, hash, skip_sig_provider?), else: candidates)} - end - - # if to_address is not loaded and input is not a method call return error - def decoded_input_data( - %__MODULE__{to_address: %NotLoaded{}}, - _, - _, - _, - _ - ) do - {:error, :contract_not_verified, []} + decode_without_contract_abi(input, hash, skip_sig_provider?, options, methods_map) end def decoded_input_data( @@ -1085,6 +1004,30 @@ defmodule Explorer.Chain.Transaction do end end + defp decode_without_contract_abi( + %{bytes: <<method_id::binary-size(4), _::binary>> = data} = input, + hash, + skip_sig_provider?, + options, + methods_map + ) do + {:ok, method_id} = MethodIdentifier.cast(method_id) + methods = check_methods_cache(method_id, methods_map, options) + + candidates = + Enum.flat_map(methods, fn candidate -> + case do_decoded_input_data(data, [candidate.abi], hash) do + {:ok, _, _, _} = decoded -> [decoded] + _ -> [] + end + end) + + {:error, :contract_not_verified, + if(candidates == [], do: decode_function_call_via_sig_provider(input, hash, skip_sig_provider?), else: candidates)} + end + + defp decode_without_contract_abi(_, _, _, _, _), do: {:error, :contract_not_verified, []} + defp do_decoded_input_data(data, full_abi, hash) do with {:ok, {selector, values}} <- find_and_decode(full_abi, data, hash), {:ok, mapping} <- selector_mapping(selector, values, hash), diff --git a/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex b/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex index 017f5726ae0..d995237e265 100644 --- a/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex +++ b/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex @@ -10,7 +10,7 @@ defmodule Explorer.Chain.Transaction.History.TransactionStats do alias Explorer.Chain - @derive {Jason.Encoder, + @derive {JSON.Encoder, except: [ :__meta__ ]} diff --git a/apps/explorer/lib/explorer/chain/transaction/state_change.ex b/apps/explorer/lib/explorer/chain/transaction/state_change.ex index 8fe2e18bb6d..9655850f00a 100644 --- a/apps/explorer/lib/explorer/chain/transaction/state_change.ex +++ b/apps/explorer/lib/explorer/chain/transaction/state_change.ex @@ -17,14 +17,25 @@ defmodule Explorer.Chain.Transaction.StateChange do DenormalizationHelper, Hash, InternalTransaction, + Token, TokenTransfer, Transaction, Wei } + alias Explorer.Chain.Token.UIMultiplierChange alias Explorer.Chain.Transaction.StateChange - defstruct [:coin_or_token_transfers, :address, :token_id, :balance_before, :balance_after, :balance_diff, :miner?] + defstruct [ + :coin_or_token_transfers, + :address, + :token_id, + :balance_before, + :balance_after, + :balance_diff, + :miner?, + :ui_multiplier + ] @type t :: %__MODULE__{ coin_or_token_transfers: :coin | [TokenTransfer.t()], @@ -33,7 +44,8 @@ defmodule Explorer.Chain.Transaction.StateChange do balance_before: Wei.t() | Decimal.t(), balance_after: Wei.t() | Decimal.t(), balance_diff: Wei.t() | Decimal.t(), - miner?: boolean() + miner?: boolean(), + ui_multiplier: Decimal.t() | nil } @type coin_balances_map :: %{Hash.Address.t() => {Address.t(), Wei.t()}} @@ -424,7 +436,7 @@ defmodule Explorer.Chain.Transaction.StateChange do token_type = if DenormalizationHelper.tt_denormalization_finished?(), do: transfer.token_type, else: transfer.token.type - if token_type not in ["ERC-20", "ZRC-2"] or has_diff?(balance_diff) do + if token_type not in ["ERC-20", "ERC-8056", "ZRC-2"] or has_diff?(balance_diff) do %StateChange{ coin_or_token_transfers: transfers, address: address, @@ -439,4 +451,68 @@ defmodule Explorer.Chain.Transaction.StateChange do |> Enum.reject(&is_nil/1) |> Enum.sort_by(fn state_change -> to_string(state_change.address && state_change.address.hash) end) end + + @doc """ + Fills the `ui_multiplier` of the given token state changes with the + [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) multiplier that was in + force at the end of the transaction. + + A balance is a state of the transaction as a whole, so unlike the amount of a + single transfer it is resolved against the last log of the transaction: a + change the transaction announced itself applies to the balances it leaves + behind. + + Costs one query for the whole transaction, and none at all — the usual case — + when none of the tokens involved implements ERC-8056. Requires `:token` to be + preloaded on the transfers of the state changes and on `transaction`, whose + block timestamp decides whether a scheduled change had already matured. + """ + @spec put_ui_multipliers([t()], Transaction.t(), keyword()) :: [t()] + def put_ui_multipliers(state_changes, %Transaction{} = transaction, options \\ []) do + scaled_token_hashes = + state_changes + |> Enum.map(&scaled_token_hash/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + case UIMultiplierChange.for_tokens(scaled_token_hashes, options) do + changes_by_token when map_size(changes_by_token) == 0 -> + state_changes + + changes_by_token -> + log_index = last_log_index(transaction) + + Enum.map(state_changes, &put_ui_multiplier(&1, transaction, log_index, changes_by_token)) + end + end + + defp put_ui_multiplier(%StateChange{} = state_change, transaction, log_index, changes_by_token) do + with token_contract_address_hash when not is_nil(token_contract_address_hash) <- scaled_token_hash(state_change), + changes when not is_nil(changes) <- changes_by_token[token_contract_address_hash] do + %{state_change | ui_multiplier: UIMultiplierChange.at_end_of_transaction(changes, transaction, log_index)} + else + _ -> state_change + end + end + + defp scaled_token_hash(%StateChange{coin_or_token_transfers: [{_direction, transfer} | _]}) do + case transfer.token do + %Token{ui_multiplier: %Decimal{}, contract_address_hash: contract_address_hash} -> contract_address_hash + _ -> nil + end + end + + defp scaled_token_hash(_state_change), do: nil + + # The transfers the balances were derived from are the part of the transaction + # known here; `UIMultiplierChange.at_end_of_transaction/3` reaches the rest of + # it through the hash the changes carry. + defp last_log_index(%Transaction{token_transfers: token_transfers}) when is_list(token_transfers) do + token_transfers + |> Enum.map(& &1.log_index) + |> Enum.reject(&is_nil/1) + |> Enum.max(fn -> 0 end) + end + + defp last_log_index(_transaction), do: 0 end diff --git a/apps/explorer/lib/explorer/chain/wei.ex b/apps/explorer/lib/explorer/chain/wei.ex index bb2cd0fba94..fba274fb0ae 100644 --- a/apps/explorer/lib/explorer/chain/wei.ex +++ b/apps/explorer/lib/explorer/chain/wei.ex @@ -333,9 +333,9 @@ defimpl Inspect, for: Explorer.Chain.Wei do end end -defimpl Jason.Encoder, for: Explorer.Chain.Wei do - def encode(wei, opts) do +defimpl JSON.Encoder, for: Explorer.Chain.Wei do + def encode(wei, encoder) do # changed since it's needed to return wei value (which is big number) as string - Jason.Encode.struct(wei.value, opts) + JSON.Encoder.encode(wei.value, encoder) end end diff --git a/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex index d0629a12a66..c7f22782f55 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex @@ -115,27 +115,26 @@ defmodule Explorer.Chain.Zilliqa.Zrc2.TokenTransfer do } ] def read_block_logs(block_number, transfer_events) do - Repo.all( - from( - l in Log, - inner_join: b in Block, - on: b.hash == l.block_hash and b.consensus == true, - left_join: a in TokenAdapter, - on: a.zrc2_address_hash == l.address_hash, - where: l.block_number == ^block_number and l.first_topic in ^transfer_events, - select: %{ - first_topic: l.first_topic, - data: l.data, - address_hash: l.address_hash, - transaction_hash: l.transaction_hash, - index: l.index, - block_number: l.block_number, - block_hash: l.block_hash, - adapter_address_hash: a.adapter_address_hash - } - ), - timeout: :infinity + Log + |> join(:inner, [l], b in Block, on: b.number == l.block_number and b.consensus == true) + |> Log.join_transaction_query() + |> Log.join_address_mapping_query() + |> join(:left, [l], a in TokenAdapter, + on: a.zrc2_address_hash == coalesce(l.address_hash, as(:address_mapping).address_hash) ) + |> where([l], l.block_number == ^block_number) + |> Log.filter_by_topic_query(:first_topic, transfer_events) + |> select([l, b, t, _am, a], %{ + first_topic: l.first_topic, + data: l.data, + address_hash: coalesce(l.address_hash, as(:address_mapping).address_hash), + transaction_hash: t.hash, + index: l.index, + block_number: l.block_number, + block_hash: b.hash, + adapter_address_hash: a.adapter_address_hash + }) + |> Repo.all(timeout: :infinity) end @doc """ diff --git a/apps/explorer/lib/explorer/chain_spec/genesis_data.ex b/apps/explorer/lib/explorer/chain_spec/genesis_data.ex index 5bf9bf4a1ed..6ee20772f8c 100644 --- a/apps/explorer/lib/explorer/chain_spec/genesis_data.ex +++ b/apps/explorer/lib/explorer/chain_spec/genesis_data.ex @@ -164,20 +164,20 @@ defmodule Explorer.ChainSpec.GenesisData do end # Reads and parses JSON data from a file. - @spec fetch_from_file(binary()) :: {:ok, list() | map()} | {:error, Jason.DecodeError.t()} + @spec fetch_from_file(binary()) :: {:ok, list() | map()} | {:error, Exception.t()} # sobelow_skip ["Traversal"] defp fetch_from_file(path) do with {:ok, data} <- File.read(path) do - Jason.decode(data) + Utils.JSON.decode(data) end end # Fetches JSON data from a provided URL. - @spec fetch_from_url(binary()) :: {:ok, list() | map()} | {:error, Jason.DecodeError.t() | any()} + @spec fetch_from_url(binary()) :: {:ok, list() | map()} | {:error, Exception.t() | any()} defp fetch_from_url(url) do case HttpClient.get(url, [], timeout: 60_000, recv_timeout: 60_000) do {:ok, %{body: body, status_code: 200}} -> - {:ok, Jason.decode!(body)} + {:ok, Utils.JSON.decode!(body)} reason -> {:error, reason} @@ -305,7 +305,7 @@ defmodule Explorer.ChainSpec.GenesisData do constructor_arguments: nil, external_libraries: [], secondary_sources: [], - abi: Jason.decode!(contract["abi"]), + abi: Utils.JSON.decode!(contract["abi"]), verified_via_sourcify: false, verified_via_eth_bytecode_db: false, verified_via_verifier_alliance: false, diff --git a/apps/explorer/lib/explorer/env_var_translator.ex b/apps/explorer/lib/explorer/env_var_translator.ex index 9dc388cc2a1..c8be22e5f39 100644 --- a/apps/explorer/lib/explorer/env_var_translator.ex +++ b/apps/explorer/lib/explorer/env_var_translator.ex @@ -4,8 +4,6 @@ defmodule Explorer.EnvVarTranslator do The module for transformation of environment variables """ - alias Poison.Parser - @spec map_array_env_var_to_list(atom()) :: list() def map_array_env_var_to_list(config_name) do env_var = Application.get_env(:block_scout_web, config_name) @@ -13,7 +11,8 @@ defmodule Explorer.EnvVarTranslator do if env_var do try do env_var - |> Parser.parse!(%{keys: :atoms!}) + |> Utils.JSON.decode!() + |> atomize_new_tag_entries() rescue _ -> [] @@ -22,4 +21,11 @@ defmodule Explorer.EnvVarTranslator do [] end end + + defp atomize_new_tag_entries(entries) when is_list(entries) do + Enum.map(entries, fn + %{"tag" => tag, "title" => title} -> %{tag: tag, title: title} + entry -> entry + end) + end end diff --git a/apps/explorer/lib/explorer/eth_rpc.ex b/apps/explorer/lib/explorer/eth_rpc.ex index 80d71ae2650..84c0a210469 100644 --- a/apps/explorer/lib/explorer/eth_rpc.ex +++ b/apps/explorer/lib/explorer/eth_rpc.ex @@ -3,6 +3,8 @@ defmodule Explorer.EthRPC do @moduledoc """ Ethereum JSON RPC methods logic implementation. """ + + import EthereumJSONRPC, only: [quantity_to_integer: 1] import Explorer.EthRpcHelper use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -16,6 +18,7 @@ defmodule Explorer.EthRPC do DenormalizationHelper, Hash, Hash.Address, + Log, Transaction, Transaction.Status, Wei @@ -24,7 +27,20 @@ defmodule Explorer.EthRPC do alias Explorer.Chain.Cache.{BlockNumber, GasPriceOracle} alias Explorer.Etherscan.{Blocks, Logs} + # Error codes + @invalid_request_code -32600 + @method_not_found_code -32601 + @invalid_param_code -32602 + @internal_error_code -32603 + + # Error messages @nil_gas_price_message "Gas price is not estimated yet" + @invalid_block_hash_message "Invalid block hash" + @invalid_block_number_message "Invalid block number" + @incorrect_number_of_params_message "Incorrect number of params" + @something_went_wrong_message "Something went wrong" + + @type error :: %{code: integer(), message: String.t()} @methods %{ "eth_blockNumber" => %{ @@ -651,6 +667,199 @@ defmodule Explorer.EthRPC do } } + @extended_proxy_methods %{ + "net_version" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "net_version", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "1"} + """ + }, + "net_listening" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "net_listening", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": true} + """ + }, + "net_peerCount" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "net_peerCount", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x2"} + """ + }, + "web3_clientVersion" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "web3_clientVersion", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "Geth/v1.13.0"} + """ + }, + "web3_sha3" => %{ + arity: 1, + params_validators: [&hex_data_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "web3_sha3", "params": ["0x68656c6c6f20776f726c64"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x47173285a8d7341e5e972fc677286384f802f8ef42a5ec5f03bbfa254cb01fad"} + """ + }, + "eth_syncing" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_syncing", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": false} + """ + }, + "eth_protocolVersion" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_protocolVersion", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x41"} + """ + }, + "eth_getBlockTransactionCountByHash" => %{ + arity: 1, + params_validators: [&hash_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getBlockTransactionCountByHash", "params": ["0x2980314632a35ff83ef1f26a2a972259dca49353ed9368a04f21bcd7a5512231"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x14"} + """ + }, + "eth_getBlockTransactionCountByNumber" => %{ + arity: 1, + params_validators: [&block_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getBlockTransactionCountByNumber", "params": ["latest"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x14"} + """ + }, + "eth_getTransactionByBlockHashAndIndex" => %{ + arity: 2, + params_validators: [&hash_validator/1, &integer_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getTransactionByBlockHashAndIndex", "params": ["0x2980314632a35ff83ef1f26a2a972259dca49353ed9368a04f21bcd7a5512231", "0x0"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": null} + """ + }, + "eth_getTransactionByBlockNumberAndIndex" => %{ + arity: 2, + params_validators: [&block_validator/1, &integer_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getTransactionByBlockNumberAndIndex", "params": ["latest", "0x0"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": null} + """ + }, + "eth_getUncleByBlockHashAndIndex" => %{ + arity: 2, + params_validators: [&hash_validator/1, &integer_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getUncleByBlockHashAndIndex", "params": ["0x2980314632a35ff83ef1f26a2a972259dca49353ed9368a04f21bcd7a5512231", "0x0"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": null} + """ + }, + "eth_getUncleByBlockNumberAndIndex" => %{ + arity: 2, + params_validators: [&block_validator/1, &integer_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getUncleByBlockNumberAndIndex", "params": ["latest", "0x0"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": null} + """ + }, + "eth_getUncleCountByBlockHash" => %{ + arity: 1, + params_validators: [&hash_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getUncleCountByBlockHash", "params": ["0x2980314632a35ff83ef1f26a2a972259dca49353ed9368a04f21bcd7a5512231"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x0"} + """ + }, + "eth_getUncleCountByBlockNumber" => %{ + arity: 1, + params_validators: [&block_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getUncleCountByBlockNumber", "params": ["latest"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x0"} + """ + }, + "eth_feeHistory" => %{ + arity: 2..3, + params_validators: [&integer_validator/1, &block_validator/1, &fee_history_reward_percentiles_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_feeHistory", "params": ["0x4", "latest", [25, 50, 75]]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": {"oldestBlock": "0x1", "baseFeePerGas": [], "gasUsedRatio": [], "reward": []}} + """ + }, + "eth_blobBaseFee" => %{ + arity: 0, + params_validators: [], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_blobBaseFee", "params": []} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": "0x1"} + """ + }, + "eth_getBlockReceipts" => %{ + arity: 1, + params_validators: [&hash_or_block_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getBlockReceipts", "params": ["latest"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": []} + """ + }, + "eth_getProof" => %{ + arity: 3, + params_validators: [&address_hash_validator/1, &data_array_validator/1, &block_validator/1], + example: """ + {"id": 0, "jsonrpc": "2.0", "method": "eth_getProof", "params": ["0x0000000000000000000000000000000000000007", ["0x0"], "latest"]} + """, + result: """ + {"id": 0, "jsonrpc": "2.0", "result": {"address": "0x0000000000000000000000000000000000000007"}} + """ + } + } + @index_to_word %{ 0 => "first", 1 => "second", @@ -658,7 +867,6 @@ defmodule Explorer.EthRPC do 3 => "fourth" } - @incorrect_number_of_params "Incorrect number of params." @no_result "No result" @spec responses([map()]) :: [map()] @@ -689,27 +897,48 @@ defmodule Explorer.EthRPC do {:request, {:ok, result}} <- {:request, do_eth_request(request)} do format_success(result, id) else - {:id, :error} -> format_error("id is a required field", 0) - {:request, {:error, message}} -> format_error(message, Map.get(request, "id")) - {:proxy, {:error, message}} -> format_error(message, Map.get(request, "id")) - {:proxy, %{result: result}} -> format_success(result, Map.get(request, "id")) - {:proxy, %{error: error}} -> format_error(error, Map.get(request, "id")) + {:id, :error} -> + format_error("Id is a required field", @invalid_request_code, 0) + + {:request, {:error, %{code: code, message: message}}} -> + format_error(message, code, Map.get(request, "id")) + + {:proxy, {:error, %{code: code, message: message}}} -> + format_error(message, code, Map.get(request, "id")) + + {:proxy, %{result: result}} -> + format_success(result, Map.get(request, "id")) + + # a batched request succeeded on the transport level, but the node returned + # a JSONRPC error object for this particular request + {:proxy, %{error: %{code: _code, message: _message} = error}} -> + format_error(error, Map.get(request, "id")) + + # the request URL is intentionally dropped from the reason to avoid exposing it + {:proxy, {:error, {error, _request_url}}} when error in [:bad_response, :bad_gateway] -> + format_error(inspect(error), @internal_error_code, Map.get(request, "id")) + + {:proxy, {:error, %Mint.TransportError{reason: reason}}} -> + format_error(inspect(reason), @internal_error_code, Map.get(request, "id")) + + {:proxy, {:error, reason}} -> + format_error(inspect(reason), @internal_error_code, Map.get(request, "id")) end end) end defp proxy_method?(%{"jsonrpc" => "2.0", "method" => method, "params" => params, "id" => id}) when is_list(params) and (is_number(id) or is_binary(id) or is_nil(id)) do - with method_definition when not is_nil(method_definition) <- @proxy_methods[method], - {:arity, true} <- {:arity, method_definition[:arity] == length(params)}, + with method_definition when not is_nil(method_definition) <- active_proxy_methods()[method], + {:arity, true} <- {:arity, arity_valid?(method_definition[:arity], length(params))}, :ok <- validate_params(method_definition[:params_validators], params) do true else - {:error, _reason} = error -> - error + {:error, reason} -> + {:error, %{code: @invalid_param_code, message: reason}} {:arity, false} -> - {:error, @incorrect_number_of_params} + {:error, %{code: @invalid_param_code, message: @incorrect_number_of_params_message}} _ -> false @@ -718,6 +947,29 @@ defmodule Explorer.EthRPC do defp proxy_method?(_), do: false + defp active_proxy_methods do + core = if core_proxy_methods_disabled?(), do: %{}, else: @proxy_methods + + if extended_proxy_methods_enabled?() do + Map.merge(core, @extended_proxy_methods) + else + core + end + end + + defp extended_proxy_methods_enabled? do + Application.get_env(:explorer, __MODULE__, []) + |> Keyword.get(:extended_proxy_methods_enabled, false) + end + + defp core_proxy_methods_disabled? do + Application.get_env(:explorer, __MODULE__, []) + |> Keyword.get(:disable_core_proxy_methods, false) + end + + defp arity_valid?(%Range{} = range, params_length), do: params_length in range + defp arity_valid?(arity, params_length) when is_integer(arity), do: arity == params_length + defp validate_params(validators, params) do validators |> Enum.zip(params) @@ -742,6 +994,9 @@ defmodule Explorer.EthRPC do {index, request} when is_map(request) -> [request |> request_to_elixir() |> Map.put(:id, index)] + + _ -> + [] end) with [_ | _] <- to_request, @@ -759,6 +1014,14 @@ defmodule Explorer.EthRPC do else [] -> map + |> Enum.map(fn + {_index, {:error, _}} = elem -> + elem + + {index, elem} -> + {index, {:error, elem}} + end) + |> Enum.into(%{}) {:error, _reason} = error -> map @@ -794,7 +1057,7 @@ defmodule Explorer.EthRPC do @doc """ Handles `eth_getBalance` method """ - @spec eth_get_balance(String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, String.t()} + @spec eth_get_balance(String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, error()} def eth_get_balance(address_param, block_param \\ nil) do with {:address, {:ok, address}} <- {:address, Chain.string_to_address_hash(address_param)}, {:block, {:ok, block}} <- {:block, block_param(block_param)}, @@ -802,41 +1065,41 @@ defmodule Explorer.EthRPC do {:ok, Wei.hex_format(balance)} else {:address, :error} -> - {:error, "Query parameter 'address' is invalid"} + {:error, %{code: @invalid_param_code, message: "Query parameter 'address' is invalid"}} {:block, :error} -> - {:error, "Query parameter 'block' is invalid"} + {:error, %{code: @invalid_param_code, message: "Query parameter 'block' is invalid"}} {:balance, {:error, :not_found}} -> - {:error, "Balance not found"} + {:error, %{code: @invalid_param_code, message: "Balance not found"}} end end @doc """ Handles `eth_gasPrice` method """ - @spec eth_gas_price() :: {:ok, String.t()} | {:error, String.t()} + @spec eth_gas_price() :: {:ok, String.t()} | {:error, error()} def eth_gas_price do case GasPriceOracle.get_gas_prices() do {:ok, gas_prices} -> {:ok, Wei.hex_format(gas_prices[:average][:wei])} _ -> - {:error, @nil_gas_price_message} + {:error, %{code: @internal_error_code, message: @nil_gas_price_message}} end end @doc """ Handles `eth_maxPriorityFeePerGas` method """ - @spec eth_max_priority_fee_per_gas() :: {:ok, String.t()} | {:error, String.t()} + @spec eth_max_priority_fee_per_gas() :: {:ok, String.t()} | {:error, error()} def eth_max_priority_fee_per_gas do case GasPriceOracle.get_gas_prices() do {:ok, gas_prices} -> {:ok, Wei.hex_format(gas_prices[:average][:priority_fee_wei])} _ -> - {:error, @nil_gas_price_message} + {:error, %{code: @internal_error_code, message: @nil_gas_price_message}} end end @@ -851,7 +1114,7 @@ defmodule Explorer.EthRPC do @doc """ Handles `eth_getTransactionByHash` method """ - @spec eth_get_transaction_by_hash(String.t()) :: {:ok, map() | nil} | {:error, String.t()} + @spec eth_get_transaction_by_hash(String.t()) :: {:ok, map() | nil} | {:error, error()} def eth_get_transaction_by_hash(transaction_hash_string) do # `signed_authorizations` are preloaded separately, only for EIP-7702 # (type 4) transactions, to skip the query for every other type. @@ -899,7 +1162,7 @@ defmodule Explorer.EthRPC do @doc """ Handles `eth_getTransactionReceipt` method """ - @spec eth_get_transaction_receipt(String.t()) :: {:ok, map() | nil} | {:error, String.t()} + @spec eth_get_transaction_receipt(String.t()) :: {:ok, map() | nil} | {:error, error()} def eth_get_transaction_receipt(transaction_hash_string) do necessity_by_association = %{block: :optional, logs: :optional} @@ -922,6 +1185,14 @@ defmodule Explorer.EthRPC do defp render_transaction_receipt(transaction) do {:ok, status} = Status.dump(transaction.status) + logs = + transaction.logs + |> Log.preload_block() + |> Log.preload_transaction() + |> Log.preload_address() + |> Log.prepare_data() + |> Log.prepare_first_topic() + props = %{ "blockHash" => transaction.block_hash, @@ -934,8 +1205,8 @@ defmodule Explorer.EthRPC do |> encode_quantity(), "from" => transaction.from_address_hash, "gasUsed" => encode_quantity(transaction.gas_used), - "logs" => Enum.map(transaction.logs, &render_log(&1, transaction)), - "logsBloom" => "0x" <> (transaction.logs |> BloomFilter.logs_bloom() |> Base.encode16(case: :lower)), + "logs" => Enum.map(logs, &render_log(&1, transaction)), + "logsBloom" => "0x" <> (logs |> BloomFilter.logs_bloom() |> Base.encode16(case: :lower)), "status" => encode_quantity(status), "to" => transaction.to_address_hash, "transactionHash" => transaction.hash, @@ -1034,7 +1305,7 @@ defmodule Explorer.EthRPC do render_func.(transaction) else {:transaction_hash, :error} -> - {:error, "Transaction hash is invalid"} + {:error, %{code: @invalid_param_code, message: "Transaction hash is invalid"}} {:transaction, _} -> {:ok, nil} @@ -1059,14 +1330,14 @@ defmodule Explorer.EthRPC do {:ok, logs} else - {:error, message} when is_bitstring(message) -> - {:error, message} + {:error, %{} = error} -> + {:error, error} {:error, :empty} -> {:ok, []} _ -> - {:error, "Something went wrong."} + {:error, %{code: @internal_error_code, message: @something_went_wrong_message}} end end @@ -1126,12 +1397,12 @@ defmodule Explorer.EthRPC do defp cast_block("0x" <> hexadecimal_digits = input) do case Integer.parse(hexadecimal_digits, 16) do {integer, ""} -> {:ok, integer} - _ -> {:error, input <> " is not a valid block number"} + _ -> {:error, %{code: @invalid_param_code, message: input <> " is not a valid block number"}} end end defp cast_block(integer) when is_integer(integer), do: {:ok, integer} - defp cast_block(_), do: {:error, "invalid block number"} + defp cast_block(_), do: {:error, %{code: @invalid_param_code, message: @invalid_block_number_message}} defp address_or_topic_params(filter_options) do address_param = Map.get(filter_options, "address") @@ -1143,7 +1414,9 @@ defmodule Explorer.EthRPC do end end - defp address_and_topics(nil, nil), do: {:error, "Must supply one of address and topics"} + defp address_and_topics(nil, nil), + do: {:error, %{code: @invalid_param_code, message: "Must supply one of address and topics"}} + defp address_and_topics(address, nil), do: {:ok, %{address_hash: address}} defp address_and_topics(nil, topics), do: {:ok, topics} defp address_and_topics(address, topics), do: {:ok, Map.put(topics, :address_hash, address)} @@ -1153,7 +1426,7 @@ defmodule Explorer.EthRPC do defp validate_address(address) do case Address.cast(address) do {:ok, address} -> {:ok, address} - :error -> {:error, "invalid address"} + :error -> {:error, %{code: @invalid_param_code, message: "invalid address"}} end end @@ -1172,7 +1445,7 @@ defmodule Explorer.EthRPC do {:ok, add_operator(with_filter, index)} :error -> - {:error, "invalid topics"} + {:error, %{code: @invalid_param_code, message: "invalid topics"}} end end) end @@ -1210,10 +1483,10 @@ defmodule Explorer.EthRPC do resolve_logs_blocks_range(from_block, to_block) {:block, _} -> - {:error, "Invalid Block Hash"} + {:error, %{code: @invalid_param_code, message: @invalid_block_hash_message}} {:block_hash, _} -> - {:error, "Invalid Block Hash"} + {:error, %{code: @invalid_param_code, message: @invalid_block_hash_message}} end end @@ -1237,7 +1510,7 @@ defmodule Explorer.EthRPC do "blockNumber" => block_number } }) do - with {:ok, parsed_block_number} <- to_number(block_number, "invalid block number"), + with {:ok, parsed_block_number} <- to_number(block_number, @invalid_block_number_message), {:ok, parsed_log_index} <- to_number(log_index, "invalid log index") do {:ok, %{ @@ -1264,27 +1537,20 @@ defmodule Explorer.EthRPC do defp to_block_number("0x" <> number, _) do case Integer.parse(number, 16) do {integer, ""} -> {:ok, integer} - _ -> {:error, "invalid block number"} - end - end - - defp to_block_number(number, _) when is_bitstring(number) do - case Integer.parse(number, 16) do - {integer, ""} -> {:ok, integer} - _ -> {:error, "invalid block number"} + _ -> {:error, %{code: @invalid_param_code, message: @invalid_block_number_message}} end end - defp to_block_number(_, _), do: {:error, "invalid block number"} + defp to_block_number(_, _), do: {:error, %{code: @invalid_param_code, message: @invalid_block_number_message}} defp to_number(number, error_message) when is_bitstring(number) do case Integer.parse(number, 16) do {integer, ""} -> {:ok, integer} - _ -> {:error, error_message} + _ -> {:error, %{code: @invalid_param_code, message: error_message}} end end - defp to_number(_, error_message), do: {:error, error_message} + defp to_number(_, error_message), do: {:error, %{code: @invalid_param_code, message: error_message}} defp max_consensus_block_number do case Chain.max_consensus_block_number() do @@ -1297,12 +1563,17 @@ defmodule Explorer.EthRPC do %{result: result, id: id} end - defp format_error(message, id) do - %{error: message, id: id} + defp format_error(message, code, id) do + %{error: %{code: code, message: message}, id: id} + end + + # passes an already built JSONRPC error object through, keeping the optional `data` field + defp format_error(%{code: _code, message: _message} = error, id) do + %{error: error, id: id} end defp do_eth_request(%{"jsonrpc" => rpc_version}) when rpc_version != "2.0" do - {:error, "invalid rpc version"} + {:error, %{code: @invalid_request_code, message: "invalid rpc version"}} end defp do_eth_request(%{"jsonrpc" => "2.0", "method" => method, "params" => params}) @@ -1313,15 +1584,15 @@ defmodule Explorer.EthRPC do apply(__MODULE__, action, params) else {:correct_arity, _} -> - {:error, "Incorrect number of params."} + {:error, %{code: @invalid_param_code, message: @incorrect_number_of_params_message}} _ -> - {:error, "Action not found."} + {:error, %{code: @method_not_found_code, message: "Method not found."}} end end - defp do_eth_request(%{"params" => _params, "method" => _}) do - {:error, "Invalid params. Params must be a list."} + defp do_eth_request(%{"jsonrpc" => _jsonrpc, "params" => _params, "method" => _}) do + {:error, %{code: @invalid_param_code, message: "Invalid params. Params must be a list."}} end defp do_eth_request(%{"jsonrpc" => jsonrpc, "method" => method}) do @@ -1329,7 +1600,7 @@ defmodule Explorer.EthRPC do end defp do_eth_request(_) do - {:error, "Method, and jsonrpc are required parameters."} + {:error, %{code: @invalid_request_code, message: "Method, and jsonrpc are required parameters."}} end defp get_action(action) do @@ -1354,15 +1625,21 @@ defmodule Explorer.EthRPC do end defp block_param(string_integer) when is_bitstring(string_integer) do - case Integer.parse(string_integer) do - {integer, ""} -> {:ok, integer} - _ -> :error + case quantity_to_integer(string_integer) do + nil -> :error + block_number -> {:ok, block_number} end end defp block_param(nil), do: {:ok, :latest} defp block_param(_), do: :error + @doc """ + Encodes a numeric or binary value as a hex-prefixed quantity string (e.g. `"0x1a"`). + + Returns `nil` when the value is `nil`. + """ + @spec encode_quantity(Decimal.t() | binary() | integer() | nil) :: String.t() | nil def encode_quantity(%Decimal{} = decimal), do: encode_quantity(Decimal.to_integer(decimal)) def encode_quantity(binary) when is_binary(binary) do @@ -1381,10 +1658,12 @@ defmodule Explorer.EthRPC do |> encode_quantity() end - def encode_quantity(value) when is_nil(value) do - nil - end + def encode_quantity(value) when is_nil(value), do: nil + @doc """ + Returns the map of supported Ethereum JSON RPC methods with their metadata. + """ + @spec methods() :: map() def methods, do: @methods defp chain_id, do: :block_scout_web |> Application.get_env(:chain_id) |> Helper.parse_integer() |> encode_quantity() diff --git a/apps/explorer/lib/explorer/eth_rpc_helper.ex b/apps/explorer/lib/explorer/eth_rpc_helper.ex index 298898cce21..2f76435bb96 100644 --- a/apps/explorer/lib/explorer/eth_rpc_helper.ex +++ b/apps/explorer/lib/explorer/eth_rpc_helper.ex @@ -14,6 +14,8 @@ defmodule Explorer.EthRpcHelper do @invalid_bool "Invalid bool" @invalid_hash "Invalid hash" @invalid_hex_data "Invalid hex data" + @invalid_data_array "Invalid data array" + @invalid_fee_history_reward_percentiles "Invalid fee history reward percentiles" @doc """ Validates if address is valid """ @@ -88,6 +90,45 @@ defmodule Explorer.EthRpcHelper do def bool_validator(bool) when is_boolean(bool), do: :ok def bool_validator(_), do: {:error, @invalid_bool} + @doc """ + Validates if value is a valid data array + """ + @spec data_array_validator(list()) :: :ok | {:error, String.t()} + def data_array_validator(data) when is_list(data) do + if Enum.all?(data, &match?({:ok, _}, Data.cast(&1))) do + :ok + else + {:error, @invalid_data_array} + end + end + + def data_array_validator(_), do: {:error, @invalid_data_array} + + @doc """ + Validates if fee history reward percentiles are valid + """ + @spec fee_history_reward_percentiles_validator(list()) :: :ok | {:error, String.t()} + def fee_history_reward_percentiles_validator(reward_percentiles) when is_list(reward_percentiles) do + if sorted?(reward_percentiles) and Enum.all?(reward_percentiles, &valid_percentile?/1) do + :ok + else + {:error, @invalid_fee_history_reward_percentiles} + end + end + + def fee_history_reward_percentiles_validator(_), do: {:error, @invalid_fee_history_reward_percentiles} + + @doc """ + Validates if value is either a block tag/number or block hash + """ + @spec hash_or_block_validator(binary()) :: :ok | {:error, String.t()} + def hash_or_block_validator(value) do + case block_validator(value) do + :ok -> :ok + _ -> hash_validator(value) + end + end + defp validate_optional_address(nil, _), do: :ok defp validate_optional_address(address_hash, field_name) do @@ -119,4 +160,12 @@ defmodule Explorer.EthRpcHelper do _ -> {:error, "Invalid `#{field_name}` data"} end end + + defp sorted?([]), do: true + defp sorted?([_]), do: true + + defp sorted?([first, second | _]) when first > second, do: false + defp sorted?([_ | rest]), do: sorted?(rest) + + defp valid_percentile?(percentile), do: is_number(percentile) and percentile >= 0 and percentile <= 100 end diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index 6704e551270..68342d9a2dc 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -11,7 +11,6 @@ defmodule Explorer.Etherscan do alias Explorer.{Chain, Repo} alias Explorer.Chain.{Address, Block, DenormalizationHelper, Hash, InternalTransaction, TokenTransfer, Transaction} alias Explorer.Chain.Address.{CurrentTokenBalance, TokenBalance} - alias Explorer.Chain.Cache.BackgroundMigrations alias Explorer.Chain.Transaction.History.TransactionStats alias Explorer.Etherscan.Logs @@ -141,10 +140,9 @@ defmodule Explorer.Etherscan do merge(map(it, ^@internal_transaction_fields), %{ block_timestamp: as(:transaction).block_timestamp, transaction_hash: as(:transaction).hash, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), - to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + from_address_hash: as(:from_address_mapping).address_hash, + to_address_hash: as(:to_address_mapping).address_hash, + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) ) else @@ -161,10 +159,9 @@ defmodule Explorer.Etherscan do merge(map(it, ^@internal_transaction_fields), %{ block_timestamp: as(:block).timestamp, transaction_hash: as(:transaction).hash, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), - to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + from_address_hash: as(:from_address_mapping).address_hash, + to_address_hash: as(:to_address_mapping).address_hash, + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) ) end @@ -194,8 +191,7 @@ defmodule Explorer.Etherscan do options |> options_to_directions() |> then(fn directions -> - if BackgroundMigrations.get_empty_internal_transactions_data_finished() and - Enum.member?(directions, :to_address_hash) do + if Enum.member?(directions, :to_address_hash) do directions |> Kernel.--([:created_contract_address_hash, :to_address_hash]) |> Enum.concat([:to]) @@ -276,10 +272,9 @@ defmodule Explorer.Etherscan do merge(map(it, ^@internal_transaction_fields), %{ block_timestamp: transaction.block_timestamp, transaction_hash: transaction.hash, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), - to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + from_address_hash: as(:from_address_mapping).address_hash, + to_address_hash: as(:to_address_mapping).address_hash, + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) ) else @@ -305,10 +300,9 @@ defmodule Explorer.Etherscan do merge(map(it, ^@internal_transaction_fields), %{ block_timestamp: as(:block).timestamp, transaction_hash: as(:transaction).hash, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), - to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + from_address_hash: as(:from_address_mapping).address_hash, + to_address_hash: as(:to_address_mapping).address_hash, + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) ) end @@ -337,10 +331,9 @@ defmodule Explorer.Etherscan do merge(map(it, ^@internal_transaction_fields), %{ block_timestamp: as(:block).timestamp, transaction_hash: as(:transaction).hash, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), - to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + from_address_hash: as(:from_address_mapping).address_hash, + to_address_hash: as(:to_address_mapping).address_hash, + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) ) end @@ -624,7 +617,9 @@ defmodule Explorer.Etherscan do end defp list_erc20_token_transfers(address_hash, contract_address_hash, options) do - "ERC-20" |> base_token_transfers_query(address_hash, contract_address_hash, options) |> Repo.replica().all() + ["ERC-20", "ERC-8056"] + |> base_token_transfers_query(address_hash, contract_address_hash, options) + |> Repo.replica().all() end # Retrieves token transfers filtered by ZRC-2 type with optional address and contract filtering. diff --git a/apps/explorer/lib/explorer/etherscan/logs.ex b/apps/explorer/lib/explorer/etherscan/logs.ex index 1393807b080..728fe8521ca 100644 --- a/apps/explorer/lib/explorer/etherscan/logs.ex +++ b/apps/explorer/lib/explorer/etherscan/logs.ex @@ -6,11 +6,10 @@ defmodule Explorer.Etherscan.Logs do """ - import Ecto.Query, - only: [dynamic: 2, from: 2, join: 5, limit: 2, where: 2, where: 3, subquery: 1, order_by: 3, union_all: 2] + import Ecto.Query alias Explorer.{Chain, Repo} - alias Explorer.Chain.{DenormalizationHelper, Log, Transaction} + alias Explorer.Chain.{DenormalizationHelper, Log} @base_filter %{ from_block: nil, @@ -30,13 +29,13 @@ defmodule Explorer.Etherscan.Logs do @log_fields [ :data, + :compressed_data, :first_topic, + :first_topic_id, :second_topic, :third_topic, :fourth_topic, - :index, - :address_hash, - :transaction_hash + :index ] @default_paging_options %{block_number: nil, log_index: nil} @@ -82,7 +81,7 @@ defmodule Explorer.Etherscan.Logs do # combination query would only affect its first branch. logs_query = Log - |> where([log], log.address_hash == ^address_hash) + |> Log.address_match_query(address_hash) |> where([log], log.block_number >= ^prepared_filter.from_block) |> where([log], log.block_number <= ^prepared_filter.to_block) |> page_logs(paging_options) @@ -92,19 +91,23 @@ defmodule Explorer.Etherscan.Logs do |> join_transaction_data() |> limit(1000) |> fetch_ordered() + |> Log.preload_block() + |> Log.preload_transaction([], Repo.replica()) end # Since address_hash was not present, we know that a topic filter has been # applied. Ordering, paging and the LIMIT are applied inside a subquery over - # `logs` alone, so the planner has to produce at most 1000 logs before - # joining `transactions`. Joining first and limiting afterwards lets the - # planner scan the whole `transactions` block range up front, which is - # prohibitively slow for wide ranges. + # `logs` (with only the consensus check joined), so the planner has to + # produce at most 1000 logs before joining the transaction data. Joining + # first and limiting afterwards lets the planner scan the whole + # `transactions` block range up front, which is prohibitively slow for wide + # ranges. # - # Logs of non-consensus blocks are never deleted, so consensus has to be - # checked before the LIMIT: otherwise a page could come back short, or empty - # with no cursor to continue from, while later consensus logs still exist. - # The check uses the same predicate as `join_transaction_data/1`, so no row + # Logs of non-consensus blocks are not guaranteed to be deleted (see + # `Explorer.Migrator.DeleteNonConsensusLogs`), so consensus has to be checked + # before the LIMIT: otherwise a page could come back short, or empty with no + # cursor to continue from, while later consensus logs still exist. The check + # uses the same join and predicate as `join_transaction_data/1`, so no row # that makes it into the page is dropped by the outer join afterwards. def list_logs(filter, paging_options) do paging_options = if is_nil(paging_options), do: @default_paging_options, else: paging_options @@ -126,72 +129,82 @@ defmodule Explorer.Etherscan.Logs do end # Keeps only logs whose consensus predicate matches the one applied by - # `join_transaction_data/1` in the current denormalization state. Each check - # is a primary-key lookup per candidate log. `transactions.block_consensus` - # can diverge from `blocks.consensus` (see + # `join_transaction_data/1` in the current denormalization state. Logs are + # joined to transactions through `Log.join_transaction_query/1` in both + # places, so each check is a few index lookups per candidate log. + # `transactions.block_consensus` can diverge from `blocks.consensus` (see # `Explorer.Migrator.TransactionBlockConsensus`), which is why the predicate # is not simply `blocks.consensus` in both states. defp where_consensus(logs_query) do if DenormalizationHelper.transactions_denormalization_finished?() do - join(logs_query, :inner, [log], transaction in Transaction, - on: - log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash and - transaction.block_consensus == true - ) + logs_query + |> Log.join_transaction_query() + |> where(as(:transaction).block_consensus == true) else - join(logs_query, :inner, [log], block in assoc(log, :block), on: block.consensus == true) + logs_query + |> Log.join_transaction_query() + |> join(:inner, [transaction: transaction], block in assoc(transaction, :block), as: :block) + |> where(as(:block).consensus == true) end end # Re-selects the joined query through an outer subquery so that fields # taken from the `logs` subquery are loaded with their schema types (`Hash` - # structs instead of raw binaries), then applies the final ordering. + # structs instead of raw binaries), then applies the final ordering and fills + # `data` and `first_topic` of logs stored with `compressed_data` and + # `first_topic_id` only. defp fetch_ordered(query) do query |> Chain.wrapped_union_subquery() |> order_by([log], asc: log.block_number, asc: log.index) |> Repo.replica().all() + |> Log.prepare_data() + |> Log.prepare_first_topic() end # Wraps `logs_query` in a subquery and joins each log to its consensus - # transaction by `(hash, block_hash)`, a primary-key lookup per log. The - # selected shape is identical in both denormalization states. + # transaction through `Log.join_transaction_query/1`, a few index lookups per + # log. The selected shape is identical in both denormalization states. defp join_transaction_data(logs_query) do if DenormalizationHelper.transactions_denormalization_finished?() do - from(log in subquery(logs_query), - join: transaction in Transaction, - on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, - where: transaction.block_consensus == true, - select: map(log, ^@log_fields), - select_merge: %{ - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_number: transaction.block_number, - block_timestamp: transaction.block_timestamp, - block_consensus: transaction.block_consensus - }, - order_by: [asc: log.block_number, asc: log.index] - ) + logs_query + |> subquery() + |> Log.join_transaction_query() + |> Log.join_address_mapping_query() + |> where(as(:transaction).block_consensus == true) + |> select([log], map(log, ^@log_fields)) + |> select_merge([log], %{ + gas_price: as(:transaction).gas_price, + gas_used: as(:transaction).gas_used, + transaction_index: as(:transaction).index, + block_hash: as(:transaction).block_hash, + block_number: as(:transaction).block_number, + block_timestamp: as(:transaction).block_timestamp, + block_consensus: as(:transaction).block_consensus, + transaction_hash: as(:transaction).hash, + address_hash: coalesce(log.address_hash, as(:address_mapping).address_hash) + }) + |> order_by([log], asc: log.block_number, asc: log.index) else - from(log in subquery(logs_query), - join: transaction in Transaction, - on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, - inner_join: block in assoc(transaction, :block), - where: block.consensus == true, - select: map(log, ^@log_fields), - select_merge: %{ - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_number: transaction.block_number, - block_timestamp: block.timestamp, - block_consensus: block.consensus - }, - order_by: [asc: log.block_number, asc: log.index] - ) + logs_query + |> subquery() + |> Log.join_transaction_query() + |> Log.join_address_mapping_query() + |> join(:inner, [l, t], block in assoc(t, :block)) + |> where([_l, _t, _am, block], block.consensus == true) + |> select([log], map(log, ^@log_fields)) + |> select_merge([log, transaction, address_mapping, block], %{ + gas_price: transaction.gas_price, + gas_used: transaction.gas_used, + transaction_index: transaction.index, + block_hash: transaction.block_hash, + block_number: transaction.block_number, + block_timestamp: block.timestamp, + block_consensus: block.consensus, + transaction_hash: transaction.hash, + address_hash: coalesce(log.address_hash, address_mapping.address_hash) + }) + |> order_by([log, _t, _am, _b], asc: log.block_number, asc: log.index) end end @@ -211,10 +224,6 @@ defmodule Explorer.Etherscan.Logs do topic2_3_opr: {:third_topic, :fourth_topic} } - # Above this number of values for a single topic, fall back to - # `= ANY(...)` to keep query size and planning time bounded. - @max_topic_union_branches 16 - defp where_topic_match(query, filter, opts \\ []) do filter = sanitize_filter_topics(filter) @@ -223,47 +232,13 @@ defmodule Explorer.Etherscan.Logs do query [topic] -> - where_single_topic_match(query, topic, filter[topic], opts) + Log.filter_by_topic_query(query, topic, filter[topic], opts) _ -> where_multiple_topics_match(query, filter) end end - # With `union_multiple_values: true`, a single topic with multiple values - # is combined with UNION ALL (one equality branch per value) instead of - # `topic = ANY(...)`: a scalar-array condition on a topic column prevents - # PostgreSQL from returning rows in `(block_number, index)` order from the - # (address_hash, first_topic, block_number, index) index, forcing it to - # materialize and sort every match in the block range before applying the - # LIMIT. With UNION ALL each branch is an ordered index scan, so the - # planner can merge branches and stop at the LIMIT. The resulting - # combination query must be wrapped in `subquery/1` by the caller before - # any further composition. - defp where_single_topic_match(query, topic, values, opts) when is_list(values) do - if Keyword.get(opts, :union_multiple_values, false) and length(values) <= @max_topic_union_branches do - values - |> Enum.map(fn value -> where(query, [l], field(l, ^topic) == ^value) end) - |> Enum.reduce(fn branch, acc -> union_all(acc, ^branch) end) - else - where(query, ^topic_condition(topic, values)) - end - end - - defp where_single_topic_match(query, topic, value, _opts) do - where(query, [l], field(l, ^topic) == ^value) - end - - # Equality instead of `= ANY(...)` for scalar values matters for - # performance: see `where_single_topic_match/3`. - defp topic_condition(topic, values) when is_list(values) do - dynamic([l], field(l, ^topic) in ^values) - end - - defp topic_condition(topic, value) do - dynamic([l], field(l, ^topic) == ^value) - end - defp sanitize_filter_topics(filter) do @topics |> Enum.reduce(filter, fn topic, acc -> @@ -313,19 +288,19 @@ defmodule Explorer.Etherscan.Logs do defp where_multiple_topics_match(query, filter, topic_operation, "and") do {topic_a, topic_b} = @topic_operations[topic_operation] - where( - query, - ^dynamic([l], ^topic_condition(topic_a, filter[topic_a]) and ^topic_condition(topic_b, filter[topic_b])) - ) + dynamic = + dynamic( + [l], + ^Log.topic_filter_dynamic(topic_a, [filter[topic_a]]) and + ^Log.topic_filter_dynamic(topic_b, List.wrap(filter[topic_b])) + ) + + where(query, [l], ^dynamic) end defp where_multiple_topics_match(query, filter, topic_operation, "or") do {topic_a, topic_b} = @topic_operations[topic_operation] - - where( - query, - ^dynamic([l], ^topic_condition(topic_a, filter[topic_a]) or ^topic_condition(topic_b, filter[topic_b])) - ) + where(query, [l], ^Log.filter_by_topic_dynamic([topic_a, topic_b], [[filter[topic_a]], List.wrap(filter[topic_b])])) end defp where_multiple_topics_match(query, _, _, _), do: query diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex index d2d028a9192..f614bbb625d 100644 --- a/apps/explorer/lib/explorer/graphql.ex +++ b/apps/explorer/lib/explorer/graphql.ex @@ -12,7 +12,7 @@ defmodule Explorer.GraphQL do where: 3 ] - alias Explorer.{Chain, Repo} + alias Explorer.{Chain, Helper, Repo} alias Explorer.Chain.{ Hash, @@ -118,13 +118,17 @@ defmodule Explorer.GraphQL do Orders token transfers by descending block number. """ - @spec list_token_transfers_query(Hash.t()) :: Ecto.Query.t() - def list_token_transfers_query(%Hash{byte_count: unquote(Hash.Address.byte_count())} = token_contract_address_hash) do + @spec list_token_transfers_query(Hash.t(), Keyword.t()) :: Ecto.Query.t() + def list_token_transfers_query( + %Hash{byte_count: unquote(Hash.Address.byte_count())} = token_contract_address_hash, + options \\ [] + ) do from( tt in TokenTransfer, inner_join: t in assoc(tt, :transaction), where: tt.token_contract_address_hash == ^token_contract_address_hash, order_by: [desc: tt.block_number, desc: tt.log_index] ) + |> Helper.maybe_hide_scam_addresses_for_token_transfers(options) end end diff --git a/apps/explorer/lib/explorer/helper.ex b/apps/explorer/lib/explorer/helper.ex index 82ab76c54b5..06eb7d8d95a 100644 --- a/apps/explorer/lib/explorer/helper.ex +++ b/apps/explorer/lib/explorer/helper.ex @@ -210,7 +210,7 @@ defmodule Explorer.Helper do end defp safe_decode_json(data, error_as_tuple?) do - case Jason.decode(data) do + case Utils.JSON.decode(data) do {:ok, decoded} -> decoded {:error, reason} -> if error_as_tuple?, do: {:error, reason}, else: %{error: data} end diff --git a/apps/explorer/lib/explorer/market/fetcher/token_list.ex b/apps/explorer/lib/explorer/market/fetcher/token_list.ex index bdfe3a989eb..184366f6ac0 100644 --- a/apps/explorer/lib/explorer/market/fetcher/token_list.ex +++ b/apps/explorer/lib/explorer/market/fetcher/token_list.ex @@ -62,7 +62,7 @@ defmodule Explorer.Market.Fetcher.TokenList do defp fetch_and_import(url) do with {:ok, %{body: body, status_code: 200}} <- HttpClient.get(url), - {:ok, %{"tokens" => tokens}} when is_list(tokens) <- Jason.decode(body) do + {:ok, %{"tokens" => tokens}} when is_list(tokens) <- Utils.JSON.decode(body) do token_params = tokens |> filter_by_chain_id() diff --git a/apps/explorer/lib/explorer/market/source/coin_gecko.ex b/apps/explorer/lib/explorer/market/source/coin_gecko.ex index 26109f6198c..d5d9dfe19b8 100644 --- a/apps/explorer/lib/explorer/market/source/coin_gecko.ex +++ b/apps/explorer/lib/explorer/market/source/coin_gecko.ex @@ -10,6 +10,10 @@ defmodule Explorer.Market.Source.CoinGecko do @behaviour Source + # CoinGecko rejects `/coins/markets` requests with `per_page` above 250, so a + # larger configured batch size is capped here and processed across several requests. + @coins_markets_max_per_page 250 + @impl Source def native_coin_fetching_enabled?, do: not is_nil(config(:coin_id)) @@ -41,26 +45,30 @@ defmodule Explorer.Market.Source.CoinGecko do @impl Source def fetch_tokens(state, batch_size) do - {to_fetch, remaining} = Enum.split(state, batch_size) + per_page = min(batch_size, @coins_markets_max_per_page) + {to_fetch, remaining} = Enum.split(state, per_page) joined_token_ids = Enum.map_join(to_fetch, ",", & &1.id) case Source.http_request( base_url() - |> URI.append_path("/simple/price") - |> URI.append_query("vs_currencies=#{config(:currency)}") - |> URI.append_query("include_market_cap=true") - |> URI.append_query("include_24hr_vol=true") + |> URI.append_path("/coins/markets") + |> URI.append_query("vs_currency=#{config(:currency)}") |> URI.append_query("ids=#{joined_token_ids}") + |> URI.append_query("per_page=#{per_page}") + |> URI.append_query("page=1") |> URI.to_string(), headers(), __MODULE__, - :simple_price + :coins_markets ) do - {:ok, data} -> + {:ok, data} when is_list(data) -> to_import = put_market_data_to_tokens(to_fetch, data) {:ok, remaining, Enum.empty?(remaining), to_import} + {:ok, unexpected_response} -> + {:error, Source.unexpected_response_error("CoinGecko", unexpected_response)} + {:error, _reason} = error -> error end @@ -222,19 +230,22 @@ defmodule Explorer.Market.Source.CoinGecko do end defp put_market_data_to_tokens(tokens, market_data) do - currency = config(:currency) - market_cap = currency <> "_market_cap" - volume_24h = currency <> "_24h_vol" + # /coins/markets returns an array of coin objects, each carrying its "id". + # Note: `total_supply` from the response is intentionally not imported: `Token.total_supply` + # holds the raw on-chain `totalSupply()` value maintained by the token total supply fetchers, + # while CoinGecko reports a unit-scaled amount. + market_data_map = Map.new(market_data, &{&1["id"], &1}) tokens |> Enum.reduce([], fn token, to_import -> - case Map.fetch(market_data, token.id) do - {:ok, %{^currency => fiat_value, ^market_cap => market_cap, ^volume_24h => volume_24h}} -> + case Map.fetch(market_data_map, token.id) do + {:ok, coin_data} -> token_with_market_data = Map.merge(token, %{ - fiat_value: Source.to_decimal(fiat_value), - circulating_market_cap: Source.to_decimal(market_cap), - volume_24h: Source.to_decimal(volume_24h) + fiat_value: Source.to_decimal(coin_data["current_price"]), + circulating_market_cap: Source.to_decimal(coin_data["market_cap"]), + volume_24h: Source.to_decimal(coin_data["total_volume"]), + circulating_supply: Source.to_decimal(coin_data["circulating_supply"]) }) [token_with_market_data | to_import] diff --git a/apps/explorer/lib/explorer/market/token.ex b/apps/explorer/lib/explorer/market/token.ex index 91f702ef993..28ca6675d04 100644 --- a/apps/explorer/lib/explorer/market/token.ex +++ b/apps/explorer/lib/explorer/market/token.ex @@ -36,7 +36,7 @@ defmodule Explorer.Market.Token do circulating_supply: Decimal.t() | nil } - @derive Jason.Encoder + @derive JSON.Encoder @enforce_keys ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url circulating_supply)a defstruct ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url circulating_supply)a diff --git a/apps/explorer/lib/explorer/metadata_uri_validator.ex b/apps/explorer/lib/explorer/metadata_uri_validator.ex index e17d0bc8d7e..fb6ad30e029 100644 --- a/apps/explorer/lib/explorer/metadata_uri_validator.ex +++ b/apps/explorer/lib/explorer/metadata_uri_validator.ex @@ -1,130 +1,12 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MetadataURIValidator do @moduledoc """ - Validates metadata URI - """ - - require Logger - - @reserved_ranges [ - # Current (local, "this") network - "0.0.0.0/8", - # Used for local communications within a private network - "10.0.0.0/8", - # [Shared address space](https://en.wikipedia.org/wiki/IPv4_shared_address_space) for communications between a service provider and its subscribers when using a carrier-grade NAT - "100.64.0.0/10", - # Used for [loopback addresses](https://en.wikipedia.org/wiki/Loopback_address) to the local host - "127.0.0.0/8", - # Used for [link-local addresses](https://en.wikipedia.org/wiki/Link-local_address) between two hosts on a single link when no IP address is otherwise specified, such as would have normally been retrieved from a DHCP server - "169.254.0.0/16", - # Used for local communications within a private network - "172.16.0.0/12", - # IETF Protocol Assignments, [DS-Lite](https://en.wikipedia.org/wiki/DS-Lite) - "192.0.0.0/29", - # Assigned as TEST-NET-1, documentation and examples - "192.0.2.0/24", - # Reserved. Formerly used for IPv6 to IPv4 relay (included IPv6 address block) - "192.88.99.0/24", - # Used for local communications within a private network - "192.168.0.0/16", - # Used for benchmark testing of inter-network communications between two separate subnets - "198.18.0.0/15", - # Assigned as TEST-NET-2, documentation and examples - "198.51.100.0/24", - # Assigned as TEST-NET-3, documentation and examples - "203.0.113.0/24", - # In use for [multicast](https://en.wikipedia.org/wiki/IP_multicast) (former Class D network) - "224.0.0.0/4", - # Reserved for future use (former Class E network) - "240.0.0.0/4", - # Reserved for the "limited [broadcast](https://en.wikipedia.org/wiki/Broadcast_address)" destination address - "255.255.255.255/32" - ] - - @doc """ - Validates the given URI. - - ## Parameters - - uri: The URI to be validated. - - ## Returns - - :ok if the URI is valid. - - {:error, reason} if the URI is invalid. - - ## Examples - - iex> validate_uri("https://example.com") - :ok + Validates metadata URI. - iex> validate_uri("invalid_uri") - {:error, :empty_host} - - iex> validate_uri("https://not_existing_domain.com") - {:error, :nxdomain} + Thin wrapper around `Utils.UrlValidator`, which holds the actual validation logic in + the `:utils` app so it can be shared by every app that fetches attacker-controlled + URLs (see `Utils.HttpClient.SafeFetch`). """ - @spec validate_uri(String.t()) :: :ok | {:error, atom()} - def validate_uri(uri) do - with {:not_printable, false} <- {:not_printable, not String.printable?(uri)}, - {:empty_host, %URI{host: host, scheme: scheme}} when host not in ["", nil] <- {:empty_host, URI.parse(uri)}, - {:disallowed_protocol, false} <- {:disallowed_protocol, scheme not in allowed_uri_protocols()}, - {:nxdomain, ip_list} when not is_nil(ip_list) <- {:nxdomain, host_to_ip_list(host)}, - {:blacklist, false} <- {:blacklist, not Enum.all?(ip_list, &allowed_ip?/1)} do - :ok - else - {reason, _} -> - {:error, reason} - end - end - - @spec host_to_ip_list(String.t()) :: [tuple()] | nil - defp host_to_ip_list(host) do - host - |> to_charlist() - |> :inet.parse_address() - |> case do - {:ok, ip} -> - [ip] - - {:error, :einval} -> - case DNS.resolve(host) do - {:ok, ip_list} -> ip_list - {:error, _reason} -> nil - end - end - end - - @spec allowed_ip?(tuple()) :: boolean() - defp allowed_ip?(ip) do - not Enum.any?(prepare_cidr_blacklist(), fn range -> - range - |> InetCidr.contains?(ip) - end) - end - - defp prepare_cidr_blacklist do - from_cache = :persistent_term.get(:parsed_cidr_list, nil) - - from_cache || - ( - cidr_list = - (Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper)[:cidr_blacklist] ++ @reserved_ranges) - |> Enum.flat_map(fn cidr -> - case InetCidr.parse_cidr(cidr) do - {:ok, cidr} -> - [cidr] - - _ -> - Logger.warning("Invalid CIDR range: #{inspect(cidr)}") - [] - end - end) - - :persistent_term.put(:parsed_cidr_list, cidr_list) - cidr_list - ) - end - defp allowed_uri_protocols do - Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper)[:allowed_uri_protocols] - end + defdelegate validate_uri(uri), to: Utils.UrlValidator end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex b/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex index 3a37032ef17..2f9ee3ba811 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex @@ -142,7 +142,7 @@ defmodule Explorer.MicroserviceInterfaces.AccountAbstraction do case HttpClient.get(url, [], params: query_params) do {:ok, %{body: body, status_code: status_code}} when status_code in [200, 404] -> - {:ok, response_json} = Jason.decode(body) + {:ok, response_json} = Utils.JSON.decode(body) {status_code, response_json} {_, %{body: body, status_code: status_code} = error} -> @@ -160,7 +160,7 @@ defmodule Explorer.MicroserviceInterfaces.AccountAbstraction do ] end) - {:ok, response_json} = Jason.decode(body) + {:ok, response_json} = Utils.JSON.decode(body) {status_code, response_json} {:error, reason} -> diff --git a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex index 4f414c56a03..ee42510905d 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex @@ -7,7 +7,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do alias Explorer.Chain alias Explorer.Chain.Address.MetadataPreloader - alias Explorer.Chain.{Address, Transaction} + alias Explorer.Chain.{Address, Token.Instance, Transaction} alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice @@ -28,7 +28,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do In multiprotocol mode: {{baseUrl}}/api/v1/addresses:batch-resolve with protocols in body. In legacy mode: {{baseUrl}}/api/v1/:chainId/addresses:batch-resolve-names """ - @spec ens_names_batch_request([binary()]) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} + @spec ens_names_batch_request([binary()]) :: {:error, :disabled | binary() | Exception.t()} | {:ok, any} def ens_names_batch_request(addresses) do with :ok <- Microservice.check_enabled(__MODULE__) do body = @@ -44,7 +44,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do In multiprotocol mode: {{baseUrl}}/api/v1/addresses:lookup with protocols query parameter. In legacy mode: {{baseUrl}}/api/v1/:chainId/addresses:lookup """ - @spec address_lookup(binary()) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} + @spec address_lookup(binary()) :: {:error, :disabled | binary() | Exception.t()} | {:ok, any} def address_lookup(address) do with :ok <- Microservice.check_enabled(__MODULE__) do query_params = @@ -81,7 +81,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do In multiprotocol mode: {{baseUrl}}/api/v1/domains:lookup with protocols query parameter. In legacy mode: {{baseUrl}}/api/v1/:chainId/domains:lookup """ - @spec ens_domain_lookup(binary()) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} + @spec ens_domain_lookup(binary()) :: {:error, :disabled | binary() | Exception.t()} | {:ok, any} def ens_domain_lookup(domain) do with :ok <- Microservice.check_enabled(__MODULE__) do query_params = @@ -120,9 +120,9 @@ 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: @preload_timeout) do + case HttpClient.post(url, Utils.JSON.encode!(body), headers, recv_timeout: @preload_timeout) do {:ok, %{body: body, status_code: 200}} -> - Jason.decode(body) + Utils.JSON.decode(body) {_, error} -> Logger.error(fn -> @@ -146,7 +146,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do defp http_get_request(url, query_params) do case HttpClient.get(url, [], params: query_params) do {:ok, %{body: body, status_code: 200}} -> - Jason.decode(body) + Utils.JSON.decode(body) {_, error} -> Logger.error(fn -> @@ -336,6 +336,14 @@ defmodule Explorer.MicroserviceInterfaces.BENS do maybe_preload_meta(address, __MODULE__, &MetadataPreloader.preload_ens_to_address/1) end + @doc """ + Preloads ENS data to the NFT instance if BENS is enabled + """ + @spec maybe_preload_ens_to_instance(Instance.t()) :: Instance.t() + def maybe_preload_ens_to_instance(instance) do + maybe_preload_meta(instance, __MODULE__, &MetadataPreloader.preload_ens_to_instance/1) + end + @doc """ Preloads ENS data to the list of blocks unless disabled via DISABLE_BLOCKS_BENS_PRELOAD. diff --git a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex index f92c52859d7..751126df3f6 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex @@ -5,7 +5,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do """ alias Explorer.Chain - alias Explorer.Chain.{Address.MetadataPreloader, Transaction} + alias Explorer.Chain.{Address.MetadataPreloader, Token.Instance, Transaction} alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice @@ -43,7 +43,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do """ @spec get_addresses_tags([String.t()]) :: - {:error, :disabled | <<_::416>> | Jason.DecodeError.t()} | {:ok, any()} | :ignore + {:error, :disabled | <<_::416>> | Exception.t()} | {:ok, any()} | :ignore def get_addresses_tags([]), do: :ignore def get_addresses_tags(addresses) do @@ -112,7 +112,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do case HttpClient.get(url, headers, params: params, recv_timeout: recv_timeout) do {:ok, %{body: body, status_code: 200}} -> - body |> Jason.decode() |> parsing_function.() + body |> Utils.JSON.decode() |> parsing_function.() {_, error} -> Logger.error(fn -> @@ -129,7 +129,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do defp http_get_request_for_proxy_method(url, params, parsing_function) 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.()} + {200, body |> Utils.JSON.decode() |> parsing_function.()} {_, %{body: body, status_code: status_code} = error} -> Logger.error(fn -> @@ -139,8 +139,10 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do ] end) - {:ok, response_json} = Jason.decode(body) - {status_code, response_json} + case Utils.JSON.decode(body) do + {:ok, response_json} -> {status_code, response_json} + {:error, _} -> {status_code, %{error: body}} + end {:error, reason} -> {500, %{error: reason}} @@ -186,6 +188,14 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do maybe_preload_meta(transaction, __MODULE__, &MetadataPreloader.preload_metadata_to_transaction/1) end + @doc """ + Preloads metadata to NFT instance if Metadata microservice is enabled + """ + @spec maybe_preload_metadata_to_instance(Instance.t()) :: Instance.t() + def maybe_preload_metadata_to_instance(instance) do + maybe_preload_meta(instance, __MODULE__, &MetadataPreloader.preload_metadata_to_instance/1) + end + defp decode_meta({:ok, %{"addresses" => addresses} = result}) do prepared_address = Enum.reduce(addresses, %{}, fn {address, meta}, acc -> @@ -199,7 +209,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do defp decode_meta(other), do: other defp decode_meta_in_tag(%{"meta" => meta} = tag) do - Map.put(tag, "meta", Jason.decode!(meta)) + Map.put(tag, "meta", Utils.JSON.decode!(meta)) end defp prepare_addresses_response({:ok, %{"items" => addresses} = response}) do diff --git a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex index d57b6d992d0..df88fd612af 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex @@ -16,6 +16,9 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do TokenInfoExportQueue } + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, + only: [proxy_implementations_smart_contracts_association: 0] + alias Explorer.{Helper, HttpClient, Repo} alias Explorer.Utility.Microservice @@ -622,11 +625,14 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do - A map containing token type and its metadata in the format approved on Multichain service. """ @spec prepare_token_metadata_for_queue(Token.t(), %{ - :token_type => String.t(), + optional(:type) => String.t(), optional(:name) => String.t(), optional(:symbol) => String.t(), optional(:decimals) => non_neg_integer(), optional(:total_supply) => non_neg_integer(), + optional(:ui_multiplier) => non_neg_integer() | Decimal.t(), + optional(:new_ui_multiplier) => non_neg_integer() | Decimal.t(), + optional(:ui_multiplier_effective_at) => DateTime.t(), optional(any()) => any() }) :: %{ optional(:token_type) => String.t(), @@ -634,21 +640,87 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do optional(:symbol) => String.t(), optional(:decimals) => String.t(), optional(:total_supply) => String.t(), - optional(:icon_url) => String.t() + optional(:icon_url) => String.t(), + optional(:ui_multiplier) => String.t(), + optional(:new_ui_multiplier) => String.t(), + optional(:ui_multiplier_effective_at) => String.t() } def prepare_token_metadata_for_queue(%Token{} = token, metadata) do if enabled?() do - %{token_type: token.type} + # the metadata fetched from the contract may carry a type more recent than the + # one stored in the token row (e.g. an ERC-20 token just found to implement ERC-8056) + token_type = Map.get(metadata, :type) || token.type + + %{token_type: token_type} |> token_optional_field(metadata, :name) |> token_optional_field(metadata, :symbol) |> token_optional_field(token, :icon_url) |> token_optional_field(metadata, :decimals) |> token_optional_field(metadata, :total_supply, true) + |> put_ui_multiplier_fields(token, metadata, token_type) else %{} end end + @doc """ + Prepares token metadata for writing to database queue and subsequent sending to Multichain service, + taking all the metadata from the token row itself. + + Used when the token row has just been refreshed (e.g. after an ERC-8056 multiplier change) and + the whole metadata entry should be re-sent to the Multichain service. + + ## Parameters + - `token`: The token to prepare metadata for. + + ## Returns + - A map containing the token metadata in the format approved on Multichain service. + """ + @spec prepare_token_metadata_for_queue(Token.t()) :: map() + def prepare_token_metadata_for_queue(%Token{} = token) do + metadata = + %{} + |> token_optional_field(token, :name) + |> token_optional_field(token, :symbol) + |> put_token_decimal_field(:decimals, token.decimals, &Decimal.to_integer/1) + |> put_token_decimal_field(:total_supply, token.total_supply, &Decimal.to_string(&1, :normal)) + + prepare_token_metadata_for_queue(token, metadata) + end + + # `decimals` and `total_supply` are decimals in the token row but come as integers from the + # contract, so they are brought to the shape `prepare_token_metadata_for_queue/2` expects + defp put_token_decimal_field(metadata, _key, nil, _convert), do: metadata + + defp put_token_decimal_field(metadata, key, %Decimal{} = value, convert), + do: Map.put(metadata, key, convert.(value)) + + # ERC-8056 multiplier fields are sent only with ERC-8056 tokens. Values are taken from the + # freshly fetched metadata first and fall back to the token row. + defp put_ui_multiplier_fields(data, token, metadata, "ERC-8056") do + data + |> put_ui_multiplier_field(token, metadata, :ui_multiplier) + |> put_ui_multiplier_field(token, metadata, :new_ui_multiplier) + |> put_ui_multiplier_field(token, metadata, :ui_multiplier_effective_at) + end + + defp put_ui_multiplier_fields(data, _token, _metadata, _token_type), do: data + + defp put_ui_multiplier_field(data, token, metadata, key) do + case Map.get(metadata, key) || Map.get(token, key) do + nil -> data + value -> Map.put(data, key, ui_multiplier_field_to_string(value)) + end + end + + # `effectiveAt()` is a Unix timestamp in seconds, so the microseconds the token row stores + # carry no information and are dropped to keep the format the same on every export path + defp ui_multiplier_field_to_string(%DateTime{} = value), + do: value |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + defp ui_multiplier_field_to_string(%Decimal{} = value), do: Decimal.to_string(value, :normal) + defp ui_multiplier_field_to_string(value) when is_integer(value), do: Integer.to_string(value) + @doc """ Prepares token total supply for writing to database queue and subsequent sending to Multichain service. @@ -975,12 +1047,12 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HttpClient.post(url, Jason.encode!(body), headers, + case HttpClient.post(url, Utils.JSON.encode!(body), headers, recv_timeout: @post_timeout, pool: false ) do {:ok, %{body: response_body, status_code: 200}} -> - response_body |> Jason.decode() + response_body |> Utils.JSON.decode() {:ok, %{body: response_body, status_code: status_code}} -> {:error, @@ -1039,9 +1111,41 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do {addresses, coin_balances_from_addresses_list} = params |> Map.get(:addresses, []) - |> Repo.preload([:token, :smart_contract]) + |> Repo.preload([:token, :smart_contract, proxy_implementations_smart_contracts_association()]) |> Enum.reduce({[], []}, fn address, {acc_addresses, acc_coin_balances} -> - {[format_address(address) | acc_addresses], [format_address_coin_balance(address) | acc_coin_balances]} + proxy_implementations = address.proxy_implementations + + implementations = + if proxy_implementations do + not_verified_implementations = + proxy_implementations.address_hashes + |> Enum.zip(proxy_implementations.names) + |> Enum.reject(fn {address_hash, _name} -> + # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting + Enum.any?(proxy_implementations.smart_contracts, fn smart_contract -> + smart_contract.address_hash == address_hash + end) + end) + |> Enum.map(fn {address_hash, name} -> + %{ + hash: address_hash, + name: name + } + end) + + verified_formatted_implementations = + proxy_implementations.smart_contracts |> Enum.map(&format_smart_contract/1) + + not_verified_formatted_implementations = + not_verified_implementations |> Enum.map(&format_not_verified_implementation/1) + + verified_formatted_implementations ++ not_verified_formatted_implementations + else + [] + end + + {[format_address(address) | implementations ++ acc_addresses], + [format_address_coin_balance(address) | acc_coin_balances]} end) address_coin_balances = @@ -1203,6 +1307,24 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do } end + defp format_smart_contract(smart_contract) do + %{ + hash: Hash.to_string(smart_contract.address_hash), + is_contract: true, + is_verified_contract: true, + contract_name: get_smart_contract_name(smart_contract) + } + end + + defp format_not_verified_implementation(address) do + %{ + hash: Hash.to_string(address[:hash]), + is_contract: true, + is_verified_contract: false, + contract_name: get_smart_contract_name(address) + } + end + defp format_address_coin_balance(address) do %{ address_hash: Hash.to_string(address.hash), @@ -1269,7 +1391,9 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do defp get_smart_contract_name(%NotLoaded{}), do: nil - defp get_smart_contract_name(smart_contract), do: smart_contract.name + defp get_smart_contract_name(smart_contract) when is_struct(smart_contract), do: smart_contract.name + + defp get_smart_contract_name(smart_contract), do: smart_contract[:name] defp get_block_ranges([]), do: [] diff --git a/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex b/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex index cf2cebc97d7..cdc6f9da31c 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex @@ -12,7 +12,7 @@ defmodule Explorer.MicroserviceInterfaces.TACOperationLifecycle do @spec get_operations_by_id_or_sender_or_transaction_hash(String.t(), nil | map()) :: {:ok, %{items: [map()], next_page_params: map() | nil}} - | {:error, :disabled | :not_found | Jason.DecodeError.t() | String.t()} + | {:error, :disabled | :not_found | Exception.t() | String.t()} def get_operations_by_id_or_sender_or_transaction_hash(param, page_params) do with :ok <- Microservice.check_enabled(__MODULE__) do query_params = @@ -40,7 +40,7 @@ defmodule Explorer.MicroserviceInterfaces.TACOperationLifecycle do defp http_get_request(url, query_params) do case HttpClient.get(url, [], params: query_params) do {:ok, %{body: body, status_code: 200}} -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, decoded_body} -> {:ok, decoded_body} diff --git a/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex b/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex index 0fa5cbc9355..b040e2118c3 100644 --- a/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex @@ -45,7 +45,9 @@ defmodule Explorer.Migrator.AddressCurrentTokenBalanceTokenType do update: [set: [token_type: token.type]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex b/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex index d00dcf3528a..47152a30587 100644 --- a/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex @@ -45,7 +45,9 @@ defmodule Explorer.Migrator.AddressTokenBalanceTokenType do update: [set: [token_type: token.type]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex b/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex index 49d4627151e..ef35d75777f 100644 --- a/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex +++ b/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex @@ -56,7 +56,9 @@ defmodule Explorer.Migrator.ArbitrumDaRecordsNormalization do } end) - Repo.insert_all(BatchToDaBlob, records, timeout: :infinity) + {count, _} = Repo.insert_all(BatchToDaBlob, records, timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex b/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex index f1ff5a7b586..b50e381a9e1 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_address_counters.ex @@ -82,6 +82,7 @@ defmodule Explorer.Migrator.BackfillAddressCounters do @impl FillingMigration def update_batch(address_hashes) do AddressCountersConsolidator.consolidate_addresses(address_hashes, await_safe_block()) + Enum.count(address_hashes) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex b/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex index 30bab5fcf4c..b94848d462d 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex @@ -55,6 +55,19 @@ defmodule Explorer.Migrator.BackfillMetadataURL do @impl FillingMigration def update_batch(token_instances) do + token_instances + |> update_batch_with_results() + |> length() + end + + @doc """ + Same as `update_batch/1`, but returns the upserted `Explorer.Chain.Token.Instance` records instead of their count. + + Used by callers that need to inspect the per-instance result (e.g. to detect + a `blacklist` error for a single, on-demand refreshed instance). + """ + @spec update_batch_with_results([any()]) :: [map()] + def update_batch_with_results(token_instances) do now = DateTime.utc_now() prepared_params = diff --git a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex index edd15c6c1e0..1b5be8209b9 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex @@ -143,8 +143,8 @@ defmodule Explorer.Migrator.BackfillMultichainSearchDB do # credo:disable-for-next-line Credo.Check.Refactor.Nesting case MultichainSearch.batch_import(to_import) do - {:ok, _} = result -> - result + {:ok, _} -> + Enum.count(addresses) + Enum.count(blocks) + Enum.count(transactions) {:error, _} -> Logger.error(fn -> diff --git a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db_current_token_balances.ex b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db_current_token_balances.ex new file mode 100644 index 00000000000..1c60c944553 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db_current_token_balances.ex @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillMultichainSearchDbCurrentTokenBalances do + @moduledoc """ + Backfills current token balances from Blockscout DB to Multichain Search DB. + + This migration exports only records from `address_current_token_balances` and + intentionally does not export coin balances. + + Requires `MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_LAST_BLOCK_NUMBER` + to be set. Only balances with `block_number` less than or equal to that value are + exported. The migration does not start when the variable is unset. + + When `TRACE_BLOCK_RANGES` is configured, only balances whose `block_number` falls + within `EthereumJSONRPC.Utility.RangesHelper.get_trace_block_ranges/0` are exported. + """ + + require Logger + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Migrator.FillingMigration + + @migration_name "backfill_multichain_search_db_current_token_balances" + @failed_to_export_data_error "Batch token-balance export to the Multichain Search DB failed" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def unprocessed_data_query(state) do + last_processed_id = Map.get(state, "last_processed_id", 0) + + from(ctb in CurrentTokenBalance, + where: ctb.id > ^last_processed_id, + where: ctb.block_number <= ^max_block_number(), + where: ^traceable_block_numbers_dynamic_query(), + order_by: [asc: ctb.id], + select: ctb.id + ) + end + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + state + |> unprocessed_data_query() + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + new_state = + case List.last(ids) do + nil -> state + id -> Map.put(state, "last_processed_id", id) + end + + {ids, new_state} + end + + @impl FillingMigration + def update_batch(ids) do + balances = + from(ctb in CurrentTokenBalance, + where: ctb.id in ^ids, + order_by: [asc: ctb.id] + ) + |> Repo.all(timeout: :infinity) + + to_import = %{ + addresses: [], + blocks: [], + transactions: [], + address_current_token_balances: balances + } + + case MultichainSearch.batch_import(to_import) do + {:ok, _} -> + Enum.count(balances) + + {:error, _} -> + Logger.error(fn -> + ["#{@failed_to_export_data_error}", ": ", "#{inspect(ids)}"] + end) + + :timer.sleep(1000) + + update_batch(ids) + end + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_backfill_multichain_search_db_current_token_balances_finished(true) + end + + defp max_block_number do + Application.fetch_env!(:explorer, __MODULE__)[:max_block_number] + end + + defp traceable_block_numbers_dynamic_query do + if RangesHelper.trace_ranges_present?() do + block_ranges = RangesHelper.get_trace_block_ranges() + + Enum.reduce(block_ranges, dynamic([_], false), fn + first..last//_step, acc -> + min_block = min(first, last) + max_block = max(first, last) + dynamic([ctb], ^acc or (ctb.block_number >= ^min_block and ctb.block_number <= ^max_block)) + + num_to_latest, acc -> + dynamic([ctb], ^acc or ctb.block_number >= ^num_to_latest) + end) + else + dynamic([_], true) + end + end +end diff --git a/apps/explorer/lib/explorer/migrator/backfill_scaled_ui_amount_tokens.ex b/apps/explorer/lib/explorer/migrator/backfill_scaled_ui_amount_tokens.ex new file mode 100644 index 00000000000..4d1e58d20cd --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/backfill_scaled_ui_amount_tokens.ex @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillScaledUIAmountTokens do + @moduledoc """ + Rebuilds the [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) state that can + be derived from `UIMultiplierUpdated` logs already indexed. + + Every such log of a contract that claims the interface through ERC-165 becomes + a row of `Explorer.Chain.Token.UIMultiplierChange`, and that contract is typed + `ERC-8056`. The log alone settles nothing — any contract can emit any topic it + likes — so the claim is checked before anything is recorded, the same policy + `Explorer.Token.MetadataRetriever` applies. + + Chains indexed before ERC-8056 support existed hold those logs untouched: + Blockscout stores every log of every receipt regardless of its topic, it just + did not interpret this one. + + Without this the history would start at whatever change happened after the + upgrade, and `Explorer.Chain.Token.UIMultiplierChange.at/4` would resolve + transfers older than that against an incomplete history. + + The token's own multiplier columns are left alone here. A single past log + cannot tell what is in force now, and once the type is `ERC-8056` the metadata + refresh reads the getters anyway. + """ + + use Explorer.Migrator.FillingMigration + + require Logger + + import Ecto.Query + + alias Explorer.Chain.{Hash, Log, Token} + alias Explorer.Chain.Token.{ScaledUIAmount, UIMultiplierChange} + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + alias Explorer.Token.MetadataRetriever + alias Explorer.Utility.AddressIdToAddressHash + + @migration_name "backfill_scaled_ui_amount_tokens" + @token_type "ERC-8056" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + {block_number, index} = last_position(state) + + logs = + unprocessed_data_query() + |> where([log], fragment("(?, ?) > (?, ?)", log.block_number, log.index, ^block_number, ^index)) + |> order_by([log], asc: log.block_number, asc: log.index) + |> preload([:address_mapping]) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + |> Log.prepare_data() + |> Enum.map(&with_address_hash/1) + + support = resolve_support(logs) + entries = Enum.map(logs, &entry(&1, support)) + + {entries, advance(state, entries)} + end + + @impl FillingMigration + def unprocessed_data_query do + topic_filter = Log.topic_filter_dynamic(:first_topic, [ScaledUIAmount.ui_multiplier_updated_event()]) + + from(log in Log, as: :log, where: ^topic_filter) + end + + @impl FillingMigration + def update_batch(entries) do + changes = + entries + |> Enum.filter(&match?({_position, %{}, true}, &1)) + |> Enum.map(fn {_position, change, _support} -> change end) + + UIMultiplierChange.insert_changes(changes) + + changes + |> Enum.map(& &1.token_contract_address_hash) + |> Enum.uniq() + |> type_as_scaled_ui_amount() + + length(entries) + end + + defp entry(log, support) do + {{log.block_number, log.index}, ScaledUIAmount.parse_known_ui_multiplier_updated(log), + Map.get(support, log.address_hash, :unknown)} + end + + defp resolve_support(logs) do + logs + |> Enum.map(& &1.address_hash) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Map.new(&{&1, support_of(&1)}) + end + + defp support_of(contract_address_hash) do + case contract_address_hash |> Hash.to_string() |> MetadataRetriever.scaled_ui_amount_support() do + {:ok, supports?} -> + supports? + + :error -> + Logger.warning(fn -> + "ERC-165 lookup for #{contract_address_hash} did not reach the node; its logs stay queued" + end) + + :unknown + end + end + + defp advance(state, []), do: state + + defp advance(state, entries) do + if Enum.any?(entries, &match?({_position, _change, :unknown}, &1)) do + state + else + {position, _change, _support} = List.last(entries) + + Map.put(state, "last_position", Tuple.to_list(position)) + end + end + + defp last_position(%{"last_position" => [block_number, index]}), do: {block_number, index} + defp last_position(%{"last_position" => {block_number, index}}), do: {block_number, index} + defp last_position(_state), do: {-1, -1} + + @impl FillingMigration + def update_cache, do: :ok + + defp with_address_hash(%Log{address_hash: nil, address_mapping: %AddressIdToAddressHash{} = mapping} = log), + do: %{log | address_hash: mapping.address_hash} + + defp with_address_hash(log), do: log + + defp type_as_scaled_ui_amount([]), do: :ok + + defp type_as_scaled_ui_amount(contract_address_hashes) do + {_count, _} = + Token + |> where([token], token.contract_address_hash in ^contract_address_hashes) + |> where([token], token.type != ^@token_type) + |> Repo.update_all([set: [type: @token_type, updated_at: DateTime.utc_now()]], timeout: :infinity) + + :ok + end +end diff --git a/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex b/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex index 31fb738418e..463c5e94fad 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_token_counters.ex @@ -81,6 +81,7 @@ defmodule Explorer.Migrator.BackfillTokenCounters do @impl FillingMigration def update_batch(contract_address_hashes) do TokenCountersConsolidator.consolidate_tokens(contract_address_hashes, await_safe_block()) + Enum.count(contract_address_hashes) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/celo_accounts.ex b/apps/explorer/lib/explorer/migrator/celo_accounts.ex index 393c009d26f..be3cb94583b 100644 --- a/apps/explorer/lib/explorer/migrator/celo_accounts.ex +++ b/apps/explorer/lib/explorer/migrator/celo_accounts.ex @@ -36,17 +36,21 @@ defmodule Explorer.Migrator.CeloAccounts do @impl FillingMigration def unprocessed_data_query do - pending_op_hashes_query = from(op in PendingAccountOperation, select: op.address_hash) - existing_account_hashes_query = from(a in Account, select: a.address_hash) + pending_op_hashes_query = + from(op in PendingAccountOperation, select: fragment("bytea_ltrim_zeroes(?)", op.address_hash)) + + existing_account_hashes_query = from(a in Account, select: fragment("bytea_ltrim_zeroes(?)", a.address_hash)) excluded_hashes_query = pending_op_hashes_query |> union(^existing_account_hashes_query) - from( - log in Log, - where: - log.first_topic in ^Events.account_events() and - fragment("SUBSTRING(? from 13)", log.second_topic) not in subquery(excluded_hashes_query), - order_by: [asc: log.block_number, asc: log.index] + Log + |> Log.filter_by_topic_query(:first_topic, Events.account_events()) + |> where( + [log], + fragment("substring(? FROM greatest(octet_length(?) - 20 + 1, 1) FOR 20)", log.second_topic, log.second_topic) not in subquery( + excluded_hashes_query + ) ) + |> order_by([log], asc: log.block_number, asc: log.index) end @impl FillingMigration @@ -62,6 +66,8 @@ defmodule Explorer.Migrator.CeloAccounts do params: unique_pending_operation_params } }) + + Enum.count(logs) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex b/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex index 5a3589c611c..f0a7faf2d3a 100644 --- a/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex +++ b/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex @@ -88,6 +88,8 @@ defmodule Explorer.Migrator.CeloAggregatedElectionRewards do params: aggregates } }) + + Enum.count(aggregates) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex b/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex index 54a35ef485e..0300626a36f 100644 --- a/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex +++ b/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex @@ -45,28 +45,32 @@ defmodule Explorer.Migrator.CeloL2Epochs do @impl FillingMigration def unprocessed_data_query do - epochs_start_processing_block_hashes = - from(epoch in Epoch, select: epoch.start_processing_block_hash) - - epochs_end_processing_block_hashes = - from(epoch in Epoch, select: epoch.end_processing_block_hash) - - from( - log in Log, - where: - log.address_hash == ^epoch_manager_contract_address_hash() and - ((log.first_topic == ^@epoch_processing_started_topic and - log.block_hash not in subquery(epochs_start_processing_block_hashes)) or - (log.first_topic == ^@epoch_processing_ended_topic and - log.block_hash not in subquery(epochs_end_processing_block_hashes))), - order_by: [asc: log.block_number] - ) + epochs_start_processing_block_numbers = + from(epoch in Epoch, join: block in assoc(epoch, :start_processing_block), select: block.number) + + epochs_end_processing_block_numbers = + from(epoch in Epoch, join: block in assoc(epoch, :end_processing_block), select: block.number) + + dynamic = + dynamic( + [log], + (^Log.topic_filter_dynamic(:first_topic, [@epoch_processing_started_topic]) and + log.block_number not in subquery(epochs_start_processing_block_numbers)) or + (^Log.topic_filter_dynamic(:first_topic, [@epoch_processing_ended_topic]) and + log.block_number not in subquery(epochs_end_processing_block_numbers)) + ) + + Log + |> Log.address_match_query(epoch_manager_contract_address_hash()) + |> where([log], ^dynamic) + |> order_by([log], asc: log.block_number) end @impl FillingMigration def update_batch(logs) do changes_list = logs + |> Log.preload_block() |> Enum.reduce(%{}, fn log, epochs_acc -> # Extract epoch number from the log [epoch_number] = log.second_topic |> to_string() |> Helper.decode_data([{:uint, 256}]) @@ -78,10 +82,10 @@ defmodule Explorer.Migrator.CeloL2Epochs do |> to_string() |> case do @epoch_processing_started_topic -> - Map.put(current_epoch, :start_processing_block_hash, log.block_hash) + Map.put(current_epoch, :start_processing_block_hash, log.block.hash) @epoch_processing_ended_topic -> - Map.put(current_epoch, :end_processing_block_hash, log.block_hash) + Map.put(current_epoch, :end_processing_block_hash, log.block.hash) end Map.put(epochs_acc, epoch_number, updated_epoch) @@ -96,6 +100,8 @@ defmodule Explorer.Migrator.CeloL2Epochs do timestamps: Import.timestamps() } ) + + Enum.count(changes_list) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/delete_non_consensus_logs.ex b/apps/explorer/lib/explorer/migrator/delete_non_consensus_logs.ex new file mode 100644 index 00000000000..63fad7e605f --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/delete_non_consensus_logs.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.DeleteNonConsensusLogs do + @moduledoc """ + Deletes all logs related to non-consensus blocks + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.{Block, Log} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "delete_non_consensus_logs" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(state) do + block_number = state["max_block_number"] || BlockNumber.get_max() + + limit = batch_size() * concurrency() + + from_block_number = max(block_number - limit, 0) + + {Enum.to_list(from_block_number..block_number), Map.put(state, "max_block_number", from_block_number - 1)} + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + {count, _} = + Log + |> join(:inner, [l], b in Block, on: l.block_hash == b.hash) + |> where([l], l.block_number in ^block_numbers) + |> where([l, b], b.consensus == false) + |> Repo.delete_all(timeout: :infinity) + + count + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex index 5d48a4dfd95..1a622af3e33 100644 --- a/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex @@ -14,7 +14,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Migrator.MigrationStatus alias Explorer.Repo - alias Explorer.Utility.{AddressIdToAddressHash, InternalTransactionsAddressPlaceholder} + alias Explorer.Utility.InternalTransactionsAddressPlaceholder alias Timex.Duration @migration_name "delete_zero_value_internal_transactions" @@ -89,6 +89,10 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do MigrationStatus.update_meta(@migration_name, state) state + %{status: "started", meta: %{"completed" => true} = meta} -> + MigrationStatus.set_status(@migration_name, "completed") + meta + %{meta: meta} -> meta end @@ -128,9 +132,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do inner_join: locked_it in subquery(locked_internal_transactions_to_delete_query), on: join_on_ctid(it, locked_it), select: %{ - from_address_hash: it.from_address_hash, from_address_id: it.from_address_id, - to_address_hash: it.to_address_hash, to_address_id: it.to_address_id, block_number: it.block_number, index: it.index @@ -139,23 +141,6 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do {_count, deleted_internal_transactions} = Repo.delete_all(delete_query, timeout: :infinity) - address_hashes = - deleted_internal_transactions - |> Enum.flat_map(&[&1.from_address_hash, &1.to_address_hash]) - |> Enum.uniq() - |> Enum.reject(&is_nil/1) - - id_to_address_params = Enum.map(address_hashes, &%{address_hash: &1}) - - Repo.insert_all(AddressIdToAddressHash, id_to_address_params, on_conflict: :nothing) - - address_to_id_map = - AddressIdToAddressHash - |> where([a], a.address_hash in ^address_hashes) - |> select([a], {a.address_hash, a.address_id}) - |> Repo.all() - |> Map.new() - placeholders_params = deleted_internal_transactions |> Enum.group_by(& &1.block_number) @@ -166,11 +151,8 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do inner_acc internal_transaction, inner_acc -> - from_address_id = - internal_transaction.from_address_id || address_to_id_map[internal_transaction.from_address_hash] - - to_address_id = - internal_transaction.to_address_id || address_to_id_map[internal_transaction.to_address_hash] + from_address_id = internal_transaction.from_address_id + to_address_id = internal_transaction.to_address_id inner_acc |> Map.update( diff --git a/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex b/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex index e604f98dbf6..655ec861683 100644 --- a/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex +++ b/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex @@ -64,7 +64,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do @impl FillingMigration def update_batch(block_numbers) do if Enum.empty?(block_numbers) do - {:ok, []} + 0 else # Find all selfdestruct internal transactions in these blocks selfdestruct_query = @@ -74,14 +74,14 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do |> where([it], it.type == :selfdestruct) |> select([it], %{ transaction_index: it.transaction_index, - from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + from_address_hash: as(:from_address_mapping).address_hash, block_number: it.block_number }) selfdestruct_transactions = Repo.all(selfdestruct_query, timeout: :infinity) if Enum.empty?(selfdestruct_transactions) do - {:ok, []} + 0 else # Get unique transaction block numbers and indexes to check for create/create2 transaction_identifiers = @@ -98,8 +98,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do |> select([it], %{ block_number: it.block_number, transaction_index: it.transaction_index, - created_contract_address_hash: - coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + created_contract_address_hash: as(:created_contract_address_mapping).address_hash }) created_contracts = Repo.all(create_query, timeout: :infinity) @@ -120,7 +119,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do |> Enum.uniq() if Enum.empty?(addresses_to_empty) do - {:ok, []} + 0 else # Only update addresses that still have non-empty contract_code update_query = @@ -142,7 +141,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do ) end - {:ok, count} + count end end end diff --git a/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex b/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex index 0aba5a1a698..f677ee33bee 100644 --- a/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex +++ b/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex @@ -55,85 +55,89 @@ defmodule Explorer.Migrator.EmptyInternalTransactionsData do @impl FillingMigration def update_batch(block_numbers) do - Repo.transaction( - fn -> - lock_query = - from( - it in InternalTransaction, - select: select_ctid(it), - select_merge: %{error: it.error}, - where: - it.block_number in ^block_numbers and - (not is_nil(it.trace_address) or it.value == ^0 or - (is_nil(it.call_type_enum) and not is_nil(it.call_type)) or - not is_nil(it.error) or (not is_nil(it.created_contract_address_hash) and is_nil(it.to_address_hash))), - order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], - lock: "FOR UPDATE" - ) - - extract_error_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - where: not is_nil(it.error), - distinct: true, - select: it.error - ) - - error_messages = Repo.all(extract_error_query, timeout: :infinity) - - TransactionError.find_or_create_multiple(error_messages) - - update_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - left_join: te in TransactionError, - on: te.message == locked_it.error, - update: [ - set: [ - error: nil, - error_id: - fragment( - "CASE WHEN ? IS NOT NULL THEN COALESCE(?, ?) ELSE ? END", - it.error, - te.id, - it.error_id, - it.error_id - ), - call_type_enum: - fragment( - "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN (?::internal_transactions_call_type) ELSE ? END", - it.call_type_enum, - it.call_type, - it.call_type, - it.call_type_enum - ), - call_type: - fragment( - "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN NULL ELSE ? END", - it.call_type_enum, - it.call_type, - it.call_type - ), - trace_address: nil, - value: fragment("CASE WHEN ? = 0 THEN NULL ELSE ? END", it.value, it.value), - to_address_hash: - fragment( - "CASE WHEN ? IS NOT NULL AND ? IS NULL THEN ? ELSE ? END", - it.created_contract_address_hash, - it.to_address_hash, - it.created_contract_address_hash, - it.to_address_hash - ) + {:ok, {count, _}} = + Repo.transaction( + fn -> + lock_query = + from( + it in InternalTransaction, + select: select_ctid(it), + select_merge: %{error: it.error}, + where: + it.block_number in ^block_numbers and + (not is_nil(it.trace_address) or it.value == ^0 or + (is_nil(it.call_type_enum) and not is_nil(it.call_type)) or + not is_nil(it.error) or + (not is_nil(it.created_contract_address_hash) and is_nil(it.to_address_hash))), + order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], + lock: "FOR UPDATE" + ) + + extract_error_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.error), + distinct: true, + select: it.error + ) + + error_messages = Repo.all(extract_error_query, timeout: :infinity) + + TransactionError.find_or_create_multiple(error_messages) + + update_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + left_join: te in TransactionError, + on: te.message == locked_it.error, + update: [ + set: [ + error: nil, + error_id: + fragment( + "CASE WHEN ? IS NOT NULL THEN COALESCE(?, ?) ELSE ? END", + it.error, + te.id, + it.error_id, + it.error_id + ), + call_type_enum: + fragment( + "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN (?::internal_transactions_call_type) ELSE ? END", + it.call_type_enum, + it.call_type, + it.call_type, + it.call_type_enum + ), + call_type: + fragment( + "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN NULL ELSE ? END", + it.call_type_enum, + it.call_type, + it.call_type + ), + trace_address: nil, + value: fragment("CASE WHEN ? = 0 THEN NULL ELSE ? END", it.value, it.value), + to_address_hash: + fragment( + "CASE WHEN ? IS NOT NULL AND ? IS NULL THEN ? ELSE ? END", + it.created_contract_address_hash, + it.to_address_hash, + it.created_contract_address_hash, + it.to_address_hash + ) + ] ] - ] - ) + ) - Repo.update_all(update_query, [], timeout: :infinity) - end, - timeout: :infinity - ) + Repo.update_all(update_query, [], timeout: :infinity) + end, + timeout: :infinity + ) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex b/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex index ad8b14d066d..a0e61283229 100644 --- a/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex +++ b/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex @@ -65,6 +65,8 @@ defmodule Explorer.Migrator.FilecoinPendingAddressOperations do timeout: :infinity, timestamps: Import.timestamps() ) + + Enum.count(ordered_address_hashes) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex b/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex index fa0995d85ff..c245625c318 100644 --- a/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex +++ b/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex @@ -57,85 +57,88 @@ defmodule Explorer.Migrator.FillInternalTransactionsAddressIds do @impl FillingMigration def update_batch(block_numbers) do - Repo.transaction( - fn -> - lock_query = - from( - it in InternalTransaction, - select: select_ctid(it), - select_merge: %{ - from_address_hash: it.from_address_hash, - to_address_hash: it.to_address_hash, - created_contract_address_hash: it.created_contract_address_hash - }, - where: - it.block_number in ^block_numbers and - (not is_nil(it.from_address_hash) or not is_nil(it.to_address_hash) or - not is_nil(it.created_contract_address_hash)), - order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], - lock: "FOR UPDATE" - ) - - from_address_hashes_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - where: not is_nil(it.from_address_hash), - distinct: true, - select: it.from_address_hash - ) - - to_address_hashes_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - where: not is_nil(it.to_address_hash), - distinct: true, - select: it.to_address_hash - ) - - created_contract_address_hashes_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - where: not is_nil(it.created_contract_address_hash), - distinct: true, - select: it.created_contract_address_hash - ) - - from_address_hashes_query - |> union_all(^to_address_hashes_query) - |> union_all(^created_contract_address_hashes_query) - |> Repo.all() - |> Enum.uniq() - |> AddressIdToAddressHash.find_or_create_multiple() - - update_query = - from(it in InternalTransaction, - inner_join: locked_it in subquery(lock_query), - on: join_on_ctid(it, locked_it), - left_join: from_map in AddressIdToAddressHash, - on: from_map.address_hash == locked_it.from_address_hash, - left_join: to_map in AddressIdToAddressHash, - on: to_map.address_hash == locked_it.to_address_hash, - left_join: created_map in AddressIdToAddressHash, - on: created_map.address_hash == locked_it.created_contract_address_hash, - update: [ - set: [ - from_address_id: from_map.address_id, - to_address_id: coalesce(to_map.address_id, created_map.address_id), - created_contract_address_id: created_map.address_id, - from_address_hash: nil, - to_address_hash: nil, - created_contract_address_hash: nil + {:ok, {count, _}} = + Repo.transaction( + fn -> + lock_query = + from( + it in InternalTransaction, + select: select_ctid(it), + select_merge: %{ + from_address_hash: it.from_address_hash, + to_address_hash: it.to_address_hash, + created_contract_address_hash: it.created_contract_address_hash + }, + where: + it.block_number in ^block_numbers and + (not is_nil(it.from_address_hash) or not is_nil(it.to_address_hash) or + not is_nil(it.created_contract_address_hash)), + order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], + lock: "FOR UPDATE" + ) + + from_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.from_address_hash), + distinct: true, + select: it.from_address_hash + ) + + to_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.to_address_hash), + distinct: true, + select: it.to_address_hash + ) + + created_contract_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.created_contract_address_hash), + distinct: true, + select: it.created_contract_address_hash + ) + + from_address_hashes_query + |> union_all(^to_address_hashes_query) + |> union_all(^created_contract_address_hashes_query) + |> Repo.all() + |> Enum.uniq() + |> AddressIdToAddressHash.find_or_create_multiple() + + update_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + left_join: from_map in AddressIdToAddressHash, + on: from_map.address_hash == locked_it.from_address_hash, + left_join: to_map in AddressIdToAddressHash, + on: to_map.address_hash == locked_it.to_address_hash, + left_join: created_map in AddressIdToAddressHash, + on: created_map.address_hash == locked_it.created_contract_address_hash, + update: [ + set: [ + from_address_id: from_map.address_id, + to_address_id: coalesce(to_map.address_id, created_map.address_id), + created_contract_address_id: created_map.address_id, + from_address_hash: nil, + to_address_hash: nil, + created_contract_address_hash: nil + ] ] - ] - ) + ) - Repo.update_all(update_query, [], timeout: :infinity) - end, - timeout: :infinity - ) + Repo.update_all(update_query, [], timeout: :infinity) + end, + timeout: :infinity + ) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/fill_logs_compressed_data.ex b/apps/explorer/lib/explorer/migrator/fill_logs_compressed_data.ex new file mode 100644 index 00000000000..f452cad7c68 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/fill_logs_compressed_data.ex @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.FillLogsCompressedData do + @moduledoc """ + Fills `compressed_data` and empties `data` fields in `logs` table. + """ + + use Explorer.Migrator.FillingMigration + + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + import Ecto.Query + + alias Explorer.Chain.Log + alias Explorer.Migrator.{FillingMigration, FillLogsOptimizedFields} + alias Explorer.Repo + alias Explorer.Utility.LogHelper + + @migration_name "fill_logs_compressed_data" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def dependent_from_migrations, + do: [FillLogsOptimizedFields.migration_name()] + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + logs = + state + |> unprocessed_data_query() + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + max_block_number = + case List.last(logs) do + nil -> -1 + %{block_number: block_number} -> block_number + end + + {logs, Map.put(state, "max_block_number", max_block_number)} + end + + @impl FillingMigration + def unprocessed_data_query(state) do + Log + |> where([l], not is_nil(l.data)) + |> then(fn query -> + case Map.get(state, "max_block_number") do + nil -> query + max_block_number -> where(query, [l], l.block_number <= ^max_block_number) + end + end) + |> order_by([l], desc: l.block_number) + end + + @impl FillingMigration + def update_batch(logs) do + params = + Enum.map(logs, fn log -> + log + |> Map.from_struct() + |> Map.drop([:block, :address, :transaction, :__meta__, :address_by_hash, :address_mapping, :log_first_topic]) + |> Map.merge(%{data: nil, compressed_data: log.data}) + end) + + {ordered_params, conflict_target} = + case chain_identity() do + {:optimism, :celo} -> + if LogHelper.primary_key_updated?() do + { + Enum.sort_by(params, &{&1.block_number, &1.index}), + [:index, :block_number] + } + else + { + Enum.sort_by(params, &{&1.block_number, &1.index}), + [:index, :block_hash] + } + end + + _ -> + if LogHelper.primary_key_updated?() do + { + Enum.sort_by(params, &{&1.block_number, &1.transaction_index, &1.index}), + [:transaction_index, :index, :block_number] + } + else + { + Enum.sort_by(params, &{&1.block_number, &1.transaction_index, &1.index}), + [:transaction_hash, :index, :block_hash] + } + end + end + + {count, _} = + Repo.safe_insert_all(Log, ordered_params, + on_conflict: {:replace, [:data, :compressed_data]}, + conflict_target: conflict_target + ) + + count + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/fill_logs_optimized_fields.ex b/apps/explorer/lib/explorer/migrator/fill_logs_optimized_fields.ex new file mode 100644 index 00000000000..54ed2155b00 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/fill_logs_optimized_fields.ex @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.FillLogsOptimizedFields do + @moduledoc """ + Fills `transaction_index`, `address_id` fields in `logs` table. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] + + alias Explorer.Chain.Cache.{BackgroundMigrations, BlockNumber} + alias Explorer.Chain.{Log, Transaction} + alias Explorer.Migrator.FillingMigration + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex + alias Explorer.Repo + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic} + + @migration_name "fill_logs_optimized_fields" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def dependent_from_migrations, + do: [CreateLogsFirstTopicIdIndex.migration_name()] + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(state) do + block_number = state["max_block_number"] || BlockNumber.get_max() + + limit = batch_size() * concurrency() + + from_block_number = max(block_number - limit, 0) + + {Enum.to_list(from_block_number..block_number), Map.put(state, "max_block_number", from_block_number - 1)} + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + {:ok, {count, _}} = + Repo.transaction( + fn -> + lock_query = + from( + l in Log, + select: select_ctid(l), + select_merge: %{ + address_hash: l.address_hash, + transaction_hash: l.transaction_hash, + first_topic: l.first_topic + }, + where: l.block_number in ^block_numbers, + order_by: [asc: l.block_number, asc: l.transaction_index, asc: l.index], + lock: "FOR UPDATE" + ) + + address_hashes_query = + from(l in Log, + inner_join: locked_l in subquery(lock_query), + on: join_on_ctid(l, locked_l), + where: not is_nil(l.address_hash), + distinct: true, + select: l.address_hash + ) + + first_topics_query = + from(l in Log, + inner_join: locked_l in subquery(lock_query), + on: join_on_ctid(l, locked_l), + where: not is_nil(l.first_topic), + distinct: true, + select: l.first_topic + ) + + address_hashes_query + |> Repo.all() + |> Enum.uniq() + |> AddressIdToAddressHash.find_or_create_multiple() + + first_topics_query + |> Repo.all() + |> LogFirstTopic.find_or_create_multiple() + + update_query = + from(l in Log, + inner_join: locked_l in subquery(lock_query), + on: join_on_ctid(l, locked_l), + left_join: it_to_hash_map in AddressIdToAddressHash, + on: it_to_hash_map.address_hash == locked_l.address_hash, + left_join: t in Transaction, + on: locked_l.transaction_hash == t.hash, + left_join: lft in LogFirstTopic, + on: lft.value == locked_l.first_topic, + update: [ + set: [ + address_id: it_to_hash_map.address_id, + address_hash: nil, + first_topic_id: lft.id, + first_topic: nil, + # Logs which are not linked to a transaction (Celo epoch logs) keep `NULL`: + # `transaction_index` is nullable and excluded from the primary key for such + # chains. For the rest the already filled value is kept, and the number of + # transactions in the block is used as a last resort, since `transaction_index` + # is `NOT NULL` there: it can't collide with the index of any real transaction + # of the block. + transaction_index: + fragment( + "COALESCE(?, ?, CASE WHEN ? IS NOT NULL THEN (SELECT count(*) FROM transactions WHERE block_number = ?) END)", + t.index, + l.transaction_index, + l.transaction_hash, + l.block_number + ), + second_topic: fragment("bytea_ltrim_zeroes(?)", l.second_topic), + third_topic: fragment("bytea_ltrim_zeroes(?)", l.third_topic), + fourth_topic: fragment("bytea_ltrim_zeroes(?)", l.fourth_topic) + ] + ] + ) + + Repo.update_all(update_query, [], timeout: :infinity) + end, + timeout: :infinity + ) + + count + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_fill_logs_optimized_fields_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/filling_migration.ex b/apps/explorer/lib/explorer/migrator/filling_migration.ex index c12fab60418..3bed464f8c1 100644 --- a/apps/explorer/lib/explorer/migrator/filling_migration.ex +++ b/apps/explorer/lib/explorer/migrator/filling_migration.ex @@ -109,9 +109,9 @@ defmodule Explorer.Migrator.FillingMigration do usage during migration. ## Returns - N/A + Number of processed elements. """ - @callback update_batch([any()]) :: any() + @callback update_batch([any()]) :: non_neg_integer() @doc """ This callback updates the migration completion status in the cache. @@ -279,10 +279,12 @@ defmodule Explorer.Migrator.FillingMigration do {:stop, :normal, new_state} {identifiers, new_state} -> - identifiers - |> Enum.chunk_every(batch_size()) - |> Enum.map(&run_task/1) - |> Task.await_many(:infinity) + migrated_count = + identifiers + |> Enum.chunk_every(batch_size()) + |> Enum.map(&run_task/1) + |> Task.await_many(:infinity) + |> Enum.sum() unquote do if !opts[:skip_meta_update?] do @@ -292,7 +294,10 @@ defmodule Explorer.Migrator.FillingMigration do end end - schedule_batch_migration() + case migrated_count do + 0 -> schedule_batch_migration(0) + _ -> schedule_batch_migration() + end {:noreply, new_state} end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex index e1852652a66..d7665015200 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex @@ -35,6 +35,7 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do | :smart_contracts_additional_sources | :tokens | :address_current_token_balances + | :address_token_balances @doc """ Specifies the type of operation to be performed on the database index. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_address_current_token_balances_token_holders_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_address_current_token_balances_token_holders_index.ex new file mode 100644 index 00000000000..2a68a4307a8 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_address_current_token_balances_token_holders_index.ex @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesTokenHoldersIndex do + @moduledoc """ + Create partial B-tree index on `address_current_token_balances` table for the + token holders queries: `(token_contract_address_hash, value DESC, address_hash DESC)` + filtered by `address_hash != burn address AND (value > 0 OR token_type = 'ERC-7984')`. + + Replaces "address_current_token_balances_token_contract_address_hash__val" + (same columns, but predicate `value > 0` only; the name may differ on + instances where the index was created manually): after the + `OR token_type = 'ERC-7984'` disjunct was added to the token holders queries + for confidential tokens support, their predicate stopped implying the old + index's predicate, making the old partial index unusable for them and forcing + a sorted scan over a non-partial fallback index. The predicate here matches + the queries verbatim, and queries filtering by plain `value > 0` can use it + as well (`value > 0` implies the disjunction). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :address_current_token_balances + @index_name "address_current_token_balances_token_holders_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name} (token_contract_address_hash, value DESC, address_hash DESC) + WHERE address_hash != '\\x0000000000000000000000000000000000000000' AND (value > 0 OR token_type = 'ERC-7984'); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_block_number_desc_index_desc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_block_number_desc_index_desc_index.ex new file mode 100644 index 00000000000..025496bc39e --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_block_number_desc_index_desc_index.ex @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdBlockNumberDescIndexDescIndex do + @moduledoc """ + Create B-tree index `logs_address_id_block_number_DESC_index_DESC_index` on `logs` table for (`address_id`, `block_number DESC`, `index DESC`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_id_block_number_DESC_index_DESC_index" + @operation_type :create + @table_columns ["address_id", "block_number DESC", "index DESC"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [CreateLogsBlockNumberTransactionIndexIndexUniqueIndex.migration_name()] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_logs_address_id_block_number_desc_index_desc_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_block_number_index_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_block_number_index_index.ex new file mode 100644 index 00000000000..5e0f9815607 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_block_number_index_index.ex @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicBlockNumberIndexIndex do + @moduledoc """ + Create B-tree index `logs_address_id_first_topic_id_block_number_index_index` on `logs` table for (`address_id`, `first_topic_id`, `block_number`, `index`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdBlockNumberDescIndexDescIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_id_first_topic_id_block_number_index_index" + @operation_type :create + @table_columns ["address_id", "first_topic_id", "block_number", "index"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateLogsAddressIdBlockNumberDescIndexDescIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_logs_address_id_first_topic_id_block_number_index_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_id_second_topic_block_number_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_id_second_topic_block_number_index.ex new file mode 100644 index 00000000000..f51e34dfe9f --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_id_first_topic_id_second_topic_block_number_index.ex @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex do + @moduledoc """ + Create B-tree index `logs_address_id_first_topic_id_second_topic_block_number_index` on + `logs` table for (`address_id`, `first_topic_id`, `second_topic`, `block_number`, `index`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicBlockNumberIndexIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_id_first_topic_id_second_topic_block_number_index" + @operation_type :create + @table_columns ["address_id", "first_topic_id", "second_topic", "block_number", "index"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateLogsAddressIdFirstTopicBlockNumberIndexIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_number_transaction_index_index_unique_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_number_transaction_index_index_unique_index.ex new file mode 100644 index 00000000000..2bab64f63d3 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_number_transaction_index_index_unique_index.ex @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex do + @moduledoc """ + Create unique B-tree index `logs_block_number_transaction_index_index_index` on `logs` table for (`block_number`, `transaction_index`, `index`) columns. + + The index becomes the primary key of `logs` in + `Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey`. On Celo, logs may be emitted by + the block itself, so `transaction_index` is nullable there and is excluded from the index. The + index name is kept the same for all chain types since it is used to track the migration status. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + + alias Explorer.Migrator.{ + DeleteNonConsensusLogs, + HeavyDbIndexOperation, + MigrationStatus + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_block_number_transaction_index_index_index" + @operation_type :create + @table_columns (case @chain_identity do + {:optimism, :celo} -> ["block_number", "index"] + _ -> ["block_number", "transaction_index", "index"] + end) + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + DeleteNonConsensusLogs.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns, true) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns, true) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_create_logs_block_number_transaction_index_index_unique_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index_with_updated_pk.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index_with_updated_pk.ex new file mode 100644 index 00000000000..248073cc53f --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index_with_updated_pk.ex @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndexWithUpdatedPk do + @moduledoc """ + Create partial B-tree index `logs_deposits_withdrawals_index_with_updated_pk` on `logs` table for (`transaction_index`, `block_number`, `index`, `address_id`) columns, filtered by first_topic IN + ('\\xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c', + '\\x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65') + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateLogsAddressIdFirstTopicBlockNumberIndexIndex, + UpdateLogsPrimaryKey + } + + @table_name :logs + @index_name "logs_deposits_withdrawals_index_with_updated_pk" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [CreateLogsAddressIdFirstTopicBlockNumberIndexIndex.migration_name(), UpdateLogsPrimaryKey.migration_name()] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name} (transaction_index, block_number, index, address_id) where first_topic_id IN (2, 3); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_logs_deposits_withdrawals_index_with_updated_pk_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_first_topic_id_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_first_topic_id_index.ex new file mode 100644 index 00000000000..c3387e169fa --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_first_topic_id_index.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex do + @moduledoc """ + Create B-tree index on `logs` table for `first_topic_id` column. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_first_topic_id_index" + @operation_type :create + @table_columns ["first_topic_id"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_token_transfer_method_id_ordered_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_token_transfer_method_id_ordered_index.ex new file mode 100644 index 00000000000..2b182ce9e52 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_token_transfer_method_id_ordered_index.ex @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsTokenTransferMethodIdOrderedIndex do + @moduledoc """ + Create B-tree index `transactions_token_transfer_method_id_ordered_index` on `transactions` + table for `SUBSTRING(input FROM 1 FOR 4)` ordered by `block_number desc, index desc` and only + for transactions with `has_token_transfers = true`. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus, TransactionHasTokenTransfers} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :transactions + @index_name "transactions_token_transfer_method_id_ordered_index" + @operation_type :create + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} transactions_token_transfer_method_id_ordered_index + ON transactions (SUBSTRING(input FROM 1 FOR 4), block_number DESC, index DESC) WHERE (has_token_transfers = TRUE); + """ + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [TransactionHasTokenTransfers.migration_name()] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_transactions_token_transfer_method_id_ordered_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_unfetched_address_token_balances_v3_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_unfetched_address_token_balances_v3_index.ex new file mode 100644 index 00000000000..99c65b6151c --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_unfetched_address_token_balances_v3_index.ex @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateUnfetchedAddressTokenBalancesV3Index do + @moduledoc """ + Create the third version of the partial B-tree index on `address_token_balances` + serving `Explorer.Chain.Address.TokenBalance.unfetched_token_balances/0`. + + A partial index is only usable when the query's predicate implies the index's, + so the predicate has to list every token type that query accepts. The `v2` + index this one replaces predates ERC-8056 — and ZRC-2, which the Zilliqa + variant of the query has always asked for — which would make the planner drop + the index and fall back to a sequential scan of the table. + + Listing a type the query does not ask for is harmless the other way round: the + index simply holds a few rows that get filtered out afterwards. That is why one + predicate covers both the Zilliqa and the ordinary shape of the query. + + `refetch_after < now()` stays out of the predicate: it is not immutable and + cannot appear in one. Filtering it out of the far smaller set of rows the index + yields is cheap. + + The `v2` index is removed by + `Explorer.Migrator.HeavyDbIndexOperation.DropUnfetchedAddressTokenBalancesV2Index` + once this one is in place, which keeps the queries served throughout — the same + create-then-drop sequence the `v1` to `v2` replacement used. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :address_token_balances + @index_name "unfetched_address_token_balances_v3_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name}(id) + WHERE ((address_hash != '\\x0000000000000000000000000000000000000000' AND token_type = 'ERC-721') + OR token_type IN ('ERC-20', 'ERC-8056', 'ZRC-2', 'ERC-1155', 'ERC-404')) + AND (value_fetched_at IS NULL OR value IS NULL); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_address_current_token_balances_token_contract_address_hash_value_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_address_current_token_balances_token_contract_address_hash_value_index.ex new file mode 100644 index 00000000000..9814862b552 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_address_current_token_balances_token_contract_address_hash_value_index.ex @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropAddressCurrentTokenBalancesTokenContractAddressHashValueIndex do + @moduledoc """ + Drops the old token holders partial index on the `address_current_token_balances` + table: `(token_contract_address_hash, value DESC, address_hash DESC)` filtered by + `address_hash != burn address AND value > 0`. + + The index is identified by its content (columns and predicate) rather than by + name: the 20240404102510 migration produced + "address_current_token_balances_token_contract_address_hash__val" (a 63-byte + truncation), but on some instances the same index exists under a different, + manually assigned name. Every index matching the content is dropped. + + Superseded by "address_current_token_balances_token_holders_index" (same + columns, wider partial predicate additionally covering + `token_type = 'ERC-7984'` rows), so it waits for + `CreateAddressCurrentTokenBalancesTokenHoldersIndex` to complete first. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Ecto.Adapters.SQL + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateAddressCurrentTokenBalancesTokenHoldersIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + alias Explorer.Repo + + @table_name :address_current_token_balances + # the name produced by the 20240404102510 migration; used as the stable + # migration identity, while the actual name(s) to drop are discovered by + # index content (see `redundant_index_names/0`) + @index_name "address_current_token_balances_token_contract_address_hash__val" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [CreateAddressCurrentTokenBalancesTokenHoldersIndex.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + redundant_index_names() + |> Enum.reduce(:ok, fn index_name, acc -> + case HeavyDbIndexOperationHelper.safely_drop_db_index(index_name) do + :ok -> acc + :error -> :error + end + end) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + redundant_index_names() + |> Enum.map(fn index_name -> + operation = HeavyDbIndexOperationHelper.drop_index_query_string(index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(index_name, operation) + end) + |> Enum.max_by( + fn + :in_progress -> 2 + :unknown -> 1 + :finished_or_not_started -> 0 + end, + fn -> :finished_or_not_started end + ) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + redundant_index_names() + |> Enum.map(&HeavyDbIndexOperationHelper.db_index_dropping_status/1) + |> Enum.max_by( + fn + :unknown -> 3 + :not_initialized -> 2 + :not_completed -> 1 + :completed -> 0 + end, + fn -> :completed end + ) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + db_index_operation() + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok + + # Finds indexes on the table matching the old token holders index content: + # the exact column list, the burn address filter, the `value > 0` condition + # (both renderings pg_get_indexdef is known to produce), and no `token_type` + # in the predicate (which distinguishes the superseding index). The lookup is + # constrained to the current schema, where the unqualified DROP INDEX issued + # by `safely_drop_db_index/1` resolves. + @redundant_index_names_query """ + SELECT indexname FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = $1 + AND indexdef LIKE '%(token#_contract#_address#_hash, value DESC, address#_hash DESC)%' ESCAPE '#' + AND indexdef LIKE '%WHERE%' + AND indexdef LIKE '%0000000000000000000000000000000000000000%' + AND (indexdef LIKE '%value > (0)::numeric%' OR indexdef LIKE '%value > 0::numeric%') + AND indexdef NOT LIKE '%token_type%'; + """ + + defp redundant_index_names do + case SQL.query(Repo, @redundant_index_names_query, [to_string(@table_name)]) do + {:ok, %Postgrex.Result{rows: rows}} -> + List.flatten(rows) + + {:error, error} -> + Logger.error("Failed to look up old token holders indexes: #{inspect(error)}") + [] + end + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_block_number_desc_index_desc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_block_number_desc_index_desc_index.ex new file mode 100644 index 00000000000..e2c2ebc4e04 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_block_number_desc_index_desc_index.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashBlockNumberDescIndexDescIndex do + @moduledoc """ + Drops B-tree index `logs_address_hash_block_number_DESC_index_DESC_index` on `logs` table for (`address_hash`, `block_number DESC`, `index DESC`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_hash_block_number_DESC_index_DESC_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [FillLogsOptimizedFields.migration_name()] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_block_number_index_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_block_number_index_index.ex new file mode 100644 index 00000000000..22e8c14ac05 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_block_number_index_index.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicBlockNumberIndexIndex do + @moduledoc """ + Drops B-tree index `logs_address_hash_first_topic_block_number_index_index` on `logs` table for (`address_hash`, `first_topic`, `block_number`, `index`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashBlockNumberDescIndexDescIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_hash_first_topic_block_number_index_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillLogsOptimizedFields.migration_name(), + DropLogsAddressHashBlockNumberDescIndexDescIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index.ex new file mode 100644 index 00000000000..467407d4c53 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index.ex @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex do + @moduledoc """ + Drops B-tree index `logs_address_hash_first_topic_second_topic_block_number_index` on `logs` table for + (`address_hash`, `first_topic`, `second_topic`, `block_number`, `index`) columns. + + The index is superseded by `logs_address_id_first_topic_id_second_topic_block_number_index` once + `address_hash` and `first_topic` are replaced with `address_id` and `first_topic_id`. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex, + DropLogsAddressHashFirstTopicBlockNumberIndexIndex + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_hash_first_topic_second_topic_block_number_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + # The create migration of this very index is a dependency too: otherwise the drop could complete while the + # creation has not started yet or is being retried after an invalid build, and the index would be rebuilt afterwards. + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex.migration_name(), + FillLogsOptimizedFields.migration_name(), + CreateLogsAddressIdFirstTopicIdSecondTopicBlockNumberIndex.migration_name(), + DropLogsAddressHashFirstTopicBlockNumberIndexIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_deposits_withdrawals_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_deposits_withdrawals_index.ex new file mode 100644 index 00000000000..3dd2cd2822b --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_deposits_withdrawals_index.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsDepositsWithdrawalsIndex do + @moduledoc """ + Drops partial B-tree index `logs_deposits_withdrawals_index` on `logs` table for (`transaction_hash`, `block_hash`, `index`, `address_hash`) columns, filtered by first_topic IN + ('\\xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c', + '\\x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65') + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + DropLogsAddressHashBlockNumberDescIndexDescIndex, + DropLogsAddressHashFirstTopicBlockNumberIndexIndex + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_deposits_withdrawals_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillLogsOptimizedFields.migration_name(), + DropLogsAddressHashBlockNumberDescIndexDescIndex.migration_name(), + DropLogsAddressHashFirstTopicBlockNumberIndexIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_unfetched_address_token_balances_v2_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_unfetched_address_token_balances_v2_index.ex new file mode 100644 index 00000000000..bfd681c16e7 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_unfetched_address_token_balances_v2_index.ex @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropUnfetchedAddressTokenBalancesV2Index do + @moduledoc """ + Drop the second version of the partial B-tree index on `address_token_balances` + serving `Explorer.Chain.Address.TokenBalance.unfetched_token_balances/0`. + + Its predicate lists neither ERC-8056 nor ZRC-2, so the query no longer implies + it and the planner cannot use it — it only takes up space and slows writes. + + Waits for + `Explorer.Migrator.HeavyDbIndexOperation.CreateUnfetchedAddressTokenBalancesV3Index`, + so the queries are served by the replacement before this one goes away. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateUnfetchedAddressTokenBalancesV3Index + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :address_token_balances + @index_name "unfetched_address_token_balances_v2_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateUnfetchedAddressTokenBalancesV3Index.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex index 6b3b74e37e0..2bb56d25953 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex @@ -195,6 +195,138 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do end end + @doc """ + Validates `NOT NULL` constraints for the given table columns and sets the columns to `NOT NULL`. + + The operation runs inside a transaction. For each column it first validates the corresponding + check constraint and then alters the column to `NOT NULL`. + """ + @spec validate_not_null_db_index_operation(atom(), [String.t()]) :: :ok | :error + # sobelow_skip ["SQL"] + def validate_not_null_db_index_operation(table_name, columns) do + result = + Repo.transaction( + fn -> + columns + |> Enum.reduce_while(:ok, fn column, _acc -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + with {:ok, _} <- + Repo.query(validate_not_null_constraint_query_string(table_name, column), [], timeout: :infinity), + {:ok, _} <- Repo.query(set_not_null_query_string(table_name, column)) do + {:cont, :ok} + else + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + :ok -> + :ok + + {:error, error} -> + Repo.rollback(error) + end + end, + timeout: :infinity + ) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error("Migration validate #{table_name} #{inspect(columns)} not null failed: #{inspect(error)}") + + :error + end + end + + @doc """ + Checks the progress of `NOT NULL` constraint validation operation for the given table columns. + """ + @spec validate_not_null_check_db_index_operation_progress(atom(), String.t(), [String.t()]) :: + :finished_or_not_started | :unknown | :in_progress + def validate_not_null_check_db_index_operation_progress(table_name, index_name, columns) do + all_operations = + Enum.flat_map(columns, fn column -> + [ + validate_not_null_constraint_query_string(table_name, column), + set_not_null_query_string(table_name, column) + ] + end) + + Enum.reduce_while(all_operations, :finished_or_not_started, fn operation, acc -> + case check_db_index_operation_progress(index_name, operation) do + :finished_or_not_started -> {:cont, acc} + progress -> {:halt, progress} + end + end) + end + + @doc """ + Returns status of `NOT NULL` constraint validation operation for the given table columns. + """ + @spec validate_not_null_db_index_operation_status(atom(), [String.t()]) :: + :not_initialized | :not_completed | :completed | :unknown + # sobelow_skip ["SQL"] + # credo:disable-for-next-line /Complexity/ + def validate_not_null_db_index_operation_status(table_name, columns) do + column_name_condition = Enum.map_join(columns, " OR ", &"column_name = '#{&1}'") + + columns_count = Enum.count(columns) + + completed? = + case Repo.query(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_name = '#{table_name}' AND (#{column_name_condition}); + """) do + {:ok, %Postgrex.Result{rows: rows}} when length(rows) == columns_count -> + Enum.all?(rows, &match?(["NO"], &1)) + + _ -> + nil + end + + started? = + case Repo.query(""" + SELECT convalidated + FROM pg_constraint + WHERE conname = '#{table_name}_#{List.first(columns)}_not_null'; + """) do + {:ok, %Postgrex.Result{rows: [[true]]}} -> true + {:ok, %Postgrex.Result{rows: [[false]]}} -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @doc """ + Cancels currently active `NOT NULL` constraint validation queries for the given table columns. + """ + @spec validate_not_null_restart_db_index_operation(atom(), [String.t()]) :: :ok | :error + # sobelow_skip ["SQL"] + def validate_not_null_restart_db_index_operation(table_name, columns) do + query_condition = + Enum.map_join(columns, " OR ", &"query ILIKE '%#{validate_not_null_constraint_query_string(table_name, &1)}%'") + + case Repo.query(""" + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND (#{query_condition}) + AND state = 'active'; + """) do + {:ok, _} -> :ok + _ -> :error + end + end + @doc """ Returns the prefix used for naming heavy database operation migrations. @@ -250,6 +382,22 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do """ end + @doc """ + Generates a SQL query string to validate a `NOT NULL` check constraint for the given table column. + """ + @spec validate_not_null_constraint_query_string(atom(), String.t()) :: String.t() + def validate_not_null_constraint_query_string(table_name, column) do + "ALTER TABLE #{table_name} VALIDATE CONSTRAINT #{table_name}_#{column}_not_null;" + end + + @doc """ + Generates a SQL query string to set the given table column to `NOT NULL`. + """ + @spec set_not_null_query_string(atom(), String.t()) :: String.t() + def set_not_null_query_string(table_name, column) do + "ALTER TABLE #{table_name} ALTER COLUMN #{column} SET NOT NULL;" + end + @doc """ As a workaround we have to remove `CONCURRENTLY` in tests since the error like "DROP INDEX CONCURRENTLY cannot run inside a transaction diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_address_hashes.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_address_hashes.ex new file mode 100644 index 00000000000..a0502fc7599 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_address_hashes.ex @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsAddressHashes do + @moduledoc """ + Removes `from_address_hash`, `to_address_hash`, `created_contract_address_hash` columns from `internal_transactions` + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Repo + + @table_name :internal_transactions + @index_name "internal_transactions_remove_address_hashes" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillInternalTransactionsAddressIds.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + Logger.info("Migration RemoveInternalTransactionsAddressHashes started") + + case Repo.query(drop_columns_query_string(), [], timeout: :infinity) do + {:ok, _} -> + Logger.info("Migration RemoveInternalTransactionsAddressHashes finished") + :ok + + {:error, error} -> + Logger.error("Migration RemoveInternalTransactionsAddressHashes failed: #{inspect(error)}") + + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, drop_columns_query_string()) + end + + @impl HeavyDbIndexOperation + # credo:disable-for-next-line /Complexity/ + def db_index_operation_status do + completed? = + case Repo.query(""" + SELECT COUNT(*) = 0 + FROM information_schema.columns + WHERE table_name = '#{@table_name}' AND column_name IN ('from_address_hash', 'to_address_hash', 'created_contract_address_hash'); + """) do + {:ok, %Postgrex.Result{rows: [[completed]]}} -> completed + _ -> nil + end + + started? = + case check_db_index_operation_progress() do + :in_progress -> true + :finished_or_not_started -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation, do: db_index_operation() + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok + + defp drop_columns_query_string do + """ + ALTER TABLE #{@table_name} + DROP COLUMN IF EXISTS from_address_hash, + DROP COLUMN IF EXISTS to_address_hash, + DROP COLUMN IF EXISTS created_contract_address_hash; + """ + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_logs_primary_key.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_logs_primary_key.ex new file mode 100644 index 00000000000..3b3dcbfa0dc --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_logs_primary_key.ex @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey do + @moduledoc """ + Update primary key on `logs` table from (`block_hash`, `transaction_hash`, `index`) to (`block_number`, `transaction_index`, `index`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + ValidateLogsBlockNumberTransactionIndexNotNull + } + + alias Explorer.Repo + + @table_name :logs + @index_name "logs_pkey" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + ValidateLogsBlockNumberTransactionIndexNotNull.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + result = + Repo.transaction(fn -> + with {:ok, _} <- Repo.query(drop_pk_constraint_query_string()), + {:ok, _} <- Repo.query(rename_index_query_string()), + {:ok, _} <- Repo.query(add_new_pk_query_string()), + {:ok, _} <- Repo.query(drop_block_hash_not_null_query_string()), + {:ok, _} <- Repo.query(drop_transaction_hash_not_null_query_string()) do + update_cache() + :ok + else + {:error, error} -> + Repo.rollback(error) + end + end) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error("Migration UpdateLogsPrimaryKey failed: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + all_operations = [ + drop_pk_constraint_query_string(), + rename_index_query_string(), + add_new_pk_query_string(), + drop_block_hash_not_null_query_string(), + drop_transaction_hash_not_null_query_string() + ] + + Enum.reduce_while(all_operations, :finished_or_not_started, fn operation, acc -> + case HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) do + :finished_or_not_started -> {:cont, acc} + progress -> {:halt, progress} + end + end) + end + + @impl HeavyDbIndexOperation + # credo:disable-for-next-line /Complexity/ + def db_index_operation_status do + completed? = + case Repo.query(""" + SELECT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = '#{@table_name}'::regclass + AND c.contype = 'p' + AND a.attname = 'block_number' + ); + """) do + {:ok, %Postgrex.Result{rows: [[true]]}} -> true + {:ok, %Postgrex.Result{rows: [[false]]}} -> false + _ -> nil + end + + started? = + case Repo.query(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = '#{@table_name}' + AND constraint_type = 'PRIMARY KEY' + ); + """) do + {:ok, %Postgrex.Result{rows: [[false]]}} -> true + {:ok, %Postgrex.Result{rows: [[true]]}} -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation do + result = + Repo.transaction(fn -> + with {:ok, _} <- Repo.query(drop_pk_constraint_query_string()), + {:ok, _} <- Repo.query(set_block_hash_not_null_query_string()), + {:ok, _} <- Repo.query(set_transaction_hash_not_null_query_string()), + {:ok, _} <- Repo.query(add_old_pk_query_string()) do + :ok + else + {:error, error} -> + Repo.rollback(error) + end + end) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error("Migration UpdateLogsPrimaryKey rollback failed: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_update_logs_primary_key_finished(true) + end + + defp drop_pk_constraint_query_string do + "ALTER TABLE #{@table_name} DROP CONSTRAINT #{@index_name};" + end + + defp rename_index_query_string do + "ALTER INDEX #{CreateLogsBlockNumberTransactionIndexIndexUniqueIndex.index_name()} RENAME TO #{@index_name};" + end + + defp add_new_pk_query_string do + "ALTER TABLE #{@table_name} ADD PRIMARY KEY USING INDEX #{@index_name};" + end + + defp add_old_pk_query_string do + "ALTER TABLE #{@table_name} ADD PRIMARY KEY (block_hash, transaction_hash, index);" + end + + defp drop_transaction_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN transaction_hash DROP NOT NULL;" + end + + defp set_transaction_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN transaction_hash SET NOT NULL;" + end + + defp drop_block_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_hash DROP NOT NULL;" + end + + defp set_block_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_hash SET NOT NULL;" + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex index 6c0b046d559..1469129c672 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex @@ -6,14 +6,12 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBl use Explorer.Migrator.HeavyDbIndexOperation - require Logger - alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} alias Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex - alias Explorer.Repo @table_name :internal_transactions @index_name "internal_transactions_not_null_constraints" + @columns ["block_number", "transaction_index"] @operation_type :create @impl HeavyDbIndexOperation @@ -32,100 +30,23 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBl ] @impl HeavyDbIndexOperation - # sobelow_skip ["SQL"] def db_index_operation do - result = - Repo.transaction( - fn -> - with {:ok, _} <- Repo.query(validate_constraint_query_string("block_number"), [], timeout: :infinity), - {:ok, _} <- Repo.query(validate_constraint_query_string("transaction_index"), [], timeout: :infinity), - {:ok, _} <- Repo.query(set_not_null_query_string("block_number")), - {:ok, _} <- Repo.query(set_not_null_query_string("transaction_index")) do - :ok - else - {:error, error} -> - Repo.rollback(error) - end - end, - timeout: :infinity - ) - - case result do - {:ok, :ok} -> - :ok - - {:error, error} -> - Logger.error( - "Migration ValidateInternalTransactionsBlockNumberTransactionIndexNotNull failed: #{inspect(error)}" - ) - - :error - end + HeavyDbIndexOperationHelper.validate_not_null_db_index_operation(@table_name, @columns) end @impl HeavyDbIndexOperation def check_db_index_operation_progress do - all_operations = [ - validate_constraint_query_string("block_number"), - validate_constraint_query_string("transaction_index"), - set_not_null_query_string("block_number"), - set_not_null_query_string("transaction_index") - ] - - Enum.reduce_while(all_operations, :finished_or_not_started, fn operation, acc -> - case HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) do - :finished_or_not_started -> {:cont, acc} - progress -> {:halt, progress} - end - end) + HeavyDbIndexOperationHelper.validate_not_null_check_db_index_operation_progress(@table_name, @index_name, @columns) end @impl HeavyDbIndexOperation - # credo:disable-for-next-line /Complexity/ def db_index_operation_status do - completed? = - case Repo.query(""" - SELECT is_nullable - FROM information_schema.columns - WHERE table_name = '#{@table_name}' AND (column_name = 'block_number' OR column_name = 'transaction_index'); - """) do - {:ok, %Postgrex.Result{rows: [["NO"], ["NO"]]}} -> true - {:ok, %Postgrex.Result{rows: [_, _]}} -> false - _ -> nil - end - - started? = - case Repo.query(""" - SELECT convalidated - FROM pg_constraint - WHERE conname = '#{@table_name}_block_number_not_null'; - """) do - {:ok, %Postgrex.Result{rows: [[true]]}} -> true - {:ok, %Postgrex.Result{rows: [[false]]}} -> false - _ -> nil - end - - cond do - completed? == true -> :completed - started? == true -> :not_completed - is_nil(completed?) or is_nil(started?) -> :unknown - true -> :not_initialized - end + HeavyDbIndexOperationHelper.validate_not_null_db_index_operation_status(@table_name, @columns) end @impl HeavyDbIndexOperation - # sobelow_skip ["SQL"] def restart_db_index_operation do - case Repo.query(""" - SELECT pg_cancel_backend(pid) - FROM pg_stat_activity - WHERE pid <> pg_backend_pid() - AND (query ILIKE '%#{validate_constraint_query_string("block_number")}%' OR query ILIKE '%#{validate_constraint_query_string("transaction_index")}%') - AND state = 'active'; - """) do - {:ok, _} -> :ok - _ -> :error - end + HeavyDbIndexOperationHelper.validate_not_null_restart_db_index_operation(@table_name, @columns) end @impl HeavyDbIndexOperation @@ -135,12 +56,4 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBl @impl HeavyDbIndexOperation def update_cache, do: :ok - - defp validate_constraint_query_string(column) do - "ALTER TABLE #{@table_name} VALIDATE CONSTRAINT #{@table_name}_#{column}_not_null;" - end - - defp set_not_null_query_string(column) do - "ALTER TABLE #{@table_name} ALTER COLUMN #{column} SET NOT NULL;" - end end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_block_number_transaction_index_not_null.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_block_number_transaction_index_not_null.ex new file mode 100644 index 00000000000..da5d1c5f897 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_block_number_transaction_index_not_null.ex @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsBlockNumberTransactionIndexNotNull do + @moduledoc """ + Validate `NOT NULL` constraints for `logs` (`block_number`, `transaction_index`). + + On Celo, logs may be emitted by the block itself, not by a transaction, so `transaction_index` + stays nullable there and only `block_number` is validated. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + + @table_name :logs + @index_name "logs_not_null_constraints" + @columns (case @chain_identity do + {:optimism, :celo} -> ["block_number"] + _ -> ["block_number", "transaction_index"] + end) + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillLogsOptimizedFields.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.validate_not_null_db_index_operation(@table_name, @columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.validate_not_null_check_db_index_operation_progress(@table_name, @index_name, @columns) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.validate_not_null_db_index_operation_status(@table_name, @columns) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.validate_not_null_restart_db_index_operation(@table_name, @columns) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_first_topic_id_fkey.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_first_topic_id_fkey.ex new file mode 100644 index 00000000000..eda2abeae70 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_logs_first_topic_id_fkey.ex @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateLogsFirstTopicIdFkey do + @moduledoc """ + Validates `logs_first_topic_id_fkey` constraint. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{FillLogsOptimizedFields, HeavyDbIndexOperation, MigrationStatus} + + @table_name :logs + @index_name "logs_first_topic_id_fkey_constraint" + @operation_type :create + @constraint_name "logs_first_topic_id_fkey" + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillLogsOptimizedFields.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + Logger.info("Migration ValidateLogsFirstTopicIdFkey started") + + case Repo.query(validate_constraint_query_string(), [], timeout: :infinity) do + {:ok, _} -> + Logger.info("Migration ValidateLogsFirstTopicIdFkey finished") + :ok + + {:error, error} -> + Logger.error("Migration ValidateLogsFirstTopicIdFkey failed: #{inspect(error)}") + + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, validate_constraint_query_string()) + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def db_index_operation_status do + {started?, completed?} = + case Repo.query(""" + SELECT convalidated + FROM pg_constraint + WHERE conname = '#{@constraint_name}'; + """) do + {:ok, %Postgrex.Result{rows: [[true]]}} -> {true, true} + {:ok, %Postgrex.Result{rows: [[false]]}} -> {true, false} + {:ok, %Postgrex.Result{rows: [[]]}} -> {false, false} + _ -> {nil, nil} + end + + cond do + completed? -> :completed + started? -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation do + case Repo.query(""" + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND query = '#{validate_constraint_query_string()}' + AND state = 'active'; + """) do + {:ok, _} -> :ok + _ -> :error + end + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok + + defp validate_constraint_query_string do + "ALTER TABLE #{@table_name} VALIDATE CONSTRAINT #{@constraint_name};" + end +end diff --git a/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex b/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex index 473d03fcf55..40c8a04c32e 100644 --- a/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex +++ b/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex @@ -65,6 +65,8 @@ defmodule Explorer.Migrator.MergeAdjacentMissingBlockRanges do |> RangesHelper.sanitize_ranges() |> MissingBlockRange.save_batch(nil) end) + + Enum.count(ranges_batch) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex b/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex index 2a6ee0accbe..79c0fe4599e 100644 --- a/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex +++ b/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex @@ -59,10 +59,14 @@ defmodule Explorer.Migrator.RefetchContractCodes do timestamps: Import.timestamps() }) + Enum.count(addresses_params) + {:error, reason} -> Logger.error(fn -> ["failed to fetch contract codes: ", inspect(reason)] end, error_count: Enum.count(address_hashes) ) + + 0 end end diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex index 39e75750d07..1479706571b 100644 --- a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex @@ -42,13 +42,17 @@ defmodule Explorer.Migrator.ReindexBlocksWithMissingTransactions do @impl FillingMigration def update_batch(block_numbers) do - Block - |> where([b], b.number in ^block_numbers) - |> where([b], b.consensus == true) - |> where([b], b.refetch_needed == false) - |> select([b], b.number) - |> Repo.all() - |> do_update() + blocks = + Block + |> where([b], b.number in ^block_numbers) + |> where([b], b.consensus == true) + |> where([b], b.refetch_needed == false) + |> select([b], b.number) + |> Repo.all() + + do_update(blocks) + + Enum.count(blocks) end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex index b6c97f5c061..ebd1f1a3686 100644 --- a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_stale_internal_transactions.ex @@ -108,7 +108,7 @@ defmodule Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions do ) end - defp full_refetch([]), do: :ok + defp full_refetch([]), do: 0 defp full_refetch(block_numbers) do case Block.full_refetch(block_numbers) do @@ -117,7 +117,7 @@ defmodule Explorer.Migrator.ReindexBlocksWithStaleInternalTransactions do "Migration #{@migration_name} sent blocks to full refetch: #{inspect(block_numbers, limit: :infinity)}" ) - :ok + Enum.count(block_numbers) {:error, reason} -> raise "Migration #{@migration_name} failed to send blocks #{inspect(block_numbers, limit: :infinity)} to full refetch: #{inspect(reason)}" diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex index 711d87d8f73..0f95e8318df 100644 --- a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_uncataloged_token_transfers.ex @@ -49,8 +49,12 @@ defmodule Explorer.Migrator.ReindexBlocksWithUncatalogedTokenTransfers do {min_block_number, max_block_number} = Enum.min_max(block_numbers) case TokenTransfer.uncataloged_token_transfer_block_numbers(min_block_number, max_block_number) do - [] -> :ok - uncataloged_block_numbers -> MissingBlockRange.add_ranges_by_block_numbers(uncataloged_block_numbers) + [] -> + 0 + + uncataloged_block_numbers -> + MissingBlockRange.add_ranges_by_block_numbers(uncataloged_block_numbers) + Enum.count(uncataloged_block_numbers) end end diff --git a/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex b/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex index 833f2a65821..d648c941f63 100644 --- a/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex +++ b/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex @@ -87,7 +87,7 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do pending_operations_type = PendingOperationsHelper.pending_operations_type() - {_total, inserted} = + {count, inserted} = case pending_operations_type do "blocks" -> params = @@ -127,6 +127,8 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do InternalTransactionFetcher.async_fetch(block_numbers, transactions, false) end + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex b/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex index c765b32ea2a..83cdbc30796 100644 --- a/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex @@ -18,6 +18,8 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do @migration_timeout 250 @migration_name "restore_omitted_weth_transfers" + def migration_name, do: @migration_name + def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @@ -169,6 +171,11 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do defp migrate_batch(batch) do {token_transfers, token_balances} = batch + |> Log.preload_block() + |> Log.preload_transaction() + |> Log.preload_address() + |> Log.prepare_data() + |> Log.prepare_first_topic() |> Enum.map(fn log -> with %{second_topic: second_topic, third_topic: nil, fourth_topic: nil, data: data} when not is_nil(second_topic) <- diff --git a/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex b/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex index ab95c0503bc..59ad3e45090 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex @@ -51,9 +51,12 @@ defmodule Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources do @impl FillingMigration def update_batch(ids) do - SmartContractAdditionalSource - |> where([sc], sc.id in ^ids) - |> Repo.delete_all(timeout: :infinity) + {count, _} = + SmartContractAdditionalSource + |> where([sc], sc.id in ^ids) + |> Repo.delete_all(timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex b/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex index 22db224ced2..4f5b3622dba 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex @@ -54,7 +54,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do ## Returns - :ok + Number of updated logs """ def update_batch(block_numbers) do logs_by_block = @@ -77,7 +77,17 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do {[id | ids], [ - %Log{log | index: new_index} |> Map.from_struct() |> Map.drop([:block, :address, :transaction, :__meta__]) + %Log{log | index: new_index} + |> Map.from_struct() + |> Map.drop([ + :block, + :address, + :transaction, + :__meta__, + :address_by_hash, + :address_mapping, + :log_first_topic + ]) | logs ], Map.put(ids_to_new_index, id, new_index)} end) @@ -117,6 +127,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do :index_in_batch, :reverse_index_in_batch, :token_decimals, + :ui_multiplier, :from_address, :to_address, :token_contract_address, @@ -170,7 +181,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do )).() end) - :ok + Enum.count(logs_to_update) end defp process_block({block_hash, logs}) do diff --git a/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex b/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex index af3eef97120..805551c7603 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex @@ -53,7 +53,9 @@ defmodule Explorer.Migrator.SanitizeEmptyContractCodeAddresses do update: [set: [contract_code: ^@empty_contract_code]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex b/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex index 29a53fd1216..1980c390ca1 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex @@ -43,7 +43,9 @@ defmodule Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds do def update_batch(token_balance_ids) do query = from(tb in TokenBalance, where: tb.id in ^token_balance_ids) - Repo.delete_all(query, timeout: :infinity) + {count, _} = Repo.delete_all(query, timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex index b49f0e6cc8d..bd3b0e73bdf 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex @@ -19,6 +19,8 @@ defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers do @migration_name "sanitize_incorrect_nft" @default_batch_size 500 + def migration_name, do: @migration_name + def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @@ -107,10 +109,18 @@ defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers do logs_query = base_query + |> Log.join_transaction_query() + |> Log.join_address_mapping_query() |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) - |> join(:left, [log], token in Token, on: log.address_hash == token.contract_address_hash) - |> where([log, token], token.type == ^"ERC-721") - |> select([log], %{block_hash: log.block_hash, transaction_hash: log.transaction_hash, index: log.index}) + |> join(:left, [log], token in Token, + on: coalesce(log.address_hash, as(:address_mapping).address_hash) == token.contract_address_hash + ) + |> where([log, _t, _am, token], token.type == ^"ERC-721") + |> select([log], %{ + block_hash: as(:transaction).block_hash, + transaction_hash: as(:transaction).hash, + index: log.index + }) TokenTransfer |> select([tt], {tt.transaction_hash, tt.block_hash, tt.log_index}) diff --git a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex index 79937c606c6..ba8a34348f8 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex @@ -18,6 +18,8 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers do @migration_name "sanitize_incorrect_weth_transfers" + def migration_name, do: @migration_name + def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @@ -116,7 +118,7 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers do defp unprocessed_identifiers("delete_duplicates") do weth_transfers = token_transfers_with_logs_query() - |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) + |> where(^Log.first_topic_is_deposit_or_withdrawal_signature(:log)) not_weth_transfers = token_transfers_with_logs_query() @@ -136,18 +138,13 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers do defp unprocessed_identifiers("delete_not_whitelisted_weth_transfers") do token_transfers_with_logs_query() - |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) + |> where(^Log.first_topic_is_deposit_or_withdrawal_signature(:log)) |> where([tt], tt.token_contract_address_hash not in ^whitelisted_weth_contracts()) |> select([tt], {tt.transaction_hash, tt.block_hash, tt.log_index}) end defp token_transfers_with_logs_query do - from( - tt in TokenTransfer, - left_join: l in Log, - as: :log, - on: tt.block_hash == l.block_hash and tt.transaction_hash == l.transaction_hash and tt.log_index == l.index - ) + Log.join_to_token_transfer_query(TokenTransfer) end defp run_task(batch), do: Task.async(fn -> handle_batch(batch) end) diff --git a/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex b/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex index 53024cf3bb1..4d535fbc242 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex @@ -54,19 +54,22 @@ defmodule Explorer.Migrator.SanitizeMissingTokenBalances do {[ctb_id | ctb_acc], [tb_id | tb_acc]} end) - Repo.transaction(fn -> - ctb_query = from(ctb in CurrentTokenBalance, where: ctb.id in ^ctb_ids) + {:ok, {count, _}} = + Repo.transaction(fn -> + ctb_query = from(ctb in CurrentTokenBalance, where: ctb.id in ^ctb_ids) - Repo.delete_all(ctb_query, timeout: :infinity) + Repo.delete_all(ctb_query, timeout: :infinity) - tb_query = - from(tb in TokenBalance, - where: tb.id in ^tb_ids, - update: [set: [value: nil, value_fetched_at: nil]] - ) + tb_query = + from(tb in TokenBalance, + where: tb.id in ^tb_ids, + update: [set: [value: nil, value_fetched_at: nil]] + ) - Repo.update_all(tb_query, [], timeout: :infinity) - end) + Repo.update_all(tb_query, [], timeout: :infinity) + end) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex b/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex index 29b17cff828..f384eb88854 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex @@ -39,7 +39,9 @@ defmodule Explorer.Migrator.SanitizeReplacedTransactions do def update_batch(transaction_hashes) do query = from(t in Transaction, where: t.hash in ^transaction_hashes) - Repo.delete_all(query, timeout: :infinity) + {count, _} = Repo.delete_all(query, timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types.ex b/apps/explorer/lib/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types.ex new file mode 100644 index 00000000000..94937b14b18 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types.ex @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.SanitizeScaledUIAmountTokenTransferTypes do + @moduledoc """ + Corrects the denormalized `token_transfers.token_type` of ERC-8056 tokens. + + ERC-8056 cannot be told from a log — such a token emits the plain ERC-20 + `Transfer` — so its transfers were indexed as `ERC-20` long before the token + itself was typed `ERC-8056`. That both hides them from a filter by `ERC-8056` + and shows them under one by `ERC-20`. + + Runs after `Explorer.Migrator.BackfillScaledUIAmountTokens`, which is what + types the tokens in the first place, and after + `Explorer.Migrator.TokenTransferTokenType`: until the latter has finished a + token type filter joins `tokens` and reads the current type rather than the + denormalized column, so there is nothing to correct yet. + + Tokens are taken one at a time in the order of their address, and the + transfers of each from the newest down. The position reached is kept in the + migration `meta`, so a batch continues the range scan of the + `(token_contract_address_hash, block_number DESC, log_index DESC)` index + where the previous one stopped instead of stepping over every transfer + corrected so far. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.{Token, TokenTransfer} + alias Explorer.Migrator.{BackfillScaledUIAmountTokens, FillingMigration, TokenTransferTokenType} + alias Explorer.Repo + + @migration_name "sanitize_scaled_ui_amount_token_transfer_types" + @token_type "ERC-8056" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def dependent_from_migrations, + do: [ + BackfillScaledUIAmountTokens.migration_name(), + TokenTransferTokenType.migration_name() + ] + + @impl FillingMigration + def last_unprocessed_identifiers(%{"token_contract_address_hash" => token_contract_address_hash} = state) + when is_binary(token_contract_address_hash) do + limit = batch_size() * concurrency() + + {ids, positions} = + state + |> unprocessed_data_query() + |> order_by([token_transfer], desc: token_transfer.block_number, desc: token_transfer.log_index) + |> select( + [token_transfer], + {{token_transfer.transaction_hash, token_transfer.block_hash, token_transfer.log_index}, + [token_transfer.block_number, token_transfer.log_index]} + ) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + |> Enum.unzip() + + case List.last(positions) do + nil -> + start_next_token(state, token_contract_address_hash) + + last_position -> + {ids, %{"token_contract_address_hash" => token_contract_address_hash, "last_position" => last_position}} + end + end + + def last_unprocessed_identifiers(state), do: start_next_token(state, nil) + + @impl FillingMigration + def unprocessed_data_query(%{"token_contract_address_hash" => token_contract_address_hash} = state) do + from(token_transfer in TokenTransfer, + where: token_transfer.token_contract_address_hash == ^token_contract_address_hash, + where: token_transfer.token_type != ^@token_type + ) + |> at_or_below(state["last_position"]) + end + + @impl FillingMigration + def update_batch(token_transfer_ids) do + {count, _} = + token_transfer_ids + |> TokenTransfer.by_ids_query() + |> update(set: [token_type: ^@token_type]) + |> Repo.update_all([], timeout: :infinity) + + count + end + + @impl FillingMigration + def update_cache, do: :ok + + defp start_next_token(state, previous_token_contract_address_hash) do + case next_token_contract_address_hash(previous_token_contract_address_hash) do + nil -> + {[], state} + + token_contract_address_hash -> + last_unprocessed_identifiers(%{ + "token_contract_address_hash" => to_string(token_contract_address_hash), + "last_position" => nil + }) + end + end + + defp next_token_contract_address_hash(previous_token_contract_address_hash) do + from(token in Token, + where: token.type == ^@token_type, + order_by: [asc: token.contract_address_hash], + limit: 1, + select: token.contract_address_hash + ) + |> after_token(previous_token_contract_address_hash) + |> Repo.one(timeout: :infinity) + end + + defp after_token(query, nil), do: query + + defp after_token(query, token_contract_address_hash), + do: where(query, [token], token.contract_address_hash > ^token_contract_address_hash) + + defp at_or_below(query, nil), do: query + + # Inclusive: a reorg can leave several transfers at one position, and the + # previous batch may have ended between them. The ones already corrected are + # dropped by the type filter. + defp at_or_below(query, [block_number, log_index]) do + where( + query, + [token_transfer], + fragment("(?, ?) <= (?, ?)", token_transfer.block_number, token_transfer.log_index, ^block_number, ^log_index) + ) + end +end diff --git a/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex b/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex index 78636fead87..cd57d59f27f 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex @@ -40,7 +40,9 @@ defmodule Explorer.Migrator.SanitizeVerifiedAddresses do update: [set: [verified: true]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end def start_link(_) do diff --git a/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex index ff47dfc43c5..3f7aef5e96f 100644 --- a/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex @@ -54,7 +54,9 @@ defmodule Explorer.Migrator.ShrinkInternalTransactions do update: [set: [input: fragment("substring(? FOR 4)", it.input), output: nil]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex b/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex index 3ee56fa6504..05447cb88ee 100644 --- a/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex +++ b/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex @@ -47,7 +47,9 @@ defmodule Explorer.Migrator.TokenTransferBlockConsensus do |> join(:inner, [tt], b in assoc(tt, :block)) |> update([tt, b], set: [block_consensus: b.consensus]) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex b/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex index b154e3e7a5d..249a7d25cde 100644 --- a/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex @@ -61,7 +61,9 @@ defmodule Explorer.Migrator.TokenTransferTokenType do ] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex b/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex index 64e80a7d294..00a3aa1129e 100644 --- a/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex +++ b/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex @@ -48,7 +48,9 @@ defmodule Explorer.Migrator.TransactionBlockConsensus do update: [set: [block_consensus: block.consensus]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/transaction_has_token_transfers.ex b/apps/explorer/lib/explorer/migrator/transaction_has_token_transfers.ex new file mode 100644 index 00000000000..c2e0ec124a4 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/transaction_has_token_transfers.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.TransactionHasTokenTransfers do + @moduledoc """ + Backfills the transactions table with the `has_token_transfers` field. + """ + + alias Explorer.Chain.Transaction + alias Explorer.Migrator.FillingMigration + + use FillingMigration + + import Ecto.Query + + @migration_name "transaction_has_token_transfers" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([t], t.hash) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + from(transaction in Transaction, where: is_nil(transaction.has_token_transfers)) + end + + @impl FillingMigration + def update_batch(transaction_hashes) do + {count, _} = + Transaction + |> where([transaction], transaction.hash in ^transaction_hashes) + |> update([transaction], + set: [ + has_token_transfers: + fragment( + "EXISTS (SELECT 1 FROM token_transfers WHERE transaction_hash = ? LIMIT 1)", + transaction.hash + ) + ] + ) + |> Repo.update_all([], timeout: :infinity) + + count + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex b/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex index 3d780f171e8..c7e258d6d3d 100644 --- a/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex +++ b/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex @@ -47,7 +47,9 @@ defmodule Explorer.Migrator.TransactionsDenormalization do update: [set: [block_consensus: block.consensus, block_timestamp: block.timestamp]] ) - Repo.update_all(query, [], timeout: :infinity) + {count, _} = Repo.update_all(query, [], timeout: :infinity) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex b/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex index 7258b82e751..7b548441538 100644 --- a/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex +++ b/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex @@ -52,7 +52,10 @@ defmodule Explorer.Migrator.UnescapeAmpersandsInTokens do } end) - Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + {count, _} = + Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex b/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex index 238de785b47..e06915d0526 100644 --- a/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex +++ b/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex @@ -52,7 +52,10 @@ defmodule Explorer.Migrator.UnescapeQuotesInTokens do } end) - Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + {count, _} = + Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + + count end @impl FillingMigration diff --git a/apps/explorer/lib/explorer/paging_options.ex b/apps/explorer/lib/explorer/paging_options.ex index ea52356235a..8a54cfb06f7 100644 --- a/apps/explorer/lib/explorer/paging_options.ex +++ b/apps/explorer/lib/explorer/paging_options.ex @@ -42,4 +42,9 @@ defmodule Explorer.PagingOptions do def default_paging_options do %__MODULE__{page_size: @page_size + 1} end + + @spec max_page_size() :: pos_integer() + def max_page_size do + Application.get_env(:explorer, __MODULE__)[:max_page_size] || 100 + end end diff --git a/apps/explorer/lib/explorer/repo.ex b/apps/explorer/lib/explorer/repo.ex index 8741a68d0db..3f520af0633 100644 --- a/apps/explorer/lib/explorer/repo.ex +++ b/apps/explorer/lib/explorer/repo.ex @@ -144,7 +144,6 @@ defmodule Explorer.Repo do Explorer.Repo.Celo, Explorer.Repo.Eden, Explorer.Repo.Filecoin, - Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, Explorer.Repo.RSK, diff --git a/apps/explorer/lib/explorer/repo/config_helper.ex b/apps/explorer/lib/explorer/repo/config_helper.ex index 624f06b47f0..cb535400e1f 100644 --- a/apps/explorer/lib/explorer/repo/config_helper.ex +++ b/apps/explorer/lib/explorer/repo/config_helper.ex @@ -43,9 +43,6 @@ defmodule Explorer.Repo.ConfigHelper do UtilsConfigHelper.parse_url_env_var("DATABASE_READ_ONLY_API_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") - def get_mud_db_url, - do: UtilsConfigHelper.parse_url_env_var("MUD_DATABASE_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") - def get_event_notification_db_url, do: UtilsConfigHelper.parse_url_env_var("DATABASE_EVENT_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") @@ -64,7 +61,11 @@ defmodule Explorer.Repo.ConfigHelper do Application.put_env(:explorer, module, merged) - {:ok, opts |> Keyword.put(:url, remove_search_path(db_url)) |> Keyword.merge(Keyword.take(merged, [:search_path]))} + {:ok, + opts + |> Keyword.put(:url, remove_search_path(db_url)) + |> Keyword.merge(Keyword.take(merged, [:search_path])) + |> Keyword.put_new(:types, Explorer.Repo.PostgrexTypes)} end @doc """ diff --git a/apps/explorer/lib/explorer/repo/lock_timeout.ex b/apps/explorer/lib/explorer/repo/lock_timeout.ex new file mode 100644 index 00000000000..8eb4de459ac --- /dev/null +++ b/apps/explorer/lib/explorer/repo/lock_timeout.ex @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.LockTimeout do + @moduledoc """ + Runs optional read queries which must fail fast instead of waiting for a + table lock. + + PostgreSQL makes even a plain `SELECT` wait behind an `ACCESS EXCLUSIVE` + lock, which is held for the whole duration of `VACUUM FULL`, `CLUSTER`, + `TRUNCATE` and most `ALTER TABLE` forms. Without a lock timeout, an API + request touching such a table hangs (and keeps a pool connection busy) until + the lock is released or the query timeout fires, so a single maintenance + operation on e.g. the `logs` table makes every address and transaction page + unavailable. + + `run/3` executes the given function inside a transaction with the PostgreSQL + `lock_timeout` parameter set for that transaction only (`SET LOCAL`). When a + query inside waits for a lock longer than the timeout, the whole call returns + `{:error, :lock_timeout}` and the caller can degrade its response instead of + hanging. Queries which don't wait for any lock are not affected. + + The timeout is intended to be short: while a table is locked, every request + waits for the timeout before falling back, so the wait multiplied by the + request rate is the number of pool connections kept busy by the fallback. + """ + + require Logger + + @default_timeout 100 + + @doc """ + Runs `fun` with the given repo inside a transaction with PostgreSQL + `lock_timeout` set for that transaction. + + `fun` receives the repo and must use it for all its queries, so they run on + the transaction connection. + + ## Options + + - `:lock_timeout` — the lock timeout in milliseconds; defaults to `timeout/0`. + - other options are passed to the repo's `transaction/2` (e.g. `:timeout`). + + ## Returns + + - `{:ok, result}` where `result` is the return value of `fun`. + - `{:error, :lock_timeout}` when a query inside `fun` waited for a lock + longer than the lock timeout. The transaction is rolled back. + - `{:error, reason}` when `fun` rolled the transaction back explicitly. + """ + @spec run(module(), (module() -> result), Keyword.t()) :: {:ok, result} | {:error, :lock_timeout | term()} + when result: term() + def run(repo, fun, opts \\ []) when is_atom(repo) and is_function(fun, 1) and is_list(opts) do + {lock_timeout, transaction_opts} = Keyword.pop(opts, :lock_timeout, timeout()) + + if not (is_integer(lock_timeout) and lock_timeout > 0) do + raise ArgumentError, "lock timeout must be a positive number of milliseconds, got: #{inspect(lock_timeout)}" + end + + try do + repo.transaction( + fn -> + # `SET` doesn't accept bind parameters; the value is a checked integer. + repo.query!("SET LOCAL lock_timeout = #{lock_timeout}") + fun.(repo) + end, + transaction_opts + ) + rescue + error in Postgrex.Error -> + case error do + %Postgrex.Error{postgres: %{code: :lock_not_available}} -> + Logger.warning(fn -> + [ + "Query on ", + inspect(repo), + " aborted after waiting for a table lock longer than ", + to_string(lock_timeout), + " ms" + ] + end) + + {:error, :lock_timeout} + + _ -> + reraise error, __STACKTRACE__ + end + end + end + + @doc """ + The configured lock timeout in milliseconds. + """ + @spec timeout() :: pos_integer() + def timeout do + Application.get_env(:explorer, __MODULE__, [])[:timeout] || @default_timeout + end +end diff --git a/apps/explorer/lib/explorer/repo/postgrex_types.ex b/apps/explorer/lib/explorer/repo/postgrex_types.ex new file mode 100644 index 00000000000..35b80827e21 --- /dev/null +++ b/apps/explorer/lib/explorer/repo/postgrex_types.ex @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# Defines a custom Postgrex type module that encodes/decodes JSON columns +# using Elixir's built-in JSON library instead of Jason. +Postgrex.Types.define(Explorer.Repo.PostgrexTypes, [], json: Utils.JSON) diff --git a/apps/explorer/lib/explorer/smart_contract/compiler_version.ex b/apps/explorer/lib/explorer/smart_contract/compiler_version.ex index 52eecf275fd..82c87333249 100644 --- a/apps/explorer/lib/explorer/smart_contract/compiler_version.ex +++ b/apps/explorer/lib/explorer/smart_contract/compiler_version.ex @@ -123,7 +123,7 @@ defmodule Explorer.SmartContract.CompilerVersion do case compiler do :solc -> json - |> Jason.decode!() + |> Utils.JSON.decode!() |> Map.fetch!("builds") |> remove_unsupported_versions(compiler) |> format_versions(compiler) @@ -131,7 +131,7 @@ defmodule Explorer.SmartContract.CompilerVersion do :vyper -> json - |> Jason.decode!() + |> Utils.JSON.decode!() |> remove_unsupported_versions(compiler) |> format_versions(compiler) |> Enum.sort(fn version1, version2 -> diff --git a/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex b/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex index d2508ef89ee..0a66d15f960 100644 --- a/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex @@ -76,7 +76,7 @@ defmodule Explorer.SmartContract.Geas.Publisher do %{^file_name => contract_source_code} = sources - compiler_settings = Jason.decode!(compiler_settings_string) + compiler_settings = Utils.JSON.decode!(compiler_settings_string) prepared_params = %{} @@ -93,7 +93,7 @@ defmodule Explorer.SmartContract.Geas.Publisher do |> Map.put("license_type", initial_params["license_type"]) |> Map.put("is_blueprint", source["isBlueprint"] || false) - publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string || "null"), save_file_path?) + publish_smart_contract(address_hash, prepared_params, Utils.JSON.decode!(abi_string || "null"), save_file_path?) end @doc """ diff --git a/apps/explorer/lib/explorer/smart_contract/reader.ex b/apps/explorer/lib/explorer/smart_contract/reader.ex index 728353cc203..bea45a070d7 100644 --- a/apps/explorer/lib/explorer/smart_contract/reader.ex +++ b/apps/explorer/lib/explorer/smart_contract/reader.ex @@ -73,30 +73,17 @@ defmodule Explorer.SmartContract.Reader do ) :: functions_results() def query_verified_contract(address_hash, functions, from, leave_error_as_map, mabi, options \\ []) do - query_verified_contract_inner(address_hash, functions, mabi, from, leave_error_as_map, options) + contract_address = Hash.to_string(address_hash) + abi = prepare_abi(mabi, address_hash) + query_contract(contract_address, from, abi, functions, leave_error_as_map, options) end @spec query_verified_contract(Hash.Address.t(), functions(), true | false, SmartContract.abi() | nil) :: functions_results() def query_verified_contract(address_hash, functions, leave_error_as_map, mabi \\ nil) do - query_verified_contract_inner(address_hash, functions, mabi, nil, leave_error_as_map) - end - - @spec query_verified_contract_inner( - Hash.Address.t(), - functions(), - SmartContract.abi() | nil, - String.t() | nil, - true | false, - Keyword.t() - ) :: - functions_results() - defp query_verified_contract_inner(address_hash, functions, mabi, from, leave_error_as_map, options \\ []) do contract_address = Hash.to_string(address_hash) - abi = prepare_abi(mabi, address_hash) - - query_contract(contract_address, from, abi, functions, leave_error_as_map, options) + query_contract(contract_address, nil, abi, functions, leave_error_as_map) end defp prepare_abi(nil, address_hash) do @@ -130,7 +117,16 @@ defmodule Explorer.SmartContract.Reader do Keyword.t() ) :: functions_results() def query_contract(contract_address, from \\ nil, abi, functions, leave_error_as_map, options \\ []) do - query_contract_inner(contract_address, abi, functions, nil, from, leave_error_as_map, options) + requests = + Enum.map(functions, fn {method_id, args} -> + req = %{contract_address: contract_address, method_id: method_id, args: args, block_number: nil} + if from && from != "", do: Map.put(req, :from, from), else: req + end) + + requests + |> query_contracts(abi, [], leave_error_as_map, options) + |> Enum.zip(requests) + |> Enum.into(%{}, fn {response, request} -> {request.method_id, response} end) end @spec query_contract_by_block_number( @@ -140,28 +136,15 @@ defmodule Explorer.SmartContract.Reader do non_neg_integer() ) :: functions_results() def query_contract_by_block_number(contract_address, abi, functions, block_number, leave_error_as_map \\ false) do - query_contract_inner(contract_address, abi, functions, block_number, nil, leave_error_as_map) - end - - defp query_contract_inner(contract_address, abi, functions, block_number, from, leave_error_as_map, options \\ []) do requests = - functions - |> Enum.map(fn {method_id, args} -> - %{ - contract_address: contract_address, - method_id: method_id, - args: args, - block_number: block_number - } - |> (&if(!is_nil(from) && from != "", do: Map.put(&1, :from, from), else: &1)).() + Enum.map(functions, fn {method_id, args} -> + %{contract_address: contract_address, method_id: method_id, args: args, block_number: block_number} end) requests - |> query_contracts(abi, [], leave_error_as_map, options) + |> query_contracts(abi, [], leave_error_as_map) |> Enum.zip(requests) - |> Enum.into(%{}, fn {response, request} -> - {request.method_id, response} - end) + |> Enum.into(%{}, fn {response, request} -> {request.method_id, response} end) end @doc """ @@ -456,31 +439,21 @@ defmodule Explorer.SmartContract.Reader do def query_function_with_names( contract_address_hash, - %{method_id: method_id, args: args}, + %{method_id: method_id} = params, :regular, from, abi, leave_error_as_map, options ) do - outputs = - query_function_with_custom_abi_inner( - contract_address_hash, - method_id, - args || [], - from, - leave_error_as_map, - abi, - options - ) - + outputs = query_function_with_custom_abi(contract_address_hash, params, from, leave_error_as_map, abi, options) names = parse_names_from_abi(abi, method_id) %{output: outputs, names: names} end def query_function_with_names( contract_address_hash, - %{method_id: method_id, args: args}, + %{method_id: method_id} = params, :proxy, from, _abi, @@ -488,17 +461,7 @@ defmodule Explorer.SmartContract.Reader do options ) do abi = get_abi(contract_address_hash, :proxy, options) - - outputs = - query_function_with_custom_abi_inner( - contract_address_hash, - method_id, - args || [], - from, - leave_error_as_map, - abi - ) - + outputs = query_function_with_custom_abi(contract_address_hash, params, from, leave_error_as_map, abi, options) names = parse_names_from_abi(abi, method_id) %{output: outputs, names: names} end @@ -559,20 +522,19 @@ defmodule Explorer.SmartContract.Reader do leave_error_as_map, options \\ [] ) do - query_function_inner(contract_address_hash, method_id, args || [], type, from, leave_error_as_map, options) - end - - @spec query_function_inner(Hash.t(), String.t(), [term()], atom(), String.t() | nil, true | false, [api?]) :: [%{}] - defp query_function_inner(contract_address_hash, method_id, args, type, from, leave_error_as_map, options) do abi = get_abi(contract_address_hash, type, options) - parsed_final_abi = - abi - |> ABI.parse_specification() - - case process_abi(parsed_final_abi, method_id) do + case process_abi(ABI.parse_specification(abi), method_id) do %{outputs: outputs, method_id: method_id} -> - query_contract_and_link_outputs(contract_address_hash, args, from, abi, outputs, method_id, leave_error_as_map) + query_contract_and_link_outputs( + contract_address_hash, + args || [], + from, + abi, + outputs, + method_id, + leave_error_as_map + ) {:error, message} -> {:error, message} @@ -597,46 +559,11 @@ defmodule Explorer.SmartContract.Reader do custom_abi, options \\ [] ) do - query_function_with_custom_abi_inner( - contract_address_hash, - method_id, - args || [], - from, - leave_error_as_map, - custom_abi, - options - ) - end - - @spec query_function_with_custom_abi_inner( - Hash.t(), - String.t(), - [term()], - String.t() | nil, - true | false, - [%{}], - Keyword.t() - ) :: [ - %{} - ] - defp query_function_with_custom_abi_inner( - contract_address_hash, - method_id, - args, - from, - leave_error_as_map, - custom_abi, - options \\ [] - ) do - parsed_abi = - custom_abi - |> ABI.parse_specification() - - case process_abi(parsed_abi, method_id) do + case process_abi(ABI.parse_specification(custom_abi), method_id) do %{outputs: outputs, method_id: method_id} -> query_contract_and_link_outputs( contract_address_hash, - args, + args || [], from, custom_abi, outputs, diff --git a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex index 55ec90cd1c3..7b206c33558 100644 --- a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex +++ b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex @@ -79,7 +79,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do def http_post_request(url, body, options \\ []) do headers = [{"Content-Type", "application/json"}] - case HttpClient.post(url, Jason.encode!(body), put_api_key_header(headers), recv_timeout: @post_timeout) do + case HttpClient.post(url, Utils.JSON.encode!(body), put_api_key_header(headers), recv_timeout: @post_timeout) do {:ok, %{body: body, status_code: _}} -> process_verifier_response(body, options) @@ -141,7 +141,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do end def process_verifier_response(body, options) when is_binary(body) do - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, decoded} -> process_verifier_response(decoded, options) diff --git a/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex b/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex index ecb22c5021c..864c3360d50 100644 --- a/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex @@ -101,9 +101,9 @@ defmodule Explorer.SmartContract.SigProviderInterface 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, Utils.JSON.encode!(body), headers, recv_timeout: @post_timeout) do {:ok, %{body: body, status_code: 200}} -> - body |> Jason.decode() + body |> Utils.JSON.decode() error -> Logger.error(fn -> @@ -125,7 +125,7 @@ defmodule Explorer.SmartContract.SigProviderInterface do end defp process_sig_provider_response(body) when is_binary(body) do - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, decoded} -> process_sig_provider_response(decoded) diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex b/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex index 284b78f7c40..def9d701917 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex @@ -82,7 +82,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do bytecode_hash = Keyword.get(params, :bytecode_hash, "default") external_libs = Keyword.get(params, :external_libs, %{}) - external_libs_string = Jason.encode!(external_libs) + external_libs_string = Utils.JSON.encode!(external_libs) checked_evm_version = if evm_version in evm_versions(:solidity) do @@ -110,7 +110,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do ] ) - with {:ok, decoded} <- Jason.decode(response), + with {:ok, decoded} <- Utils.JSON.decode(response), {:ok, contracts} <- get_contracts(decoded), %{ "abi" => abi, @@ -119,7 +119,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do get_contract_info(contracts, name) do {:ok, %{"abi" => abi, "bytecode" => bytecode, "name" => name, "deployedBytecode" => deployed_bytecode}} else - {:error, %Jason.DecodeError{}} -> + {:error, %JSON.DecodeError{}} -> {:error, :compilation} {:error, reason} when reason in [:name, :compilation] -> @@ -154,11 +154,11 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do path ] ), - {:ok, decoded} <- Jason.decode(response), + {:ok, decoded} <- Utils.JSON.decode(response), {:ok, contracts} <- get_contracts_standard_input_verification(decoded) do fetch_candidates(contracts, name) else - {:error, %Jason.DecodeError{}} -> + {:error, %JSON.DecodeError{}} -> {:error, :compilation} {:error, reason} when reason in [:name, :compilation, :json] -> @@ -177,7 +177,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do end defp tune_json(json_input) when is_binary(json_input) do - case Jason.decode(json_input) do + case Utils.JSON.decode(json_input) do {:ok, map_input} -> map_set_input_keys = map_input |> Map.keys() |> MapSet.new() map_set_required_keys = MapSet.new(@required_standard_input_fields) @@ -185,7 +185,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do if MapSet.subset?(map_set_required_keys, map_set_input_keys) do settings = Map.fetch!(map_input, "settings") new_settings = Map.put(settings, "outputSelection", @default_output_selection) - map_input |> Map.replace("settings", new_settings) |> Jason.encode() + map_input |> Map.replace("settings", new_settings) |> Utils.JSON.encode() else {:error, :json} end diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex b/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex index 0f92cfec5fa..8a65b1193f3 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex @@ -3,7 +3,6 @@ defmodule Explorer.SmartContract.Solidity.PublishHelper do @moduledoc """ Module responsible for preparing and publishing smart contracts """ - require Logger use Utils.RuntimeEnvHelper, eth_bytecode_db_enabled?: [ @@ -48,14 +47,6 @@ defmodule Explorer.SmartContract.Solidity.PublishHelper do prepare_verification_error(error, address_hash_string, conn, api_v2?), :on_demand ) - - error -> - Logger.error("Unexpected error during verification: #{inspect(error)}") - - EventsPublisher.broadcast( - prepare_verification_error("Unexpected error", address_hash_string, conn, api_v2?), - :on_demand - ) end end diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex b/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex index 0993c96d018..5d7e84ba706 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex @@ -105,7 +105,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do "compilerSettings" => _, "runtimeMatch" => _ } = result_params} -> - compilation_artifacts = Jason.decode!(compilation_artifacts_string) + compilation_artifacts = Utils.JSON.decode!(compilation_artifacts_string) transformed_result_params = result_params @@ -195,7 +195,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do %{^file_name => contract_source_code} = sources - compiler_settings = Jason.decode!(compiler_settings_string) + compiler_settings = Utils.JSON.decode!(compiler_settings_string) optimization = extract_optimization(compiler_settings) @@ -255,7 +255,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do %{^file_name => contract_source_code} = sources - compiler_settings = Jason.decode!(compiler_settings_string) + compiler_settings = Utils.JSON.decode!(compiler_settings_string) optimization = extract_optimization(compiler_settings) @@ -284,7 +284,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do |> Map.put("is_blueprint", source["isBlueprint"]) |> Map.put("language", verification_language) - publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string || "null"), save_file_path?) + publish_smart_contract(address_hash, prepared_params, Utils.JSON.decode!(abi_string || "null"), save_file_path?) end defp parse_optimization_runs(compiler_settings, optimization) do @@ -423,10 +423,21 @@ defmodule Explorer.SmartContract.Solidity.Publisher do """ @spec clear_constructor_arguments(String.t() | nil) :: String.t() | nil def clear_constructor_arguments(constructor_arguments) do - if constructor_arguments != nil && constructor_arguments != "" do - constructor_arguments - else - nil + case constructor_arguments do + nil -> + nil + + "" -> + nil + + "0x" -> + nil + + "0x" <> _ = prefixed_arguments -> + prefixed_arguments + + constructor_arguments -> + "0x" <> constructor_arguments end end diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex b/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex index e5787e84fcd..325ea908859 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex @@ -45,6 +45,8 @@ defmodule Explorer.SmartContract.Solidity.Verifier do Exception.format(:error, exception, __STACKTRACE__) ] end) + + {:error, Exception.message(exception)} end end @@ -187,7 +189,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do case solc_output do {:ok, candidates} -> - case Jason.decode(json_input) do + case Utils.JSON.decode(json_input) do {:ok, map_json_input} -> Enum.reduce_while(candidates, %{}, fn candidate, _acc -> file_path = candidate["file_path"] @@ -311,24 +313,27 @@ defmodule Explorer.SmartContract.Solidity.Verifier do defp is_compiler_version_at_least_0_6_0?("latest"), do: true defp is_compiler_version_at_least_0_6_0?(compiler_version) do - case compiler_version |> String.split("+", parts: 2) do - [version, _] -> - digits = - version - |> String.replace("v", "") - |> String.split(".") - |> Enum.map(fn str -> - {num, _} = Integer.parse(str) - num - end) - - Enum.fetch!(digits, 0) > 0 || Enum.fetch!(digits, 1) >= 6 - - _ -> - false + parts = + compiler_version + |> String.split("+", parts: 2) + |> hd() + |> String.replace("v", "") + |> String.split(".") + + with [major_str, minor_str | _] <- parts, + {major, ""} <- Integer.parse(major_str), + {minor, ""} <- Integer.parse(minor_str) do + major > 0 || minor >= 6 + else + _ -> false end end + # Exposed for testing of the version-parsing edge cases; see verifier_test.exs. + @doc false + def compiler_version_at_least_0_6_0?(compiler_version), + do: is_compiler_version_at_least_0_6_0?(compiler_version) + defp compare_bytecodes({:error, :name}, _, _, _), do: {:error, :name} defp compare_bytecodes({:error, _}, _, _, _), do: {:error, :compilation} @@ -381,7 +386,10 @@ defmodule Explorer.SmartContract.Solidity.Verifier do } = extract_bytecode_and_metadata_hash(bc_creation_transaction_input, bc_deployed_bytecode) bc_replaced_local = - String.replace(bc_creation_transaction_input_without_meta, local_bytecode_without_meta, "", global: false) + case bc_creation_transaction_input_without_meta do + <<^local_bytecode_without_meta::binary, rest::binary>> -> rest + _ -> nil + end has_constructor_with_params? = has_constructor_with_params?(abi) @@ -397,7 +405,11 @@ defmodule Explorer.SmartContract.Solidity.Verifier do !String.contains?(bc_creation_transaction_input, bc_meta) || bc_deployed_bytecode in ["", "0x"] -> {:error, :deployed_bytecode} - solc_local != solc_bc -> + # Reject a genuine mismatch: exactly one side embeds a compiler version, or both + # embed versions that differ. When neither bytecode embeds a version (e.g. pre-0.5.9 + # contracts whose CBOR metadata carries only a bzzr0/ipfs hash and no `solc` field), + # there is nothing to compare, so fall through instead of failing verification. + is_nil(solc_local) != is_nil(solc_bc) or (not is_nil(solc_local) and solc_local != solc_bc) -> {:error, :compiler_version} !String.contains?(bc_creation_transaction_input_without_meta, local_bytecode_without_meta) -> @@ -450,7 +462,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do |> String.reverse() end - defp replace_last_occurrence(_, _), do: nil + defp replace_last_occurrence(where, _), do: where defp parse_constructor_and_return_check_function(abi) do constructor_abi = Enum.find(abi, fn el -> el["type"] == "constructor" && el["inputs"] != [] end) @@ -493,7 +505,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do else _ -> Logger.warning("Failed to decode CBOR metadata from bytecode, falling back to empty metadata") - %{} + nil end end diff --git a/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex b/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex index 95cc36f4304..9425ed85770 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex @@ -119,7 +119,7 @@ defmodule Explorer.SmartContract.Stylus.Publisher do |> Map.put("package_name", package_name) |> Map.put("github_repository_metadata", github_repository_metadata) - publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string || "null")) + publish_smart_contract(address_hash, prepared_params, Utils.JSON.decode!(abi_string || "null")) end # Stores information about a verified Stylus smart contract in the database. diff --git a/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex b/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex index 8a7bb3d073b..6982a33aa74 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex @@ -56,7 +56,7 @@ defmodule Explorer.SmartContract.StylusVerifierInterface 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, Utils.JSON.encode!(body), headers, recv_timeout: @post_timeout) do {:ok, %{body: body, status_code: _}} -> process_verifier_response(body) @@ -114,7 +114,7 @@ defmodule Explorer.SmartContract.StylusVerifierInterface do @spec process_verifier_response(binary()) :: {:ok, map() | [String.t()]} | {:error, any()} defp process_verifier_response(body) when is_binary(body) do - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, decoded} -> process_verifier_response(decoded) diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex b/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex index 5e4078bc0e1..996d3aebccd 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex @@ -30,11 +30,11 @@ defmodule Explorer.SmartContract.Vyper.CodeCompiler do abi_row = response_data |> Enum.at(0) bytecode = response_data |> Enum.at(1) - case Jason.decode(abi_row) do + case Utils.JSON.decode(abi_row) do {:ok, abi} -> {:ok, %{"abi" => abi, "bytecode" => bytecode}} - {:error, %Jason.DecodeError{}} -> + {:error, _reason} -> {:error, :compilation} end else diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex b/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex index 567cb6da566..fe51afc4295 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex @@ -10,6 +10,7 @@ defmodule Explorer.SmartContract.Vyper.Publisher do alias Explorer.Chain.SmartContract alias Explorer.SmartContract.CompilerVersion + alias Explorer.SmartContract.Solidity.Publisher, as: SolidityPublisher alias Explorer.SmartContract.Vyper.Verifier def publish(address_hash, params) do @@ -98,7 +99,7 @@ defmodule Explorer.SmartContract.Vyper.Publisher do %{^file_name => contract_source_code} = sources - compiler_settings = Jason.decode!(compiler_settings_string) + compiler_settings = Utils.JSON.decode!(compiler_settings_string) prepared_params = %{} @@ -122,7 +123,7 @@ defmodule Explorer.SmartContract.Vyper.Publisher do |> Map.put("license_type", initial_params["license_type"]) |> Map.put("is_blueprint", source["isBlueprint"]) - publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string), save_file_path?) + publish_smart_contract(address_hash, prepared_params, Utils.JSON.decode!(abi_string), save_file_path?) end def publish_smart_contract(address_hash, params, abi, verification_with_files?) do @@ -167,7 +168,7 @@ defmodule Explorer.SmartContract.Vyper.Publisher do constructor_arguments = params["constructor_arguments"] compiler_settings = params["compiler_settings"] - clean_constructor_arguments = clear_constructor_arguments(constructor_arguments) + clean_constructor_arguments = SolidityPublisher.clear_constructor_arguments(constructor_arguments) clean_compiler_settings = if compiler_settings in ["", nil, %{}] do @@ -203,12 +204,4 @@ defmodule Explorer.SmartContract.Vyper.Publisher do language: :vyper } end - - defp clear_constructor_arguments(constructor_arguments) do - if constructor_arguments != nil && constructor_arguments != "" do - constructor_arguments - else - nil - end - end end diff --git a/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex b/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex index 1e48babfa73..f739ffe9ec8 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex @@ -101,7 +101,7 @@ defmodule Explorer.SmartContract.VyperDownloader do releases_path |> HttpClient.get!([], timeout: 60_000, recv_timeout: 60_000) |> Map.get(:body) - |> Jason.decode!() + |> Utils.JSON.decode!() release = releases_body diff --git a/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex b/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex index c64c14ef25a..2f42304c579 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex @@ -45,11 +45,11 @@ defmodule Explorer.ThirdPartyIntegrations.AirTableAuditReport do "records" => [%{"fields" => map}] } - request = HttpClient.post(url, Jason.encode!(body), headers) + request = HttpClient.post(url, Utils.JSON.encode!(body), headers) case request do {:ok, %{body: body, status_code: 200}} -> - request_id = Enum.at(Jason.decode!(body)["records"], 0)["fields"]["request_id"] + request_id = Enum.at(Utils.JSON.decode!(body)["records"], 0)["fields"]["request_id"] success_callback.(request_id) diff --git a/apps/explorer/lib/explorer/third_party_integrations/auth0.ex b/apps/explorer/lib/explorer/third_party_integrations/auth0.ex index b57dd5415ed..c3d45387d8c 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/auth0.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/auth0.ex @@ -48,9 +48,9 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do "grant_type" => "client_credentials" } - case HttpClient.post("https://#{config[:domain]}/oauth/token", Jason.encode!(body), @json_content_type) do + case HttpClient.post("https://#{config[:domain]}/oauth/token", Utils.JSON.encode!(body), @json_content_type) do {:ok, %{status_code: 200, body: body}} -> - case Jason.decode!(body) do + case Utils.JSON.decode!(body) do %{"access_token" => token, "expires_in" => ttl} -> cache_token(Vault.encrypt!(token), ttl - 1) token diff --git a/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex b/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex index cb6aab51ee2..2eefe7de9fb 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex @@ -152,7 +152,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do @doc false def find_users_by_email(email) do case admin_get(users_path(), %{email: email, exact: true}) do - {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 200, body: body}} -> {:ok, Utils.JSON.decode!(body)} {:ok, %{status_code: 404}} -> {:ok, []} error -> handle_error(error, "Failed to search user by email") end @@ -161,7 +161,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do @doc false def find_users_by_address(address) do case admin_get(users_path(), %{q: "address:#{String.downcase(address)}"}) do - {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 200, body: body}} -> {:ok, Utils.JSON.decode!(body)} {:ok, %{status_code: 404}} -> {:ok, []} error -> handle_error(error, "Failed to search user by address") end @@ -220,7 +220,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do @doc false def get_user(user_id) do case admin_get("#{users_path()}/#{user_id}") do - {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 200, body: body}} -> {:ok, Utils.JSON.decode!(body)} {:ok, %{status_code: 404}} -> {:error, "User not found"} error -> handle_error(error, "Failed to get user") end @@ -245,7 +245,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do with {:ok, token} <- get_admin_token() do HttpClient.post( build_url(path), - Jason.encode!(body), + Utils.JSON.encode!(body), auth_headers(token) ++ @json_headers ) end @@ -257,7 +257,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do :put, build_url(path), auth_headers(token) ++ @json_headers, - Jason.encode!(body) + Utils.JSON.encode!(body) ) end end @@ -293,7 +293,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do case HttpClient.post(url, body, headers) do {:ok, %{status_code: 200, body: resp_body}} -> - case Jason.decode(resp_body) do + case Utils.JSON.decode(resp_body) do {:ok, %{"access_token" => token, "expires_in" => ttl}} -> Redix.command(:redix, ["SET", admin_token_key(), Vault.encrypt!(token), "EX", ttl - 1]) {:ok, token} @@ -459,7 +459,7 @@ defmodule Explorer.ThirdPartyIntegrations.Keycloak do defp do_send_registration_webhook(email, webhook_url) when not is_nil(webhook_url) do payload = - Jason.encode!(%{ + Utils.JSON.encode!(%{ email: email, name: email, labels: [Helper.get_app_host()] diff --git a/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex b/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex index c59fcf6c6eb..f51eaed02b9 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex @@ -32,7 +32,7 @@ defmodule Explorer.ThirdPartyIntegrations.NovesFi do conn.query_params |> Map.drop(["hashes"]) - case HttpClient.post(url, Jason.encode!(hashes), headers, recv_timeout: @recv_timeout, params: prepared_params) do + case HttpClient.post(url, Utils.JSON.encode!(hashes), headers, recv_timeout: @recv_timeout, params: prepared_params) do {:ok, %{status_code: status, body: body}} -> {Helper.decode_json(body), status} diff --git a/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex b/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex index 960fa47aef1..cf5cc56b2d4 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex @@ -2,31 +2,78 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do @moduledoc """ Adapter for contracts verification with https://sourcify.dev/ + + Uses the Sourcify API v2 (https://docs.sourcify.dev/docs/api/). The legacy v1 + endpoints (`/check-by-addresses`, `/files`, `/verify`) have been deprecated and + shut down (https://docs.sourcify.dev/blog/api-v1-brownouts/). + + The public functions keep their historic return contracts so that the callers in + `Explorer.SmartContract.Solidity.PublishHelper` and the RPC contract controller do + not need to change: the v2 responses are adapted back into the legacy + metadata-file-list shape and the `exact_match`/`match` statuses are mapped onto the + legacy `perfect`/`partial`/`full` tokens. """ alias Explorer.Helper, as: ExplorerHelper alias Explorer.HttpClient alias Explorer.SmartContract.{Helper, RustVerifierInterface} - alias Tesla.Multipart @post_timeout :timer.seconds(30) @no_metadata_message "Sourcify did not return metadata" @failed_verification_message "Unsuccessful Sourcify verification" + @not_verified_message "Contract is not verified" + @timeout_message "Sourcify verification timed out" + + @default_poll_interval_ms :timer.seconds(3) + @default_poll_max_attempts 20 def check_by_address(address_hash_string) do - chain_id = config(__MODULE__, :chain_id) - params = [addresses: address_hash_string, chainIds: chain_id] - http_get_request(check_by_address_url(), params) + case do_lookup(address_hash_string, nil) do + {:ok, "exact_match", _body} -> + {:ok, [%{"status" => "perfect"}]} + + {:ok, "match", _body} -> + {:error, "partial"} + + :not_verified -> + {:error, @not_verified_message} + + {:error, reason} -> + {:error, reason} + end end def check_by_address_any(address_hash_string) do - get_metadata_full_url = get_metadata_any_url() <> "/" <> address_hash_string - http_get_request(get_metadata_full_url, []) + case do_lookup(address_hash_string, :with_sources) do + {:ok, "exact_match", body} -> + {:ok, "full", reconstruct_file_list(body)} + + {:ok, "match", body} -> + {:ok, "partial", reconstruct_file_list(body)} + + :not_verified -> + {:error, @not_verified_message} + + {:error, reason} -> + {:error, reason} + end end def get_metadata(address_hash_string) do - get_metadata_full_url = get_metadata_url() <> "/" <> address_hash_string - http_get_request(get_metadata_full_url, []) + case do_lookup(address_hash_string, :with_sources) do + # v1 `/files/{chainId}/{address}` returned full matches only + {:ok, "exact_match", body} -> + {:ok, reconstruct_file_list(body)} + + {:ok, "match", _body} -> + {:error, %{"error" => @not_verified_message}} + + :not_verified -> + {:error, %{"error" => @not_verified_message}} + + {:error, reason} -> + {:error, %{"error" => reason}} + end end def verify(address_hash_string, files, chosen_contract) do @@ -38,43 +85,11 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do end def verify_via_sourcify_server(address_hash_string, files) do - chain_id = config(__MODULE__, :chain_id) - - multipart_text_params = - Multipart.new() - |> Multipart.add_field("chain", chain_id) - |> Multipart.add_field("address", address_hash_string) - - multipart_body = prepare_body_for_sourcify(files, multipart_text_params) - - http_post_request(verify_url(), multipart_body) - end - - defp prepare_body_for_sourcify(files, multipart_text_params) when is_map(files) do - files - |> Enum.reduce(multipart_text_params, fn {name, content}, acc -> - if content do - acc - |> Multipart.add_file_content(content, name, name: "files") - else - acc - end - end) - end - - defp prepare_body_for_sourcify(files, multipart_text_params) do - files - |> Enum.reduce(multipart_text_params, fn file, acc -> - if file do - acc - |> Multipart.add_file(file.path, - name: "files", - file_name: Path.basename(file.path) - ) - else - acc - end - end) + with normalized_files <- normalize_files(files), + {:ok, metadata, sources} <- extract_metadata_and_sources(normalized_files) do + body = %{"sources" => sources, "metadata" => metadata} + submit_and_poll_verification(verify_metadata_url(address_hash_string), body) + end end def verify_via_rust_microservice(address_hash_string, files, chosen_contract) do @@ -145,149 +160,223 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do defp get_file_content(name, content) do if Helper.json_file?(name) do content - |> Jason.decode!() - |> Jason.encode!() + |> Utils.JSON.decode!() + |> Utils.JSON.encode!() else content end end - defp http_get_request(url, params) do - request = HttpClient.get(url, [], params: params) + # Normalizes the incoming files into a flat `%{relative_name => content}` map. + # Files arrive either as a `name => content` map or as a list of upload structs + # (`%Plug.Upload{}` / `%{path:, filename:}`) - see the controllers building them. + defp normalize_files(files) when is_map(files) do + Enum.reduce(files, %{}, fn {name, content}, acc -> + if content, do: Map.put(acc, name, content), else: acc + end) + end - case request do - {:ok, %{body: body, status_code: 200}} -> - process_sourcify_response(url, body) + # sobelow_skip ["Traversal.FileModule"] + defp normalize_files(files) when is_list(files) do + Enum.reduce(files, %{}, fn + %{path: path} = file, acc when not is_nil(path) -> + case File.read(path) do + {:ok, content} -> + name = Map.get(file, :filename) || Path.basename(path) + Map.put(acc, name, content) + + _ -> + acc + end + + _file, acc -> + acc + end) + end - {:ok, %{body: body, status_code: status_code}} when status_code in 400..526 -> - parse_http_error_response(body) + defp normalize_files(_files), do: %{} - {:ok, %{status_code: status_code}} when status_code in 300..308 -> - {:error, "Sourcify redirected"} + # Splits the normalized files into the `metadata` object and the `sources` map + # expected by the v2 `POST /v2/verify/metadata/{chainId}/{address}` endpoint. + defp extract_metadata_and_sources(files_map) do + {metadata_files, source_files} = + Enum.split_with(files_map, fn {name, _content} -> + Path.basename(name) == "metadata.json" + end) - {:ok, %{status_code: _status_code}} -> - {:error, "Sourcify unexpected status code"} + case metadata_files do + [{_name, metadata_content} | _] -> + {:ok, ExplorerHelper.decode_json(metadata_content), Map.new(source_files)} - {:error, reason} -> - {:error, reason} - - _ -> - {:error, "Unexpected response from Sourcify"} + [] -> + {:error, @no_metadata_message} end end - defp http_post_request(url, body) do - request = Tesla.post(url, body) + defp submit_and_poll_verification(url, body) do + request = + HttpClient.post(url, Utils.JSON.encode!(body), [{"Content-Type", "application/json"}], + recv_timeout: @post_timeout + ) case request do - {:ok, %Tesla.Env{body: body}} -> - process_sourcify_response(url, body) + {:ok, %{body: response_body, status_code: status_code}} when status_code in [200, 202] -> + case ExplorerHelper.decode_json(response_body) do + %{"verificationId" => verification_id} -> + poll_verification(verification_id, poll_max_attempts()) + + _ -> + {:error, @failed_verification_message} + end + + {:ok, %{body: response_body, status_code: status_code}} when status_code in 400..526 -> + parse_http_error_response(response_body) _ -> {:error, "Unexpected response from Sourcify verify method"} end end - def http_post_request_rust_microservice(url, body) do - request = - HttpClient.post(url, Jason.encode!(body), [{"Content-Type", "application/json"}], recv_timeout: @post_timeout) + defp poll_verification(_verification_id, attempts_left) when attempts_left <= 0 do + {:error, @timeout_message} + end + + defp poll_verification(verification_id, attempts_left) do + request = HttpClient.get(verify_job_url(verification_id), [], recv_timeout: @post_timeout) case request do {:ok, %{body: body, status_code: 200}} -> - process_sourcify_response(url, body) + handle_verification_job(ExplorerHelper.decode_json(body), verification_id, attempts_left) + + {:ok, %{body: body, status_code: status_code}} when status_code in 400..526 -> + parse_http_error_response(body) _ -> {:error, "Unexpected response from Sourcify verify method"} end end - defp process_sourcify_response(url, body) do - cond do - url =~ "check-by-addresses" -> - parse_check_by_address_http_response(body) + defp handle_verification_job(%{"isJobCompleted" => true, "error" => error}, _id, _attempts) + when not is_nil(error) do + {:error, verification_error_message(error)} + end - url =~ "/verify" -> - parse_verify_http_response(body) + defp handle_verification_job(%{"isJobCompleted" => true, "contract" => %{"match" => match}} = body, _id, _attempts) + when match in ["exact_match", "match"] do + {:ok, body} + end - url =~ "/sourcify/sources:verify" -> - parse_verify_http_response(body) + # Completed without an error but with no contract match means the contract was + # not verified, so surface it as a failure rather than a success. + defp handle_verification_job(%{"isJobCompleted" => true}, _id, _attempts) do + {:error, @failed_verification_message} + end - url =~ "/files/any" -> - parse_get_metadata_any_http_response(body) + # Not completed yet (or an unexpected shape): wait and poll again. + defp handle_verification_job(_body, verification_id, attempts_left) do + Process.sleep(poll_interval_ms()) + poll_verification(verification_id, attempts_left - 1) + end - url =~ "/files/" -> - parse_get_metadata_http_response(body) + defp verification_error_message(%{"message" => message}) when is_binary(message), do: message + defp verification_error_message(error) when is_binary(error), do: error + defp verification_error_message(_error), do: @failed_verification_message - true -> - {:error, body} - end - end + # Fetches a contract from the v2 `GET /v2/contract/{chainId}/{address}` endpoint. + # `fields == :with_sources` additionally requests the `sources` and `metadata` fields. + defp do_lookup(address_hash_string, fields) do + params = if fields == :with_sources, do: [fields: "sources,metadata"], else: [] - defp parse_verify_http_response(body) do - body_json = ExplorerHelper.decode_json(body) + case HttpClient.get(contract_lookup_url(address_hash_string), [], params: params) do + {:ok, %{body: body, status_code: 200}} -> + parse_lookup_response(ExplorerHelper.decode_json(body)) - case body_json do - # Success status from native Sourcify server - %{"result" => [%{"status" => "perfect"}]} -> - {:ok, body_json} + {:ok, %{status_code: 404}} -> + :not_verified - # Success status code from Rust microservice - %{"status" => "SUCCESS"} -> - {:ok, body_json} + {:ok, %{body: body, status_code: status_code}} when status_code in 400..526 -> + parse_http_error_response(body) - %{"status" => "FAILURE", "message" => message} -> - {:error, message} + {:ok, %{status_code: status_code}} when status_code in 300..308 -> + {:error, "Sourcify redirected"} - %{"result" => [%{"status" => unknown_status}]} -> - {:error, unknown_status} + {:ok, %{status_code: _status_code}} -> + {:error, "Sourcify unexpected status code"} - body -> - {:error, body} + {:error, reason} -> + {:error, reason} + + _ -> + {:error, "Unexpected response from Sourcify"} end end - defp parse_check_by_address_http_response(body) do - body_json = ExplorerHelper.decode_json(body) + defp parse_lookup_response(%{"match" => match} = body) when match in ["exact_match", "match"] do + {:ok, match, body} + end - case body_json do - [%{"status" => "perfect"}] -> - {:ok, body_json} + defp parse_lookup_response(%{"match" => nil}), do: :not_verified - [%{"status" => "false"}] -> - {:error, "Contract is not verified"} + defp parse_lookup_response(_body), do: {:error, "Unexpected response from Sourcify"} - [%{"status" => unknown_status}] -> - {:error, unknown_status} + # Rebuilds the legacy v1 metadata-file-list `[%{"name","path","content"}]` from the + # v2 `sources` map and `metadata` object so that `parse_params_from_sourcify/2` keeps + # working unchanged. The metadata content must be a JSON string because the parser + # decodes it downstream. + defp reconstruct_file_list(body_json) do + sources = Map.get(body_json, "sources") || %{} + metadata = Map.get(body_json, "metadata") - body -> - {:error, body} + source_files = + Enum.map(sources, fn {path, content} -> + %{"name" => Path.basename(path), "path" => path, "content" => source_content(content)} + end) + + if is_map(metadata) do + metadata_file = %{ + "name" => "metadata.json", + "path" => "metadata.json", + "content" => Utils.JSON.encode!(metadata) + } + + [metadata_file | source_files] + else + source_files end end - defp parse_get_metadata_http_response(body) do - body_json = ExplorerHelper.decode_json(body) + defp source_content(%{"content" => content}), do: content + defp source_content(content) when is_binary(content), do: content + defp source_content(content), do: content - case body_json do - %{"message" => message, "errors" => errors} -> - {:error, "#{message}: #{ExplorerHelper.decode_json(errors)}"} + def http_post_request_rust_microservice(url, body) do + request = + HttpClient.post(url, Utils.JSON.encode!(body), [{"Content-Type", "application/json"}], + recv_timeout: @post_timeout + ) - metadata -> - {:ok, metadata} + case request do + {:ok, %{body: body, status_code: 200}} -> + parse_verify_http_response(body) + + _ -> + {:error, "Unexpected response from Sourcify verify method"} end end - defp parse_get_metadata_any_http_response(body) do + defp parse_verify_http_response(body) do body_json = ExplorerHelper.decode_json(body) case body_json do - %{"message" => message, "errors" => errors} -> - {:error, "#{message}: #{ExplorerHelper.decode_json(errors)}"} + # Success status code from Rust microservice + %{"status" => "SUCCESS"} -> + {:ok, body_json} - %{"status" => status, "files" => metadata} -> - {:ok, status, metadata} + %{"status" => "FAILURE", "message" => message} -> + {:error, message} - _ -> - {:error, "Unknown Error"} + body -> + {:error, body} end end @@ -296,7 +385,7 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do body_json = ExplorerHelper.decode_json(body) if is_map(body_json) do - error = body_json["error"] + error = body_json["error"] || body_json["message"] parse_http_error_response_internal(error) else @@ -396,19 +485,16 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do end defp prepare_additional_source(address_hash_string, %{"name" => _name, "content" => content, "path" => path}) do - trimmed_path = - path - |> String.split("/") - |> Enum.slice(9..-1//-1) - |> Enum.join("/") - %{ "address_hash" => address_hash_string, - "file_name" => "/" <> trimmed_path, + "file_name" => normalize_source_path(path), "contract_source_code" => content } end + defp normalize_source_path("/" <> _rest = path), do: path + defp normalize_source_path(path), do: "/" <> path + defp extract_primary_source_code(content, params) do params |> Map.put("contract_source_code", content) @@ -424,26 +510,30 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do config(__MODULE__, :server_url) end - defp verify_url do - "#{base_server_url()}" <> "/verify" + defp contract_lookup_url(address_hash_string) do + chain_id = config(__MODULE__, :chain_id) + "#{base_server_url()}/v2/contract/#{chain_id}/#{address_hash_string}" + end + + defp verify_metadata_url(address_hash_string) do + chain_id = config(__MODULE__, :chain_id) + "#{base_server_url()}/v2/verify/metadata/#{chain_id}/#{address_hash_string}" end - defp verify_url_rust_microservice do - "#{RustVerifierInterface.base_api_url()}" <> "/verifier/sourcify/sources:verify" + defp verify_job_url(verification_id) do + "#{base_server_url()}/v2/verify/#{verification_id}" end - defp check_by_address_url do - "#{base_server_url()}" <> "/check-by-addresses" + defp verify_url_rust_microservice do + "#{RustVerifierInterface.base_api_url()}" <> "/verifier/sourcify/sources:verify" end - defp get_metadata_url do - chain_id = config(__MODULE__, :chain_id) - "#{base_server_url()}" <> "/files/" <> chain_id + defp poll_interval_ms do + config(__MODULE__, :verification_poll_interval_ms) || @default_poll_interval_ms end - defp get_metadata_any_url do - chain_id = config(__MODULE__, :chain_id) - "#{base_server_url()}" <> "/files/any/" <> chain_id + defp poll_max_attempts do + config(__MODULE__, :verification_max_attempts) || @default_poll_max_attempts end def no_metadata_message, do: @no_metadata_message diff --git a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex index a3fab5dfb46..513f68abe95 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex @@ -374,7 +374,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do end defp safe_parse_config_string(config_string, update_cache? \\ false) do - case Jason.decode(config_string) do + case Utils.JSON.decode(config_string) do {:ok, config} -> if update_cache?, do: :persistent_term.put(@cache_name, config_string) config diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index a6610697f78..53b63a5be67 100644 --- a/apps/explorer/lib/explorer/token/metadata_retriever.ex +++ b/apps/explorer/lib/explorer/token/metadata_retriever.ex @@ -7,15 +7,23 @@ defmodule Explorer.Token.MetadataRetriever do require Logger alias Explorer.Chain.{Hash, Token} + alias Explorer.Chain.Token.ScaledUIAmount alias Explorer.Helper, as: ExplorerHelper - alias Explorer.{HttpClient, MetadataURIValidator} + alias Explorer.HttpClient alias Explorer.SmartContract.Reader + alias Utils.HttpClient.SafeFetch + + # Reasons `Utils.UrlValidator` can reject a URL. These are reported as bare strings (rather + # than `inspect/1`-ed like transport errors) since `Explorer.Chain.Token.Instance` matches + # them to decide retry/ban intervals. + @url_validation_errors [:not_printable, :empty_host, :disallowed_protocol, :nxdomain, :blacklist] @no_uri_error "no uri" @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.3.3"}] + @invalid_swarm_path "invalid swarm path" + @default_headers [{"User-Agent", "blockscout-12.0.0"}] # https://eips.ethereum.org/EIPS/eip-1155#metadata @erc1155_token_id_placeholder "{id}" @@ -129,6 +137,20 @@ defmodule Explorer.Token.MetadataRetriever do @total_supply_signature => [] } + # The order the base functions are requested in and their results are read + # back in. `Reader.query_contracts/2` answers with a flat list, so the zip that + # restores the mapping has to know the exact order rather than depend on the + # iteration order of `@contract_functions`. + @base_signatures [@name_signature, @total_supply_signature, @decimals_signature, @symbol_signature] + + @supports_interface_signature ScaledUIAmount.supports_interface_signature() + @ui_multiplier_signature ScaledUIAmount.ui_multiplier_signature() + @new_ui_multiplier_signature ScaledUIAmount.new_ui_multiplier_signature() + @effective_at_signature ScaledUIAmount.effective_at_signature() + @ui_multiplier_functions Map.new(ScaledUIAmount.signatures(), &{&1, []}) + + @full_contract_abi @contract_abi ++ ScaledUIAmount.contract_abi() + # e8a3d485 = keccak256(contractURI()) @erc1155_contract_uri_signature "e8a3d485" @erc1155_contract_uri_function %{ @@ -176,34 +198,18 @@ defmodule Explorer.Token.MetadataRetriever do def get_functions_of(tokens, opts \\ []) def get_functions_of(tokens, _opts) when is_list(tokens) do - requests = - tokens - |> Enum.flat_map(fn token -> - @contract_functions - |> Enum.map(fn {method_id, args} -> - %{contract_address: token.contract_address_hash, method_id: method_id, args: args} - end) - end) - - hashes = Enum.map(tokens, fn token -> token.contract_address_hash end) - updated_at = DateTime.utc_now() fetched_result = - requests - |> Reader.query_contracts(@contract_abi) - |> Enum.chunk_every(4) - |> Enum.zip(hashes) - |> Enum.map(fn {result, hash} -> - formatted_result = - [@name_signature, @total_supply_signature, @decimals_signature, @symbol_signature] - |> Enum.zip(result) - |> format_contract_functions_result(hash) - - formatted_result + tokens + |> Enum.map(&{&1.contract_address_hash, signatures_of(&1)}) + |> query_per_token() + |> Enum.map(fn {hash, metadata} -> + metadata |> Map.put(:contract_address_hash, hash) |> Map.put(:updated_at, updated_at) end) + |> read_scaled_ui_amount() erc_1155_tokens = tokens |> Enum.filter(fn token -> token.type == "ERC-1155" end) @@ -253,6 +259,84 @@ defmodule Explorer.Token.MetadataRetriever do end end + # ERC-8056 extends ERC-20 only, so the ERC-165 probe rides along in the batch + # that is sent anyway for ERC-20 tokens, costing one more `eth_call` inside an + # existing batch request and no extra round trip. + # + # It is deliberately not asked on the single token code path: that one retries + # every reverting call `:token_functions_reader_max_retries` times, and for a + # contract without ERC-165 the probe does revert. Such a token gets its + # multiplier on the next metadata refresh, or at once if it emits + # `UIMultiplierUpdated`. + defp signatures_of(%{type: type}) when type in ["ERC-20", "ERC-8056"], + do: @base_signatures ++ [@supports_interface_signature] + + defp signatures_of(_token), do: @base_signatures + + defp query_per_token([]), do: [] + + defp query_per_token(tokens_with_signatures) do + requests = + Enum.flat_map(tokens_with_signatures, fn {contract_address_hash, signatures} -> + Enum.map(signatures, &request(contract_address_hash, &1)) + end) + + requests + |> Reader.query_contracts(@full_contract_abi) + |> split_results_per_token(tokens_with_signatures) + |> Enum.map(fn {hash, signatures, results} -> + {hash, signatures |> Enum.zip(results) |> format_contract_functions_result(hash)} + end) + end + + defp request(contract_address_hash, @supports_interface_signature) do + %{ + contract_address: contract_address_hash, + method_id: @supports_interface_signature, + args: [ScaledUIAmount.interface_id()] + } + end + + defp request(contract_address_hash, method_id), + do: %{contract_address: contract_address_hash, method_id: method_id, args: []} + + # ERC-8056 makes ERC-165 detection mandatory, so the getters are only worth + # reading — and the type only worth assigning — once the contract has claimed + # the interface. That keeps an ordinary ERC-20 to a single extra call instead + # of three reverting ones, and stops a contract that happens to expose a + # `uiMultiplier()` of its own from being relabelled ERC-8056. + defp read_scaled_ui_amount(results) do + supporting_hashes = + results + |> Enum.filter(& &1[:supports_scaled_ui_amount]) + |> Enum.map(& &1.contract_address_hash) + + multipliers = + supporting_hashes + |> Enum.map(&{&1, ScaledUIAmount.signatures()}) + |> query_per_token() + |> Map.new() + + Enum.map(results, fn metadata -> + metadata + |> Map.merge(Map.get(multipliers, metadata.contract_address_hash, %{})) + |> put_scaled_ui_amount_type(metadata[:supports_scaled_ui_amount]) + |> Map.delete(:supports_scaled_ui_amount) + end) + end + + defp put_scaled_ui_amount_type(metadata, true), do: Map.put(metadata, :type, "ERC-8056") + defp put_scaled_ui_amount_type(metadata, _supports?), do: metadata + + defp split_results_per_token(results, tokens_with_signatures) do + tokens_with_signatures + |> Enum.map_reduce(results, fn {hash, signatures}, rest -> + {token_results, remaining} = Enum.split(rest, length(signatures)) + {{hash, signatures, token_results}, remaining} + end) + |> elem(0) + end + defp contract_failure?({:error, %{message: message}}) when is_binary(message), do: String.match?(message, ~r/execution.*revert/) @@ -333,6 +417,77 @@ defmodule Explorer.Token.MetadataRetriever do |> format_contract_functions_result(contract_address_hash) end + @doc """ + Whether the contract claims the + [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) `IScaledUIAmount` + interface through ERC-165. + + This is the single authority on whether a contract is an ERC-8056 token. + Nothing else qualifies: a `uiMultiplier()` that answers may well mean + something entirely different, and a log carrying the `UIMultiplierUpdated` + topic proves nothing at all, since any contract can emit any topic it likes. + + Anything that is not a definitive answer counts as "no", which suits a caller + that will look again later. A caller that will not — a migration about to + checkpoint past the contract, say — must use `scaled_ui_amount_support/1` and + treat a failed lookup differently from a denied claim. + """ + @spec supports_scaled_ui_amount?(String.t()) :: boolean() + def supports_scaled_ui_amount?(contract_address_hash) when is_binary(contract_address_hash) do + scaled_ui_amount_support(contract_address_hash) == {:ok, true} + end + + @doc """ + Same as `supports_scaled_ui_amount?/1` but keeps a failed lookup apart from a + denied claim. + + Returns `{:ok, true}` or `{:ok, false}` when the contract answered — a revert + counts as an answer, since a contract without ERC-165 is definitively not an + ERC-8056 token — and `:error` when the node did not, so the caller can try + again rather than record a "no" that is really "unknown". + """ + @spec scaled_ui_amount_support(String.t()) :: {:ok, boolean()} | :error + def scaled_ui_amount_support(contract_address_hash) when is_binary(contract_address_hash) do + contract_address_hash + |> Reader.query_contract( + @full_contract_abi, + %{@supports_interface_signature => [ScaledUIAmount.interface_id()]}, + false + ) + |> Map.get(@supports_interface_signature) + |> case do + {:ok, [supports?]} when is_boolean(supports?) -> + {:ok, supports?} + + {:error, :invalid_data} -> + {:ok, false} + + {:error, _reason} = error -> + if contract_failure?(error), do: {:ok, false}, else: :error + + _other -> + :error + end + end + + @doc """ + Reads the [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) multiplier of a + token: `uiMultiplier()`, `newUIMultiplier()` and `effectiveAt()`. + + Returns a map with `:ui_multiplier`, `:new_ui_multiplier` and + `:ui_multiplier_effective_at`, each present only if its call succeeded — an + empty map means the token does not implement ERC-8056. `newUIMultiplier()` and + `effectiveAt()` belong to a required extension of the standard, but are + tolerated to be missing so that a token exposing the bare `uiMultiplier()` is + still displayed correctly, just without the scheduled change. + """ + @spec get_ui_multiplier_of(String.t()) :: map() + def get_ui_multiplier_of(contract_address_hash) when is_binary(contract_address_hash) do + contract_address_hash + |> fetch_functions_from_contract(@ui_multiplier_functions) + |> format_contract_functions_result(contract_address_hash) + end + defp fetch_functions_from_contract(contract_address_hash, contract_functions) do max_retries = Application.get_env(:explorer, :token_functions_reader_max_retries) @@ -343,7 +498,8 @@ defmodule Explorer.Token.MetadataRetriever do defp fetch_functions_with_retries(contract_address_hash, contract_functions, accumulator, retries_left) when retries_left > 0 do - contract_functions_result = Reader.query_contract(contract_address_hash, @contract_abi, contract_functions, false) + contract_functions_result = + Reader.query_contract(contract_address_hash, @full_contract_abi, contract_functions, false) functions_with_errors = Enum.filter(contract_functions_result, fn function -> @@ -404,6 +560,7 @@ defmodule Explorer.Token.MetadataRetriever do |> handle_invalid_strings(contract_address_hash) |> handle_large_strings() |> limit_decimals() + |> convert_ui_multiplier_effective_at() end defp atomized_key(@name_signature), do: :name @@ -411,6 +568,23 @@ defmodule Explorer.Token.MetadataRetriever do defp atomized_key(@decimals_signature), do: :decimals defp atomized_key(@total_supply_signature), do: :total_supply defp atomized_key(@erc1155_contract_uri_signature), do: :name + defp atomized_key(@supports_interface_signature), do: :supports_scaled_ui_amount + defp atomized_key(@ui_multiplier_signature), do: :ui_multiplier + defp atomized_key(@new_ui_multiplier_signature), do: :new_ui_multiplier + defp atomized_key(@effective_at_signature), do: :ui_multiplier_effective_at + + defp convert_ui_multiplier_effective_at(%{ui_multiplier_effective_at: timestamp} = contract_functions) + when is_integer(timestamp) do + case ScaledUIAmount.effective_at_from_unix(timestamp) do + {:ok, effective_at} -> + %{contract_functions | ui_multiplier_effective_at: effective_at} + + {:error, _reason} -> + Map.delete(contract_functions, :ui_multiplier_effective_at) + end + end + + defp convert_ui_multiplier_effective_at(contract_functions), do: contract_functions # It's a temp fix to store tokens that have names and/or symbols with characters that the database # doesn't accept. See https://github.com/blockscout/blockscout/issues/669 for more info. @@ -554,6 +728,42 @@ defmodule Explorer.Token.MetadataRetriever do "https://arweave.net/#{uid}" end + @doc """ + Generates an ETH Swarm gateway link for the given hash. + + The hash can be a 64-character hex Swarm hash (keccak-256 based) or any + Swarm-compatible identifier (e.g. from SOC or feeds). The configured + gateway URL defaults to `https://gateway.ethswarm.org`. + + ## Parameters + - uid: The Swarm hash or resource identifier. + + ## Returns + - A string representing the full URL to the resource on the Swarm gateway. + + ## Examples + + iex> swarm_link("1234abcd" <> String.duplicate("0", 56)) + "https://gateway.ethswarm.org/bzz/1234abcd" <> String.duplicate("0", 56) <> "/" + + """ + @spec swarm_link(uid :: any()) :: String.t() + def swarm_link(uid) do + uid = to_string(uid) + + base_url = + :indexer + |> Application.get_env(:swarm, []) + |> Keyword.get(:gateway_url, "https://gateway.ethswarm.org") + |> String.trim_trailing("/") + + if String.contains?(uid, "/") do + "#{base_url}/bzz/#{uid}" + else + "#{base_url}/bzz/#{uid}/" + end + end + defp maybe_add_ipfs_gateway_params_to_url?(url, true), do: url defp maybe_add_ipfs_gateway_params_to_url?(url, _) do @@ -623,6 +833,128 @@ defmodule Explorer.Token.MetadataRetriever do @default_headers end + @type nft_url_class :: + {:ipfs, binary() | nil} + | {:arweave, binary()} + | {:swarm, binary() | nil} + | {:regular, binary()} + | {:bare_path, binary() | nil} + + @doc """ + Classifies an NFT URL into one of: `{:ipfs, resource_id}`, `{:arweave, resource_id}`, `{:regular, url}`, or `{:bare_path, path}`. + """ + @spec classify_nft_url(binary()) :: nft_url_class() + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def classify_nft_url(url) do + case URI.parse(url) do + %URI{scheme: "ipfs", host: host, path: path} -> + {:ipfs, extract_ipfs_resource_id(host, path)} + + %URI{scheme: "ar", host: host, path: path} when is_binary(host) and host != "" -> + resource_id = if is_binary(path) and path != "", do: host <> path, else: host + {:arweave, resource_id} + + %URI{scheme: "bzz", host: host, path: path} -> + uid = + cond do + is_binary(host) and is_binary(path) -> host <> path + is_binary(host) -> host + true -> nil + end + + {:swarm, uid} + + %URI{scheme: _, path: "/ipfs/" <> resource_id} -> + {:ipfs, resource_id} + + %URI{scheme: _, path: "ipfs/" <> resource_id} -> + {:ipfs, resource_id} + + %URI{scheme: _, path: "/bzz/" <> resource_id} -> + {:swarm, resource_id} + + %URI{scheme: scheme} when not is_nil(scheme) -> + {:regular, url} + + %URI{path: path} -> + {:bare_path, path} + end + end + + @doc """ + Resolves an NFT media URL to a fetchable gateway URL with appropriate headers (e.g. IPFS gateway, Arweave gateway). + """ + @spec resolve_nft_media_url(binary()) :: {binary(), list()} + # credo:disable-for-next-line /Complexity/ + def resolve_nft_media_url(url) do + case classify_nft_url(url) do + {:ipfs, resource_id} -> + if is_binary(resource_id) and valid_ipfs_path?(public_ipfs_link(resource_id)) do + {ipfs_link(resource_id), ipfs_headers()} + else + {url, []} + end + + {:arweave, resource_id} -> + {arweave_link(resource_id), ar_headers()} + + {:swarm, resource_id} -> + if is_binary(resource_id) do + {swarm_link(resource_id), swarm_headers()} + else + {url, []} + end + + {:regular, url} -> + {url, []} + + {:bare_path, path} -> + if is_binary(path) and valid_ipfs_path?(public_ipfs_link(path)) do + {ipfs_link(path), ipfs_headers()} + else + {url, []} + end + end + end + + defp extract_ipfs_resource_id(host, path) do + cond do + host == "ipfs" and is_binary(path) and String.starts_with?(path, "/") -> + String.replace_leading(path, "/", "") + + is_binary(host) and host != "" -> + if is_nil(path), do: host, else: host <> path + + true -> + path + end + end + + @doc """ + Returns the headers for making requests to the ETH Swarm gateway. + + If `INDEXER_SWARM_GATEWAY_BEARER_TOKEN` is configured the token is sent as + an `Authorization: Bearer <token>` header, which is the mechanism used by + private / gateway-as-a-service Swarm nodes. + + ## Examples + + iex> Explorer.Token.MetadataRetriever.swarm_headers() + [{"User-Agent", "blockscout-11.0.1"}] + + """ + @spec swarm_headers() :: [{binary(), binary()}] + def swarm_headers do + swarm_params = Application.get_env(:indexer, :swarm, []) + bearer_token = Keyword.get(swarm_params, :bearer_token) + + if is_binary(bearer_token) and String.trim(bearer_token) != "" do + [{"Authorization", "Bearer #{bearer_token}"} | @default_headers] + else + @default_headers + end + end + @doc """ Fetch/parse metadata using smart-contract's response """ @@ -720,38 +1052,20 @@ defmodule Explorer.Token.MetadataRetriever do # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp fetch_from_ipfs_or_ar?(token_uri_string, ipfs_params, token_id, hex_token_id, from_base_uri?) do - case URI.parse(token_uri_string) do - %URI{scheme: "ipfs", host: host, path: path} -> - resource_id = - cond do - host == "ipfs" and is_binary(path) and String.starts_with?(path, "/") -> - String.replace_leading(path, "/", "") - - is_binary(host) and is_binary(path) -> - host <> path - - is_binary(host) and is_nil(path) -> - host - - true -> - nil - end - + case classify_nft_url(token_uri_string) do + {:ipfs, resource_id} -> fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) - %URI{scheme: "ar", host: _host, path: resource_id} -> + {:arweave, resource_id} -> fetch_from_arweave(resource_id, hex_token_id) - %URI{scheme: _, path: "/ipfs/" <> resource_id} -> - fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) - - %URI{scheme: _, path: "ipfs/" <> resource_id} -> - fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) + {:swarm, resource_id} -> + fetch_from_swarm_if_valid_hash(resource_id, hex_token_id) - %URI{scheme: scheme} when not is_nil(scheme) -> - fetch_metadata_inner(token_uri_string, ipfs_params, token_id, hex_token_id, from_base_uri?) + {:regular, url} -> + fetch_metadata_inner(url, ipfs_params, token_id, hex_token_id, from_base_uri?) - %URI{path: path} -> + {:bare_path, path} -> if is_binary(path) and valid_ipfs_path?(public_ipfs_link(path)) do fetch_from_ipfs(path, hex_token_id) else @@ -799,6 +1113,19 @@ defmodule Explorer.Token.MetadataRetriever do fetch_metadata_inner(arweave_url, ipfs_params, nil, hex_token_id) end + defp fetch_from_swarm(uid, hex_token_id) do + url = swarm_link(uid) + fetch_metadata_inner(url, [ipfs?: false, swarm?: true], nil, hex_token_id) + end + + defp fetch_from_swarm_if_valid_hash(uid, hex_token_id) do + if is_binary(uid) and valid_swarm_hash?(uid) do + fetch_from_swarm(uid, hex_token_id) + else + {:error, @invalid_swarm_path} + end + end + defp fetch_metadata_inner(uri, ipfs_params, token_id, hex_token_id, from_base_uri? \\ false) defp fetch_metadata_inner(uri, ipfs_params, token_id, hex_token_id, from_base_uri?) do @@ -839,34 +1166,28 @@ defmodule Explorer.Token.MetadataRetriever do """ @spec fetch_metadata_from_uri(String.t(), keyword(), String.t() | nil) :: {:ok, %{metadata: any}} | {:error, binary()} def fetch_metadata_from_uri(uri, ipfs_params, hex_token_id \\ nil) do - case Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper)[:host_filtering_enabled?] && - !ipfs?(ipfs_params) && !arweave?(ipfs_params) && MetadataURIValidator.validate_uri(uri) do - {:error, reason} -> - if reason == :blacklist do - Logger.warning( - [ - "Request to token uri failed: #{inspect(uri)}.", - "Host is blacklisted.", - "To disable IPs blacklisting set INDEXER_TOKEN_INSTANCE_HOST_FILTERING_ENABLED=false" - ], - fetcher: :token_instances - ) - end - - {:error, reason |> to_string() |> truncate_error()} - - _ -> - fetch_metadata_from_uri_request(uri, hex_token_id, ipfs_params) - end + fetch_metadata_from_uri_request(uri, hex_token_id, ipfs_params) end + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp fetch_metadata_from_uri_request(uri, hex_token_id, ipfs_params) do - headers = if ipfs?(ipfs_params), do: ipfs_headers(), else: @default_headers + headers = + cond do + ipfs?(ipfs_params) -> ipfs_headers() + swarm?(ipfs_params) -> swarm_headers() + true -> @default_headers + end - case HttpClient.get(uri, headers, - recv_timeout: 30_000, - follow_redirect: true, - pool: :token_instance_fetcher + case SafeFetch.request( + uri, + headers, + [ + validate_host?: regular_url?(ipfs_params), + transport_opts: [recv_timeout: 30_000, pool: :token_instance_fetcher] + ], + fn request_uri, request_headers, request_opts -> + HttpClient.get(request_uri, request_headers, request_opts) + end ) do {:ok, %{body: body, status_code: 200, headers: response_headers}} -> content_type = get_content_type_from_headers(response_headers) @@ -887,6 +1208,20 @@ defmodule Explorer.Token.MetadataRetriever do {:error_code, code} + {:error, reason} when reason in @url_validation_errors -> + if reason == :blacklist do + Logger.warning( + [ + "Request to token uri failed: #{inspect(uri)}.", + "Host is blacklisted.", + "To disable IPs blacklisting set INDEXER_TOKEN_INSTANCE_HOST_FILTERING_ENABLED=false" + ], + fetcher: :token_instances + ) + end + + {:error, reason |> to_string() |> truncate_error()} + {:error, reason} -> Logger.warning( ["Request to token uri failed: #{inspect(uri)}.", inspect(reason)], @@ -906,7 +1241,7 @@ defmodule Explorer.Token.MetadataRetriever do end defp process_result(metadata, uri, ipfs_params) do - if arweave?(ipfs_params) || ipfs?(ipfs_params) do + if arweave?(ipfs_params) || ipfs?(ipfs_params) || swarm?(ipfs_params) do {:ok, metadata} else {:ok_store_uri, metadata, uri} @@ -921,6 +1256,17 @@ defmodule Explorer.Token.MetadataRetriever do Keyword.get(ipfs_params, :arweave?) end + defp swarm?(ipfs_params) do + Keyword.get(ipfs_params, :swarm?) + end + + # A regular (attacker-controlled) URL, as opposed to one resolved to an operator-trusted + # IPFS/Arweave/Swarm gateway. Only regular URLs have their host validated against the + # SSRF blacklist. + defp regular_url?(ipfs_params) do + !(ipfs?(ipfs_params) || arweave?(ipfs_params) || swarm?(ipfs_params)) + end + defp check_content_type(content_type, uri, hex_token_id, body, ipfs_params) do image = image?(content_type) video = video?(content_type) @@ -961,9 +1307,9 @@ defmodule Explorer.Token.MetadataRetriever do defp check_type(json, hex_token_id) when is_map(json) do metadata = case json - |> Jason.encode!() + |> Utils.JSON.encode!() |> String.replace(@erc1155_token_id_placeholder, hex_token_id) - |> Jason.decode() do + |> Utils.JSON.decode() do {:ok, map} -> map @@ -1008,6 +1354,22 @@ defmodule Explorer.Token.MetadataRetriever do def valid_ipfs_path?(_), do: false + @doc """ + Returns `true` when `hash` is a valid ETH Swarm keccak-256 content address. + + Swarm hashes (both legacy and new-style) are 64 lower-case hex characters. + The function also accepts hashes embedded in a path (e.g. + `"<hash>/path/to/file"`) by checking only the first path segment, so that + deep-linked Swarm URLs work correctly. + """ + @spec valid_swarm_hash?(binary()) :: boolean() + def valid_swarm_hash?(hash) when is_binary(hash) do + bare = hash |> String.split("/") |> List.first() + String.match?(bare, ~r/^[0-9a-f]{64}$/) + end + + def valid_swarm_hash?(_), do: false + defp fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) do if is_binary(resource_id) and valid_ipfs_path?(public_ipfs_link(resource_id)) do fetch_from_ipfs(resource_id, hex_token_id) diff --git a/apps/explorer/lib/explorer/tuple_encoder.ex b/apps/explorer/lib/explorer/tuple_encoder.ex index 4705a3a14f1..7dd2e812d29 100644 --- a/apps/explorer/lib/explorer/tuple_encoder.ex +++ b/apps/explorer/lib/explorer/tuple_encoder.ex @@ -1,15 +1,14 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule TupleEncoder do @moduledoc """ - Implementation of Jason.Encoder for Tuple + Implementation of JSON.Encoder for Tuple """ - alias Jason.{Encode, Encoder} - defimpl Encoder, for: Tuple do - def encode(value, opts) when is_tuple(value) do + defimpl JSON.Encoder, for: Tuple do + def encode(value, encoder) when is_tuple(value) do value |> Tuple.to_list() - |> Encode.list(opts) + |> JSON.Encoder.encode(encoder) end end end diff --git a/apps/explorer/lib/explorer/utility/log_first_topic.ex b/apps/explorer/lib/explorer/utility/log_first_topic.ex new file mode 100644 index 00000000000..30fbbda17c3 --- /dev/null +++ b/apps/explorer/lib/explorer/utility/log_first_topic.ex @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.LogFirstTopic do + @moduledoc """ + Module is responsible for keeping the log first_topic value to id correspondence. + """ + + use Explorer.Schema + + alias Explorer.Chain.Hash + alias Explorer.Repo + + typed_schema "log_first_topics" do + field(:value, Hash.Full) + + timestamps(updated_at: false) + end + + @doc false + @spec changeset(__MODULE__.t(), map()) :: Ecto.Changeset.t() + def changeset(log_first_topic \\ %__MODULE__{}, params) do + cast(log_first_topic, params, [:value]) + end + + @doc """ + Retrieves the first_topic id for a given first_topic value. + + ## Parameters + - `value`: The value to look up + + ## Returns + - The first_topic id if found, nil otherwise + """ + @spec value_to_id(Hash.Full.t() | binary()) :: integer() | nil + def value_to_id(nil), do: nil + + def value_to_id(value) do + [value] + |> values_to_ids() + |> List.first() + end + + @doc """ + Retrieves all first_topic ids for the given first_topic values. + + ## Parameters + - `values`: A list of first_topic values to look up + + ## Returns + - A list of first_topic ids for the matching mappings + """ + @spec values_to_ids([Hash.Full.t() | binary()]) :: [integer()] + def values_to_ids(values) do + __MODULE__ + |> where([t], t.value in ^values) + |> select([t], t.id) + |> Repo.all() + end + + @doc """ + Retrieves first topic mappings by their ids. + + ## Parameters + - `ids`: A list of first topic mapping ids to look up + + ## Returns + - A list of `%Explorer.Utility.LogFirstTopic{}` structs for the matching ids + """ + @spec fetch_by_ids([integer()]) :: [__MODULE__.t()] + def fetch_by_ids(ids) do + __MODULE__ + |> where([t], t.id in ^ids) + |> Repo.all() + end + + @doc """ + Finds the mapping for the given first topic or creates it if it does not yet + exist. + + This function is a convenience wrapper around `find_or_create_multiple/2` + for a single first topic. + + ## Parameters + - `first_topic`: The first topic to look up or create a mapping for + + ## Returns + - An `%Explorer.Utility.LogFirstTopic{}` struct for the given topic + - `nil` if `first_topic` is `nil` + """ + @spec find_or_create(Hash.Full.t() | nil) :: __MODULE__.t() | nil + def find_or_create(first_topic) do + [first_topic] + |> find_or_create_multiple(false) + |> List.first() + end + + @doc """ + Finds or creates mappings for the given first topics in bulk. + + The input is normalized by removing `nil` values, deduplicating hashes, and + casting each topic to `Hash.Full`. Missing mappings are inserted with + `on_conflict: :nothing`, so existing mappings are preserved. + + ## Parameters + - `first_topics`: A list of first topics to resolve + - `to_map?`: When `true`, returns a map of `%{first_topic => first_topic_id}`. + When `false`, returns the list of `%Explorer.Utility.LogFirstTopic{}` + records + + ## Returns + - A map of first topics to first topic ids when `to_map?` is `true` + - A list of `%Explorer.Utility.LogFirstTopic{}` structs when + `to_map?` is `false` + """ + @spec find_or_create_multiple([Hash.Full.t() | nil], true) :: %{optional(Hash.Full.t()) => integer()} + @spec find_or_create_multiple([Hash.Full.t() | nil], false) :: [__MODULE__.t()] + def find_or_create_multiple(first_topics, to_map? \\ true) do + filtered_first_topics = + first_topics + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.map(fn first_topic -> + {:ok, casted} = Hash.Full.cast(first_topic) + casted + end) + |> Enum.sort() + + Repo.safe_insert_all( + __MODULE__, + Enum.map(filtered_first_topics, &%{value: &1, inserted_at: DateTime.utc_now()}), + on_conflict: :nothing + ) + + __MODULE__ + |> where([t], t.value in ^filtered_first_topics) + |> Repo.all() + |> then(fn records -> + if to_map?, do: Map.new(records, &{to_string(&1.value), &1.id}), else: records + end) + end +end diff --git a/apps/explorer/lib/explorer/utility/log_helper.ex b/apps/explorer/lib/explorer/utility/log_helper.ex new file mode 100644 index 00000000000..78f976cbcca --- /dev/null +++ b/apps/explorer/lib/explorer/utility/log_helper.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.LogHelper do + @moduledoc """ + Logs helping functions. + """ + + alias Explorer.Chain.Cache.BackgroundMigrations + + @spec primary_key_updated? :: boolean() + def primary_key_updated? do + BackgroundMigrations.get_heavy_indexes_update_logs_primary_key_finished() + end + + @spec fill_optimized_fields_migration_finished? :: boolean() + def fill_optimized_fields_migration_finished? do + BackgroundMigrations.get_fill_logs_optimized_fields_finished() + end + + @spec fill_optimized_fields_migration_started? :: boolean() + def fill_optimized_fields_migration_started? do + BackgroundMigrations.get_create_logs_block_number_transaction_index_index_unique_index_finished() + end +end diff --git a/apps/explorer/lib/explorer/utility/massive_block.ex b/apps/explorer/lib/explorer/utility/massive_block.ex index cda8a29208e..3e5ca6a7bab 100644 --- a/apps/explorer/lib/explorer/utility/massive_block.ex +++ b/apps/explorer/lib/explorer/utility/massive_block.ex @@ -40,4 +40,33 @@ defmodule Explorer.Utility.MassiveBlock do |> where([mb], mb.number == ^number) |> Repo.delete_all() end + + @doc """ + Deletes the block numbers that don't fall into the given block ranges. + + ## Parameters + - `ranges`: The list of finite block ranges to keep. + - `open_range_boundary`: The highest block number that is not covered by the + trailing `..latest` range, if such a range is configured: every number above + it is kept. `nil` means that all the configured ranges are finite. + + ## Returns + - `{deleted_count, nil}` + """ + @spec delete_numbers_out_of_ranges([Range.t()], non_neg_integer() | nil) :: {non_neg_integer(), nil} + def delete_numbers_out_of_ranges(ranges, open_range_boundary \\ nil) do + base_query = + if is_nil(open_range_boundary), + do: __MODULE__, + else: where(__MODULE__, [mb], mb.number <= ^open_range_boundary) + + ranges + |> Enum.reduce(base_query, fn from..to//_, query -> + min_number = min(from, to) + max_number = max(from, to) + + where(query, [mb], mb.number < ^min_number or mb.number > ^max_number) + end) + |> Repo.delete_all() + end end diff --git a/apps/explorer/lib/explorer/utility/version_upgrade.ex b/apps/explorer/lib/explorer/utility/version_upgrade.ex index 46132c5da45..35e088959b5 100644 --- a/apps/explorer/lib/explorer/utility/version_upgrade.ex +++ b/apps/explorer/lib/explorer/utility/version_upgrade.ex @@ -18,6 +18,15 @@ defmodule Explorer.Utility.VersionUpgrade do * `:required_completed_migrations` — a list of migration names that must have status `"completed"` before the upgrade is allowed + Each entry of `:required_completed_migrations` is either: + + * a migration name — the migration is required on every chain type, or + * a `{migration_name, chain_types}` tuple — the migration is required only + when the current chain type is one of `chain_types`. This is meant for + migrations that are started only on some chain types (see + `Explorer.Application`), since on the other chain types they never reach + the `"completed"` status. + Rules are evaluated based on the target version. If multiple rules match, the most specific one (with the highest `:since` version) is applied. @@ -26,10 +35,18 @@ defmodule Explorer.Utility.VersionUpgrade do use GenServer + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] + alias Explorer.Application.Constants alias Explorer.Chain.Block alias Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey - alias Explorer.Migrator.MigrationStatus + + alias Explorer.Migrator.{ + FillInternalTransactionsAddressIds, + MigrationStatus, + SanitizeDuplicatedLogIndexLogs + } + alias Explorer.Repo @upgrade_rules [ @@ -37,6 +54,14 @@ defmodule Explorer.Utility.VersionUpgrade do since: "11.0.0", min_from: "10.2.3", required_completed_migrations: [UpdateInternalTransactionsPrimaryKey.migration_name()] + }, + %{ + since: "12.0.0", + min_from: "11.0.2", + required_completed_migrations: [ + FillInternalTransactionsAddressIds.migration_name(), + {SanitizeDuplicatedLogIndexLogs.migration_name(), [:rsk, :filecoin]} + ] } ] @@ -102,7 +127,10 @@ defmodule Explorer.Utility.VersionUpgrade do defp validate_required_migrations!(to_version, %{required_completed_migrations: migration_names}) do not_completed = - Enum.flat_map(migration_names, fn migration_name -> + migration_names + |> Enum.filter(&required_on_current_chain_type?/1) + |> Enum.map(&migration_name/1) + |> Enum.flat_map(fn migration_name -> status = MigrationStatus.get_status(migration_name) if status == "completed" do @@ -121,6 +149,12 @@ defmodule Explorer.Utility.VersionUpgrade do defp validate_required_migrations!(_to_version, _rule), do: :ok + defp required_on_current_chain_type?({_migration_name, chain_types}), do: chain_type() in chain_types + defp required_on_current_chain_type?(_migration_name), do: true + + defp migration_name({migration_name, _chain_types}), do: migration_name + defp migration_name(migration_name), do: migration_name + defp raise_wrong_version(from_version, to_version, min_from) do raise "Upgrade to #{to_version} is allowed only from version #{min_from} and higher. Current previous version: #{from_version || "(empty)"}" end diff --git a/apps/explorer/lib/explorer/validator/metadata_retriever.ex b/apps/explorer/lib/explorer/validator/metadata_retriever.ex index e189faf8ce6..83369cae3b7 100644 --- a/apps/explorer/lib/explorer/validator/metadata_retriever.ex +++ b/apps/explorer/lib/explorer/validator/metadata_retriever.ex @@ -96,6 +96,6 @@ defmodule Explorer.Validator.MetadataRetriever do :explorer |> Application.app_dir("priv/contracts_abi/poa/#{file_name}") |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() end end diff --git a/apps/explorer/lib/explorer/visualize/sol2uml.ex b/apps/explorer/lib/explorer/visualize/sol2uml.ex index c302148d8a4..24899993ef3 100644 --- a/apps/explorer/lib/explorer/visualize/sol2uml.ex +++ b/apps/explorer/lib/explorer/visualize/sol2uml.ex @@ -17,7 +17,7 @@ defmodule Explorer.Visualize.Sol2uml 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, Utils.JSON.encode!(body), headers, recv_timeout: @post_timeout) do {:ok, %{body: body, status_code: 200}} -> process_visualizer_response(body) @@ -45,7 +45,7 @@ defmodule Explorer.Visualize.Sol2uml do end def process_visualizer_response(body) when is_binary(body) do - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, decoded} -> process_visualizer_response(decoded) diff --git a/apps/explorer/lib/fetch_celo_core_contracts.ex b/apps/explorer/lib/fetch_celo_core_contracts.ex index d68dbc1fdaf..1aaac2709b9 100644 --- a/apps/explorer/lib/fetch_celo_core_contracts.ex +++ b/apps/explorer/lib/fetch_celo_core_contracts.ex @@ -152,7 +152,7 @@ defmodule Mix.Tasks.FetchCeloCoreContracts do atom_to_contract_name[:fee_handler] => fee_handler_events } } - |> Jason.encode!() + |> Utils.JSON.encode!() IO.puts("CELO_CORE_CONTRACTS=#{core_contracts_json}") end diff --git a/apps/explorer/lib/test_helper.ex b/apps/explorer/lib/test_helper.ex index c91a2b12ea9..f6382e6eff4 100644 --- a/apps/explorer/lib/test_helper.ex +++ b/apps/explorer/lib/test_helper.ex @@ -21,7 +21,9 @@ defmodule Explorer.TestHelper do def run_necessary_background_migrations do for background_migration <- [ Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, - Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey + Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey, + Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.UpdateLogsPrimaryKey ] do case background_migration.db_index_operation_status() do :completed -> @@ -246,7 +248,7 @@ defmodule Explorer.TestHelper do mocks |> Keyword.get(:eip1967, @zero_address_hash) |> encode_in_batch_response(id1), mocks |> Keyword.get(:eip1822, @zero_address_hash) |> encode_in_batch_response(id2), mocks |> Keyword.get(:eip1967_beacon, @zero_address_hash) |> encode_in_batch_response(id3), - %{id: id4, error: "error"}, + %{id: id4, error: %{code: 3, message: "execution reverted"}}, mocks |> Keyword.get(:eip1967_oz, @zero_address_hash) |> encode_in_batch_response(id5) ] ++ Enum.map(rest, fn diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index d8217a55e42..f8bdf3139eb 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.3.3", + version: "12.0.0", xref: [exclude: [BlockScoutWeb.Routers.WebRouter.Helpers, Indexer.Helper, Indexer.Fetcher.InternalTransaction]] ] end @@ -85,7 +85,6 @@ defmodule Explorer.Mixfile do {:ezstd, "~> 1.2"}, {:exvcr, "~> 0.10", only: :test}, {:httpoison, "~> 2.0"}, - {:jason, "~> 1.3"}, {:junit_formatter, ">= 0.0.0", only: [:test], runtime: false}, {:libcluster, "~> 3.5"}, # Log errors and application output to separate files @@ -94,7 +93,6 @@ defmodule Explorer.Mixfile do {:math, "~> 0.7.0"}, {:mock, "~> 0.3.0", only: [:test], runtime: false}, {:mox, "~> 1.3.2"}, - {:poison, "~> 5.0.0"}, {:nimble_csv, "~> 1.1"}, {:postgrex, ">= 0.0.0"}, {:prometheus, "~> 6.0", override: true}, @@ -132,14 +130,13 @@ defmodule Explorer.Mixfile do {:joken, "~> 2.6"}, {:joken_jwks, "~> 1.7.0"}, {:utils, in_umbrella: true}, - {:dns, "~> 2.4.0"}, - {:inet_cidr, "~> 1.0.0"}, {: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"} + {:oban, "~> 2.19"}, + {:nimble_lz4, "~> 1.1"} ] end diff --git a/apps/explorer/priv/celo/migrations/20260805120000_remove_transaction_index_not_null_constraint_from_logs.exs b/apps/explorer/priv/celo/migrations/20260805120000_remove_transaction_index_not_null_constraint_from_logs.exs new file mode 100644 index 00000000000..338d2b29923 --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20260805120000_remove_transaction_index_not_null_constraint_from_logs.exs @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.RemoveTransactionIndexNotNullConstraintFromLogs do + use Ecto.Migration + + def up do + drop_if_exists(constraint(:logs, :logs_transaction_index_not_null)) + end + + def down do + create( + constraint(:logs, :logs_transaction_index_not_null, + check: "transaction_index IS NOT NULL", + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250220074005_transactions_add_has_token_transfers.exs b/apps/explorer/priv/repo/migrations/20250220074005_transactions_add_has_token_transfers.exs new file mode 100644 index 00000000000..4f36f64b805 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250220074005_transactions_add_has_token_transfers.exs @@ -0,0 +1,9 @@ +defmodule Explorer.Repo.Migrations.TransactionsAddHasTokenTransfers do + use Ecto.Migration + + def change do + alter table(:transactions) do + add(:has_token_transfers, :boolean, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260413121113_smart_contracts_add_constructor_arguments_hex_field.exs b/apps/explorer/priv/repo/migrations/20260413121113_smart_contracts_add_constructor_arguments_hex_field.exs new file mode 100644 index 00000000000..075086cace6 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260413121113_smart_contracts_add_constructor_arguments_hex_field.exs @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.SmartContractsAddConstructorArgumentsHexField do + use Ecto.Migration + + def up do + alter table(:smart_contracts) do + add(:constructor_arguments_hex, :bytea) + end + + execute(""" + UPDATE smart_contracts + SET constructor_arguments_hex = decode(regexp_replace(constructor_arguments, '^0[xX]', ''), 'hex') + WHERE constructor_arguments IS NOT NULL + AND regexp_replace(constructor_arguments, '^0[xX]', '') ~ '^[0-9A-Fa-f]*$' + AND mod(length(regexp_replace(constructor_arguments, '^0[xX]', '')), 2) = 0; + """) + + alter table(:smart_contracts) do + remove(:constructor_arguments) + end + + execute("ALTER TABLE smart_contracts RENAME COLUMN constructor_arguments_hex TO constructor_arguments") + end + + def down do + alter table(:smart_contracts) do + add(:constructor_arguments_text, :text) + end + + execute(""" + UPDATE smart_contracts + SET constructor_arguments_text = encode(constructor_arguments, 'hex') + WHERE constructor_arguments IS NOT NULL; + """) + + alter table(:smart_contracts) do + remove(:constructor_arguments) + end + + execute("ALTER TABLE smart_contracts RENAME COLUMN constructor_arguments_text TO constructor_arguments") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260416172533_remove_legacy_optimism_portal_contract_constant.exs b/apps/explorer/priv/repo/migrations/20260416172533_remove_legacy_optimism_portal_contract_constant.exs new file mode 100644 index 00000000000..034a6c25799 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260416172533_remove_legacy_optimism_portal_contract_constant.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.RemoveLegacyOptimismPortalContractConstant do + use Ecto.Migration + + def up do + execute("DELETE FROM constants WHERE key = 'optimism_portal_contract_address'") + end + + def down do + :ok + end +end diff --git a/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs b/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs index 51be7d6dbae..c3d8d331a53 100644 --- a/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs +++ b/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ResetNftsSkipMetadata do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20260513120000_add_media_types_to_token_instances.exs b/apps/explorer/priv/repo/migrations/20260513120000_add_media_types_to_token_instances.exs new file mode 100644 index 00000000000..f526c24e386 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260513120000_add_media_types_to_token_instances.exs @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddMediaTypesToTokenInstances do + use Ecto.Migration + + def change do + alter table(:token_instances) do + add(:image_type, :string, null: true) + add(:animation_type, :string, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260521103421_add_transaction_index_to_logs.exs b/apps/explorer/priv/repo/migrations/20260521103421_add_transaction_index_to_logs.exs new file mode 100644 index 00000000000..0a9e6cd4d22 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260521103421_add_transaction_index_to_logs.exs @@ -0,0 +1,9 @@ +defmodule Explorer.Repo.Migrations.AddTransactionIndexToLogs do + use Ecto.Migration + + def change do + alter table(:logs) do + add(:transaction_index, :integer) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260521115803_add_logs_pk_not_null_constraint.exs b/apps/explorer/priv/repo/migrations/20260521115803_add_logs_pk_not_null_constraint.exs new file mode 100644 index 00000000000..24ef18ae488 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260521115803_add_logs_pk_not_null_constraint.exs @@ -0,0 +1,19 @@ +defmodule Explorer.Repo.Migrations.AddLogsPkNotNullConstraint do + use Ecto.Migration + + def change do + create( + constraint(:logs, :logs_block_number_not_null, + check: "block_number IS NOT NULL", + validate: false + ) + ) + + create( + constraint(:logs, :logs_transaction_index_not_null, + check: "transaction_index IS NOT NULL", + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260605121256_add_address_id_to_logs.exs b/apps/explorer/priv/repo/migrations/20260605121256_add_address_id_to_logs.exs new file mode 100644 index 00000000000..4220902caa0 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260605121256_add_address_id_to_logs.exs @@ -0,0 +1,9 @@ +defmodule Explorer.Repo.Migrations.AddAddressIdToLogs do + use Ecto.Migration + + def change do + alter table(:logs) do + add(:address_id, :bigint) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260619133008_create_bytea_ltrim_zeroes_function.exs b/apps/explorer/priv/repo/migrations/20260619133008_create_bytea_ltrim_zeroes_function.exs new file mode 100644 index 00000000000..316dcec0445 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260619133008_create_bytea_ltrim_zeroes_function.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateByteaLtrimZeroesFunction do + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION bytea_ltrim_zeroes(b BYTEA) + RETURNS BYTEA AS $$ + DECLARE + i INT := 0; + BEGIN + WHILE i < octet_length(b) AND get_byte(b, i) = 0 LOOP + i := i + 1; + END LOOP; + RETURN substr(b, i + 1); + END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + """) + end + + def down do + execute("DROP FUNCTION IF EXISTS bytea_ltrim_zeroes(b BYTEA);") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260626154924_add_compressed_data_to_logs.exs b/apps/explorer/priv/repo/migrations/20260626154924_add_compressed_data_to_logs.exs new file mode 100644 index 00000000000..c7d0081370f --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260626154924_add_compressed_data_to_logs.exs @@ -0,0 +1,10 @@ +defmodule Explorer.Repo.Migrations.AddCompressedDataToLogs do + use Ecto.Migration + + def change do + alter table(:logs) do + add(:compressed_data, :bytea) + modify(:data, :bytea, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260703093513_create_log_first_topics.exs b/apps/explorer/priv/repo/migrations/20260703093513_create_log_first_topics.exs new file mode 100644 index 00000000000..3433ee4b100 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260703093513_create_log_first_topics.exs @@ -0,0 +1,81 @@ +defmodule Explorer.Repo.Migrations.CreateLogFirstTopics do + use Ecto.Migration + + def change do + create table(:log_first_topics) do + add(:value, :bytea, null: false) + + timestamps(updated_at: false) + end + + create(unique_index(:log_first_topics, [:value])) + + alter table(:logs) do + add(:first_topic_id, references(:log_first_topics, validate: false)) + end + + execute(""" + INSERT INTO log_first_topics (id, value, inserted_at) + VALUES + ( + 1, + decode('ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', 'hex'), + NOW() + ), + ( + 2, + decode('e1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c', 'hex'), + NOW() + ), + ( + 3, + decode('7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65', 'hex'), + NOW() + ), + ( + 4, + decode('c3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62', 'hex'), + NOW() + ), + ( + 5, + decode('4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb', 'hex'), + NOW() + ), + ( + 6, + decode('e59fdd36d0d223c0c7d996db7ad796880f45e1936cb0bb7ac102e7082e031487', 'hex'), + NOW() + ), + ( + 7, + decode('e5f815dc84b8cecdfd4beedfc3f91ab5be7af100eca4e8fb11552b867995394f', 'hex'), + NOW() + ), + ( + 8, + decode('62f084c00a442dcf51cdbb51beed2839bf42a268da8474b0e98f38edb7db5a22', 'hex'), + NOW() + ), + ( + 9, + decode('b049859d09b3a7d0189a07db4d4becee1a2aa269023205478b1360ab6fc12114', 'hex'), + NOW() + ), + ( + 10, + decode('aaf1ef013644e67c5cea90217acdf0accd334f8437fc9a89a53cfc9b25fb5c25', 'hex'), + NOW() + ), + ( + 11, + decode('67500e8d0ed826d2194f514dd0d8124f35648ab6e3fb5e6ed867134cffe661e9', 'hex'), + NOW() + ); + """) + + execute(""" + SELECT setval(pg_get_serial_sequence('log_first_topics', 'id'), (SELECT MAX(id) FROM log_first_topics)); + """) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260817134921_add_ui_multiplier_to_tokens.exs b/apps/explorer/priv/repo/migrations/20260817134921_add_ui_multiplier_to_tokens.exs new file mode 100644 index 00000000000..12820c1735f --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260817134921_add_ui_multiplier_to_tokens.exs @@ -0,0 +1,11 @@ +defmodule Explorer.Repo.Migrations.AddUiMultiplierToTokens do + use Ecto.Migration + + def change do + alter table(:tokens) do + add(:ui_multiplier, :decimal, null: true) + add(:new_ui_multiplier, :decimal, null: true) + add(:ui_multiplier_effective_at, :utc_datetime_usec, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260818090854_create_token_ui_multiplier_changes.exs b/apps/explorer/priv/repo/migrations/20260818090854_create_token_ui_multiplier_changes.exs new file mode 100644 index 00000000000..a9fd417cd2f --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260818090854_create_token_ui_multiplier_changes.exs @@ -0,0 +1,17 @@ +defmodule Explorer.Repo.Migrations.CreateTokenUiMultiplierChanges do + use Ecto.Migration + + def change do + create table(:token_ui_multiplier_changes, primary_key: false) do + add(:token_contract_address_hash, :bytea, null: false, primary_key: true) + add(:block_number, :bigint, null: false, primary_key: true) + add(:block_hash, :bytea, null: false) + add(:log_index, :integer, null: false, primary_key: true) + add(:old_multiplier, :decimal, null: false) + add(:new_multiplier, :decimal, null: false) + add(:effective_at, :utc_datetime_usec, null: false) + + timestamps() + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260911000000_add_transaction_hash_to_token_ui_multiplier_changes.exs b/apps/explorer/priv/repo/migrations/20260911000000_add_transaction_hash_to_token_ui_multiplier_changes.exs new file mode 100644 index 00000000000..199208c6311 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260911000000_add_transaction_hash_to_token_ui_multiplier_changes.exs @@ -0,0 +1,9 @@ +defmodule Explorer.Repo.Migrations.AddTransactionHashToTokenUiMultiplierChanges do + use Ecto.Migration + + def change do + alter table(:token_ui_multiplier_changes) do + add(:transaction_hash, :bytea, null: true) + end + end +end diff --git a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs index 4e0fda4d730..acac7e1a252 100644 --- a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CoinBalanceTest do use Explorer.DataCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias Ecto.Changeset alias Explorer.Chain.Address.CoinBalance @@ -24,6 +25,27 @@ defmodule Explorer.Chain.Address.CoinBalanceTest do end end + describe "get_coin_balance/3" do + test "loads the transaction hash for the matching balance" do + address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address, to_address: insert(:address), value: 1) + |> with_block(block) + + insert(:fetched_balance, + address_hash: address.hash, + block_number: block.number, + value: 1_000 + ) + + assert %{transaction_hash: transaction_hash} = CoinBalance.get_coin_balance(address.hash, block.number) + assert transaction_hash == transaction.hash + end + end + describe "fetch_coin_balances/2" do test "returns the coin balances for the given address" do address_a = insert(:address) @@ -300,6 +322,151 @@ defmodule Explorer.Chain.Address.CoinBalanceTest do end end + if @chain_type == :arc do + describe "address_hash_to_coin_balances/2 on Arc" do + @arc_native_token "0x3600000000000000000000000000000000000000" + + setup do + original_arc = Application.get_env(:indexer, :arc) || [] + + Application.put_env( + :indexer, + :arc, + Keyword.merge(original_arc, arc_native_token_address: @arc_native_token) + ) + + on_exit(fn -> + Application.put_env(:indexer, :arc, original_arc) + end) + + :ok + end + + test "falls back to the synthetic native-token transfer when no regular tx involves the address" do + {:ok, native_token_address_hash} = Explorer.Chain.string_to_address_hash(@arc_native_token) + native_token_address = insert(:address, hash: native_token_address_hash) + insert(:token, contract_address: native_token_address) + + address = insert(:address) + block = insert(:block) + + transfer_transaction = + :transaction + |> insert() + |> with_block(block) + + token_transfer = + insert(:token_transfer, + from_address: address, + token_contract_address: native_token_address, + transaction: transfer_transaction, + block: block, + block_number: block.number + ) + + insert(:fetched_balance, address_hash: address.hash, value: 100, block_number: block.number) + + [coin_balance] = + CoinBalance.address_hash_to_coin_balances(address.hash, paging_options: %PagingOptions{page_size: 50}) + + assert coin_balance.transaction_hash == token_transfer.transaction_hash + assert coin_balance.transaction_hash == transfer_transaction.hash + end + + test "prefers a regular transaction involving the address over the system token transfer" do + {:ok, native_token_address_hash} = Explorer.Chain.string_to_address_hash(@arc_native_token) + native_token_address = insert(:address, hash: native_token_address_hash) + insert(:token, contract_address: native_token_address) + + address = insert(:address) + block = insert(:block) + + regular_transaction = + :transaction + |> insert(from_address: address) + |> with_block(block) + + transfer_transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:token_transfer, + from_address: address, + token_contract_address: native_token_address, + transaction: transfer_transaction, + block: block, + block_number: block.number + ) + + insert(:fetched_balance, address_hash: address.hash, value: 100, block_number: block.number) + + [coin_balance] = + CoinBalance.address_hash_to_coin_balances(address.hash, paging_options: %PagingOptions{page_size: 50}) + + assert coin_balance.transaction_hash == regular_transaction.hash + end + + test "returns nil transaction hash when neither a regular tx nor a system token transfer matches the balance" do + {:ok, native_token_address_hash} = Explorer.Chain.string_to_address_hash(@arc_native_token) + native_token_address = insert(:address, hash: native_token_address_hash) + insert(:token, contract_address: native_token_address) + + address = insert(:address) + block = insert(:block) + + unrelated_transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:token_transfer, + token_contract_address: native_token_address, + transaction: unrelated_transaction, + block: block, + block_number: block.number + ) + + insert(:fetched_balance, address_hash: address.hash, value: 100, block_number: block.number) + + [coin_balance] = + CoinBalance.address_hash_to_coin_balances(address.hash, paging_options: %PagingOptions{page_size: 50}) + + assert is_nil(coin_balance.transaction_hash) + end + + test "ignores token transfers on a different block" do + {:ok, native_token_address_hash} = Explorer.Chain.string_to_address_hash(@arc_native_token) + native_token_address = insert(:address, hash: native_token_address_hash) + insert(:token, contract_address: native_token_address) + + address = insert(:address) + balance_block = insert(:block) + other_block = insert(:block) + + other_transaction = + :transaction + |> insert() + |> with_block(other_block) + + insert(:token_transfer, + from_address: address, + token_contract_address: native_token_address, + transaction: other_transaction, + block: other_block, + block_number: other_block.number + ) + + insert(:fetched_balance, address_hash: address.hash, value: 100, block_number: balance_block.number) + + [coin_balance] = + CoinBalance.address_hash_to_coin_balances(address.hash, paging_options: %PagingOptions{page_size: 50}) + + assert is_nil(coin_balance.transaction_hash) + end + end + end + describe "stream_unfetched_balances/2" do test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> "does not return `t:Explorer.Chain.Block.t/0` `miner_hash`" do diff --git a/apps/explorer/test/explorer/chain/address/counters_test.exs b/apps/explorer/test/explorer/chain/address/counters_test.exs new file mode 100644 index 00000000000..db6ad44659d --- /dev/null +++ b/apps/explorer/test/explorer/chain/address/counters_test.exs @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Address.CountersTest do + use Explorer.DataCase + + alias Explorer.Chain.Address.Counters + + describe "check_if_logs_at_address/2" do + test "detects logs stored with `address_id` and with the legacy `address_hash` while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + address_with_migrated_log = insert(:address) + address_with_legacy_log = insert(:address) + address_without_logs = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + log_params = [block: transaction.block, block_number: transaction.block_number, transaction: transaction] + + # already migrated log, matched by `address_id` + insert(:log, log_params ++ [index: 1, address: address_with_migrated_log, address_hash: nil]) + # not yet migrated log, matched by the legacy `address_hash` + insert(:log, log_params ++ [index: 2, address: address_with_legacy_log, address_mapping: nil]) + + assert Counters.check_if_logs_at_address(address_with_migrated_log.hash) + assert Counters.check_if_logs_at_address(address_with_legacy_log.hash) + refute Counters.check_if_logs_at_address(address_without_logs.hash) + end + end + + describe "address_existence_checks/2" do + test "returns the existence checks" do + address = insert(:address) + insert(:block, miner: address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + address: address + ) + + assert %{ + has_validated_blocks: true, + has_logs: true, + has_tokens: false, + has_token_transfers: false, + has_beacon_chain_withdrawals: false + } = Counters.address_existence_checks(address.hash, api?: true) + end + + test "degrades `has_logs` to false instead of hanging while the logs table is locked" do + address = insert(:address) + insert(:block, miner: address) + + with_table_locked("logs", fn -> + assert %{ + has_validated_blocks: true, + has_logs: false, + has_tokens: false, + has_token_transfers: false, + has_beacon_chain_withdrawals: false + } = Counters.address_existence_checks(address.hash, api?: true) + end) + end + + test "reports every check as false instead of hanging while another checked table is locked" do + address = insert(:address) + + with_table_locked("token_transfers", fn -> + assert %{ + has_validated_blocks: false, + has_logs: false, + has_tokens: false, + has_token_transfers: false, + has_beacon_chain_withdrawals: false + } = Counters.address_existence_checks(address.hash, api?: true) + end) + end + end + + describe "address_limited_counters/2" do + test "counts logs matched by `address_id` and by the legacy `address_hash` while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + address = insert(:address) + + transaction = + :transaction + |> insert(to_address: address) + |> with_block() + + log_params = [ + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + address: address + ] + + # already migrated logs, matched by `address_id` + insert(:log, log_params ++ [index: 1, address_hash: nil]) + insert(:log, log_params ++ [index: 2, address_hash: nil]) + # not yet migrated log, matched by the legacy `address_hash` + insert(:log, log_params ++ [index: 3, address_mapping: nil]) + # re-imported log with both fields filled, counted once + insert(:log, log_params ++ [index: 4]) + # log of another address + insert(:log, log_params ++ [index: 5, address: insert(:address), address_mapping: nil]) + + assert %{logs: 4} = Counters.address_limited_counters(address.hash, []) + end + end +end diff --git a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs index 874139efe3b..35e267b984c 100644 --- a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs @@ -200,6 +200,71 @@ defmodule Explorer.Chain.Address.CurrentTokenBalanceTest do assert token_balances == [current_token_balance_a.address_hash] end + + test "prices an ERC-8056 balance by its displayed amount, not its raw one" do + address = insert(:address) + + # `fiat_value` quotes the UI amount, which is twice the raw one here + token = + insert(:token, + type: "ERC-8056", + decimals: 0, + fiat_value: Decimal.new(3), + ui_multiplier: Decimal.new("2000000000000000000") + ) + + insert(:address_current_token_balance, + address: address, + token_contract_address_hash: token.contract_address_hash, + value: 5 + ) + + assert [%{fiat_value: fiat_value}] = + address.hash |> CurrentTokenBalance.last_token_balances() |> Repo.all() + + assert Decimal.equal?(fiat_value, Decimal.new(30)) + end + + test "takes the maturing of a scheduled multiplier change into account when pricing" do + address = insert(:address) + + token = + insert(:token, + type: "ERC-8056", + decimals: 0, + fiat_value: Decimal.new(3), + ui_multiplier: Decimal.new("1000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: DateTime.add(DateTime.utc_now(), -1, :hour) + ) + + insert(:address_current_token_balance, + address: address, + token_contract_address_hash: token.contract_address_hash, + value: 5 + ) + + assert [%{fiat_value: fiat_value}] = + address.hash |> CurrentTokenBalance.last_token_balances() |> Repo.all() + + assert Decimal.equal?(fiat_value, Decimal.new(30)) + end + + test "leaves a token without ERC-8056 support priced by its raw balance" do + address = insert(:address) + token = insert(:token, decimals: 0, fiat_value: Decimal.new(3)) + + insert(:address_current_token_balance, + address: address, + token_contract_address_hash: token.contract_address_hash, + value: 5 + ) + + assert [%{fiat_value: fiat_value}] = + address.hash |> CurrentTokenBalance.last_token_balances() |> Repo.all() + + assert Decimal.equal?(fiat_value, Decimal.new(15)) + end end describe "count_token_holders_from_token_hash" do diff --git a/apps/explorer/test/explorer/chain/beacon/deposit_test.exs b/apps/explorer/test/explorer/chain/beacon/deposit_test.exs index a9b490a1606..8e734377973 100644 --- a/apps/explorer/test/explorer/chain/beacon/deposit_test.exs +++ b/apps/explorer/test/explorer/chain/beacon/deposit_test.exs @@ -3,54 +3,83 @@ defmodule Explorer.Chain.Beacon.DepositTest do use Explorer.DataCase alias Explorer.Chain.Beacon.Deposit + alias Explorer.Chain.Hash + + @deposit_event_signature "0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5" describe "get_logs_with_deposits/4" do - @deposit_event_signature "0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5" - - test "filter out reorged blocks" do - deposit_contract = insert(:address) - reorg = insert(:block, consensus: false) - actual_block = insert(:block, consensus: true, number: reorg.number) - transaction = insert(:transaction) |> with_block(actual_block) - - _reorged_logs = [ - insert(:log, - address: deposit_contract, - block: reorg, - transaction: transaction, - first_topic: @deposit_event_signature - ), - insert(:log, - address: deposit_contract, - block: reorg, - transaction: transaction, - first_topic: @deposit_event_signature - ) + test "fetches logs matched by `address_id` and by the legacy `address_hash` while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + deposit_contract_address = insert(:address) + {:ok, first_topic} = Hash.Full.cast(@deposit_event_signature) + + transaction = + :transaction + |> insert(to_address: deposit_contract_address) + |> with_block() + + log_params = [ + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + address: deposit_contract_address, + first_topic: first_topic ] - actual_logs = [ - insert(:log, - address: deposit_contract, - block: actual_block, - transaction: transaction, - first_topic: @deposit_event_signature - ), - insert(:log, - address: deposit_contract, - block: actual_block, - transaction: transaction, - first_topic: @deposit_event_signature - ) + # already migrated log, matched by `address_id` + insert(:log, log_params ++ [index: 1, address_hash: nil]) + # not yet migrated log, matched by the legacy `address_hash` + insert(:log, log_params ++ [index: 2, address_mapping: nil]) + # re-imported log with both fields filled, returned once + insert(:log, log_params ++ [index: 3]) + # log of another address + insert(:log, log_params ++ [index: 4, address: insert(:address), address_mapping: nil]) + # log with another first topic + insert(:log, log_params ++ [index: 5, first_topic: nil, address_hash: nil]) + + logs = Deposit.get_logs_with_deposits(deposit_contract_address.hash, transaction.block_number - 1, 0, 10) + + assert [1, 2, 3] == Enum.map(logs, & &1.index) + + assert Enum.all?(logs, fn log -> + log.block_number == transaction.block_number and log.transaction_hash == transaction.hash and + log.from_address_hash == transaction.from_address_hash and log.first_topic == first_topic + end) + end + + test "respects the limit and the paging key while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + deposit_contract_address = insert(:address) + {:ok, first_topic} = Hash.Full.cast(@deposit_event_signature) + + transaction = + :transaction + |> insert(to_address: deposit_contract_address) + |> with_block() + + log_params = [ + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + address: deposit_contract_address, + first_topic: first_topic ] - assert actual_logs |> Enum.map(&{&1.transaction_hash, &1.block_hash, &1.block_number, &1.data, &1.index}) == - Deposit.get_logs_with_deposits( - deposit_contract.hash, - -1, - -1, - 10 - ) - |> Enum.map(&{&1.transaction_hash, &1.block_hash, &1.block_number, &1.data, &1.index}) + insert(:log, log_params ++ [index: 1, address_hash: nil]) + insert(:log, log_params ++ [index: 2, address_mapping: nil]) + insert(:log, log_params ++ [index: 3, address_hash: nil]) + + assert [1] == + deposit_contract_address.hash + |> Deposit.get_logs_with_deposits(transaction.block_number - 1, 0, 1) + |> Enum.map(& &1.index) + + assert [2, 3] == + deposit_contract_address.hash + |> Deposit.get_logs_with_deposits(transaction.block_number, 1, 10) + |> Enum.map(& &1.index) end end end diff --git a/apps/explorer/test/explorer/chain/block_test.exs b/apps/explorer/test/explorer/chain/block_test.exs index 566ce0221c3..ab4a3905290 100644 --- a/apps/explorer/test/explorer/chain/block_test.exs +++ b/apps/explorer/test/explorer/chain/block_test.exs @@ -175,9 +175,10 @@ defmodule Explorer.Chain.BlockTest do {[before_update, after_update], preload_query_sources} = with_query_sources(fn -> Block.preload_eip1559_config(blocks) end) - assert preload_query_sources == ["op_eip1559_config_updates"] + # The superchain config defaults (`constants`) are read once for the whole batch + assert preload_query_sources == ["op_eip1559_config_updates", "constants", "constants"] - # The env defaults apply before the first update + # The env defaults apply before the first update, as the superchain config is not set assert before_update.eip1559_config == {Application.get_env(:explorer, :base_fee_max_change_denominator), Application.get_env(:explorer, :elasticity_multiplier)} @@ -194,7 +195,32 @@ defmodule Explorer.Chain.BlockTest do {gas_targets, gas_target_query_sources} = with_query_sources(fn -> Enum.map(blocks, &Block.gas_target/1) end) assert gas_targets == [0.0, 100.0] - assert gas_target_query_sources == ["op_eip1559_config_updates", "op_eip1559_config_updates"] + + assert gas_target_query_sources == [ + "op_eip1559_config_updates", + "constants", + "constants", + "op_eip1559_config_updates" + ] + end + + test "does not read the defaults when every block has an update" do + %Explorer.Chain.Optimism.EIP1559ConfigUpdate{} + |> Explorer.Chain.Optimism.EIP1559ConfigUpdate.changeset(%{ + l2_block_number: 10, + l2_block_hash: block_hash(), + base_fee_max_change_denominator: 50, + elasticity_multiplier: 4 + }) + |> Repo.insert!() + + blocks = for number <- [15, 16], do: insert(:block, number: number) + + {preloaded_blocks, preload_query_sources} = + with_query_sources(fn -> Block.preload_eip1559_config(blocks) end) + + assert preload_query_sources == ["op_eip1559_config_updates"] + assert Enum.map(preloaded_blocks, & &1.eip1559_config) == [{50, 4}, {50, 4}] end else test "leaves the blocks as is without queries" do @@ -202,6 +228,16 @@ defmodule Explorer.Chain.BlockTest do assert with_query_sources(fn -> Block.preload_eip1559_config(blocks) end) == {blocks, []} end + + test "gas_target/1 uses the env config without queries" do + block = insert(:block, gas_limit: Decimal.new(30_000_000), gas_used: Decimal.new(15_000_000)) + + {gas_target, query_sources} = with_query_sources(fn -> Block.gas_target(block) end) + + # The default elasticity multiplier is 2, so the gas target is met exactly + assert gas_target == 0.0 + assert query_sources == [] + end end end diff --git a/apps/explorer/test/explorer/chain/cache/counters/optimism/deposits_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/optimism/deposits_count_test.exs new file mode 100644 index 00000000000..1a904864b42 --- /dev/null +++ b/apps/explorer/test/explorer/chain/cache/counters/optimism/deposits_count_test.exs @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.Optimism.DepositsCountTest do + use Explorer.DataCase + + alias Explorer.Chain.Cache.Counters.Optimism.DepositsCount + + if Application.compile_env(:explorer, :chain_type) == :optimism do + test "populates the cache with the number of deposits" do + insert_list(3, :op_deposit) + + start_supervised!(DepositsCount) + DepositsCount.consolidate() + + assert DepositsCount.fetch([]) == Decimal.new("3") + end + + test "does not count deposits with unsuccessful L2 transactions" do + insert_list(3, :op_deposit) + + failed_transaction = :transaction |> insert() |> with_block(status: :error) + insert(:op_deposit, l2_transaction: failed_transaction, l2_transaction_hash: failed_transaction.hash) + + start_supervised!(DepositsCount) + DepositsCount.consolidate() + + assert DepositsCount.fetch([]) == Decimal.new("3") + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs b/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs index 5a5cd0bfd08..1165d9327fd 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs @@ -29,6 +29,7 @@ defmodule Explorer.Chain.Address.LogsTest do address: address, index: 1, transaction: transaction, + transaction_index: transaction.index, block: transaction.block, block_number: transaction.block_number, data: "0x12", @@ -110,6 +111,7 @@ defmodule Explorer.Chain.Address.LogsTest do address: address, index: index, transaction: transaction, + transaction_index: transaction.index, block: transaction.block, block_number: transaction.block_number ) diff --git a/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs b/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs index 1f4e8ca4129..88b3145e848 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs @@ -49,6 +49,8 @@ defmodule Explorer.Chain.Address.TokenTransfersTest do _, [[], tokens_transferred], _, + [[], ui_multiplier], + _, [[], transaction_fee], _, [[], status], @@ -67,6 +69,7 @@ defmodule Explorer.Chain.Address.TokenTransfersTest do token_decimals: token_decimals, token_symbol: token_symbol, tokens_transferred: tokens_transferred, + ui_multiplier: ui_multiplier, transaction_fee: transaction_fee, status: status, err_code: err_code @@ -84,6 +87,51 @@ defmodule Explorer.Chain.Address.TokenTransfersTest do assert result.status == to_string(token_transfer.transaction.status) assert result.err_code == to_string(token_transfer.transaction.error) assert result.type == "OUT" + assert result.ui_multiplier == "" + end + + test "exports the ERC-8056 multiplier of the moment of the transfer, leaving the amount raw" do + address = insert(:address) + + token = + insert(:token, + ui_multiplier: Decimal.new("2000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + transaction = :transaction |> insert(from_address: address) |> with_block(block) + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + from_address: address, + token_contract_address: token.contract_address + ) + + [row] = + address.hash + |> AddressTokenTransfersCsvExporter.export("2026-04-01", "2026-07-01", [], nil, nil) + |> Enum.to_list() + |> Enum.drop(1) + |> Enum.map(&(&1 |> IO.iodata_to_binary() |> String.trim() |> String.split(","))) + + # raw amount, plus the multiplier the holders saw back then rather than + # the one in force now + assert Enum.at(row, 9) == to_string(token_transfer.amount) + assert Enum.at(row, 10) == "1000000000000000000" end test "fetches all token transfers" do diff --git a/apps/explorer/test/explorer/chain/csv_export/helper_test.exs b/apps/explorer/test/explorer/chain/csv_export/helper_test.exs index 777c1f3299a..d6ba9f15ded 100644 --- a/apps/explorer/test/explorer/chain/csv_export/helper_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/helper_test.exs @@ -23,6 +23,21 @@ defmodule Explorer.Chain.CsvExport.HelperTest do :ok end + describe "block_from_period/2" do + test "returns nil bounds when periods are missing or empty" do + assert Helper.block_from_period(nil, nil) == {nil, nil} + assert Helper.block_from_period("", "") == {nil, nil} + assert Helper.block_from_period(nil, "") == {nil, nil} + end + + test "resolves only the provided bound and leaves the other unbounded" do + insert(:block, number: 100, timestamp: ~U[2023-01-02 12:00:00Z]) + + assert {100, nil} = Helper.block_from_period("2023-01-01", nil) + assert {nil, 100} = Helper.block_from_period("", "2023-01-03") + end + end + describe "valid_filter?/3" do test "returns true for valid address filter to" do assert Helper.valid_filter?("address", "to", "transactions") == true diff --git a/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs b/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs index 430a967bd9f..12267dd4a6e 100644 --- a/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs @@ -86,6 +86,39 @@ defmodule Explorer.Chain.CsvExport.Token.HoldersTest do assert row =~ "1234.56789" end + test "scales balances of an ERC-8056 token by the multiplier in force now" do + # the export is human-readable, so it has to show what the holders page + # shows rather than the raw balance + token = + insert(:token, + type: "ERC-8056", + decimals: 6, + ui_multiplier: Decimal.new("1000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: DateTime.add(DateTime.utc_now(), -1, :hour) + ) + + holder = + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 1_000_000 + ) + + csv_string = + token.contract_address_hash + |> TokenHoldersExporter.export("2020-01-01", "2025-12-31", [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [_header, row] = String.split(csv_string, "\r\n", trim: true) + + # 1.0 of the token, doubled by the multiplier that has already matured + assert [address, balance] = String.split(row, ",") + assert address == Address.checksum(holder.address_hash) + assert Decimal.equal?(Decimal.new(balance), Decimal.new(2)) + end + test "respects pagination with many holders" do token = insert(:token, type: "ERC-20", decimals: 18) diff --git a/apps/explorer/test/explorer/chain/csv_export/token/transfers_test.exs b/apps/explorer/test/explorer/chain/csv_export/token/transfers_test.exs new file mode 100644 index 00000000000..cce85eb523a --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/token/transfers_test.exs @@ -0,0 +1,244 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Token.TransfersTest do + use Explorer.DataCase + + alias Explorer.Chain.Address + alias Explorer.Chain.CsvExport.Token.Transfers, as: TokenTransfersExporter + + setup do + original_limit = Application.get_env(:explorer, :csv_export_limit) + Application.put_env(:explorer, :csv_export_limit, 150) + + on_exit(fn -> + if original_limit do + Application.put_env(:explorer, :csv_export_limit, original_limit) + else + Application.delete_env(:explorer, :csv_export_limit) + end + end) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :day) |> DateTime.to_iso8601() + to_period = DateTime.add(now, 1, :day) |> DateTime.to_iso8601() + + {:ok, %{from_period: from_period, to_period: to_period}} + end + + describe "export/6" do + test "exports token transfers to csv with header and transfer rows", %{ + from_period: from_period, + to_period: to_period + } do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "TKN") + + transaction1 = + :transaction + |> insert() + |> with_block() + + transfer1 = + insert(:token_transfer, + transaction: transaction1, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction1.block_number, + amount: Decimal.new(1_000) + ) + + transaction2 = + :transaction + |> insert() + |> with_block() + + transfer2 = + insert(:token_transfer, + transaction: transaction2, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction2.block_number, + amount: Decimal.new(2_000) + ) + + csv_string = + token.contract_address_hash + |> TokenTransfersExporter.export(from_period, to_period, [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert header =~ "TxHash" + assert header =~ "BlockNumber" + assert header =~ "FromAddress" + assert header =~ "ToAddress" + assert header =~ "TokenContractAddress" + assert header =~ "TokenDecimals" + assert header =~ "TokenSymbol" + assert header =~ "TokensTransferred" + assert header =~ "UIMultiplier" + assert header =~ "TransactionFee" + assert header =~ "Status" + assert header =~ "ErrCode" + + assert length(rows) == 2 + + assert Enum.any?(rows, fn row -> + row =~ Address.checksum(transfer1.from_address_hash) + end) + + assert Enum.any?(rows, fn row -> + row =~ Address.checksum(transfer2.from_address_hash) + end) + + # a token without ERC-8056 support has no multiplier to export + assert Enum.all?(rows, &(&1 |> String.split(",") |> Enum.at(9) == "")) + end + + test "exports the ERC-8056 multiplier of the moment of the transfer, leaving the amount raw" do + token = + insert(:token, + type: "ERC-8056", + ui_multiplier: Decimal.new("2000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + block = insert(:block, number: 150, timestamp: ~U[2026-05-01 00:00:00.000000Z]) + transaction = :transaction |> insert() |> with_block(block) + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address + ) + + [header | rows] = + token.contract_address_hash + |> TokenTransfersExporter.export("2026-04-01", "2026-07-01", [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + |> String.split("\r\n", trim: true) + + assert header |> String.split(",") |> Enum.at(9) == "UIMultiplier" + assert [row] = Enum.map(rows, &String.split(&1, ",")) + + # raw amount, plus the multiplier the holders saw back then rather than + # the one in force now + assert Enum.at(row, 8) == to_string(token_transfer.amount) + assert Enum.at(row, 9) == "1000000000000000000" + end + + test "formats addresses as checksummed", %{from_period: from_period, to_period: to_period} do + token = insert(:token, type: "ERC-20", decimals: 6, symbol: "USDC") + + transaction = + :transaction + |> insert() + |> with_block() + + transfer = + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(500) + ) + + csv_string = + token.contract_address_hash + |> TokenTransfersExporter.export(from_period, to_period, [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [_header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert length(rows) == 1 + row = hd(rows) + assert row =~ Address.checksum(transfer.from_address_hash) + assert row =~ Address.checksum(transfer.to_address_hash) + assert row =~ Address.checksum(transfer.token_contract_address_hash) + end + + test "does not include transfers from other tokens", %{from_period: from_period, to_period: to_period} do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "AAA") + other_token = insert(:token, type: "ERC-20", decimals: 18, symbol: "BBB") + + transaction1 = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction1, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction1.block_number, + amount: Decimal.new(100) + ) + + transaction2 = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction2, + token_contract_address: other_token.contract_address, + token_type: "ERC-20", + block_number: transaction2.block_number, + amount: Decimal.new(200) + ) + + csv_string = + token.contract_address_hash + |> TokenTransfersExporter.export(from_period, to_period, [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [_header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert length(rows) == 1 + end + + test "respects export limit with many transfers", %{from_period: from_period, to_period: to_period} do + token = insert(:token, type: "ERC-20", decimals: 18, symbol: "TKN") + + Enum.each(1..200, fn _i -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + token_contract_address: token.contract_address, + token_type: "ERC-20", + block_number: transaction.block_number, + amount: Decimal.new(1) + ) + end) + + csv_string = + token.contract_address_hash + |> TokenTransfersExporter.export(from_period, to_period, [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [_header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert length(rows) == 150 + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/worker_test.exs b/apps/explorer/test/explorer/chain/csv_export/worker_test.exs index 4b05c2706cb..c2b7be710ca 100644 --- a/apps/explorer/test/explorer/chain/csv_export/worker_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/worker_test.exs @@ -71,7 +71,7 @@ defmodule Explorer.Chain.CsvExport.WorkerTest do Conn.resp( conn, 200, - Jason.encode!(%{ + Utils.JSON.encode!(%{ "FileInfo" => %{"Id" => "test-file-id", "ExpireAt" => date_time_to_expect |> DateTime.to_unix()} }) ) diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index f3c530959b8..33af14457b6 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -1071,6 +1071,26 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do refute_received {:"$gen_cast", {:lost_consensus, _}} end + test "returns empty list and skips cleanup when no blocks lose consensus" do + existing_block = insert(:block, consensus: true, number: 5) + + new_block = + params_for(:block, + miner_hash: insert(:address).hash, + parent_hash: existing_block.hash, + number: 6 + ) + + %Ecto.Changeset{valid?: true, changes: new_block_changes} = Block.changeset(%Block{}, new_block) + + opts = %{ + timeout: 60_000, + timestamps: %{updated_at: DateTime.utc_now()} + } + + assert {:ok, []} = Blocks.process_blocks_consensus([new_block_changes], Repo, opts) + end + test "triggers beacon deposit reorg handling on fresh blocks" do Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) diff --git a/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs b/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs index 4cfda44856b..7143fa64665 100644 --- a/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs @@ -3,7 +3,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do use Explorer.DataCase alias Ecto.Multi - alias Explorer.Chain.{Block, Data, Wei, PendingBlockOperation, Transaction, InternalTransaction} + alias Explorer.Chain.{Block, Data, Wei, PendingBlockOperation, Transaction, TransactionError, InternalTransaction} alias Explorer.Chain.Import.Runner.InternalTransactions alias Explorer.Migrator.DeleteZeroValueInternalTransactions @@ -141,6 +141,90 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert :ok == Repo.get(Transaction, transaction2.hash).status end + test "caps a long trace error at 255 characters before storing it in transaction_errors and transactions.error" do + transaction = insert(:transaction) |> with_block(status: :error) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # 339-character error returned by a Geth `callTracer` for a failed precompile call + error = + "err message: unlock store fee failed: air-credential-default " <> + "e6a5828d666166b3d5e19e0b01f64277663290a170bca04fae96949b01c42546 update stream record failed: " <> + "air-credential-default e6a5828d666166b3d5e19e0b01f64277663290a170bca04fae96949b01c42546 " <> + "stream record 0x9eA05E1447889cF23E20e8306F3A8F481D2F0761 is frozen: invalid global virtual group" + + assert String.length(error) > 255 + + expected_error = String.slice(error, 0, 255) + + internal_transaction_changes = make_internal_transaction_changes(transaction, 0, error) + + assert {:ok, _} = run_internal_transactions([internal_transaction_changes]) + + assert %InternalTransaction{error_id: error_id} = + Repo.get_by(InternalTransaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: 0 + ) + + assert expected_error == TransactionError.id_to_error(error_id) + assert expected_error == Repo.get(Transaction, transaction.hash).error + end + + test "caps a non-printable trace error at 255 characters after escaping it" do + transaction = insert(:transaction) |> with_block(status: :error) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # 200 NUL bytes, each escaped to `\0` (2 characters) by `inspect/2`, so the escaped message is 400 characters + error = :binary.copy(<<0>>, 200) + + internal_transaction_changes = make_internal_transaction_changes(transaction, 0, error) + + assert {:ok, _} = run_internal_transactions([internal_transaction_changes]) + + assert %InternalTransaction{error_id: error_id} = + Repo.get_by(InternalTransaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: 0 + ) + + stored_error = TransactionError.id_to_error(error_id) + + assert 255 == String.length(stored_error) + assert String.printable?(stored_error) + assert String.starts_with?(stored_error, "\\0\\0") + end + + test "caps a trace error by code points, not graphemes, so multi-code-point graphemes fit varchar(255)" do + transaction = insert(:transaction) |> with_block(status: :error) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # "e" + combining acute accent: one grapheme, two code points. 200 graphemes = 400 code points, + # which PostgreSQL counts as 400 characters for `varchar(255)`. + error = String.duplicate("é", 200) + + assert 200 == String.length(error) + assert 400 == length(String.codepoints(error)) + + internal_transaction_changes = make_internal_transaction_changes(transaction, 0, error) + + assert {:ok, _} = run_internal_transactions([internal_transaction_changes]) + + assert %InternalTransaction{error_id: error_id} = + Repo.get_by(InternalTransaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: 0 + ) + + stored_error = TransactionError.id_to_error(error_id) + + assert length(String.codepoints(stored_error)) <= 255 + assert String.starts_with?(error, stored_error) + assert stored_error == Repo.get(Transaction, transaction.hash).error + end + # test "simple coin transfer has no internal transaction inserted for Nethermind" do # transaction = insert(:transaction) |> with_block(status: :ok) # insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index c217011eed6..5055256f1f0 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -21,7 +21,7 @@ defmodule Explorer.Chain.ImportTest do } alias Explorer.Chain.Events.Subscriber - alias Explorer.Utility.MissingBlockRange + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic, MissingBlockRange} @moduletag :capturelog @@ -100,6 +100,7 @@ defmodule Explorer.Chain.ImportTest do params: [ %{ block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + block_number: 37, address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", first_topic: first_topic, @@ -107,7 +108,8 @@ defmodule Explorer.Chain.ImportTest do third_topic: third_topic, fourth_topic: nil, index: 0, - transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" + transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 0 } ], timeout: 5 @@ -177,7 +179,7 @@ defmodule Explorer.Chain.ImportTest do } test "with valid data" do - {:ok, first_topic} = Explorer.Chain.Hash.Full.cast(@first_topic_hex_string) + %{id: first_topic_id} = LogFirstTopic.find_or_create(@first_topic_hex_string) {:ok, second_topic} = Explorer.Chain.Hash.Full.cast(@second_topic_hex_string) {:ok, third_topic} = Explorer.Chain.Hash.Full.cast(@third_topic_hex_string) difficulty = Decimal.new(340_282_366_920_938_463_463_374_607_431_768_211_454) @@ -185,6 +187,7 @@ defmodule Explorer.Chain.ImportTest do token_transfer_amount = Decimal.new(1_000_000_000_000_000_000) gas_limit = Decimal.new(6_946_336) gas_used = Decimal.new(50450) + %{address_id: address_id} = AddressIdToAddressHash.find_or_create("0x8bf38d4764929064f2d4d3a56520a76ab3df415b") assert {:ok, %{ @@ -278,19 +281,14 @@ defmodule Explorer.Chain.ImportTest do ], logs: [ %Log{ - address_hash: %Hash{ - byte_count: 20, - bytes: - <<139, 243, 141, 71, 100, 146, 144, 100, 242, 212, 211, 165, 101, 32, 167, 106, 179, 223, 65, - 91>> - }, - data: %Data{ + address_id: ^address_id, + compressed_data: %Data{ bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 224, 182, 179, 167, 100, 0, 0>> }, index: 0, - first_topic: ^first_topic, + first_topic_id: ^first_topic_id, second_topic: ^second_topic, third_topic: ^third_topic, fourth_topic: nil, @@ -300,6 +298,7 @@ defmodule Explorer.Chain.ImportTest do <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> }, + transaction_index: 0, inserted_at: %{}, updated_at: %{} } @@ -1759,6 +1758,7 @@ defmodule Explorer.Chain.ImportTest do params: [ params_for(:log, transaction_hash: transaction_hash, + transaction_index: 0, address_hash: miner_hash, block_hash: block_hash ) diff --git a/apps/explorer/test/explorer/chain/internal_transaction_test.exs b/apps/explorer/test/explorer/chain/internal_transaction_test.exs index 12f00dc2f59..900d908f97f 100644 --- a/apps/explorer/test/explorer/chain/internal_transaction_test.exs +++ b/apps/explorer/test/explorer/chain/internal_transaction_test.exs @@ -321,6 +321,70 @@ defmodule Explorer.Chain.InternalTransactionTest do ) |> Enum.map(&{&1.transaction_index, &1.index}) end + + test "excludes zero-value call internal transactions when include_zero_value: false" do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + %InternalTransaction{transaction_index: t_index, index: kept_index} = + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + result = + transaction.hash + |> InternalTransaction.transaction_to_internal_transactions(include_zero_value: false) + |> Enum.map(&{&1.transaction_index, &1.index}) + + assert result == [{t_index, kept_index}] + end + + test "includes zero-value call internal transactions by default" do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + assert length(InternalTransaction.transaction_to_internal_transactions(transaction.hash)) == 2 + end end describe "all_transaction_to_internal_transactions/1" do @@ -994,6 +1058,142 @@ defmodule Explorer.Chain.InternalTransactionTest do assert {actual.block_number, actual.transaction_index, actual.index} == {expected.block_number, expected.transaction_index, expected.index} end + + test "excludes zero-value call internal transactions when include_zero_value: false" do + address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + %InternalTransaction{transaction_index: t_index, index: kept_index} = + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + result = + address.hash + |> InternalTransaction.address_to_internal_transactions(include_zero_value: false) + |> Enum.map(&{&1.transaction_index, &1.index}) + + assert result == [{t_index, kept_index}] + end + + test "includes zero-value call internal transactions by default" do + address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + assert length(InternalTransaction.address_to_internal_transactions(address.hash)) == 2 + end + end + + describe "block_to_internal_transactions/1" do + test "excludes zero-value call internal transactions when include_zero_value: false" do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + %InternalTransaction{transaction_index: t_index, index: kept_index} = + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + result = + block.number + |> InternalTransaction.block_to_internal_transactions(include_zero_value: false) + |> Enum.map(&{&1.transaction_index, &1.index}) + + assert result == [{t_index, kept_index}] + end + + test "includes zero-value call internal transactions by default" do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + assert length(InternalTransaction.block_to_internal_transactions(block.number)) == 2 + end end describe "fetch/1" do @@ -1032,6 +1232,66 @@ defmodule Explorer.Chain.InternalTransactionTest do # |> InternalTransaction.fetch() # |> Enum.map(&{&1.transaction_hash, &1.index, &1.block_hash}) # end + + test "excludes zero-value call internal transactions when include_zero_value: false" do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + %InternalTransaction{transaction_index: t_index, index: kept_index} = + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + result = + [include_zero_value: false, exclude_origin_internal_transaction: false] + |> InternalTransaction.fetch() + |> Enum.map(&{&1.transaction_index, &1.index}) + + assert result == [{t_index, kept_index}] + end + + test "includes zero-value call internal transactions by default" do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(0) + ) + + insert(:internal_transaction, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: Decimal.new(1) + ) + + assert length(InternalTransaction.fetch(exclude_origin_internal_transaction: false)) == 2 + end end describe "fetch_first_trace/2" do diff --git a/apps/explorer/test/explorer/chain/log_test.exs b/apps/explorer/test/explorer/chain/log_test.exs index 306a727f631..61295746ceb 100644 --- a/apps/explorer/test/explorer/chain/log_test.exs +++ b/apps/explorer/test/explorer/chain/log_test.exs @@ -33,7 +33,7 @@ defmodule Explorer.Chain.LogTest do end test "rejects missing attributes" do - params = params_for(:log, data: nil) + params = params_for(:log, compressed_data: nil) changeset = Log.changeset(%Log{}, params) refute changeset.valid? end diff --git a/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs b/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs index 622e66d5ec8..e89d3da94c3 100644 --- a/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs +++ b/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs @@ -32,6 +32,24 @@ defmodule Explorer.Chain.Metrics.Queries.IndexerMetricsTest do assert IndexerMetrics.missing_blocks_count() == 1 end + + if Application.compile_env(:explorer, :chain_type) == :filecoin do + test "does not count recorded null rounds as missing blocks" do + previous_block_ranges = Application.get_env(:indexer, :block_ranges) + on_exit(fn -> Application.put_env(:indexer, :block_ranges, previous_block_ranges) end) + + Application.put_env(:indexer, :block_ranges, "1..5,10..latest") + + Enum.each([1, 3, 5, 10, 13], fn number -> + insert(:block, number: number, consensus: true) + end) + + # 2 and 11 are null rounds, 4 and 12 are genuinely missing + Explorer.Chain.NullRoundHeight.insert_heights([2, 11]) + + assert IndexerMetrics.missing_blocks_count() == 2 + end + end end describe "refetch_needed_blocks_count/0" do 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 dfa62c3b68b..ca8ff3d17e0 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs @@ -305,6 +305,70 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do assert implementation_abi == @implementation_abi end + describe "fetch_value/2 and fetch_values/2" do + setup do + address = insert(:address) + %{address: address} + end + + test "returns nil when eth_call reverts", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:error, "execution reverted"} + end) + + assert Proxy.fetch_value({:call, "0x5c60da1b"}, address.hash) == {:ok, nil} + end + + test "returns error when eth_call fails with a non-revert error", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:error, :timeout} + end) + + assert Proxy.fetch_value({:call, "0x5c60da1b"}, address.hash) == :error + end + + test "returns error when batched eth_call fails with a non-revert error", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:ok, [%{id: 0, error: "timeout"}]} + end) + + assert Proxy.fetch_values([{:call, "0x5c60da1b"}], address.hash) == :error + end + + test "returns nil for reverted batched eth_call", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:ok, [%{id: 0, error: %{code: 3, message: "execution reverted"}}]} + end) + + assert Proxy.fetch_values([{:call, "0x5c60da1b"}], address.hash) == + {:ok, %{{:call, "0x5c60da1b"} => nil}} + end + + test "returns nil when eth_call reverts with a capitalized message (Besu)", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:error, %{code: -32000, message: "Execution reverted"}} + end) + + assert Proxy.fetch_value({:call, "0x5c60da1b"}, address.hash) == {:ok, nil} + end + + test "returns nil when eth_call reverts with the standard revert error code", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:error, %{code: 3, message: "Reverted", data: "0x08c379a0"}} + end) + + assert Proxy.fetch_value({:call, "0x5c60da1b"}, address.hash) == {:ok, nil} + end + + test "returns error when eth_call fails with a non-binary error message", %{address: address} do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:error, %{message: {:closed, :timeout}}} + end) + + assert Proxy.fetch_value({:call, "0x5c60da1b"}, address.hash) == :error + end + end + test "extract_address_hash/1" do assert Proxy.extract_address_hash(nil) == nil assert Proxy.extract_address_hash("0x") == nil diff --git a/apps/explorer/test/explorer/chain/smart_contract_test.exs b/apps/explorer/test/explorer/chain/smart_contract_test.exs index 3951e66ed3f..3457712e5a9 100644 --- a/apps/explorer/test/explorer/chain/smart_contract_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract_test.exs @@ -549,4 +549,69 @@ defmodule Explorer.Chain.SmartContractTest do refute log =~ ~s(Error determining value json for "tuple") end + + describe "check_and_update_constructor_args/1" do + setup do + # the last 2 bytes of the deployed bytecode hold the length of the CBOR metadata preceding them + metadata = "a1b2c3d4" <> "0004" + + constructor_arguments = + [42] + |> ABI.TypeEncoder.encode([{:uint, 256}]) + |> Base.encode16(case: :lower) + + address = insert(:address, contract_code: "0x6080604052" <> metadata) + + :transaction + |> insert( + created_contract_address_hash: address.hash, + input: "0x6080604052" <> metadata <> constructor_arguments + ) + |> with_block(status: :ok) + + smart_contract = + insert(:smart_contract, + address_hash: address.hash, + verified_via_sourcify: true, + constructor_arguments: nil, + abi: [ + %{ + "type" => "constructor", + "inputs" => [%{"name" => "value", "type" => "uint256"}], + "stateMutability" => "nonpayable" + } + ] + ) + + %{address: address, smart_contract: smart_contract, constructor_arguments: "0x" <> constructor_arguments} + end + + test "stores 0x-prefixed constructor arguments parsed for a Sourcify-verified smart contract", %{ + address: address, + smart_contract: smart_contract, + constructor_arguments: constructor_arguments + } do + updated_smart_contract = SmartContract.check_and_update_constructor_args(smart_contract) + + assert to_string(updated_smart_contract.constructor_arguments) == constructor_arguments + + assert to_string(Repo.get_by(SmartContract, address_hash: address.hash).constructor_arguments) == + constructor_arguments + end + + test "stores 0x-prefixed constructor arguments parsed for an address with a Sourcify-verified smart contract", %{ + address: address, + smart_contract: smart_contract, + constructor_arguments: constructor_arguments + } do + address = %Address{Repo.reload!(address) | smart_contract: smart_contract} + + updated_address = SmartContract.check_and_update_constructor_args(address) + + assert to_string(updated_address.smart_contract.constructor_arguments) == constructor_arguments + + assert to_string(Repo.get_by(SmartContract, address_hash: address.hash).constructor_arguments) == + constructor_arguments + end + end end diff --git a/apps/explorer/test/explorer/chain/token/instance_test.exs b/apps/explorer/test/explorer/chain/token/instance_test.exs index ffaf92926ae..051f6eaf4e4 100644 --- a/apps/explorer/test/explorer/chain/token/instance_test.exs +++ b/apps/explorer/test/explorer/chain/token/instance_test.exs @@ -213,4 +213,213 @@ defmodule Explorer.Chain.Token.InstanceTest do assert result.metadata == %{"uri" => "http://example1.com"} end end + + describe "mime_to_media_category/1" do + test "returns nil for nil" do + assert Instance.mime_to_media_category(nil) == nil + end + + test "returns nil for empty string" do + assert Instance.mime_to_media_category("") == nil + end + + test "returns \"image\" for image MIME types" do + assert Instance.mime_to_media_category("image/png") == "image" + assert Instance.mime_to_media_category("image/jpeg") == "image" + assert Instance.mime_to_media_category("image/svg+xml") == "image" + assert Instance.mime_to_media_category("image") == "image" + end + + test "returns \"video\" for video MIME types" do + assert Instance.mime_to_media_category("video/mp4") == "video" + assert Instance.mime_to_media_category("video/webm") == "video" + assert Instance.mime_to_media_category("video") == "video" + end + + test "returns \"html\" for text/html" do + assert Instance.mime_to_media_category("text/html") == "html" + end + + test "returns nil for unsupported MIME types" do + assert Instance.mime_to_media_category("application/json") == nil + assert Instance.mime_to_media_category("text/plain") == nil + assert Instance.mime_to_media_category("audio/mpeg") == nil + end + end + + describe "get_image_url_from_metadata/1" do + test "returns nil for nil metadata" do + assert Instance.get_image_url_from_metadata(nil) == nil + end + + test "extracts image_url field" do + assert Instance.get_image_url_from_metadata(%{"image_url" => "https://example.com/img.png"}) == + "https://example.com/img.png" + end + + test "extracts image field" do + assert Instance.get_image_url_from_metadata(%{"image" => "ipfs://Qm123"}) == "ipfs://Qm123" + end + + test "extracts from properties.image" do + assert Instance.get_image_url_from_metadata(%{"properties" => %{"image" => "https://example.com/img.png"}}) == + "https://example.com/img.png" + end + + test "prefers image_url over image" do + metadata = %{"image_url" => "https://primary.com/img.png", "image" => "https://fallback.com/img.png"} + assert Instance.get_image_url_from_metadata(metadata) == "https://primary.com/img.png" + end + + test "returns nil when no image fields present" do + assert Instance.get_image_url_from_metadata(%{"name" => "Token"}) == nil + end + + test "returns nil for blank image_url" do + assert Instance.get_image_url_from_metadata(%{"image_url" => " "}) == nil + end + end + + describe "get_animation_url_from_metadata/1" do + test "returns nil for nil metadata" do + assert Instance.get_animation_url_from_metadata(nil) == nil + end + + test "extracts animation_url" do + assert Instance.get_animation_url_from_metadata(%{"animation_url" => "https://example.com/anim.mp4"}) == + "https://example.com/anim.mp4" + end + + test "returns nil when no animation_url" do + assert Instance.get_animation_url_from_metadata(%{"image" => "https://example.com/img.png"}) == nil + end + + test "returns nil for blank animation_url" do + assert Instance.get_animation_url_from_metadata(%{"animation_url" => " "}) == nil + end + end + + describe "batch_update_media_types/1" do + test "returns {0, nil} for empty list" do + assert Instance.batch_update_media_types([]) == {0, nil} + end + + test "updates media types for existing token instances" do + instance = insert(:token_instance) + + assert {1, nil} = + Instance.batch_update_media_types([ + %{ + token_contract_address_hash: instance.token_contract_address_hash, + token_id: instance.token_id, + image_type: "image/png", + animation_type: "" + } + ]) + + updated = + Repo.get_by!(Instance, + token_id: instance.token_id, + token_contract_address_hash: instance.token_contract_address_hash + ) + + assert updated.image_type == "image/png" + assert updated.animation_type == "" + end + + test "updates multiple instances in a single batch" do + instance1 = insert(:token_instance) + instance2 = insert(:token_instance) + + assert {2, nil} = + Instance.batch_update_media_types([ + %{ + token_contract_address_hash: instance1.token_contract_address_hash, + token_id: instance1.token_id, + image_type: "image/png", + animation_type: "video/mp4" + }, + %{ + token_contract_address_hash: instance2.token_contract_address_hash, + token_id: instance2.token_id, + image_type: "image/svg+xml", + animation_type: "" + } + ]) + + updated1 = + Repo.get_by!(Instance, + token_id: instance1.token_id, + token_contract_address_hash: instance1.token_contract_address_hash + ) + + assert updated1.image_type == "image/png" + assert updated1.animation_type == "video/mp4" + + updated2 = + Repo.get_by!(Instance, + token_id: instance2.token_id, + token_contract_address_hash: instance2.token_contract_address_hash + ) + + assert updated2.image_type == "image/svg+xml" + assert updated2.animation_type == "" + end + end + + describe "stream_token_instances_with_unfetched_media_type/2" do + test "streams instances with metadata but nil image_type" do + instance = + insert(:token_instance, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: nil, + animation_type: nil + ) + + {:ok, results} = + Instance.stream_token_instances_with_unfetched_media_type([], fn data, acc -> [data | acc] end) + + assert length(results) == 1 + assert hd(results).contract_address_hash == instance.token_contract_address_hash + assert hd(results).token_id == instance.token_id + end + + test "does not stream instances with both types already set" do + _instance = + insert(:token_instance, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "image/png", + animation_type: "" + ) + + {:ok, results} = + Instance.stream_token_instances_with_unfetched_media_type([], fn data, acc -> [data | acc] end) + + assert results == [] + end + + test "does not stream instances without metadata" do + _instance = insert(:token_instance, metadata: nil, image_type: nil, animation_type: nil) + + {:ok, results} = + Instance.stream_token_instances_with_unfetched_media_type([], fn data, acc -> [data | acc] end) + + assert results == [] + end + + test "streams instances where only one type is nil" do + instance = + insert(:token_instance, + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "image/png", + animation_type: nil + ) + + {:ok, results} = + Instance.stream_token_instances_with_unfetched_media_type([], fn data, acc -> [data | acc] end) + + assert length(results) == 1 + assert hd(results).token_id == instance.token_id + end + end end diff --git a/apps/explorer/test/explorer/chain/token/ui_multiplier_change_test.exs b/apps/explorer/test/explorer/chain/token/ui_multiplier_change_test.exs new file mode 100644 index 00000000000..3298c8fb46c --- /dev/null +++ b/apps/explorer/test/explorer/chain/token/ui_multiplier_change_test.exs @@ -0,0 +1,525 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Token.UIMultiplierChangeTest do + use Explorer.DataCase + + import ExUnit.CaptureLog + + alias Explorer.Chain.Token.UIMultiplierChange + alias Explorer.PagingOptions + + @one Decimal.new("1000000000000000000") + @two Decimal.new("2000000000000000000") + @four Decimal.new("4000000000000000000") + @eight Decimal.new("8000000000000000000") + + defp change(block_number, log_index, old, new, effective_at) do + %UIMultiplierChange{ + block_number: block_number, + log_index: log_index, + old_multiplier: old, + new_multiplier: new, + effective_at: effective_at + } + end + + describe "at/4" do + test "is nil when nothing is known about the token" do + refute UIMultiplierChange.at([], 100, 0, ~U[2026-09-01 00:00:00Z]) + end + + test "is nil when the moment of the amount is unknown" do + # without it there is no telling whether a scheduled change had matured, + # and guessing would be silently wrong + changes = [change(100, 0, @one, @two, ~U[2026-09-01 00:00:00Z])] + + refute UIMultiplierChange.at(changes, 200, 0, nil) + end + + test "resolves an amount older than every known change to the value the earliest one replaced" do + changes = [change(100, 0, @one, @two, ~U[2026-09-01 00:00:00Z])] + + assert UIMultiplierChange.at(changes, 50, 0, ~U[2026-08-01 00:00:00Z]) == @one + end + + test "keeps the old value between the announcement and the moment it takes effect" do + changes = [change(100, 0, @one, @two, ~U[2026-09-01 00:00:00Z])] + + assert UIMultiplierChange.at(changes, 150, 0, ~U[2026-08-31 23:59:59Z]) == @one + end + + test "switches to the new value from the moment it takes effect" do + changes = [change(100, 0, @one, @two, ~U[2026-09-01 00:00:00Z])] + + assert UIMultiplierChange.at(changes, 150, 0, ~U[2026-09-01 00:00:00Z]) == @two + assert UIMultiplierChange.at(changes, 150, 0, ~U[2026-09-02 00:00:00Z]) == @two + end + + test "accounts for a change announced earlier in the very same block" do + changes = [change(100, 3, @one, @two, ~U[2026-08-01 00:00:00Z])] + + assert UIMultiplierChange.at(changes, 100, 2, ~U[2026-09-01 00:00:00Z]) == @one + assert UIMultiplierChange.at(changes, 100, 4, ~U[2026-09-01 00:00:00Z]) == @two + end + + test "never applies a change that was superseded before it matured" do + changes = [ + # scheduled for October, but replaced in block 200 before that + change(100, 0, @one, @eight, ~U[2026-10-01 00:00:00Z]), + change(200, 0, @one, @two, ~U[2026-09-01 00:00:00Z]) + ] + + assert UIMultiplierChange.at(changes, 300, 0, ~U[2026-09-15 00:00:00Z]) == @two + assert UIMultiplierChange.at(changes, 300, 0, ~U[2026-11-01 00:00:00Z]) == @two + end + + test "walks a chain of matured changes" do + changes = [ + change(100, 0, @one, @two, ~U[2026-03-01 00:00:00Z]), + change(200, 0, @two, @four, ~U[2026-06-01 00:00:00Z]), + change(300, 0, @four, @eight, ~U[2026-09-01 00:00:00Z]) + ] + + assert UIMultiplierChange.at(changes, 150, 0, ~U[2026-04-01 00:00:00Z]) == @two + assert UIMultiplierChange.at(changes, 250, 0, ~U[2026-07-01 00:00:00Z]) == @four + assert UIMultiplierChange.at(changes, 350, 0, ~U[2026-10-01 00:00:00Z]) == @eight + end + end + + describe "put_ui_multipliers/2" do + test "fills the multiplier each transfer saw, not the one in force now" do + token = insert(:token, ui_multiplier: @four, new_ui_multiplier: @four, ui_multiplier_effective_at: nil) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + before_split = transfer_at(token, ~U[2026-05-01 00:00:00.000000Z], 150) + after_split = transfer_at(token, ~U[2026-07-01 00:00:00.000000Z], 160) + + assert [resolved_before, resolved_after] = + UIMultiplierChange.put_ui_multipliers([before_split, after_split]) + + assert resolved_before.ui_multiplier == @two + assert resolved_after.ui_multiplier == @four + end + + test "leaves transfers of tokens without ERC-8056 support alone" do + token = insert(:token) + transfer = transfer_at(token, ~U[2026-07-01 00:00:00.000000Z], 150) + + assert [resolved] = UIMultiplierChange.put_ui_multipliers([transfer]) + + refute resolved.ui_multiplier + end + + test "passes nil entries through" do + assert UIMultiplierChange.put_ui_multipliers([nil]) == [nil] + end + + test "resolves a transfer that comes without its block or transaction" do + # the shape the transaction endpoints hand over: the page carries the + # transaction once, so its transfers are loaded with nothing but the token + token = insert(:token, ui_multiplier: @four, new_ui_multiplier: @four, ui_multiplier_effective_at: nil) + + insert(:token_ui_multiplier_change, + token: token, + block_number: 100, + log_index: 0, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + transfer = %{transfer_at(token, ~U[2026-05-01 00:00:00.000000Z], 150) | block: nil, transaction: nil} + + assert [resolved] = UIMultiplierChange.put_ui_multipliers([transfer]) + + assert resolved.ui_multiplier == @two + end + + test "ignores a change whose block lost consensus" do + token = insert(:token, ui_multiplier: @four) + reorged = insert(:block, number: 100, consensus: false) + + insert(:token_ui_multiplier_change, + token: token, + block: reorged, + block_number: reorged.number, + log_index: 0, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + transfer = transfer_at(token, ~U[2026-07-01 00:00:00.000000Z], 150) + + assert [resolved] = UIMultiplierChange.put_ui_multipliers([transfer]) + + refute resolved.ui_multiplier + end + + test "refuses to resolve a token whose history outgrew the cap" do + token = insert(:token, ui_multiplier: @four) + block = insert(:block, number: 100) + now = DateTime.utc_now() + + rows = + Enum.map(1..1001, fn log_index -> + %{ + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + block_hash: block.hash, + log_index: log_index, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z], + inserted_at: now, + updated_at: now + } + end) + + Repo.insert_all(UIMultiplierChange, rows) + + transfer = transfer_at(token, ~U[2026-07-01 00:00:00.000000Z], 150) + + assert [resolved] = UIMultiplierChange.put_ui_multipliers([transfer]) + + refute resolved.ui_multiplier + end + end + + describe "insert_changes/1" do + test "records a change whose hashes are still the strings the log carried" do + # the shape `Indexer.Transform.TokenTransfers` hands over: both hashes are + # taken straight off the log and have not been cast yet, while + # `insert_all/3` dumps rather than casts and would raise on them + token = insert(:token) + block = insert(:block, number: 100) + + UIMultiplierChange.insert_changes([ + %{ + token_contract_address_hash: to_string(token.contract_address_hash), + block_number: block.number, + block_hash: to_string(block.hash), + log_index: 5, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ]) + + assert change = + Repo.get_by(UIMultiplierChange, + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + log_index: 5 + ) + + assert change.block_hash == block.hash + assert Decimal.equal?(change.new_multiplier, @four) + end + + test "records the transaction the log came from" do + token = insert(:token) + block = insert(:block, number: 100) + transaction = :transaction |> insert() |> with_block(block) + + UIMultiplierChange.insert_changes([ + %{ + token_contract_address_hash: to_string(token.contract_address_hash), + block_number: block.number, + block_hash: to_string(block.hash), + transaction_hash: to_string(transaction.hash), + log_index: 5, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ]) + + assert change = + Repo.get_by(UIMultiplierChange, + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + log_index: 5 + ) + + assert change.transaction_hash == transaction.hash + end + + test "records a change of a log that belongs to no transaction" do + token = insert(:token) + block = insert(:block, number: 100) + + UIMultiplierChange.insert_changes([ + %{ + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + block_hash: block.hash, + transaction_hash: nil, + log_index: 5, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ]) + + assert change = + Repo.get_by(UIMultiplierChange, + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + log_index: 5 + ) + + refute change.transaction_hash + end + + test "drops a change carrying an unparsable hash instead of taking the caller down" do + block = insert(:block, number: 100) + + log = + capture_log(fn -> + assert UIMultiplierChange.insert_changes([ + %{ + token_contract_address_hash: "not a hash", + block_number: block.number, + block_hash: to_string(block.hash), + log_index: 5, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ]) == {0, nil} + end) + + assert log =~ "unparsable hash" + assert Repo.all(UIMultiplierChange) == [] + end + + test "stops recording once a token reached the cap" do + token = insert(:token) + block = insert(:block, number: 100) + now = DateTime.utc_now() + + rows = + Enum.map(1..1000, fn log_index -> + %{ + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + block_hash: block.hash, + log_index: log_index, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z], + inserted_at: now, + updated_at: now + } + end) + + Repo.insert_all(UIMultiplierChange, rows) + + UIMultiplierChange.insert_changes([ + %{ + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + block_hash: block.hash, + log_index: 2000, + old_multiplier: @two, + new_multiplier: @one, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ]) + + refute Repo.get_by(UIMultiplierChange, + token_contract_address_hash: token.contract_address_hash, + block_number: block.number, + log_index: 2000 + ) + end + end + + describe "at_end_of_transaction/3" do + test "accounts for a change the transaction announced after its last known log" do + # a balance is the state the whole transaction leaves behind, so a change + # the transaction itself announced counts even when it was announced after + # the transfer the balance was derived from + transaction = transaction_at(~U[2026-09-01 00:00:00Z], 100) + + changes = [%{change(100, 9, @one, @two, ~U[2026-08-01 00:00:00Z]) | transaction_hash: transaction.hash}] + + assert UIMultiplierChange.at_end_of_transaction(changes, transaction, 3) == @two + end + + test "ignores a change announced by a later transaction of the same block" do + transaction = transaction_at(~U[2026-09-01 00:00:00Z], 100) + changes = [change(100, 9, @one, @two, ~U[2026-08-01 00:00:00Z])] + + assert UIMultiplierChange.at_end_of_transaction(changes, transaction, 3) == @one + end + + test "accounts for a change announced by an earlier transaction of the same block" do + transaction = transaction_at(~U[2026-09-01 00:00:00Z], 100) + changes = [change(100, 1, @one, @two, ~U[2026-08-01 00:00:00Z])] + + assert UIMultiplierChange.at_end_of_transaction(changes, transaction, 3) == @two + end + + test "keeps the old value of a change the transaction announced but which has not matured" do + transaction = transaction_at(~U[2026-09-01 00:00:00Z], 100) + + changes = [%{change(100, 9, @one, @two, ~U[2026-10-01 00:00:00Z]) | transaction_hash: transaction.hash}] + + assert UIMultiplierChange.at_end_of_transaction(changes, transaction, 3) == @one + end + end + + describe "paginated_for_token/2" do + test "lists the history of one token, newest first, with the moment of each announcement" do + token = insert(:token, ui_multiplier: @four) + transaction = transaction_at(~U[2026-03-01 00:00:00.000000Z], 100) + + insert(:token_ui_multiplier_change, + token: token, + block: transaction.block, + block_number: 100, + log_index: 0, + transaction_hash: transaction.hash, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-01 00:00:00.000000Z] + ) + + later = insert(:block, number: 200, timestamp: ~U[2026-06-01 00:00:00.000000Z]) + + insert(:token_ui_multiplier_change, + token: token, + block: later, + block_number: 200, + log_index: 7, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-06-01 00:00:00.000000Z] + ) + + assert [newest, oldest] = UIMultiplierChange.paginated_for_token(token.contract_address_hash) + + assert %{block_number: 200, log_index: 7, transaction_hash: nil} = newest + assert newest.timestamp == ~U[2026-06-01 00:00:00.000000Z] + assert newest.block_hash == later.hash + assert Decimal.equal?(newest.new_multiplier, @four) + + assert %{block_number: 100, log_index: 0} = oldest + assert oldest.transaction_hash == transaction.hash + assert oldest.timestamp == ~U[2026-03-01 00:00:00.000000Z] + end + + test "leaves out a change whose block lost consensus" do + token = insert(:token, ui_multiplier: @four) + reorged = insert(:block, number: 100, consensus: false) + + insert(:token_ui_multiplier_change, + token: token, + block: reorged, + block_number: 100, + log_index: 0, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-01 00:00:00.000000Z] + ) + + assert UIMultiplierChange.paginated_for_token(token.contract_address_hash) == [] + end + + test "pages with the position of the last row of the previous page" do + token = insert(:token, ui_multiplier: @four) + block = insert(:block, number: 100) + + for log_index <- 0..2 do + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: 100, + log_index: log_index, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-01 00:00:00.000000Z] + ) + end + + assert [%{log_index: 1}, %{log_index: 0}] = + UIMultiplierChange.paginated_for_token(token.contract_address_hash, + paging_options: %PagingOptions{key: {100, 2}, page_size: 10} + ) + end + + test "keeps a change that is announced but not yet in force" do + token = insert(:token, ui_multiplier: @one) + block = insert(:block, number: 100) + + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: 100, + log_index: 0, + old_multiplier: @one, + new_multiplier: @two, + effective_at: DateTime.add(DateTime.utc_now(), 1, :hour) + ) + + assert [%{log_index: 0}] = UIMultiplierChange.paginated_for_token(token.contract_address_hash) + end + end + + describe "count_for_token/2" do + test "counts what paginated_for_token/2 lists" do + token = insert(:token, ui_multiplier: @four) + block = insert(:block, number: 100) + reorged = insert(:block, number: 101, consensus: false) + + for {block, log_index} <- [{block, 0}, {block, 1}, {reorged, 0}] do + insert(:token_ui_multiplier_change, + token: token, + block: block, + block_number: block.number, + log_index: log_index, + old_multiplier: @one, + new_multiplier: @two, + effective_at: ~U[2026-03-01 00:00:00.000000Z] + ) + end + + assert UIMultiplierChange.count_for_token(token.contract_address_hash) == 2 + end + + test "is zero for a token that never announced a change" do + token = insert(:token) + + assert UIMultiplierChange.count_for_token(token.contract_address_hash) == 0 + end + end + + defp transfer_at(token, timestamp, block_number) do + block = insert(:block, number: block_number, timestamp: timestamp) + transaction = :transaction |> insert() |> with_block(block) + + :token_transfer + |> insert( + transaction: transaction, + block: block, + block_number: block_number, + token_contract_address: token.contract_address + ) + |> Repo.preload([:token, :block]) + end + + defp transaction_at(timestamp, block_number) do + block = insert(:block, number: block_number, timestamp: timestamp) + + :transaction |> insert() |> with_block(block) |> Repo.preload(:block) + end +end diff --git a/apps/explorer/test/explorer/chain/token_test.exs b/apps/explorer/test/explorer/chain/token_test.exs index 29a2251cf72..6072501f7cb 100644 --- a/apps/explorer/test/explorer/chain/token_test.exs +++ b/apps/explorer/test/explorer/chain/token_test.exs @@ -233,4 +233,47 @@ defmodule Explorer.Chain.TokenTest do assert [%{name: "0xINVALID_HEX Token"}] = results end end + + describe "effective_ui_multiplier/2" do + @one Decimal.new("1000000000000000000") + @two Decimal.new("2000000000000000000") + @effective_at ~U[2026-09-01 00:00:00.000000Z] + + test "is nil for a token that does not implement ERC-8056" do + refute Token.effective_ui_multiplier(build(:token)) + end + + test "returns the current multiplier when no change is scheduled" do + token = build(:token, ui_multiplier: @one, new_ui_multiplier: nil, ui_multiplier_effective_at: nil) + + assert Token.effective_ui_multiplier(token) == @one + end + + test "returns the current multiplier while the scheduled change is in the future" do + token = build(:token, ui_multiplier: @one, new_ui_multiplier: @two, ui_multiplier_effective_at: @effective_at) + + assert Token.effective_ui_multiplier(token, ~U[2026-08-31 23:59:59.999999Z]) == @one + end + + test "returns the pending multiplier from the moment it becomes effective" do + token = build(:token, ui_multiplier: @one, new_ui_multiplier: @two, ui_multiplier_effective_at: @effective_at) + + # the on-chain getter compares with `>=`, so the switch happens exactly at + # `effectiveAt` and nothing has to be written to the database for it + assert Token.effective_ui_multiplier(token, @effective_at) == @two + assert Token.effective_ui_multiplier(token, ~U[2026-09-01 00:00:00.000001Z]) == @two + end + + test "returns the pending multiplier when effectiveAt is the Unix epoch" do + # `effectiveAt() == 0` means the pending multiplier is already in effect + token = + build(:token, + ui_multiplier: @one, + new_ui_multiplier: @two, + ui_multiplier_effective_at: ~U[1970-01-01 00:00:00Z] + ) + + assert Token.effective_ui_multiplier(token) == @two + end + end end diff --git a/apps/explorer/test/explorer/chain/transaction_test.exs b/apps/explorer/test/explorer/chain/transaction_test.exs index e7f1a16bdbd..d624fd6e6d8 100644 --- a/apps/explorer/test/explorer/chain/transaction_test.exs +++ b/apps/explorer/test/explorer/chain/transaction_test.exs @@ -468,7 +468,7 @@ defmodule Explorer.Chain.TransactionTest do insert(:transaction, to_address: proxy_smart_contract.address, input: "0x" <> input_data) end - describe "Poison.encode!/1" do + describe "Utils.JSON.encode!/1" do test "encodes transaction input" do assert %{ insert(:transaction) @@ -478,7 +478,7 @@ defmodule Explorer.Chain.TransactionTest do 191, 128, 248>> } } - |> Poison.encode!() + |> Utils.JSON.encode!() end end diff --git a/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs b/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs index 3470e18aa93..80f89e3627e 100644 --- a/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs +++ b/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs @@ -5,7 +5,7 @@ defmodule Explorer.ChainSpec.GenesisDataTest do @besu_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/qdai_genesis.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() setup do # Patch application env old_genesis_data = Application.get_env(:explorer, Explorer.ChainSpec.GenesisData) diff --git a/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs b/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs index 4916ada1c42..46f25dff865 100644 --- a/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs +++ b/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs @@ -15,19 +15,19 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do @geth_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/qdai_genesis.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() @polygon_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/polygon_genesis.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() @optimism_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/optimism_genesis.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() @zkatana_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/zkatana_genesis.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() describe "genesis_accounts/1" do test "parses coin balance and contract code" do diff --git a/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs b/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs index d711dcb2e08..1b2a19bc9c7 100644 --- a/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs +++ b/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs @@ -16,11 +16,11 @@ defmodule Explorer.ChainSpec.Parity.ImporterTest do @chain_spec "#{File.cwd!()}/test/support/fixture/chain_spec/foundation.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() @chain_classic_spec "#{File.cwd!()}/test/support/fixture/chain_spec/classic.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() describe "emission_rewards/1" do test "fetches and formats reward ranges" do diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 2508e0ee25b..ae8aabeaf79 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -40,7 +40,7 @@ defmodule Explorer.ChainTest do alias Explorer.TestHelper - alias Explorer.Utility.AddressIdToAddressHash + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic} @first_topic_hex_string "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" @second_topic_hex_string "0x000000000000000000000000e8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" @@ -106,6 +106,104 @@ defmodule Explorer.ChainTest do assert Enum.count(Chain.address_to_logs(address_hash, false)) == 2 end + test "fetches logs matched by `address_id` and by the legacy `address_hash` while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + %Address{hash: address_hash} = address = insert(:address) + + transaction = + :transaction + |> insert(to_address: address) + |> with_block() + + # already migrated log, matched by `address_id` + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 1, + address: address, + address_hash: nil + ) + + # not yet migrated log, matched by the legacy `address_hash` + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 2, + address: address, + address_mapping: nil + ) + + # log re-imported before it is migrated: the upsert fills `address_id` while + # `address_hash` is kept, so both fields match the address and the log has to + # be returned once + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 3, + address: address + ) + + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 4, + address: insert(:address), + address_mapping: nil + ) + + assert [3, 2, 1] == address_hash |> Chain.address_to_logs(false) |> Enum.map(& &1.index) + + paging_options = %PagingOptions{page_size: 1} + + assert [3] == address_hash |> Chain.address_to_logs(false, paging_options: paging_options) |> Enum.map(& &1.index) + + paging_options = %PagingOptions{page_size: 50, key: {transaction.block_number, 2}} + + assert [1] == address_hash |> Chain.address_to_logs(false, paging_options: paging_options) |> Enum.map(& &1.index) + end + + test "filters logs by topic while the optimized fields migration is in progress" do + set_fill_logs_optimized_fields_migration_started() + + %Address{hash: address_hash} = address = insert(:address) + + transaction = + :transaction + |> insert(to_address: address) + |> with_block() + + {:ok, first_topic} = Hash.Full.cast(@first_topic_hex_string) + + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 1, + address: address, + address_hash: nil + ) + + insert(:log, + block: transaction.block, + block_number: transaction.block_number, + transaction: transaction, + index: 2, + address: address, + address_mapping: nil, + first_topic: first_topic + ) + + assert [2] == + address_hash + |> Chain.address_to_logs(false, topic: @first_topic_hex_string) + |> Enum.map(& &1.index) + end + test "paginates logs" do %Address{hash: address_hash} = address = insert(:address) @@ -161,6 +259,7 @@ defmodule Explorer.ChainTest do insert(:log, block: transaction2.block, transaction: transaction2, + transaction_index: transaction2.index, index: 2, address: address, first_topic: first_topic, @@ -187,6 +286,7 @@ defmodule Explorer.ChainTest do block: transaction1.block, block_number: transaction1.block_number, transaction: transaction1, + transaction_index: transaction1.index, index: 1, address: address, fourth_topic: fourth_topic @@ -201,6 +301,7 @@ defmodule Explorer.ChainTest do block: transaction2.block, block_number: transaction2.block.number, transaction: transaction2, + transaction_index: transaction2.index, index: 2, address: address ) @@ -1100,6 +1201,7 @@ defmodule Explorer.ChainTest do params: [ %{ block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + block_number: 37, address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", first_topic: first_topic, @@ -1107,7 +1209,8 @@ defmodule Explorer.ChainTest do third_topic: third_topic, fourth_topic: nil, index: 0, - transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" + transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 1 } ] }, @@ -1171,7 +1274,6 @@ defmodule Explorer.ChainTest do } test "with valid data", %{json_rpc_named_arguments: _json_rpc_named_arguments} do - {:ok, first_topic} = Explorer.Chain.Hash.Full.cast(@first_topic_hex_string) {:ok, second_topic} = Explorer.Chain.Hash.Full.cast(@second_topic_hex_string) {:ok, third_topic} = Explorer.Chain.Hash.Full.cast(@third_topic_hex_string) difficulty = Decimal.new(340_282_366_920_938_463_463_374_607_431_768_211_454) @@ -1188,6 +1290,8 @@ defmodule Explorer.ChainTest do %{address_id: to_address_id} = AddressIdToAddressHash.find_or_create("0x8bf38d4764929064f2d4d3a56520a76ab3df415b") + %{id: first_topic_id} = LogFirstTopic.find_or_create(@first_topic_hex_string) + assert {:ok, %{ addresses: [ @@ -1295,19 +1399,14 @@ defmodule Explorer.ChainTest do ], logs: [ %Log{ - address_hash: %Hash{ - byte_count: 20, - bytes: - <<139, 243, 141, 71, 100, 146, 144, 100, 242, 212, 211, 165, 101, 32, 167, 106, 179, 223, 65, - 91>> - }, - data: %Data{ + address_id: ^to_address_id, + compressed_data: %Data{ bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 224, 182, 179, 167, 100, 0, 0>> }, index: 0, - first_topic: ^first_topic, + first_topic_id: ^first_topic_id, second_topic: ^second_topic, third_topic: ^third_topic, fourth_topic: nil, @@ -1317,6 +1416,7 @@ defmodule Explorer.ChainTest do <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> }, + transaction_index: 1, inserted_at: %{}, updated_at: %{} } @@ -1405,6 +1505,42 @@ defmodule Explorer.ChainTest do assert Repo.aggregate(MainExportQueue, :count, :hash) == 5 end + test "populates main multichain export queue with proxy contract implementations (verified and not verified)" do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + multichain_configuration = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, multichain_configuration) + end) + + bypass = Bypass.open() + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + addresses_chunk_size: 7_000 + ) + + proxy_hash = "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" + implementation_1 = insert(:address) + implementation_2 = insert(:address) + + insert(:proxy_implementation, + proxy_address_hash: proxy_hash, + proxy_type: "eip1167", + address_hashes: [implementation_1.hash, implementation_2.hash], + names: ["Test1", "Test2"] + ) + + insert(:smart_contract, address_hash: implementation_1.hash, name: "TestContract1", contract_code_md5: "123") + + TestHelper.get_chain_id_mock() + Chain.import(@import_data) + + # 3 addresses + 2 implementations + 1 block + 1 transaction + assert Repo.aggregate(MainExportQueue, :count, :hash) == 7 + end + test "doesn't populate main multichain export queue from on_demand fetcher, if the address is inactive on the chain" do Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) @@ -1738,31 +1874,6 @@ defmodule Explorer.ChainTest do |> Chain.transaction_to_logs(paging_options: %PagingOptions{key: {log.index}, page_size: 50}) |> Enum.map(& &1.index) end - - test "with logs necessity_by_association loads associations" do - transaction = - :transaction - |> insert() - |> with_block() - - insert(:log, transaction: transaction, block: transaction.block, block_number: transaction.block_number) - - assert [%Log{address: %Address{}, transaction: %Transaction{}}] = - Chain.transaction_to_logs( - transaction.hash, - necessity_by_association: %{ - address: :optional, - transaction: :optional - } - ) - - assert [ - %Log{ - address: %Ecto.Association.NotLoaded{}, - transaction: %Ecto.Association.NotLoaded{} - } - ] = Chain.transaction_to_logs(transaction.hash) - end end describe "transaction_to_token_transfers/2" do @@ -2258,6 +2369,30 @@ defmodule Explorer.ChainTest do end end + describe "balance_in_fiat/1" do + test "prices an ERC-8056 balance by its displayed amount, not its raw one" do + # `fiat_value` quotes the UI amount, which is twice the raw one here + token = + build(:token, + type: "ERC-8056", + decimals: Decimal.new(0), + fiat_value: Decimal.new(3), + ui_multiplier: Decimal.new("2000000000000000000") + ) + + token_balance = %{token: token, value: Decimal.new(5), fiat_value: nil} + + assert Decimal.equal?(Chain.balance_in_fiat(token_balance), Decimal.new(30)) + end + + test "leaves a token without ERC-8056 support priced by its raw balance" do + token = build(:token, decimals: Decimal.new(0), fiat_value: Decimal.new(3)) + token_balance = %{token: token, value: Decimal.new(5), fiat_value: nil} + + assert Decimal.equal?(Chain.balance_in_fiat(token_balance), Decimal.new(15)) + end + end + describe "fetch_token_holders_from_token_hash/3" do test "returns the token holders" do %Token{contract_address_hash: contract_address_hash} = insert(:token) diff --git a/apps/explorer/test/explorer/eth_rpc_test.exs b/apps/explorer/test/explorer/eth_rpc_test.exs index 52973c170f5..efef2b15821 100644 --- a/apps/explorer/test/explorer/eth_rpc_test.exs +++ b/apps/explorer/test/explorer/eth_rpc_test.exs @@ -1,15 +1,284 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.EthRPCTest do - # the tested requests are proxied to the node, so no database is involved - use ExUnit.Case, async: false + use Explorer.DataCase, async: false import Mox alias Explorer.EthRPC + alias Explorer.Utility.LogFirstTopic + + @log_address_hash_string "0xe93c8cd0d409341205a592f8c4ac1a5fe5585cfa" + @first_topic_hex_string "0xb3813568d9991fc951961fcb4c784893574240a28925604d09fc577c55bb7c32" + @second_topic_hex_string "0x000000000000000000000000e38ecdf3cfbaf5cf347e6a3d6490eb34e3a0119d" + @third_topic_hex_string "0x000000000000000000000000e38ecdf3cfbaf5cf347e6a3d6490eb34e3a0119d" + @fourth_topic_hex_string "0x0000000000000000000000000000000000000000000000000000000000000000" + @logs_bloom "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000040000000000000000000000000002000000000000000000000000000000000000000000000000030000000000000000000800000000000000000000000000000000000000000000000002000000008000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000002000000000000000080000000000000000000000" setup :verify_on_exit! setup :set_mox_global + setup do + original_json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + original_eth_rpc_config = Application.get_env(:explorer, Explorer.EthRPC) + + Application.put_env(:explorer, :json_rpc_named_arguments, + transport: EthereumJSONRPC.Mox, + transport_options: [] + ) + + on_exit(fn -> + restore_env(:explorer, :json_rpc_named_arguments, original_json_rpc_named_arguments) + restore_env(:explorer, Explorer.EthRPC, original_eth_rpc_config) + end) + + :ok + end + + test "extended proxy methods are disabled by default" do + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32601, message: "Method not found."}, id: 1} + end + + test "extended proxy methods are proxied when feature flag is enabled" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [%{id: id, jsonrpc: "2.0", method: "net_version", params: []}], _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "1"}]} + end) + + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{id: 1, result: "1"} + end + + test "proxy validation errors are returned without re-dispatching locally" do + request = %{ + "id" => 1, + "jsonrpc" => "2.0", + "method" => "eth_getCode", + "params" => ["not-an-address", "latest"] + } + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32_602, message: "Invalid address"}, id: 1} + end + + test "JSONRPC error returned by the node for a proxied request is passed through" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [%{id: id, jsonrpc: "2.0", method: "eth_getProof", params: _}], _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + error: %{code: -32_602, message: "distance to target block exceeds maximum proof window"} + } + ]} + end) + + request = %{ + "id" => 3, + "jsonrpc" => "2.0", + "method" => "eth_getProof", + "params" => ["0x96f56752bde1b9f6f86393658a79dec9f7095de3", [], "0x2d69fc1"] + } + + assert [response] = EthRPC.responses([request]) + + assert response == %{ + error: %{code: -32_602, message: "distance to target block exceeds maximum proof window"}, + id: 3 + } + end + + test "transport error for a proxied request is returned as an internal error" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: _, jsonrpc: "2.0", method: "net_version", params: []}], _options -> + {:error, :timeout} + end) + + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32_603, message: ":timeout"}, id: 1} + end + + test "undecodable node response for a proxied request is returned as an internal error without the node URL" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: _, jsonrpc: "2.0", method: "net_version", params: []}], _options -> + {:error, {:bad_response, "http://node.example.com:8545"}} + end) + + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32_603, message: ":bad_response"}, id: 1} + end + + test "bad gateway node response for a proxied request is returned as an internal error without the node URL" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: _, jsonrpc: "2.0", method: "net_version", params: []}], _options -> + {:error, {:bad_gateway, "http://node.example.com:8545"}} + end) + + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32_603, message: ":bad_gateway"}, id: 1} + end + + test "default proxy methods remain available when feature flag is disabled" do + set_extended_proxy_methods_enabled(false) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [%{id: id, jsonrpc: "2.0", method: "eth_getCode", params: [_, "latest"]}], _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "0x"}]} + end) + + request = %{ + "id" => 1, + "jsonrpc" => "2.0", + "method" => "eth_getCode", + "params" => ["0x0000000000000000000000000000000000000007", "latest"] + } + + assert [response] = EthRPC.responses([request]) + assert response == %{id: 1, result: "0x"} + end + + test "eth_feeHistory accepts both arity 2 and arity 3" do + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [ + %{id: first_id, jsonrpc: "2.0", method: "eth_feeHistory", params: ["0x4", "latest"]}, + %{id: second_id, jsonrpc: "2.0", method: "eth_feeHistory", params: ["0x4", "latest", [25, 50]]} + ], + _options -> + {:ok, + [ + %{id: first_id, jsonrpc: "2.0", result: %{oldestBlock: "0x1"}}, + %{id: second_id, jsonrpc: "2.0", result: %{oldestBlock: "0x1"}} + ]} + end) + + requests = [ + %{"id" => 1, "jsonrpc" => "2.0", "method" => "eth_feeHistory", "params" => ["0x4", "latest"]}, + %{"id" => 2, "jsonrpc" => "2.0", "method" => "eth_feeHistory", "params" => ["0x4", "latest", [25, 50]]} + ] + + assert [%{id: 1, result: %{oldestBlock: "0x1"}}, %{id: 2, result: %{oldestBlock: "0x1"}}] = + EthRPC.responses(requests) + end + + test "core proxy methods are disabled when API_ETH_RPC_DISABLE_CORE_PROXY_METHODS is true" do + set_core_proxy_methods_disabled(true) + + request = %{ + "id" => 1, + "jsonrpc" => "2.0", + "method" => "eth_getCode", + "params" => ["0x0000000000000000000000000000000000000007", "latest"] + } + + assert [response] = EthRPC.responses([request]) + assert response == %{error: %{code: -32601, message: "Method not found."}, id: 1} + end + + test "core proxy methods remain available when API_ETH_RPC_DISABLE_CORE_PROXY_METHODS is false" do + set_core_proxy_methods_disabled(false) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [%{id: id, jsonrpc: "2.0", method: "eth_getCode", params: [_, "latest"]}], _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "0x"}]} + end) + + request = %{ + "id" => 1, + "jsonrpc" => "2.0", + "method" => "eth_getCode", + "params" => ["0x0000000000000000000000000000000000000007", "latest"] + } + + assert [response] = EthRPC.responses([request]) + assert response == %{id: 1, result: "0x"} + end + + test "extended proxy methods still work when core proxy methods are disabled" do + set_core_proxy_methods_disabled(true) + set_extended_proxy_methods_enabled(true) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [%{id: id, jsonrpc: "2.0", method: "net_version", params: []}], _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "1"}]} + end) + + request = %{"id" => 1, "jsonrpc" => "2.0", "method" => "net_version", "params" => []} + + assert [response] = EthRPC.responses([request]) + assert response == %{id: 1, result: "1"} + end + + describe "eth_getTransactionReceipt" do + test "renders logs and logsBloom of a transaction with already migrated logs" do + address = insert(:address, hash: @log_address_hash_string) + + transaction = :transaction |> insert() |> with_block() + + log = + insert(:log, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + address: address, + address_hash: nil, + data: nil, + first_topic: nil, + first_topic_id: LogFirstTopic.find_or_create(@first_topic_hex_string).id, + second_topic: @second_topic_hex_string, + third_topic: @third_topic_hex_string, + fourth_topic: @fourth_topic_hex_string + ) + + assert {:ok, receipt} = EthRPC.eth_get_transaction_receipt(to_string(transaction.hash)) + + assert receipt["logsBloom"] == @logs_bloom + + assert [rendered_log] = receipt["logs"] + assert to_string(rendered_log["address"]) == @log_address_hash_string + assert rendered_log["data"] == log.compressed_data + + assert Enum.map(rendered_log["topics"], &to_string/1) == [ + @first_topic_hex_string, + @second_topic_hex_string, + @third_topic_hex_string, + @fourth_topic_hex_string + ] + end + end + + defp set_extended_proxy_methods_enabled(value) do + initial = Application.get_env(:explorer, Explorer.EthRPC) || [] + Application.put_env(:explorer, Explorer.EthRPC, Keyword.merge(initial, extended_proxy_methods_enabled: value)) + end + + defp set_core_proxy_methods_disabled(value) do + initial = Application.get_env(:explorer, Explorer.EthRPC) || [] + Application.put_env(:explorer, Explorer.EthRPC, Keyword.merge(initial, disable_core_proxy_methods: value)) + end + + defp restore_env(app, key, nil), do: Application.delete_env(app, key) + defp restore_env(app, key, value), do: Application.put_env(app, key, value) + @address_hash "0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F" describe "responses/1" do diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs index eb61e51872b..88cc37c554f 100644 --- a/apps/explorer/test/explorer/graphql_test.exs +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -255,7 +255,7 @@ defmodule Explorer.GraphQLTest do end end - describe "list_token_transfers_query/1" do + describe "list_token_transfers_query/1,2" do test "with token contract address hash with zero token transfers" do result = :address @@ -346,5 +346,64 @@ defmodule Explorer.GraphQLTest do assert block_number_order == Enum.sort(block_number_order, &(&1 >= &2)) end + + test "hides scam token transfers when hide_scam_addresses is enabled and show_scam_tokens? is false" do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address) + insert(:scam_badge_to_address, address_hash: token_address.hash) + transaction = insert(:transaction) + insert(:token_transfer, transaction: transaction, token_contract_address: token_address) + + result = + token_address.hash + |> GraphQL.list_token_transfers_query(show_scam_tokens?: false) + |> Repo.replica().all() + + assert result == [] + end + + test "shows scam token transfers when show_scam_tokens? is true" do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address) + insert(:scam_badge_to_address, address_hash: token_address.hash) + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction, token_contract_address: token_address) + + [found_token_transfer] = + token_address.hash + |> GraphQL.list_token_transfers_query(show_scam_tokens?: true) + |> Repo.replica().all() + + assert found_token_transfer.transaction_hash == token_transfer.transaction_hash + assert found_token_transfer.log_index == token_transfer.log_index + end + + test "shows scam token transfers when hide_scam_addresses is disabled regardless of show_scam_tokens?" do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address) + insert(:scam_badge_to_address, address_hash: token_address.hash) + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction, token_contract_address: token_address) + + [found_token_transfer] = + token_address.hash + |> GraphQL.list_token_transfers_query(show_scam_tokens?: false) + |> Repo.replica().all() + + assert found_token_transfer.transaction_hash == token_transfer.transaction_hash + assert found_token_transfer.log_index == token_transfer.log_index + end end end diff --git a/apps/explorer/test/explorer/market/fetcher/token_list_test.exs b/apps/explorer/test/explorer/market/fetcher/token_list_test.exs index 1d9293f86fa..0eae792f2f0 100644 --- a/apps/explorer/test/explorer/market/fetcher/token_list_test.exs +++ b/apps/explorer/test/explorer/market/fetcher/token_list_test.exs @@ -36,7 +36,7 @@ defmodule Explorer.Market.Fetcher.TokenListTest do end defp token_list_json(tokens) do - Jason.encode!(%{ + Utils.JSON.encode!(%{ "name" => "Test Token List", "tokens" => tokens }) @@ -218,7 +218,7 @@ defmodule Explorer.Market.Fetcher.TokenListTest do test "skips tokens with missing address field", %{bypass: bypass} do body = - Jason.encode!(%{ + Utils.JSON.encode!(%{ "name" => "Test List", "tokens" => [ %{"chainId" => 77, "name" => "No Address", "symbol" => "NA", "decimals" => 18} diff --git a/apps/explorer/test/explorer/market/fetcher/token_test.exs b/apps/explorer/test/explorer/market/fetcher/token_test.exs index c8ab66da1fd..ad7581014c7 100644 --- a/apps/explorer/test/explorer/market/fetcher/token_test.exs +++ b/apps/explorer/test/explorer/market/fetcher/token_test.exs @@ -37,11 +37,15 @@ defmodule Explorer.Market.Fetcher.TokenTest do Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + previous_fetcher_enabled = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + on_exit(fn -> Application.put_env(:explorer, Explorer.Market.Source, source_configuration) Application.put_env(:explorer, Explorer.Market.Fetcher.Token, fetcher_configuration) Application.put_env(:explorer, Explorer.Market.Source.CoinGecko, coin_gecko_configuration) Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + :persistent_term.put(:market_token_fetcher_enabled, previous_fetcher_enabled) end) [_token_with_no_exchange_rate | tokens] = @@ -69,15 +73,20 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> assert conn.query_string == "include_platform=true" - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) token_exchange_rates = tokens - |> Enum.reduce(%{}, fn %{contract_address_hash: contract_address_hash}, acc -> - Map.put(acc, "#{contract_address_hash}_id", %{ - "usd" => 1..100 |> Enum.random() |> Decimal.new() |> Decimal.mult(Decimal.from_float(0.7)) - }) + |> Enum.map(fn %{contract_address_hash: contract_address_hash} -> + %{ + "id" => "#{contract_address_hash}_id", + "current_price" => 1..100 |> Enum.random() |> Decimal.new() |> Decimal.mult(Decimal.from_float(0.7)), + "market_cap" => 1..100 |> Enum.random() |> Decimal.new() |> Decimal.mult(Decimal.from_float(0.7)), + "total_volume" => 1..100 |> Enum.random() |> Decimal.new() |> Decimal.mult(Decimal.from_float(0.7)), + "circulating_supply" => 1_000_000, + "total_supply" => 2_000_000 + } end) joined_ids = @@ -88,22 +97,37 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect_once( bypass, "GET", - "/simple/price", + "/coins/markets", fn conn -> assert conn.query_string == - "vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&ids=#{joined_ids}" + "vs_currency=usd&ids=#{joined_ids}&per_page=10&page=1" - Conn.resp(conn, 200, Jason.encode!(token_exchange_rates)) + Conn.resp(conn, 200, Utils.JSON.encode!(token_exchange_rates)) end ) + on_chain_total_supplies = Token |> Repo.all() |> Map.new(&{&1.contract_address_hash, &1.total_supply}) + GenServer.start_link(TokenFetcher, []) :timer.sleep(100) Repo.all(Token) - |> Enum.each(fn %{contract_address_hash: contract_address_hash, fiat_value: fiat_value} -> - assert token_exchange_rates[contract_address_hash]["usd"] == fiat_value + |> Enum.each(fn %{ + contract_address_hash: contract_address_hash, + fiat_value: fiat_value, + circulating_supply: circulating_supply, + total_supply: total_supply + } -> + matching = Enum.find(token_exchange_rates, &(&1["id"] == "#{contract_address_hash}_id")) + assert matching["current_price"] == fiat_value + + if matching do + assert Decimal.eq?(circulating_supply, Decimal.new(1_000_000)) + end + + # market data must never overwrite the raw on-chain total supply + assert Decimal.eq?(total_supply, on_chain_total_supplies[contract_address_hash]) end) end @@ -138,7 +162,7 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> assert conn.query_string == "include_platform=true" - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) joined_ids = @@ -149,12 +173,12 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect_once( bypass, "GET", - "/simple/price", + "/coins/markets", fn conn -> assert conn.query_string == - "vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&ids=#{joined_ids}" + "vs_currency=usd&ids=#{joined_ids}&per_page=10&page=1" - Conn.resp(conn, 200, "{}") + Conn.resp(conn, 200, "[]") end ) @@ -197,7 +221,7 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect(bypass, "GET", "/coins/list", fn conn -> assert conn.query_string == "include_platform=true" - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) joined_ids = @@ -208,10 +232,10 @@ defmodule Explorer.Market.Fetcher.TokenTest do Bypass.expect( bypass, "GET", - "/simple/price", + "/coins/markets", fn conn -> assert conn.query_string == - "vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&ids=#{joined_ids}" + "vs_currency=usd&ids=#{joined_ids}&per_page=10&page=1" Conn.resp(conn, 429, "Too many requests") end @@ -317,19 +341,22 @@ defmodule Explorer.Market.Fetcher.TokenTest do ] Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) - token_exchange_rates = %{ - "returned_token" => %{ - "usd" => 5.0, - "usd_market_cap" => 1000.0, - "usd_24h_vol" => 500.0 + token_exchange_rates = [ + %{ + "id" => "returned_token", + "current_price" => 5.0, + "market_cap" => 1000.0, + "total_volume" => 500.0, + "circulating_supply" => 1_000_000, + "total_supply" => 2_000_000 } - } + ] - Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> - Conn.resp(conn, 200, Jason.encode!(token_exchange_rates)) + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + Conn.resp(conn, 200, Utils.JSON.encode!(token_exchange_rates)) end) GenServer.start_link(TokenFetcher, []) @@ -386,11 +413,24 @@ defmodule Explorer.Market.Fetcher.TokenTest do ] Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) - Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> - Conn.resp(conn, 200, Jason.encode!(%{"zero_token" => %{"usd" => 0, "usd_market_cap" => 0, "usd_24h_vol" => 0}})) + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + Conn.resp( + conn, + 200, + Utils.JSON.encode!([ + %{ + "id" => "zero_token", + "current_price" => 0, + "market_cap" => 0, + "total_volume" => 0, + "circulating_supply" => 0, + "total_supply" => 0 + } + ]) + ) end) GenServer.start_link(TokenFetcher, []) @@ -432,24 +472,30 @@ defmodule Explorer.Market.Fetcher.TokenTest do ] Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> - Conn.resp(conn, 200, Jason.encode!(coins_list)) + Conn.resp(conn, 200, Utils.JSON.encode!(coins_list)) end) - token_exchange_rates = %{ - "zero_price_token" => %{ - "usd" => 0, - "usd_market_cap" => 0, - "usd_24h_vol" => 0 + token_exchange_rates = [ + %{ + "id" => "zero_price_token", + "current_price" => 0, + "market_cap" => 0, + "total_volume" => 0, + "circulating_supply" => 0, + "total_supply" => 0 }, - "real_price_token" => %{ - "usd" => 10.0, - "usd_market_cap" => 5000.0, - "usd_24h_vol" => 100.0 + %{ + "id" => "real_price_token", + "current_price" => 10.0, + "market_cap" => 5000.0, + "total_volume" => 100.0, + "circulating_supply" => 1_000_000, + "total_supply" => 2_000_000 } - } + ] - Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> - Conn.resp(conn, 200, Jason.encode!(token_exchange_rates)) + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + Conn.resp(conn, 200, Utils.JSON.encode!(token_exchange_rates)) end) GenServer.start_link(TokenFetcher, []) diff --git a/apps/explorer/test/explorer/market/source/coin_gecko_test.exs b/apps/explorer/test/explorer/market/source/coin_gecko_test.exs index 43d0239d767..7c58bc994c4 100644 --- a/apps/explorer/test/explorer/market/source/coin_gecko_test.exs +++ b/apps/explorer/test/explorer/market/source/coin_gecko_test.exs @@ -147,9 +147,9 @@ defmodule Explorer.Market.Source.CoinGeckoTest do Conn.resp(conn, 200, json_coins_list()) end) - Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> - assert conn.query_string == "vs_currencies=aed&include_market_cap=true&include_24hr_vol=true&ids=4,3,1" - Conn.resp(conn, 200, json_simple_price()) + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + assert conn.query_string == "vs_currency=aed&ids=4,3,1&per_page=5&page=1" + Conn.resp(conn, 200, json_coins_markets()) end) assert {:ok, [], true, @@ -165,7 +165,8 @@ defmodule Explorer.Market.Source.CoinGeckoTest do }, circulating_market_cap: Decimal.new("100"), fiat_value: Decimal.new("1"), - volume_24h: Decimal.new("10") + volume_24h: Decimal.new("10"), + circulating_supply: Decimal.new("1000000") }, %{ id: "3", @@ -178,7 +179,8 @@ defmodule Explorer.Market.Source.CoinGeckoTest do }, circulating_market_cap: Decimal.new("300.03"), fiat_value: Decimal.new("3.3"), - volume_24h: Decimal.new("33.333") + volume_24h: Decimal.new("33.333"), + circulating_supply: Decimal.new("3000000") }, %{ id: "4", @@ -191,7 +193,8 @@ defmodule Explorer.Market.Source.CoinGeckoTest do }, circulating_market_cap: Decimal.new("4"), fiat_value: Decimal.new("4"), - volume_24h: Decimal.new("4") + volume_24h: Decimal.new("4"), + circulating_supply: Decimal.new("4000000") } ]} == CoinGecko.fetch_tokens(nil, 5) end @@ -202,9 +205,9 @@ defmodule Explorer.Market.Source.CoinGeckoTest do Conn.resp(conn, 200, json_coins_list()) end) - Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> - assert conn.query_string == "vs_currencies=aed&include_market_cap=true&include_24hr_vol=true&ids=4,3" - Conn.resp(conn, 200, json_simple_price()) + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + assert conn.query_string == "vs_currency=aed&ids=4,3&per_page=2&page=1" + Conn.resp(conn, 200, json_coins_markets()) end) assert {:ok, @@ -232,7 +235,8 @@ defmodule Explorer.Market.Source.CoinGeckoTest do }, circulating_market_cap: Decimal.new("300.03"), fiat_value: Decimal.new("3.3"), - volume_24h: Decimal.new("33.333") + volume_24h: Decimal.new("33.333"), + circulating_supply: Decimal.new("3000000") }, %{ id: "4", @@ -245,10 +249,37 @@ defmodule Explorer.Market.Source.CoinGeckoTest do }, circulating_market_cap: Decimal.new("4"), fiat_value: Decimal.new("4"), - volume_24h: Decimal.new("4") + volume_24h: Decimal.new("4"), + circulating_supply: Decimal.new("4000000") } ]} == CoinGecko.fetch_tokens(nil, 2) end + + test "caps per_page at the CoinGecko limit of 250", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, json_coins_list()) + end) + + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + assert conn.query_string == "vs_currency=aed&ids=4,3,1&per_page=250&page=1" + Conn.resp(conn, 200, json_coins_markets()) + end) + + assert {:ok, [], true, [_, _, _]} = CoinGecko.fetch_tokens(nil, 500) + end + + test "returns an error for a non-list /coins/markets body", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, json_coins_list()) + end) + + Bypass.expect_once(bypass, "GET", "/coins/markets", fn conn -> + Conn.resp(conn, 200, ~s({"status": {"error_code": 10005, "error_message": "plan limit"}})) + end) + + assert {:error, error} = CoinGecko.fetch_tokens(nil, 5) + assert error =~ "CoinGecko" + end end describe "native_coin_price_history_fetching_enabled?" do @@ -704,25 +735,34 @@ defmodule Explorer.Market.Source.CoinGeckoTest do """ end - defp json_simple_price do + defp json_coins_markets do """ - { - "1": { - "aed": 1, - "aed_market_cap": 100, - "aed_24h_vol": 10 + [ + { + "id": "1", + "current_price": 1, + "market_cap": 100, + "total_volume": 10, + "circulating_supply": 1000000, + "total_supply": 2000000 }, - "3": { - "aed": 3.3, - "aed_market_cap": 300.03, - "aed_24h_vol": 33.333 + { + "id": "3", + "current_price": 3.3, + "market_cap": 300.03, + "total_volume": 33.333, + "circulating_supply": 3000000, + "total_supply": 6000000 }, - "4": { - "aed": 4, - "aed_market_cap": 4, - "aed_24h_vol": 4 + { + "id": "4", + "current_price": 4, + "market_cap": 4, + "total_volume": 4, + "circulating_supply": 4000000, + "total_supply": 8000000 } - } + ] """ end diff --git a/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs index cc4ea1484a3..9eb56ebecb3 100644 --- a/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs +++ b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs @@ -58,7 +58,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -106,7 +106,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -243,7 +243,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(%{"status" => "ok"}) + body: Utils.JSON.encode!(%{"status" => "ok"}) }} end ) @@ -255,7 +255,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do {:ok, %Tesla.Env{ status: 500, - body: Jason.encode!(%{"code" => 0, "message" => "Error"}) + body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) }} end ) @@ -297,7 +297,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -342,7 +342,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -405,7 +405,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -447,7 +447,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -504,7 +504,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -569,7 +569,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(%{"status" => "ok"}) + body: Utils.JSON.encode!(%{"status" => "ok"}) }} end ) @@ -581,7 +581,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do {:ok, %Tesla.Env{ status: 500, - body: Jason.encode!(%{"code" => 0, "message" => "Error"}) + body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) }} end ) @@ -613,7 +613,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -654,7 +654,7 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do Conn.resp( conn, 500, - Jason.encode!(%{"code" => 0, "message" => "Error"}) + Utils.JSON.encode!(%{"code" => 0, "message" => "Error"}) ) end) @@ -873,6 +873,98 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do token_type: "ERC-1155" } end + + test "adds ERC-8056 multiplier fields taken from the fetched metadata" do + enable_multichain_search() + + # the token row still says ERC-20: the fetched metadata is what found the ERC-8056 interface + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{type: "ERC-20", name: "Scaled", symbol: "SCL"}, + %{ + type: "ERC-8056", + name: "Scaled", + symbol: "SCL", + decimals: 18, + ui_multiplier: 1_000_000_000_000_000_000, + new_ui_multiplier: 2_000_000_000_000_000_000, + ui_multiplier_effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + ) == %{ + token_type: "ERC-8056", + name: "Scaled", + symbol: "SCL", + decimals: 18, + ui_multiplier: "1000000000000000000", + new_ui_multiplier: "2000000000000000000", + ui_multiplier_effective_at: "2026-09-01T00:00:00Z" + } + end + + test "falls back to the multiplier stored in the token row and omits the missing fields" do + enable_multichain_search() + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{type: "ERC-8056", ui_multiplier: Decimal.new("1000000000000000000")}, + %{name: "Scaled"} + ) == %{ + token_type: "ERC-8056", + name: "Scaled", + ui_multiplier: "1000000000000000000" + } + end + + test "does not add multiplier fields to tokens of other types" do + enable_multichain_search() + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{type: "ERC-20", ui_multiplier: Decimal.new("1000000000000000000")}, + %{name: "Plain", ui_multiplier: 1_000_000_000_000_000_000} + ) == %{ + token_type: "ERC-20", + name: "Plain" + } + end + end + + describe "prepare_token_metadata_for_queue/1" do + test "returns an empty map when the service is disabled" do + assert MultichainSearch.prepare_token_metadata_for_queue(%Token{type: "ERC-8056", name: "Scaled"}) == %{} + end + + test "takes the whole metadata from the token row" do + enable_multichain_search() + + assert MultichainSearch.prepare_token_metadata_for_queue(%Token{ + type: "ERC-8056", + name: "Scaled", + symbol: "SCL", + decimals: Decimal.new(18), + total_supply: Decimal.new("1000000000000000000000"), + icon_url: "http://localhost:1235/test.png", + ui_multiplier: Decimal.new("1000000000000000000"), + new_ui_multiplier: Decimal.new("2000000000000000000"), + ui_multiplier_effective_at: ~U[2026-09-01 00:00:00.000000Z] + }) == %{ + token_type: "ERC-8056", + name: "Scaled", + symbol: "SCL", + decimals: 18, + total_supply: "1000000000000000000000", + icon_url: "http://localhost:1235/test.png", + ui_multiplier: "1000000000000000000", + new_ui_multiplier: "2000000000000000000", + ui_multiplier_effective_at: "2026-09-01T00:00:00Z" + } + end + + test "omits the fields the token row lacks" do + enable_multichain_search() + + assert MultichainSearch.prepare_token_metadata_for_queue(%Token{type: "ERC-20", symbol: "PLN"}) == %{ + token_type: "ERC-20", + symbol: "PLN" + } + end end describe "prepare_token_total_supply_for_queue/1" do @@ -1423,4 +1515,16 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do is_verified_contract: false } end + + defp enable_multichain_search do + initial = Application.get_env(:explorer, MultichainSearch) || [] + + Application.put_env( + :explorer, + MultichainSearch, + Keyword.merge(initial, service_url: "http://localhost:1234", api_key: "12345", token_info_chunk_size: 1000) + ) + + on_exit(fn -> Application.put_env(:explorer, MultichainSearch, initial) end) + end end diff --git a/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs b/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs index 34547ca7be5..7bec8e02fc3 100644 --- a/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs +++ b/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs @@ -106,7 +106,7 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do assert MigrationStatus.get_status("backfill_metadata_url") == nil BackfillMetadataURL.start_link([]) - Process.sleep(100) + wait_for_migration_completion() [instance_1, instance_2] = Instance @@ -428,7 +428,7 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do assert MigrationStatus.get_status("backfill_metadata_url") == nil BackfillMetadataURL.start_link([]) - Process.sleep(100) + wait_for_migration_completion() [instance_1, instance_2] = Instance @@ -448,4 +448,20 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do assert MigrationStatus.get_status("backfill_metadata_url") == "completed" end end + + # The migration runs asynchronously and now performs A + AAAA lookups per URL, so a fixed + # sleep is racy. Poll for completion instead. + defp wait_for_migration_completion(retries \\ 100) do + cond do + MigrationStatus.get_status("backfill_metadata_url") == "completed" -> + :ok + + retries == 0 -> + flunk("backfill_metadata_url migration did not complete in time") + + true -> + Process.sleep(50) + wait_for_migration_completion(retries - 1) + end + end end diff --git a/apps/explorer/test/explorer/migrator/backfill_multichain_search_db_current_token_balances_test.exs b/apps/explorer/test/explorer/migrator/backfill_multichain_search_db_current_token_balances_test.exs new file mode 100644 index 00000000000..3d0026de783 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/backfill_multichain_search_db_current_token_balances_test.exs @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillMultichainSearchDbCurrentTokenBalancesTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.{BackfillMultichainSearchDbCurrentTokenBalances, MigrationStatus} + alias Explorer.TestHelper + + setup do + original_migrator_env = + Application.get_env(:explorer, BackfillMultichainSearchDbCurrentTokenBalances, []) + + original_multichain_env = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, []) + original_tesla_adapter = Application.get_env(:tesla, :adapter) + original_trace_block_ranges = Application.get_env(:indexer, :trace_block_ranges) + + Repo.delete_all( + from(ms in MigrationStatus, + where: + ms.migration_name == + "backfill_multichain_search_db_current_token_balances" + ) + ) + + on_exit(fn -> + Application.put_env(:explorer, BackfillMultichainSearchDbCurrentTokenBalances, original_migrator_env) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, original_multichain_env) + Application.put_env(:tesla, :adapter, original_tesla_adapter) + Application.put_env(:indexer, :trace_block_ranges, original_trace_block_ranges) + end) + + :ok + end + + test "last_unprocessed_identifiers filters by configured last block and updates cursor state" do + Application.put_env(:explorer, BackfillMultichainSearchDbCurrentTokenBalances, + batch_size: 2, + concurrency: 1, + max_block_number: 100 + ) + + balance_1 = insert(:address_current_token_balance, block_number: 90, token_type: "ERC-20") + balance_2 = insert(:address_current_token_balance, block_number: 100, token_type: "ERC-20") + insert(:address_current_token_balance, block_number: 101, token_type: "ERC-20") + + {ids, state} = BackfillMultichainSearchDbCurrentTokenBalances.last_unprocessed_identifiers(%{}) + + assert ids == [balance_1.id, balance_2.id] + assert state == %{"last_processed_id" => balance_2.id} + + assert {[], %{"last_processed_id" => balance_2.id}} == + BackfillMultichainSearchDbCurrentTokenBalances.last_unprocessed_identifiers(state) + end + + test "last_unprocessed_identifiers filters by trace block ranges" do + Application.put_env(:explorer, BackfillMultichainSearchDbCurrentTokenBalances, + batch_size: 2, + concurrency: 1, + max_block_number: 100 + ) + + Application.put_env(:indexer, :trace_block_ranges, "50..80") + + balance_in_range = insert(:address_current_token_balance, block_number: 60, token_type: "ERC-20") + insert(:address_current_token_balance, block_number: 40, token_type: "ERC-20") + insert(:address_current_token_balance, block_number: 90, token_type: "ERC-20") + + {ids, state} = BackfillMultichainSearchDbCurrentTokenBalances.last_unprocessed_identifiers(%{}) + + assert ids == [balance_in_range.id] + assert state == %{"last_processed_id" => balance_in_range.id} + end + + test "update_batch exports only token balances payload" do + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + + balance_1 = insert(:address_current_token_balance, block_number: 100, token_type: "ERC-20") + balance_2 = insert(:address_current_token_balance, block_number: 110, token_type: "ERC-721", token_id: 1) + + TestHelper.get_chain_id_mock() + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + {:ok, payload} = Utils.JSON.decode(body) + + assert payload["addresses"] == [] + assert payload["block_ranges"] == [] + assert payload["hashes"] == [] + assert payload["address_coin_balances"] == [] + assert Enum.count(payload["address_token_balances"]) == 2 + + assert Enum.any?(payload["address_token_balances"], fn token_balance -> + token_balance["address_hash"] == to_string(balance_1.address_hash) && + token_balance["token_address_hash"] == to_string(balance_1.token_contract_address_hash) && + token_balance["token_id"] == nil + end) + + assert Enum.any?(payload["address_token_balances"], fn token_balance -> + token_balance["address_hash"] == to_string(balance_2.address_hash) && + token_balance["token_address_hash"] == to_string(balance_2.token_contract_address_hash) && + token_balance["token_id"] == "1" + end) + + {:ok, + %Tesla.Env{ + status: 200, + body: Utils.JSON.encode!(%{"status" => "ok"}) + }} + end + ) + + assert 2 = + BackfillMultichainSearchDbCurrentTokenBalances.update_batch([balance_1.id, balance_2.id]) + end + + test "start_link completes migration and updates status" do + Application.put_env(:explorer, BackfillMultichainSearchDbCurrentTokenBalances, + batch_size: 1, + concurrency: 1, + max_block_number: 100 + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: nil, + api_key: nil, + addresses_chunk_size: 7000 + ) + + insert(:block) + + balance = insert(:address_current_token_balance, block_number: 100, token_type: "ERC-20") + + assert MigrationStatus.get_status("backfill_multichain_search_db_current_token_balances") == nil + + {:ok, _pid} = BackfillMultichainSearchDbCurrentTokenBalances.start_link([]) + Process.sleep(100) + + assert MigrationStatus.get_status("backfill_multichain_search_db_current_token_balances") == "completed" + + assert %MigrationStatus{meta: %{"last_processed_id" => last_processed_id}} = + MigrationStatus.fetch("backfill_multichain_search_db_current_token_balances") + + assert last_processed_id == balance.id + end +end diff --git a/apps/explorer/test/explorer/migrator/backfill_scaled_ui_amount_tokens_test.exs b/apps/explorer/test/explorer/migrator/backfill_scaled_ui_amount_tokens_test.exs new file mode 100644 index 00000000000..1c2f72e31b0 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/backfill_scaled_ui_amount_tokens_test.exs @@ -0,0 +1,186 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.BackfillScaledUIAmountTokensTest do + use Explorer.DataCase, async: false + + import Ecto.Query + import Mox + + alias Explorer.Chain.Token.{ScaledUIAmount, UIMultiplierChange} + alias Explorer.Migrator.{BackfillScaledUIAmountTokens, MigrationStatus} + alias Explorer.Repo + + @abi_true "0x0000000000000000000000000000000000000000000000000000000000000001" + @abi_false "0x0000000000000000000000000000000000000000000000000000000000000000" + + setup :set_mox_global + + # supportsInterface(0xa60bf13d); nothing is recorded without the claim + defp stub_json_rpc(supports_interface?) do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn %{id: id, params: [%{data: data}, _]} -> + cond do + String.starts_with?(data, "0x01ffc9a7") -> + %{id: id, result: if(supports_interface?, do: @abi_true, else: @abi_false)} + + true -> + %{id: id, error: %{code: -32015, data: "something", message: "execution reverted"}, jsonrpc: "2.0"} + end + end)} + end) + end + + setup do + Repo.delete_all( + from(status in MigrationStatus, where: status.migration_name == ^BackfillScaledUIAmountTokens.migration_name()) + ) + + stub_json_rpc(true) + + :ok + end + + @one 1_000_000_000_000_000_000 + @two 2_000_000_000_000_000_000 + # 2026-01-01T00:00:00Z + @effective_at_unix 1_767_225_600 + + defp ui_multiplier_updated_log(token, block, log_index, old, new, effective_at_unix) do + data = + [old, new, effective_at_unix] + |> Enum.map(&(&1 |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(64, "0"))) + |> Enum.join() + + insert(:log, + address: token.contract_address, + block: block, + block_number: block.number, + index: log_index, + first_topic: ScaledUIAmount.ui_multiplier_updated_event(), + second_topic: nil, + third_topic: nil, + fourth_topic: nil, + data: "0x" <> data + ) + end + + defp run_migration do + {:ok, pid} = BackfillScaledUIAmountTokens.start_link([]) + ref = Process.monitor(pid) + + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 5_000 + end + + defp changes_of(token) do + UIMultiplierChange + |> Repo.all() + |> Enum.filter(&(&1.token_contract_address_hash == token.contract_address_hash)) + |> Enum.sort_by(&{&1.block_number, &1.log_index}) + end + + describe "Backfill ERC-8056 state from logs" do + test "records the history and types the token from the log alone" do + token = insert(:token) + block = insert(:block, number: 100) + ui_multiplier_updated_log(token, block, 3, @one, @two, @effective_at_unix) + + assert MigrationStatus.get_status(BackfillScaledUIAmountTokens.migration_name()) == nil + + run_migration() + + assert [change] = changes_of(token) + assert change.block_number == 100 + assert change.log_index == 3 + assert Decimal.equal?(change.old_multiplier, Decimal.new(@one)) + assert Decimal.equal?(change.new_multiplier, Decimal.new(@two)) + assert change.effective_at == ~U[2026-01-01 00:00:00.000000Z] + + assert %{type: "ERC-8056"} = Repo.reload(token) + + assert MigrationStatus.get_status(BackfillScaledUIAmountTokens.migration_name()) == "completed" + end + + test "walks every log of a token, in order" do + token = insert(:token) + block = insert(:block, number: 100) + ui_multiplier_updated_log(token, block, 0, @one, @two, @effective_at_unix) + ui_multiplier_updated_log(token, block, 1, @two, @one, @effective_at_unix) + + run_migration() + + assert [%{log_index: 0}, %{log_index: 1}] = changes_of(token) + end + + test "records nothing for a contract that does not claim the ERC-165 interface" do + stub_json_rpc(false) + + token = insert(:token) + block = insert(:block, number: 100) + ui_multiplier_updated_log(token, block, 0, @one, @two, @effective_at_unix) + + run_migration() + + assert changes_of(token) == [] + assert %{type: "ERC-20"} = Repo.reload(token) + + assert MigrationStatus.get_status(BackfillScaledUIAmountTokens.migration_name()) == "completed" + end + + test "does not move past a log whose ERC-165 lookup failed" do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn %{id: id} -> + %{id: id, error: %{code: -32603, message: "timeout"}, jsonrpc: "2.0"} + end)} + end) + + token = insert(:token) + block = insert(:block, number: 100) + ui_multiplier_updated_log(token, block, 0, @one, @two, @effective_at_unix) + + {:ok, pid} = BackfillScaledUIAmountTokens.start_link([]) + Process.sleep(300) + + assert Process.alive?(pid) + assert changes_of(token) == [] + refute MigrationStatus.get_status(BackfillScaledUIAmountTokens.migration_name()) == "completed" + + GenServer.stop(pid) + end + + test "records an empty ERC-165 answer as a denial rather than a failure" do + # some nodes answer `0x` for a function the contract does not have + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, Enum.map(requests, fn %{id: id} -> %{id: id, result: "0x"} end)} + end) + + token = insert(:token) + block = insert(:block, number: 100) + ui_multiplier_updated_log(token, block, 0, @one, @two, @effective_at_unix) + + run_migration() + + assert changes_of(token) == [] + assert %{type: "ERC-20"} = Repo.reload(token) + assert MigrationStatus.get_status(BackfillScaledUIAmountTokens.migration_name()) == "completed" + end + + test "leaves tokens whose logs are of other events alone" do + token = insert(:token) + block = insert(:block, number: 100) + + insert(:log, + address: token.contract_address, + block: block, + block_number: block.number, + index: 0, + first_topic: ScaledUIAmount.transfer_with_ui_amount_event() + ) + + run_migration() + + assert changes_of(token) == [] + assert %{type: "ERC-20"} = Repo.reload(token) + end + end +end diff --git a/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs b/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs index 5073d7a3d1b..3cb26b2c182 100644 --- a/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs +++ b/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs @@ -28,7 +28,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do block = insert(:block, timestamp: Timex.shift(Timex.now(), days: -40)) - Enum.map(1..3, fn i -> + Enum.map(1..3, fn _i -> transaction = :transaction |> insert() @@ -46,7 +46,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do ) end) - Enum.map(1..4, fn i -> + Enum.map(1..4, fn _i -> transaction = :transaction |> insert() @@ -64,7 +64,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do ) end) - Enum.map(1..5, fn i -> + Enum.map(1..5, fn _i -> transaction = :transaction |> insert() @@ -82,7 +82,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do ) end) - Enum.map(1..4, fn i -> + Enum.map(1..4, fn _i -> transaction = :transaction |> insert() @@ -98,7 +98,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do ) end) - Enum.map(1..5, fn i -> + Enum.map(1..5, fn _i -> transaction = :transaction |> insert() @@ -113,7 +113,7 @@ defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do ) end) - Enum.map(1..6, fn i -> + Enum.map(1..6, fn _i -> transaction = :transaction |> insert() diff --git a/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs b/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs index bccff5a851d..b75b91cff1d 100644 --- a/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs +++ b/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs @@ -15,7 +15,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do describe "update_batch/1" do test "returns {:ok, []} when block_numbers is empty" do - assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([]) end test "returns {:ok, []} when no selfdestruct transactions exist in blocks" do @@ -30,7 +30,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do type: :call ) - assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) end test "empties contract_code for address with selfdestruct transaction" do @@ -55,7 +55,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 1 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code is now empty updated_address = Repo.get(Address, contract_address.hash) @@ -93,7 +93,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code is still present updated_address = Repo.get(Address, contract_address.hash) @@ -132,7 +132,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code is still present updated_address = Repo.get(Address, contract_address.hash) @@ -176,7 +176,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 2} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 2 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify both contracts have empty contract_code updated_address_1 = Repo.get(Address, contract_address_1.hash) @@ -207,7 +207,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 0} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code remains empty (no update occurred) updated_address = Repo.get(Address, contract_address.hash) @@ -235,7 +235,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 0} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 0 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code remains nil updated_address = Repo.get(Address, address.hash) @@ -282,7 +282,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 2} = + assert 2 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([ block_1.number, block_2.number @@ -343,7 +343,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do gas: nil ) - assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 1 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify first contract is empty, second is not updated_address_1 = Repo.get(Address, contract_address_1.hash) @@ -387,7 +387,7 @@ defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do ) # Should still only update once since it's the same address - assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + assert 1 = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) # Verify contract_code is empty updated_address = Repo.get(Address, contract_address.hash) diff --git a/apps/explorer/test/explorer/migrator/fill_logs_compressed_data_test.exs b/apps/explorer/test/explorer/migrator/fill_logs_compressed_data_test.exs new file mode 100644 index 00000000000..b19bc411f30 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/fill_logs_compressed_data_test.exs @@ -0,0 +1,46 @@ +defmodule Explorer.Migrator.FillLogsCompressedDataTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.Log + alias Explorer.Migrator.{FillLogsCompressedData, FillLogsOptimizedFields, MigrationStatus} + alias Explorer.Repo + + test "fills compressed_data and empties data" do + transaction = :transaction |> insert() |> with_block() + + initial_logs = + 10 + |> insert_list(:log, transaction: transaction, compressed_data: nil) + |> Enum.sort_by(&{&1.block_number, &1.index}) + + MigrationStatus.set_status(FillLogsOptimizedFields.migration_name(), "completed") + + assert Enum.all?(initial_logs, &(not is_nil(&1.data) and is_nil(&1.compressed_data))) + assert MigrationStatus.get_status("fill_logs_compressed_data") == nil + + Application.put_env(:explorer, FillLogsCompressedData, batch_size: 100, timeout: 0) + + FillLogsCompressedData.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"fill_logs_compressed_data" and ms.status == "completed" + ) + ) + end) + + processed_logs = + Log + |> Repo.all() + |> Enum.sort_by(&{&1.block_number, &1.index}) + + zipped_logs = Enum.zip(initial_logs, processed_logs) + + assert Enum.all?(zipped_logs, fn {initial_log, processed_log} -> + initial_log.data == processed_log.compressed_data and is_nil(processed_log.data) + end) + end +end diff --git a/apps/explorer/test/explorer/migrator/fill_logs_optimized_fields_test.exs b/apps/explorer/test/explorer/migrator/fill_logs_optimized_fields_test.exs new file mode 100644 index 00000000000..b53db4c8054 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/fill_logs_optimized_fields_test.exs @@ -0,0 +1,106 @@ +defmodule Explorer.Migrator.FillLogsOptimizedFieldsTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.{Log, TokenTransfer} + alias Explorer.Migrator.{FillLogsOptimizedFields, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateLogsFirstTopicIdIndex + alias Explorer.{Repo, TestHelper} + alias Explorer.Utility.{AddressIdToAddressHash, LogFirstTopic} + + setup do + initial_status = BackgroundMigrations.get_fill_logs_optimized_fields_finished() + + on_exit(fn -> BackgroundMigrations.set_fill_logs_optimized_fields_finished(initial_status) end) + end + + test "fills relates fields" do + transaction = :transaction |> insert() |> with_block() + topic = TestHelper.topic("0x000000000000000000000000e8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca") + + insert_list(10, :log, + transaction: transaction, + address_id: nil, + first_topic: TokenTransfer.constant(), + transaction_index: nil, + second_topic: topic, + third_topic: topic, + fourth_topic: topic + ) + + MigrationStatus.set_status( + CreateLogsFirstTopicIdIndex.migration_name(), + "completed" + ) + + assert MigrationStatus.get_status("fill_logs_optimized_fields") == nil + + Application.put_env(:explorer, FillLogsOptimizedFields, batch_size: 100, timeout: 0) + + FillLogsOptimizedFields.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"fill_logs_optimized_fields" and ms.status == "completed" + ) + ) + end) + + all_logs = Repo.all(Log) + + assert Enum.all?(all_logs, &(is_nil(&1.address_hash) and not is_nil(&1.address_id))) + + all_address_ids = + all_logs + |> Enum.map(& &1.address_id) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + + existing_address_ids = + AddressIdToAddressHash + |> Repo.all() + |> Enum.map(& &1.address_id) + + assert Enum.all?(all_address_ids, &Enum.member?(existing_address_ids, &1)) + + first_topic_id = LogFirstTopic.value_to_id(TokenTransfer.constant()) + + assert Enum.all?(all_logs, &(is_nil(&1.first_topic) and &1.first_topic_id == first_topic_id)) + + assert Enum.all?(all_logs, &(&1.transaction_index == transaction.index)) + + assert Enum.all?(all_logs, &(&1.second_topic == topic and &1.third_topic == topic and &1.fourth_topic == topic)) + + {:ok, %{rows: [[binary_topic, binary_topic, binary_topic]]}} = + Repo.query("select second_topic, third_topic, fourth_topic from logs limit 1;") + + assert <<first, _::binary>> = binary_topic + assert first != 0 + end + + test "keeps transaction_index of logs which are not linked to a transaction" do + log = insert(:log, transaction: nil, transaction_index: 5) + + MigrationStatus.set_status( + CreateLogsFirstTopicIdIndex.migration_name(), + "completed" + ) + + Application.put_env(:explorer, FillLogsOptimizedFields, batch_size: 100, timeout: 0) + + FillLogsOptimizedFields.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"fill_logs_optimized_fields" and ms.status == "completed" + ) + ) + end) + + assert %Log{transaction_index: 5} = Repo.get_by!(Log, block_number: log.block_number, index: log.index) + end +end diff --git a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index_test.exs b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index_test.exs new file mode 100644 index 00000000000..af8e15820f7 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_first_topic_second_topic_block_number_index_test.exs @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndexTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex, + Helper + } + + alias Explorer.Repo + + @index_name "logs_address_hash_first_topic_second_topic_block_number_index" + + describe "Drops heavy index `#{@index_name}`" do + setup do + configuration = Application.get_env(:explorer, HeavyDbIndexOperation) + Application.put_env(:explorer, HeavyDbIndexOperation, Keyword.merge(configuration || [], check_interval: 200)) + + # The index is created by a heavy migration, not by an Ecto migration, so it is absent from the test schema. + Repo.query!( + "CREATE INDEX IF NOT EXISTS \"#{@index_name}\" ON logs (address_hash, first_topic, second_topic, block_number, index)" + ) + + on_exit(fn -> + Application.put_env(:explorer, HeavyDbIndexOperation, configuration) + end) + + :ok + end + + test "Drops heavy DB index only after all dependent migrations, including its own create migration, are completed" do + migration_name = DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex.migration_name() + create_migration_name = CreateLogsAddressHashFirstTopicSecondTopicBlockNumberIndex.migration_name() + dependent_migration_names = DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex.dependent_from_migrations() + + assert length(dependent_migration_names) == 4 + assert create_migration_name in dependent_migration_names + assert MigrationStatus.get_status(migration_name) == nil + assert Helper.db_index_exists_and_valid?(@index_name) == %{exists?: true, valid?: true} + + DropLogsAddressHashFirstTopicSecondTopicBlockNumberIndex.start_link([]) + Process.sleep(300) + + # Should not start until all dependencies are completed + assert MigrationStatus.get_status(migration_name) == nil + assert Helper.db_index_exists_and_valid?(@index_name) == %{exists?: true, valid?: true} + + # Everything except the create migration of this index is completed: emulates the index creation still + # pending or being retried after a failed build. The drop must keep waiting. + dependent_migration_names + |> Enum.reject(&(&1 == create_migration_name)) + |> Enum.each(fn dependent_migration_name -> + insert(:db_migration_status, migration_name: dependent_migration_name, status: "completed") + end) + + insert(:db_migration_status, migration_name: create_migration_name, status: "started") + Process.sleep(300) + + assert MigrationStatus.get_status(migration_name) == nil + assert Helper.db_index_exists_and_valid?(@index_name) == %{exists?: true, valid?: true} + + # Once the create migration is completed, the drop proceeds + MigrationStatus.set_status(create_migration_name, "completed") + Process.sleep(300) + + assert MigrationStatus.get_status(migration_name) == "completed" + assert Helper.db_index_exists_and_valid?(@index_name) == %{exists?: false, valid?: nil} + end + end +end diff --git a/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs b/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs index 0c6749b05cf..ee93832e0f0 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs @@ -58,11 +58,12 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogsTest do updated_logs ) - assert %Log{log4 | address: nil, block: nil, transaction: nil} == %Log{ + assert %Log{log4 | address: nil, block: nil, transaction: nil, address_mapping: nil} == %Log{ Repo.one(Log |> where([log], log.block_number != ^block.number)) | address: nil, block: nil, - transaction: nil + transaction: nil, + address_mapping: nil } end @@ -145,11 +146,12 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogsTest do assert [%{log_index: 1, block_number: ^block_number}] = Repo.all(TokenTransfer |> where([tt], tt.token_type == "ERC-721")) - assert %Log{log4 | address: nil, block: nil, transaction: nil} == %Log{ + assert %Log{log4 | address: nil, block: nil, transaction: nil, address_mapping: nil} == %Log{ Repo.one(Log |> where([log], log.block_number != ^block.number)) | address: nil, block: nil, - transaction: nil + transaction: nil, + address_mapping: nil } end diff --git a/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs index 5f51fc103ff..275d47298da 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs @@ -20,7 +20,17 @@ defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfersTest do token_type: "ERC-721" ) - deposit_log = insert(:log, first_topic: TokenTransfer.weth_deposit_signature(), address: token_address) + transaction_1 = :transaction |> insert() |> with_block() + + deposit_log = + insert(:log, + first_topic: TokenTransfer.weth_deposit_signature(), + address: token_address, + transaction: transaction_1, + transaction_index: transaction_1.index, + block_number: transaction_1.block_number, + block: transaction_1.block + ) tt = insert(:token_transfer, @@ -36,7 +46,17 @@ defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfersTest do assert tt.token_contract_address_hash == deposit_log.address_hash - withdrawal_log = insert(:log, first_topic: TokenTransfer.weth_withdrawal_signature(), address: token_address) + transaction_2 = :transaction |> insert() |> with_block() + + withdrawal_log = + insert(:log, + first_topic: TokenTransfer.weth_withdrawal_signature(), + address: token_address, + transaction: transaction_2, + transaction_index: transaction_2.index, + block_number: transaction_2.block_number, + block: transaction_2.block + ) insert(:token_transfer, from_address: insert(:address), diff --git a/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs index d0d4daacaf3..b5567bf3710 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs @@ -20,7 +20,16 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do token_ids: nil ) - deposit_log = insert(:log, first_topic: TokenTransfer.weth_deposit_signature()) + transaction_1 = :transaction |> insert() |> with_block() + + deposit_log = + insert(:log, + first_topic: TokenTransfer.weth_deposit_signature(), + transaction: transaction_1, + transaction_index: transaction_1.index, + block_number: transaction_1.block_number, + block: transaction_1.block + ) insert(:token_transfer, from_address: insert(:address), @@ -30,7 +39,16 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do log_index: deposit_log.index ) - withdrawal_log = insert(:log, first_topic: TokenTransfer.weth_withdrawal_signature()) + transaction_2 = :transaction |> insert() |> with_block() + + withdrawal_log = + insert(:log, + first_topic: TokenTransfer.weth_withdrawal_signature(), + transaction: transaction_2, + transaction_index: transaction_2.index, + block_number: transaction_2.block_number, + block: transaction_2.block + ) insert(:token_transfer, from_address: insert(:address), @@ -52,7 +70,16 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do |> Keyword.put(:weth_token_transfers_filtering_enabled, true) ) - withdrawal_log = insert(:log, first_topic: TokenTransfer.weth_withdrawal_signature()) + transaction_3 = :transaction |> insert() |> with_block() + + withdrawal_log = + insert(:log, + first_topic: TokenTransfer.weth_withdrawal_signature(), + transaction: transaction_3, + transaction_index: transaction_3.index, + block_number: transaction_3.block_number, + block: transaction_3.block + ) insert(:token_transfer, from_address: insert(:address), @@ -62,7 +89,16 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do log_index: withdrawal_log.index ) - deposit_log = insert(:log, first_topic: TokenTransfer.weth_deposit_signature()) + transaction_4 = :transaction |> insert() |> with_block() + + deposit_log = + insert(:log, + first_topic: TokenTransfer.weth_deposit_signature(), + transaction: transaction_4, + transaction_index: transaction_4.index, + block_number: transaction_4.block_number, + block: transaction_4.block + ) insert(:token_transfer, from_address: insert(:address), @@ -72,8 +108,17 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do log_index: deposit_log.index ) + transaction_5 = :transaction |> insert() |> with_block() + withdrawal_log_duplicate = - insert(:log, first_topic: TokenTransfer.weth_withdrawal_signature(), address: whitelisted_token_address) + insert(:log, + first_topic: TokenTransfer.weth_withdrawal_signature(), + address: whitelisted_token_address, + transaction: transaction_5, + transaction_index: transaction_5.index, + block_number: transaction_5.block_number, + block: transaction_5.block + ) tt_withdrawal = insert(:token_transfer, @@ -89,7 +134,9 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do first_topic: TokenTransfer.constant(), address: whitelisted_token_address, transaction: withdrawal_log_duplicate.transaction, - block: withdrawal_log_duplicate.block + block: withdrawal_log_duplicate.block, + transaction_index: transaction_5.index, + block_number: transaction_5.block_number ) insert(:token_transfer, @@ -102,7 +149,16 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do amount: tt_withdrawal.amount ) - deposit_log_duplicate = insert(:log, first_topic: TokenTransfer.weth_deposit_signature()) + transaction_6 = :transaction |> insert() |> with_block() + + deposit_log_duplicate = + insert(:log, + first_topic: TokenTransfer.weth_deposit_signature(), + transaction: transaction_6, + transaction_index: transaction_6.index, + block_number: transaction_6.block_number, + block: transaction_6.block + ) tt_deposit = insert(:token_transfer, @@ -118,7 +174,9 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do first_topic: TokenTransfer.constant(), address: whitelisted_token_address, transaction: deposit_log_duplicate.transaction, - block: deposit_log_duplicate.block + block: deposit_log_duplicate.block, + transaction_index: transaction_6.index, + block_number: transaction_6.block_number ) insert(:token_transfer, diff --git a/apps/explorer/test/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types_test.exs b/apps/explorer/test/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types_test.exs new file mode 100644 index 00000000000..3b3f96ba3a4 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/sanitize_scaled_ui_amount_token_transfer_types_test.exs @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.SanitizeScaledUIAmountTokenTransferTypesTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.TokenTransfer + + alias Explorer.Migrator.{ + BackfillScaledUIAmountTokens, + MigrationStatus, + SanitizeScaledUIAmountTokenTransferTypes, + TokenTransferTokenType + } + + alias Explorer.Repo + + setup do + Repo.delete_all( + from(status in MigrationStatus, + where: status.migration_name == ^SanitizeScaledUIAmountTokenTransferTypes.migration_name() + ) + ) + + :ok + end + + defp complete_dependencies do + MigrationStatus.set_status(BackfillScaledUIAmountTokens.migration_name(), "completed") + MigrationStatus.set_status(TokenTransferTokenType.migration_name(), "completed") + end + + defp run_migration do + {:ok, pid} = SanitizeScaledUIAmountTokenTransferTypes.start_link([]) + ref = Process.monitor(pid) + + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 5_000 + end + + defp reload_transfer(token_transfer) do + Repo.get_by(TokenTransfer, + transaction_hash: token_transfer.transaction_hash, + block_hash: token_transfer.block_hash, + log_index: token_transfer.log_index + ) + end + + defp insert_transfer(token, token_type) do + block = insert(:block) + transaction = :transaction |> insert() |> with_block(block) + + insert(:token_transfer, + transaction: transaction, + block: block, + block_number: block.number, + token_contract_address: token.contract_address, + token_type: token_type + ) + end + + describe "Sanitize ERC-8056 token transfer types" do + test "corrects the type of transfers indexed before the token was recognised" do + complete_dependencies() + + token = insert(:token, type: "ERC-8056") + # indexed while the token still looked like a plain ERC-20 + token_transfer = insert_transfer(token, "ERC-20") + + run_migration() + + assert %{token_type: "ERC-8056"} = reload_transfer(token_transfer) + + assert MigrationStatus.get_status(SanitizeScaledUIAmountTokenTransferTypes.migration_name()) == "completed" + end + + test "leaves transfers of other tokens alone" do + complete_dependencies() + + token = insert(:token) + token_transfer = insert_transfer(token, "ERC-20") + + run_migration() + + assert %{token_type: "ERC-20"} = reload_transfer(token_transfer) + end + + test "waits for the migrations it depends on" do + MigrationStatus.set_status(BackfillScaledUIAmountTokens.migration_name(), "completed") + MigrationStatus.set_status(TokenTransferTokenType.migration_name(), "started") + + token = insert(:token, type: "ERC-8056") + token_transfer = insert_transfer(token, "ERC-20") + + {:ok, pid} = SanitizeScaledUIAmountTokenTransferTypes.start_link([]) + Process.sleep(200) + + assert %{token_type: "ERC-20"} = reload_transfer(token_transfer) + refute MigrationStatus.get_status(SanitizeScaledUIAmountTokenTransferTypes.migration_name()) == "completed" + + GenServer.stop(pid) + end + end +end diff --git a/apps/explorer/test/explorer/migrator/transaction_has_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/transaction_has_token_transfers_test.exs new file mode 100644 index 00000000000..96fbf1e2a44 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/transaction_has_token_transfers_test.exs @@ -0,0 +1,40 @@ +defmodule Explorer.Migrator.TransactionHasTokenTransfersTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.Transaction + alias Explorer.Migrator.{MigrationStatus, TransactionHasTokenTransfers} + alias Explorer.Repo + + describe "Migrate transactions" do + test "Set has_token_transfers for not processed transactions" do + _transactions_without_token_transfers = insert_list(10, :transaction) + + Enum.each(0..10, fn _x -> + transaction = + :transaction + |> insert() + + insert(:token_transfer, transaction: transaction) + end) + + assert MigrationStatus.get_status("transaction_has_token_transfers") == nil + + TransactionHasTokenTransfers.start_link([]) + Process.sleep(100) + + Transaction + |> Repo.all() + |> Enum.group_by(& &1.has_token_transfers) + |> Enum.map(fn + {true, transactions_with_token_transfers} -> + assert Enum.count(transactions_with_token_transfers) == 11 + + {false, transactions_without_token_transfers} -> + assert Enum.count(transactions_without_token_transfers) == 10 + end) + + assert MigrationStatus.get_status("transaction_has_token_transfers") == "completed" + end + end +end diff --git a/apps/explorer/test/explorer/repo/lock_timeout_test.exs b/apps/explorer/test/explorer/repo/lock_timeout_test.exs new file mode 100644 index 00000000000..b533d456674 --- /dev/null +++ b/apps/explorer/test/explorer/repo/lock_timeout_test.exs @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.LockTimeoutTest do + use Explorer.DataCase + + alias Explorer.Chain.Log + alias Explorer.Repo.LockTimeout + + describe "run/3" do + test "returns the function result when no lock is waited for" do + assert {:ok, false} = LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end) + end + + test "returns an error instead of waiting while the queried table is locked" do + with_table_locked("logs", fn -> + {microseconds, result} = :timer.tc(fn -> LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end) end) + + assert {:error, :lock_timeout} = result + assert microseconds < :timer.seconds(5) * 1000 + end) + end + + test "accepts a custom lock timeout" do + with_table_locked("logs", fn -> + assert {:error, :lock_timeout} = + LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end, lock_timeout: 10) + end) + end + + test "leaves the connection usable after the lock timeout" do + address = insert(:address) + + with_table_locked("logs", fn -> + assert {:error, :lock_timeout} = LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end) + end) + + assert Repo.get(Explorer.Chain.Address, address.hash) + assert {:ok, false} = LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end) + end + + test "rejects a non-positive lock timeout" do + assert_raise ArgumentError, fn -> + LockTimeout.run(Repo, fn repo -> repo.exists?(Log) end, lock_timeout: 0) + end + end + end +end diff --git a/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs index 52d4ab33fbc..d8c504918a7 100644 --- a/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs +++ b/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs @@ -36,7 +36,7 @@ defmodule Explorer.SmartContract.Geas.PublisherTest do "src/consolidations/main.eas" => "// Test GEAS contract source code" }, "abi" => - Jason.encode!([ + Utils.JSON.encode!([ %{ "type" => "function", "name" => "test_function", diff --git a/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs index bb8f0bfb16d..050a07a1228 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs @@ -15,7 +15,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompilerTest do @compiler_tests "#{File.cwd!()}/test/support/fixture/smart_contract/compiler_tests.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() describe "run/2" do setup do diff --git a/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs index 480596708e6..dc06ec3d07d 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs @@ -86,7 +86,7 @@ defmodule Explorer.SmartContract.Solidity.PublisherTest do } assert {:ok, result} = Publisher.publish(contract_address.hash, params) - assert result.constructor_arguments == expected_constructor_arguments + assert Data.to_string(result.constructor_arguments) == "0x" <> expected_constructor_arguments end test "corresponding contract_methods are created for the abi" do @@ -139,7 +139,7 @@ defmodule Explorer.SmartContract.Solidity.PublisherTest do response = Publisher.publish(contract_address.hash, params) assert {:ok, %SmartContract{} = smart_contract} = response - assert smart_contract.constructor_arguments == contract_code_info.constructor_args + assert Data.to_string(smart_contract.constructor_arguments) == "0x" <> contract_code_info.constructor_args end test "with invalid data returns error changeset" do @@ -159,7 +159,7 @@ defmodule Explorer.SmartContract.Solidity.PublisherTest do contract_data = "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() |> List.first() compiler_version = contract_data["compiler_version"] diff --git a/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs index 2b4f4248d1d..90e689ee5dc 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs @@ -107,7 +107,7 @@ defmodule Explorer.SmartContract.Solidity.VerifierTest do contract_data = "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() |> List.first() compiler_version = contract_data["compiler_version"] @@ -140,7 +140,7 @@ defmodule Explorer.SmartContract.Solidity.VerifierTest do contract_data = "#{File.cwd!()}/test/support/fixture/smart_contract/solidity_5.11_new_whisper_metadata.json" |> File.read!() - |> Jason.decode!() + |> Utils.JSON.decode!() compiler_version = contract_data["compiler_version"] name = contract_data["name"] @@ -400,6 +400,25 @@ defmodule Explorer.SmartContract.Solidity.VerifierTest do assert {:error, :compilation, "Function, variable, struct or modifier declaration expected."} = Verifier.evaluate_authenticity(contract_address.hash, params) end + + test "returns {:error, message} instead of :ok when verification raises an exception", %{ + contract_code_info: contract_code_info + } do + contract_address = insert(:contract_address, contract_code: contract_code_info.bytecode) + + # Omitting "contract_source_code" makes the internal `Map.fetch!/2` raise a + # KeyError, which is caught by the try/rescue in evaluate_authenticity/2. + # Before the fix the rescue silently returned :ok (the result of Logger.error/1); + # now it must return an {:error, message} tuple. + params = %{ + "compiler_version" => contract_code_info.version, + "name" => contract_code_info.name, + "optimization" => contract_code_info.optimized + } + + assert {:error, message} = Verifier.evaluate_authenticity(contract_address.hash, params) + assert message =~ "contract_source_code" + end end describe "extract_bytecode_and_metadata_hash/1" do @@ -606,6 +625,30 @@ defmodule Explorer.SmartContract.Solidity.VerifierTest do end end + describe "compiler_version_at_least_0_6_0?/1" do + test "returns true for supported versions with and without a commit hash" do + assert Verifier.compiler_version_at_least_0_6_0?("latest") + assert Verifier.compiler_version_at_least_0_6_0?("v0.8.19+commit.7dd6d404") + assert Verifier.compiler_version_at_least_0_6_0?("0.6.0") + assert Verifier.compiler_version_at_least_0_6_0?("v0.6") + assert Verifier.compiler_version_at_least_0_6_0?("1.0.0") + end + + test "returns false for versions below 0.6.0" do + refute Verifier.compiler_version_at_least_0_6_0?("v0.5.17+commit.d19bba13") + refute Verifier.compiler_version_at_least_0_6_0?("0.4.26") + end + + test "returns false for malformed versions instead of crashing" do + # single component: previously raised Enum.OutOfBoundsError + refute Verifier.compiler_version_at_least_0_6_0?("v6") + # non-numeric components: previously raised MatchError on Integer.parse/1 + refute Verifier.compiler_version_at_least_0_6_0?("vx.y+commit.deadbeef") + refute Verifier.compiler_version_at_least_0_6_0?("") + refute Verifier.compiler_version_at_least_0_6_0?("garbage") + end + end + describe "compiler version tests" do # flaky test test "verification is failed if wrong version of compiler" do diff --git a/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs index f1ef4d2ccf0..ef4b76e382b 100644 --- a/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs +++ b/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs @@ -81,5 +81,30 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do assert smart_contract.name == valid_attrs["name"] end end + + describe "publish_smart_contract/4" do + test "stores bare hex constructor arguments with 0x prefix" do + contract_code_info = Factory.contract_code_info_vyper() + + contract_address = insert(:contract_address, contract_code: contract_code_info.bytecode) + + constructor_arguments = + [42] + |> ABI.TypeEncoder.encode([{:uint, 256}]) + |> Base.encode16(case: :lower) + + params = %{ + "contract_source_code" => contract_code_info.source_code, + "compiler_version" => contract_code_info.version, + "name" => contract_code_info.name, + "constructor_arguments" => constructor_arguments + } + + assert {:ok, %SmartContract{} = smart_contract} = + Publisher.publish_smart_contract(contract_address.hash, params, contract_code_info.abi, false) + + assert to_string(smart_contract.constructor_arguments) == "0x" <> constructor_arguments + end + end end end diff --git a/apps/explorer/test/explorer/third_party_integrations/sourcify_test.exs b/apps/explorer/test/explorer/third_party_integrations/sourcify_test.exs new file mode 100644 index 00000000000..bf2f81c5624 --- /dev/null +++ b/apps/explorer/test/explorer/third_party_integrations/sourcify_test.exs @@ -0,0 +1,249 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.SourcifyTest do + use ExUnit.Case, async: false + + alias Explorer.ThirdPartyIntegrations.Sourcify + alias Plug.Conn + + @chain_id "1" + @address "0x0000000000000000000000000000000000000abc" + + @metadata %{ + "compiler" => %{"version" => "0.8.19+commit.7dd6d404"}, + "output" => %{"abi" => [%{"type" => "constructor", "inputs" => []}]}, + "settings" => %{ + "compilationTarget" => %{"contracts/Foo.sol" => "Foo"}, + "evmVersion" => "paris", + "optimizer" => %{"enabled" => true, "runs" => 200}, + "libraries" => %{} + } + } + + setup do + bypass = Bypass.open() + + previous_tesla_adapter = Application.get_env(:tesla, :adapter) + previous_sourcify_config = Application.get_env(:explorer, Sourcify) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, Sourcify, + server_url: "http://localhost:#{bypass.port}", + enabled: true, + chain_id: @chain_id, + repo_url: "https://repo.sourcify.dev", + verification_poll_interval_ms: 5, + verification_max_attempts: 5 + ) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, previous_tesla_adapter) + Application.put_env(:explorer, Sourcify, previous_sourcify_config) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + defp expect_lookup(bypass, body, status \\ 200) do + Bypass.expect_once(bypass, "GET", "/v2/contract/#{@chain_id}/#{@address}", fn conn -> + Conn.resp(conn, status, Utils.JSON.encode!(body)) + end) + end + + describe "check_by_address/1" do + test "maps exact_match to the legacy perfect status", %{bypass: bypass} do + expect_lookup(bypass, %{"match" => "exact_match", "address" => @address, "chainId" => @chain_id}) + + assert Sourcify.check_by_address(@address) == {:ok, [%{"status" => "perfect"}]} + end + + test "maps a partial match to {:error, \"partial\"}", %{bypass: bypass} do + expect_lookup(bypass, %{"match" => "match", "address" => @address, "chainId" => @chain_id}) + + assert Sourcify.check_by_address(@address) == {:error, "partial"} + end + + test "treats match: null as not verified", %{bypass: bypass} do + expect_lookup(bypass, %{"match" => nil, "address" => @address, "chainId" => @chain_id}) + + assert Sourcify.check_by_address(@address) == {:error, "Contract is not verified"} + end + + test "treats 404 as not verified", %{bypass: bypass} do + expect_lookup(bypass, %{"customCode" => "not_found"}, 404) + + assert Sourcify.check_by_address(@address) == {:error, "Contract is not verified"} + end + end + + describe "check_by_address_any/1" do + test "returns the reconstructed legacy file list for a full match", %{bypass: bypass} do + expect_lookup(bypass, %{ + "match" => "exact_match", + "sources" => %{"contracts/Foo.sol" => "contract Foo {}"}, + "metadata" => @metadata + }) + + assert {:ok, "full", file_list} = Sourcify.check_by_address_any(@address) + + metadata_file = Enum.find(file_list, &(&1["name"] == "metadata.json")) + assert metadata_file + # metadata content must be a JSON string so downstream parsing can decode it + assert {:ok, _} = Utils.JSON.decode(metadata_file["content"]) + + source_file = Enum.find(file_list, &(&1["name"] == "Foo.sol")) + assert source_file["path"] == "contracts/Foo.sol" + assert source_file["content"] == "contract Foo {}" + end + + test "maps a partial match to the \"partial\" status", %{bypass: bypass} do + expect_lookup(bypass, %{ + "match" => "match", + "sources" => %{"contracts/Foo.sol" => "contract Foo {}"}, + "metadata" => @metadata + }) + + assert {:ok, "partial", _file_list} = Sourcify.check_by_address_any(@address) + end + end + + describe "get_metadata/1" do + test "returns the file list for a full match", %{bypass: bypass} do + expect_lookup(bypass, %{ + "match" => "exact_match", + "sources" => %{"contracts/Foo.sol" => "contract Foo {}"}, + "metadata" => @metadata + }) + + assert {:ok, file_list} = Sourcify.get_metadata(@address) + assert Enum.any?(file_list, &(&1["name"] == "metadata.json")) + end + + test "errors on a partial match (v1 files endpoint was full-match only)", %{bypass: bypass} do + expect_lookup(bypass, %{"match" => "match", "sources" => %{}, "metadata" => @metadata}) + + assert {:error, %{"error" => _}} = Sourcify.get_metadata(@address) + end + end + + describe "parse_params_from_sourcify/2 with a reconstructed v2 file list" do + test "builds secondary sources with a correct relative file name", %{bypass: bypass} do + expect_lookup(bypass, %{ + "match" => "exact_match", + "sources" => %{ + "contracts/Foo.sol" => "contract Foo {}", + "contracts/Lib.sol" => "library Lib {}" + }, + "metadata" => @metadata + }) + + {:ok, file_list} = Sourcify.get_metadata(@address) + + params = Sourcify.parse_params_from_sourcify(@address, file_list) + + assert %{"secondary_sources" => [secondary], "params_to_publish" => params_to_publish} = params + # regression: v2 source paths are relative, so the name must not collapse to "/" + assert secondary["file_name"] == "/contracts/Lib.sol" + assert params_to_publish["contract_source_code"] == "contract Foo {}" + assert params_to_publish["verified_via_sourcify"] == true + end + end + + describe "verify_via_sourcify_server/2" do + test "submits metadata verification and polls until success", %{bypass: bypass} do + {:ok, agent} = Agent.start_link(fn -> 0 end) + + Bypass.expect(bypass, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/v2/verify/metadata/" <> _rest} -> + Conn.resp(conn, 202, Utils.JSON.encode!(%{"verificationId" => "job-1"})) + + {"GET", "/v2/verify/job-1"} -> + count = Agent.get_and_update(agent, fn n -> {n, n + 1} end) + + if count == 0 do + Conn.resp(conn, 200, Utils.JSON.encode!(%{"isJobCompleted" => false})) + else + Conn.resp( + conn, + 200, + Utils.JSON.encode!(%{"isJobCompleted" => true, "contract" => %{"match" => "exact_match"}}) + ) + end + end + end) + + files = %{"metadata.json" => Utils.JSON.encode!(@metadata), "contracts/Foo.sol" => "contract Foo {}"} + + assert {:ok, %{"isJobCompleted" => true}} = Sourcify.verify_via_sourcify_server(@address, files) + end + + test "returns an error when the job completes with an error", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/v2/verify/metadata/" <> _rest} -> + Conn.resp(conn, 202, Utils.JSON.encode!(%{"verificationId" => "job-2"})) + + {"GET", "/v2/verify/job-2"} -> + Conn.resp( + conn, + 200, + Utils.JSON.encode!(%{ + "isJobCompleted" => true, + "error" => %{"message" => "compilation failed"} + }) + ) + end + end) + + files = %{"metadata.json" => Utils.JSON.encode!(@metadata), "contracts/Foo.sol" => "contract Foo {}"} + + assert Sourcify.verify_via_sourcify_server(@address, files) == {:error, "compilation failed"} + end + + test "returns an error when the job completes without a contract match", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/v2/verify/metadata/" <> _rest} -> + Conn.resp(conn, 202, Utils.JSON.encode!(%{"verificationId" => "job-4"})) + + {"GET", "/v2/verify/job-4"} -> + Conn.resp( + conn, + 200, + Utils.JSON.encode!(%{"isJobCompleted" => true, "contract" => %{"match" => nil}}) + ) + end + end) + + files = %{"metadata.json" => Utils.JSON.encode!(@metadata), "contracts/Foo.sol" => "contract Foo {}"} + + assert Sourcify.verify_via_sourcify_server(@address, files) == + {:error, Sourcify.failed_verification_message()} + end + + test "times out when the job never completes", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/v2/verify/metadata/" <> _rest} -> + Conn.resp(conn, 202, Utils.JSON.encode!(%{"verificationId" => "job-3"})) + + {"GET", "/v2/verify/job-3"} -> + Conn.resp(conn, 200, Utils.JSON.encode!(%{"isJobCompleted" => false})) + end + end) + + files = %{"metadata.json" => Utils.JSON.encode!(@metadata), "contracts/Foo.sol" => "contract Foo {}"} + + assert Sourcify.verify_via_sourcify_server(@address, files) == {:error, "Sourcify verification timed out"} + end + + test "returns the no-metadata error when metadata.json is missing" do + files = %{"contracts/Foo.sol" => "contract Foo {}"} + + assert Sourcify.verify_via_sourcify_server(@address, files) == + {:error, Sourcify.no_metadata_message()} + end + end +end diff --git a/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs b/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs index 93c4ccdf44a..dc31c2dedf7 100644 --- a/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs +++ b/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs @@ -28,7 +28,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do times: 1, returns: %Tesla.Env{ status: 200, - body: Jason.encode!(%{"success" => true}) + body: Utils.JSON.encode!(%{"success" => true}) } ) @@ -55,7 +55,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "endpoints" => %{ @@ -91,7 +91,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -143,7 +143,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -183,7 +183,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -228,7 +228,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -268,7 +268,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -317,7 +317,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -357,7 +357,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -402,7 +402,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -442,7 +442,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -483,7 +483,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -517,7 +517,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -552,7 +552,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do returns: %Tesla.Env{ status: 200, body: - Jason.encode!(%{ + Utils.JSON.encode!(%{ "platforms" => %{ "test_platform" => %{ "base_url" => "https://api.test.com", @@ -600,7 +600,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do Application.put_env( :explorer, Explorer.ThirdPartyIntegrations.UniversalProxy, - config_json: Jason.encode!(inline_config), + config_json: Utils.JSON.encode!(inline_config), config_url: nil ) diff --git a/apps/explorer/test/explorer/token/metadata_retriever/nft_url_test.exs b/apps/explorer/test/explorer/token/metadata_retriever/nft_url_test.exs new file mode 100644 index 00000000000..1465e1d02f5 --- /dev/null +++ b/apps/explorer/test/explorer/token/metadata_retriever/nft_url_test.exs @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Token.MetadataRetriever.NftUrlTest do + use ExUnit.Case, async: true + + alias Explorer.Token.MetadataRetriever + + describe "classify_nft_url/1" do + test "classifies ipfs:// URL" do + assert {:ipfs, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} = + MetadataRetriever.classify_nft_url("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + end + + test "classifies ipfs://ipfs/ URL" do + assert {:ipfs, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} = + MetadataRetriever.classify_nft_url("ipfs://ipfs/QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + end + + test "classifies ipfs:// URL with path" do + assert {:ipfs, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/metadata.json"} = + MetadataRetriever.classify_nft_url("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/metadata.json") + end + + test "classifies ar:// URL" do + assert {:arweave, "abc123"} = MetadataRetriever.classify_nft_url("ar://abc123") + end + + test "classifies ar:// URL with path" do + assert {:arweave, "abc123/metadata.json"} = MetadataRetriever.classify_nft_url("ar://abc123/metadata.json") + end + + test "classifies https URL with /ipfs/ path" do + assert {:ipfs, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} = + MetadataRetriever.classify_nft_url( + "https://gateway.pinata.cloud/ipfs/QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + ) + end + + test "classifies regular https URL" do + assert {:regular, "https://example.com/metadata.json"} = + MetadataRetriever.classify_nft_url("https://example.com/metadata.json") + end + + test "classifies bare path as bare_path" do + assert {:bare_path, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} = + MetadataRetriever.classify_nft_url("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + end + end + + describe "resolve_nft_media_url/1" do + test "resolves ipfs:// URL to gateway URL" do + {url, headers} = + MetadataRetriever.resolve_nft_media_url("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + + assert url =~ "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + assert url =~ "ipfs" + refute url =~ "ipfs://" + assert is_list(headers) + end + + test "resolves ar:// URL to arweave.net" do + {url, headers} = MetadataRetriever.resolve_nft_media_url("ar://abc123") + + assert url == "https://arweave.net/abc123" + assert is_list(headers) + end + + test "resolves ar:// URL with path to arweave.net" do + {url, headers} = MetadataRetriever.resolve_nft_media_url("ar://abc123/metadata.json") + + assert url == "https://arweave.net/abc123/metadata.json" + assert is_list(headers) + end + + test "passes through regular https URL unchanged" do + original = "https://example.com/image.png" + assert {^original, []} = MetadataRetriever.resolve_nft_media_url(original) + end + + test "resolves https URL with /ipfs/ path to gateway" do + {url, headers} = + MetadataRetriever.resolve_nft_media_url( + "https://gateway.pinata.cloud/ipfs/QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + ) + + assert url =~ "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + assert is_list(headers) + end + + test "returns original URL for invalid IPFS path" do + original = "ipfs://not-a-valid-cid" + assert {^original, []} = MetadataRetriever.resolve_nft_media_url(original) + end + + test "resolves bare CID path via IPFS gateway" do + {url, _headers} = + MetadataRetriever.resolve_nft_media_url("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + + assert url =~ "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + refute url =~ "ipfs://" + end + end +end diff --git a/apps/explorer/test/explorer/token/metadata_retriever_test.exs b/apps/explorer/test/explorer/token/metadata_retriever_test.exs index 377debf0c1d..ca82971b18a 100644 --- a/apps/explorer/test/explorer/token/metadata_retriever_test.exs +++ b/apps/explorer/test/explorer/token/metadata_retriever_test.exs @@ -11,6 +11,45 @@ defmodule Explorer.Token.MetadataRetrieverTest do setup :verify_on_exit! setup :set_mox_global + describe "fetch_metadata_from_uri/2 SSRF protection" do + setup do + initial = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) || [] + + Application.put_env( + :indexer, + Indexer.Fetcher.TokenInstance.Helper, + Keyword.merge(initial, host_filtering_enabled?: true) + ) + + :persistent_term.erase(:parsed_cidr_list) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, initial) + :persistent_term.erase(:parsed_cidr_list) + end) + + :ok + end + + test "rejects a redirect from a public host to an internal address and stores nothing" do + # The initial (public) host passes validation, but the 302 target is internal. + # SafeFetch re-validates each hop, so the internal address is never fetched. + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://8.8.8.8/token.json"}, _opts -> + {:ok, %Tesla.Env{status: 302, headers: [{"location", "http://127.0.0.1/"}], body: ""}} + end + ) + + assert {:error, error} = MetadataRetriever.fetch_metadata_from_uri("http://8.8.8.8/token.json", []) + assert error =~ "blacklist" + end + + test "rejects a token uri whose host is an internal address" do + assert {:error, "blacklist"} = MetadataRetriever.fetch_metadata_from_uri("http://169.254.169.254/latest/meta", []) + end + end + describe "get_functions_of/1" do test "returns all functions read in the smart contract" do token = insert(:token, contract_address: build(:contract_address)) @@ -96,6 +135,15 @@ defmodule Explorer.Token.MetadataRetrieverTest do id: id, result: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" } + + # the ERC-165 probe, which an ERC-20 token not implementing the + # extension reverts on + %{id: id, method: "eth_call", params: [%{data: _, to: _}, "latest"]} -> + %{ + id: id, + error: %{code: -32015, data: "something", message: "some error"}, + jsonrpc: "2.0" + } end)} end ) @@ -117,6 +165,147 @@ defmodule Explorer.Token.MetadataRetrieverTest do ]} = MetadataRetriever.get_functions_of([token, token]) end + # The ERC-165 probe carries its argument, so its request data is the selector + # followed by the interface id; matching has to be on the prefix. + defp erc8056_mock_response(id, data) do + cond do + String.starts_with?(data, "0x01ffc9a7") -> + # supportsInterface(0xa60bf13d) == true + %{id: id, result: "0x0000000000000000000000000000000000000000000000000000000000000001"} + + data == "0xa60bf13d" -> + # uiMultiplier() == 2e18 + %{id: id, result: "0x0000000000000000000000000000000000000000000000001bc16d674ec80000"} + + data == "0xdc767007" -> + # newUIMultiplier() == 4e18 + %{id: id, result: "0x0000000000000000000000000000000000000000000000003782dace9d900000"} + + data == "0x97a4064f" -> + # effectiveAt() == 2026-01-01T00:00:00Z + %{id: id, result: "0x000000000000000000000000000000000000000000000000000000006955b900"} + + true -> + %{id: id, error: %{code: -32015, data: "something", message: "some error"}, jsonrpc: "2.0"} + end + end + + defp selectors_of(requests) do + requests + |> Enum.map(&(&1.params |> hd() |> Map.fetch!(:data) |> String.slice(0, 10))) + |> Enum.sort() + end + + test "reads the ERC-8056 multiplier of a token that claims the interface" do + token = insert(:token, contract_address: build(:contract_address)) + + # the base metadata batch carries the ERC-165 probe, one extra call rather + # than the three getters + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + assert selectors_of(requests) == ["0x01ffc9a7", "0x06fdde03", "0x18160ddd", "0x313ce567", "0x95d89b41"] + + {:ok, Enum.map(requests, &erc8056_mock_response(&1.id, &1.params |> hd() |> Map.fetch!(:data)))} + end) + + # only a token that claimed the interface is asked for the getters + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + assert selectors_of(requests) == ["0x97a4064f", "0xa60bf13d", "0xdc767007"] + + {:ok, Enum.map(requests, &erc8056_mock_response(&1.id, &1.params |> hd() |> Map.fetch!(:data)))} + end) + + assert {:ok, + [ + %{ + ui_multiplier: 2_000_000_000_000_000_000, + new_ui_multiplier: 4_000_000_000_000_000_000, + ui_multiplier_effective_at: ~U[2026-01-01 00:00:00.000000Z], + type: "ERC-8056" + } + ]} = MetadataRetriever.get_functions_of([token]) + end + + test "does not read the getters of a contract that answers ERC-165 with false" do + # a contract may well expose a function named `uiMultiplier()` meaning + # something else entirely; only the ERC-165 claim makes it an ERC-8056 + # token, and a denied claim has to stop the getters from being asked at all + token = insert(:token, contract_address: build(:contract_address)) + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + selectors = selectors_of(requests) + + assert "0x01ffc9a7" in selectors + assert Enum.all?(["0xa60bf13d", "0xdc767007", "0x97a4064f"], &(&1 not in selectors)) + + {:ok, + Enum.map(requests, fn %{id: id, params: [%{data: data}, _]} -> + if String.starts_with?(data, "0x01ffc9a7") do + # supportsInterface(0xa60bf13d) == false + %{id: id, result: "0x0000000000000000000000000000000000000000000000000000000000000000"} + else + %{id: id, error: %{code: -32015, data: "something", message: "some error"}, jsonrpc: "2.0"} + end + end)} + end) + + assert {:ok, [metadata]} = MetadataRetriever.get_functions_of([token]) + + refute Map.has_key?(metadata, :type) + refute Map.has_key?(metadata, :ui_multiplier) + refute Map.has_key?(metadata, :supports_scaled_ui_amount) + end + + test "keeps probing a token already typed as ERC-8056" do + # dropping it from the batch here would freeze its multiplier at whatever + # was read the first time + token = insert(:token, contract_address: build(:contract_address), type: "ERC-8056") + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + assert selectors_of(requests) == ["0x01ffc9a7", "0x06fdde03", "0x18160ddd", "0x313ce567", "0x95d89b41"] + + {:ok, Enum.map(requests, &erc8056_mock_response(&1.id, &1.params |> hd() |> Map.fetch!(:data)))} + end) + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + {:ok, Enum.map(requests, &erc8056_mock_response(&1.id, &1.params |> hd() |> Map.fetch!(:data)))} + end) + + assert {:ok, [%{ui_multiplier: 2_000_000_000_000_000_000, type: "ERC-8056"}]} = + MetadataRetriever.get_functions_of([token]) + end + + test "does not assign a type to an ERC-20 token that has no multiplier" do + token = insert(:token, contract_address: build(:contract_address)) + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + {:ok, + Enum.map(requests, fn %{id: id} -> + %{id: id, error: %{code: -32015, data: "something", message: "some error"}, jsonrpc: "2.0"} + end)} + end) + + assert {:ok, [metadata]} = MetadataRetriever.get_functions_of([token]) + + refute Map.has_key?(metadata, :type) + refute Map.has_key?(metadata, :ui_multiplier) + refute Map.has_key?(metadata, :supports_scaled_ui_amount) + end + + test "does not probe tokens that are not ERC-20" do + token = insert(:token, contract_address: build(:contract_address), type: "ERC-721") + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + assert selectors_of(requests) == ["0x06fdde03", "0x18160ddd", "0x313ce567", "0x95d89b41"] + + {:ok, + Enum.map(requests, fn %{id: id} -> + %{id: id, error: %{code: -32015, data: "something", message: "some error"}, jsonrpc: "2.0"} + end)} + end) + + assert {:ok, [_metadata]} = MetadataRetriever.get_functions_of([token]) + end + test "returns only the functions that were read without error" do token = insert(:token, contract_address: build(:contract_address)) @@ -731,7 +920,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(result) + body: Utils.JSON.encode!(result) }} end ) @@ -776,7 +965,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(result) + body: Utils.JSON.encode!(result) }} end ) @@ -825,7 +1014,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(result) + body: Utils.JSON.encode!(result) }} end ) @@ -896,7 +1085,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do {:ok_store_uri, %{metadata: metadata}, ^url} = MetadataRetriever.fetch_metadata_from_uri(url, []) - assert Map.get(metadata, "attributes") == Jason.decode!(attributes) + assert Map.get(metadata, "attributes") == Utils.JSON.decode!(attributes) end test "decodes json file in tokenURI" do @@ -1055,7 +1244,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do assert {:ok_store_uri, %{ - metadata: Jason.decode!(json) + metadata: Utils.JSON.decode!(json) }, url} == MetadataRetriever.fetch_json(data) end @@ -1084,7 +1273,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(result) + body: Utils.JSON.encode!(result) }} end ) @@ -1144,7 +1333,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do assert {:ok_store_uri, %{ - metadata: Jason.decode!(json) + metadata: Utils.JSON.decode!(json) }, url} == MetadataRetriever.fetch_json({:ok, [url]}) end @@ -1276,4 +1465,215 @@ defmodule Explorer.Token.MetadataRetrieverTest do assert MetadataRetriever.fetch_json({:ok, [invalid_path]}) == {:error, "invalid ipfs path"} end end + + describe "swarm_link/1" do + @swarm_hash String.duplicate("a", 64) + + test "returns default gateway URL when no config override is set" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, []) + + expected = "https://gateway.ethswarm.org/bzz/#{@swarm_hash}/" + assert MetadataRetriever.swarm_link(@swarm_hash) == expected + end + + test "uses configured gateway_url" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://my-swarm-node.example.com") + + expected = "https://my-swarm-node.example.com/bzz/#{@swarm_hash}/" + assert MetadataRetriever.swarm_link(@swarm_hash) == expected + end + + test "strips trailing slash from configured gateway_url" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://my-swarm-node.example.com/") + + expected = "https://my-swarm-node.example.com/bzz/#{@swarm_hash}/" + assert MetadataRetriever.swarm_link(@swarm_hash) == expected + end + + test "preserves deep path without forcing trailing slash" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://my-swarm-node.example.com/") + + uid = "#{@swarm_hash}/metadata.json" + expected = "https://my-swarm-node.example.com/bzz/#{uid}" + + assert MetadataRetriever.swarm_link(uid) == expected + end + end + + describe "swarm_headers/0" do + test "returns only default headers when no bearer token is configured" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, []) + + assert MetadataRetriever.swarm_headers() == MetadataRetriever.ar_headers() + end + + test "prepends Authorization header when bearer_token is configured" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, bearer_token: "secret-token") + + headers = MetadataRetriever.swarm_headers() + assert {"Authorization", "Bearer secret-token"} in headers + assert headers == [{"Authorization", "Bearer secret-token"} | MetadataRetriever.ar_headers()] + end + + test "does not prepend Authorization header when bearer_token is empty" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, bearer_token: "") + + assert MetadataRetriever.swarm_headers() == MetadataRetriever.ar_headers() + end + + test "does not prepend Authorization header when bearer_token is whitespace" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, bearer_token: " ") + + assert MetadataRetriever.swarm_headers() == MetadataRetriever.ar_headers() + end + end + + describe "valid_swarm_hash?/1" do + @valid_hash String.duplicate("a1", 32) + + test "returns true for a 64-character lowercase hex hash" do + assert MetadataRetriever.valid_swarm_hash?(@valid_hash) + end + + test "returns true for a hash embedded in a path" do + assert MetadataRetriever.valid_swarm_hash?("#{@valid_hash}/path/to/resource") + end + + test "returns false for uppercase hex" do + refute MetadataRetriever.valid_swarm_hash?(String.upcase(@valid_hash)) + end + + test "returns false for a hash that is too short" do + refute MetadataRetriever.valid_swarm_hash?(String.duplicate("a", 63)) + end + + test "returns false for a hash that is too long" do + refute MetadataRetriever.valid_swarm_hash?(String.duplicate("a", 65)) + end + + test "returns false for an empty string" do + refute MetadataRetriever.valid_swarm_hash?("") + end + + test "returns false for non-binary" do + refute MetadataRetriever.valid_swarm_hash?(nil) + end + end + + describe "ETH Swarm URI routing" do + @valid_hash String.duplicate("b2", 32) + @swarm_metadata %{"name" => "Swarm NFT", "description" => "stored on Swarm"} + + test "fetch_json resolves bzz:// URI scheme via Swarm gateway" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://gateway.ethswarm.org") + + expected_url = "https://gateway.ethswarm.org/bzz/#{@valid_hash}/" + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^expected_url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(@swarm_metadata)}} + end + ) + + assert {:ok, %{metadata: metadata}} = + MetadataRetriever.fetch_json({:ok, ["bzz://#{@valid_hash}"]}) + + assert metadata["name"] == "Swarm NFT" + end + + test "fetch_json resolves https://gateway.ethswarm.org/bzz/<hash>/ URL" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://gateway.ethswarm.org") + + url = "https://gateway.ethswarm.org/bzz/#{@valid_hash}/" + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(@swarm_metadata)}} + end + ) + + assert {:ok, %{metadata: metadata}} = + MetadataRetriever.fetch_json({:ok, [url]}) + + assert metadata["description"] == "stored on Swarm" + end + + test "fetch_json preserves deep path for https swarm URLs" do + original = Application.get_env(:indexer, :swarm, []) + on_exit(fn -> Application.put_env(:indexer, :swarm, original) end) + Application.put_env(:indexer, :swarm, gateway_url: "https://gateway.ethswarm.org") + + url = "https://gateway.ethswarm.org/bzz/#{@valid_hash}/meta/1.json" + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(@swarm_metadata)}} + end + ) + + assert {:ok, %{metadata: metadata}} = + MetadataRetriever.fetch_json({:ok, [url]}) + + assert metadata["name"] == "Swarm NFT" + end + + test "fetch_json returns error for invalid Swarm hash in bzz:// URI" do + assert MetadataRetriever.fetch_json({:ok, ["bzz://not-a-valid-hash"]}) == + {:error, "invalid swarm path"} + end + end + + describe "scaled_ui_amount_support/1" do + defp probe(response) do + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn requests, _opts -> + {:ok, Enum.map(requests, fn %{id: id} -> Map.put(response, :id, id) end)} + end) + + MetadataRetriever.scaled_ui_amount_support(to_string(insert(:contract_address).hash)) + end + + test "an explicit true is a claim" do + assert probe(%{result: "0x0000000000000000000000000000000000000000000000000000000000000001"}) == {:ok, true} + end + + test "an explicit false is a denial" do + assert probe(%{result: "0x0000000000000000000000000000000000000000000000000000000000000000"}) == {:ok, false} + end + + test "a revert is a denial, since a contract without ERC-165 is not ERC-8056" do + assert probe(%{error: %{code: -32015, data: "something", message: "execution reverted"}, jsonrpc: "2.0"}) == + {:ok, false} + end + + test "an empty answer is a denial, not a failure" do + assert probe(%{result: "0x"}) == {:ok, false} + end + + test "an unanswered call is neither" do + assert probe(%{error: %{code: -32603, message: "timeout"}, jsonrpc: "2.0"}) == :error + end + end end diff --git a/apps/explorer/test/support/data_case.ex b/apps/explorer/test/support/data_case.ex index 2a67c2630c4..9a5012717f9 100644 --- a/apps/explorer/test/support/data_case.ex +++ b/apps/explorer/test/support/data_case.ex @@ -16,6 +16,7 @@ defmodule Explorer.DataCase do use ExUnit.CaseTemplate alias Ecto.Changeset + alias Explorer.Chain.Cache.BackgroundMigrations using do quote do @@ -56,6 +57,71 @@ defmodule Explorer.DataCase do :ok end + @doc """ + Runs `fun` while the given table is locked in `ACCESS EXCLUSIVE` mode by + another database connection, emulating a running `VACUUM FULL`. + + The lock is taken outside of the sandbox, in a transaction which is held + until `fun` returns. Since the sandbox connection is the one waiting for the + lock, the test must not touch the table before calling this function: + otherwise the lock waits for the sandbox transaction and both hang. + """ + def with_table_locked(table, fun) when is_binary(table) and is_function(fun, 0) do + test_pid = self() + {locker_pid, locker_ref} = spawn_monitor(fn -> hold_table_lock(table, test_pid) end) + + receive do + {:locked, ^locker_pid} -> + :ok + after + :timer.seconds(10) -> + kill_locker(locker_pid, locker_ref) + raise "could not lock the #{table} table" + end + + try do + fun.() + after + send(locker_pid, :release) + + receive do + {:DOWN, ^locker_ref, :process, ^locker_pid, _reason} -> + :ok + after + :timer.seconds(10) -> + kill_locker(locker_pid, locker_ref) + raise "could not release the lock on the #{table} table" + end + end + end + + # Terminates a locker which didn't respond in time, so that it doesn't keep + # the lock and the connection, and consumes its `:DOWN` message. + defp kill_locker(locker_pid, locker_ref) do + Process.exit(locker_pid, :kill) + + receive do + {:DOWN, ^locker_ref, :process, ^locker_pid, _reason} -> :ok + end + end + + # Takes the lock outside of the sandbox and holds it until `:release` is received. + defp hold_table_lock(table, test_pid) do + Ecto.Adapters.SQL.Sandbox.unboxed_run(Explorer.Repo, fn -> + Explorer.Repo.transaction( + fn -> + Explorer.Repo.query!("LOCK TABLE #{table} IN ACCESS EXCLUSIVE MODE") + send(test_pid, {:locked, self()}) + + receive do + :release -> :ok + end + end, + timeout: :timer.minutes(1) + ) + end) + end + def wait_for_results(producer) do wait_for_results(producer, 30) end @@ -73,6 +139,29 @@ defmodule Explorer.DataCase do wait_for_results(producer, retries - 1) end + @doc """ + Emulates the state when the `fill_logs_optimized_fields` migration is started + but not finished yet, so logs are stored either with the new `address_id` or + with the legacy `address_hash`. + + Both cached statuses are set explicitly and restored afterwards: + `Explorer.Chain.Cache.BackgroundMigrations` is a process-wide cache which is + not rolled back by the sandbox, so another test completing the migration + leaks a finished status into the whole test run. + """ + def set_fill_logs_optimized_fields_migration_started do + initial_started? = BackgroundMigrations.get_create_logs_block_number_transaction_index_index_unique_index_finished() + initial_finished? = BackgroundMigrations.get_fill_logs_optimized_fields_finished() + + BackgroundMigrations.set_create_logs_block_number_transaction_index_index_unique_index_finished(true) + BackgroundMigrations.set_fill_logs_optimized_fields_finished(false) + + ExUnit.Callbacks.on_exit(fn -> + BackgroundMigrations.set_create_logs_block_number_transaction_index_index_unique_index_finished(initial_started?) + BackgroundMigrations.set_fill_logs_optimized_fields_finished(initial_finished?) + end) + end + @doc """ Converts a changeset to a map of fields with lists of formatted error messages. """ diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index b3d3880ab2f..c1e2f38e18f 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -35,6 +35,8 @@ defmodule Explorer.Factory do alias Explorer.Chain.Scroll.BatchBundle, as: ScrollBatchBundle alias Explorer.Chain.Scroll.Bridge, as: ScrollBridge alias Explorer.Chain.Stability.Validator, as: ValidatorStability + alias Explorer.Chain.ZkSync.LifecycleTransaction, as: ZkSyncLifecycleTransaction + alias Explorer.Chain.ZkSync.TransactionBatch, as: ZkSyncTransactionBatch alias Explorer.Chain.{ Address, @@ -60,6 +62,7 @@ defmodule Explorer.Factory do Token, TokenTransfer, Token.Instance, + Token.UIMultiplierChange, Transaction, TransactionError, Wei, @@ -88,6 +91,7 @@ defmodule Explorer.Factory do AddressIdToAddressHash, EventNotification, InternalTransactionsAddressPlaceholder, + LogFirstTopic, MassiveBlock, MissingBalanceOfToken, MissingBlockRange @@ -876,7 +880,13 @@ defmodule Explorer.Factory do all_attrs = attrs - |> adjust_internal_transaction_addresses_attrs([:from_address, :created_contract_address], contract_code) + |> then(fn attrs -> + Map.put(attrs, :to_address, Map.get(attrs, :to_address) || Map.get(attrs, :created_contract_address)) + end) + |> adjust_internal_transaction_addresses_attrs( + [:from_address, :created_contract_address, :to_address], + contract_code + ) |> adjust_internal_transaction_error_attr() %InternalTransaction{ @@ -920,21 +930,88 @@ defmodule Explorer.Factory do } end - def log_factory do + def log_factory(attrs) do block = build(:block) + transaction = build(:transaction) + data = data(:log_data) + + all_attrs = + attrs + |> adjust_log_address_attrs() + |> adjust_log_transaction_attrs() + |> adjust_log_first_topic_attrs() %Log{ - address: build(:address), block: block, block_number: block.number, - data: data(:log_data), + data: data, + compressed_data: data, first_topic: nil, fourth_topic: nil, index: sequence("log_index", & &1), second_topic: nil, third_topic: nil, - transaction: build(:transaction) + transaction: transaction, + transaction_index: transaction_index() } + |> merge_attributes(all_attrs) + |> evaluate_lazy_attributes() + end + + defp adjust_log_address_attrs(attrs) do + {address_related_attrs, other_attrs} = Map.split(attrs, [:address, :address_hash]) + + address = + case Map.get(address_related_attrs, :address, false) do + nil -> + nil + + false -> + address_params = if hash = Map.get(address_related_attrs, :address_hash), do: %{hash: hash}, else: %{} + + case Map.get(address_params, :hash) && Repo.get_by(Address, hash: address_params.hash) do + nil -> + built_address = build(:address, address_params) + insert(built_address) + built_address + + _ -> + build(:address, address_params) + end + + address -> + address + end + + address_attrs = %{ + address: address, + address_hash: address && address.hash, + address_mapping: address && AddressIdToAddressHash.find_or_create(address.hash) + } + + address_attrs + |> Map.merge(address_related_attrs) + |> Map.merge(other_attrs) + end + + defp adjust_log_transaction_attrs(attrs) do + transaction_attrs = + case Map.get(attrs, :transaction) do + %{index: index} when not is_nil(index) -> %{transaction_index: index} + _ -> %{} + end + + Map.merge(attrs, transaction_attrs) + end + + defp adjust_log_first_topic_attrs(attrs) do + first_topic_attrs = + case Map.get(attrs, :first_topic) do + nil -> %{} + first_topic -> %{first_topic_id: LogFirstTopic.find_or_create(first_topic).id} + end + + Map.merge(attrs, first_topic_attrs) end def token_factory do @@ -952,6 +1029,20 @@ defmodule Explorer.Factory do } end + def token_ui_multiplier_change_factory do + block = build(:block) + + %UIMultiplierChange{ + token: build(:token), + block: block, + block_number: block.number, + log_index: sequence("token_ui_multiplier_change_log_index", & &1), + old_multiplier: Decimal.new("1000000000000000000"), + new_multiplier: Decimal.new("2000000000000000000"), + effective_at: DateTime.utc_now() + } + end + def unique_token_factory do Map.replace(token_factory(), :name, sequence("Infinite Token")) end @@ -990,7 +1081,8 @@ defmodule Explorer.Factory do address_hash: token_address.hash, address: token_address, data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", - transaction: transaction + transaction: transaction, + transaction_index: transaction_index() } build(:log, log_params) @@ -1208,6 +1300,10 @@ defmodule Explorer.Factory do transaction_hash end + def transaction_index do + sequence("transaction_index", & &1) + end + def transaction_input do data(:transaction_input) end @@ -1751,7 +1847,7 @@ defmodule Explorer.Factory do }) do data = "0x" <> (Integer.to_string(amount, 16) |> String.downcase() |> String.pad_leading(64, "0")) - %Log{ + log_params = %{ address: token_contract_address, address_hash: token_contract_address.hash, block: block, @@ -1762,8 +1858,11 @@ defmodule Explorer.Factory do third_topic: nil, fourth_topic: nil, index: sequence("log_index", & &1), - transaction: transaction + transaction: transaction, + transaction_index: transaction.index } + + build(:log, log_params) end def event_notification_factory do @@ -1970,6 +2069,30 @@ defmodule Explorer.Factory do } end + def zksync_lifecycle_transaction_factory do + %ZkSyncLifecycleTransaction{ + id: sequence("zksync_lifecycle_tx_id", & &1, start_at: 1), + hash: transaction_hash(), + timestamp: DateTime.utc_now() + } + end + + def zksync_transaction_batch_factory do + start_block = Enum.random(1..100_000) + + %ZkSyncTransactionBatch{ + number: sequence("zksync_transaction_batch_number", & &1), + timestamp: DateTime.utc_now(), + l1_transaction_count: Enum.random(0..50), + l2_transaction_count: Enum.random(0..100), + root_hash: block_hash(), + l1_gas_price: %Wei{value: Decimal.new(Enum.random(1..1_000_000_000))}, + l2_fair_gas_price: %Wei{value: Decimal.new(Enum.random(1..1_000_000_000))}, + start_block: start_block, + end_block: Kernel.+(start_block, 10) + } + end + def arbitrum_message_factory do %ArbitrumMessage{ direction: :to_l2, diff --git a/apps/explorer/test/test_helper.exs b/apps/explorer/test/test_helper.exs index 50bfec9620b..b321b46bbdd 100644 --- a/apps/explorer/test/test_helper.exs +++ b/apps/explorer/test/test_helper.exs @@ -24,7 +24,6 @@ Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Beacon, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.BridgedTokens, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Filecoin, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Stability, :auto) -Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Mud, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.ShrunkInternalTransactions, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, :auto) diff --git a/apps/indexer/lib/indexer/application.ex b/apps/indexer/lib/indexer/application.ex index 86f593b22e6..103aa4393ef 100644 --- a/apps/indexer/lib/indexer/application.ex +++ b/apps/indexer/lib/indexer/application.ex @@ -14,6 +14,7 @@ defmodule Indexer.Application do alias Indexer.Fetcher.OnDemand.TokenBalance, as: TokenBalanceOnDemand alias Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, as: TokenInstanceMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: TokenTotalSupplyOnDemand + alias Indexer.Fetcher.TokenInstance.MediaType, as: TokenInstanceMediaType alias Indexer.Fetcher.TokenInstance.Refetch, as: TokenInstanceRefetch alias Indexer.Memory @@ -47,12 +48,17 @@ defmodule Indexer.Application do Indexer.Fetcher.TokenInstance.Refetch.Supervisor ) + token_instance_fetcher_pool_size(Indexer.Fetcher.TokenInstance.SanitizeERC1155, nil) + - token_instance_fetcher_pool_size(Indexer.Fetcher.TokenInstance.SanitizeERC721, nil) + 1 + token_instance_fetcher_pool_size(Indexer.Fetcher.TokenInstance.SanitizeERC721, nil) + + token_instance_fetcher_pool_size( + Indexer.Fetcher.TokenInstance.MediaType, + Indexer.Fetcher.TokenInstance.MediaType.Supervisor + ) + 1 # + 1 (above in pool_size calculation) for the Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch base_children = [ :hackney_pool.child_spec(:token_instance_fetcher, max_connections: pool_size), + {Indexer.Fetcher.Optimism.SuperchainConfigUpdater, []}, {Memory.Monitor, [%{}, [name: memory_monitor_name]]}, {CoinBalanceOnDemand.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, {TokenBalanceOnDemand.Supervisor, []}, @@ -60,6 +66,7 @@ defmodule Indexer.Application do {ContractCreatorOnDemand.Supervisor, [json_rpc_named_arguments]}, {NFTCollectionMetadataRefetchOnDemand.Supervisor, [json_rpc_named_arguments]}, {TokenInstanceMetadataRefetchOnDemand.Supervisor, [json_rpc_named_arguments]}, + {TokenInstanceMediaType.Supervisor, []}, {TokenInstanceRefetch.Supervisor, []}, {TokenTotalSupplyOnDemand.Supervisor, []}, {FirstTraceOnDemand.Supervisor, [json_rpc_named_arguments]} diff --git a/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex b/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex index 15dc99c4f2e..fc3db92ec68 100644 --- a/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex +++ b/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex @@ -34,6 +34,8 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do - **Range-specific scanning** - Used when specific finite ranges or multiple ranges are configured. This strategy: * Clears all existing missing block ranges from the database + * Removes the massive blocks that fall outside the configured ranges, so they + are not re-fetched by `Indexer.Block.Catchup.MassiveBlocksFetcher` * For each configured range (e.g., "100..200,1000..9000"), identifies all missing blocks within those ranges by querying the blockchain * Saves all identified missing ranges to the database in a single batch operation @@ -65,7 +67,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.{Chain, Repo} alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Utility.MissingBlockRange + alias Explorer.Utility.{MassiveBlock, MissingBlockRange} @default_missing_ranges_batch_size 100_000 @past_check_interval 10 @@ -165,14 +167,18 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do # # Performs the following steps: # 1. Clears all existing missing block ranges from the database - # 2. Identifies missing blocks within each range and saves the new identified + # 2. Deletes the massive blocks that don't fall into the given ranges, so that + # `Indexer.Block.Catchup.MassiveBlocksFetcher` doesn't keep refetching blocks + # outside of the configured ranges + # 3. Identifies missing blocks within each range and saves the new identified # ranges of missing blocks to the database - # 3. If `max_fetched_block_number` is provided, schedules future block checks + # 4. If `max_fetched_block_number` is provided, schedules future block checks # # ## Parameters # - `ranges`: List of `Range` structs defining the block ranges to initialize # - `max_fetched_block_number`: Optional highest block number processed, used for - # scheduling future checks + # scheduling future checks and as the boundary of the trailing `..latest` range: + # every block above it belongs to the configured ranges # # ## Returns # A map containing: @@ -184,6 +190,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do } defp ranges_init(ranges, max_fetched_block_number \\ nil) do Repo.delete_all(MissingBlockRange) + MassiveBlock.delete_numbers_out_of_ranges(ranges, max_fetched_block_number) ranges |> Enum.reverse() diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index a8326800ec7..4e77c9df8a1 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -16,7 +16,7 @@ defmodule Indexer.Block.Fetcher do alias EthereumJSONRPC.{Blocks, FetchedBeneficiaries} alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Block, Hash, Import, Transaction, Wei, Withdrawal} + alias Explorer.Chain.{Block, Hash, Import, Log, Transaction, Wei, Withdrawal} alias Explorer.Chain.Block.Reward alias Explorer.Chain.Cache.{Accounts, BlockNumber, Transactions, Uncles} alias Explorer.Chain.Cache.Blocks, as: BlocksCache @@ -295,7 +295,15 @@ defmodule Indexer.Block.Fetcher do async_match_arbitrum_messages_to_l2(arbitrum_transactions_for_further_handling) if chain_type() == :zilliqa do - inserted_logs = Map.get(inserted, :logs, []) + inserted_logs = + inserted + |> Map.get(:logs, []) + |> Log.preload_block() + |> Log.preload_transaction() + |> Log.preload_address() + |> Log.prepare_data() + |> Log.prepare_first_topic() + inserted_transactions = Map.get(inserted, :transactions, []) Zrc2Tokens.fetch_zrc2_token_transfers_and_adapters(inserted_logs, inserted_transactions, range, callback_module) end diff --git a/apps/indexer/lib/indexer/block/fetcher/receipts.ex b/apps/indexer/lib/indexer/block/fetcher/receipts.ex index 274944ca7d2..e5efdb73be3 100644 --- a/apps/indexer/lib/indexer/block/fetcher/receipts.ex +++ b/apps/indexer/lib/indexer/block/fetcher/receipts.ex @@ -72,7 +72,7 @@ defmodule Indexer.Block.Fetcher.Receipts do {:halt, {:error, reason}} end) |> case do - {:ok, receipt_params} -> {:ok, set_block_number_to_logs(receipt_params, transaction_params)} + {:ok, receipt_params} -> {:ok, set_block_number_and_transaction_index_to_logs(receipt_params, transaction_params)} other -> other end end @@ -114,46 +114,43 @@ defmodule Indexer.Block.Fetcher.Receipts do end) end - # Updates block numbers in transaction logs by matching them with their parent transactions. + # Updates block numbers and transaction indexes in transaction logs by matching them with + # their parent transactions. # - # For logs with missing block numbers, finds the corresponding transaction and copies its - # block number to the log. Leaves logs with existing block numbers unchanged. + # Some clients omit `blockNumber` and/or `transactionIndex` in the logs returned within + # transaction receipts. Both fields are `NOT NULL` in the `logs` table, so they are restored + # from the corresponding transaction params. Logs with both fields set are left unchanged. # # ## Parameters # - `params`: Map containing logs and other optional data - # - `transaction_params`: List of transaction parameter maps with block numbers + # - `transaction_params`: List of transaction parameter maps with block numbers and indexes # # ## Returns - # - Updated params map with block numbers added to logs where missing - @spec set_block_number_to_logs( + # - Updated params map with block numbers and transaction indexes added to logs where missing + @spec set_block_number_and_transaction_index_to_logs( %{:logs => list(), optional(atom()) => any()}, [%{:hash => EthereumJSONRPC.hash(), optional(atom()) => any()}] ) :: %{:logs => list(), optional(atom()) => any()} - defp set_block_number_to_logs(%{logs: logs} = params, transaction_params) do - logs_with_block_numbers = - Enum.map(logs, fn %{transaction_hash: transaction_hash, block_number: block_number} = log_params -> - if is_nil(block_number) do - transaction = find_transaction_by_hash(transaction_params, transaction_hash) - - %{log_params | block_number: transaction[:block_number]} - else + defp set_block_number_and_transaction_index_to_logs(%{logs: logs} = params, transaction_params) do + transaction_hash_to_transaction = Map.new(transaction_params, &{&1[:hash], &1}) + + updated_logs = + Enum.map(logs, fn + %{block_number: block_number, transaction_index: transaction_index} = log_params + when is_nil(block_number) or is_nil(transaction_index) -> + transaction = transaction_hash_to_transaction[log_params.transaction_hash] + + %{ + log_params + | block_number: block_number || transaction[:block_number], + transaction_index: transaction_index || transaction[:index] + } + + log_params -> log_params - end end) - %{params | logs: logs_with_block_numbers} - end - - # Finds a transaction in the list of transaction parameters by its hash. - @spec find_transaction_by_hash( - [%{:hash => EthereumJSONRPC.hash(), optional(atom()) => any()}], - EthereumJSONRPC.hash() - ) :: - %{:hash => EthereumJSONRPC.hash(), optional(atom()) => any()} | nil - defp find_transaction_by_hash(transaction_params, transaction_hash) do - Enum.find(transaction_params, fn transaction -> - transaction[:hash] == transaction_hash - end) + %{params | logs: updated_logs} end defp split_transaction_params(transaction_params) do diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex index a68cbb04d77..a8cf583fb67 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex @@ -25,7 +25,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do import Explorer.Helper, only: [decode_data: 2] - import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1, log_debug: 1] + import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1, log_debug: 1, log_warning: 1] alias Indexer.Fetcher.Arbitrum.Utils.Db.Messages, as: DbMessages alias Indexer.Fetcher.Arbitrum.Utils.Helper, as: ArbitrumHelper @@ -245,9 +245,11 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do newly discovered messages as range delimiters. Note: When consecutive messages are missing (no fully indexed message between - them), processing one may also import others in the same L1 block range. This - results in some messages being imported multiple times, which is harmless due - to Chain.import's upsert behavior. + them), processing one may also import others in the same L1 block range as a + side effect. Before processing each message, its origination status is + re-checked against the database so that messages already back-filled by an + earlier iteration of this same loop are skipped instead of triggering a + redundant L1 scan. The cursor advances by the configured range size with each iteration. The task completes and stops when the next iteration's range would extend below @@ -264,6 +266,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do - JSON RPC arguments for L1 - L1 bridge address - L1 rollup initialization block + - L1 RPC block range for chunking `eth_getLogs` requests - L1 RPC chunk size for batch requests - Message ID range window size - `task_data`: Contains: @@ -288,6 +291,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do :json_l1_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments(), :l1_bridge_address => binary(), :l1_rollup_init_block => non_neg_integer(), + :l1_rpc_block_range => non_neg_integer(), :l1_rpc_chunk_size => non_neg_integer(), :missed_message_ids_range => non_neg_integer(), optional(any()) => any() @@ -308,6 +312,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do config: %{ json_l1_rpc_named_arguments: json_rpc_named_arguments, l1_rpc_chunk_size: chunk_size, + l1_rpc_block_range: rpc_block_range, l1_bridge_address: bridge_address, l1_rollup_init_block: l1_rollup_init_block, missed_message_ids_range: missed_message_ids_range @@ -336,31 +341,41 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do log_info( "Found #{length(missing_origination_message_ids)} messages with missing origination in range #{start_message_id}..#{end_message_id}" ) + end - # Process each missing message (already in descending order from highest to lowest ID). - # - # Note on duplicate imports: When consecutive messages are missing (no fully indexed - # message between them), processing the higher-ID message may also discover and import - # lower-ID messages within the same L1 block range. For example: - # - Messages A(indexed), B(missing), C(missing), D(indexed) - # - Processing C: L1 range spans A..D, both B and C are discovered and imported - # - Processing B: B is imported again (already has origination from previous step) - # - # This is expected and harmless - Chain.import performs upserts, so reimporting - # the same message data is idempotent. Duplicate imports only occur with consecutive - # missing messages; alternating indexed/missing patterns naturally prevent this. - missing_origination_message_ids - |> Enum.each(fn message_id -> + # Process each missing message (already in descending order from highest to lowest ID). + # + # Note on co-import side effect: When consecutive messages are missing (no fully + # indexed message between them), processing the higher-ID message may also discover + # and import lower-ID messages within the same L1 block range. For example: + # - Messages A(indexed), B(missing), C(missing), D(indexed) + # - Processing C: L1 range spans A..D, both B and C are discovered and imported + # - Processing B: origination is already present from the previous step, so the + # skip check below avoids re-scanning L1 for it + # + # This side effect is intentionally not short-circuited by an early exit from this + # loop - scanning every remaining chunk for the highest-ID message is what lets + # neighbouring missing messages in the same gap get co-discovered in the first + # place. Instead, each message is re-checked against the database immediately + # before it would be processed: if an earlier iteration of this same loop already + # filled in its origination data, the message is skipped and no RPC call is made + # for it at all. + missing_origination_message_ids + |> Enum.each(fn message_id -> + if origination_info_missing?(message_id) do discover_and_import_single_missing_message( message_id, l1_rollup_init_block, safe_l1_block, bridge_address, json_rpc_named_arguments, - chunk_size + chunk_size, + rpc_block_range ) - end) - end + else + log_debug("Message ID #{message_id} already has originating block number, skipping") + end + end) # Move cursor backward for next iteration next_end_message_id = start_message_id - 1 @@ -382,6 +397,18 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do {:ok, updated_state} end + # Tells whether the message still lacks its originating block number, i.e. it + # was not back-filled as a side effect of processing a higher message ID + # earlier in the same `check_missing_origination/1` loop. The check must run + # immediately before the message is processed - a single query ahead of the + # loop would not observe those side effects. + # + # A single-ID range (message_id, message_id) is a valid query - the guards + # only require non-negative IDs with start <= end. + defp origination_info_missing?(message_id) do + DbMessages.messages_to_l2_completed_but_originating_info_missed(message_id, message_id) != [] + end + # Discovers and imports origination information for a single L1-to-L2 message # that is missing such information. # @@ -408,6 +435,9 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do # - `bridge_address`: L1 bridge contract address for log filtering # - `json_rpc_named_arguments`: RPC configuration # - `chunk_size`: Batch size for RPC requests + # - `rpc_block_range`: Maximum width of a single `eth_getLogs` request; the + # discovered L1 block range is split into chunks of at most this width so + # that the node's per-request block-range limit is never exceeded # # ## Returns # - `:ok` after attempting to discover and import the message @@ -417,7 +447,8 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do safe_l1_block, bridge_address, json_rpc_named_arguments, - chunk_size + chunk_size, + rpc_block_range ) do log_debug("Discovering origination for message ID #{message_id}") @@ -442,15 +473,25 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do lower_than: higher_bound.message_id } - # Discover messages in the L1 block range, filtering to only messages - # within the discovered bounds (exclusive) - discover( - bridge_address, + # Since the L1 block range to search could be wider than `rpc_block_range` + # it is required to divide it into smaller chunks so that a single + # `eth_getLogs` request never exceeds the node's per-request block-range + # limit. `filter_range` is computed once above and reused for every chunk, + # so chunking does not change which messages end up imported. + ArbitrumHelper.execute_for_block_range_in_chunks( l1_start_block, l1_end_block, - json_rpc_named_arguments, - chunk_size, - filter_range + rpc_block_range, + fn chunk_start, chunk_end -> + discover( + bridge_address, + chunk_start, + chunk_end, + json_rpc_named_arguments, + chunk_size, + filter_range + ) + end ) :ok @@ -568,7 +609,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do [%{String.t() => any()}], EthereumJSONRPC.json_rpc_named_arguments(), non_neg_integer(), - %{higher_than: non_neg_integer(), lower_than: non_neg_integer()} | nil + %{higher_than: integer(), lower_than: non_neg_integer()} | nil ) :: [Arbitrum.Message.to_import()] defp get_messages_from_logs(logs, json_rpc_named_arguments, chunk_size, only_messages_in_range) @@ -580,8 +621,26 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do transactions_to_from = Rpc.execute_transactions_requests_and_get_from(transactions_requests, json_rpc_named_arguments, chunk_size) + # A successful-but-null `eth_getTransactionByHash` response (e.g. from a + # lagging or pruned node behind a load balancer) leaves the originator + # address unknown. Such a message must not be imported at all: persisting it + # with the block number filled but the originator empty would make it look + # complete to the discovery queries keyed on the block number, so it would + # never be re-processed. Left unimported, it stays selectable for a retry on + # a later pass. + {messages_with_originator, messages_without_originator} = + Enum.split_with(messages, fn msg -> + not is_nil(transactions_to_from[msg.originating_transaction_hash]) + end) + + unless Enum.empty?(messages_without_originator) do + log_warning( + "Skipping import of #{length(messages_without_originator)} L1-to-L2 messages with unknown originator address" + ) + end + complete_messages = - Enum.map(messages, fn msg -> + Enum.map(messages_with_originator, fn msg -> Map.merge(msg, %{ originator_address: transactions_to_from[msg.originating_transaction_hash], status: :initiated diff --git a/apps/indexer/lib/indexer/fetcher/beacon/client.ex b/apps/indexer/lib/indexer/fetcher/beacon/client.ex index 49306a2f93c..485217af562 100644 --- a/apps/indexer/lib/indexer/fetcher/beacon/client.ex +++ b/apps/indexer/lib/indexer/fetcher/beacon/client.ex @@ -12,7 +12,7 @@ defmodule Indexer.Fetcher.Beacon.Client do defp http_get_request(url) do case HttpClient.get(url, [], recv_timeout: 30_000) do {:ok, %{body: body, status_code: 200}} -> - Jason.decode(body) + Utils.JSON.decode(body) {:ok, %{body: body, status_code: _}} -> {:error, body} diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex index 574574498c3..db5c1eafa27 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex @@ -22,7 +22,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPos read_contracts_with_retries: 4 ] - import Ecto.Query, only: [from: 2] + import Ecto.Query import Explorer.Helper, only: [abi_to_method_id: 1] alias Explorer.Chain.{Block, Log} @@ -94,7 +94,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPos def fetch(%Epoch{number: epoch_number, start_processing_block: %Block{number: block_number, hash: block_hash}}) do with false <- - check_if_validator_payment_distributed_events_exist(block_hash), + check_if_validator_payment_distributed_events_exist(block_number), {:ok, length} <- number_of_elected_in_current_set(block_number), {:ok, account_address_hashes} <- get_elected_accounts(block_number, length), {:ok, payments_before} <- get_allocated_payments(account_address_hashes, block_number - 1), @@ -311,18 +311,11 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPos ) end - defp check_if_validator_payment_distributed_events_exist(block_hash) do - query = - from( - log in Log, - where: [ - block_hash: ^block_hash, - address_hash: ^epoch_manager_contract_address_hash(), - first_topic: ^@validator_epoch_payment_distributed_event_topic - ] - ) - - query + defp check_if_validator_payment_distributed_events_exist(block_number) do + Log + |> where([l], l.block_number == ^block_number) + |> where([l], l.address_hash == ^epoch_manager_contract_address_hash()) + |> Log.filter_by_topic_query(:first_topic, @validator_epoch_payment_distributed_event_topic) |> Repo.exists?() end end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex index 4636e723dc7..8f8b1379090 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex @@ -3,28 +3,36 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPri @moduledoc """ Fetches validator and group payments for the epoch block. """ - import Ecto.Query, only: [from: 2] + import Ecto.Query alias Explorer.Chain.{Block, Celo.Epoch, Log} alias Explorer.Chain.Cache.CeloCoreContracts alias Explorer.Repo + alias Explorer.Utility.LogHelper alias Indexer.Transform.Celo.ValidatorEpochPaymentDistributions @spec fetch(Epoch.t()) :: {:ok, list()} - def fetch(%Epoch{start_processing_block: %Block{number: block_number, hash: block_hash}} = epoch) do + def fetch(%Epoch{start_processing_block: %Block{number: block_number}} = epoch) do epoch_payment_distributions_signature = ValidatorEpochPaymentDistributions.signature() {:ok, validators_contract_address} = CeloCoreContracts.get_address(:validators, block_number) query = - from( - log in Log, - where: - log.block_hash == ^block_hash and - log.address_hash == ^validators_contract_address and - log.first_topic == ^epoch_payment_distributions_signature and - is_nil(log.transaction_hash), - select: log - ) + Log + |> Log.address_match_query(validators_contract_address) + |> where([log], log.block_number == ^block_number) + |> Log.filter_by_topic_query(:first_topic, epoch_payment_distributions_signature) + |> then(fn query -> + cond do + LogHelper.fill_optimized_fields_migration_finished?() -> + where(query, [log], is_nil(log.transaction_index)) + + LogHelper.fill_optimized_fields_migration_started?() -> + where(query, [log], is_nil(log.transaction_hash) and is_nil(log.transaction_index)) + + true -> + where(query, [log], is_nil(log.transaction_hash)) + end + end) payments = query diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex index 2c2928a465a..36adb7de85b 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex @@ -3,7 +3,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do @moduledoc """ Fetches voter payments for the epoch block. """ - import Ecto.Query, only: [from: 2, select: 3] + import Ecto.Query import Explorer.Helper, only: [ @@ -164,14 +164,11 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do defp epoch_rewards_distributed_to_voters_query(start_block_number, end_block_number) do {:ok, election_contract_address} = CeloCoreContracts.get_address(:election, start_block_number) - from( - l in Log, - where: - l.block_number >= ^start_block_number and - l.block_number <= ^end_block_number and - l.address_hash == ^election_contract_address and - l.first_topic == ^@epoch_rewards_distributed_to_voters_topic - ) + Log + |> where([l], l.block_number >= ^start_block_number) + |> where([l], l.block_number <= ^end_block_number) + |> where([l], l.address_hash == ^election_contract_address) + |> Log.filter_by_topic_query(:first_topic, @epoch_rewards_distributed_to_voters_topic) end # Validates voter rewards by comparing the sum of what we got from the diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex index d6c5cc9b4b4..a3a57d255dd 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex @@ -72,7 +72,7 @@ defmodule Indexer.Fetcher.Celo.EpochLogs do {:ok, logs} <- Logs.from_responses(responses) do logs |> Enum.filter(&(&1.transaction_hash == &1.block_hash)) - |> Enum.map(&Map.put(&1, :transaction_hash, nil)) + |> Enum.map(&%{&1 | transaction_hash: nil, transaction_index: nil}) end end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex index 0c5b761da1b..3dbd2c35a25 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex @@ -557,9 +557,13 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do defp filter_by_address(internal_transactions, address_hash, direction) do Enum.filter(internal_transactions, fn internal_transaction -> case direction do - d when d in [:to, :to_address_hash] -> + :to -> internal_transaction.to_address_hash == address_hash + :to_address_hash -> + internal_transaction.to_address_hash == address_hash and + is_nil(internal_transaction.created_contract_address_hash) + d when d in [:from, :from_address_hash] -> internal_transaction.from_address_hash == address_hash @@ -815,16 +819,16 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransaction do end defp serialize(internal_transaction_params) do + to_address_hash = convert_to_address_hash(internal_transaction_params[:to_address_hash]) + created_contract_address_hash = convert_to_address_hash(internal_transaction_params[:created_contract_address_hash]) + %InternalTransaction{} |> InternalTransaction.changeset(internal_transaction_params) |> Ecto.Changeset.apply_changes() |> Map.put(:transaction_hash, internal_transaction_params[:transaction_hash]) |> Map.put(:from_address_hash, convert_to_address_hash(internal_transaction_params[:from_address_hash])) - |> Map.put(:to_address_hash, convert_to_address_hash(internal_transaction_params[:to_address_hash])) - |> Map.put( - :created_contract_address_hash, - convert_to_address_hash(internal_transaction_params[:created_contract_address_hash]) - ) + |> Map.put(:to_address_hash, to_address_hash || created_contract_address_hash) + |> Map.put(:created_contract_address_hash, created_contract_address_hash) end defp convert_to_address_hash(nil), do: nil diff --git a/apps/indexer/lib/indexer/fetcher/optimism.ex b/apps/indexer/lib/indexer/fetcher/optimism.ex index 2b15ab5f464..d3c9ae00098 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism.ex @@ -19,10 +19,9 @@ defmodule Indexer.Fetcher.Optimism do alias EthereumJSONRPC.Block.{ByHash, ByNumber} alias EthereumJSONRPC.{Blocks, Contract} - alias Explorer.Application.Constants alias Explorer.Chain.Cache.ChainId alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Chain.Optimism.Withdrawal + alias Explorer.Chain.Optimism.SuperchainConfig alias Explorer.Repo alias Indexer.Fetcher.RollupL1ReorgMonitor alias Indexer.Helper @@ -110,7 +109,7 @@ defmodule Indexer.Fetcher.Optimism do end optimism_env = Application.get_all_env(:indexer)[__MODULE__] - system_config = optimism_env[:optimism_l1_system_config] + system_config = SuperchainConfig.optimism_l1_system_config_contract() optimism_l1_rpc = l1_rpc_url() with {:system_config_valid, true} <- {:system_config_valid, Helper.address_correct?(system_config)}, @@ -217,8 +216,8 @@ defmodule Indexer.Fetcher.Optimism do error_message = &"Cannot call public getters of SystemConfig. Error: #{inspect(&1)}" - env = Application.get_all_env(:indexer)[__MODULE__] - fallback_start_block = env[:start_block_l1] + fallback_start_block = SuperchainConfig.optimism_l1_batch_start_block() + fallback_portal = SuperchainConfig.optimism_l1_portal_contract() {optimism_portal, start_block} = case Helper.repeated_call( @@ -232,8 +231,8 @@ defmodule Indexer.Fetcher.Optimism do optimism_portal = with {:nil_result, true, _} <- {:nil_result, is_nil(optimism_portal_result), optimism_portal_result}, - {:fallback_defined, true} <- {:fallback_defined, Helper.address_correct?(env[:portal])} do - env[:portal] + {:fallback_defined, true} <- {:fallback_defined, Helper.address_correct?(fallback_portal)} do + fallback_portal else {:nil_result, false, portal} -> "0x000000000000000000000000" <> optimism_portal = portal @@ -249,15 +248,10 @@ defmodule Indexer.Fetcher.Optimism do |> Map.get(:result, fallback_start_block) |> quantity_to_integer() - if not is_nil(optimism_portal) do - # we save the OptimismPortal contract address to use it in other modules (e.g. by `BlockScoutWeb.API.V2.OptimismView`) - Constants.set_constant_value(Withdrawal.portal_contract_address_constant(), optimism_portal) - end - {optimism_portal, start_block} _ -> - {env[:portal], fallback_start_block} + {fallback_portal, fallback_start_block} end if Helper.address_correct?(optimism_portal) and !is_nil(start_block) do @@ -282,8 +276,7 @@ defmodule Indexer.Fetcher.Optimism do """ @spec requires_l1_reorg_monitor?() :: boolean() def requires_l1_reorg_monitor? do - optimism_config = Application.get_all_env(:indexer)[__MODULE__] - not is_nil(optimism_config[:optimism_l1_system_config]) + not is_nil(SuperchainConfig.optimism_l1_system_config_contract()) end @doc """ diff --git a/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex b/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex index 4924e79f4ca..a070ba86de2 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex @@ -19,7 +19,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do alias Explorer.{Chain, Helper, Repo} alias Explorer.Chain.Data alias Explorer.Chain.Hash.Address - alias Explorer.Chain.Optimism.{DisputeGame, Withdrawal} + alias Explorer.Chain.Optimism.{DisputeGame, SuperchainConfig, Withdrawal} alias Indexer.Fetcher.Optimism alias Indexer.Helper, as: IndexerHelper @@ -59,7 +59,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do :timer.sleep(2000) env = Application.get_all_env(:indexer)[Optimism] - system_config = env[:optimism_l1_system_config] + system_config = SuperchainConfig.optimism_l1_system_config_contract() rpc = env[:optimism_l1_rpc] with {:system_config_valid, true} <- {:system_config_valid, IndexerHelper.address_correct?(system_config)}, diff --git a/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex b/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex index 9a3f209bc52..0b42811f8cb 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex @@ -43,6 +43,7 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do alias Explorer.Chain.Cache.Counters.LastFetchedCounter alias Explorer.Chain.Events.Subscriber alias Explorer.Chain.Optimism.EIP1559ConfigUpdate + alias Explorer.Chain.Optimism.SuperchainConfig alias Indexer.Fetcher.Optimism alias Indexer.Helper @@ -107,8 +108,8 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do env = Application.get_all_env(:indexer)[__MODULE__] optimism_env = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism] - timestamp_holocene = env[:holocene_timestamp_l2] - timestamp_jovian = env[:jovian_timestamp_l2] + timestamp_holocene = SuperchainConfig.holocene_timestamp_l2() + timestamp_jovian = SuperchainConfig.jovian_timestamp_l2() with false <- is_nil(timestamp_holocene), {:jovian_after_holocene, true} <- {:jovian_after_holocene, timestamp_jovian >= timestamp_holocene}, diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex index db1961af02b..ee1f8ad56b9 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex @@ -414,9 +414,9 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do headers = [{"Content-Type", "application/json"}] opts = [adapter: [timeout: recv_timeout, transport_opts: [timeout: timeout]]] - case Tesla.post(client, url, Jason.encode!(body), headers: headers, opts: opts) do + case Tesla.post(client, url, Utils.JSON.encode!(body), headers: headers, opts: opts) do {:ok, %{body: response_body, status: 200}} -> - case Jason.decode(response_body) do + case Utils.JSON.decode(response_body) do {:ok, response} -> response diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex index 24981a315ae..fbf52cd230f 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex @@ -243,7 +243,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MultichainExport do recv_timeout = 5_000 client = Tesla.client([{Tesla.Middleware.Timeout, timeout: recv_timeout}], Tesla.Adapter.Mint) - json_body = Jason.encode!(body) + json_body = Utils.JSON.encode!(body) headers = [{"Content-Type", "application/json"}] # Mint adapter doesn't support sending more than 65535 bytes when using HTTP/2, so we use HTTP/1 diff --git a/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex b/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex index 5d86b15e942..d33468ab86c 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex @@ -29,6 +29,7 @@ defmodule Indexer.Fetcher.Optimism.OperatorFee do alias EthereumJSONRPC.Receipts.ByTransactionHash alias Explorer.Application.Constants alias Explorer.Chain.{Hash, Transaction} + alias Explorer.Chain.Optimism.SuperchainConfig alias Explorer.Repo alias Indexer.{BufferedTask, Helper} @@ -68,7 +69,7 @@ defmodule Indexer.Fetcher.Optimism.OperatorFee do Process.send(__MODULE__, :shutdown, []) initial_acc else - isthmus_timestamp_l2 = Application.get_env(:indexer, Indexer.Fetcher.Optimism)[:isthmus_timestamp_l2] + isthmus_timestamp_l2 = SuperchainConfig.isthmus_timestamp_l2() {:ok, acc} = Transaction.stream_transactions_without_operator_fee( diff --git a/apps/indexer/lib/indexer/fetcher/optimism/superchain_config_updater.ex b/apps/indexer/lib/indexer/fetcher/optimism/superchain_config_updater.ex new file mode 100644 index 00000000000..19b64c23a56 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/optimism/superchain_config_updater.ex @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Optimism.SuperchainConfigUpdater do + @moduledoc """ + Runs once on startup to populate Optimism constants from Superchain TOML + (with env fallback) into the `constants` table. + """ + + use GenServer + + require Logger + + alias Explorer.Chain.Optimism.SuperchainConfig + + @max_attempts 3 + @refresh_timeout_ms 20_000 + @retry_backoff_ms 2_000 + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl GenServer + def init(_) do + state = %{attempt: 0} + + if Application.get_env(:explorer, :chain_type) == :optimism do + {:ok, state, {:continue, :refresh}} + else + {:ok, state} + end + end + + @impl GenServer + def handle_continue(:refresh, state) do + {:noreply, maybe_refresh(state)} + end + + @impl GenServer + def handle_info(:retry_refresh, state) do + {:noreply, maybe_refresh(state)} + end + + defp maybe_refresh(%{attempt: attempt} = state) do + case refresh_with_timeout() do + :ok -> + state + + {:error, reason} -> + next_attempt = attempt + 1 + + if next_attempt < @max_attempts do + Logger.warning( + "Superchain config refresh failed (attempt #{next_attempt}/#{@max_attempts}): #{inspect(reason)}. Retrying in #{@retry_backoff_ms}ms." + ) + + Process.send_after(self(), :retry_refresh, @retry_backoff_ms) + %{state | attempt: next_attempt} + else + Logger.error("Superchain config refresh failed after #{@max_attempts} attempts: #{inspect(reason)}") + + %{state | attempt: next_attempt} + end + end + end + + defp refresh_with_timeout do + task = Task.async(fn -> SuperchainConfig.refresh() end) + + try do + case Task.await(task, @refresh_timeout_ms) do + :ok -> :ok + other -> {:error, {:unexpected_refresh_result, other}} + end + catch + :exit, {:timeout, _} = timeout_reason -> + Task.shutdown(task, :brutal_kill) + {:error, timeout_reason} + + :exit, reason -> + {:error, reason} + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex index 0b0b3976d48..d368c9bef33 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex @@ -36,8 +36,10 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do alias Explorer.Chain.{Block, Hash} alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Optimism.{FrameSequence, FrameSequenceBlob} + alias Explorer.Chain.Optimism.SuperchainConfig alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch - alias Indexer.Fetcher.{Optimism, RollupL1ReorgMonitor} + alias Indexer.Fetcher.Optimism + alias Indexer.Fetcher.RollupL1ReorgMonitor alias Indexer.Helper alias Indexer.Prometheus.Instrumenter alias Indexer.RollupReorgMonitorQueue @@ -83,15 +85,16 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do %{json_rpc_named_arguments_l2: json_rpc_named_arguments_l2} = state ) do env = Application.get_all_env(:indexer)[__MODULE__] + genesis_block_l2 = SuperchainConfig.optimism_l2_batch_genesis_block_number() optimism_env = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism] - system_config = optimism_env[:optimism_l1_system_config] + system_config = SuperchainConfig.optimism_l1_system_config_contract() optimism_l1_rpc = l1_rpc_url() with {:system_config_valid, true} <- {:system_config_valid, Helper.address_correct?(system_config)}, {:genesis_block_l2_invalid, false} <- - {:genesis_block_l2_invalid, is_nil(env[:genesis_block_l2]) or env[:genesis_block_l2] < 0}, + {:genesis_block_l2_invalid, is_nil(genesis_block_l2) or genesis_block_l2 < 0}, _ <- RollupL1ReorgMonitor.wait_for_start(__MODULE__), {:rpc_l1_undefined, false} <- {:rpc_l1_undefined, is_nil(optimism_l1_rpc)}, json_rpc_named_arguments = Helper.l1_json_rpc_named_arguments(optimism_l1_rpc), @@ -142,7 +145,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do end_block: last_safe_block, chunk_size: chunk_size, incomplete_channels: %{}, - genesis_block_l2: env[:genesis_block_l2], + genesis_block_l2: genesis_block_l2, block_duration: optimism_env[:block_duration], json_rpc_named_arguments: json_rpc_named_arguments, json_rpc_named_arguments_l2: json_rpc_named_arguments_l2, @@ -1568,6 +1571,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # from the contract. In particular, SystemConfig v4.0.0 (OP Stack Upgrade 20) removed the `batchInbox()` getter, # so the inbox address must be defined through INDEXER_OPTIMISM_L1_BATCH_INBOX for such chains, whereas # `startBlock()` and `batcherHash()` are still readable. + # If SystemConfig has obsolete implementation, the values are fallen back from the corresponding + # SuperchainConfig-backed values. # # Moreover, if INDEXER_OPTIMISM_L1_BATCH_INBOX and/or INDEXER_OPTIMISM_L1_BATCH_SUBMITTER are explicitly set, # they take precedence over the corresponding values read from the SystemConfig contract. This is needed when @@ -1599,8 +1604,9 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do error_message = &"Cannot call public getters of SystemConfig. Error: #{inspect(&1)}" - env = Application.get_all_env(:indexer)[__MODULE__] - fallback_start_block = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism][:start_block_l1] + fallback_start_block = SuperchainConfig.optimism_l1_batch_start_block() + fallback_inbox = SuperchainConfig.optimism_l1_batch_inbox() + fallback_submitter = SuperchainConfig.optimism_l1_batch_submitter() result_by_id = case Helper.repeated_call( @@ -1622,8 +1628,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # An explicitly configured inbox/submitter overrides the value read from the SystemConfig contract. # Only kicks in when the corresponding env variable holds a correct address, so the on-chain value # is still used by default. - batch_inbox = system_config_address(result_by_id[batch_inbox_request_id], env[:inbox]) - batch_submitter = system_config_address(result_by_id[batch_submitter_request_id], env[:submitter]) + batch_inbox = system_config_address(result_by_id[batch_inbox_request_id], fallback_inbox) + batch_submitter = system_config_address(result_by_id[batch_submitter_request_id], fallback_submitter) if is_nil(batch_inbox) and Map.has_key?(result_by_id, batch_submitter_request_id) do Logger.error( diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex b/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex index 0a6497da5df..c535685994f 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex @@ -9,6 +9,7 @@ defmodule Indexer.Fetcher.TokenInstance.Helper do alias Explorer.Chain.Token.Instance alias Explorer.Token.MetadataRetriever alias Indexer.NFTMediaHandler.Queue + alias Utils.TokenInstanceHelper require Logger @@ -212,4 +213,84 @@ defmodule Indexer.Fetcher.TokenInstance.Helper do |> upsert_with_rescue(true) end end + + @doc """ + Fetches media types for a token instance, saves to DB, and returns the result. + + Returns: + - `{:ok, %{image_type: String.t(), animation_type: String.t()}}` on success + - `{:error, :metadata_not_found}` when metadata is nil + - `{:error, :already_fetched}` when both types are already set (non-nil) + """ + @spec fetch_media_types(Instance.t()) :: + {:ok, %{image_type: String.t(), animation_type: String.t()}} + | {:error, :metadata_not_found | :already_fetched} + def fetch_media_types(%Instance{metadata: nil}), do: {:error, :metadata_not_found} + + def fetch_media_types(%Instance{image_type: image_type, animation_type: animation_type}) + when not is_nil(image_type) and not is_nil(animation_type), + do: {:error, :already_fetched} + + def fetch_media_types(%Instance{ + token_contract_address_hash: address_hash, + token_id: token_id, + metadata: metadata + }) do + image_url = Instance.get_image_url_from_metadata(metadata) + animation_url = Instance.get_animation_url_from_metadata(metadata) + + image_type = determine_media_type(image_url) + animation_type = determine_media_type(animation_url) + + Instance.batch_update_media_types([ + %{ + token_contract_address_hash: address_hash, + token_id: token_id, + image_type: image_type, + animation_type: animation_type + } + ]) + + {:ok, %{image_type: image_type, animation_type: animation_type}} + end + + @doc """ + Determines the MIME type string for a given URL. + + Returns: + - `""` when URL is nil (no URL in metadata, sentinel to prevent re-processing) + - full MIME string like `"image/png"` when type is determined + - `""` when host is blacklisted or type cannot be determined + """ + @spec determine_media_type(binary() | nil) :: String.t() + def determine_media_type(nil), do: "" + + def determine_media_type("data:" <> _ = url) do + case TokenInstanceHelper.media_type_detailed(url) do + {:ok, {type, subtype}} -> + mime_tuple_to_string({type, subtype}) + + {:error, reason} -> + Logger.warning("Failed to determine media type for data URI: #{String.slice(url, 0, 100)}, reason: #{reason}") + "" + end + end + + def determine_media_type(url) do + {resolved_url, headers} = MetadataRetriever.resolve_nft_media_url(url) + + # `media_type_detailed/3` validates the host and every redirect target itself, so no + # separate pre-flight check is needed here (each one costs blocking DNS lookups). + case TokenInstanceHelper.media_type_detailed(resolved_url, headers) do + {:ok, {type, subtype}} -> + mime_tuple_to_string({type, subtype}) + + {:error, reason} -> + Logger.warning("Failed to determine media type for URL: #{url}, reason: #{inspect(reason)}") + "" + end + end + + defp mime_tuple_to_string({type, ""}), do: type + defp mime_tuple_to_string({type, subtype}), do: "#{type}/#{subtype}" end diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/media_type.ex b/apps/indexer/lib/indexer/fetcher/token_instance/media_type.ex new file mode 100644 index 00000000000..e8abb80993a --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_instance/media_type.ex @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenInstance.MediaType do + @moduledoc """ + Backfills MIME types for token instances that have metadata + but missing image_type or animation_type. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + import Ecto.Query + + alias Explorer.Chain.Token.Instance + alias Explorer.{QueryHelper, Repo} + alias Indexer.BufferedTask + alias Indexer.Fetcher.TokenInstance.Helper + + @behaviour BufferedTask + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.merge(state: []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + Instance.stream_token_instances_with_unfetched_media_type(initial_acc, fn data, acc -> + reducer.(data, acc) + end) + + acc + end + + @impl BufferedTask + def run(token_instances, _) when is_list(token_instances) do + ids = + Enum.map(token_instances, fn %{contract_address_hash: address_hash, token_id: token_id} -> + {token_id, address_hash.bytes} + end) + + instances = + Instance + |> where([i], ^QueryHelper.tuple_in([:token_id, :token_contract_address_hash], ids)) + |> Repo.all() + + updates = + instances + |> Enum.filter(& &1.metadata) + |> Enum.map(fn instance -> + image_url = Instance.get_image_url_from_metadata(instance.metadata) + animation_url = Instance.get_animation_url_from_metadata(instance.metadata) + + %{ + token_contract_address_hash: instance.token_contract_address_hash, + token_id: instance.token_id, + image_type: Helper.determine_media_type(image_url), + animation_type: Helper.determine_media_type(animation_url) + } + end) + + Instance.batch_update_media_types(updates) + + :ok + end + + defp defaults do + [ + flush_interval: :infinity, + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency], + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size], + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_ui_multiplier_updater.ex b/apps/indexer/lib/indexer/fetcher/token_ui_multiplier_updater.ex new file mode 100644 index 00000000000..1e3bc2fe454 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_ui_multiplier_updater.ex @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenUIMultiplierUpdater do + @moduledoc """ + Records the [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) multiplier + changes announced by `UIMultiplierUpdated` logs. + + Each change is written to `Explorer.Chain.Token.UIMultiplierChange`, so that + amounts of past token transfers can be displayed with the multiplier that was + in force back then, and the token itself is refreshed to carry the multiplier + in force now. + + The standard requires the event on every change of the multiplier, so it is + the only trigger needed: a change is always announced by a log, and the moment + a scheduled change matures is derived from the recorded values rather than + polled for. + + The history rows come from the log alone — all three parameters of the event + are non-indexed — while the token itself is refreshed by reading the getters, + which keeps it correct even if a log is missed or replaced by a reorg. + """ + + use GenServer + + require Logger + + alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Explorer.Chain.{Hash, Token} + alias Explorer.Chain.Token.UIMultiplierChange + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + alias Explorer.Token.MetadataRetriever + alias Timex.Duration + + @default_update_interval :timer.seconds(10) + + @max_attempts 10 + + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl GenServer + def init(_) do + schedule_next_update() + + {:ok, %{}} + end + + @doc """ + Schedules the given ERC-8056 multiplier changes, as parsed by `Explorer.Chain.Token.ScaledUIAmount.parse_ui_multiplier_updated/1`, to be recorded. + """ + @spec add_changes([map()]) :: :ok + def add_changes([]), do: :ok + + def add_changes(changes) do + GenServer.cast(__MODULE__, {:add_changes, changes}) + end + + @impl GenServer + def handle_cast({:add_changes, changes}, state) do + {:noreply, changes |> List.wrap() |> Enum.reduce(state, &Map.put_new(&2, &1, 0))} + end + + @impl GenServer + def handle_info(:update, state) do + postponed = + state + |> Map.keys() + |> Enum.group_by(& &1.token_contract_address_hash) + |> Enum.flat_map(fn {contract_address_hash, token_changes} -> + case update_token(contract_address_hash, token_changes) do + :ok -> [] + {:retry, retried_changes} -> retried_changes + end + end) + |> Enum.reduce(%{}, &postpone(&1, &2, state)) + + schedule_next_update() + + {:noreply, postponed} + end + + defp postpone(change, postponed, state) do + case Map.get(state, change, 0) + 1 do + attempts when attempts < @max_attempts -> + Map.put(postponed, change, attempts) + + _ -> + Logger.warning(fn -> + "Dropping ERC-8056 multiplier change of #{change.token_contract_address_hash}: " <> + "the token is still not indexed after #{@max_attempts} attempts" + end) + + postponed + end + end + + defp schedule_next_update do + update_interval = + case AverageBlockTime.average_block_time() do + {:error, :disabled} -> @default_update_interval + block_time -> round(Duration.to_milliseconds(block_time)) + end + + Process.send_after(self(), :update, update_interval) + end + + # the hash comes off the log, so it is the string the node answered with, but + # `Hash.Address.cast/1` takes an already cast struct just as well and keeps a + # producer that hands one over from being dropped silently + @spec update_token(String.t() | Hash.Address.t(), [map()]) :: :ok | {:retry, [map()]} + defp update_token(contract_address_hash, changes) do + case Hash.Address.cast(contract_address_hash) do + {:ok, address_hash} -> + address_hash + |> then(&Repo.get_by(Token, contract_address_hash: &1)) + |> record_changes(address_hash, changes) + + _ -> + :ok + end + end + + defp record_changes(nil, _address_hash, changes), do: {:retry, changes} + + defp record_changes(%Token{} = token, address_hash, changes) do + case scaled_ui_amount_support(token, address_hash) do + {:ok, true} -> + changes + |> Enum.map(&Map.put(&1, :token_contract_address_hash, address_hash)) + |> UIMultiplierChange.insert_changes() + + unless token.skip_metadata, do: refresh_token(token, address_hash) + + :ok + + {:ok, false} -> + :ok + + :error -> + {:retry, changes} + end + end + + defp scaled_ui_amount_support(%Token{type: "ERC-8056"}, _address_hash), do: {:ok, true} + + defp scaled_ui_amount_support(_token, address_hash) do + address_hash |> Hash.to_string() |> MetadataRetriever.scaled_ui_amount_support() + end + + defp refresh_token(token, address_hash) do + token_params = address_hash |> Hash.to_string() |> MetadataRetriever.get_ui_multiplier_of() + + case Token.update(token, Map.put(token_params, :type, "ERC-8056")) do + {:ok, updated_token} -> + # the multiplier is part of the token metadata exported to the Multichain service, + # so the whole metadata entry is re-sent with the values in force now + %{address_hash => MultichainSearch.prepare_token_metadata_for_queue(updated_token)} + |> MultichainSearch.send_token_info_to_queue(:metadata) + + :ok + + {:error, changeset} -> + Logger.error(fn -> + "Failed to update ERC-8056 token #{address_hash}: #{inspect(changeset.errors)}" + end) + + :ok + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex b/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex index 09a9361e55f..4af58152aa1 100644 --- a/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex +++ b/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex @@ -690,7 +690,7 @@ defmodule Indexer.Fetcher.Zilliqa.Zrc2Tokens do log_data |> decode_data([:string]) |> Enum.at(0) - |> Jason.decode!() + |> Utils.JSON.decode!() |> Map.get("params", nil) |> Enum.map(fn param -> {String.to_atom(param["vname"]), param["value"]} end) |> Enum.into(%{}) diff --git a/apps/indexer/lib/indexer/helper.ex b/apps/indexer/lib/indexer/helper.ex index c7acd7c24bc..fefd655c572 100644 --- a/apps/indexer/lib/indexer/helper.ex +++ b/apps/indexer/lib/indexer/helper.ex @@ -802,7 +802,7 @@ defmodule Indexer.Helper do ## Parameters - `url`: The URL which needs to be requested. - - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Jason.decode`. + - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Utils.JSON.decode`. - `attempts_done`: The number of attempts done. Incremented by the function itself. - `avoid_retry_for_statuses`: The list of http error codes we don't need to re-send the request for. E.g. for 404 error we don't try to re-send the request. @@ -821,7 +821,7 @@ defmodule Indexer.Helper do case Tesla.get(client, url, opts: [adapter: [timeout: recv_timeout, transport_opts: [timeout: connect_timeout]]]) do {:ok, %{body: body, status: 200}} -> if response_format == :json do - Jason.decode(body) + Utils.JSON.decode(body) else {:ok, body} end @@ -842,7 +842,7 @@ defmodule Indexer.Helper do # # ## Parameters # - `url`: The URL which needs to be requested. - # - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Jason.decode`. + # - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Utils.JSON.decode`. # - `error`: The error description for logging purposes. # - `attempts_done`: The number of attempts done. Incremented by the function itself. # - `avoid_retry_for_statuses`: The list of http error codes we don't need to re-send the request for. diff --git a/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex b/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex index 01e563bf7ce..0fe8bd1373e 100644 --- a/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex +++ b/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex @@ -111,13 +111,21 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do base_query = from(log in Log, as: :log) base_query + |> Log.join_transaction_query() + |> Log.join_address_mapping_query() + |> Log.join_first_topic_query() |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) |> apply_block_number_condition(max_block_number) |> apply_transaction_hash_condition(transaction_hash) - |> group_by([log], [log.transaction_hash, log.address_hash, log.first_topic, log.second_topic]) + |> group_by([log], [ + as(:transaction).hash, + coalesce(log.address_hash, as(:address_mapping).address_hash), + coalesce(log.first_topic, as(:first_topic).value), + log.second_topic + ]) |> having([log], count(log) > 1) - |> order_by([log], asc: log.transaction_hash) - |> select([log], log.transaction_hash) + |> order_by([log], asc: as(:transaction).hash) + |> select([log], as(:transaction).hash) end defp apply_block_number_condition(query, 0), do: query |> where([log], log.block_number == 0) @@ -134,7 +142,7 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do defp apply_transaction_hash_condition(query, transaction_hash), do: query - |> where([log], log.transaction_hash > ^transaction_hash) + |> where(as(:transaction).hash > ^transaction_hash) @spec run_task([any()]) :: any() defp run_task(batch), do: Task.async(fn -> update_batch(batch) end) @@ -144,14 +152,17 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do %{token_transfers: token_transfers} = base_query - |> where([log], log.transaction_hash in ^batch) + |> Log.join_transaction_query() + |> where(as(:transaction).hash in ^batch) |> join(:left, [log], tt in TokenTransfer, on: - log.transaction_hash == tt.transaction_hash and log.index == tt.log_index and log.block_hash == tt.block_hash + as(:transaction).hash == tt.transaction_hash and log.index == tt.log_index and + as(:transaction).block_hash == tt.block_hash ) - |> where([log, tt], is_nil(tt)) + |> where([log, t, tt], is_nil(tt)) |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) |> Repo.all(timeout: :infinity) + |> Log.prepare_first_topic() |> Enum.map(fn %Log{} = log -> %Log{ log diff --git a/apps/indexer/lib/indexer/supervisor.ex b/apps/indexer/lib/indexer/supervisor.ex index ce4abe299da..a075a941e3d 100644 --- a/apps/indexer/lib/indexer/supervisor.ex +++ b/apps/indexer/lib/indexer/supervisor.ex @@ -57,6 +57,7 @@ defmodule Indexer.Supervisor do TokenCountersUpdater, TokenInstanceImporter, TokenTotalSupplyUpdater, + TokenUIMultiplierUpdater, TokenUpdater, UncleBlock, Withdrawal @@ -270,6 +271,7 @@ defmodule Indexer.Supervisor do {EmptyBlocksSanitizer.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, {PendingTransactionsSanitizer, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, {TokenTotalSupplyUpdater, [[]]}, + {TokenUIMultiplierUpdater, [[]]}, AddressNonceUpdater, # Notifications cleaner diff --git a/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex b/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex index ff92d64ee5f..5058c8fd3e8 100644 --- a/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex +++ b/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex @@ -60,7 +60,7 @@ defmodule Indexer.Transform.Celo.ValidatorEpochPaymentDistributions do {"validatorPayment", "uint256", false, validator_payment}, {"group", "address", true, group_address_bytes}, {"groupPayment", "uint256", false, group_payment} - ]} = Log.find_and_decode(@event_abi, log, log.block_hash) + ]} = Log.find_and_decode(@event_abi, log) {:ok, validator_address} = Hash.Address.cast(validator_address_bytes) {:ok, group_address} = Hash.Address.cast(group_address_bytes) diff --git a/apps/indexer/lib/indexer/transform/token_transfers.ex b/apps/indexer/lib/indexer/transform/token_transfers.ex index a269a42439c..34adca8fbb7 100644 --- a/apps/indexer/lib/indexer/transform/token_transfers.ex +++ b/apps/indexer/lib/indexer/transform/token_transfers.ex @@ -16,8 +16,9 @@ defmodule Indexer.Transform.TokenTransfers do import Explorer.Helper, only: [decode_data: 2, truncate_address_hash: 1] alias Explorer.Chain.{Hash, Token, TokenTransfer} + alias Explorer.Chain.Token.ScaledUIAmount alias Explorer.Repo - alias Indexer.Fetcher.TokenTotalSupplyUpdater + alias Indexer.Fetcher.{TokenTotalSupplyUpdater, TokenUIMultiplierUpdater} @doc """ Returns a list of token transfers given a list of logs. @@ -101,13 +102,21 @@ defmodule Indexer.Transform.TokenTransfers do erc1155_token_transfers.token_transfers ++ erc20_and_erc721_token_transfers.token_transfers ++ weth_transfers.token_transfers - tokens_uniq = sanitize_token_types(rough_tokens_uniq, rough_token_transfers) - token_transfers = sanitize_weth_transfers(tokens_uniq, rough_token_transfers, weth_transfers.token_transfers) + {tokens_uniq, token_types_map} = sanitize_token_types(rough_tokens_uniq, rough_token_transfers) + + token_transfers = + tokens_uniq + |> sanitize_weth_transfers(rough_token_transfers, weth_transfers.token_transfers) + |> sanitize_scaled_ui_amount_transfer_types(token_types_map) if !skip_additional_fetchers? do token_transfers |> filter_tokens_for_supply_update() |> TokenTotalSupplyUpdater.add_tokens() + + logs + |> parse_ui_multiplier_changes() + |> TokenUIMultiplierUpdater.add_changes() end token_transfers_from_logs_uniq = %{ @@ -203,8 +212,22 @@ defmodule Indexer.Transform.TokenTransfers do if token_type_priority(old_type) > token_type_priority(new_type), do: old_type, else: new_type end) - Enum.map(tokens, fn %{contract_address_hash: hash} = token -> - Map.put(token, :type, actual_token_types_map[hash]) + {Enum.map(tokens, fn %{contract_address_hash: hash} = token -> + Map.put(token, :type, actual_token_types_map[hash]) + end), actual_token_types_map} + end + + # `token_transfers.token_type` is denormalized, and for every other type the + # value `do_parse/3` derived from the log is already right. ERC-8056 is the + # exception: its transfers look exactly like ERC-20 ones, so the type resolved + # for the token has to be carried over to them. + defp sanitize_scaled_ui_amount_transfer_types(token_transfers, token_types_map) do + Enum.map(token_transfers, fn token_transfer -> + if token_types_map[token_transfer.token_contract_address_hash] == "ERC-8056" do + %{token_transfer | token_type: "ERC-8056"} + else + token_transfer + end end) end @@ -216,7 +239,7 @@ defmodule Indexer.Transform.TokenTransfers do defp token_type_priority(nil), do: -1 - @token_types_priority_order ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] + @token_types_priority_order ["ERC-20", "ERC-8056", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] defp token_type_priority(token_type) do Enum.find_index(@token_types_priority_order, &(&1 == token_type)) end @@ -693,4 +716,25 @@ defmodule Indexer.Transform.TokenTransfers do |> Enum.map(& &1.token_contract_address_hash) |> Enum.uniq() end + + @doc """ + Returns the ERC-8056 multiplier changes announced in the given logs, as `t:Explorer.Chain.Token.UIMultiplierChange.t/0` parameters. + + This is a candidate filter, not an acceptance: matching the topic says nothing + about the contract, since any contract can emit any topic it likes. Whether a + change is recorded, and whether the token becomes `ERC-8056`, is settled by + `Indexer.Fetcher.TokenUIMultiplierUpdater` once it has checked the ERC-165 + claim — which keeps the block import path free of `eth_call`s. + + Note that `TransferWithUIAmount`, the other event of the standard, is + intentionally not handled by this module: it is emitted *in addition to* the + regular ERC-20 `Transfer`, so parsing it as a transfer of its own would count + every movement of an ERC-8056 token twice. + """ + @spec parse_ui_multiplier_changes([map()]) :: [map()] + def parse_ui_multiplier_changes(logs) do + logs + |> Enum.map(&ScaledUIAmount.parse_ui_multiplier_updated/1) + |> Enum.reject(&is_nil/1) + end end diff --git a/apps/indexer/mix.exs b/apps/indexer/mix.exs index 41466972912..13100f22985 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.3.3", + version: "12.0.0", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs b/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs index fd86c68826c..87bb2a82e59 100644 --- a/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs +++ b/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs @@ -4,7 +4,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do import Mox - alias Explorer.Utility.MissingBlockRange + alias Explorer.Utility.{MassiveBlock, MissingBlockRange} alias Indexer.Block.Catchup.MissingRangesCollector setup :set_mox_global @@ -102,6 +102,37 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do MissingBlockRange.clear_batch(batch) assert [] = MissingBlockRange.get_latest_batch() end + + test "deletes massive blocks out of the finite ranges" do + Application.put_env(:indexer, :block_ranges, "10..20,30..50") + + insert(:block, number: 200_000) + + Enum.each([9, 10, 15, 20, 21, 29, 40, 51], &insert(:massive_block, number: &1)) + + MissingRangesCollector.start_link([]) + Process.sleep(500) + + assert [10, 15, 20, 40] == massive_block_numbers() + end + + test "deletes massive blocks out of the ranges with the trailing latest range" do + stub(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_getBlockByNumber", params: ["latest", false]}], + _options -> + block_response(id, 300) + end) + + Application.put_env(:indexer, :block_ranges, "10..20,100..latest") + + insert(:block, number: 300) + + Enum.each([9, 10, 20, 21, 99, 100, 150, 1000], &insert(:massive_block, number: &1)) + + MissingRangesCollector.start_link([]) + Process.sleep(500) + + assert [10, 20, 100, 150, 1000] == massive_block_numbers() + end end test "parse_block_ranges/1" do @@ -117,6 +148,13 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do {:finite_ranges, [5..25, 30..50, 150..200]} end + defp massive_block_numbers do + MassiveBlock + |> Repo.all() + |> Enum.map(& &1.number) + |> Enum.sort() + end + defp block_response(id, block_number) do {:ok, [ diff --git a/apps/indexer/test/indexer/block/fetcher/receipts_test.exs b/apps/indexer/test/indexer/block/fetcher/receipts_test.exs index 9bc1f4adc38..4517cba2765 100644 --- a/apps/indexer/test/indexer/block/fetcher/receipts_test.exs +++ b/apps/indexer/test/indexer/block/fetcher/receipts_test.exs @@ -28,7 +28,7 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do @tag :no_nethermind @tag :no_geth - test "fetches logs setting their blocks if they're null", %{ + test "fetches logs setting their block numbers and transaction indexes if they're null", %{ block_fetcher: %Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} = block_fetcher } do if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do @@ -54,7 +54,7 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do "logIndex" => "0x0", "topics" => ["0x600bcf04a13e752d1e3670a5a9f1c21177ca2a93c6f5391d4f1298d098097c22"], "transactionHash" => "0x43bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", - "transactionIndex" => "0x0", + "transactionIndex" => nil, "transactionLogIndex" => "0x0" } ], @@ -108,7 +108,7 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do gas: 21000, gas_price: 50_000_000_000_000, hash: "0x43bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", - index: 0, + index: 2, input: "0x", nonce: 0, r: 61_965_845_294_689_009_770_156_372_156_374_760_022_787_886_965_323_743_865_986_648_153_755_601_564_112, @@ -116,7 +116,7 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do to_address_hash: "0x5df9b87991262f6ba471f09758cde1c0fc1de734", v: 28, value: 31337, - transaction_index: 0 + transaction_index: 2 }, %{ block_hash: "0xf7b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", @@ -141,12 +141,12 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do assert Enum.find(logs, fn log -> log[:transaction_hash] == "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" && - log[:block_number] == 37 + log[:block_number] == 37 && log[:transaction_index] == 0 end) assert Enum.find(logs, fn log -> log[:transaction_hash] == "0x43bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" && - log[:block_number] == 46147 + log[:block_number] == 46147 && log[:transaction_index] == 2 end) end end diff --git a/apps/indexer/test/indexer/block/fetcher_test.exs b/apps/indexer/test/indexer/block/fetcher_test.exs index 948172f107f..95da9fbf2db 100644 --- a/apps/indexer/test/indexer/block/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/fetcher_test.exs @@ -678,7 +678,8 @@ defmodule Indexer.Block.FetcherTest do bytes: <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } + }, + transaction_index: 0 } ], transactions: [ @@ -1206,7 +1207,8 @@ defmodule Indexer.Block.FetcherTest do bytes: <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } + }, + transaction_index: 0 }, %Log{ index: 1, @@ -1215,7 +1217,8 @@ defmodule Indexer.Block.FetcherTest do bytes: <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } + }, + transaction_index: 0 } ], transactions: [ diff --git a/apps/indexer/test/indexer/fetcher/arbitrum/workers/new_messages_to_l2_test.exs b/apps/indexer/test/indexer/fetcher/arbitrum/workers/new_messages_to_l2_test.exs new file mode 100644 index 00000000000..0203b84065b --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/arbitrum/workers/new_messages_to_l2_test.exs @@ -0,0 +1,465 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +if Application.get_env(:explorer, :chain_type) == :arbitrum do + defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2Test do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import EthereumJSONRPC, only: [quantity_to_integer: 1, integer_to_quantity: 1] + import Mox + + alias ABI.TypeEncoder + alias EthereumJSONRPC.Arbitrum.Constants.Events, as: ArbitrumEvents + alias Explorer.Chain.Arbitrum.Message, as: ArbitrumMessage + alias Explorer.Repo + alias Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 + + @bridge_address "0xa723c008e76e379c55599d2e4d93879beafda790" + + setup :verify_on_exit! + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + # Builds the fetcher state accepted by `check_missing_origination/1`. + # + # `end_message_id` and `missed_message_ids_range` are chosen so that the + # message-ID range checked by `check_missing_origination/1` always covers + # `end_message_id` itself, keeping every scenario's setup self-contained. + defp build_state(json_rpc_named_arguments, opts) do + end_message_id = Keyword.fetch!(opts, :end_message_id) + rpc_block_range = Keyword.fetch!(opts, :rpc_block_range) + + %{ + config: %{ + json_l1_rpc_named_arguments: json_rpc_named_arguments, + l1_rpc_chunk_size: 50, + l1_rpc_block_range: rpc_block_range, + l1_bridge_address: @bridge_address, + l1_rollup_init_block: 0, + missed_message_ids_range: 1000 + }, + task_data: %{ + check_missing_origination: %{ + end_message_id: end_message_id, + earliest_discovered_message_id: 0, + safe_l1_block: 999_999_999 + } + }, + completed_tasks: %{} + } + end + + # Inserts a `:to_l2` message that has completion information but lacks all + # four origination fields - the shape selected by + # `messages_to_l2_completed_but_originating_info_missed/2`. The factory + # already defaults `status` to `:relayed`; `opts` extends or overrides the + # attributes (e.g. `completion_transaction_hash`). + defp insert_message_missing_origination(message_id, opts \\ []) do + insert( + :arbitrum_message, + Keyword.merge( + [ + message_id: message_id, + originator_address: nil, + originating_transaction_hash: nil, + origination_timestamp: nil, + originating_transaction_block_number: nil + ], + opts + ) + ) + end + + # Mocks both the `eth_getLogs` and `eth_getTransactionByHash` requests the + # discovery loop can issue, in a single `expect/4` with as many clauses as + # request shapes: since a single chunk's `eth_getTransactionByHash` lookups + # are interleaved between two chunks' `eth_getLogs` calls, separate `expect` + # calls (each queued independently in call order, not dispatched by pattern) + # would need to predict that interleaving; one closure with multiple clauses + # dispatches purely on each call's own shape instead. + # + # Every `eth_getLogs` request's `fromBlock`/`toBlock` (as integers) is also + # sent to the calling test process; the whole call path runs synchronously + # in that same process, so the messages land in its mailbox in call order + # and can be drained with `drain_get_logs_ranges/0` right after. + # + # An `eth_getLogs` range absent from `get_logs_responses` raises `KeyError`, + # so an unexpected chunk boundary fails the test loudly instead of being + # silently answered with no logs. A transaction hash mapped to `nil` in + # `transaction_responses` simulates a successful-but-null + # `eth_getTransactionByHash` response (the transaction is unknown to the + # node). + # + # By default every map entry is expected to be requested exactly once; + # scenarios that legitimately repeat requests (e.g. re-scanning overlapping + # ranges for a retried message) pass the exact call count as + # `expected_calls`. + defp expect_rpc(get_logs_responses, transaction_responses \\ %{}, expected_calls \\ nil) do + test_pid = self() + total_calls = expected_calls || map_size(get_logs_responses) + map_size(transaction_responses) + + expect(EthereumJSONRPC.Mox, :json_rpc, total_calls, fn + %{method: "eth_getLogs", params: [%{fromBlock: from_block_quantity, toBlock: to_block_quantity}]}, _options -> + from_block = quantity_to_integer(from_block_quantity) + to_block = quantity_to_integer(to_block_quantity) + send(test_pid, {:eth_get_logs_range, from_block, to_block}) + + {:ok, Map.fetch!(get_logs_responses, {from_block, to_block})} + + [%{id: 0, jsonrpc: "2.0", method: "eth_getTransactionByHash", params: [transaction_hash]}], _options -> + case Map.fetch!(transaction_responses, transaction_hash) do + nil -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: nil}]} + + from_address -> + {:ok, [%{id: 0, jsonrpc: "2.0", result: %{"hash" => transaction_hash, "from" => from_address}}]} + end + end) + end + + defp drain_get_logs_ranges do + receive do + {:eth_get_logs_range, from_block, to_block} -> [{from_block, to_block} | drain_get_logs_ranges()] + after + 0 -> [] + end + end + + # Builds a raw `MessageDelivered` event log (as it would arrive from an + # `eth_getLogs` JSON-RPC response) for the given message ID, L1 block number, + # originating transaction hash, and unix timestamp. + defp build_message_delivered_log(message_id, block_number, transaction_hash, timestamp) do + data = + "0x" <> + ([<<0::160>>, 3, <<0::160>>, <<0::256>>, 0, timestamp] + |> TypeEncoder.encode(%ABI.FunctionSelector{ + function: nil, + types: ArbitrumEvents.message_delivered_unindexed_params() + }) + |> Base.encode16(case: :lower)) + + message_id_topic = + "0x" <> + (message_id + |> Integer.to_string(16) + |> String.downcase() + |> String.pad_leading(64, "0")) + + %{ + "address" => @bridge_address, + "blockHash" => "0x" <> String.duplicate("0", 64), + "blockNumber" => integer_to_quantity(block_number), + "data" => data, + "logIndex" => "0x0", + "removed" => false, + "topics" => [ArbitrumEvents.message_delivered(), message_id_topic], + "transactionHash" => transaction_hash, + "transactionIndex" => "0x0" + } + end + + describe "check_missing_origination/1" do + test "splits a gap wider than l1_rpc_block_range into exact, contiguous, non-overlapping chunks (regression)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + insert(:arbitrum_message, message_id: 10, originating_transaction_block_number: 100) + insert(:arbitrum_message, message_id: 30, originating_transaction_block_number: 135) + + insert_message_missing_origination(20) + + expect_rpc(%{ + {100, 109} => [], + {110, 119} => [], + {120, 129} => [], + {130, 135} => [] + }) + + state = build_state(json_rpc_named_arguments, end_message_id: 20, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert drain_get_logs_ranges() == [{100, 109}, {110, 119}, {120, 129}, {130, 135}] + end + + test "issues exactly one request when the gap does not exceed l1_rpc_block_range", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 40, originating_transaction_block_number: 200) + insert(:arbitrum_message, message_id: 42, originating_transaction_block_number: 205) + + insert_message_missing_origination(41) + + expect_rpc(%{{200, 205} => []}) + + state = build_state(json_rpc_named_arguments, end_message_id: 41, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert drain_get_logs_ranges() == [{200, 205}] + end + + test "issues exactly one request for a single-block range", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 50, originating_transaction_block_number: 300) + insert(:arbitrum_message, message_id: 52, originating_transaction_block_number: 300) + + insert_message_missing_origination(51) + + expect_rpc(%{{300, 300} => []}) + + state = build_state(json_rpc_named_arguments, end_message_id: 51, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert drain_get_logs_ranges() == [{300, 300}] + end + + test "imports a message whose log falls in a later chunk, preserving completion data and :relayed status", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 60, originating_transaction_block_number: 400) + insert(:arbitrum_message, message_id: 62, originating_transaction_block_number: 435) + + completion_transaction_hash = transaction_hash() + + insert_message_missing_origination(61, completion_transaction_hash: completion_transaction_hash) + + transaction_hash_string = to_string(transaction_hash()) + originator_address_string = to_string(address_hash()) + timestamp = 1_700_000_000 + + log = build_message_delivered_log(61, 425, transaction_hash_string, timestamp) + + expect_rpc( + %{ + {400, 409} => [], + {410, 419} => [], + {420, 429} => [log], + {430, 435} => [] + }, + %{transaction_hash_string => originator_address_string} + ) + + state = build_state(json_rpc_named_arguments, end_message_id: 61, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert drain_get_logs_ranges() == [{400, 409}, {410, 419}, {420, 429}, {430, 435}] + + message = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 61) + + assert to_string(message.originator_address) == String.downcase(originator_address_string) + assert to_string(message.originating_transaction_hash) == String.downcase(transaction_hash_string) + assert DateTime.to_unix(message.origination_timestamp) == timestamp + assert message.originating_transaction_block_number == 425 + assert to_string(message.completion_transaction_hash) == to_string(completion_transaction_hash) + assert message.status == :relayed + end + + test "applies the message-ID filter per chunk, excluding an out-of-bounds neighbour found in another chunk", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 70, originating_transaction_block_number: 500) + insert(:arbitrum_message, message_id: 72, originating_transaction_block_number: 535) + + insert_message_missing_origination(71) + + neighbour_transaction_hash_string = to_string(transaction_hash()) + neighbour_originator_address_string = to_string(address_hash()) + target_transaction_hash_string = to_string(transaction_hash()) + target_originator_address_string = to_string(address_hash()) + timestamp = 1_700_000_000 + + # Out-of-bounds neighbour: shares message ID 72 with the higher bound + # message, which already has origination information, so it must be + # filtered out (the filter is exclusive of the bounds). + neighbour_log = build_message_delivered_log(72, 505, neighbour_transaction_hash_string, timestamp) + target_log = build_message_delivered_log(71, 525, target_transaction_hash_string, timestamp) + + # Transaction enrichment happens before the ID filter is applied, so both + # logs' `eth_getTransactionByHash` lookups are issued even though the + # neighbour is later filtered out. + expect_rpc( + %{ + {500, 509} => [neighbour_log], + {510, 519} => [], + {520, 529} => [target_log], + {530, 535} => [] + }, + %{ + neighbour_transaction_hash_string => neighbour_originator_address_string, + target_transaction_hash_string => target_originator_address_string + } + ) + + state = build_state(json_rpc_named_arguments, end_message_id: 71, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + target_message = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 71) + assert to_string(target_message.originating_transaction_hash) == String.downcase(target_transaction_hash_string) + assert target_message.originating_transaction_block_number == 525 + + neighbour_message = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 72) + assert neighbour_message.originating_transaction_block_number == 535 + end + + test "advances the cursor and completion flag as usual when all chunks return no logs", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 80, originating_transaction_block_number: 600) + insert(:arbitrum_message, message_id: 82, originating_transaction_block_number: 635) + + insert_message_missing_origination(81) + + expect_rpc(%{ + {600, 609} => [], + {610, 619} => [], + {620, 629} => [], + {630, 635} => [] + }) + + state = build_state(json_rpc_named_arguments, end_message_id: 81, rpc_block_range: 10) + + assert {:ok, updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert updated_state.task_data.check_missing_origination.end_message_id == -1 + assert updated_state.completed_tasks.check_missing_origination == true + + assert Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 81).originating_transaction_block_number == + nil + end + + test "skips a message already back-filled by the same loop's processing of a higher ID (regression)", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + insert(:arbitrum_message, message_id: 90, originating_transaction_block_number: 700) + insert(:arbitrum_message, message_id: 93, originating_transaction_block_number: 735) + + insert_message_missing_origination(91) + insert_message_missing_origination(92) + + transaction_hash_91_string = to_string(transaction_hash()) + originator_address_91_string = to_string(address_hash()) + transaction_hash_92_string = to_string(transaction_hash()) + originator_address_92_string = to_string(address_hash()) + timestamp = 1_700_000_000 + + # Both logs fall within the L1 block range scanned while processing the + # higher missing message ID (92); the message-ID filter for that scan + # allows both 91 and 92 through since it is bounded by the indexed + # neighbours 90 and 93. + log_91 = build_message_delivered_log(91, 725, transaction_hash_91_string, timestamp) + log_92 = build_message_delivered_log(92, 715, transaction_hash_92_string, timestamp) + + expect_rpc( + %{ + {700, 709} => [], + {710, 719} => [log_92], + {720, 729} => [log_91], + {730, 735} => [] + }, + %{ + transaction_hash_91_string => originator_address_91_string, + transaction_hash_92_string => originator_address_92_string + } + ) + + state = build_state(json_rpc_named_arguments, end_message_id: 92, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + # Only the chunks scanned while processing message ID 92 are observed; + # processing message ID 91 afterwards issues no `eth_getLogs` request at + # all because the skip check finds its origination already filled in. + assert drain_get_logs_ranges() == [{700, 709}, {710, 719}, {720, 729}, {730, 735}] + + message_91 = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 91) + assert to_string(message_91.originator_address) == String.downcase(originator_address_91_string) + assert to_string(message_91.originating_transaction_hash) == String.downcase(transaction_hash_91_string) + assert message_91.originating_transaction_block_number == 725 + + message_92 = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 92) + assert to_string(message_92.originator_address) == String.downcase(originator_address_92_string) + assert to_string(message_92.originating_transaction_hash) == String.downcase(transaction_hash_92_string) + assert message_92.originating_transaction_block_number == 715 + end + + test "does not import a message whose originating transaction lookup returns null and retries it in a later iteration (regression)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + insert(:arbitrum_message, message_id: 100, originating_transaction_block_number: 800) + insert(:arbitrum_message, message_id: 103, originating_transaction_block_number: 835) + + insert_message_missing_origination(101) + insert_message_missing_origination(102) + + transaction_hash_101_string = to_string(transaction_hash()) + transaction_hash_102_string = to_string(transaction_hash()) + originator_address_102_string = to_string(address_hash()) + timestamp = 1_700_000_000 + + log_101 = build_message_delivered_log(101, 815, transaction_hash_101_string, timestamp) + log_102 = build_message_delivered_log(102, 829, transaction_hash_102_string, timestamp) + + # Message 101's `eth_getTransactionByHash` responds successfully but with + # a null result (mapped to `nil` below), so its originator address stays + # unknown. The message must not be imported half-filled while processing + # message ID 102 - otherwise the skip check (keyed on the originating + # block number) would consider it done and it would never be re-processed. + # + # Processing 102 scans 800..835 (4 chunks, bounded by messages 100 and + # 103) and imports only 102. Processing 101 afterwards is NOT skipped: + # it re-scans 800..829 (3 chunks, the higher bound now comes from the + # just-imported 102) and repeats both transaction lookups (101's log in + # chunk {810, 819}, 102's log in chunk {820, 829} - the latter is then + # dropped by the message-ID filter). Hence 7 `eth_getLogs` calls over 4 + # distinct ranges and 4 `eth_getTransactionByHash` calls over 2 hashes, + # so the expected call count (11) is passed explicitly. + expect_rpc( + %{ + {800, 809} => [], + {810, 819} => [log_101], + {820, 829} => [log_102], + {830, 835} => [] + }, + %{ + transaction_hash_101_string => nil, + transaction_hash_102_string => originator_address_102_string + }, + 11 + ) + + state = build_state(json_rpc_named_arguments, end_message_id: 102, rpc_block_range: 10) + + assert {:ok, _updated_state} = NewMessagesToL2.check_missing_origination(state) + + assert drain_get_logs_ranges() == [ + {800, 809}, + {810, 819}, + {820, 829}, + {830, 835}, + {800, 809}, + {810, 819}, + {820, 829} + ] + + # Message 101 stays entirely unimported - no half-filled record with the + # block number set but the originator address empty - so it remains + # selectable for a retry on a later pass. + message_101 = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 101) + assert message_101.originator_address == nil + assert message_101.originating_transaction_hash == nil + assert message_101.origination_timestamp == nil + assert message_101.originating_transaction_block_number == nil + assert message_101.status == :relayed + + message_102 = Repo.get_by(ArbitrumMessage, direction: :to_l2, message_id: 102) + assert to_string(message_102.originator_address) == String.downcase(originator_address_102_string) + assert message_102.originating_transaction_block_number == 829 + end + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs index 13c993a0dfc..a2ad7461972 100644 --- a/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs @@ -164,7 +164,7 @@ defmodule Indexer.Fetcher.MultichainSearchDb.BalancesExportQueueTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -332,9 +332,9 @@ defmodule Indexer.Fetcher.MultichainSearchDb.BalancesExportQueueTest do Tesla.Test.expect_tesla_call( times: 1, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) @@ -351,12 +351,12 @@ defmodule Indexer.Fetcher.MultichainSearchDb.BalancesExportQueueTest do Tesla.Test.expect_tesla_call( times: 2, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, %{"address_coin_balances" => [%{"address_hash" => ^address_hash_string}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs index 9b5ed4e5021..c2f8292b6c1 100644 --- a/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs @@ -124,7 +124,7 @@ defmodule Indexer.Fetcher.MultichainSearchDb.CountersExportQueueTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -194,7 +194,7 @@ defmodule Indexer.Fetcher.MultichainSearchDb.CountersExportQueueTest do Tesla.Test.expect_tesla_call( times: 1, returns: fn %{url: "http://localhost:1234/api/v1/import:batch"}, _opts -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end ) @@ -207,12 +207,12 @@ defmodule Indexer.Fetcher.MultichainSearchDb.CountersExportQueueTest do Tesla.Test.expect_tesla_call( times: times, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, %{"counters" => [%{"timestamp" => ^failed_timestamp_string}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs index 7702ddedbfc..2f63cb849e4 100644 --- a/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs @@ -111,7 +111,7 @@ defmodule Indexer.Fetcher.MultichainSearchDb.MainExportQueueTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -159,15 +159,15 @@ defmodule Indexer.Fetcher.MultichainSearchDb.MainExportQueueTest do Tesla.Test.expect_tesla_call( times: 3, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, %{"block_ranges" => [%{"max_block_number" => _, "min_block_number" => _}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} {:ok, %{"addresses" => [%{"hash" => ^address_2_hash_string}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) @@ -246,12 +246,12 @@ defmodule Indexer.Fetcher.MultichainSearchDb.MainExportQueueTest do Tesla.Test.expect_tesla_call( times: 2, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, %{"block_ranges" => [%{"max_block_number" => _, "min_block_number" => _}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs index 629689cd2fe..a264880f766 100644 --- a/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs @@ -46,9 +46,9 @@ defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueueTest do token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) token_info_item_3 = insert(:multichain_search_db_export_token_info_queue) - {:ok, token_info_item_1_data} = Jason.decode(Jason.encode!(token_info_item_1.data)) - {:ok, token_info_item_2_data} = Jason.decode(Jason.encode!(token_info_item_2.data)) - {:ok, token_info_item_3_data} = Jason.decode(Jason.encode!(token_info_item_3.data)) + {:ok, token_info_item_1_data} = Utils.JSON.decode(Utils.JSON.encode!(token_info_item_1.data)) + {:ok, token_info_item_2_data} = Utils.JSON.decode(Utils.JSON.encode!(token_info_item_2.data)) + {:ok, token_info_item_3_data} = Utils.JSON.decode(Utils.JSON.encode!(token_info_item_3.data)) reducer = fn data, acc -> [data | acc] end @@ -118,7 +118,7 @@ defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueueTest do Conn.resp( conn, 200, - Jason.encode!(%{"status" => "ok"}) + Utils.JSON.encode!(%{"status" => "ok"}) ) end) @@ -193,9 +193,9 @@ defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueueTest do Tesla.Test.expect_tesla_call( times: 4, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) @@ -212,12 +212,12 @@ defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueueTest do Tesla.Test.expect_tesla_call( times: 5, returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> - case Jason.decode(body) do + case Utils.JSON.decode(body) do {:ok, %{"tokens" => [%{"address_hash" => ^address_4_hash_string}]}} -> - {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + {:ok, %Tesla.Env{status: 500, body: Utils.JSON.encode!(%{"code" => 0, "message" => "Error"})}} _ -> - {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + {:ok, %Tesla.Env{status: 200, body: Utils.JSON.encode!(%{"status" => "ok"})}} end end ) diff --git a/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs index 35cb34a4634..7d4545dc02d 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs @@ -400,6 +400,59 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransactionTest do assert result |> Enum.filter(&(&1.block_number == 2)) |> Enum.count() == 1 end + test "fetch_by_address/2 (contract creation)" do + address = insert(:address) + address_hash_str = to_string(address.hash) + id_to_hash = insert(:address_id_to_address_hash, address: address) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 1, + count_tos: 1, + count_froms: 0 + ) + + expect(EthereumJSONRPC.Mox, :json_rpc, 2, fn [%{id: id, params: ["0x1", _]}], _ -> + {:ok, + [ + %{ + id: id, + result: [ + %{ + "result" => %{ + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0x106f5", + "gasUsed" => "0x106f5", + "input" => "0x6080", + "output" => "0x6080", + "to" => address_hash_str, + "type" => "CREATE", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5c" + } + ] + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, + tracer: "call_tracer", + debug_trace_timeout: "5s", + block_traceable?: true + ) + + opts = [paging_options: %PagingOptions{page_size: 1}] + + assert [%InternalTransaction{type: :create, to_address_hash: to_address_hash}] = + InternalTransactionOnDemand.fetch_by_address(address.hash, Keyword.put(opts, :direction, :to)) + + assert to_address_hash == address.hash + + assert [] = + InternalTransactionOnDemand.fetch_by_address(address.hash, Keyword.put(opts, :direction, :to_address_hash)) + end + test "fetch_by_address/2 chunks block trace requests by blocks_batch_size" do Application.put_env(:indexer, Indexer.Fetcher.OnDemand.InternalTransaction, blocks_batch_size: 1) @@ -609,7 +662,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransactionTest do %{ created_contract_address_hash: created_contract_address_hash, from_address_hash: from_address_hash, - to_address_hash: nil, + to_address_hash: to_address_hash, block_timestamp: ^block_timestamp, transaction_hash: ^transaction_hash, error: nil @@ -617,6 +670,7 @@ defmodule Indexer.Fetcher.OnDemand.InternalTransactionTest do ] = InternalTransactionOnDemand.etherscan_fetch_by_transaction(transaction, %{}) assert to_string(created_contract_address_hash) == "0x205a6b72ce16736c9d87172568a9c0cb9304de0d" + assert to_string(to_address_hash) == "0x205a6b72ce16736c9d87172568a9c0cb9304de0d" assert to_string(from_address_hash) == "0x117b358218da5a4f647072ddb50ded038ed63d17" end end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs index 9573d40ddf0..b9e9c0c258c 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs @@ -62,7 +62,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(metadata) + body: Utils.JSON.encode!(metadata) }} end ) @@ -122,7 +122,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do {:ok, %Tesla.Env{ status: 200, - body: Jason.encode!(metadata) + body: Utils.JSON.encode!(metadata) }} end ) diff --git a/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs index 277dfecbf1a..30c10a117d3 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs @@ -174,7 +174,16 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do type: "ERC-721" ) - Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, base_uri_retry?: true) + # Merge into (rather than replace) the existing config, otherwise sibling settings such + # as `host_filtering_enabled?: false` are wiped for every later test in this VM. + initial_env = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, []) + on_exit(fn -> Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, initial_env) end) + + Application.put_env( + :indexer, + Indexer.Fetcher.TokenInstance.Helper, + Keyword.put(initial_env, :base_uri_retry?, true) + ) assert [ %Instance{ @@ -184,8 +193,6 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do } ] = Helper.batch_fetch_instances([{token.contract_address_hash, 5_710_384_980_761_197_878}]) - - Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, base_uri_retry?: false) end # https://github.com/blockscout/blockscout/issues/9696 @@ -674,4 +681,136 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do ]} end) end + + describe "determine_media_type/1" do + test "returns empty string for nil" do + assert Helper.determine_media_type(nil) == "" + end + + test "returns MIME type for data:image URI" do + assert Helper.determine_media_type("data:image/png;base64,abc") == "image" + end + + test "returns MIME type for data:video URI" do + assert Helper.determine_media_type("data:video/mp4;base64,abc") == "video" + end + + test "returns empty string for unsupported data URI" do + assert Helper.determine_media_type("data:application/json;base64,abc") == "" + end + + test "determines MIME type from file extension", %{bypass: bypass} do + assert Helper.determine_media_type("https://example.com/image.png") == "image/png" + end + + test "determines MIME type from file extension for svg" do + assert Helper.determine_media_type("https://example.com/image.svg") == "image/svg+xml" + end + + test "determines MIME type from file extension for mp4" do + assert Helper.determine_media_type("https://example.com/video.mp4") == "video/mp4" + end + + test "determines MIME type from file extension for html" do + assert Helper.determine_media_type("https://example.com/page.html") == "text/html" + end + + test "makes HEAD request when URL has no extension", %{bypass: bypass} do + Bypass.expect_once(bypass, "HEAD", "/media", fn conn -> + conn + |> Conn.put_resp_header("content-type", "image/jpeg") + |> Conn.resp(200, "") + end) + + assert Helper.determine_media_type("http://localhost:#{bypass.port}/media") == "image/jpeg" + end + + test "returns empty string when HEAD request fails", %{bypass: bypass} do + Bypass.expect_once(bypass, "HEAD", "/media", fn conn -> + Conn.resp(conn, 404, "") + end) + + assert Helper.determine_media_type("http://localhost:#{bypass.port}/media") == "" + end + + test "resolves ipfs:// URL via gateway and determines type" do + assert Helper.determine_media_type("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/image.png") == + "image/png" + end + end + + describe "fetch_media_types/1" do + test "returns error for nil metadata" do + instance = %Instance{metadata: nil, image_type: nil, animation_type: nil} + assert {:error, :metadata_not_found} = Helper.fetch_media_types(instance) + end + + test "returns error when both types already fetched" do + instance = %Instance{ + metadata: %{"image" => "https://example.com/img.png"}, + image_type: "image/png", + animation_type: "" + } + + assert {:error, :already_fetched} = Helper.fetch_media_types(instance) + end + + test "fetches media types and saves to DB", %{bypass: bypass} do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{ + "image_url" => "https://example.com/image.png", + "animation_url" => "http://localhost:#{bypass.port}/animation" + }, + image_type: nil, + animation_type: nil + ) + + Bypass.expect_once(bypass, "HEAD", "/animation", fn conn -> + conn + |> Conn.put_resp_header("content-type", "video/mp4") + |> Conn.resp(200, "") + end) + + instance = + Repo.get_by!(Instance, + token_id: instance.token_id, + token_contract_address_hash: instance.token_contract_address_hash + ) + + assert {:ok, %{image_type: "image/png", animation_type: "video/mp4"}} = Helper.fetch_media_types(instance) + + updated = + Repo.get_by!(Instance, + token_id: instance.token_id, + token_contract_address_hash: instance.token_contract_address_hash + ) + + assert updated.image_type == "image/png" + assert updated.animation_type == "video/mp4" + end + + test "sets empty string for missing image/animation URLs", %{bypass: _bypass} do + token = insert(:token, type: "ERC-721") + + instance = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + metadata: %{"name" => "Test NFT"}, + image_type: nil, + animation_type: nil + ) + + instance = + Repo.get_by!(Instance, + token_id: instance.token_id, + token_contract_address_hash: instance.token_contract_address_hash + ) + + assert {:ok, %{image_type: "", animation_type: ""}} = Helper.fetch_media_types(instance) + end + end end diff --git a/apps/indexer/test/indexer/fetcher/token_ui_multiplier_updater_test.exs b/apps/indexer/test/indexer/fetcher/token_ui_multiplier_updater_test.exs new file mode 100644 index 00000000000..2dc439d0bc2 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/token_ui_multiplier_updater_test.exs @@ -0,0 +1,256 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenUIMultiplierUpdaterTest do + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue + alias Explorer.Chain.Token.UIMultiplierChange + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Indexer.Fetcher.TokenUIMultiplierUpdater + + @abi_true "0x0000000000000000000000000000000000000000000000000000000000000001" + @abi_false "0x0000000000000000000000000000000000000000000000000000000000000000" + + setup :verify_on_exit! + setup :set_mox_global + + @one Decimal.new("1000000000000000000") + @two Decimal.new("2000000000000000000") + @four Decimal.new("4000000000000000000") + + # supportsInterface(0xa60bf13d) == true; the getters revert, which keeps these + # tests about the history rather than the multiplier columns + defp stub_json_rpc(supports_interface?) do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn %{id: id, params: [%{data: data}, _]} -> + cond do + String.starts_with?(data, "0x01ffc9a7") -> + %{id: id, result: if(supports_interface?, do: @abi_true, else: @abi_false)} + + true -> + %{id: id, error: %{code: -32015, data: "something", message: "execution reverted"}, jsonrpc: "2.0"} + end + end)} + end) + end + + # supportsInterface(0xa60bf13d) == true and the getters answer: the multiplier + # is 2.0 and becomes 4.0 at `effective_at` + defp stub_json_rpc_with_getters(effective_at) do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + {:ok, + Enum.map(requests, fn %{id: id, params: [%{data: data}, _]} -> + cond do + String.starts_with?(data, "0x01ffc9a7") -> %{id: id, result: @abi_true} + String.starts_with?(data, "0xa60bf13d") -> %{id: id, result: abi_uint(@two)} + String.starts_with?(data, "0xdc767007") -> %{id: id, result: abi_uint(@four)} + String.starts_with?(data, "0x97a4064f") -> %{id: id, result: abi_uint(DateTime.to_unix(effective_at))} + end + end)} + end) + end + + defp abi_uint(%Decimal{} = value), do: value |> Decimal.to_integer() |> abi_uint() + + defp abi_uint(value) when is_integer(value), + do: "0x" <> (value |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(64, "0")) + + defp enable_multichain_search do + initial = Application.get_env(:explorer, MultichainSearch) || [] + + Application.put_env( + :explorer, + MultichainSearch, + Keyword.merge(initial, service_url: "http://localhost:1234", api_key: "12345", token_info_chunk_size: 1000) + ) + + on_exit(fn -> Application.put_env(:explorer, MultichainSearch, initial) end) + end + + setup do + stub_json_rpc(true) + + :ok + end + + # The shape `Indexer.Transform.TokenTransfers.parse_ui_multiplier_changes/1` + # produces: both hashes are still the strings taken off the log. + defp change_params(token, block_number, log_index, old, new, effective_at) do + block = insert(:block, number: block_number) + + %{ + token_contract_address_hash: to_string(token.contract_address.hash), + block_number: block_number, + block_hash: to_string(block.hash), + log_index: log_index, + old_multiplier: old, + new_multiplier: new, + effective_at: effective_at + } + end + + defp run_update(changes) do + # the updater is registered by name, so a test driving it twice reuses it + pid = Process.whereis(TokenUIMultiplierUpdater) || start_supervised!(TokenUIMultiplierUpdater) + + TokenUIMultiplierUpdater.add_changes(changes) + send(pid, :update) + + # a `:sys` call is answered only after the queued cast and info messages + # have been handled, which makes the asynchronous updater testable + :sys.get_state(pid) + + pid + end + + defp recorded_changes(token) do + UIMultiplierChange + |> Repo.all() + |> Enum.filter(&(&1.token_contract_address_hash == token.contract_address.hash)) + |> Enum.sort_by(&{&1.block_number, &1.log_index}) + end + + describe "add_changes/1" do + test "records the announced change" do + token = insert(:token) + + run_update([change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z])]) + + assert [change] = recorded_changes(token) + assert change.block_number == 200 + assert change.log_index == 0 + assert Decimal.equal?(change.old_multiplier, @two) + assert Decimal.equal?(change.new_multiplier, @four) + end + + test "converges when the same change arrives twice" do + token = insert(:token) + change = change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z]) + + run_update([change]) + run_update([%{change | new_multiplier: @one}]) + + assert [only_change] = recorded_changes(token) + assert Decimal.equal?(only_change.new_multiplier, @one) + end + + test "sends nothing to the updater when no log announced a change" do + # the common case: a block with ordinary transfers leaves the state empty + # and `update_token/2` is never reached + assert TokenUIMultiplierUpdater.add_changes([]) == :ok + end + + test "keeps the change until the token it belongs to is indexed" do + # the block import inserts the token together with the transfers of its + # block, but this updater runs on its own schedule and can get there first + token = build(:token) + change = change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z]) + + pid = run_update([change]) + + assert recorded_changes(token) == [] + assert %{^change => 1} = :sys.get_state(pid) + + insert(:token, contract_address: token.contract_address) + run_update([]) + + assert [%{block_number: 200}] = recorded_changes(token) + assert :sys.get_state(pid) == %{} + end + + test "gives up on a change whose token never gets indexed" do + token = build(:token) + change = change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z]) + + pid = run_update([change]) + + state = + Enum.reduce(1..12, :sys.get_state(pid), fn _attempt, _acc -> + send(pid, :update) + :sys.get_state(pid) + end) + + assert state == %{} + assert recorded_changes(token) == [] + end + + test "records nothing for a contract that does not claim the ERC-165 interface" do + stub_json_rpc(false) + + token = insert(:token) + + run_update([change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z])]) + + assert recorded_changes(token) == [] + assert %{type: "ERC-20"} = Repo.reload(token) + end + + test "keeps the history of a token whose metadata is skipped" do + token = insert(:token, skip_metadata: true) + + run_update([change_params(token, 200, 0, @two, @four, ~U[2026-09-01 00:00:00.000000Z])]) + + assert [%{block_number: 200}] = recorded_changes(token) + end + + test "sends the refreshed token metadata to the multichain service" do + effective_at = ~U[2026-09-01 00:00:00.000000Z] + stub_json_rpc_with_getters(effective_at) + enable_multichain_search() + + token = insert(:token) + + run_update([change_params(token, 200, 0, @two, @four, effective_at)]) + + assert [%TokenInfoExportQueue{address_hash: address_hash, data_type: :metadata, data: data}] = + Repo.all(TokenInfoExportQueue) + + assert address_hash == token.contract_address_hash + + assert data == %{ + "token_type" => "ERC-8056", + "name" => token.name, + "symbol" => token.symbol, + "decimals" => 18, + "total_supply" => "1000000000", + "icon_url" => token.icon_url, + "ui_multiplier" => "2000000000000000000", + "new_ui_multiplier" => "4000000000000000000", + "ui_multiplier_effective_at" => "2026-09-01T00:00:00Z" + } + end + + test "sends nothing to the multichain service when it is disabled" do + effective_at = ~U[2026-09-01 00:00:00.000000Z] + stub_json_rpc_with_getters(effective_at) + + token = insert(:token) + + run_update([change_params(token, 200, 0, @two, @four, effective_at)]) + + assert %{type: "ERC-8056", new_ui_multiplier: new_ui_multiplier} = Repo.reload(token) + assert Decimal.equal?(new_ui_multiplier, @four) + assert Repo.all(TokenInfoExportQueue) == [] + end + + test "gives up on a change whose contract hash cannot be parsed" do + change = + %{ + token_contract_address_hash: "not a hash", + block_number: 200, + block_hash: to_string(insert(:block, number: 200).hash), + log_index: 0, + old_multiplier: @two, + new_multiplier: @four, + effective_at: ~U[2026-09-01 00:00:00.000000Z] + } + + pid = run_update([change]) + + # retrying it forever would never succeed + assert :sys.get_state(pid) == %{} + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/token_updater_test.exs b/apps/indexer/test/indexer/fetcher/token_updater_test.exs index 8943f33e6a8..619660be3fc 100644 --- a/apps/indexer/test/indexer/fetcher/token_updater_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_updater_test.exs @@ -5,7 +5,9 @@ defmodule Indexer.Fetcher.TokenUpdaterTest do import Mox alias Explorer.Chain + alias Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue alias Explorer.Chain.Token + alias Explorer.MicroserviceInterfaces.MultichainSearch alias Indexer.Fetcher.TokenUpdater setup :verify_on_exit! @@ -52,6 +54,15 @@ defmodule Indexer.Fetcher.TokenUpdaterTest do id: id, result: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" } + + # the ERC-165 probe, read along with the base metadata of an ERC-20 + # token and reverting on one that does not implement ERC-8056 + %{id: id, method: "eth_call", params: [%{data: _, to: _}, "latest"]} -> + %{ + id: id, + error: %{code: -32015, data: "something", message: "some error"}, + jsonrpc: "2.0" + } end)} end ) @@ -84,5 +95,54 @@ defmodule Indexer.Fetcher.TokenUpdaterTest do cataloged: true }} = Chain.token_from_address_hash(token.contract_address_hash) end + + test "exports the ERC-8056 multiplier to the multichain service along with the metadata" do + initial = Application.get_env(:explorer, MultichainSearch) || [] + + Application.put_env( + :explorer, + MultichainSearch, + Keyword.merge(initial, service_url: "http://localhost:1234", api_key: "12345", token_info_chunk_size: 1000) + ) + + on_exit(fn -> Application.put_env(:explorer, MultichainSearch, initial) end) + + plain_token = insert(:token) + # the row still says ERC-20: the metadata batch is what finds the ERC-8056 interface + scaled_token = insert(:token) + + TokenUpdater.update_metadata([ + %{name: "Plain", contract_address_hash: to_string(plain_token.contract_address_hash)}, + %{ + name: "Scaled", + type: "ERC-8056", + ui_multiplier: 2_000_000_000_000_000_000, + new_ui_multiplier: 4_000_000_000_000_000_000, + ui_multiplier_effective_at: ~U[2026-09-01 00:00:00.000000Z], + contract_address_hash: to_string(scaled_token.contract_address_hash) + } + ]) + + queue = TokenInfoExportQueue |> Repo.all() |> Map.new(&{&1.address_hash, {&1.data_type, &1.data}}) + + assert queue[plain_token.contract_address_hash] == + {:metadata, %{"token_type" => "ERC-20", "name" => "Plain", "icon_url" => plain_token.icon_url}} + + assert queue[scaled_token.contract_address_hash] == + {:metadata, + %{ + "token_type" => "ERC-8056", + "name" => "Scaled", + "icon_url" => scaled_token.icon_url, + "ui_multiplier" => "2000000000000000000", + "new_ui_multiplier" => "4000000000000000000", + "ui_multiplier_effective_at" => "2026-09-01T00:00:00Z" + }} + + assert {:ok, %Token{type: "ERC-8056", ui_multiplier: ui_multiplier}} = + Chain.token_from_address_hash(scaled_token.contract_address_hash) + + assert Decimal.equal?(ui_multiplier, Decimal.new("2000000000000000000")) + end end 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 372d41295c7..0d883f5c993 100644 --- a/apps/indexer/test/indexer/transform/address_coin_balances_test.exs +++ b/apps/indexer/test/indexer/transform/address_coin_balances_test.exs @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressCoinBalancesTest do use Explorer.DataCase, async: true + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias Explorer.Factory alias Indexer.Transform.AddressCoinBalances @@ -247,7 +248,7 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do end end - if Application.compile_env(:explorer, :chain_type) == :eden do + if @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 @@ -321,7 +322,7 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do end end - if Application.compile_env(:explorer, :chain_type) == :arc do + if @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 from_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/apps/indexer/test/indexer/transform/token_transfers_test.exs b/apps/indexer/test/indexer/transform/token_transfers_test.exs index 2d70864cdf4..20265faa103 100644 --- a/apps/indexer/test/indexer/transform/token_transfers_test.exs +++ b/apps/indexer/test/indexer/transform/token_transfers_test.exs @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.TokenTransfersTest do use Explorer.DataCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] import ExUnit.CaptureLog @@ -687,7 +688,87 @@ defmodule Indexer.Transform.TokenTransfersTest do end end - if Application.compile_env(:explorer, :chain_type) == :arc do + describe "parse/1 with ERC-8056 logs" do + @erc8056_contract "0xf2eec76e45b328df99a34fa696320a262cb92154" + @transfer_topic "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + @transfer_with_ui_amount_topic "0x0226a2f5c1ae0e071aeec3d4ebafcefdc5c549be11f40ed27e76e802acccf374" + @ui_multiplier_updated_topic "0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055" + + defp erc8056_logs do + transfer = %{ + address_hash: @erc8056_contract, + block_number: 3_530_917, + block_hash: "0x79594150677f083756a37eee7b97ed99ab071f502104332cb3835bac345711ca", + data: "0x000000000000000000000000000000000000000000000000ebec21ee1da40000", + first_topic: @transfer_topic, + second_topic: "0x000000000000000000000000556813d9cc20acfe8388af029a679d34a63388db", + third_topic: "0x00000000000000000000000092148dd870fa1b7c4700f2bd7f44238821c26f73", + fourth_topic: nil, + index: 8, + transaction_hash: "0x43dfd761974e8c3351d285ab65bee311454eb45b149a015fe7804a33252f19e5" + } + + # emitted by the same `_update` call as the `Transfer` above, carrying the + # same raw amount plus the amount scaled by the multiplier + transfer_with_ui_amount = %{ + transfer + | first_topic: @transfer_with_ui_amount_topic, + index: 9, + data: + "0x000000000000000000000000000000000000000000000000ebec21ee1da40000" <> + "0000000000000000000000000000000000000000000000001d7d843dc3b48000" + } + + {transfer, transfer_with_ui_amount} + end + + test "does not count TransferWithUIAmount as a transfer of its own" do + {transfer, transfer_with_ui_amount} = erc8056_logs() + + %{token_transfers: token_transfers, tokens: tokens} = + TokenTransfers.parse([transfer, transfer_with_ui_amount]) + + # the standard emits `TransferWithUIAmount` *in addition to* `Transfer`, + # so recognising it as a transfer would double every movement of the token + assert [%{log_index: 8, amount: amount}] = token_transfers + assert Decimal.equal?(amount, Decimal.new(17_000_000_000_000_000_000)) + assert [%{type: "ERC-20"}] = tokens + end + + test "decodes an announced change of the multiplier out of the log alone" do + {transfer, _transfer_with_ui_amount} = erc8056_logs() + + ui_multiplier_updated = %{ + transfer + | first_topic: @ui_multiplier_updated_topic, + index: 10, + second_topic: nil, + third_topic: nil, + # (oldMultiplier, newMultiplier, effectiveAtTimestamp), none indexed + data: + "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" <> + "0000000000000000000000000000000000000000000000001bc16d674ec80000" <> + "000000000000000000000000000000000000000000000000000000006955b900" + } + + assert [change] = TokenTransfers.parse_ui_multiplier_changes([transfer, ui_multiplier_updated]) + + assert change.token_contract_address_hash == @erc8056_contract + assert change.block_number == transfer.block_number + assert change.log_index == 10 + assert Decimal.equal?(change.old_multiplier, Decimal.new("1000000000000000000")) + assert Decimal.equal?(change.new_multiplier, Decimal.new("2000000000000000000")) + assert change.effective_at == ~U[2026-01-01 00:00:00.000000Z] + end + + test "ignores logs of other events when collecting multiplier changes" do + {transfer, transfer_with_ui_amount} = erc8056_logs() + + assert TokenTransfers.parse_ui_multiplier_changes([transfer, transfer_with_ui_amount]) == [] + end + end + + if @chain_type == :arc do describe "parse/1 on Arc chain" do @arc_native_token "0x3600000000000000000000000000000000000000" @arc_system "0x1800000000000000000000000000000000000000" diff --git a/apps/nft_media_handler/lib/nft_media_handler.ex b/apps/nft_media_handler/lib/nft_media_handler.ex index df92f576ff5..e524d465335 100644 --- a/apps/nft_media_handler/lib/nft_media_handler.ex +++ b/apps/nft_media_handler/lib/nft_media_handler.ex @@ -29,7 +29,8 @@ defmodule NFTMediaHandler do @spec prepare_and_upload_by_url(binary(), binary()) :: {:error, any()} | {list(), {binary(), binary()}} def prepare_and_upload_by_url(url, r2_folder) do with {prepared_url, headers} <- maybe_process_ipfs(url), - {:fetch, {:ok, media_type, body}} <- {:fetch, Fetcher.fetch_media(prepared_url, headers)}, + {:fetch, {:ok, media_type, body}} <- + {:fetch, Fetcher.fetch_media(prepared_url, headers, prepared_url == url)}, {:ok, result} <- prepare_and_upload_inner(media_type, body, url, r2_folder) do result else @@ -159,50 +160,11 @@ defmodule NFTMediaHandler do end end - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + # Rewrites ipfs://, ar:// and swarm URIs to their operator-configured gateway, and returns + # regular URLs unchanged. So an unchanged URL means the host still comes from on-chain + # metadata and must be validated against the SSRF blacklist, while a rewritten one points at + # a gateway the operator chose (possibly on a private address) and must not be. defp maybe_process_ipfs(uri) do - case URI.parse(uri) do - %URI{scheme: "ipfs", host: host, path: path} -> - resource_id = - cond do - host == "ipfs" and is_binary(path) and String.starts_with?(path, "/") -> - String.replace_leading(path, "/", "") - - is_binary(host) and host != "" -> - build_ipfs_resource_id(host, path) - - true -> - path - end - - maybe_fetch_ipfs_url(resource_id, uri) - - %URI{scheme: "ar", host: _host, path: resource_id} -> - {TokenMetadataRetriever.arweave_link(resource_id), TokenMetadataRetriever.ar_headers()} - - %URI{scheme: _, path: "/ipfs/" <> resource_id} -> - maybe_fetch_ipfs_url(resource_id, uri) - - %URI{scheme: _, path: "ipfs/" <> resource_id} -> - maybe_fetch_ipfs_url(resource_id, uri) - - %URI{scheme: scheme} when not is_nil(scheme) -> - {uri, []} - - %URI{path: path} -> - maybe_fetch_ipfs_url(path, uri) - end - end - - defp build_ipfs_resource_id(host, path) do - if is_nil(path), do: host, else: host <> path - end - - defp maybe_fetch_ipfs_url(resource_id, uri) do - if is_binary(resource_id) and TokenMetadataRetriever.valid_ipfs_path?("ipfs://" <> resource_id) do - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} - else - {uri, []} - end + TokenMetadataRetriever.resolve_nft_media_url(uri) end end diff --git a/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex b/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex index 9f2c13c819a..2262b3d3e82 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex @@ -7,7 +7,9 @@ defmodule NFTMediaHandler.Media.Fetcher do @supported_image_types ["png", "jpeg", "gif", "webp"] @supported_video_types ["mp4"] - import Utils.TokenInstanceHelper, only: [media_type: 3] + import Utils.TokenInstanceHelper, only: [media_type: 4] + + alias Utils.HttpClient.SafeFetch @doc """ Fetches media from the given URL with the specified headers. @@ -16,6 +18,9 @@ defmodule NFTMediaHandler.Media.Fetcher do - url: A binary string representing the URL to fetch the media from. - headers: A list of headers to include in the request. + - validate_host?: Optional boolean flag to validate the host against the SSRF blacklist. + Defaults to true. Pass `false` when `url` has already been resolved to an + operator-configured gateway (IPFS/Arweave/Swarm), which may live on a private address. ## Returns @@ -27,12 +32,19 @@ defmodule NFTMediaHandler.Media.Fetcher do {:ok, media_content} """ - @spec fetch_media(binary(), list()) :: {:error, any()} | {:ok, nil | tuple(), any()} - def fetch_media(url, headers) when is_binary(url) do - with media_type <- media_type(url, headers, false), + @spec fetch_media(binary(), list(), boolean()) :: {:error, any()} | {:ok, nil | tuple(), any()} + def fetch_media(url, headers, validate_host? \\ true) + + def fetch_media(url, headers, validate_host?) when is_binary(url) do + with media_type <- media_type(url, headers, false, validate_host?), {:support, true} <- {:support, media_type_supported?(media_type)}, {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- - HTTPoison.get(url, headers, follow_redirect: true, max_body_length: 20_000_000) do + SafeFetch.request( + url, + headers, + [validate_host?: validate_host?, transport_opts: [max_body_length: 20_000_000]], + fn request_url, request_headers, opts -> HTTPoison.get(request_url, request_headers, opts) end + ) do {:ok, media_type, body} else {:support, false} -> @@ -43,6 +55,9 @@ defmodule NFTMediaHandler.Media.Fetcher do {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} + + {:error, reason} -> + {:error, reason} end end diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 7d2acbf0795..4edd6c4f928 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.3.3", + version: "12.0.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", @@ -26,12 +26,11 @@ defmodule NFTMediaHandler.MixProject do [ {:ex_aws, "~> 2.0"}, {:ex_aws_s3, "~> 2.0"}, - {:jason, "~> 1.3"}, {:hackney, "~> 1.9"}, {:sweet_xml, "~> 0.7"}, {:image, "~> 0.54"}, {:httpoison, "~> 2.0"}, - {:evision, "~> 0.2"}, + {:xav, "~> 0.10"}, {:mime, "~> 2.0"}, {:utils, in_umbrella: true} ] diff --git a/apps/utils/lib/utils/http_client/safe_fetch.ex b/apps/utils/lib/utils/http_client/safe_fetch.ex new file mode 100644 index 00000000000..ff2c744bc7d --- /dev/null +++ b/apps/utils/lib/utils/http_client/safe_fetch.ex @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.HttpClient.SafeFetch do + @moduledoc """ + Performs an HTTP request against an attacker-controlled URL while validating the + host (and every redirect target) against `Utils.UrlValidator`. + + Neither `Tesla.Middleware.FollowRedirects` nor hackney exposes a per-redirect hook, + so redirects are followed manually here: the request is issued with automatic + redirect-following disabled, and each `Location` is re-validated before the next hop. + + The concrete HTTP client is injected as a `transport` closure of arity 3 + (`url, headers, transport_opts -> {:ok, response} | {:error, term}`), so this module + stays transport-agnostic and callers keep using their existing client + (`Explorer.HttpClient` for the Tesla path, `HTTPoison` for the media paths). The + response is returned verbatim, so callers can keep pattern-matching on their client's + native shape (`%HTTPoison.Response{}` or the `Explorer.HttpClient`-normalized map). + """ + + alias Utils.UrlValidator + + @default_max_redirects 5 + @redirect_statuses [301, 302, 303, 307, 308] + + @type transport :: (String.t(), list(), keyword() -> {:ok, term()} | {:error, term()}) + + @doc """ + Validates `url`, issues the request via `transport` with redirects disabled, and + manually follows up to `:max_redirects` redirects, validating each target. + + ## Options + - `:validate_host?` (default `true`) — whether to validate the host of the **initial** + request. Pass `false` for a URL already resolved to an operator-configured gateway + (IPFS/Arweave/Swarm), which may legitimately live on a private address. Redirect + targets are validated regardless, since the operator does not choose those. Host + filtering is additionally gated by the global `host_filtering_enabled?` flag. + - `:max_redirects` (default `#{@default_max_redirects}`) — maximum number of redirect hops. + - `:transport_opts` — options passed straight to the `transport` closure (e.g. + `recv_timeout`, `max_body_length`, `pool`). Must NOT enable automatic redirect + following. + + Returns the transport's `{:ok, response}` for the final (non-redirect) hop, or + `{:error, reason}` — including `{:error, :too_many_redirects}` and + `{:error, :invalid_redirect_location}`. + """ + @spec request(String.t(), list(), keyword(), transport()) :: {:ok, term()} | {:error, term()} + def request(url, headers, opts, transport) when is_function(transport, 3) do + do_request( + url, + headers, + opts, + transport, + Keyword.get(opts, :max_redirects, @default_max_redirects), + Keyword.get(opts, :validate_host?, true) + ) + end + + defp do_request(url, headers, opts, transport, hops_left, validate_host?) do + with :ok <- maybe_validate(url, validate_host?), + {:ok, response} <- transport.(url, headers, Keyword.get(opts, :transport_opts, [])) do + case redirect_target(response, url) do + :none -> + {:ok, response} + + # Redirect targets are always validated, even when the initial host was exempt: an + # operator-configured gateway is trusted because the operator chose it, but the host + # it redirects to is not something the operator chose. + {:ok, next_url} when hops_left > 0 -> + do_request(next_url, headers, opts, transport, hops_left - 1, true) + + {:ok, _next_url} -> + {:error, :too_many_redirects} + + :error -> + {:error, :invalid_redirect_location} + end + end + end + + defp maybe_validate(url, true) do + if host_filtering_enabled?() do + UrlValidator.validate_uri(url) + else + :ok + end + end + + defp maybe_validate(_url, false), do: :ok + + # Returns `{:ok, absolute_url}` for a redirect to follow, `:none` when the response is + # not a followable redirect, or `:error` when the `Location` cannot be resolved. + defp redirect_target(response, base_url) do + if status_code(response) in @redirect_statuses do + case location_header(response) do + nil -> + :none + + location -> + {:ok, base_url |> URI.merge(location) |> URI.to_string()} + end + else + :none + end + rescue + _ -> :error + end + + defp status_code(%{status_code: code}), do: code + defp status_code(_), do: nil + + defp location_header(%{headers: headers}) when is_list(headers) do + Enum.find_value(headers, fn + {name, value} -> if String.downcase(to_string(name)) == "location", do: value + _ -> nil + end) + end + + defp location_header(_), do: nil + + # Defaults to `true` so a consumer without this configuration fails closed rather than + # silently skipping validation. + defp host_filtering_enabled? do + (Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) || []) + |> Keyword.get(:host_filtering_enabled?, true) + end +end diff --git a/apps/utils/lib/utils/http_client/tesla_helper.ex b/apps/utils/lib/utils/http_client/tesla_helper.ex index 5349ac35770..d95c188973d 100644 --- a/apps/utils/lib/utils/http_client/tesla_helper.ex +++ b/apps/utils/lib/utils/http_client/tesla_helper.ex @@ -17,12 +17,13 @@ defmodule Utils.HttpClient.TeslaHelper do ## Returns - A Tesla client struct with configured middleware """ - @spec client(keyword() | map()) :: Tesla.Client.t() - def client(options) do + @spec client(keyword() | map(), list()) :: Tesla.Client.t() + def client(options, additional_middleware \\ []) do options[:recv_timeout] |> add_timeout_middleware() |> add_follow_redirect_middleware(options[:follow_redirect]) |> add_basic_auth_middleware(options[:basic_auth]) + |> add_additional_middleware(additional_middleware) |> Tesla.client() end @@ -66,6 +67,10 @@ defmodule Utils.HttpClient.TeslaHelper do defp add_basic_auth_middleware(middleware, _basic_auth), do: middleware + defp add_additional_middleware(middleware, additional_middleware) when is_list(additional_middleware) do + middleware ++ additional_middleware + end + defp add_recv_timeout_option(options, nil), do: options defp add_recv_timeout_option(options, timeout) do diff --git a/apps/utils/lib/utils/json.ex b/apps/utils/lib/utils/json.ex new file mode 100644 index 00000000000..477fdff66e1 --- /dev/null +++ b/apps/utils/lib/utils/json.ex @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.JSON do + @moduledoc """ + Facade for Elixir's built-in JSON module, providing Jason-compatible API. + + This module abstracts the internal JSON library implementation so that code + does not directly depend on Jason or Poison. All encoding/decoding operations + flow through this facade, enabling future library swaps without widespread refactoring. + + Features: + - Encoding: `encode!/2`, `encode_to_iodata!/2`, `encode/2` + - Decoding: `decode!/2`, `decode/2`, with support for atom keys + - Pretty-printing: deterministic JSON formatting with customizable options + - Error handling: exceptions and tuples with consistent structure + """ + + @type option :: {:pretty, boolean()} | {:keys, :atoms | :strings} + @type decode_option :: {:keys, :atoms | :strings} + + @doc """ + Encodes a term to JSON string. + + Options: + - `:pretty` (boolean) - if true, formats with indentation (default: false) + - `:space` (non_neg_integer) - spaces per indent level when pretty=true (default: 2) + + Raises on encoding errors. + """ + @spec encode!(term(), [option()]) :: binary() + def encode!(term, options \\ []) do + pretty = Keyword.get(options, :pretty, false) + result = term |> normalize_for_encoding() |> JSON.encode!() + + if pretty do + pretty_print(result, Keyword.get(options, :space, 2)) + else + result + end + end + + @doc """ + Encodes a term to iodata (efficient for I/O operations). + + Options: + - `:pretty` (boolean) - if true, formats with indentation (default: false) + - `:space` (non_neg_integer) - spaces per indent level when pretty=true (default: 2) + + Raises on encoding errors. + """ + @spec encode_to_iodata!(term(), [option()]) :: iodata() + def encode_to_iodata!(term, options \\ []) do + pretty = Keyword.get(options, :pretty, false) + result = term |> normalize_for_encoding() |> JSON.encode_to_iodata!() + + if pretty do + pretty_print(IO.iodata_to_binary(result), Keyword.get(options, :space, 2)) + else + result + end + end + + @doc """ + Encodes a term to JSON, returning {:ok, result} or {:error, reason}. + + Options: same as `encode!/2` + """ + @spec encode(term(), [option()]) :: {:ok, binary()} | {:error, term()} + def encode(term, options \\ []) do + {:ok, encode!(term, options)} + rescue + e -> {:error, e} + end + + @doc """ + Decodes JSON binary to a term. + + Options: + - `:keys` - `:atoms` to convert string keys to atoms (default: `:strings`) + + Returns {:ok, term} or {:error, reason}. + """ + @spec decode(iodata(), [decode_option()]) :: {:ok, term()} | {:error, term()} + def decode(input, options \\ []) do + case input |> IO.iodata_to_binary() |> JSON.decode() do + {:ok, term} -> + {:ok, maybe_atomize_keys(term, options)} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Decodes JSON binary to a term, raising on error. + + Options: + - `:keys` - `:atoms` to convert string keys to atoms (default: `:strings`) + + Raises on decode errors. + """ + @spec decode!(iodata(), [decode_option()]) :: term() + def decode!(input, options \\ []) do + term = input |> IO.iodata_to_binary() |> JSON.decode!() + maybe_atomize_keys(term, options) + end + + @doc """ + Soft-decoding helper: returns term on success or a fallback value on error. + Useful for optional JSON parsing in views. + + Options: same as `decode!/2` + """ + @spec decode_string(binary(), [decode_option()]) :: term() | nil + def decode_string(binary, options \\ []) do + decode!(binary, options) + rescue + _ -> nil + end + + # Private helpers + + @spec maybe_atomize_keys(term(), [decode_option()]) :: term() + defp maybe_atomize_keys(term, options) do + if Keyword.get(options, :keys) == :atoms do + atomize_keys(term) + else + term + end + end + + @spec atomize_keys(term()) :: term() + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_binary(k) -> {binary_key_to_atom(k), atomize_keys(v)} + {k, v} -> {k, atomize_keys(v)} + end) + end + + defp atomize_keys(list) when is_list(list) do + Enum.map(list, &atomize_keys/1) + end + + defp atomize_keys(term) do + term + end + + defp binary_key_to_atom(key) do + String.to_existing_atom(key) + rescue + ArgumentError -> key + end + + @spec normalize_for_encoding(term()) :: term() + defp normalize_for_encoding(term) when is_list(term) do + Enum.map(term, &normalize_for_encoding/1) + end + + defp normalize_for_encoding(%_{} = term), do: term + + defp normalize_for_encoding(map) when is_map(map) do + Map.new(map, fn {key, value} -> + {normalize_for_encoding(key), normalize_for_encoding(value)} + end) + end + + defp normalize_for_encoding(term) do + term + end + + @spec pretty_print(binary(), non_neg_integer()) :: binary() + defp pretty_print(json, spaces) do + json + |> String.graphemes() + |> format_graphemes(0, spaces, [], false, false) + |> Enum.join() + end + + @spec format_graphemes([String.t()], non_neg_integer(), non_neg_integer(), [String.t()], boolean(), boolean()) :: [ + String.t() + ] + defp format_graphemes([], _level, _spaces, acc, _in_string, _escaped) do + Enum.reverse(acc) + end + + defp format_graphemes([char | rest], level, spaces, acc, in_string, true) do + format_graphemes(rest, level, spaces, [char | acc], in_string, false) + end + + defp format_graphemes(["\\" | rest], level, spaces, acc, true, false) do + format_graphemes(rest, level, spaces, ["\\" | acc], true, true) + end + + defp format_graphemes(["\"" | rest], level, spaces, acc, in_string, false) do + format_graphemes(rest, level, spaces, ["\"" | acc], not in_string, false) + end + + defp format_graphemes([char | rest], level, spaces, acc, true, false) do + format_graphemes(rest, level, spaces, [char | acc], true, false) + end + + defp format_graphemes(["{" | rest], level, spaces, acc, false, false) do + format_graphemes( + rest, + level + 1, + spaces, + [ + "{\n" | [String.duplicate(" ", (level + 1) * spaces) | acc] + ], + false, + false + ) + end + + defp format_graphemes(["}" | rest], level, spaces, acc, false, false) do + new_level = max(0, level - 1) + + format_graphemes( + rest, + new_level, + spaces, + [ + "}" | ["\n" | [String.duplicate(" ", new_level * spaces) | acc]] + ], + false, + false + ) + end + + defp format_graphemes(["[" | rest], level, spaces, acc, false, false) do + format_graphemes( + rest, + level + 1, + spaces, + [ + "[\n" | [String.duplicate(" ", (level + 1) * spaces) | acc] + ], + false, + false + ) + end + + defp format_graphemes(["]" | rest], level, spaces, acc, false, false) do + new_level = max(0, level - 1) + + format_graphemes( + rest, + new_level, + spaces, + [ + "]" | ["\n" | [String.duplicate(" ", new_level * spaces) | acc]] + ], + false, + false + ) + end + + defp format_graphemes(["," | rest], level, spaces, acc, false, false) do + format_graphemes( + rest, + level, + spaces, + [ + ",\n" | [String.duplicate(" ", level * spaces) | acc] + ], + false, + false + ) + end + + defp format_graphemes([":" | rest], level, spaces, acc, false, false) do + format_graphemes(rest, level, spaces, [": " | acc], false, false) + end + + defp format_graphemes([char | rest], level, spaces, acc, false, false) + when char in [" ", "\n", "\t"] do + format_graphemes(rest, level, spaces, acc, false, false) + end + + defp format_graphemes([char | rest], level, spaces, acc, false, false) do + format_graphemes(rest, level, spaces, [char | acc], false, false) + end +end diff --git a/apps/utils/lib/utils/runtime_env_helper.ex b/apps/utils/lib/utils/runtime_env_helper.ex index 56b2964371b..e6483b698a1 100644 --- a/apps/utils/lib/utils/runtime_env_helper.ex +++ b/apps/utils/lib/utils/runtime_env_helper.ex @@ -17,13 +17,8 @@ defmodule Utils.RuntimeEnvHelper do ```elixir defmodule MyModule do use Utils.RuntimeEnvHelper, - mud_enabled?: [:explorer, [Explorer.Chain.Mud, :enabled]], api_enabled?: [:block_scout_web, :api_enabled] - pipeline :mud do - plug(CheckFeature, feature_check: &mud_enabled?/0) - end - def process do if api_enabled?() do # API-specific logic diff --git a/apps/utils/lib/utils/token_instance_helper.ex b/apps/utils/lib/utils/token_instance_helper.ex index f8a9ab1fc81..15368bdc828 100644 --- a/apps/utils/lib/utils/token_instance_helper.ex +++ b/apps/utils/lib/utils/token_instance_helper.ex @@ -4,6 +4,8 @@ defmodule Utils.TokenInstanceHelper do Auxiliary functions for NFTs """ + alias Utils.HttpClient.SafeFetch + @doc """ Determines the media type of the given URL. @@ -12,32 +14,33 @@ defmodule Utils.TokenInstanceHelper do - url: The URL to check the media type for. - headers: Optional list of headers to include in the request. Defaults to an empty list. - treat_data_as_valid_media_type?: Optional boolean flag to treat url of `data:image/` format as a valid media type. Defaults to true. + - validate_host?: Optional boolean flag to validate the host against the SSRF blacklist. Defaults to true. Pass `false` for URLs already resolved to an operator-configured gateway (IPFS/Arweave/Swarm), which may legitimately live on a private address. ## Returns The media type of the given URL, or nil """ - @spec media_type(binary(), list(), boolean()) :: {binary(), binary()} | nil - def media_type(url, headers \\ [], treat_data_as_valid_media_type? \\ true) + @spec media_type(binary(), list(), boolean(), boolean()) :: {binary(), binary()} | nil + def media_type(url, headers \\ [], treat_data_as_valid_media_type? \\ true, validate_host? \\ true) - def media_type("data:image/" <> _data, _headers, true) do + def media_type("data:image/" <> _data, _headers, true, _validate_host?) do {"image", ""} end - def media_type("data:video/" <> _data, _headers, true) do + def media_type("data:video/" <> _data, _headers, true, _validate_host?) do {"video", ""} end - def media_type("data:" <> _data, _headers, _) do + def media_type("data:" <> _data, _headers, _, _validate_host?) do nil end - def media_type(media_src, headers, _) when not is_nil(media_src) do + def media_type(media_src, headers, _, validate_host?) when not is_nil(media_src) do ext = media_src |> Path.extname() |> String.trim() mime_type = if ext == "" do - process_missing_extension(media_src, headers) + process_missing_extension(media_src, headers, validate_host?) else ext_with_dot = media_src @@ -56,10 +59,52 @@ defmodule Utils.TokenInstanceHelper do end end - def media_type(nil, _headers, _), do: nil + def media_type(nil, _headers, _, _validate_host?), do: nil + + @doc """ + Same as `media_type/4` but reports why detection failed instead of returning `nil`. + + ## Parameters + + - url: The URL to check the media type for. + - headers: Optional list of headers to include in the request. Defaults to an empty list. + - validate_host?: Optional boolean flag to validate the host against the SSRF blacklist. Defaults to true. Pass `false` for URLs already resolved to an operator-configured gateway (IPFS/Arweave/Swarm), which may live on a private address. + + ## Returns + + - `{:ok, {type, subtype}}` with the media type split on `/`, e.g. `{"image", "png"}`. For + `data:image/` and `data:video/` URLs the subtype is an empty string. + - `{:error, reason}` with a human-readable binary reason when the URL is nil, uses an + unsupported `data:` scheme, is rejected by host validation, or when the HEAD request + fails or returns no `content-type` header. + """ + @spec media_type_detailed(binary(), list(), boolean()) :: {:ok, {binary(), binary()}} | {:error, binary()} + def media_type_detailed(url, headers \\ [], validate_host? \\ true) + + def media_type_detailed("data:image/" <> _data, _headers, _validate_host?), do: {:ok, {"image", ""}} + def media_type_detailed("data:video/" <> _data, _headers, _validate_host?), do: {:ok, {"video", ""}} + + def media_type_detailed("data:" <> data, _headers, _validate_host?), + do: {:error, "unsupported data URI scheme: #{String.slice(data, 0, 20)}"} + + def media_type_detailed(nil, _headers, _validate_host?), do: {:error, "nil URL"} + + def media_type_detailed(media_src, headers, validate_host?) do + ext = media_src |> Path.extname() |> String.trim() - defp process_missing_extension(media_src, headers) do - case HTTPoison.head(media_src, headers, follow_redirect: true) do + if ext == "" do + case process_missing_extension_detailed(media_src, headers, validate_host?) do + {:ok, mime_type} -> {:ok, mime_type |> String.split("/") |> List.to_tuple()} + {:error, _} = error -> error + end + else + "." <> ext_without_dot = Path.extname(media_src) + {:ok, ext_without_dot |> MIME.type() |> String.split("/") |> List.to_tuple()} + end + end + + defp process_missing_extension(media_src, headers, validate_host?) do + case safe_head(media_src, headers, validate_host?) do {:ok, %HTTPoison.Response{status_code: 200, headers: headers}} -> headers_map = Map.new(headers, fn {key, value} -> {String.downcase(key), value} end) headers_map["content-type"] @@ -68,4 +113,36 @@ defmodule Utils.TokenInstanceHelper do nil end end + + defp process_missing_extension_detailed(media_src, headers, validate_host?) do + case safe_head(media_src, headers, validate_host?) do + {:ok, %HTTPoison.Response{status_code: 200, headers: headers}} -> + headers_map = Map.new(headers, fn {key, value} -> {String.downcase(key), value} end) + + case headers_map["content-type"] do + nil -> {:error, "no content-type header in response"} + mime_type -> {:ok, mime_type} + end + + {:ok, %HTTPoison.Response{status_code: status_code}} -> + {:error, "HTTP HEAD returned #{status_code}"} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "HTTP HEAD failed: #{inspect(reason)}"} + + {:error, reason} -> + {:error, "HTTP HEAD failed: #{inspect(reason)}"} + end + end + + # Issues a HEAD request that validates the host (and any redirect target) against the + # SSRF blacklist, following redirects manually since the media URL is attacker-controlled. + defp safe_head(media_src, headers, validate_host?) do + SafeFetch.request( + media_src, + headers, + [validate_host?: validate_host?, transport_opts: [timeout: 30_000, recv_timeout: 30_000]], + fn url, request_headers, opts -> HTTPoison.head(url, request_headers, opts) end + ) + end end diff --git a/apps/utils/lib/utils/url_validator.ex b/apps/utils/lib/utils/url_validator.ex new file mode 100644 index 00000000000..2915d1071b5 --- /dev/null +++ b/apps/utils/lib/utils/url_validator.ex @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.UrlValidator do + @moduledoc """ + Validates outbound URLs built from attacker-controlled data (e.g. on-chain token + metadata) against a blacklist of reserved/internal IP ranges, to prevent SSRF. + + Lives in `:utils` so it is reachable from every app that issues such requests + (`:explorer`, `:nft_media_handler`). Configuration is read from the `:indexer` + application environment, which is global and requires no compile-time dependency. + """ + + import Bitwise + + require Logger + + @reserved_ranges [ + # --- IPv4 --- + # Current (local, "this") network + "0.0.0.0/8", + # Used for local communications within a private network + "10.0.0.0/8", + # [Shared address space](https://en.wikipedia.org/wiki/IPv4_shared_address_space) for communications between a service provider and its subscribers when using a carrier-grade NAT + "100.64.0.0/10", + # Used for [loopback addresses](https://en.wikipedia.org/wiki/Loopback_address) to the local host + "127.0.0.0/8", + # Used for [link-local addresses](https://en.wikipedia.org/wiki/Link-local_address) between two hosts on a single link when no IP address is otherwise specified, such as would have normally been retrieved from a DHCP server + "169.254.0.0/16", + # Used for local communications within a private network + "172.16.0.0/12", + # IETF Protocol Assignments, [DS-Lite](https://en.wikipedia.org/wiki/DS-Lite) + "192.0.0.0/29", + # Assigned as TEST-NET-1, documentation and examples + "192.0.2.0/24", + # Reserved. Formerly used for IPv6 to IPv4 relay (included IPv6 address block) + "192.88.99.0/24", + # Used for local communications within a private network + "192.168.0.0/16", + # Used for benchmark testing of inter-network communications between two separate subnets + "198.18.0.0/15", + # Assigned as TEST-NET-2, documentation and examples + "198.51.100.0/24", + # Assigned as TEST-NET-3, documentation and examples + "203.0.113.0/24", + # In use for [multicast](https://en.wikipedia.org/wiki/IP_multicast) (former Class D network) + "224.0.0.0/4", + # Reserved for future use (former Class E network) + "240.0.0.0/4", + # Reserved for the "limited [broadcast](https://en.wikipedia.org/wiki/Broadcast_address)" destination address + "255.255.255.255/32", + # --- IPv6 --- + # Deprecated IPv4-compatible addresses (`::a.b.c.d`), which reach the IPv4 stack without + # the `::ffff:` prefix that `normalize_mapped_ipv4/1` handles. Also covers the + # unspecified address (`::`) and loopback (`::1`). + "::/96", + # Discard-only address block + "100::/64", + # NAT64 well-known prefix + "64:ff9b::/96", + # 6to4 + "2002::/16", + # Teredo tunneling (embeds an IPv4 address) + "2001::/32", + # Documentation and examples + "2001:db8::/32", + # Unique local addresses (covers fc00::/8 and fd00::/8) + "fc00::/7", + # Link-local addresses + "fe80::/10", + # Multicast + "ff00::/8" + ] + + @doc """ + Validates the given URI. + + ## Parameters + - uri: The URI to be validated. + + ## Returns + - :ok if the URI is valid. + - {:error, reason} if the URI is invalid. + + ## Examples + + iex> validate_uri("https://example.com") + :ok + + iex> validate_uri("invalid_uri") + {:error, :empty_host} + + iex> validate_uri("https://not_existing_domain.com") + {:error, :nxdomain} + """ + @spec validate_uri(String.t()) :: :ok | {:error, atom()} + def validate_uri(uri) do + with {:not_printable, false} <- {:not_printable, not String.printable?(uri)}, + {:empty_host, %URI{host: host, scheme: scheme}} when host not in ["", nil] <- {:empty_host, URI.parse(uri)}, + {:disallowed_protocol, false} <- {:disallowed_protocol, scheme not in allowed_uri_protocols()}, + {:nxdomain, ip_list} when not is_nil(ip_list) <- {:nxdomain, host_to_ip_list(host)}, + {:blacklist, false} <- {:blacklist, not Enum.all?(ip_list, &allowed_ip?/1)} do + :ok + else + {reason, _} -> + {:error, reason} + end + end + + @spec host_to_ip_list(String.t()) :: [tuple()] | nil + defp host_to_ip_list(host) do + host + |> to_charlist() + |> :inet.parse_address() + |> case do + {:ok, ip} -> + [ip] + + {:error, _reason} -> + case resolve(host, :a) ++ resolve(host, :aaaa) do + [] -> nil + ip_list -> ip_list + end + end + end + + @spec resolve(String.t(), :a | :aaaa) :: [tuple()] + defp resolve(host, type) do + case DNS.resolve(host, type) do + {:ok, ip_list} when is_list(ip_list) -> ip_list + _ -> [] + end + end + + @spec allowed_ip?(tuple()) :: boolean() + defp allowed_ip?(ip) do + normalized = normalize_mapped_ipv4(ip) + + not Enum.any?(prepare_cidr_blacklist(), fn range -> + InetCidr.contains?(range, normalized) + end) + end + + # Normalizes an IPv4-mapped IPv6 address (::ffff:a.b.c.d) to its IPv4 tuple so it is + # matched against the IPv4 reserved ranges (e.g. ::ffff:127.0.0.1 -> 127.0.0.0/8). + @spec normalize_mapped_ipv4(tuple()) :: tuple() + defp normalize_mapped_ipv4({0, 0, 0, 0, 0, 0xFFFF, ab, cd}) do + {ab >>> 8, ab &&& 0xFF, cd >>> 8, cd &&& 0xFF} + end + + defp normalize_mapped_ipv4(ip), do: ip + + defp prepare_cidr_blacklist do + from_cache = :persistent_term.get(:parsed_cidr_list, nil) + + from_cache || + ( + cidr_list = + (config(:cidr_blacklist, []) ++ @reserved_ranges) + |> Enum.flat_map(fn cidr -> + case InetCidr.parse_cidr(cidr) do + {:ok, cidr} -> + [cidr] + + _ -> + Logger.warning("Invalid CIDR range: #{inspect(cidr)}") + [] + end + end) + + :persistent_term.put(:parsed_cidr_list, cidr_list) + cidr_list + ) + end + + defp allowed_uri_protocols do + config(:allowed_uri_protocols, ["http", "https"]) + end + + # Defaults are applied here so a consumer without this configuration still validates + # (fails closed) instead of crashing on a missing key. + defp config(key, default) do + (Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) || []) + |> Keyword.get(key, default) + end +end diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index f679d96c033..c4048a10278 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.3.3", + version: "12.0.0", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", @@ -31,8 +31,11 @@ defmodule Utils.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:bypass, "~> 2.1", only: :test}, {:credo, "~> 1.5", only: [:test, :dev], runtime: false}, + {:dns, "~> 2.4"}, {:httpoison, "~> 2.0"}, + {:inet_cidr, "~> 1.0"}, {:mime, "~> 2.0"}, {:tesla, "~> 1.21.0"} ] diff --git a/apps/utils/test/utils/http_client/safe_fetch_test.exs b/apps/utils/test/utils/http_client/safe_fetch_test.exs new file mode 100644 index 00000000000..fa3fc7012d5 --- /dev/null +++ b/apps/utils/test/utils/http_client/safe_fetch_test.exs @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.HttpClient.SafeFetchTest do + use ExUnit.Case, async: false + + alias Utils.HttpClient.SafeFetch + + @public "http://8.8.8.8/" + @internal "http://127.0.0.1/" + + setup do + previous = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) + + put_config(host_filtering_enabled?: true) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, previous) + :persistent_term.erase(:parsed_cidr_list) + end) + + :ok + end + + defp put_config(overrides) do + base = [host_filtering_enabled?: true, cidr_blacklist: [], allowed_uri_protocols: ["http", "https"]] + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, Keyword.merge(base, overrides)) + :persistent_term.erase(:parsed_cidr_list) + end + + defp resp(status, headers, body \\ ""), do: %{status_code: status, headers: headers, body: body} + + # Builds an arity-3 transport that records each requested URL to the test process + # and delegates to `responder` for the canned response. + defp transport(responder) do + parent = self() + + fn url, _headers, _opts -> + send(parent, {:requested, url}) + responder.(url) + end + end + + test "rejects a blacklisted initial url without issuing the request" do + t = transport(fn _ -> flunk("transport must not be called for a blacklisted host") end) + assert SafeFetch.request(@internal, [], [], t) == {:error, :blacklist} + end + + test "rejects a redirect to an internal address (per-hop validation)" do + t = + transport(fn + @public -> {:ok, resp(302, [{"Location", @internal}])} + end) + + assert SafeFetch.request(@public, [], [], t) == {:error, :blacklist} + assert_received {:requested, @public} + refute_received {:requested, @internal} + end + + test "follows a redirect to a public address" do + t = + transport(fn + @public -> {:ok, resp(302, [{"location", "http://1.1.1.1/next"}])} + "http://1.1.1.1/next" -> {:ok, resp(200, [], "ok")} + end) + + assert {:ok, %{status_code: 200, body: "ok"}} = SafeFetch.request(@public, [], [], t) + end + + test "resolves a relative Location against the current url and re-validates it" do + t = + transport(fn + "http://8.8.8.8/a/b" -> {:ok, resp(302, [{"location", "/c"}])} + "http://8.8.8.8/c" -> {:ok, resp(200, [], "ok")} + end) + + assert {:ok, %{status_code: 200}} = SafeFetch.request("http://8.8.8.8/a/b", [], [], t) + assert_received {:requested, "http://8.8.8.8/a/b"} + assert_received {:requested, "http://8.8.8.8/c"} + end + + test "stops after max redirects" do + t = transport(fn url -> {:ok, resp(302, [{"location", url}])} end) + assert SafeFetch.request(@public, [], [max_redirects: 3], t) == {:error, :too_many_redirects} + end + + test "returns a non-redirect response as-is" do + t = transport(fn @public -> {:ok, resp(200, [], "body")} end) + assert {:ok, %{status_code: 200, body: "body"}} = SafeFetch.request(@public, [], [], t) + end + + test "returns a 3xx without a Location header as-is" do + t = transport(fn @public -> {:ok, resp(302, [])} end) + assert {:ok, %{status_code: 302}} = SafeFetch.request(@public, [], [], t) + end + + test "skips validation of the initial url when validate_host? is false" do + t = transport(fn @internal -> {:ok, resp(200, [], "ok")} end) + assert {:ok, %{status_code: 200}} = SafeFetch.request(@internal, [], [validate_host?: false], t) + end + + test "still validates redirect targets when validate_host? is false" do + # The initial host is exempt (operator-configured gateway), but a host it redirects to + # is not, so the internal target must be rejected without being requested. + t = + transport(fn + "http://10.0.0.1/gateway" -> {:ok, resp(302, [{"location", @internal}])} + end) + + assert SafeFetch.request("http://10.0.0.1/gateway", [], [validate_host?: false], t) == + {:error, :blacklist} + + assert_received {:requested, "http://10.0.0.1/gateway"} + refute_received {:requested, @internal} + end + + test "rejects a redirect to a disallowed scheme when validate_host? is false" do + t = + transport(fn + "http://10.0.0.1/gateway" -> {:ok, resp(302, [{"location", "ftp://8.8.8.8/x"}])} + end) + + assert SafeFetch.request("http://10.0.0.1/gateway", [], [validate_host?: false], t) == + {:error, :disallowed_protocol} + + refute_received {:requested, "ftp://8.8.8.8/x"} + end + + test "skips validation when host filtering is globally disabled" do + put_config(host_filtering_enabled?: false) + t = transport(fn @internal -> {:ok, resp(200, [], "ok")} end) + assert {:ok, %{status_code: 200}} = SafeFetch.request(@internal, [], [], t) + end + + test "propagates transport errors" do + t = transport(fn @public -> {:error, :econnrefused} end) + assert SafeFetch.request(@public, [], [], t) == {:error, :econnrefused} + end +end diff --git a/apps/utils/test/utils/json_test.exs b/apps/utils/test/utils/json_test.exs new file mode 100644 index 00000000000..a24a263628b --- /dev/null +++ b/apps/utils/test/utils/json_test.exs @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.JSONTest do + use ExUnit.Case + doctest Utils.JSON + + describe "encode!/2" do + test "encodes basic term to JSON string" do + result = Utils.JSON.encode!([1, 2, 3]) + assert result == "[1,2,3]" + end + + test "encodes map to JSON string" do + result = Utils.JSON.encode!(%{"key" => "value", "num" => 42}) + assert Utils.JSON.decode!(result) == %{"key" => "value", "num" => 42} + end + + test "encodes with pretty option" do + result = Utils.JSON.encode!(%{"key" => "value"}, pretty: true) + assert String.contains?(result, "\n") + assert String.contains?(result, " ") + end + + test "encodes with custom space count for pretty" do + result = Utils.JSON.encode!([1, 2], pretty: true, space: 4) + assert String.contains?(result, "\n") + # Should have 4-space indentation + assert String.contains?(result, " ") + end + + test "encodes nil" do + assert Utils.JSON.encode!(nil) == "null" + end + + test "encodes booleans" do + assert Utils.JSON.encode!(true) == "true" + assert Utils.JSON.encode!(false) == "false" + end + + test "encodes strings" do + assert Utils.JSON.encode!("hello") == ~s("hello") + end + + test "raises on circular reference" do + circular = %{"a" => 1} + # Elixir JSON doesn't support circular refs, so this may raise + # depending on the data structure. Just verify no crash on simple data. + assert Utils.JSON.encode!(%{"test" => circular}) |> is_binary() + end + end + + describe "encode_to_iodata!/2" do + test "encodes to iodata" do + result = Utils.JSON.encode_to_iodata!([1, 2, 3]) + assert IO.iodata_to_binary(result) == "[1,2,3]" + end + + test "encodes to iodata with pretty option" do + result = Utils.JSON.encode_to_iodata!(%{"key" => "value"}, pretty: true) + binary = IO.iodata_to_binary(result) + assert String.contains?(binary, "\n") + end + end + + describe "encode/2" do + test "returns ok tuple on success" do + {:ok, result} = Utils.JSON.encode(%{"key" => "value"}) + assert is_binary(result) + end + end + + describe "decode!/2" do + test "decodes basic JSON string to term" do + result = Utils.JSON.decode!("[1,2,3]") + assert result == [1, 2, 3] + end + + test "decodes object with string keys" do + result = Utils.JSON.decode!(~s({"key":"value"})) + assert result == %{"key" => "value"} + end + + test "decodes object with atom keys" do + result = Utils.JSON.decode!(~s({"key":"value"}), keys: :atoms) + assert result == %{key: "value"} + end + + test "decodes nested object with atom keys" do + result = Utils.JSON.decode!(~s({"outer":{"inner":"value"}}), keys: :atoms) + assert result == %{outer: %{inner: "value"}} + end + + test "decodes array with nested objects as atoms" do + result = Utils.JSON.decode!(~s([{"key":"value"}]), keys: :atoms) + assert result == [%{key: "value"}] + end + + test "decodes null" do + assert Utils.JSON.decode!("null") == nil + end + + test "decodes booleans" do + assert Utils.JSON.decode!("true") == true + assert Utils.JSON.decode!("false") == false + end + + test "decodes numbers" do + assert Utils.JSON.decode!("42") == 42 + assert Utils.JSON.decode!("3.14") == 3.14 + end + + test "raises on invalid JSON" do + assert_raise JSON.DecodeError, fn -> + Utils.JSON.decode!("{invalid}") + end + end + end + + describe "decode/2" do + test "returns ok tuple on success" do + {:ok, result} = Utils.JSON.decode("[1,2,3]") + assert result == [1, 2, 3] + end + + test "returns error tuple on invalid JSON" do + {:error, _reason} = Utils.JSON.decode("{invalid}") + end + + test "respects atom keys option" do + {:ok, result} = Utils.JSON.decode(~s({"key":"value"}), keys: :atoms) + assert result == %{key: "value"} + end + end + + describe "decode_string/2" do + test "returns decoded term on success" do + result = Utils.JSON.decode_string("[1,2,3]") + assert result == [1, 2, 3] + end + + test "returns nil on invalid JSON" do + result = Utils.JSON.decode_string("{invalid}") + assert result == nil + end + + test "respects atom keys option" do + result = Utils.JSON.decode_string(~s({"key":"value"}), keys: :atoms) + assert result == %{key: "value"} + end + end + + describe "pretty printing" do + test "formats object with proper indentation" do + data = %{"name" => "John", "age" => 30} + result = Utils.JSON.encode!(data, pretty: true) + + # Should have newlines and indentation + assert String.contains?(result, "\n") + assert String.contains?(result, " ") + end + + test "formats array with proper indentation" do + data = [%{"id" => 1}, %{"id" => 2}] + result = Utils.JSON.encode!(data, pretty: true) + + assert String.contains?(result, "\n") + assert String.contains?(result, " ") + end + + test "formats nested structures" do + data = %{"user" => %{"name" => "John", "emails" => ["a@test", "b@test"]}} + result = Utils.JSON.encode!(data, pretty: true) + + # Verify it's valid JSON with pretty format + parsed = Utils.JSON.decode!(result) + assert parsed == data + end + + test "does not format structural characters inside string values" do + data = %{"pattern" => "a:b,c{d}[e]"} + result = Utils.JSON.encode!(data, pretty: true) + + assert Utils.JSON.decode!(result) == data + assert String.contains?(result, ~s("pattern": "a:b,c{d}[e]")) + end + end + + describe "backwards compatibility" do + test "handles data that Jason would encode" do + data = %{ + "string" => "value", + "number" => 42, + "float" => 3.14, + "bool" => true, + "null" => nil, + "array" => [1, 2, 3], + "nested" => %{"key" => "value"} + } + + encoded = Utils.JSON.encode!(data) + decoded = Utils.JSON.decode!(encoded) + + assert decoded == data + end + end +end diff --git a/apps/utils/test/utils/token_instance_helper_test.exs b/apps/utils/test/utils/token_instance_helper_test.exs new file mode 100644 index 00000000000..636a1bff9f9 --- /dev/null +++ b/apps/utils/test/utils/token_instance_helper_test.exs @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.TokenInstanceHelperTest do + use ExUnit.Case, async: false + + alias Utils.TokenInstanceHelper + + setup do + previous = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) + + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, + host_filtering_enabled?: true, + cidr_blacklist: [], + allowed_uri_protocols: ["http", "https"] + ) + + :persistent_term.erase(:parsed_cidr_list) + + bypass = Bypass.open() + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, previous) + :persistent_term.erase(:parsed_cidr_list) + end) + + {:ok, bypass: bypass} + end + + # Bypass listens on 127.0.0.1, which is blacklisted. That is exactly the shape of an + # operator-hosted IPFS/Arweave/Swarm gateway on a private address: the host is trusted + # configuration rather than on-chain data, so it must stay fetchable. + describe "media_type/4" do + test "blocks a private host when the URL comes from on-chain metadata", %{bypass: bypass} do + Bypass.stub(bypass, "HEAD", "/media", fn conn -> + conn |> Plug.Conn.put_resp_header("content-type", "image/jpeg") |> Plug.Conn.resp(200, "") + end) + + assert TokenInstanceHelper.media_type("http://127.0.0.1:#{bypass.port}/media", [], false, true) == nil + end + + test "allows a private host when the URL was resolved to an operator gateway", %{bypass: bypass} do + Bypass.stub(bypass, "HEAD", "/media", fn conn -> + conn |> Plug.Conn.put_resp_header("content-type", "image/jpeg") |> Plug.Conn.resp(200, "") + end) + + assert TokenInstanceHelper.media_type("http://127.0.0.1:#{bypass.port}/media", [], false, false) == + {"image", "jpeg"} + end + end + + describe "media_type_detailed/3" do + test "blocks a private host by default", %{bypass: bypass} do + Bypass.stub(bypass, "HEAD", "/media", fn conn -> + conn |> Plug.Conn.put_resp_header("content-type", "image/jpeg") |> Plug.Conn.resp(200, "") + end) + + assert {:error, reason} = TokenInstanceHelper.media_type_detailed("http://127.0.0.1:#{bypass.port}/media", []) + assert reason =~ "blacklist" + end + + test "allows a private host when host validation is waived", %{bypass: bypass} do + Bypass.stub(bypass, "HEAD", "/media", fn conn -> + conn |> Plug.Conn.put_resp_header("content-type", "image/jpeg") |> Plug.Conn.resp(200, "") + end) + + assert TokenInstanceHelper.media_type_detailed("http://127.0.0.1:#{bypass.port}/media", [], false) == + {:ok, {"image", "jpeg"}} + end + end +end diff --git a/apps/utils/test/utils/url_validator_test.exs b/apps/utils/test/utils/url_validator_test.exs new file mode 100644 index 00000000000..c7a5cd60bd3 --- /dev/null +++ b/apps/utils/test/utils/url_validator_test.exs @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.UrlValidatorTest do + use ExUnit.Case, async: false + + alias Utils.UrlValidator + + setup do + previous = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) + + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, + host_filtering_enabled?: true, + cidr_blacklist: [], + allowed_uri_protocols: ["http", "https"] + ) + + :persistent_term.erase(:parsed_cidr_list) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, previous) + :persistent_term.erase(:parsed_cidr_list) + end) + + :ok + end + + describe "validate_uri/1 with IPv4 literals" do + test "blocks reserved IPv4 ranges" do + for host <- [ + "127.0.0.1", + "169.254.169.254", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "0.0.0.0", + "255.255.255.255" + ] do + assert UrlValidator.validate_uri("http://#{host}/metadata.json") == {:error, :blacklist}, + "expected #{host} to be blacklisted" + end + end + + test "allows public IPv4 literals" do + for host <- ["8.8.8.8", "1.1.1.1"] do + assert UrlValidator.validate_uri("http://#{host}/metadata.json") == :ok, + "expected #{host} to be allowed" + end + end + end + + describe "validate_uri/1 with IPv6 literals (regression for IPv4-only reserved ranges)" do + test "blocks IPv6 loopback, ULA and link-local" do + for host <- ["[::1]", "[fd00::1]", "[fe80::1]"] do + assert UrlValidator.validate_uri("http://#{host}/metadata.json") == {:error, :blacklist}, + "expected #{host} to be blacklisted" + end + end + + test "blocks IPv4-mapped IPv6 pointing at internal addresses" do + for host <- ["[::ffff:127.0.0.1]", "[::ffff:169.254.169.254]"] do + assert UrlValidator.validate_uri("http://#{host}/metadata.json") == {:error, :blacklist}, + "expected #{host} to be blacklisted" + end + end + + test "allows public IPv6 literals" do + assert UrlValidator.validate_uri("http://[2606:4700:4700::1111]/metadata.json") == :ok + end + end + + describe "validate_uri/1 general validation" do + test "rejects disallowed protocols" do + assert UrlValidator.validate_uri("ftp://8.8.8.8/metadata.json") == {:error, :disallowed_protocol} + end + + test "rejects empty host" do + assert UrlValidator.validate_uri("/just/a/path") == {:error, :empty_host} + end + + test "rejects non-printable input" do + assert UrlValidator.validate_uri("http://8.8.8.8/" <> <<0xFF>>) == {:error, :not_printable} + end + end + + describe "validate_uri/1 with operator cidr_blacklist" do + test "blocks a host added to the configured blacklist" do + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Helper, + host_filtering_enabled?: true, + cidr_blacklist: ["8.8.8.8/32"], + allowed_uri_protocols: ["http", "https"] + ) + + :persistent_term.erase(:parsed_cidr_list) + + assert UrlValidator.validate_uri("http://8.8.8.8/metadata.json") == {:error, :blacklist} + end + end +end diff --git a/config/config.exs b/config/config.exs index 7f77b099441..8717a7b9143 100644 --- a/config/config.exs +++ b/config/config.exs @@ -24,7 +24,17 @@ for config <- "../apps/*/config/config.exs" |> Path.expand(__DIR__) |> Path.wild import_config config end -config :phoenix, :json_library, Jason +config :mime, :types, %{ + "application/csv" => ["csv"], + "text/csv" => ["csv"] +} + +config :mime, :extensions, %{ + "csv" => "application/csv" +} + +config :phoenix, :json_library, Utils.JSON +config :postgrex, :json_library, Utils.JSON config :logger, :default_formatter, format: "$dateT$time $metadata[$level] $message\n" diff --git a/config/config_helper.exs b/config/config_helper.exs index ce98d6df0ee..2cd631303ce 100644 --- a/config/config_helper.exs +++ b/config/config_helper.exs @@ -35,7 +35,6 @@ defmodule ConfigHelper do ext_repos = [ {parse_bool_env_var("BRIDGED_TOKENS_ENABLED"), Explorer.Repo.BridgedTokens}, - {parse_bool_env_var("MUD_INDEXER_ENABLED"), Explorer.Repo.Mud}, {parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), Explorer.Repo.ShrunkInternalTransactions}, {mode() in [:indexer, :api], Explorer.Repo.EventNotifications} ] @@ -379,7 +378,7 @@ defmodule ConfigHelper do def parse_json_env_var(env_var, default_value \\ "{}") do env_var |> safe_get_env(default_value) - |> Jason.decode!() + |> JSON.decode!() rescue err -> raise "Invalid JSON in environment variable #{env_var}: #{inspect(err)}" end @@ -388,7 +387,7 @@ defmodule ConfigHelper do with {:ok, map} <- env_var |> safe_get_env(default_value) - |> Jason.decode() do + |> JSON.decode() do for {key, value} <- map, into: %{}, do: {String.to_atom(key), value} else {:error, error} -> raise "Invalid JSON in environment variable #{env_var}: #{inspect(error)}" diff --git a/config/runtime.exs b/config/runtime.exs index 70d69714adc..d626abd900c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -196,6 +196,8 @@ config :block_scout_web, :api_rate_limit, do: BlockScoutWeb.RateLimit.Hammer.ETS, else: BlockScoutWeb.RateLimit.Hammer.Redis ), + recaptcha_disabled_limit_multiplier: + ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_RECAPTCHA_DISABLED_LIMIT_MULTIPLIER", 2, min: 1), config_url: ConfigHelper.parse_url_env_var("API_RATE_LIMIT_CONFIG_URL") config :block_scout_web, :remote_ip, @@ -287,8 +289,14 @@ config :ethereum_jsonrpc, EthereumJSONRPC.HTTP, %{"Content-Type" => "application/json"} |> Map.merge(ConfigHelper.parse_json_env_var("ETHEREUM_JSONRPC_HTTP_HEADERS", "{}")) |> Map.to_list(), - gzip_enabled?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED", "false"), - batch_size: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_HTTP_BATCH_SIZE", 500) + batch_size: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_HTTP_BATCH_SIZE", 500), + request_compression_heavy_methods_enabled?: + ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_HEAVY_METHODS_ENABLED", "true"), + request_compression_all_methods_enabled?: + ConfigHelper.parse_bool_env_var( + "ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_ALL_METHODS_ENABLED", + "false" + ) config :ethereum_jsonrpc, EthereumJSONRPC.Geth, block_traceable?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_GETH_TRACE_BY_BLOCK"), @@ -356,6 +364,13 @@ config :explorer, 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.PagingOptions, max_page_size: ConfigHelper.parse_integer_env_var("MAX_ITEMS_PER_PAGE", 100) + +config :explorer, Explorer.EthRPC, + extended_proxy_methods_enabled: + ConfigHelper.parse_bool_env_var("API_ETH_RPC_EXTENDED_PROXY_METHODS_ENABLED", "false"), + disable_core_proxy_methods: ConfigHelper.parse_bool_env_var("API_ETH_RPC_DISABLE_CORE_PROXY_METHODS", "false") + config :explorer, Explorer.Chain.Health.Monitor, check_interval: ConfigHelper.parse_time_env_var("HEALTH_MONITOR_CHECK_INTERVAL", "1m"), healthy_blocks_period: ConfigHelper.parse_time_env_var("HEALTH_MONITOR_BLOCKS_PERIOD", "5m"), @@ -435,6 +450,9 @@ config :explorer, Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCount, config :explorer, Explorer.Chain.Cache.OptimismFinalizationPeriod, enabled: ConfigHelper.chain_type() == :optimism +config :explorer, Explorer.Repo.LockTimeout, + timeout: ConfigHelper.parse_time_env_var("API_OPTIONAL_QUERIES_LOCK_TIMEOUT", "100ms") + config :explorer, Explorer.Chain.Cache.Counters.AddressCounters, ttl: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_COUNTERS_TTL", "2h"), max_dirty_markers: ConfigHelper.parse_integer_env_var("CACHE_ADDRESS_COUNTERS_MAX_DIRTY_MARKERS", 1_000_000, min: 1) @@ -483,6 +501,11 @@ config :explorer, Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount enable_consolidation: ConfigHelper.chain_type() == :optimism, cache_period: ConfigHelper.parse_time_env_var("CACHE_OPTIMISM_LAST_OUTPUT_ROOT_SIZE_COUNTER_PERIOD", "5m") +config :explorer, Explorer.Chain.Cache.Counters.Optimism.DepositsCount, + enabled: ConfigHelper.chain_type() == :optimism, + enable_consolidation: ConfigHelper.chain_type() == :optimism, + cache_period: ConfigHelper.parse_time_env_var("CACHE_OPTIMISM_DEPOSITS_COUNTER_PERIOD", "1h") + config :explorer, Explorer.Chain.Cache.Counters.Transactions24hCount, enabled: true, cache_period: ConfigHelper.parse_time_env_var("CACHE_TRANSACTIONS_24H_STATS_PERIOD", "1h"), @@ -717,7 +740,9 @@ config :explorer, Explorer.ThirdPartyIntegrations.Sourcify, server_url: ConfigHelper.parse_url_env_var("SOURCIFY_SERVER_URL", "https://sourcify.dev/server"), enabled: ConfigHelper.parse_bool_env_var("SOURCIFY_INTEGRATION_ENABLED"), chain_id: System.get_env("CHAIN_ID"), - repo_url: ConfigHelper.parse_url_env_var("SOURCIFY_REPO_URL", "https://repo.sourcify.dev/contracts") + repo_url: ConfigHelper.parse_url_env_var("SOURCIFY_REPO_URL", "https://repo.sourcify.dev"), + verification_poll_interval_ms: ConfigHelper.parse_time_env_var("SOURCIFY_POLL_INTERVAL", "3s"), + verification_max_attempts: ConfigHelper.parse_integer_env_var("SOURCIFY_POLL_MAX_ATTEMPTS", 20) config :explorer, Explorer.ThirdPartyIntegrations.SolidityScan, platform_id: System.get_env("SOLIDITYSCAN_PLATFORM_ID", "16"), @@ -860,6 +885,16 @@ config :explorer, Explorer.Migrator.TokenTransferTokenType, batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_TOKEN_TRANSFER_TOKEN_TYPE_BATCH_SIZE", 100), concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_TOKEN_TRANSFER_TOKEN_TYPE_CONCURRENCY", 1) +config :explorer, Explorer.Migrator.BackfillScaledUIAmountTokens, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_SCALED_UI_AMOUNT_TOKENS_BATCH_SIZE", 100), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_SCALED_UI_AMOUNT_TOKENS_CONCURRENCY", 1) + +config :explorer, Explorer.Migrator.SanitizeScaledUIAmountTokenTransferTypes, + batch_size: + ConfigHelper.parse_integer_env_var("MIGRATION_SANITIZE_SCALED_UI_AMOUNT_TOKEN_TRANSFER_TYPES_BATCH_SIZE", 100), + concurrency: + ConfigHelper.parse_integer_env_var("MIGRATION_SANITIZE_SCALED_UI_AMOUNT_TOKEN_TRANSFER_TYPES_CONCURRENCY", 1) + config :explorer, Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers, batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_SANITIZE_INCORRECT_NFT_BATCH_SIZE", 100), concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_SANITIZE_INCORRECT_NFT_CONCURRENCY", 1), @@ -912,6 +947,22 @@ config :explorer, Explorer.Migrator.BackfillMultichainSearchDB, concurrency: 1, batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_MULTICHAIN_SEARCH_BATCH_SIZE", 10) +max_block_number = + ConfigHelper.parse_integer_or_nil_env_var( + "MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_LAST_BLOCK_NUMBER" + ) + +config :explorer, Explorer.Migrator.BackfillMultichainSearchDbCurrentTokenBalances, + enabled: + !ConfigHelper.parse_bool_env_var("MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_DISABLED") && + !is_nil(System.get_env("MICROSERVICE_MULTICHAIN_SEARCH_URL")) && + !is_nil(max_block_number), + batch_size: + ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_BATCH_SIZE", 100), + concurrency: + ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_CONCURRENCY", 4), + max_block_number: max_block_number + config :explorer, Explorer.Migrator.HeavyDbIndexOperation, check_interval: ConfigHelper.parse_time_env_var("MIGRATION_HEAVY_INDEX_OPERATIONS_CHECK_INTERVAL", "10m") @@ -985,6 +1036,21 @@ config :explorer, Explorer.Migrator.FillInternalTransactionsAddressIds, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY", 10), timeout: ConfigHelper.parse_time_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_TIMEOUT", "5s") +config :explorer, Explorer.Migrator.TransactionHasTokenTransfers, + enabled: ConfigHelper.parse_bool_env_var("MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_ENABLED", "true"), + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_BATCH_SIZE", 30), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_TRANSACTION_HAS_TOKEN_TRANSFERS_CONCURRENCY", 5) + +config :explorer, Explorer.Migrator.FillLogsOptimizedFields, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_BATCH_SIZE", 30), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_CONCURRENCY", 5), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_FILL_LOGS_OPTIMIZED_FIELDS_TIMEOUT", "5s") + +config :explorer, Explorer.Migrator.FillLogsCompressedData, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_LOGS_COMPRESSED_DATA_BATCH_SIZE", 5000), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_LOGS_COMPRESSED_DATA_CONCURRENCY", 10), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_FILL_LOGS_COMPRESSED_DATA_TIMEOUT", "1s") + config :explorer, Explorer.Migrator.BackfillAddressCounters, enabled: !ConfigHelper.parse_bool_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_DISABLED"), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_ADDRESS_COUNTERS_BATCH_SIZE", 10), @@ -1074,8 +1140,6 @@ config :explorer, Explorer.ThirdPartyIntegrations.UniversalProxy, config_url: universal_proxy_config_url, config_json: universal_proxy_config -config :explorer, Explorer.Chain.Mud, enabled: ConfigHelper.parse_bool_env_var("MUD_INDEXER_ENABLED") - config :explorer, Explorer.Chain.Scroll.L1FeeParam, curie_upgrade_block: ConfigHelper.parse_integer_env_var("SCROLL_L2_CURIE_UPGRADE_BLOCK", 0), scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_SCALAR_INIT", 0), @@ -1149,6 +1213,20 @@ disable_multichain_search_db_export_counters_queue_fetcher = optimism_l2_isthmus_timestamp = ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP") +superchain_config_file_path = + case System.get_env("INDEXER_OPTIMISM_SUPERCHAIN_CONFIG_FILE_PATH") do + nil -> + nil + + value -> + value + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + config :indexer, block_transformer: ConfigHelper.block_transformer(), chain_id: System.get_env("CHAIN_ID"), @@ -1259,6 +1337,13 @@ config :indexer, Indexer.Fetcher.OnDemand.ContractCode, config :indexer, Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, threshold: ConfigHelper.parse_time_env_var("TOKEN_INSTANCE_METADATA_REFETCH_ON_DEMAND_FETCHER_THRESHOLD", "5s") +config :indexer, Indexer.Fetcher.TokenInstance.MediaType.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_TOKEN_INSTANCE_MEDIA_TYPE_FETCHER") + +config :indexer, Indexer.Fetcher.TokenInstance.MediaType, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_CONCURRENCY", 5), + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_BATCH_SIZE", 10) + config :indexer, Indexer.Fetcher.BlockReward.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_BLOCK_REWARD_FETCHER") @@ -1529,6 +1614,7 @@ config :indexer, Indexer.Fetcher.Optimism.Interop.MultichainExport.Supervisor, ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_OPTIMISM_INTEROP_MULTICHAIN_EXPORT", "true") config :indexer, Indexer.Fetcher.Optimism, + superchain_config_file_path: superchain_config_file_path, optimism_l1_rpc: System.get_env("INDEXER_OPTIMISM_L1_RPC"), optimism_l1_system_config: System.get_env("INDEXER_OPTIMISM_L1_SYSTEM_CONFIG_CONTRACT"), l1_eth_get_logs_range_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_L1_ETH_GET_LOGS_RANGE_SIZE", 250), @@ -1592,7 +1678,9 @@ config :indexer, Indexer.Fetcher.Optimism.OperatorFee, init_limit: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_INIT_QUERY_LIMIT", 1_000) config :indexer, Indexer.Fetcher.Optimism.OperatorFee.Supervisor, - disabled?: is_nil(optimism_l2_isthmus_timestamp) or ConfigHelper.chain_type() != :optimism + disabled?: + (is_nil(optimism_l2_isthmus_timestamp) and is_nil(superchain_config_file_path)) or + ConfigHelper.chain_type() != :optimism config :indexer, Indexer.Fetcher.Withdrawal.Supervisor, disabled?: System.get_env("INDEXER_DISABLE_WITHDRAWALS_FETCHER", "true") == "true" diff --git a/config/runtime/dev.exs b/config/runtime/dev.exs index 13e0b0f7f95..cc5ea7ca236 100644 --- a/config/runtime/dev.exs +++ b/config/runtime/dev.exs @@ -92,17 +92,6 @@ config :explorer, Explorer.Repo.Account, pool_size: ConfigHelper.parse_integer_env_var("ACCOUNT_POOL_SIZE", 10), queue_target: queue_target -database_mud = if System.get_env("MUD_DATABASE_URL"), do: nil, else: database -hostname_mud = if System.get_env("MUD_DATABASE_URL"), do: nil, else: hostname - -# Configure MUD indexer database -config :explorer, Explorer.Repo.Mud, - database: database_mud, - hostname: hostname_mud, - url: ExplorerConfigHelper.get_mud_db_url(), - pool_size: ConfigHelper.parse_integer_env_var("MUD_POOL_SIZE", 10), - queue_target: queue_target - # Configure Suave database config :explorer, Explorer.Repo.Suave, database: database, diff --git a/config/runtime/prod.exs b/config/runtime/prod.exs index 3ce0685efce..3a6341956ee 100644 --- a/config/runtime/prod.exs +++ b/config/runtime/prod.exs @@ -88,18 +88,6 @@ config :explorer, ] |> Keyword.merge(ExplorerConfigHelper.ssl_options(account_db_url)) -mud_db_url = ExplorerConfigHelper.get_mud_db_url() - -# Configures Mud database -config :explorer, - Explorer.Repo.Mud, - [ - url: mud_db_url, - pool_size: ConfigHelper.parse_integer_env_var("MUD_POOL_SIZE", 50), - queue_target: queue_target - ] - |> Keyword.merge(ExplorerConfigHelper.ssl_options(mud_db_url)) - suave_db_url = ExplorerConfigHelper.get_suave_db_url() # Configures Suave database diff --git a/cspell.json b/cspell.json index c23d269e3a0..993d57b6af9 100644 --- a/cspell.json +++ b/cspell.json @@ -60,6 +60,9 @@ "Astar", "asterisc", "asyncable", + "attname", + "attnum", + "attrelid", "atoken", "audix", "autodetectfalse", @@ -67,6 +70,7 @@ "autodetecttrue", "Autonity", "autoplay", + "autoredirect", "Autoscout", "Averify", "awesomplete", @@ -158,6 +162,7 @@ "codecv", "codeformat", "codegen", + "codepoints", "coef", "coeff", "coinprice", @@ -173,7 +178,9 @@ "compilerversion", "concache", "cond", + "conkey", "conname", + "contype", "conrelid", "Consolas", "contenttype", @@ -223,6 +230,7 @@ "describedby", "dets", "devcontainer", + "devrel", "dfda", "dialyxir", "differenceby", @@ -268,7 +276,6 @@ "eveem", "evenodd", "eventname", - "evision", "evmversion", "exitor", "exla", @@ -336,6 +343,7 @@ "gzipped", "happygokitty", "hardfork", + "hardforks", "haspopup", "Hazkne", "healthcheck", @@ -358,6 +366,7 @@ "illustr", "inapp", "Incrementer", + "indexdef", "indexrelid", "indexrelname", "indisvalid", @@ -412,6 +421,8 @@ "lockedgold", "loggable", "LPAD", + "ltrim", + "Ltrim", "LUKSO", "luxon", "mabi", @@ -468,8 +479,10 @@ "munchos", "munknownc", "munknowne", + "myapikey", "mydep", "mykey", + "mysecret", "nanomorph", "nbsp", "Nerg", @@ -633,6 +646,7 @@ "selfdestructed", "SENDGRID", "Sepolia", + "setval", "Sérgio", "Shacham", "sharelock", @@ -697,6 +711,7 @@ "sweetalert", "Synthereum", "tabindex", + "tablename", "tablist", "tabpanel", "tarekraafat", @@ -785,6 +800,7 @@ "verifyproxycontract", "verifysourcecode", "viewerjs", + "vitalik", "vname", "volumefrom", "volumeto", @@ -811,6 +827,7 @@ "x-ratelimit-reset", "xact", "xakgj", + "xav", "xbaddress", "xdai", "Xerom", diff --git a/docker-compose/anvil.yml b/docker-compose/anvil.yml index af91124f099..6d6c9612538 100644 --- a/docker-compose/anvil.yml +++ b/docker-compose/anvil.yml @@ -92,3 +92,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 0babc774018..5a77aa74ebe 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -105,3 +105,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 1de9388a643..aa4d7197b77 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -24,8 +24,9 @@ ETHEREUM_JSONRPC_TRACE_URL=http://host.docker.internal:8545/ # ETHEREUM_JSONRPC_ARCHIVE_BALANCES_WINDOW=200 # ETHEREUM_JSONRPC_HTTP_TIMEOUT= # ETHEREUM_JSONRPC_HTTP_HEADERS= -# ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED= # ETHEREUM_JSONRPC_HTTP_BATCH_SIZE= +# ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_HEAVY_METHODS_ENABLED=true +# ETHEREUM_JSONRPC_HTTP_REQUEST_COMPRESSION_ALL_METHODS_ENABLED= # ETHEREUM_JSONRPC_WAIT_PER_TIMEOUT= # ETHEREUM_JSONRPC_GETH_TRACE_BY_BLOCK= # ETHEREUM_JSONRPC_GETH_ALLOW_EMPTY_TRACES= @@ -149,6 +150,7 @@ RELEASE_LINK= # CACHE_TRANSACTIONS_24H_STATS_PERIOD= # CACHE_FRESH_PENDING_TRANSACTIONS_COUNTER_PERIOD= # CACHE_PENDING_OPERATIONS_COUNT_PERIOD= +# CACHE_OPTIMISM_DEPOSITS_COUNTER_PERIOD= # CACHE_SCAM_ADDRESSES_UPDATE_INTERVAL= # CACHE_SCAM_ADDRESSES_MAX_SIZE= # CACHE_TOP_ADDRESSES_TTL= @@ -217,6 +219,7 @@ API_V1_WRITE_METHODS_DISABLED=false # API_GRAPHQL_RATE_LIMIT_BY_IP= # API_GRAPHQL_RATE_LIMIT_BY_IP_TIME_INTERVAL= # API_GRAPHQL_RATE_LIMIT_STATIC_API_KEY= +# API_ETH_RPC_EXTENDED_PROXY_METHODS_ENABLED=false # API_DISABLE_CONTRACT_CREATION_INTERNAL_TRANSACTION_ASSOCIATION=false # RATE_LIMITER_REDIS_URL= # RATE_LIMITER_REDIS_SSL_ENABLED= @@ -275,6 +278,9 @@ API_V1_WRITE_METHODS_DISABLED=false # INDEXER_TOKEN_INSTANCE_SANITIZE_CONCURRENCY= # INDEXER_TOKEN_INSTANCE_REFETCH_BATCH_SIZE=10 # INDEXER_TOKEN_INSTANCE_REFETCH_CONCURRENCY= +# INDEXER_DISABLE_TOKEN_INSTANCE_MEDIA_TYPE_FETCHER=false +# INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_CONCURRENCY=5 +# INDEXER_TOKEN_INSTANCE_MEDIA_TYPE_BATCH_SIZE=10 # INDEXER_TOKEN_INSTANCE_CIDR_BLACKLIST= # INDEXER_TOKEN_INSTANCE_HOST_FILTERING_ENABLED= # INDEXER_TOKEN_INSTANCE_ALLOWED_URI_PROTOCOLS= @@ -301,6 +307,10 @@ API_V1_WRITE_METHODS_DISABLED=false # INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= # INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_MAX_QUEUE_SIZE= # INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_INIT_QUERY_LIMIT= +# MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_DISABLED= +# MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_BATCH_SIZE= +# MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_CONCURRENCY= +# MIGRATION_BACKFILL_MULTICHAIN_SEARCH_CURRENT_TOKEN_BALANCES_LAST_BLOCK_NUMBER= # INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_BATCH_SIZE= # INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_CONCURRENCY= # INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_THRESHOLD= @@ -353,6 +363,7 @@ API_V1_WRITE_METHODS_DISABLED=false # INDEXER_ARBITRUM_DATA_BACKFILL_UNINDEXED_BLOCKS_RECHECK_INTERVAL= # INDEXER_ARBITRUM_DATA_BACKFILL_BLOCKS_DEPTH= # INDEXER_OPTIMISM_L1_RPC= +# INDEXER_OPTIMISM_SUPERCHAIN_CONFIG_FILE_PATH= # INDEXER_OPTIMISM_L1_SYSTEM_CONFIG_CONTRACT= # INDEXER_OPTIMISM_L1_PORTAL_CONTRACT= # INDEXER_OPTIMISM_L1_START_BLOCK= @@ -507,6 +518,7 @@ RE_CAPTCHA_DISABLED=false # API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_MASTER_NAME= # API_RATE_LIMIT_IS_BLOCKSCOUT_BEHIND_PROXY=false # API_RATE_LIMIT_UI_V2_TOKEN_TTL_IN_SECONDS=18000 +# API_RATE_LIMIT_RECAPTCHA_DISABLED_LIMIT_MULTIPLIER=2 # FETCH_REWARDS_WAY=trace_block # INDEXER_ENABLE_PARTIAL_ASYNC_IMPORT= # MICROSERVICE_SC_VERIFIER_ENABLED=true @@ -664,6 +676,8 @@ ACCOUNT_REDIS_URL=redis://redis-db:6379 # SOURCIFY_INTEGRATION_ENABLED=false # SOURCIFY_SERVER_URL= # SOURCIFY_REPO_URL= +# SOURCIFY_POLL_INTERVAL=3s +# SOURCIFY_POLL_MAX_ATTEMPTS=20 # SOLIDITYSCAN_PLATFORM_ID= # SOLIDITYSCAN_CHAIN_ID= # SOLIDITYSCAN_API_TOKEN= @@ -678,9 +692,6 @@ ACCOUNT_REDIS_URL=redis://redis-db:6379 # BRIDGED_TOKENS_POA_OMNI_BRIDGE_MEDIATOR= # BRIDGED_TOKENS_AMB_BRIDGE_MEDIATORS # BRIDGED_TOKENS_FOREIGN_JSON_RPC -# MUD_INDEXER_ENABLED= -# MUD_DATABASE_URL= -# MUD_POOL_SIZE=50 # WETH_TOKEN_TRANSFERS_FILTERING_ENABLED=false # WHITELISTED_WETH_CONTRACTS= # PUBLIC_METRICS_ENABLED= diff --git a/docker-compose/erigon.yml b/docker-compose/erigon.yml index c91739d076b..99bb3f1e058 100644 --- a/docker-compose/erigon.yml +++ b/docker-compose/erigon.yml @@ -85,3 +85,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/external-db.yml b/docker-compose/external-db.yml index 082927beb05..f288d717139 100644 --- a/docker-compose/external-db.yml +++ b/docker-compose/external-db.yml @@ -68,3 +68,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/external-frontend.yml b/docker-compose/external-frontend.yml index 775ea67de6c..ea0b5c248cf 100644 --- a/docker-compose/external-frontend.yml +++ b/docker-compose/external-frontend.yml @@ -80,4 +80,7 @@ services: - stats extends: file: ./services/nginx.yml - service: proxy \ No newline at end of file + service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/geth-clique-consensus.yml b/docker-compose/geth-clique-consensus.yml index 75a0b457e79..0f474dcd874 100644 --- a/docker-compose/geth-clique-consensus.yml +++ b/docker-compose/geth-clique-consensus.yml @@ -86,3 +86,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/geth.yml b/docker-compose/geth.yml index deed14c686b..5041b029626 100644 --- a/docker-compose/geth.yml +++ b/docker-compose/geth.yml @@ -85,3 +85,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/hardhat-network.yml b/docker-compose/hardhat-network.yml index a2a54e48ef5..2c1342f6e04 100644 --- a/docker-compose/hardhat-network.yml +++ b/docker-compose/hardhat-network.yml @@ -92,3 +92,6 @@ services: extends: file: ./services/nginx.yml service: proxy + +volumes: + backend-dets: diff --git a/docker-compose/no-services.yml b/docker-compose/no-services.yml index 99b1ce18a61..91942ae655b 100644 --- a/docker-compose/no-services.yml +++ b/docker-compose/no-services.yml @@ -61,3 +61,6 @@ services: service: proxy volumes: - "./proxy/explorer.conf.template:/etc/nginx/templates/default.conf.template" + +volumes: + backend-dets: diff --git a/docker-compose/services/backend.yml b/docker-compose/services/backend.yml index cb1cb54de02..2e109d438d9 100644 --- a/docker-compose/services/backend.yml +++ b/docker-compose/services/backend.yml @@ -14,4 +14,4 @@ services: - ../envs/common-blockscout.env volumes: - ./logs/:/app/logs/ - - ./dets/:/app/dets/ + - backend-dets:/app/dets/ diff --git a/docker/Dockerfile b/docker/Dockerfile index 1f3c715cddf..fbd2eec699a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,7 +3,8 @@ FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-3.22.2 AS builder-deps WORKDIR /app RUN apk --no-cache --update add \ - alpine-sdk gmp-dev automake libtool inotify-tools autoconf python3 file gcompat libstdc++ curl ca-certificates git make bash + alpine-sdk gmp-dev automake libtool inotify-tools autoconf python3 file gcompat libstdc++ curl ca-certificates git make bash \ + ffmpeg-dev pkgconfig # Cache elixir deps COPY mix.exs mix.lock ./ @@ -40,8 +41,6 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} -ARG MUD_INDEXER_ENABLED -ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} # Run backend compilation RUN mix compile @@ -60,7 +59,7 @@ ARG BLOCKSCOUT_GROUP=blockscout ARG BLOCKSCOUT_UID=10001 ARG BLOCKSCOUT_GID=10001 -RUN apk --no-cache --update add jq curl && \ +RUN apk --no-cache --update add jq curl ffmpeg && \ addgroup --system --gid ${BLOCKSCOUT_GID} ${BLOCKSCOUT_GROUP} && \ adduser --system --uid ${BLOCKSCOUT_UID} --ingroup ${BLOCKSCOUT_GROUP} --disabled-password --home /app ${BLOCKSCOUT_USER} @@ -78,8 +77,6 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} -ARG MUD_INDEXER_ENABLED -ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} diff --git a/docker/Makefile b/docker/Makefile index 0e64afc7d75..56af956a735 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.3.3' +RELEASE_VERSION ?= '12.0.0' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/docker/oldUI.Dockerfile b/docker/oldUI.Dockerfile index 29b7f7e083b..661bb04827a 100644 --- a/docker/oldUI.Dockerfile +++ b/docker/oldUI.Dockerfile @@ -56,8 +56,6 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} -ARG MUD_INDEXER_ENABLED -ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} # Run backend compilation RUN mix compile @@ -94,8 +92,6 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} -ARG MUD_INDEXER_ENABLED -ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} diff --git a/mix.exs b/mix.exs index 8b30f3c24cb..5210bf34a4d 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "11.3.3", + version: "12.0.0", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/mix.lock b/mix.lock index 8f8cf2bffd0..0e1eafccc85 100644 --- a/mix.lock +++ b/mix.lock @@ -23,6 +23,7 @@ "cldr_utils": {:hex, :cldr_utils, "2.29.7", "2189bc0117efe91c684558e79174b45eb43135595b7d1fe9b57f53917be195c1", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4bddcd597fee34e2d2829ae9ef62bcfef8d97ae5f6b75f0c6ee37a3db31aa73a"}, "cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"}, "cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"}, + "color": {:hex, :color, "0.13.0", "068110e5397ac5d3c9f97658282e0f4ab9a32468be6d7a2a91a8804e67b228d7", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "de127946869931d418bac2d82dc29feae1a8f5f729f135922fbccf0059a58ab2"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"}, @@ -50,7 +51,6 @@ "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "evil_crc32c": {:hex, :evil_crc32c, "0.2.9", "9e4082418c4cc27c88869c1773f114f15a9257121ebb019037bea60c75c601d2", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "86ba14f8aad0fe55eda0b57e79e3d8dffe39ea326b753eac12463ae2bec757bf"}, - "evision": {:hex, :evision, "0.2.17", "0d5aed4fdb6945f100642cfd41531d187963af984c60ec367719106dda7ed864", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "565bc8700d23cd2429444387a1cf0e53fe7a85892e9276ce62a9cef23350c11f"}, "ex_abi": {:hex, :ex_abi, "0.8.5", "1f48b716769cf0443fd87de05ba70f4bc9f484a86de63bb139102fd241781158", [:mix], [{:ex_keccak, "~> 0.7.8", [hex: :ex_keccak, repo: "hexpm", optional: true]}], "hexpm", "bb8ca507d9cc8e96c06746f2bfec7e4d06d40f62fefc4e16a996e3a7de0a61d9"}, "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, @@ -89,7 +89,7 @@ "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "image": {:hex, :image, "0.63.0", "03efcfeb1cfaac10d6ab364acce2bfa35e6b33b08947f291fdc17df796b36226", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "63b39a312f889bb61a04a4957977a7cd3fa23974ff56de3feebf46f69c5fa60e"}, + "image": {:hex, :image, "0.69.0", "5f293418e004e09239ad787839bc32556f242058485eada92ae160a2f2daefff", [:mix], [{:color, "~> 0.13", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.12", [hex: :exla, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.12", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "5b964cca89877bbdd4236f5ed9563ceab7f501688b93bba852b10d741e59e878"}, "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, @@ -118,6 +118,7 @@ "mox": {:hex, :mox, "1.3.2", "f34ca4331b1cce3125609c6de674739fe5f3df0d68df25510d64b6a94b2246de", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "97918a185e727f3128a826f01827b5b8168e1b815bda19eb4192c3ffdb6a8054"}, "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, + "nimble_lz4": {:hex, :nimble_lz4, "1.2.0", "0027d6a50468d349876776a648dbd2b59dff7160855c7c9feed3b60ad9dc1347", [:mix], [{:rustler, "~> 0.38", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.9", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "817e74d16030491b9a43985db58e4454c618e3bf27d415e181769bdde4fe39d7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, @@ -159,7 +160,8 @@ "redix": {:hex, :redix, "1.9.2", "a75b2b5f1facc06c79c6d2c5cf5bfcb5a49f84f46ede80aaa97b01c28c6cc8a6", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b0b644de27d9f25d3664e6bb7c11ee150255930f08a362655dd8aba9b0a6a1"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, - "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"}, + "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "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.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, @@ -187,5 +189,6 @@ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, - "ymlr": {:hex, :ymlr, "5.1.6", "247479328fd5ea1e222ee59ecd5571ab2885cf1a6329aea458d4944cd035cfc1", [:mix], [], "hexpm", "b36d7c72384d2cb2094d2a3c8b0c3b12303e0ccd1b7b12333b81d75b1fe0b53a"}, + "xav": {:hex, :xav, "0.11.0", "95c0a71aa1d1e2ded9d84c63659878c2bf836a1212d413271458447f310c7b93", [:make, :mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nx, "~> 0.7", [hex: :nx, repo: "hexpm", optional: true]}], "hexpm", "de754c6047dc28f7492dfc2bfe5f77d0a3e2c386f471da1ccfca8b0ffb47c4d7"}, + "ymlr": {:hex, :ymlr, "5.1.6", "247479328fd5ea1e222ee59ecd5571ab2885cf1a6329aea458d4944cd035cfc1", [:mix], [], "hexpm", "b36d7c72384d2cb2094d2a3c8b0c3b12303e0ccd1b7b12333b81d75b1fe0b53a"} } diff --git a/rel/config.exs b/rel/config.exs index f5a401740c1..5c509437454 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.3.3" + set version: "12.0.0" set applications: [ :runtime_tools, block_scout_web: :permanent, diff --git a/types-package/README.md b/types-package/README.md index 633f6ab41b1..024656f11cb 100644 --- a/types-package/README.md +++ b/types-package/README.md @@ -2,10 +2,11 @@ TypeScript types generated from Blockscout OpenAPI specs via [openapi-typescript](https://openapi-ts.dev): +- **shorthands** (`schemas`, `operations`, `paths`) — the recommended entry point; flat lookups over the merged spec, spanning every chain type. See [Usage](#usage) +- **merged** — every schema, path, and operation from all specs below combined into one namespace (for universal frontends that handle any chain type at runtime); see [Merged schemas](#merged-schemas) - **publicApi** — default public API (`BlockScoutWeb.Specs.Public`, no `CHAIN_TYPE`) - **privateApi** — account API (`BlockScoutWeb.Specs.Private`) -- **chain namespaces** — chain-specific public API (`CHAIN_TYPE` set; matches [generate-swagger.yml](https://github.com/blockscout/blockscout/blob/master/.github/workflows/generate-swagger.yml), excluding deprecated MUD) -- **merged** — every schema from all specs above combined into one namespace (for universal frontends that handle any chain type at runtime); see [Merged schemas](#merged-schemas) +- **chain namespaces** — chain-specific public API (`CHAIN_TYPE` set; matches [generate-swagger.yml](https://github.com/blockscout/blockscout/blob/master/.github/workflows/generate-swagger.yml)) Build artifacts (`openapi/`, `dist/`) are gitignored; run `npm run build` after clone. @@ -30,29 +31,35 @@ npm run build ## Usage -Types are grouped by spec name in the package entry: +### Shorthands (recommended) + +Three top-level exports flatten the most common lookups against the merged spec, so you never index through `components["schemas"]` or `["responses"][200]["content"]["application/json"]` by hand. Prefer these for everyday use: ```ts -import type { publicApi, privateApi, arbitrum, optimismCelo } from "@blockscout/api-types"; +import type { schemas, operations, paths } from "@blockscout/api-types"; -type AddressResponse = publicApi.components["schemas"]["AddressResponse"]; +// `schemas` — a model by name (merged across all chains; chain-specific props optional). +type Transaction = schemas["Transaction"]; -type GetAddressParams = - publicApi.paths["/v2/addresses/{address_hash_param}"]["get"]["parameters"]; +// `operations` — keyed by short operationId, with `json` / `params` / `requestBody`. +type TxJson = operations["TransactionController.transaction"]["json"]; +type TxParams = operations["TransactionController.transaction"]["params"]; +type EthCallBody = operations["EthController.eth_call"]["requestBody"]; -type ArbitrumPath = arbitrum.paths[keyof arbitrum.paths]; +// `paths` — the 200 `application/json` body keyed by path, then HTTP method. +type Blocks = paths["/api/v2/blocks"]["get"]; ``` -`publicApi` / `privateApi` avoid TypeScript reserved words (`public`, `private`). `optimism-celo` is exported as `optimismCelo` (hyphen is invalid in identifiers). +Notes: -## Merged schemas +- **operationIds are shortened** — the controller module prefix (`BlockScoutWeb.API.V2.`, `BlockScoutWeb.Account.API.V2.`, `BlockScoutWeb.API.Legacy.`) is stripped, so `BlockScoutWeb.API.V2.TransactionController.transaction` becomes `TransactionController.transaction`. +- `json` is the 200 `application/json` response body; `params` is the request `query`/`path` parameters; `requestBody` is the `application/json` request body (`never` for endpoints without one). +- `paths` only exposes the HTTP methods an endpoint actually defines; missing methods are absent, not `never`. +- All three are derived from `merged`, so they already span every chain type. Reach for the `merged`/per-chain namespaces below only when you need something these don't cover (non-200 responses, response headers, raw path items). -`scripts/merge-specs.mjs` merges all generated specs (public → private → chains alphabetically) into `openapi/merged.yaml`, which compiles to `dist/merged.schema.ts` (exported as `merged`). Specs must already exist; regenerate them with `generate:spec` only when the API changes: +### Merged schemas -```bash -npm run generate:spec:merged # writes openapi/merged.yaml -npx openapi-typescript openapi/merged.yaml -o dist/merged.schema.ts --export-type --alphabetize=true -``` +The shorthands above are built on the **merged** namespace — every schema, path, and operation from all specs combined, so a universal frontend can handle any chain type at runtime from a single type universe. Import `merged` directly when you need lookups the shorthands don't expose (non-200 responses, response headers, raw path items): ```ts import type { merged } from "@blockscout/api-types"; @@ -61,16 +68,25 @@ import type { merged } from "@blockscout/api-types"; type Transaction = merged.components["schemas"]["Transaction"]; // Paths/operations are merged too, referencing the merged models: -type Tx = merged.paths["/v2/transactions/{transaction_hash_param}"]["get"]["responses"][200]; +type Tx = merged.paths["/api/v2/transactions/{transaction_hash_param}"]["get"]["responses"][200]; ``` -Merge semantics: +### Per-spec namespaces -- **Schemas** — `properties` are unioned; `required` is the intersection across specs defining a schema, so chain-specific properties are optional. Sub-objects defined in a single spec keep their `required` (e.g. `arbitrum.gas_used_for_l1` stays required when `arbitrum` is present). `enum` values are unioned (e.g. `transaction_types` gains chain-specific entries). -- **Paths & operations** — unioned across specs; chain-only endpoints (e.g. `/v2/arbitrum/batches`) are included. Shared operations are deep-merged, with `parameters` matched by (in, name) and their `schema`s unioned (e.g. enum query params gain chain-specific values). All operation `$ref`s point at `components.schemas`, so they reference the merged models automatically. -- Irreconcilable schema shapes (e.g. ethereum redefines `Status` as a beacon-deposit enum) become `anyOf` unions and are reported as warnings on stderr. +Types are also grouped by spec name in the package entry: -Point your app at this package via `file:../types-package` or your monorepo workspace. +```ts +import type { publicApi, privateApi, arbitrum, optimismCelo } from "@blockscout/api-types"; + +type AddressResponse = publicApi.components["schemas"]["AddressResponse"]; + +type GetAddressParams = + publicApi.paths["/api/v2/addresses/{address_hash_param}"]["get"]["parameters"]; + +type ArbitrumPath = arbitrum.paths[keyof arbitrum.paths]; +``` + +`publicApi` / `privateApi` avoid TypeScript reserved words (`public`, `private`). `optimism-celo` is exported as `optimismCelo` (hyphen is invalid in identifiers) ## Chain types @@ -79,3 +95,13 @@ Synced with `.github/workflows/generate-swagger.yml` via `scripts/chain-types.sh `arbitrum`, `arc`, `blackfort`, `ethereum`, `filecoin`, `neon`, `optimism`, `optimism-celo`, `rsk`, `scroll`, `shibarium`, `stability`, `suave`, `zetachain`, `zilliqa`, `zksync` When CI adds or removes a chain type, update `scripts/chain-types.sh` and `index.ts`. + +## Schemas merging + +`scripts/merge-specs.mjs` merges all generated specs (public → private → chains alphabetically) into `openapi/merged.yaml`, which compiles to `dist/merged.schema.ts` (exported as `merged`). + +Merge semantics: + +- **Schemas** — `properties` are unioned; `required` is the intersection across specs defining a schema, so chain-specific properties are optional. Sub-objects defined in a single spec keep their `required` (e.g. `arbitrum.gas_used_for_l1` stays required when `arbitrum` is present). `enum` values are unioned (e.g. `transaction_types` gains chain-specific entries). +- **Paths & operations** — unioned across specs; chain-only endpoints (e.g. `/v2/arbitrum/batches`) are included. Shared operations are deep-merged, with `parameters` matched by (in, name) and their `schema`s unioned (e.g. enum query params gain chain-specific values). All operation `$ref`s point at `components.schemas`, so they reference the merged models automatically. +- Irreconcilable schema shapes (e.g. ethereum redefines `Status` as a beacon-deposit enum) become `anyOf` unions and are reported as warnings on stderr. \ No newline at end of file diff --git a/types-package/index.ts b/types-package/index.ts index 45ad8647652..074ced1c55a 100644 --- a/types-package/index.ts +++ b/types-package/index.ts @@ -14,6 +14,69 @@ export { merged }; /** Shorthand for merged.components["schemas"] */ export type schemas = merged.components["schemas"]; +/* ── Successful-response shorthands (derived from the merged spec) ──────────── + Resolve the 200 `application/json` body of an endpoint without the deep + `…["responses"][200]["content"]["application/json"]` indexing. */ + +type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; + +/** The 200 `application/json` response body of an operation (or `never` if it has none). */ +type OkJson<Operation> = Operation extends { + responses: { 200: { content: { "application/json": infer Body } } }; +} + ? Body + : never; + +/** The request parameters of an operation (`query`, `path`; `header`/`cookie` usually empty). */ +type OperationParams<Operation> = Operation extends { parameters: infer Params } ? Params : never; + +/** + * The `application/json` request body of an operation (or `never` if it has none). + * `requestBody` is optional in the generated types, so the pattern matches it optionally. + */ +type RequestJson<Operation> = Operation extends { requestBody?: infer RequestBody } + ? RequestBody extends { content: { "application/json": infer Body } } + ? Body + : never + : never; + +/** Drop the controller module prefix (v2, account v2, or legacy) from an operationId. */ +type ShortOperationId<Id extends string> = Id extends `BlockScoutWeb.API.V2.${infer Rest}` + ? Rest + : Id extends `BlockScoutWeb.Account.API.V2.${infer Rest}` + ? Rest + : Id extends `BlockScoutWeb.API.Legacy.${infer Rest}` + ? Rest + : Id; + +/** + * Per-operation shape keyed by short operationId: + * - `json`: the 200 `application/json` response body + * - `params`: the request parameters (`query`, `path`; `header`/`cookie` usually empty) + * - `requestBody`: the `application/json` request body (`never` for endpoints without one) + * e.g. `operations["BlockController.internal_transactions"]["json"]`. + */ +export type operations = { + [Id in keyof merged.operations as ShortOperationId<Id & string>]: { + json: OkJson<merged.operations[Id]>; + params: OperationParams<merged.operations[Id]>; + requestBody: RequestJson<merged.operations[Id]>; + }; +}; + +/** + * Successful (200) JSON response bodies keyed by path, then HTTP method, e.g. + * `paths["/v2/blocks/{block_hash_or_number_param}/internal-transactions"]["get"]`. + * Only the methods an endpoint actually defines are present. + */ +export type paths = { + [Path in keyof merged.paths]: { + [Method in HttpMethod as [merged.paths[Path][Method]] extends [never] ? never : Method]: OkJson< + merged.paths[Path][Method] + >; + }; +}; + /** Chain-specific public API (`CHAIN_TYPE` set). */ export * as arbitrum from "./dist/chains/arbitrum.schema"; export * as arc from "./dist/chains/arc.schema"; diff --git a/types-package/package-lock.json b/types-package/package-lock.json index 1b246604a3e..5f297be86e6 100644 --- a/types-package/package-lock.json +++ b/types-package/package-lock.json @@ -64,9 +64,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.15", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", - "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "version": "1.34.16", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz", + "integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==", "dev": true, "license": "MIT", "dependencies": { @@ -75,7 +75,7 @@ "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" @@ -86,10 +86,20 @@ } }, "node_modules/@redocly/openapi-core/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/types-package/scripts/chain-types.sh b/types-package/scripts/chain-types.sh index bcb5bc9df6a..e6d8a3df563 100755 --- a/types-package/scripts/chain-types.sh +++ b/types-package/scripts/chain-types.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Chain types from .github/workflows/generate-swagger.yml (excluding "default" and MUD). +# Chain types from .github/workflows/generate-swagger.yml (excluding "default"). CHAIN_TYPES=( arbitrum arc diff --git a/types-package/scripts/generate-spec.sh b/types-package/scripts/generate-spec.sh index 6b6a3b613ac..03e627b3264 100755 --- a/types-package/scripts/generate-spec.sh +++ b/types-package/scripts/generate-spec.sh @@ -12,7 +12,6 @@ source "$SCRIPT_DIR/chain-types.sh" cd "$REPO_ROOT" mkdir -p "$PACKAGE_ROOT/openapi/chains" -export MUD_INDEXER_ENABLED=false # Emit config-gated fields (e.g. Token.is_bridged) so the types cover the universal frontend. export BRIDGED_TOKENS_ENABLED=true