diff --git a/apps/block_scout_web/lib/block_scout_web/channels/signet_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/signet_channel.ex new file mode 100644 index 000000000000..fde89d28646d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/channels/signet_channel.ex @@ -0,0 +1,18 @@ +defmodule BlockScoutWeb.SignetChannel do + @moduledoc """ + Establishes pub/sub channel for live updates of Signet related events. + """ + use BlockScoutWeb, :channel + + def join("signet:new_order", _params, socket) do + {:ok, %{}, socket} + end + + def join("signet:new_fill", _params, socket) do + {:ok, %{}, socket} + end + + def join("signet:order_updates", _params, socket) do + {:ok, %{}, socket} + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex index dec4a541b74f..528232a31ea2 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex @@ -17,6 +17,7 @@ defmodule BlockScoutWeb.V2.UserSocket do case @chain_type do :arbitrum -> channel("arbitrum:*", BlockScoutWeb.ArbitrumChannel) :optimism -> channel("optimism:*", BlockScoutWeb.OptimismChannel) + :signet -> channel("signet:*", BlockScoutWeb.SignetChannel) _ -> nil end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/signet_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/signet_controller.ex new file mode 100644 index 000000000000..d0f98f2ba30d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/signet_controller.ex @@ -0,0 +1,239 @@ +defmodule BlockScoutWeb.API.V2.SignetController do + @moduledoc """ + Controller for Signet order and fill API endpoints. + + Provides REST API v2 endpoints for querying Signet cross-chain orders and fills. + """ + use BlockScoutWeb, :controller + + import BlockScoutWeb.Chain, + only: [ + next_page_params: 5, + paging_options: 1, + split_list_by_page: 1 + ] + + alias Explorer.Chain.Hash + alias Explorer.Chain.Signet.{Fill, Order} + alias Explorer.GraphQL.Signet, as: SignetQueries + alias Explorer.{PagingOptions, Repo} + + import Ecto.Query + + action_fallback(BlockScoutWeb.API.V2.FallbackController) + + @doc """ + GET /api/v2/signet/orders + + Lists Signet orders with pagination and optional block range filters. + + ## Query Parameters + - block_number_gte: minimum block number (optional) + - block_number_lte: maximum block number (optional) + - Standard pagination params (page, page_size) + """ + @spec orders(Plug.Conn.t(), map()) :: Plug.Conn.t() + def orders(conn, params) do + options = paging_options(params) + + filter_args = %{} + |> maybe_add_filter(:block_number_gte, params["block_number_gte"]) + |> maybe_add_filter(:block_number_lte, params["block_number_lte"]) + + {orders, next_page} = + filter_args + |> SignetQueries.orders_query() + |> paginate(options) + |> Repo.all() + |> split_list_by_page() + + next_page_params = + next_page_params( + next_page, + orders, + params, + false, + fn %Order{block_number: block_number, log_index: log_index} -> + %{"block_number" => block_number, "log_index" => log_index} + end + ) + + conn + |> put_status(200) + |> render(:signet_orders, %{ + orders: orders, + next_page_params: next_page_params + }) + end + + @doc """ + GET /api/v2/signet/orders/count + + Returns the total count of Signet orders. + """ + @spec orders_count(Plug.Conn.t(), map()) :: Plug.Conn.t() + def orders_count(conn, _params) do + count = Repo.aggregate(Order, :count, :transaction_hash) + + conn + |> put_status(200) + |> render(:signet_orders_count, %{count: count}) + end + + @doc """ + GET /api/v2/signet/orders/:transaction_hash/:log_index + + Gets a single order by transaction hash and log index. + """ + @spec order(Plug.Conn.t(), map()) :: Plug.Conn.t() + def order(conn, %{"transaction_hash" => tx_hash_str, "log_index" => log_index_str}) do + with {:ok, transaction_hash} <- Hash.Full.cast(tx_hash_str), + {log_index, ""} <- Integer.parse(log_index_str), + %Order{} = order <- SignetQueries.get_order(transaction_hash, log_index) do + conn + |> put_status(200) + |> render(:signet_order, %{order: order}) + else + :error -> + {:error, :not_found} + + nil -> + {:error, :not_found} + + _ -> + {:error, :not_found} + end + end + + @doc """ + GET /api/v2/signet/fills + + Lists Signet fills with pagination and optional filters. + + ## Query Parameters + - chain_type: "rollup" or "host" (optional) + - block_number_gte: minimum block number (optional) + - block_number_lte: maximum block number (optional) + - Standard pagination params + """ + @spec fills(Plug.Conn.t(), map()) :: Plug.Conn.t() + def fills(conn, params) do + options = paging_options(params) + + filter_args = %{} + |> maybe_add_chain_type_filter(params["chain_type"]) + |> maybe_add_filter(:block_number_gte, params["block_number_gte"]) + |> maybe_add_filter(:block_number_lte, params["block_number_lte"]) + + {fills, next_page} = + filter_args + |> SignetQueries.fills_query() + |> paginate(options) + |> Repo.all() + |> split_list_by_page() + + next_page_params = + next_page_params( + next_page, + fills, + params, + false, + fn %Fill{block_number: block_number, log_index: log_index, chain_type: chain_type} -> + %{"block_number" => block_number, "log_index" => log_index, "chain_type" => chain_type} + end + ) + + conn + |> put_status(200) + |> render(:signet_fills, %{ + fills: fills, + next_page_params: next_page_params + }) + end + + @doc """ + GET /api/v2/signet/fills/count + + Returns the total count of Signet fills, optionally filtered by chain type. + + ## Query Parameters + - chain_type: "rollup" or "host" (optional) + """ + @spec fills_count(Plug.Conn.t(), map()) :: Plug.Conn.t() + def fills_count(conn, params) do + query = from(f in Fill) + + query = + case params["chain_type"] do + "rollup" -> from(f in query, where: f.chain_type == :rollup) + "host" -> from(f in query, where: f.chain_type == :host) + _ -> query + end + + count = Repo.aggregate(query, :count, :transaction_hash) + + conn + |> put_status(200) + |> render(:signet_fills_count, %{count: count}) + end + + @doc """ + GET /api/v2/signet/fills/:chain_type/:transaction_hash/:log_index + + Gets a single fill by chain type, transaction hash, and log index. + """ + @spec fill(Plug.Conn.t(), map()) :: Plug.Conn.t() + def fill(conn, %{ + "chain_type" => chain_type_str, + "transaction_hash" => tx_hash_str, + "log_index" => log_index_str + }) do + with {:ok, chain_type} <- parse_chain_type(chain_type_str), + {:ok, transaction_hash} <- Hash.Full.cast(tx_hash_str), + {log_index, ""} <- Integer.parse(log_index_str), + %Fill{} = fill <- SignetQueries.get_fill(chain_type, transaction_hash, log_index) do + conn + |> put_status(200) + |> render(:signet_fill, %{fill: fill}) + else + :error -> + {:error, :not_found} + + nil -> + {:error, :not_found} + + {:error, :invalid_chain_type} -> + conn + |> put_status(:bad_request) + |> render(:message, %{message: "Invalid chain_type. Must be 'rollup' or 'host'."}) + + _ -> + {:error, :not_found} + end + end + + # Private helpers + + defp parse_chain_type("rollup"), do: {:ok, :rollup} + defp parse_chain_type("host"), do: {:ok, :host} + defp parse_chain_type(_), do: {:error, :invalid_chain_type} + + defp maybe_add_filter(args, key, value) when is_binary(value) do + case Integer.parse(value) do + {int_val, ""} -> Map.put(args, key, int_val) + _ -> args + end + end + + defp maybe_add_filter(args, _key, _value), do: args + + defp maybe_add_chain_type_filter(args, "rollup"), do: Map.put(args, :chain_type, :rollup) + defp maybe_add_chain_type_filter(args, "host"), do: Map.put(args, :chain_type, :host) + defp maybe_add_chain_type_filter(args, _), do: args + + defp paginate(query, %PagingOptions{page_size: page_size}) do + from(q in query, limit: ^(page_size + 1)) + end + + defp paginate(query, _), do: from(q in query, limit: 51) +end 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 727aea396e35..e71a72fbbca3 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 @@ -3,7 +3,7 @@ defmodule BlockScoutWeb.GraphQL.Schema do use Absinthe.Schema use Absinthe.Relay.Schema, :modern - use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity], chain_type: [:explorer, :chain_type] alias Absinthe.Middleware.Dataloader, as: AbsintheDataloaderMiddleware alias Absinthe.Plugin, as: AbsinthePlugin @@ -23,12 +23,21 @@ defmodule BlockScoutWeb.GraphQL.Schema do alias Explorer.Chain.TokenTransfer, as: ExplorerChainTokenTransfer alias Explorer.Chain.Transaction, as: ExplorerChainTransaction + if @chain_type == :signet do + alias Explorer.Chain.Signet.Order, as: ExplorerChainSignetOrder + alias Explorer.Chain.Signet.Fill, as: ExplorerChainSignetFill + end + import_types(BlockScoutWeb.GraphQL.Schema.Types) if @chain_identity == {:optimism, :celo} do import_types(BlockScoutWeb.GraphQL.Celo.Schema.Types) end + if @chain_type == :signet do + import_types(BlockScoutWeb.GraphQL.Signet.Schema.Types) + end + node interface do resolve_type(fn %ExplorerChainInternalTransaction{}, _ -> @@ -114,6 +123,13 @@ defmodule BlockScoutWeb.GraphQL.Schema do QueryFields.generate() end + + if @chain_type == :signet do + require BlockScoutWeb.GraphQL.Signet.QueryFields + alias BlockScoutWeb.GraphQL.Signet.QueryFields, as: SignetQueryFields + + SignetQueryFields.generate() + end end subscription do diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/fill.ex b/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/fill.ex new file mode 100644 index 000000000000..57456a392ce8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/fill.ex @@ -0,0 +1,50 @@ +defmodule BlockScoutWeb.GraphQL.Signet.Resolvers.Fill do + @moduledoc """ + Resolvers for Signet fills, used in the Signet GraphQL schema. + """ + + alias Absinthe.Relay.Connection + alias Explorer.Chain + alias Explorer.GraphQL.Signet, as: GraphQL + alias Explorer.Repo + + @doc """ + Gets a single fill by chain type, transaction hash, and log index. + """ + def get_by(_parent, %{chain_type: chain_type_string, transaction_hash: hash_string, log_index: log_index}, _resolution) do + with {:ok, hash} <- Chain.string_to_full_hash(hash_string), + {:ok, chain_type} <- parse_chain_type(chain_type_string) do + case GraphQL.get_fill(chain_type, hash, log_index) do + nil -> {:error, "Fill not found"} + fill -> {:ok, fill} + end + end + end + + @doc """ + Lists fills with optional filters and pagination. + """ + def list(_parent, args, _resolution) do + args + |> maybe_parse_chain_type_filter() + |> GraphQL.fills_query() + |> Connection.from_query(&Repo.all/1, args, options(args)) + end + + defp parse_chain_type("rollup"), do: {:ok, :rollup} + defp parse_chain_type("host"), do: {:ok, :host} + defp parse_chain_type(_), do: {:error, "Invalid chain_type. Must be 'rollup' or 'host'"} + + defp maybe_parse_chain_type_filter(%{chain_type: chain_type_string} = args) do + case parse_chain_type(chain_type_string) do + {:ok, chain_type} -> Map.put(args, :chain_type, chain_type) + {:error, _} -> args + end + end + + defp maybe_parse_chain_type_filter(args), do: args + + defp options(%{before: _}), do: [] + defp options(%{count: count}), do: [count: count] + defp options(_), do: [] +end diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/order.ex b/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/order.ex new file mode 100644 index 000000000000..22bccd5fa695 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/graphql/signet/resolvers/order.ex @@ -0,0 +1,35 @@ +defmodule BlockScoutWeb.GraphQL.Signet.Resolvers.Order do + @moduledoc """ + Resolvers for Signet orders, used in the Signet GraphQL schema. + """ + + alias Absinthe.Relay.Connection + alias Explorer.Chain + alias Explorer.GraphQL.Signet, as: GraphQL + alias Explorer.Repo + + @doc """ + Gets a single order by transaction hash and log index. + """ + def get_by(_parent, %{transaction_hash: hash_string, log_index: log_index}, _resolution) do + with {:ok, hash} <- Chain.string_to_full_hash(hash_string) do + case GraphQL.get_order(hash, log_index) do + nil -> {:error, "Order not found"} + order -> {:ok, order} + end + end + end + + @doc """ + Lists orders with optional filters and pagination. + """ + def list(_parent, args, _resolution) do + args + |> GraphQL.orders_query() + |> Connection.from_query(&Repo.all/1, args, options(args)) + end + + defp options(%{before: _}), do: [] + defp options(%{count: count}), do: [count: count] + defp options(_), do: [] +end diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/query_fields.ex b/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/query_fields.ex new file mode 100644 index 000000000000..e6de37687606 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/query_fields.ex @@ -0,0 +1,62 @@ +defmodule BlockScoutWeb.GraphQL.Signet.QueryFields do + @moduledoc """ + Query fields for the Signet schema. + """ + + alias BlockScoutWeb.GraphQL.Signet.Resolvers.{Fill, Order} + + use Absinthe.Schema.Notation + use Absinthe.Relay.Schema, :modern + + defmacro generate do + quote do + @desc "Gets a Signet order by transaction hash and log index." + field :signet_order, :signet_order do + arg(:transaction_hash, non_null(:full_hash)) + arg(:log_index, non_null(:integer)) + + resolve(&Order.get_by/3) + end + + @desc "Gets Signet orders with pagination." + connection field(:signet_orders, node_type: :signet_order) do + arg(:count, :integer) + arg(:block_number_gte, :integer) + arg(:block_number_lte, :integer) + + resolve(&Order.list/3) + + complexity(fn + %{first: first}, child_complexity -> first * child_complexity + %{last: last}, child_complexity -> last * child_complexity + %{}, _child_complexity -> 0 + end) + end + + @desc "Gets a Signet fill by chain type, transaction hash, and log index." + field :signet_fill, :signet_fill do + arg(:chain_type, non_null(:string)) + arg(:transaction_hash, non_null(:full_hash)) + arg(:log_index, non_null(:integer)) + + resolve(&Fill.get_by/3) + end + + @desc "Gets Signet fills with pagination." + connection field(:signet_fills, node_type: :signet_fill) do + arg(:count, :integer) + arg(:chain_type, :string) + arg(:block_number_gte, :integer) + arg(:block_number_lte, :integer) + + resolve(&Fill.list/3) + + complexity(fn + %{first: first}, child_complexity -> first * child_complexity + %{last: last}, child_complexity -> last * child_complexity + %{}, _child_complexity -> 0 + end) + end + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/types.ex new file mode 100644 index 000000000000..1fb4141f4f0b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/graphql/signet/schema/types.ex @@ -0,0 +1,64 @@ +defmodule BlockScoutWeb.GraphQL.Signet.Schema.Types do + @moduledoc """ + GraphQL types for Signet orders and fills. + """ + + use Absinthe.Schema.Notation + use Absinthe.Relay.Schema.Notation, :modern + + @desc """ + Represents a Signet cross-chain order from the RollupOrders contract. + + Orders specify inputs (tokens offered by the maker) and outputs (tokens + expected in return). The chainId in outputs represents the DESTINATION + chain where assets should be delivered. + """ + node object(:signet_order, id_fetcher: &signet_order_id_fetcher/2) do + field(:transaction_hash, :full_hash) + field(:log_index, :integer) + field(:block_number, :integer) + field(:deadline, :integer) + field(:inputs_json, :string) + field(:outputs_json, :string) + field(:sweep_recipient, :address_hash) + field(:sweep_token, :address_hash) + field(:sweep_amount, :decimal) + field(:inserted_at, :datetime) + end + + @desc """ + Represents a Signet fill event from RollupOrders or HostOrders contracts. + + Fills record the execution of orders. The chainId in outputs represents + the ORIGIN chain where the order was created, not where the fill occurred. + """ + node object(:signet_fill, id_fetcher: &signet_fill_id_fetcher/2) do + field(:chain_type, :string) + field(:transaction_hash, :full_hash) + field(:log_index, :integer) + field(:block_number, :integer) + field(:outputs_json, :string) + field(:inserted_at, :datetime) + end + + connection(node_type: :signet_order) + connection(node_type: :signet_fill) + + defp signet_order_id_fetcher(%{transaction_hash: transaction_hash, log_index: log_index}, _) do + Jason.encode!(%{ + transaction_hash: to_string(transaction_hash), + log_index: log_index + }) + end + + defp signet_fill_id_fetcher( + %{chain_type: chain_type, transaction_hash: transaction_hash, log_index: log_index}, + _ + ) do + Jason.encode!(%{ + chain_type: to_string(chain_type), + transaction_hash: to_string(transaction_hash), + log_index: log_index + }) + end +end 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 e01501bcd18b..3fffdf190214 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -62,6 +62,9 @@ defmodule BlockScoutWeb.Notifier do :optimism -> @chain_type_specific_events ~w(new_optimism_batches new_optimism_deposits)a + :signet -> + @chain_type_specific_events ~w(new_signet_orders new_signet_fills signet_order_updates)a + _ -> nil end @@ -432,6 +435,11 @@ defmodule BlockScoutWeb.Notifier do # credo:disable-for-next-line Credo.Check.Design.AliasUsage do: BlockScoutWeb.Notifiers.Optimism.handle_event(event) + :signet -> + def handle_event({:chain_event, topic, _, _} = event) when topic in @chain_type_specific_events, + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + do: BlockScoutWeb.Notifiers.Signet.handle_event(event) + _ -> nil end diff --git a/apps/block_scout_web/lib/block_scout_web/notifiers/signet.ex b/apps/block_scout_web/lib/block_scout_web/notifiers/signet.ex new file mode 100644 index 000000000000..e7128b364d06 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/notifiers/signet.ex @@ -0,0 +1,42 @@ +defmodule BlockScoutWeb.Notifiers.Signet do + @moduledoc """ + Module to handle and broadcast Signet related events. + """ + + alias BlockScoutWeb.API.V2.SignetView + alias BlockScoutWeb.Endpoint + + require Logger + + def handle_event({:chain_event, :new_signet_orders, :realtime, orders}) do + orders + |> Enum.each(fn order -> + Endpoint.broadcast("signet:new_order", "new_signet_order", %{ + order: SignetView.render("signet_order.json", %{order: order}) + }) + end) + end + + def handle_event({:chain_event, :new_signet_fills, :realtime, fills}) do + fills + |> Enum.each(fn fill -> + Endpoint.broadcast("signet:new_fill", "new_signet_fill", %{ + fill: SignetView.render("signet_fill.json", %{fill: fill}) + }) + end) + end + + def handle_event({:chain_event, :signet_order_updates, :realtime, orders}) do + orders + |> Enum.each(fn order -> + Endpoint.broadcast("signet:order_updates", "signet_order_updated", %{ + order: SignetView.render("signet_order.json", %{order: order}) + }) + end) + end + + def handle_event(event) do + Logger.warning("Unknown broadcasted event #{inspect(event)}.") + nil + end +end 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 24f67d4cf2c0..54ac1ac5ba3c 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 @@ -494,6 +494,17 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end end + scope "/signet" do + if @chain_type == :signet do + get("/orders", V2.SignetController, :orders) + get("/orders/count", V2.SignetController, :orders_count) + get("/orders/:transaction_hash/:log_index", V2.SignetController, :order) + get("/fills", V2.SignetController, :fills) + get("/fills/count", V2.SignetController, :fills_count) + get("/fills/:chain_type/:transaction_hash/:log_index", V2.SignetController, :fill) + end + end + scope "/advanced-filters" do get("/", V2.AdvancedFilterController, :list) get("/csv", V2.AdvancedFilterController, :list_csv) diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/signet_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/signet_view.ex new file mode 100644 index 000000000000..6b02fb48aad7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/signet_view.ex @@ -0,0 +1,124 @@ +defmodule BlockScoutWeb.API.V2.SignetView do + @moduledoc """ + View module for rendering Signet order and fill API responses. + """ + use BlockScoutWeb, :view + + alias BlockScoutWeb.API.V2.ApiView + + @doc """ + Renders error/text responses. + """ + def render("message.json", assigns) do + ApiView.render("message.json", assigns) + end + + @doc """ + Renders a list of orders with pagination. + """ + @spec render(binary(), map()) :: map() | non_neg_integer() + def render("signet_orders.json", %{ + orders: orders, + next_page_params: next_page_params + }) do + orders_out = + orders + |> Enum.map(&render_order/1) + + %{ + items: orders_out, + next_page_params: next_page_params + } + end + + @doc """ + Renders the count of orders. + """ + def render("signet_orders_count.json", %{count: count}) do + count + end + + @doc """ + Renders a single order. + """ + def render("signet_order.json", %{order: order}) do + render_order(order) + end + + @doc """ + Renders a list of fills with pagination. + """ + def render("signet_fills.json", %{ + fills: fills, + next_page_params: next_page_params + }) do + fills_out = + fills + |> Enum.map(&render_fill/1) + + %{ + items: fills_out, + next_page_params: next_page_params + } + end + + @doc """ + Renders the count of fills. + """ + def render("signet_fills_count.json", %{count: count}) do + count + end + + @doc """ + Renders a single fill. + """ + def render("signet_fill.json", %{fill: fill}) do + render_fill(fill) + end + + # Private helpers + + defp render_order(order) do + %{ + "transaction_hash" => to_string(order.transaction_hash), + "log_index" => order.log_index, + "block_number" => order.block_number, + "deadline" => order.deadline, + "inputs" => parse_json_field(order.inputs_json), + "outputs" => parse_json_field(order.outputs_json), + "sweep_recipient" => maybe_to_string(order.sweep_recipient), + "sweep_token" => maybe_to_string(order.sweep_token), + "sweep_amount" => maybe_decimal_to_string(order.sweep_amount), + "inserted_at" => order.inserted_at + } + end + + defp render_fill(fill) do + %{ + "chain_type" => to_string(fill.chain_type), + "transaction_hash" => to_string(fill.transaction_hash), + "log_index" => fill.log_index, + "block_number" => fill.block_number, + "outputs" => parse_json_field(fill.outputs_json), + "inserted_at" => fill.inserted_at + } + end + + defp parse_json_field(nil), do: nil + + defp parse_json_field(json_string) when is_binary(json_string) do + case Jason.decode(json_string) do + {:ok, parsed} -> parsed + {:error, _} -> json_string + end + end + + defp parse_json_field(other), do: other + + defp maybe_to_string(nil), do: nil + defp maybe_to_string(value), do: to_string(value) + + defp maybe_decimal_to_string(nil), do: nil + defp maybe_decimal_to_string(%Decimal{} = d), do: Decimal.to_string(d) + defp maybe_decimal_to_string(value), do: to_string(value) +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/signet_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/signet_controller_test.exs new file mode 100644 index 000000000000..8741f811ba42 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/signet_controller_test.exs @@ -0,0 +1,239 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule BlockScoutWeb.API.V2.SignetControllerTest do + use BlockScoutWeb.ConnCase + + @moduletag :signet + + describe "GET /api/v2/signet/orders" do + test "returns paginated orders", %{conn: conn} do + order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 101) + order3 = insert(:signet_order, log_index: 2, block_number: 102) + + conn = get(conn, "/api/v2/signet/orders") + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 3 + + # Orders should be returned in descending block_number order + block_numbers = Enum.map(items, & &1["block_number"]) + assert block_numbers == [order3.block_number, order2.block_number, order1.block_number] + end + + test "filters by block_number_gte", %{conn: conn} do + _order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 200) + order3 = insert(:signet_order, log_index: 2, block_number: 300) + + conn = get(conn, "/api/v2/signet/orders", %{"block_number_gte" => "150"}) + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 2 + + block_numbers = Enum.map(items, & &1["block_number"]) + assert order2.block_number in block_numbers + assert order3.block_number in block_numbers + end + + test "filters by block_number_lte", %{conn: conn} do + order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 200) + _order3 = insert(:signet_order, log_index: 2, block_number: 300) + + conn = get(conn, "/api/v2/signet/orders", %{"block_number_lte" => "250"}) + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 2 + + block_numbers = Enum.map(items, & &1["block_number"]) + assert order1.block_number in block_numbers + assert order2.block_number in block_numbers + end + + test "returns empty list when no orders exist", %{conn: conn} do + conn = get(conn, "/api/v2/signet/orders") + + assert %{"items" => items} = json_response(conn, 200) + assert items == [] + end + end + + describe "GET /api/v2/signet/orders/count" do + test "returns total count of orders", %{conn: conn} do + insert(:signet_order, log_index: 0) + insert(:signet_order, log_index: 1) + insert(:signet_order, log_index: 2) + + conn = get(conn, "/api/v2/signet/orders/count") + + assert json_response(conn, 200) == 3 + end + + test "returns 0 when no orders exist", %{conn: conn} do + conn = get(conn, "/api/v2/signet/orders/count") + + assert json_response(conn, 200) == 0 + end + end + + describe "GET /api/v2/signet/orders/:transaction_hash/:log_index" do + test "returns single order by transaction hash and log index", %{conn: conn} do + order = insert(:signet_order, log_index: 5, block_number: 100) + + conn = + get(conn, "/api/v2/signet/orders/#{order.transaction_hash}/#{order.log_index}") + + result = json_response(conn, 200) + assert result["transaction_hash"] == to_string(order.transaction_hash) + assert result["log_index"] == order.log_index + assert result["block_number"] == order.block_number + assert result["deadline"] == order.deadline + end + + test "returns 404 for non-existent order", %{conn: conn} do + fake_hash = "0x" <> String.duplicate("00", 32) + + conn = get(conn, "/api/v2/signet/orders/#{fake_hash}/0") + + assert json_response(conn, 404) + end + + test "returns 404 for invalid transaction hash", %{conn: conn} do + conn = get(conn, "/api/v2/signet/orders/invalid_hash/0") + + assert json_response(conn, 404) + end + end + + describe "GET /api/v2/signet/fills" do + test "returns paginated fills", %{conn: conn} do + fill1 = insert(:signet_fill, chain_type: :rollup, log_index: 0, block_number: 100) + fill2 = insert(:signet_fill, chain_type: :host, log_index: 1, block_number: 101) + fill3 = insert(:signet_fill, chain_type: :rollup, log_index: 2, block_number: 102) + + conn = get(conn, "/api/v2/signet/fills") + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 3 + + # Fills should be returned in descending block_number order + block_numbers = Enum.map(items, & &1["block_number"]) + assert block_numbers == [fill3.block_number, fill2.block_number, fill1.block_number] + end + + test "filters by chain_type=rollup", %{conn: conn} do + _fill1 = insert(:signet_fill, chain_type: :host, log_index: 0, block_number: 100) + fill2 = insert(:signet_fill, chain_type: :rollup, log_index: 1, block_number: 101) + fill3 = insert(:signet_fill, chain_type: :rollup, log_index: 2, block_number: 102) + + conn = get(conn, "/api/v2/signet/fills", %{"chain_type" => "rollup"}) + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 2 + + assert Enum.all?(items, &(&1["chain_type"] == "rollup")) + block_numbers = Enum.map(items, & &1["block_number"]) + assert fill2.block_number in block_numbers + assert fill3.block_number in block_numbers + end + + test "filters by chain_type=host", %{conn: conn} do + fill1 = insert(:signet_fill, chain_type: :host, log_index: 0, block_number: 100) + _fill2 = insert(:signet_fill, chain_type: :rollup, log_index: 1, block_number: 101) + _fill3 = insert(:signet_fill, chain_type: :rollup, log_index: 2, block_number: 102) + + conn = get(conn, "/api/v2/signet/fills", %{"chain_type" => "host"}) + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 1 + assert hd(items)["chain_type"] == "host" + assert hd(items)["block_number"] == fill1.block_number + end + + test "filters by block_number_gte", %{conn: conn} do + _fill1 = insert(:signet_fill, chain_type: :rollup, log_index: 0, block_number: 100) + fill2 = insert(:signet_fill, chain_type: :rollup, log_index: 1, block_number: 200) + fill3 = insert(:signet_fill, chain_type: :rollup, log_index: 2, block_number: 300) + + conn = get(conn, "/api/v2/signet/fills", %{"block_number_gte" => "150"}) + + assert %{"items" => items} = json_response(conn, 200) + assert length(items) == 2 + + block_numbers = Enum.map(items, & &1["block_number"]) + assert fill2.block_number in block_numbers + assert fill3.block_number in block_numbers + end + + test "returns empty list when no fills exist", %{conn: conn} do + conn = get(conn, "/api/v2/signet/fills") + + assert %{"items" => items} = json_response(conn, 200) + assert items == [] + end + end + + describe "GET /api/v2/signet/fills/count" do + test "returns total count of fills", %{conn: conn} do + insert(:signet_fill, chain_type: :rollup, log_index: 0) + insert(:signet_fill, chain_type: :host, log_index: 1) + insert(:signet_fill, chain_type: :rollup, log_index: 2) + + conn = get(conn, "/api/v2/signet/fills/count") + + assert json_response(conn, 200) == 3 + end + + test "returns filtered count by chain_type", %{conn: conn} do + insert(:signet_fill, chain_type: :rollup, log_index: 0) + insert(:signet_fill, chain_type: :host, log_index: 1) + insert(:signet_fill, chain_type: :rollup, log_index: 2) + + conn = get(conn, "/api/v2/signet/fills/count", %{"chain_type" => "rollup"}) + + assert json_response(conn, 200) == 2 + end + + test "returns 0 when no fills exist", %{conn: conn} do + conn = get(conn, "/api/v2/signet/fills/count") + + assert json_response(conn, 200) == 0 + end + end + + describe "GET /api/v2/signet/fills/:chain_type/:transaction_hash/:log_index" do + test "returns single fill by chain type, transaction hash, and log index", %{conn: conn} do + fill = insert(:signet_fill, chain_type: :rollup, log_index: 5, block_number: 100) + + conn = + get( + conn, + "/api/v2/signet/fills/#{fill.chain_type}/#{fill.transaction_hash}/#{fill.log_index}" + ) + + result = json_response(conn, 200) + assert result["chain_type"] == to_string(fill.chain_type) + assert result["transaction_hash"] == to_string(fill.transaction_hash) + assert result["log_index"] == fill.log_index + assert result["block_number"] == fill.block_number + end + + test "returns 404 for non-existent fill", %{conn: conn} do + fake_hash = "0x" <> String.duplicate("00", 32) + + conn = get(conn, "/api/v2/signet/fills/rollup/#{fake_hash}/0") + + assert json_response(conn, 404) + end + + test "returns 400 for invalid chain_type", %{conn: conn} do + fake_hash = "0x" <> String.duplicate("00", 32) + + conn = get(conn, "/api/v2/signet/fills/invalid_chain/#{fake_hash}/0") + + assert %{"message" => message} = json_response(conn, 400) + assert message =~ "Invalid chain_type" + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_fills_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_fills_test.exs new file mode 100644 index 000000000000..d1e74f4930c4 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_fills_test.exs @@ -0,0 +1,294 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule BlockScoutWeb.GraphQL.Signet.SignetFillsTest do + use BlockScoutWeb.ConnCase + + @moduletag :signet + + describe "signet_fill query" do + test "returns fill by chain type, transaction hash, and log index", %{conn: conn} do + fill = insert(:signet_fill, chain_type: :rollup, log_index: 0) + + query = """ + query ($chain_type: String!, $transaction_hash: FullHash!, $log_index: Int!) { + signet_fill(chain_type: $chain_type, transaction_hash: $transaction_hash, log_index: $log_index) { + chain_type + transaction_hash + log_index + block_number + outputs_json + } + } + """ + + variables = %{ + "chain_type" => "rollup", + "transaction_hash" => to_string(fill.transaction_hash), + "log_index" => fill.log_index + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fill" => result}} = json_response(conn, 200) + assert result["chain_type"] == "rollup" + assert result["transaction_hash"] == to_string(fill.transaction_hash) + assert result["log_index"] == fill.log_index + assert result["block_number"] == fill.block_number + assert result["outputs_json"] == fill.outputs_json + end + + test "returns host chain fill", %{conn: conn} do + fill = insert(:signet_fill, chain_type: :host, log_index: 0) + + query = """ + query ($chain_type: String!, $transaction_hash: FullHash!, $log_index: Int!) { + signet_fill(chain_type: $chain_type, transaction_hash: $transaction_hash, log_index: $log_index) { + chain_type + transaction_hash + } + } + """ + + variables = %{ + "chain_type" => "host", + "transaction_hash" => to_string(fill.transaction_hash), + "log_index" => fill.log_index + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fill" => result}} = json_response(conn, 200) + assert result["chain_type"] == "host" + end + + test "returns error for non-existent fill", %{conn: conn} do + query = """ + query ($chain_type: String!, $transaction_hash: FullHash!, $log_index: Int!) { + signet_fill(chain_type: $chain_type, transaction_hash: $transaction_hash, log_index: $log_index) { + transaction_hash + } + } + """ + + variables = %{ + "chain_type" => "rollup", + "transaction_hash" => "0x" <> String.duplicate("00", 32), + "log_index" => 0 + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ "Fill not found" + end + + test "returns error for invalid chain type", %{conn: conn} do + fill = insert(:signet_fill, chain_type: :rollup, log_index: 0) + + query = """ + query ($chain_type: String!, $transaction_hash: FullHash!, $log_index: Int!) { + signet_fill(chain_type: $chain_type, transaction_hash: $transaction_hash, log_index: $log_index) { + transaction_hash + } + } + """ + + variables = %{ + "chain_type" => "invalid", + "transaction_hash" => to_string(fill.transaction_hash), + "log_index" => fill.log_index + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ "Invalid chain_type" + end + end + + describe "signet_fills query" do + test "returns paginated fills", %{conn: conn} do + fill1 = insert(:signet_fill, log_index: 0, block_number: 100, chain_type: :rollup) + fill2 = insert(:signet_fill, log_index: 1, block_number: 101, chain_type: :host) + fill3 = insert(:signet_fill, log_index: 2, block_number: 102, chain_type: :rollup) + + query = """ + query ($first: Int) { + signet_fills(first: $first) { + edges { + node { + transaction_hash + log_index + block_number + chain_type + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + """ + + variables = %{"first" => 10} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 3 + + # Fills should be returned in descending block_number order + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert block_numbers == [fill3.block_number, fill2.block_number, fill1.block_number] + end + + test "filters by chain_type", %{conn: conn} do + _fill1 = insert(:signet_fill, log_index: 0, block_number: 100, chain_type: :host) + fill2 = insert(:signet_fill, log_index: 1, block_number: 101, chain_type: :rollup) + fill3 = insert(:signet_fill, log_index: 2, block_number: 102, chain_type: :rollup) + + query = """ + query ($first: Int, $chain_type: String) { + signet_fills(first: $first, chain_type: $chain_type) { + edges { + node { + block_number + chain_type + } + } + } + } + """ + + variables = %{"first" => 10, "chain_type" => "rollup"} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 2 + + chain_types = Enum.map(result["edges"], & &1["node"]["chain_type"]) + assert Enum.all?(chain_types, &(&1 == "rollup")) + + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert fill2.block_number in block_numbers + assert fill3.block_number in block_numbers + end + + test "filters by block_number_gte", %{conn: conn} do + _fill1 = insert(:signet_fill, log_index: 0, block_number: 100) + fill2 = insert(:signet_fill, log_index: 1, block_number: 200) + fill3 = insert(:signet_fill, log_index: 2, block_number: 300) + + query = """ + query ($first: Int, $block_number_gte: Int) { + signet_fills(first: $first, block_number_gte: $block_number_gte) { + edges { + node { + block_number + } + } + } + } + """ + + variables = %{"first" => 10, "block_number_gte" => 150} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 2 + + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert fill2.block_number in block_numbers + assert fill3.block_number in block_numbers + end + + test "filters by block_number_lte", %{conn: conn} do + fill1 = insert(:signet_fill, log_index: 0, block_number: 100) + fill2 = insert(:signet_fill, log_index: 1, block_number: 200) + _fill3 = insert(:signet_fill, log_index: 2, block_number: 300) + + query = """ + query ($first: Int, $block_number_lte: Int) { + signet_fills(first: $first, block_number_lte: $block_number_lte) { + edges { + node { + block_number + } + } + } + } + """ + + variables = %{"first" => 10, "block_number_lte" => 250} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 2 + + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert fill1.block_number in block_numbers + assert fill2.block_number in block_numbers + end + + test "combines chain_type and block range filters", %{conn: conn} do + _fill1 = insert(:signet_fill, log_index: 0, block_number: 100, chain_type: :rollup) + fill2 = insert(:signet_fill, log_index: 1, block_number: 200, chain_type: :rollup) + _fill3 = insert(:signet_fill, log_index: 2, block_number: 200, chain_type: :host) + _fill4 = insert(:signet_fill, log_index: 3, block_number: 300, chain_type: :rollup) + + query = """ + query ($first: Int, $chain_type: String, $block_number_gte: Int, $block_number_lte: Int) { + signet_fills(first: $first, chain_type: $chain_type, block_number_gte: $block_number_gte, block_number_lte: $block_number_lte) { + edges { + node { + block_number + chain_type + } + } + } + } + """ + + variables = %{ + "first" => 10, + "chain_type" => "rollup", + "block_number_gte" => 150, + "block_number_lte" => 250 + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 1 + + [edge] = result["edges"] + assert edge["node"]["block_number"] == fill2.block_number + assert edge["node"]["chain_type"] == "rollup" + end + + test "returns empty list when no fills match", %{conn: conn} do + query = """ + query ($first: Int) { + signet_fills(first: $first) { + edges { + node { + transaction_hash + } + } + } + } + """ + + variables = %{"first" => 10} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_fills" => result}} = json_response(conn, 200) + assert result["edges"] == [] + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_orders_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_orders_test.exs new file mode 100644 index 000000000000..10b7ebe8185c --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/graphql/signet/signet_orders_test.exs @@ -0,0 +1,224 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule BlockScoutWeb.GraphQL.Signet.SignetOrdersTest do + use BlockScoutWeb.ConnCase + + @moduletag :signet + + describe "signet_order query" do + test "returns order by transaction hash and log index", %{conn: conn} do + order = insert(:signet_order, log_index: 0) + + query = """ + query ($transaction_hash: FullHash!, $log_index: Int!) { + signet_order(transaction_hash: $transaction_hash, log_index: $log_index) { + transaction_hash + log_index + block_number + deadline + inputs_json + outputs_json + } + } + """ + + variables = %{ + "transaction_hash" => to_string(order.transaction_hash), + "log_index" => order.log_index + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_order" => result}} = json_response(conn, 200) + assert result["transaction_hash"] == to_string(order.transaction_hash) + assert result["log_index"] == order.log_index + assert result["block_number"] == order.block_number + assert result["deadline"] == order.deadline + assert result["inputs_json"] == order.inputs_json + assert result["outputs_json"] == order.outputs_json + end + + test "returns error for non-existent order", %{conn: conn} do + query = """ + query ($transaction_hash: FullHash!, $log_index: Int!) { + signet_order(transaction_hash: $transaction_hash, log_index: $log_index) { + transaction_hash + } + } + """ + + variables = %{ + "transaction_hash" => "0x" <> String.duplicate("00", 32), + "log_index" => 0 + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ "Order not found" + end + + test "returns error for invalid transaction hash", %{conn: conn} do + query = """ + query ($transaction_hash: FullHash!, $log_index: Int!) { + signet_order(transaction_hash: $transaction_hash, log_index: $log_index) { + transaction_hash + } + } + """ + + variables = %{ + "transaction_hash" => "invalid", + "log_index" => 0 + } + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"errors" => _errors} = json_response(conn, 200) + end + end + + describe "signet_orders query" do + test "returns paginated orders", %{conn: conn} do + order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 101) + order3 = insert(:signet_order, log_index: 2, block_number: 102) + + query = """ + query ($first: Int) { + signet_orders(first: $first) { + edges { + node { + transaction_hash + log_index + block_number + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + """ + + variables = %{"first" => 10} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_orders" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 3 + + # Orders should be returned in descending block_number order + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert block_numbers == [order3.block_number, order2.block_number, order1.block_number] + end + + test "filters by block_number_gte", %{conn: conn} do + _order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 200) + order3 = insert(:signet_order, log_index: 2, block_number: 300) + + query = """ + query ($first: Int, $block_number_gte: Int) { + signet_orders(first: $first, block_number_gte: $block_number_gte) { + edges { + node { + block_number + } + } + } + } + """ + + variables = %{"first" => 10, "block_number_gte" => 150} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_orders" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 2 + + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert order2.block_number in block_numbers + assert order3.block_number in block_numbers + end + + test "filters by block_number_lte", %{conn: conn} do + order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 200) + _order3 = insert(:signet_order, log_index: 2, block_number: 300) + + query = """ + query ($first: Int, $block_number_lte: Int) { + signet_orders(first: $first, block_number_lte: $block_number_lte) { + edges { + node { + block_number + } + } + } + } + """ + + variables = %{"first" => 10, "block_number_lte" => 250} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_orders" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 2 + + block_numbers = Enum.map(result["edges"], & &1["node"]["block_number"]) + assert order1.block_number in block_numbers + assert order2.block_number in block_numbers + end + + test "filters by block range (gte and lte)", %{conn: conn} do + _order1 = insert(:signet_order, log_index: 0, block_number: 100) + order2 = insert(:signet_order, log_index: 1, block_number: 200) + _order3 = insert(:signet_order, log_index: 2, block_number: 300) + + query = """ + query ($first: Int, $block_number_gte: Int, $block_number_lte: Int) { + signet_orders(first: $first, block_number_gte: $block_number_gte, block_number_lte: $block_number_lte) { + edges { + node { + block_number + } + } + } + } + """ + + variables = %{"first" => 10, "block_number_gte" => 150, "block_number_lte" => 250} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_orders" => result}} = json_response(conn, 200) + assert length(result["edges"]) == 1 + + [edge] = result["edges"] + assert edge["node"]["block_number"] == order2.block_number + end + + test "returns empty list when no orders match", %{conn: conn} do + query = """ + query ($first: Int) { + signet_orders(first: $first) { + edges { + node { + transaction_hash + } + } + } + } + """ + + variables = %{"first" => 10} + + conn = post(conn, "/api/v1/graphql", query: query, variables: variables) + + assert %{"data" => %{"signet_orders" => result}} = json_response(conn, 200) + assert result["edges"] == [] + end + end + end +end diff --git a/apps/explorer/lib/explorer/graphql/signet.ex b/apps/explorer/lib/explorer/graphql/signet.ex new file mode 100644 index 000000000000..d82627fe3d8e --- /dev/null +++ b/apps/explorer/lib/explorer/graphql/signet.ex @@ -0,0 +1,128 @@ +defmodule Explorer.GraphQL.Signet do + @moduledoc """ + Defines Ecto queries to fetch Signet order and fill data for the GraphQL schema. + + Includes functions to construct queries for orders and fills, supporting + pagination and filtering by block range and chain type. + """ + + import Ecto.Query, only: [from: 2, order_by: 3, where: 3] + + alias Explorer.Chain.Hash + alias Explorer.Chain.Signet.{Fill, Order} + alias Explorer.Repo + + @doc """ + Gets a single order by transaction hash and log index. + + ## Parameters + - transaction_hash: the full transaction hash + - log_index: the log index within the transaction + + ## Returns + - Order struct or nil + """ + @spec get_order(Hash.Full.t(), integer()) :: Order.t() | nil + def get_order(transaction_hash, log_index) do + Repo.one( + from(o in Order, + where: o.transaction_hash == ^transaction_hash and o.log_index == ^log_index + ) + ) + end + + @doc """ + Constructs a query for orders with optional filters. + + ## Parameters + - args: Map with optional filters: + - block_number_gte: minimum block number + - block_number_lte: maximum block number + + ## Returns + - Ecto query + """ + @spec orders_query(map()) :: Ecto.Query.t() + def orders_query(args \\ %{}) do + from(o in Order, as: :order) + |> maybe_filter_block_range(args, :order) + |> order_by([order: o], desc: o.block_number, desc: o.log_index) + end + + @doc """ + Gets a single fill by chain type, transaction hash, and log index. + + ## Parameters + - chain_type: :rollup or :host + - transaction_hash: the full transaction hash + - log_index: the log index within the transaction + + ## Returns + - Fill struct or nil + """ + @spec get_fill(atom(), Hash.Full.t(), integer()) :: Fill.t() | nil + def get_fill(chain_type, transaction_hash, log_index) do + Repo.one( + from(f in Fill, + where: + f.chain_type == ^chain_type and + f.transaction_hash == ^transaction_hash and + f.log_index == ^log_index + ) + ) + end + + @doc """ + Constructs a query for fills with optional filters. + + ## Parameters + - args: Map with optional filters: + - chain_type: :rollup or :host atom + - block_number_gte: minimum block number + - block_number_lte: maximum block number + + ## Returns + - Ecto query + """ + @spec fills_query(map()) :: Ecto.Query.t() + def fills_query(args \\ %{}) do + from(f in Fill, as: :fill) + |> maybe_filter_chain_type(args) + |> maybe_filter_block_range(args, :fill) + |> order_by([fill: f], desc: f.block_number, desc: f.log_index) + end + + # Private helper to filter by chain_type + defp maybe_filter_chain_type(query, %{chain_type: chain_type}) when chain_type in [:rollup, :host] do + where(query, [fill: f], f.chain_type == ^chain_type) + end + + defp maybe_filter_chain_type(query, _), do: query + + # Private helper to filter by block range + defp maybe_filter_block_range(query, args, binding) do + query + |> maybe_filter_block_gte(args, binding) + |> maybe_filter_block_lte(args, binding) + end + + defp maybe_filter_block_gte(query, %{block_number_gte: block_number}, :order) do + where(query, [order: o], o.block_number >= ^block_number) + end + + defp maybe_filter_block_gte(query, %{block_number_gte: block_number}, :fill) do + where(query, [fill: f], f.block_number >= ^block_number) + end + + defp maybe_filter_block_gte(query, _, _), do: query + + defp maybe_filter_block_lte(query, %{block_number_lte: block_number}, :order) do + where(query, [order: o], o.block_number <= ^block_number) + end + + defp maybe_filter_block_lte(query, %{block_number_lte: block_number}, :fill) do + where(query, [fill: f], f.block_number <= ^block_number) + end + + defp maybe_filter_block_lte(query, _, _), do: query +end