diff --git a/.circleci/config.yml b/.circleci/config.yml index 03275c1f98..f3ef80acc9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -224,7 +224,7 @@ jobs: paths: - plts - - run: mix dialyzer --halt-exit-status + - run: mix dialyzer eslint: docker: # Ensure .tool-versions matches diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 233b0967a9..0818cf7fbe 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -10,7 +10,7 @@ {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :exact_eq, 14}, {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 14}, ~r/lib\/phoenix\/router.ex/, - {"lib/explorer/chain/search.ex", :pattern_match, 100}, - {"lib/explorer/chain/search.ex", :pattern_match, 283}, - {"lib/explorer/chain/search.ex", :pattern_match, 383} + {"lib/explorer/chain/search.ex", :pattern_match, 101}, + {"lib/explorer/chain/search.ex", :pattern_match, 294}, + {"lib/explorer/chain/search.ex", :pattern_match, 394} ] diff --git a/.env-dev b/.env-dev index 0579bcc34d..6bf3bfc006 100644 --- a/.env-dev +++ b/.env-dev @@ -1,3 +1,5 @@ +export GOLEMBASE_ENABLED=true + export SECRET_BASE_KEY='REPLACE_WITH_GENERATED_SECRET_BASE_KEY' export DATABASE_URL='postgresql://blockscout:l3explorer@localhost:5432/blockscout' diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index fd26e53e91..998751b19d 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -256,7 +256,7 @@ jobs: CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} - name: Run Dialyzer - run: mix dialyzer --halt-exit-status + run: mix dialyzer env: CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} @@ -784,6 +784,7 @@ jobs: mix compile mix test --no-start --exclude no_nethermind env: + GOLEMBASE_ENABLED: "true" # match POSTGRES_PASSWORD for postgres image below PGPASSWORD: postgres # match POSTGRES_USER for postgres image below diff --git a/apps/block_scout_web/README.md b/apps/block_scout_web/README.md index 448152b981..22e81b4804 100644 --- a/apps/block_scout_web/README.md +++ b/apps/block_scout_web/README.md @@ -31,7 +31,7 @@ You can also run IEx (Interactive Elixir): `$ iex -S mix phx.server` (This can b * Build the assets: `cd assets && npm run build` * Format the Elixir code: `mix format` * Lint the Elixir code: `mix credo --strict` -* Run the dialyzer: `mix dialyzer --halt-exit-status` +* Run the dialyzer: `mix dialyzer` * Check the Elixir code for vulnerabilities: `mix sobelow --config` * Update translation templates and translations and check there are no uncommitted changes: `mix gettext.extract --merge` * Lint the JavaScript code: `cd assets && npm run eslint` 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 f9b14893e3..e5201bd668 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -33,6 +33,7 @@ defmodule BlockScoutWeb.Chain do Beacon.Blob, Block, Block.Reward, + GolemBase.Entity, Hash, InternalTransaction, Log, @@ -90,7 +91,8 @@ defmodule BlockScoutWeb.Chain do end @spec from_param(String.t()) :: - {:ok, Address.t() | Block.t() | Transaction.t() | UserOperation.t() | Blob.t()} | {:error, :not_found} + {:ok, Address.t() | Block.t() | Transaction.t() | UserOperation.t() | GolemBase.Entity | Blob.t()} + | {:error, :not_found} def from_param(param) def from_param("0x" <> number_string = param) when byte_size(number_string) == @address_hash_len, @@ -929,6 +931,7 @@ defmodule BlockScoutWeb.Chain do {:error, :not_found} <- hash_to_transaction(hash), {:error, :not_found} <- hash_to_block(hash), {:error, :not_found} <- hash_to_user_operation(hash), + {:error, :not_found} <- hash_to_golembase_entity(hash), {:error, :not_found} <- hash_to_blob(hash) do {:error, :not_found} else @@ -945,6 +948,14 @@ defmodule BlockScoutWeb.Chain do end end + defp hash_to_golembase_entity(hash) do + if Entity.enabled?() do + Entity.hash_to_golembase_entity(hash) + else + {:error, :not_found} + end + end + defp hash_to_blob(hash) do if Application.get_env(:explorer, :chain_type) == :ethereum do BeaconReader.blob(hash, false) diff --git a/apps/block_scout_web/lib/block_scout_web/templates/search/_tile.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/search/_tile.html.eex index 981d1dd803..77eb8551a5 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/search/_tile.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/search/_tile.html.eex @@ -82,6 +82,8 @@ transaction_hash: "0x" <> Base.encode16(@result.transaction_hash, case: :lower) %> <% "user_operation" -> %> <%= "0x" <> Base.encode16(@result.user_operation_hash, case: :lower) %> + <% "golembase_entity" -> %> + <%= "0x" <> Base.encode16(@result.golembase_entity, case: :lower) %> <% "blob" -> %> <%= "0x" <> Base.encode16(@result.blob_hash, case: :lower) %> <% "block" -> %> diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex index d0efb14ddc..0f112de2a2 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex @@ -4,7 +4,7 @@ defmodule BlockScoutWeb.API.V2.SearchView do alias BlockScoutWeb.{BlockView, Endpoint} alias Explorer.Chain - alias Explorer.Chain.{Address, Beacon.Blob, Block, Hash, Transaction, UserOperation} + alias Explorer.Chain.{Address, Beacon.Blob, Block, GolemBase, Hash, Transaction, UserOperation} alias Plug.Conn.Query def render("search_results.json", %{search_results: search_results, next_page_params: next_page_params}) do @@ -137,6 +137,13 @@ defmodule BlockScoutWeb.API.V2.SearchView do } end + def prepare_search_result(%{type: "golembase_entity"} = search_result) do + %{ + "type" => search_result.type, + "golembase_entity" => hash_to_string(search_result.golembase_entity) + } + end + def prepare_search_result(%{type: "blob"} = search_result) do %{ "type" => search_result.type, @@ -189,6 +196,10 @@ defmodule BlockScoutWeb.API.V2.SearchView do %{"type" => "user_operation", "parameter" => to_string(item.hash)} end + defp redirect_search_results(%GolemBase.Entity{} = item) do + %{"type" => "golembase_entity", "parameter" => to_string(item.key)} + end + defp redirect_search_results(%Blob{} = item) do %{"type" => "blob", "parameter" => to_string(item.hash)} 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 2e759956a2..9b559b4709 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 @@ -1,11 +1,52 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do use BlockScoutWeb.ConnCase - alias Explorer.Chain.{Address, Block} + alias Explorer.Chain.{Address, Block, GolemBase.Entity} alias Explorer.Tags.AddressTag alias Plug.Conn.Query describe "/search" do + test "search golembase entity", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_active) + + request = get(conn, "/api/v2/search?q=#{golembase_entity.key}") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + + item = Enum.at(response["items"], 0) + + assert item["type"] == "golembase_entity" + assert item["golembase_entity"] == to_string(golembase_entity.key) + end + end + + test "search golembase entity with status deleted", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_deleted) + + request = get(conn, "/api/v2/search?q=#{golembase_entity.key}") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 0 + assert response["next_page_params"] == nil + end + end + + test "search golembase entity with status expired", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_expired) + + request = get(conn, "/api/v2/search?q=#{golembase_entity.key}") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 0 + assert response["next_page_params"] == nil + end + end + test "search block", %{conn: conn} do block = insert(:block) @@ -1724,6 +1765,37 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end describe "/search/check-redirect" do + test "search golembase entity", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_active) + hash = to_string(golembase_entity.key) + + request = get(conn, "/api/v2/search/check-redirect?q=#{golembase_entity.key}") + + assert %{"redirect" => true, "type" => "golembase_entity", "parameter" => ^hash} = json_response(request, 200) + end + end + + test "search golembase entity with status deleted", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_deleted) + + request = get(conn, "/api/v2/search/check-redirect?q=#{golembase_entity.key}") + + assert %{"redirect" => false, "type" => null, "parameter" => null} = json_response(request, 200) + end + end + + test "search golembase entity with status expired", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_expired) + + request = get(conn, "/api/v2/search/check-redirect?q=#{golembase_entity.key}") + + assert %{"redirect" => false, "type" => null, "parameter" => null} = json_response(request, 200) + end + end + test "finds a consensus block by block number", %{conn: conn} do block = insert(:block) @@ -1822,6 +1894,37 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end describe "/search/quick" do + test "search golembase entity", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_active) + hash = to_string(golembase_entity.key) + + request = get(conn, "/api/v2/search/quick?q=#{golembase_entity.key}") + + assert [%{"golembase_entity" => ^hash, "type" => "golembase_entity"}] = json_response(request, 200) + end + end + + test "search golembase entity with status deleted", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_deleted) + + request = get(conn, "/api/v2/search/quick?q=#{golembase_entity.key}") + + assert [] = json_response(request, 200) + end + end + + test "search golembase entity with status expired", %{conn: conn} do + if Entity.enabled?() do + golembase_entity = insert(:golembase_entity_expired) + + request = get(conn, "/api/v2/search/quick?q=#{golembase_entity.key}") + + assert [] = json_response(request, 200) + end + end + test "check that all categories are in response list", %{conn: conn} do name = "156000" diff --git a/apps/explorer/README.md b/apps/explorer/README.md index c283c1c521..5e53d3b17c 100644 --- a/apps/explorer/README.md +++ b/apps/explorer/README.md @@ -26,7 +26,7 @@ To get BlockScout up and running locally: * Format the Elixir code: `$ mix format` * Lint the Elixir code: `$ mix credo --strict` -* Run the dialyzer: `mix dialyzer --halt-exit-status` +* Run the dialyzer: `mix dialyzer` * Check the Elixir code for vulnerabilities: `$ mix sobelow --config` ### Benchmarking diff --git a/apps/explorer/config/dev.exs b/apps/explorer/config/dev.exs index b6a0f56ad2..ee7333294d 100644 --- a/apps/explorer/config/dev.exs +++ b/apps/explorer/config/dev.exs @@ -51,3 +51,5 @@ config :logger, :token_instances, level: :debug, path: Path.absname("logs/dev/explorer/tokens/token_instances.log"), metadata_filter: [fetcher: :token_instances] + +config :explorer, :environment, :dev diff --git a/apps/explorer/config/prod.exs b/apps/explorer/config/prod.exs index b272b96fb9..84c52e420f 100644 --- a/apps/explorer/config/prod.exs +++ b/apps/explorer/config/prod.exs @@ -59,3 +59,5 @@ config :logger, :token_instances, path: Path.absname("logs/prod/explorer/tokens/token_instances.log"), metadata_filter: [fetcher: :token_instances], rotate: %{max_bytes: 52_428_800, keep: 19} + +config :explorer, :environment, :prod diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 675668e45d..61ef17a13f 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -105,3 +105,5 @@ config :explorer, Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand, enabled: config :explorer, Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand, enabled: false config :tesla, adapter: Explorer.Mock.TeslaAdapter + +config :explorer, :environment, :test diff --git a/apps/explorer/lib/explorer/chain/golem_base/entity.ex b/apps/explorer/lib/explorer/chain/golem_base/entity.ex new file mode 100644 index 0000000000..1a28fcfa2c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/golem_base/entity.ex @@ -0,0 +1,51 @@ +defmodule Explorer.Chain.GolemBase.Entity do + @moduledoc """ + The representation of a Golem Base entity + """ + + import Ecto.Query, only: [where: 2] + + use Explorer.Schema + alias Explorer.Chain + alias Explorer.Chain.Hash + + @type api? :: {:api?, true | false} + + @primary_key false + typed_schema "golem_base_entities" do + field(:key, Hash.Full, primary_key: true, null: false) + field(:status, Ecto.Enum, values: [:active, :deleted, :expired]) + field(:owner, :binary, null: false) + field(:last_updated_at_tx_hash, :binary, null: false) + field(:expires_at_block_number, :integer, null: false) + + timestamps() + end + + def changeset(%__MODULE__{} = golembase_entity, attrs) do + golembase_entity + |> cast(attrs, [:key, :status, :owner, :last_updated_at_tx_hash, :expires_at_block_number]) + |> validate_required([:key, :status, :owner, :last_updated_at_tx_hash, :expires_at_block_number]) + end + + @spec hash_to_golembase_entity(Hash.Full.t(), [api?]) :: + {:ok, __MODULE__.t()} | {:error, :not_found} + def hash_to_golembase_entity(%Hash{byte_count: unquote(Hash.Full.byte_count())} = hash, options \\ []) + when is_list(options) do + __MODULE__ + |> where(key: ^hash) + |> where(status: :active) + |> Chain.select_repo(options).one() + |> case do + nil -> + {:error, :not_found} + + golembase_entity -> + {:ok, golembase_entity} + end + end + + def enabled? do + Application.get_env(:explorer, __MODULE__)[:enabled] + end +end diff --git a/apps/explorer/lib/explorer/chain/search.ex b/apps/explorer/lib/explorer/chain/search.ex index b5f3cd2065..d675edd3ab 100644 --- a/apps/explorer/lib/explorer/chain/search.ex +++ b/apps/explorer/lib/explorer/chain/search.ex @@ -22,6 +22,7 @@ defmodule Explorer.Chain.Search do Beacon.Blob, Block, DenormalizationHelper, + GolemBase.Entity, Hash, SmartContract, Token, @@ -191,14 +192,24 @@ defmodule Explorer.Chain.Search do |> search_transaction_query() |> union_all(^search_block_by_hash_query(full_hash)) + transaction_block_golembase_entity_query = + if Entity.enabled?() do + golembase_entity_query = search_golembase_entity_query(full_hash) + + transaction_block_query + |> union_all(^golembase_entity_query) + else + transaction_block_query + end + transaction_block_op_query = if UserOperation.enabled?() do user_operation_query = search_user_operation_query(full_hash) - transaction_block_query + transaction_block_golembase_entity_query |> union_all(^user_operation_query) else - transaction_block_query + transaction_block_golembase_entity_query end result_query = @@ -688,6 +699,20 @@ defmodule Explorer.Chain.Search do ) end + defp search_golembase_entity_query(term) do + golembase_entity_search_fields = + search_fields() + |> Map.put(:golembase_entity, dynamic([golembase_entity: golembase_entity], golembase_entity.key)) + |> Map.put(:type, "golembase_entity") + + from(golembase_entity in Entity, + as: :golembase_entity, + where: golembase_entity.key == ^term, + where: golembase_entity.status == :active, + select: ^golembase_entity_search_fields + ) + end + defp search_blob_query(term) do blob_search_fields = search_fields() @@ -1020,6 +1045,7 @@ defmodule Explorer.Chain.Search do user_operation_hash: dynamic(type(^nil, :binary)), blob_hash: dynamic(type(^nil, :binary)), block_hash: dynamic(type(^nil, :binary)), + golembase_entity: dynamic(type(^nil, :binary)), type: nil, name: nil, symbol: nil, diff --git a/apps/explorer/priv/repo/migrations/20250728191950_create_golem_base_entities.exs b/apps/explorer/priv/repo/migrations/20250728191950_create_golem_base_entities.exs new file mode 100644 index 0000000000..5db1dd3da7 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250728191950_create_golem_base_entities.exs @@ -0,0 +1,45 @@ +defmodule Explorer.Repo.Migrations.CreateGolemBaseEntities do + use Ecto.Migration + + def up do + if Application.get_env(:explorer, :environment) == :test do + # Create golem_base_entity_status_type enum type if it doesn't exist + execute(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'golem_base_entity_status_type') THEN + CREATE TYPE golem_base_entity_status_type AS ENUM ('active', 'deleted', 'expired'); + END IF; + END$$; + """) + + create table(:golem_base_entities, primary_key: false) do + add(:key, :binary, null: false, primary_key: true) + add(:data, :binary) + add(:status, :golem_base_entity_status_type, null: false) + add(:owner, :binary, null: false) + + add(:created_at_tx_hash, :binary) + add(:last_updated_at_tx_hash, :binary, null: false) + add(:expires_at_block_number, :bigint, null: false) + + add(:inserted_at, :naive_datetime, null: false, default: fragment("now()")) + add(:updated_at, :naive_datetime, null: false, default: fragment("now()")) + end + end + end + + def down do + if Application.get_env(:explorer, :environment) == :test do + drop(table(:golem_base_entities)) + + # Drop golem_base_entity_status_type enum if it exists + execute(""" + DO $$ + BEGIN + DROP TYPE IF EXISTS golem_base_entity_status_type; + END$$ + """) + end + end +end diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index d7d03ac0c6..9a0581d23c 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -35,6 +35,7 @@ defmodule Explorer.Factory do Block, ContractMethod, Data, + GolemBase, Hash, InternalTransaction, Log, @@ -1406,4 +1407,34 @@ defmodule Explorer.Factory do inserted_at: DateTime.utc_now() } end + + def golembase_entity_active_factory do + %GolemBase.Entity{ + key: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + owner: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: "active", + last_updated_at_tx_hash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + expires_at_block_number: 999 + } + end + + def golembase_entity_deleted_factory do + %GolemBase.Entity{ + key: "0x9999999999999999999999999999999999999999999999999999999999999999", + owner: "0x88888888888888888888888888888888", + status: "deleted", + last_updated_at_tx_hash: "0x7777777777777777777777777777777777777777777777777777777777777777", + expires_at_block_number: 100_000_000 + } + end + + def golembase_entity_expired_factory do + %GolemBase.Entity{ + key: "0x6666666666666666666666666666666666666666666666666666666666666666", + owner: "0x55555555555555555555555555555555", + status: "expired", + last_updated_at_tx_hash: "0x4444444444444444444444444444444444444444444444444444444444444444", + expires_at_block_number: 1 + } + end end diff --git a/bin/test b/bin/test index 2d4a432bd4..1074871656 100755 --- a/bin/test +++ b/bin/test @@ -5,5 +5,5 @@ set -ex mix format --check-formatted mix credo --strict mix sobelow --config -mix dialyzer --halt-exit-status +mix dialyzer mix test diff --git a/config/runtime.exs b/config/runtime.exs index 609bf4cb2b..a3257857dc 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -844,6 +844,9 @@ config :explorer, Explorer.Chain.Filecoin.NativeAddress, config :explorer, Explorer.Chain.Blackfort.Validator, api_url: System.get_env("BLACKFORT_VALIDATOR_API_URL") +config :explorer, Explorer.Chain.GolemBase.Entity, + enabled: ConfigHelper.parse_bool_env_var("GOLEMBASE_ENABLED", "false") + addresses_blacklist_url = ConfigHelper.parse_microservice_url("ADDRESSES_BLACKLIST_URL") config :explorer, Explorer.Chain.Fetcher.AddressesBlacklist, diff --git a/cspell.json b/cspell.json index 0ab5bc6af3..b4da197b47 100644 --- a/cspell.json +++ b/cspell.json @@ -284,6 +284,7 @@ "giga", "Gitter", "goldtoken", + "golembase", "goqtclhifepvfnicv", "gqz", "granitegrey",