diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index baac41c80403..8b488af903e4 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -62,6 +62,7 @@ jobs: "rsk", "scroll", "shibarium", + "signet", "stability", "zetachain", "zilliqa", diff --git a/apps/explorer/config/dev.exs b/apps/explorer/config/dev.exs index 715741583d1a..6dc674ac6326 100644 --- a/apps/explorer/config/dev.exs +++ b/apps/explorer/config/dev.exs @@ -32,7 +32,8 @@ for repo <- [ Explorer.Repo.Suave, Explorer.Repo.Zilliqa, Explorer.Repo.ZkSync, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do config :explorer, repo, timeout: :timer.seconds(80) end diff --git a/apps/explorer/config/prod.exs b/apps/explorer/config/prod.exs index 8371ee6c6d7e..4f0f47e472d0 100644 --- a/apps/explorer/config/prod.exs +++ b/apps/explorer/config/prod.exs @@ -34,7 +34,8 @@ for repo <- [ Explorer.Repo.Suave, Explorer.Repo.Zilliqa, Explorer.Repo.ZkSync, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do config :explorer, repo, prepare: :unnamed, diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 2de39c4c3e2e..e8b3d25b6b22 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -84,7 +84,8 @@ for repo <- [ Explorer.Repo.Suave, Explorer.Repo.Zilliqa, Explorer.Repo.ZkSync, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do config :explorer, repo, database: database, diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 375d9be6a185..947f570eaa35 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -378,7 +378,8 @@ defmodule Explorer.Application do Explorer.Repo.Stability, Explorer.Repo.Suave, Explorer.Repo.Zilliqa, - Explorer.Repo.ZkSync + Explorer.Repo.ZkSync, + Explorer.Repo.Signet ] else [] diff --git a/apps/explorer/lib/explorer/chain/import/runner/signet/fills.ex b/apps/explorer/lib/explorer/chain/import/runner/signet/fills.ex new file mode 100644 index 000000000000..88eb0486edb6 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/signet/fills.ex @@ -0,0 +1,106 @@ +defmodule Explorer.Chain.Import.Runner.Signet.Fills do + @moduledoc """ + Bulk imports of Explorer.Chain.Signet.Fill. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Import + alias Explorer.Chain.Signet.Fill + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Fill.t()] + + @impl Import.Runner + def ecto_schema_module, do: Fill + + @impl Import.Runner + def option_key, do: :signet_fills + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, Fill.insert_result_key(), fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :signet_fills, + :signet_fills + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [Fill.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + # Enforce Fill ShareLocks order (see docs: sharelock.md) + # Sort by composite primary key: chain_type, transaction_hash, log_index + ordered_changes_list = + Enum.sort_by(changes_list, fn change -> + {change.chain_type, change.transaction_hash, change.log_index} + end) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:chain_type, :transaction_hash, :log_index], + on_conflict: on_conflict, + for: Fill, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + + {:ok, inserted} + end + + defp default_on_conflict do + from( + f in Fill, + update: [ + set: [ + # Don't update primary key fields (chain_type, transaction_hash, log_index) + block_number: fragment("COALESCE(EXCLUDED.block_number, ?)", f.block_number), + outputs_json: fragment("COALESCE(EXCLUDED.outputs_json, ?)", f.outputs_json), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", f.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", f.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.block_number, EXCLUDED.outputs_json) IS DISTINCT FROM (?, ?)", + f.block_number, + f.outputs_json + ) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/signet/orders.ex b/apps/explorer/lib/explorer/chain/import/runner/signet/orders.ex new file mode 100644 index 000000000000..8dcde8bbd3d8 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/signet/orders.ex @@ -0,0 +1,114 @@ +defmodule Explorer.Chain.Import.Runner.Signet.Orders do + @moduledoc """ + Bulk imports of Explorer.Chain.Signet.Order. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Import + alias Explorer.Chain.Signet.Order + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Order.t()] + + @impl Import.Runner + def ecto_schema_module, do: Order + + @impl Import.Runner + def option_key, do: :signet_orders + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, Order.insert_result_key(), fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :signet_orders, + :signet_orders + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [Order.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + # Enforce Order ShareLocks order (see docs: sharelock.md) + # Sort by composite primary key: transaction_hash, then log_index + ordered_changes_list = + Enum.sort_by(changes_list, fn change -> + {change.transaction_hash, change.log_index} + end) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:transaction_hash, :log_index], + on_conflict: on_conflict, + for: Order, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + + {:ok, inserted} + end + + defp default_on_conflict do + from( + o in Order, + update: [ + set: [ + # Don't update primary key fields (transaction_hash, log_index) + deadline: fragment("COALESCE(EXCLUDED.deadline, ?)", o.deadline), + block_number: fragment("COALESCE(EXCLUDED.block_number, ?)", o.block_number), + inputs_json: fragment("COALESCE(EXCLUDED.inputs_json, ?)", o.inputs_json), + outputs_json: fragment("COALESCE(EXCLUDED.outputs_json, ?)", o.outputs_json), + sweep_recipient: fragment("COALESCE(EXCLUDED.sweep_recipient, ?)", o.sweep_recipient), + sweep_token: fragment("COALESCE(EXCLUDED.sweep_token, ?)", o.sweep_token), + sweep_amount: fragment("COALESCE(EXCLUDED.sweep_amount, ?)", o.sweep_amount), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", o.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", o.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.deadline, EXCLUDED.block_number, EXCLUDED.sweep_recipient, EXCLUDED.sweep_token, EXCLUDED.sweep_amount) IS DISTINCT FROM (?, ?, ?, ?, ?)", + o.deadline, + o.block_number, + o.sweep_recipient, + o.sweep_token, + o.sweep_amount + ) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex b/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex index ae42f2172bf7..b174c178af79 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex @@ -68,6 +68,10 @@ defmodule Explorer.Chain.Import.Stage.ChainTypeSpecific do ], stability: [ Runner.Stability.Validators + ], + signet: [ + Runner.Signet.Orders, + Runner.Signet.Fills ] } diff --git a/apps/explorer/lib/explorer/chain/signet/fill.ex b/apps/explorer/lib/explorer/chain/signet/fill.ex new file mode 100644 index 000000000000..4cc787a7ac3a --- /dev/null +++ b/apps/explorer/lib/explorer/chain/signet/fill.ex @@ -0,0 +1,74 @@ +defmodule Explorer.Chain.Signet.Fill do + @moduledoc """ + Models a Signet Filled event from RollupOrders or HostOrders contracts. + + Fills are indexed independently and uniquely identified by their + chain_type + transaction_hash + log_index combination. + + Changes in the schema should be reflected in the bulk import module: + - Explorer.Chain.Import.Runner.Signet.Fills + + Migrations: + - Explorer.Repo.Signet.Migrations.CreateSignetTables + """ + + use Explorer.Schema + + alias Explorer.Chain.Hash + + @insert_result_key :insert_signet_fills + + @optional_attrs ~w()a + + @required_attrs ~w(chain_type block_number transaction_hash log_index outputs_json)a + + @allowed_attrs @optional_attrs ++ @required_attrs + + @typedoc """ + Descriptor of a Signet Filled event: + * `chain_type` - Whether this fill occurred on :rollup or :host chain (primary key) + * `transaction_hash` - The hash of the transaction containing the fill (primary key) + * `log_index` - The index of the log within the transaction (primary key) + * `block_number` - The block number where the fill was executed + * `outputs_json` - List of filled outputs (token, amount, recipient, chainId) stored as JSONB. + Note: Uses `:map` Ecto type which accepts both maps and lists - PostgreSQL JSONB + stores both natively, and `insert_all` bypasses changeset validation. + NOTE: In Filled events, the `chainId` field represents the ORIGIN chain + (where the order was created), not the chain where the fill occurred. + """ + @type to_import :: %{ + chain_type: :rollup | :host, + block_number: non_neg_integer(), + transaction_hash: binary(), + log_index: non_neg_integer(), + outputs_json: [map()] + } + + @primary_key false + typed_schema "signet_fills" do + field(:chain_type, Ecto.Enum, values: [:rollup, :host], primary_key: true) + field(:transaction_hash, Hash.Full, primary_key: true) + field(:log_index, :integer, primary_key: true) + field(:block_number, :integer) + field(:outputs_json, :map) + + timestamps() + end + + @doc """ + Validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = fill, attrs \\ %{}) do + fill + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + |> unique_constraint([:chain_type, :transaction_hash, :log_index]) + end + + @doc """ + Shared result key used by import runners to return inserted Signet fills. + """ + @spec insert_result_key() :: atom() + def insert_result_key, do: @insert_result_key +end diff --git a/apps/explorer/lib/explorer/chain/signet/order.ex b/apps/explorer/lib/explorer/chain/signet/order.ex new file mode 100644 index 000000000000..792155e068c3 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/signet/order.ex @@ -0,0 +1,87 @@ +defmodule Explorer.Chain.Signet.Order do + @moduledoc """ + Models a Signet Order event from the RollupOrders contract. + + Orders are indexed independently and uniquely identified by their + transaction_hash + log_index combination. + + Changes in the schema should be reflected in the bulk import module: + - Explorer.Chain.Import.Runner.Signet.Orders + + Migrations: + - Explorer.Repo.Signet.Migrations.CreateSignetTables + """ + + use Explorer.Schema + + alias Explorer.Chain.{Hash, Wei} + + @insert_result_key :insert_signet_orders + + @optional_attrs ~w(sweep_recipient sweep_token sweep_amount)a + + @required_attrs ~w(deadline block_number transaction_hash log_index inputs_json outputs_json)a + + @allowed_attrs @optional_attrs ++ @required_attrs + + @typedoc """ + Descriptor of a Signet Order event: + * `transaction_hash` - The hash of the transaction containing the order (primary key) + * `log_index` - The index of the log within the transaction (primary key) + * `deadline` - The deadline timestamp for the order + * `block_number` - The block number where the order was created + * `inputs_json` - List of input tokens and amounts (stored as JSONB). + Note: Uses `:map` Ecto type which accepts both maps and lists - PostgreSQL JSONB + stores both natively, and `insert_all` bypasses changeset validation. + * `outputs_json` - List of output tokens, amounts, recipients, and chainIds (stored as JSONB). + Note: Uses `:map` Ecto type (same rationale as inputs_json). + NOTE: In Order events, the `chainId` field represents the DESTINATION chain + (where assets should be delivered), not the chain where the order was created. + * `sweep_recipient` - Recipient address from Sweep event (if any) + * `sweep_token` - Token address from Sweep event (if any) + * `sweep_amount` - Amount from Sweep event (if any) + """ + @type to_import :: %{ + deadline: non_neg_integer(), + block_number: non_neg_integer(), + transaction_hash: binary(), + log_index: non_neg_integer(), + inputs_json: [map()], + outputs_json: [map()], + sweep_recipient: binary() | nil, + sweep_token: binary() | nil, + sweep_amount: Decimal.t() | nil + } + + @primary_key false + typed_schema "signet_orders" do + field(:transaction_hash, Hash.Full, primary_key: true) + field(:log_index, :integer, primary_key: true) + field(:deadline, :integer) + field(:block_number, :integer) + field(:inputs_json, :map) + field(:outputs_json, :map) + field(:sweep_recipient, Hash.Address) + field(:sweep_token, Hash.Address) + field(:sweep_amount, Wei) + + timestamps() + end + + @doc """ + Validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = order, attrs \\ %{}) do + order + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + |> unique_constraint([:transaction_hash, :log_index]) + end + + @doc """ + Shared result key used by import runners to return inserted Signet orders. + """ + @spec insert_result_key() :: atom() + def insert_result_key, do: @insert_result_key +end diff --git a/apps/explorer/lib/explorer/repo.ex b/apps/explorer/lib/explorer/repo.ex index 17c445d7bca9..f6b634e18398 100644 --- a/apps/explorer/lib/explorer/repo.ex +++ b/apps/explorer/lib/explorer/repo.ex @@ -153,7 +153,8 @@ defmodule Explorer.Repo do Explorer.Repo.Suave, Explorer.Repo.Zilliqa, Explorer.Repo.ZkSync, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do defmodule repo do use Ecto.Repo, diff --git a/apps/explorer/priv/contracts_abi/signet/host_orders.json b/apps/explorer/priv/contracts_abi/signet/host_orders.json new file mode 100644 index 000000000000..d90705712317 --- /dev/null +++ b/apps/explorer/priv/contracts_abi/signet/host_orders.json @@ -0,0 +1,281 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_permit2", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "fill", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "fillPermit2", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "permit2", + "type": "tuple", + "internalType": "struct UsesPermit2.Permit2Batch", + "components": [ + { + "name": "permit", + "type": "tuple", + "internalType": "struct ISignatureTransfer.PermitBatchTransferFrom", + "components": [ + { + "name": "permitted", + "type": "tuple[]", + "internalType": "struct ISignatureTransfer.TokenPermissions[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "outputWitness", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [ + { + "name": "_witness", + "type": "tuple", + "internalType": "struct UsesPermit2.Witness", + "components": [ + { + "name": "witnessHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "witnessTypeString", + "type": "string", + "internalType": "string" + } + ] + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Filled", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "OutputMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] + diff --git a/apps/explorer/priv/contracts_abi/signet/rollup_orders.json b/apps/explorer/priv/contracts_abi/signet/rollup_orders.json new file mode 100644 index 000000000000..42dd68402547 --- /dev/null +++ b/apps/explorer/priv/contracts_abi/signet/rollup_orders.json @@ -0,0 +1,541 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_permit2", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "fill", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "fillPermit2", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "permit2", + "type": "tuple", + "internalType": "struct UsesPermit2.Permit2Batch", + "components": [ + { + "name": "permit", + "type": "tuple", + "internalType": "struct ISignatureTransfer.PermitBatchTransferFrom", + "components": [ + { + "name": "permitted", + "type": "tuple[]", + "internalType": "struct ISignatureTransfer.TokenPermissions[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initiate", + "inputs": [ + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "inputs", + "type": "tuple[]", + "internalType": "struct IOrders.Input[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "initiatePermit2", + "inputs": [ + { + "name": "tokenRecipient", + "type": "address", + "internalType": "address" + }, + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "permit2", + "type": "tuple", + "internalType": "struct UsesPermit2.Permit2Batch", + "components": [ + { + "name": "permit", + "type": "tuple", + "internalType": "struct ISignatureTransfer.PermitBatchTransferFrom", + "components": [ + { + "name": "permitted", + "type": "tuple[]", + "internalType": "struct ISignatureTransfer.TokenPermissions[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "outputWitness", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "outputs": [ + { + "name": "_witness", + "type": "tuple", + "internalType": "struct UsesPermit2.Witness", + "components": [ + { + "name": "witnessHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "witnessTypeString", + "type": "string", + "internalType": "string" + } + ] + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "sweep", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Filled", + "inputs": [ + { + "name": "outputs", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Order", + "inputs": [ + { + "name": "deadline", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "inputs", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IOrders.Input[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "outputs", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IOrders.Output[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "chainId", + "type": "uint32", + "internalType": "uint32" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Sweep", + "inputs": [ + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "OrderExpired", + "inputs": [] + }, + { + "type": "error", + "name": "OutputMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] + diff --git a/apps/explorer/priv/signet/migrations/20260216040000_create_signet_tables.exs b/apps/explorer/priv/signet/migrations/20260216040000_create_signet_tables.exs new file mode 100644 index 000000000000..7bb20ad82d5e --- /dev/null +++ b/apps/explorer/priv/signet/migrations/20260216040000_create_signet_tables.exs @@ -0,0 +1,44 @@ +defmodule Explorer.Repo.Signet.Migrations.CreateSignetTables do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE signet_fill_chain_type AS ENUM ('rollup', 'host')", + "DROP TYPE signet_fill_chain_type" + ) + + create table(:signet_orders, primary_key: false) do + # Composite primary key: transaction_hash + log_index uniquely identifies an order + add(:transaction_hash, :bytea, null: false, primary_key: true) + add(:log_index, :integer, null: false, primary_key: true) + add(:deadline, :bigint, null: false) + add(:block_number, :bigint, null: false) + # JSONB columns for input/output arrays (queryable, validated) + add(:inputs_json, :map, null: false) + add(:outputs_json, :map, null: false) + # Sweep event data (nullable - only present if Sweep was emitted) + add(:sweep_recipient, :bytea, null: true) + add(:sweep_token, :bytea, null: true) + add(:sweep_amount, :numeric, precision: 100, null: true) + timestamps(null: false, type: :utc_datetime_usec) + end + + # Index for querying orders by block for reorg handling + create(index(:signet_orders, [:block_number])) + # Index for finding unfilled orders by deadline + create(index(:signet_orders, [:deadline])) + + create table(:signet_fills, primary_key: false) do + # Composite primary key: chain_type + transaction_hash + log_index + add(:chain_type, :signet_fill_chain_type, null: false, primary_key: true) + add(:transaction_hash, :bytea, null: false, primary_key: true) + add(:log_index, :integer, null: false, primary_key: true) + add(:block_number, :bigint, null: false) + add(:outputs_json, :map, null: false) + timestamps(null: false, type: :utc_datetime_usec) + end + + # Index for querying fills by block for reorg handling + create(index(:signet_fills, [:chain_type, :block_number])) + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/signet/fills_test.exs b/apps/explorer/test/explorer/chain/import/runner/signet/fills_test.exs new file mode 100644 index 000000000000..cea9ae039021 --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/runner/signet/fills_test.exs @@ -0,0 +1,224 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule Explorer.Chain.Import.Runner.Signet.FillsTest do + use Explorer.DataCase + + alias Ecto.Multi + alias Explorer.Chain.Hash + alias Explorer.Chain.Import.Runner.Signet.Fills, as: FillsRunner + alias Explorer.Chain.Signet.Fill + alias Explorer.Repo + + @moduletag :signet + + defp cast_hash!(bytes) do + {:ok, hash} = Hash.Full.cast(bytes) + hash + end + + describe "run/3" do + test "inserts a new rollup fill" do + tx_hash = cast_hash!(<<1::256>>) + + params = [ + %{ + chain_type: :rollup, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "500", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = + Multi.new() + |> FillsRunner.run(params, %{timestamps: timestamps}) + + assert {:ok, %{insert_signet_fills: [fill]}} = Repo.transaction(multi) + assert fill.block_number == 100 + assert fill.chain_type == :rollup + assert fill.log_index == 0 + end + + test "inserts a new host fill" do + tx_hash = cast_hash!(<<2::256>>) + + params = [ + %{ + chain_type: :host, + block_number: 200, + transaction_hash: tx_hash, + log_index: 1, + outputs_json: [%{"token" => "0xaaaa", "recipient" => "0xbbbb", "amount" => "1000", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> FillsRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_fills: [fill]}} = Repo.transaction(multi) + + assert fill.block_number == 200 + assert fill.chain_type == :host + end + + test "same transaction can have fills on different chains" do + tx_hash = cast_hash!(<<3::256>>) + + rollup_params = [ + %{ + chain_type: :rollup, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + outputs_json: [%{"token" => "0x1111", "recipient" => "0x2222", "amount" => "500", "chainId" => "1"}] + } + ] + + host_params = [ + %{ + chain_type: :host, + block_number: 200, + transaction_hash: tx_hash, + # Same log_index but different chain_type + log_index: 0, + outputs_json: [%{"token" => "0x1111", "recipient" => "0x2222", "amount" => "500", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi1 = Multi.new() |> FillsRunner.run(rollup_params, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi1) + + multi2 = Multi.new() |> FillsRunner.run(host_params, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi2) + + # Should have two fills (one per chain type) + assert Repo.aggregate(Fill, :count) == 2 + + # Verify both exist + rollup_fill = Repo.get_by(Fill, chain_type: :rollup, transaction_hash: tx_hash, log_index: 0) + host_fill = Repo.get_by(Fill, chain_type: :host, transaction_hash: tx_hash, log_index: 0) + + assert rollup_fill.block_number == 100 + assert host_fill.block_number == 200 + end + + test "handles duplicate fills with upsert on composite primary key" do + tx_hash = cast_hash!(<<4::256>>) + + params1 = [ + %{ + chain_type: :rollup, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + outputs_json: [%{"token" => "0x1234", "amount" => "500", "recipient" => "0x5678", "chainId" => "1"}] + } + ] + + params2 = [ + %{ + chain_type: :rollup, + # Different block + block_number: 101, + transaction_hash: tx_hash, + # Same log_index + chain_type + log_index: 0, + outputs_json: [%{"token" => "0x1234", "amount" => "1000", "recipient" => "0x5678", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi1 = Multi.new() |> FillsRunner.run(params1, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi1) + + multi2 = Multi.new() |> FillsRunner.run(params2, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi2) + + # Should only have one fill for this chain_type + tx_hash + log_index combo + assert Repo.aggregate(Fill, :count) == 1 + + fill = Repo.one!(Fill) + # Updated + assert fill.block_number == 101 + end + + test "different log_index creates separate fills" do + tx_hash = cast_hash!(<<5::256>>) + + params = [ + %{ + chain_type: :rollup, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + outputs_json: [%{"token" => "0x1111", "amount" => "500", "recipient" => "0x2222", "chainId" => "1"}] + }, + %{ + chain_type: :rollup, + block_number: 100, + transaction_hash: tx_hash, + # Different log_index + log_index: 1, + outputs_json: [%{"token" => "0x3333", "amount" => "1000", "recipient" => "0x4444", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> FillsRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_fills: fills}} = Repo.transaction(multi) + + assert length(fills) == 2 + assert Repo.aggregate(Fill, :count) == 2 + end + + test "inserts multiple fills in batch" do + params = + for i <- 1..5 do + %{ + chain_type: if(rem(i, 2) == 0, do: :host, else: :rollup), + block_number: 100 + i, + transaction_hash: cast_hash!(<<100 + i::256>>), + log_index: 0, + outputs_json: [ + %{"token" => "0x#{i}", "recipient" => "0x#{i}", "amount" => "#{i * 500}", "chainId" => "1"} + ] + } + end + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> FillsRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_fills: fills}} = Repo.transaction(multi) + + assert length(fills) == 5 + assert Repo.aggregate(Fill, :count) == 5 + + # Verify chain type distribution + rollup_count = Enum.count(fills, &(&1.chain_type == :rollup)) + host_count = Enum.count(fills, &(&1.chain_type == :host)) + # i = 1, 3, 5 + assert rollup_count == 3 + # i = 2, 4 + assert host_count == 2 + end + end + + describe "ecto_schema_module/0" do + test "returns Fill module" do + assert FillsRunner.ecto_schema_module() == Fill + end + end + + describe "option_key/0" do + test "returns :signet_fills" do + assert FillsRunner.option_key() == :signet_fills + end + end + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/signet/orders_test.exs b/apps/explorer/test/explorer/chain/import/runner/signet/orders_test.exs new file mode 100644 index 000000000000..1846248b1216 --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/runner/signet/orders_test.exs @@ -0,0 +1,188 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule Explorer.Chain.Import.Runner.Signet.OrdersTest do + use Explorer.DataCase + + alias Ecto.Multi + alias Explorer.Chain.Hash + alias Explorer.Chain.Import.Runner.Signet.Orders, as: OrdersRunner + alias Explorer.Chain.Signet.Order + alias Explorer.Repo + + @moduletag :signet + + defp cast_hash!(bytes) do + {:ok, hash} = Hash.Full.cast(bytes) + hash + end + + describe "run/3" do + test "inserts a new order" do + tx_hash = cast_hash!(<<1::256>>) + + params = [ + %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + inputs_json: [%{"token" => "0x1234", "amount" => "1000"}], + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "500", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = + Multi.new() + |> OrdersRunner.run(params, %{timestamps: timestamps}) + + assert {:ok, %{insert_signet_orders: [order]}} = Repo.transaction(multi) + assert order.block_number == 100 + assert order.deadline == 1_700_000_000 + assert order.log_index == 0 + end + + test "handles duplicate orders with upsert on composite primary key" do + tx_hash = cast_hash!(<<2::256>>) + + params = [ + %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + inputs_json: [%{"token" => "0x1234", "amount" => "1000"}], + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "500", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + # Insert first time + multi1 = Multi.new() |> OrdersRunner.run(params, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi1) + + # Insert second time with same tx_hash + log_index but different data + updated_params = [ + %{ + # Different deadline + deadline: 1_700_000_001, + # Different block + block_number: 101, + transaction_hash: tx_hash, + # Same log_index + log_index: 0, + inputs_json: [%{"token" => "0x1234", "amount" => "2000"}], + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "1000", "chainId" => "1"}] + } + ] + + multi2 = Multi.new() |> OrdersRunner.run(updated_params, %{timestamps: timestamps}) + {:ok, _} = Repo.transaction(multi2) + + # Should only have one order + assert Repo.aggregate(Order, :count) == 1 + + # Order should be updated + order = Repo.get_by(Order, transaction_hash: tx_hash, log_index: 0) + assert order.deadline == 1_700_000_001 + assert order.block_number == 101 + end + + test "different log_index creates separate orders" do + tx_hash = cast_hash!(<<3::256>>) + + params = [ + %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + inputs_json: [%{"token" => "0x1111", "amount" => "1000"}], + outputs_json: [%{"token" => "0x2222", "recipient" => "0x3333", "amount" => "500", "chainId" => "1"}] + }, + %{ + deadline: 1_700_000_001, + block_number: 100, + transaction_hash: tx_hash, + # Different log_index + log_index: 1, + inputs_json: [%{"token" => "0x4444", "amount" => "2000"}], + outputs_json: [%{"token" => "0x5555", "recipient" => "0x6666", "amount" => "1000", "chainId" => "1"}] + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> OrdersRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_orders: orders}} = Repo.transaction(multi) + + assert length(orders) == 2 + assert Repo.aggregate(Order, :count) == 2 + end + + test "inserts order with sweep data" do + tx_hash = cast_hash!(<<4::256>>) + {:ok, sweep_recipient} = Explorer.Chain.Hash.Address.cast(<<5::160>>) + {:ok, sweep_token} = Explorer.Chain.Hash.Address.cast(<<6::160>>) + + params = [ + %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + inputs_json: [%{"token" => "0x1234", "amount" => "1000"}], + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "500", "chainId" => "1"}], + sweep_recipient: sweep_recipient, + sweep_token: sweep_token, + sweep_amount: %Explorer.Chain.Wei{value: Decimal.new("12345")} + } + ] + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> OrdersRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_orders: [order]}} = Repo.transaction(multi) + + assert order.sweep_amount == %Explorer.Chain.Wei{value: Decimal.new("12345")} + end + + test "inserts multiple orders in batch" do + params = + for i <- 1..5 do + %{ + deadline: 1_700_000_000 + i, + block_number: 100 + i, + transaction_hash: cast_hash!(<<100 + i::256>>), + log_index: 0, + inputs_json: [%{"token" => "0x#{i}", "amount" => "#{i * 1000}"}], + outputs_json: [ + %{"token" => "0x#{i}", "recipient" => "0x#{i}", "amount" => "#{i * 500}", "chainId" => "1"} + ] + } + end + + timestamps = %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + + multi = Multi.new() |> OrdersRunner.run(params, %{timestamps: timestamps}) + {:ok, %{insert_signet_orders: orders}} = Repo.transaction(multi) + + assert length(orders) == 5 + assert Repo.aggregate(Order, :count) == 5 + end + end + + describe "ecto_schema_module/0" do + test "returns Order module" do + assert OrdersRunner.ecto_schema_module() == Order + end + end + + describe "option_key/0" do + test "returns :signet_orders" do + assert OrdersRunner.option_key() == :signet_orders + end + end + end +end diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index 378bcc55cd37..38ffff7c920f 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -57,6 +57,7 @@ defmodule Explorer.Factory do } alias Explorer.Chain.Optimism.{InteropMessage, OutputRoot} + alias Explorer.Chain.Signet.{Order, Fill} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Chain.Zilliqa.Hash.BLSPublicKey alias Explorer.Chain.Zilliqa.Staker, as: ZilliqaStaker @@ -1800,4 +1801,41 @@ defmodule Explorer.Factory do meta: nil } end + + def signet_order_factory do + %Order{ + transaction_hash: transaction_hash(), + log_index: sequence(:signet_log_index, & &1), + deadline: DateTime.utc_now() |> DateTime.to_unix() |> Kernel.+(3600), + block_number: block_number(), + inputs_json: [ + %{"token" => "0x" <> String.duplicate("aa", 20), "amount" => "1000000000000000000"} + ], + outputs_json: [ + %{ + "token" => "0x" <> String.duplicate("bb", 20), + "amount" => "1000000000000000000", + "recipient" => "0x" <> String.duplicate("cc", 20), + "chainId" => 1 + } + ] + } + end + + def signet_fill_factory do + %Fill{ + chain_type: :rollup, + transaction_hash: transaction_hash(), + log_index: sequence(:signet_log_index, & &1), + block_number: block_number(), + outputs_json: [ + %{ + "token" => "0x" <> String.duplicate("bb", 20), + "amount" => "1000000000000000000", + "recipient" => "0x" <> String.duplicate("cc", 20), + "chainId" => 1 + } + ] + } + end end diff --git a/apps/explorer/test/test_helper.exs b/apps/explorer/test/test_helper.exs index 55deb8e9949c..8fe047dd5a7e 100644 --- a/apps/explorer/test/test_helper.exs +++ b/apps/explorer/test/test_helper.exs @@ -27,6 +27,7 @@ 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) +Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Signet, :auto) Mox.defmock(Explorer.Market.Source.TestSource, for: Explorer.Market.Source) Mox.defmock(Explorer.History.TestHistorian, for: Explorer.History.Historian) diff --git a/apps/indexer/lib/indexer/fetcher/signet/abi.ex b/apps/indexer/lib/indexer/fetcher/signet/abi.ex new file mode 100644 index 000000000000..5c7660788669 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/signet/abi.ex @@ -0,0 +1,55 @@ +defmodule Indexer.Fetcher.Signet.Abi do + @moduledoc """ + ABI event topic hashes for Signet contracts. + + ## Event Signatures + + RollupOrders contract: + - Order(uint256 deadline, (address token, uint256 amount)[] inputs, (address token, uint256 amount, address recipient, uint32 chainId)[] outputs) + - Filled((address token, uint256 amount, address recipient, uint32 chainId)[] outputs) + - Sweep(address indexed recipient, address indexed token, uint256 amount) + + HostOrders contract: + - Filled((address token, uint256 amount, address recipient, uint32 chainId)[] outputs) + """ + + @order_event_signature "Order(uint256,(address,uint256)[],(address,uint256,address,uint32)[])" + @filled_event_signature "Filled((address,uint256,address,uint32)[])" + @sweep_event_signature "Sweep(address,address,uint256)" + + @order_event_topic "0x" <> Base.encode16(ExKeccak.hash_256(@order_event_signature), case: :lower) + @filled_event_topic "0x" <> Base.encode16(ExKeccak.hash_256(@filled_event_signature), case: :lower) + @sweep_event_topic "0x" <> Base.encode16(ExKeccak.hash_256(@sweep_event_signature), case: :lower) + + @doc "Returns the keccak256 topic hash for the Order event." + @spec order_event_topic() :: String.t() + def order_event_topic, do: @order_event_topic + + @doc "Returns the keccak256 topic hash for the Filled event." + @spec filled_event_topic() :: String.t() + def filled_event_topic, do: @filled_event_topic + + @doc "Returns the keccak256 topic hash for the Sweep event." + @spec sweep_event_topic() :: String.t() + def sweep_event_topic, do: @sweep_event_topic + + @doc "Returns all event topics for the RollupOrders contract." + @spec rollup_orders_event_topics() :: [String.t()] + def rollup_orders_event_topics, do: [@order_event_topic, @filled_event_topic, @sweep_event_topic] + + @doc "Returns all event topics for the HostOrders contract." + @spec host_orders_event_topics() :: [String.t()] + def host_orders_event_topics, do: [@filled_event_topic] + + @doc "Returns the event signature string for the Order event." + @spec order_event_signature() :: String.t() + def order_event_signature, do: @order_event_signature + + @doc "Returns the event signature string for the Filled event." + @spec filled_event_signature() :: String.t() + def filled_event_signature, do: @filled_event_signature + + @doc "Returns the event signature string for the Sweep event." + @spec sweep_event_signature() :: String.t() + def sweep_event_signature, do: @sweep_event_signature +end diff --git a/apps/indexer/lib/indexer/fetcher/signet/event_parser.ex b/apps/indexer/lib/indexer/fetcher/signet/event_parser.ex new file mode 100644 index 000000000000..55fe420a8e50 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/signet/event_parser.ex @@ -0,0 +1,388 @@ +defmodule Indexer.Fetcher.Signet.EventParser do + @moduledoc """ + Parses Signet Order and Filled events from transaction logs. + + Event signatures and ABI types are sourced from @signet-sh/sdk. + See `Indexer.Fetcher.Signet.Abi` for topic hash computation. + + ## Event Structures (from @signet-sh/sdk) + + ### Order Event + ``` + Order(uint256 deadline, Input[] inputs, Output[] outputs) + ``` + Where: + - Input = (address token, uint256 amount) + - Output = (address token, uint256 amount, address recipient, uint32 chainId) + + ### Filled Event + ``` + Filled(Output[] outputs) + ``` + + ### Sweep Event + ``` + Sweep(address indexed recipient, address indexed token, uint256 amount) + ``` + + ## Architecture Note + + Orders and fills are indexed independently. Direct correlation between orders + and their fills is not possible at the indexer level - only block-level + coordination is available. The data is stored separately for querying and + analytics purposes. + """ + + require Logger + + alias Indexer.Fetcher.Signet.Abi + + # Size of the Order event header: deadline (32) + inputs_offset (32) + outputs_offset (32) + @order_header_size 96 + + @doc """ + Parse logs from the RollupOrders contract. + + Returns {:ok, {orders, fills}} where orders and fills are lists of maps + ready for database import. + """ + @spec parse_rollup_logs([map()]) :: {:ok, {[map()], [map()]}} + def parse_rollup_logs(logs) when is_list(logs) do + {orders, fills, sweeps} = + logs + |> Enum.map(&normalize_log/1) + |> Enum.reduce({[], [], []}, &classify_and_parse_log/2) + + orders_with_sweeps = associate_sweeps_with_orders(orders, sweeps) + + {:ok, {Enum.reverse(orders_with_sweeps), Enum.reverse(fills)}} + end + + @doc """ + Parse Filled events from the HostOrders contract. + + Returns {:ok, fills} where fills is a list of maps ready for database import. + """ + @spec parse_host_filled_logs([map()]) :: {:ok, [map()]} + def parse_host_filled_logs(logs) when is_list(logs) do + filled_topic = Abi.filled_event_topic() + + fills = + logs + |> Enum.map(&normalize_log/1) + |> Enum.filter(&(Enum.at(&1.topics, 0) == filled_topic)) + |> Enum.flat_map(&parse_host_fill_log/1) + + {:ok, fills} + end + + defp parse_host_fill_log(log) do + case parse_filled_event(log) do + {:ok, fill} -> + [fill] + + {:error, reason} -> + Logger.warning("Failed to parse host Filled event: #{inspect(reason)}") + [] + end + end + + # Normalize log keys from JSON-RPC string keys or Elixir atom keys into a + # consistent atom-keyed map. Called once at the entry point so all downstream + # functions work with a single format. + defp normalize_log(log) when is_map(log) do + %{ + topics: Map.get(log, "topics") || Map.get(log, :topics) || [], + data: Map.get(log, "data") || Map.get(log, :data) || "", + transaction_hash: Map.get(log, "transactionHash") || Map.get(log, :transaction_hash), + block_number: Map.get(log, "blockNumber") || Map.get(log, :block_number), + log_index: Map.get(log, "logIndex") || Map.get(log, :log_index) + } + end + + defp classify_and_parse_log(log, {orders_acc, fills_acc, sweeps_acc}) do + topic = Enum.at(log.topics, 0) + + cond do + topic == Abi.order_event_topic() -> + collect_parsed(parse_order_event(log), "Order", orders_acc, fills_acc, sweeps_acc, :order) + + topic == Abi.filled_event_topic() -> + collect_parsed(parse_filled_event(log), "Filled", orders_acc, fills_acc, sweeps_acc, :fill) + + topic == Abi.sweep_event_topic() -> + collect_parsed(parse_sweep_event(log), "Sweep", orders_acc, fills_acc, sweeps_acc, :sweep) + + true -> + {orders_acc, fills_acc, sweeps_acc} + end + end + + defp collect_parsed({:ok, item}, _label, orders, fills, sweeps, :order), + do: {[item | orders], fills, sweeps} + + defp collect_parsed({:ok, item}, _label, orders, fills, sweeps, :fill), + do: {orders, [item | fills], sweeps} + + defp collect_parsed({:ok, item}, _label, orders, fills, sweeps, :sweep), + do: {orders, fills, [item | sweeps]} + + defp collect_parsed({:error, reason}, label, orders, fills, sweeps, _slot) do + Logger.warning("Failed to parse #{label} event: #{inspect(reason)}") + {orders, fills, sweeps} + end + + # Parse Order event: Order(uint256 deadline, Input[] inputs, Output[] outputs) + defp parse_order_event(log) do + data = decode_hex_data(log.data) + + with {:ok, {deadline, inputs, outputs}} <- decode_order_data(data), + {:ok, block_number} <- parse_block_number(log), + {:ok, log_index} <- parse_log_index(log) do + {:ok, + %{ + deadline: deadline, + block_number: block_number, + transaction_hash: format_transaction_hash(log.transaction_hash), + log_index: log_index, + inputs_json: format_inputs(inputs), + outputs_json: format_outputs(outputs) + }} + end + end + + # Parse Filled event: Filled(Output[] outputs) + defp parse_filled_event(log) do + data = decode_hex_data(log.data) + + with {:ok, outputs} <- decode_filled_data(data), + {:ok, block_number} <- parse_block_number(log), + {:ok, log_index} <- parse_log_index(log) do + {:ok, + %{ + block_number: block_number, + transaction_hash: format_transaction_hash(log.transaction_hash), + log_index: log_index, + outputs_json: format_outputs(outputs) + }} + end + end + + # Parse Sweep event: Sweep(address indexed recipient, address indexed token, uint256 amount) + defp parse_sweep_event(log) do + data = decode_hex_data(log.data) + + with {:ok, amount} <- decode_sweep_data(data), + {:ok, log_index} <- parse_log_index(log) do + {:ok, + %{ + transaction_hash: format_transaction_hash(log.transaction_hash), + log_index: log_index, + recipient: decode_indexed_address(Enum.at(log.topics, 1)), + token: decode_indexed_address(Enum.at(log.topics, 2)), + amount: amount + }} + end + end + + # ABI decoders + + defp decode_order_data(data) when is_binary(data) and byte_size(data) >= @order_header_size do + <> = data + + rest_size = byte_size(rest) + + # ABI offsets are from the start of the data payload; subtract the header + # size to get the position within `rest` (which starts after the header). + inputs_rel = inputs_offset - @order_header_size + outputs_rel = outputs_offset - @order_header_size + + if inputs_rel < 0 or inputs_rel > rest_size or outputs_rel < 0 or outputs_rel > rest_size do + {:error, :invalid_abi_offsets} + else + inputs_data = binary_part(rest, inputs_rel, rest_size - inputs_rel) + inputs = decode_input_array(inputs_data) + + outputs_data = binary_part(rest, outputs_rel, rest_size - outputs_rel) + outputs = decode_output_array(outputs_data) + + {:ok, {deadline, inputs, outputs}} + end + rescue + e -> + Logger.error("Error decoding Order data: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}") + {:error, :decode_failed} + end + + defp decode_order_data(_), do: {:error, :invalid_data} + + defp decode_filled_data(data) when is_binary(data) and byte_size(data) >= 32 do + <<_offset::unsigned-big-integer-size(256), rest::binary>> = data + {:ok, decode_output_array(rest)} + rescue + e -> + Logger.error("Error decoding Filled data: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}") + {:error, :decode_failed} + end + + defp decode_filled_data(_), do: {:error, :invalid_data} + + defp decode_sweep_data(<>), do: {:ok, amount} + + defp decode_sweep_data(_), do: {:error, :invalid_data} + + # Array decoders + + defp decode_input_array(<>) do + decode_inputs(rest, length, []) + end + + defp decode_inputs(_data, 0, acc), do: Enum.reverse(acc) + + defp decode_inputs( + <<_padding::binary-size(12), token::binary-size(20), amount::unsigned-big-integer-size(256), rest::binary>>, + count, + acc + ) do + decode_inputs(rest, count - 1, [{token, amount} | acc]) + end + + defp decode_output_array(<>) do + decode_outputs(rest, length, []) + end + + defp decode_outputs(_data, 0, acc), do: Enum.reverse(acc) + + defp decode_outputs( + <<_padding1::binary-size(12), token::binary-size(20), amount::unsigned-big-integer-size(256), + _padding2::binary-size(12), recipient::binary-size(20), _padding3::binary-size(28), + chain_id::unsigned-big-integer-size(32), rest::binary>>, + count, + acc + ) do + decode_outputs(rest, count - 1, [{token, amount, recipient, chain_id} | acc]) + end + + # Sweep association + + defp associate_sweeps_with_orders(orders, sweeps) do + sweeps_by_tx = Enum.group_by(sweeps, & &1.transaction_hash) + + # Group orders by transaction hash and match within each group + orders + |> Enum.group_by(& &1.transaction_hash) + |> Enum.flat_map(fn {tx_hash, tx_orders} -> + case Map.get(sweeps_by_tx, tx_hash) do + nil -> + tx_orders + + tx_sweeps -> + # 1:1 matching: sort both by log_index, greedily assign each order + # to the nearest unused sweep so no sweep is reused. + match_orders_to_sweeps(tx_orders, tx_sweeps) + end + end) + end + + # Greedy 1:1 matching: for each order (sorted by log_index), pick the + # closest available sweep and remove it from the pool. + defp match_orders_to_sweeps(orders, sweeps) do + sorted_orders = Enum.sort_by(orders, & &1.log_index) + available_sweeps = Enum.sort_by(sweeps, & &1.log_index) + + {matched_orders, _remaining} = + Enum.map_reduce(sorted_orders, available_sweeps, fn order, pool -> + case pool do + [] -> + {order, []} + + _ -> + {nearest, idx} = + pool + |> Enum.with_index() + |> Enum.min_by(fn {sweep, _idx} -> abs(sweep.log_index - order.log_index) end) + + {attach_sweep(order, nearest), List.delete_at(pool, idx)} + end + end) + + matched_orders + end + + defp attach_sweep(order, sweep) do + Map.merge(order, %{ + sweep_recipient: sweep.recipient, + sweep_token: sweep.token, + sweep_amount: sweep.amount + }) + end + + # Formatters + + defp format_inputs(inputs) do + Enum.map(inputs, fn {token, amount} -> + %{"token" => format_address(token), "amount" => Integer.to_string(amount)} + end) + end + + defp format_outputs(outputs) do + Enum.map(outputs, fn {token, amount, recipient, chain_id} -> + %{ + "token" => format_address(token), + "amount" => Integer.to_string(amount), + "recipient" => format_address(recipient), + "chainId" => chain_id + } + end) + end + + defp format_address(bytes) when is_binary(bytes) and byte_size(bytes) == 20 do + "0x" <> Base.encode16(bytes, case: :lower) + end + + defp format_transaction_hash("0x" <> _ = hash), do: hash + defp format_transaction_hash(bytes) when is_binary(bytes), do: "0x" <> Base.encode16(bytes, case: :lower) + + defp format_transaction_hash(other), + do: raise(ArgumentError, "invalid transaction hash: #{inspect(other)}") + + # Field parsers + + defp decode_hex_data("0x" <> hex), do: Base.decode16!(hex, case: :mixed) + defp decode_hex_data(raw) when is_binary(raw), do: raw + + defp decode_hex_data(other), + do: raise(ArgumentError, "invalid hex data: #{inspect(other)}") + + defp decode_indexed_address("0x" <> hex) do + address_hex = String.slice(hex, -40, 40) + Base.decode16!(address_hex, case: :mixed) + end + + defp decode_indexed_address(bytes) when is_binary(bytes) and byte_size(bytes) == 32 do + binary_part(bytes, 12, 20) + end + + defp decode_indexed_address(_), do: nil + + defp parse_block_number(%{block_number: "0x" <> hex}) do + case Integer.parse(hex, 16) do + {num, ""} -> {:ok, num} + _ -> {:error, {:invalid_block_number, "0x" <> hex}} + end + end + + defp parse_block_number(%{block_number: num}) when is_integer(num), do: {:ok, num} + defp parse_block_number(%{block_number: other}), do: {:error, {:invalid_block_number, other}} + + defp parse_log_index(%{log_index: "0x" <> hex}) do + case Integer.parse(hex, 16) do + {num, ""} -> {:ok, num} + _ -> {:error, {:invalid_log_index, "0x" <> hex}} + end + end + + defp parse_log_index(%{log_index: num}) when is_integer(num), do: {:ok, num} + defp parse_log_index(%{log_index: other}), do: {:error, {:invalid_log_index, other}} +end diff --git a/apps/indexer/lib/indexer/fetcher/signet/orders_fetcher.ex b/apps/indexer/lib/indexer/fetcher/signet/orders_fetcher.ex new file mode 100644 index 000000000000..b647baa38264 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/signet/orders_fetcher.ex @@ -0,0 +1,472 @@ +defmodule Indexer.Fetcher.Signet.OrdersFetcher do + @moduledoc """ + Fetcher for Signet Order and Filled events from RollupOrders and HostOrders contracts. + + This module indexes Signet protocol events: + 1. Parsing Order events from the RollupOrders contract on L2 + 2. Parsing Filled events from both RollupOrders (L2) and HostOrders (L1) contracts + 3. Parsing Sweep events from RollupOrders contract + 4. Inserting events into signet_orders / signet_fills tables + + Note: Orders and fills are indexed independently. Direct correlation between + orders and their fills is not possible at the indexer level - only block-level + coordination is available. + + ## Event Signatures + + RollupOrders contract: + - Order(uint256 deadline, Input[] inputs, Output[] outputs) + - Filled(Output[] outputs) + - Sweep(address recipient, address token, uint256 amount) + + HostOrders contract: + - Filled(Output[] outputs) + + ## Configuration + + The fetcher requires the following configuration in config.exs: + + config :indexer, Indexer.Fetcher.Signet.OrdersFetcher, + enabled: true, + rollup_orders_address: "0x...", + host_orders_address: "0x...", + l1_rpc: "https://...", + l1_rpc_block_range: 1000, + l2_rpc_block_range: 1000, + recheck_interval: 15_000, + start_block: 0, + failure_interval_threshold: 600_000 + + ## Architecture + + Uses a BufferedTask-based approach similar to Arbitrum fetchers, with tasks: + - `:check_new_rollup` - Discovers new events on rollup chain (L2) + - `:check_new_host` - Discovers new Filled events on host chain (L1) + - `:check_historical` - Backfills historical events + """ + + use Indexer.Fetcher, restart: :permanent + + require Logger + + import Ecto.Query + + alias Explorer.Chain + alias Explorer.Chain.Signet.{Fill, Order} + alias Explorer.Repo.Signet, as: SignetRepo + alias Indexer.BufferedTask + alias Indexer.Fetcher.Signet.{Abi, EventParser} + alias Indexer.Helper, as: IndexerHelper + + @behaviour BufferedTask + + # Event topic hashes are computed from @signet-sh/sdk ABIs + # See Indexer.Fetcher.Signet.Abi for event signature definitions + + # 250ms interval between processing buffered entries + @idle_interval 250 + @max_concurrency 1 + @max_batch_size 1 + + # 10 minutes cooldown for failed tasks + @cooldown_interval :timer.minutes(10) + + # Catchup interval for historical discovery + @catchup_recheck_interval :timer.seconds(2) + + @typep fetcher_task :: :check_new_rollup | :check_new_host | :check_historical + @typep queued_task :: :init_worker | {non_neg_integer(), fetcher_task()} + + def child_spec([init_options, gen_server_options]) do + {json_rpc_named_arguments, init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + config = Application.get_all_env(:indexer)[__MODULE__] || [] + + rollup_orders_address = config[:rollup_orders_address] + host_orders_address = config[:host_orders_address] + l1_rpc = config[:l1_rpc] + l1_rpc_block_range = config[:l1_rpc_block_range] || 1000 + l2_rpc_block_range = config[:l2_rpc_block_range] || 1000 + recheck_interval = config[:recheck_interval] || 15_000 + start_block = config[:start_block] || 0 + + failure_interval_threshold = config[:failure_interval_threshold] || min(20 * recheck_interval, :timer.minutes(10)) + + intervals = %{ + check_new_rollup: recheck_interval, + check_new_host: recheck_interval, + check_historical: @catchup_recheck_interval + } + + initial_config = %{ + json_l2_rpc_named_arguments: json_rpc_named_arguments, + json_l1_rpc_named_arguments: if(l1_rpc, do: IndexerHelper.json_rpc_named_arguments(l1_rpc), else: nil), + rollup_orders_address: rollup_orders_address, + host_orders_address: host_orders_address, + l1_rpc_block_range: l1_rpc_block_range, + l2_rpc_block_range: l2_rpc_block_range, + recheck_interval: recheck_interval, + failure_interval_threshold: failure_interval_threshold, + start_block: start_block + } + + initial_state = %{ + config: initial_config, + intervals: intervals, + task_data: %{}, + completed_tasks: %{} + } + + buffered_task_init_options = + defaults() + |> Keyword.merge(init_options) + |> Keyword.put(:state, initial_state) + + Supervisor.child_spec( + {BufferedTask, [{__MODULE__, buffered_task_init_options}, gen_server_options]}, + id: __MODULE__, + restart: :transient + ) + end + + defp defaults do + [ + flush_interval: @idle_interval, + max_concurrency: @max_concurrency, + max_batch_size: @max_batch_size, + poll: false, + task_supervisor: __MODULE__.TaskSupervisor, + metadata: [fetcher: :signet_orders_fetcher] + ] + end + + @impl BufferedTask + def init(initial, reducer, _state) do + reducer.(:init_worker, initial) + end + + @impl BufferedTask + @spec run([queued_task()], map()) :: {:ok, map()} | {:retry, [queued_task()], map()} | :retry + def run(tasks, state) + + def run([:init_worker], state) do + configured_state = initialize_workers(state) + + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + tasks_to_run = + [{now, :check_new_rollup}] + |> maybe_add_host_task(now, configured_state) + |> maybe_add_historical_task(now, configured_state) + + completion_state = %{ + check_historical: is_nil(configured_state.config.start_block) + } + + BufferedTask.buffer(__MODULE__, tasks_to_run, false) + + updated_state = Map.put(configured_state, :completed_tasks, completion_state) + {:ok, updated_state} + end + + def run([{timeout, task_tag}], state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + with {:timeout_elapsed, true} <- {:timeout_elapsed, timeout <= now}, + {:threshold_ok, true} <- {:threshold_ok, now - timeout <= state.config.failure_interval_threshold}, + {:runner_defined, runner} when not is_nil(runner) <- {:runner_defined, Map.get(task_runners(), task_tag)} do + runner.(state) + else + {:timeout_elapsed, false} -> + {:retry, [{timeout, task_tag}], state} + + {:threshold_ok, false} -> + new_timeout = now + @cooldown_interval + Logger.warning("Task #{task_tag} has been failing abnormally, applying cooldown") + {:retry, [{new_timeout, task_tag}], state} + + {:runner_defined, nil} -> + Logger.warning("Unknown task type: #{inspect(task_tag)}") + {:ok, state} + end + end + + defp task_runners do + %{ + check_new_rollup: &handle_check_new_rollup/1, + check_new_host: &handle_check_new_host/1, + check_historical: &handle_check_historical/1 + } + end + + defp initialize_workers(state) do + rollup_start_block = get_last_processed_block(:rollup, state.config.start_block) + host_start_block = get_last_processed_block(:host, state.config.start_block) + + task_data = %{ + check_new_rollup: %{ + start_block: rollup_start_block + }, + check_new_host: %{ + start_block: host_start_block + }, + check_historical: %{ + end_block: state.config.start_block + } + } + + %{state | task_data: task_data} + end + + defp maybe_add_host_task(tasks, now, state) do + if state.config.json_l1_rpc_named_arguments && state.config.host_orders_address do + [{now, :check_new_host} | tasks] + else + tasks + end + end + + defp maybe_add_historical_task(tasks, now, state) do + if state.config.start_block && state.config.start_block > 0 do + [{now, :check_historical} | tasks] + else + tasks + end + end + + defp handle_check_new_rollup(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + case fetch_and_process_rollup_events(state) do + {:ok, updated_state} -> + next_run_time = now + updated_state.intervals[:check_new_rollup] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_new_rollup}], false) + {:ok, updated_state} + + {:error, reason} -> + Logger.error("Failed to fetch rollup events: #{inspect(reason)}") + {:retry, [{now + @cooldown_interval, :check_new_rollup}], state} + end + end + + defp handle_check_new_host(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + case fetch_and_process_host_events(state) do + {:ok, updated_state} -> + next_run_time = now + updated_state.intervals[:check_new_host] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_new_host}], false) + {:ok, updated_state} + + {:error, reason} -> + Logger.error("Failed to fetch host events: #{inspect(reason)}") + {:retry, [{now + @cooldown_interval, :check_new_host}], state} + end + end + + defp handle_check_historical(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + case fetch_historical_events(state) do + {:ok, updated_state, :continue} -> + next_run_time = now + updated_state.intervals[:check_historical] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_historical}], false) + {:ok, updated_state} + + {:ok, updated_state, :done} -> + Logger.info("Historical event discovery completed") + updated_state = put_in(updated_state.completed_tasks[:check_historical], true) + {:ok, updated_state} + + {:error, reason} -> + Logger.error("Failed to fetch historical events: #{inspect(reason)}") + {:retry, [{now + @cooldown_interval, :check_historical}], state} + end + end + + defp fetch_and_process_rollup_events(state) do + config = state.config + start_block = state.task_data.check_new_rollup.start_block + + with {:ok, latest_block} <- get_latest_block(config.json_l2_rpc_named_arguments), + # Bound the block range to avoid unbounded eth_getLogs calls + end_block = min(start_block + config.l2_rpc_block_range, latest_block), + {:ok, logs} <- + fetch_logs( + config.json_l2_rpc_named_arguments, + config.rollup_orders_address, + start_block, + end_block + ), + {:ok, {orders, fills}} <- EventParser.parse_rollup_logs(logs), + :ok <- import_orders(orders), + :ok <- import_fills(fills, :rollup) do + Logger.info( + "Processed rollup events: #{length(orders)} orders, #{length(fills)} fills (blocks #{start_block}-#{end_block})" + ) + + updated_task_data = put_in(state.task_data.check_new_rollup.start_block, end_block + 1) + {:ok, %{state | task_data: updated_task_data}} + else + {:error, reason} -> {:error, reason} + end + end + + defp fetch_and_process_host_events(state) do + config = state.config + start_block = state.task_data.check_new_host.start_block + + with {:ok, latest_block} <- get_latest_block(config.json_l1_rpc_named_arguments), + end_block = min(start_block + config.l1_rpc_block_range, latest_block), + {:ok, logs} <- + fetch_logs( + config.json_l1_rpc_named_arguments, + config.host_orders_address, + start_block, + end_block, + Abi.host_orders_event_topics() + ), + {:ok, fills} <- EventParser.parse_host_filled_logs(logs), + :ok <- import_fills(fills, :host) do + Logger.info("Processed host events: #{length(fills)} fills (blocks #{start_block}-#{end_block})") + + updated_task_data = put_in(state.task_data.check_new_host.start_block, end_block + 1) + {:ok, %{state | task_data: updated_task_data}} + else + {:error, reason} -> {:error, reason} + end + end + + defp fetch_historical_events(state) do + config = state.config + end_block = state.task_data.check_historical.end_block + + if end_block <= 0 do + {:ok, state, :done} + else + start_block = max(0, end_block - config.l2_rpc_block_range) + + with {:ok, logs} <- + fetch_logs( + config.json_l2_rpc_named_arguments, + config.rollup_orders_address, + start_block, + end_block + ), + {:ok, {orders, fills}} <- EventParser.parse_rollup_logs(logs), + :ok <- import_orders(orders), + :ok <- import_fills(fills, :rollup) do + Logger.info("Processed historical events: #{length(orders)} orders (blocks #{start_block}-#{end_block})") + + updated_task_data = put_in(state.task_data.check_historical.end_block, start_block - 1) + status = if start_block <= 0, do: :done, else: :continue + {:ok, %{state | task_data: updated_task_data}, status} + else + {:error, reason} -> {:error, reason} + end + end + end + + defp fetch_logs(json_rpc_named_arguments, contract_address, from_block, to_block, topics \\ nil) do + topics = topics || Abi.rollup_orders_event_topics() + + request = %{ + id: 1, + jsonrpc: "2.0", + method: "eth_getLogs", + params: [ + %{ + address: contract_address, + fromBlock: "0x#{Integer.to_string(from_block, 16)}", + toBlock: "0x#{Integer.to_string(to_block, 16)}", + topics: [topics] + } + ] + } + + case EthereumJSONRPC.json_rpc(request, json_rpc_named_arguments) do + {:ok, logs} -> {:ok, logs} + {:error, reason} -> {:error, reason} + end + end + + defp get_latest_block(json_rpc_named_arguments) do + request = %{ + id: 1, + jsonrpc: "2.0", + method: "eth_blockNumber", + params: [] + } + + case EthereumJSONRPC.json_rpc(request, json_rpc_named_arguments) do + {:ok, result} -> + case EthereumJSONRPC.quantity_to_integer(result) do + nil -> {:error, {:invalid_block_number, result}} + block -> {:ok, block} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp get_last_processed_block(chain_type, default_start) do + # Query database for last processed block + # Falls back to default_start if no records exist + case chain_type do + :rollup -> + order_max = + SignetRepo.one( + from(o in Order, + select: max(o.block_number) + ) + ) + + fill_max = + SignetRepo.one( + from(f in Fill, + where: f.chain_type == :rollup, + select: max(f.block_number) + ) + ) + + case {order_max, fill_max} do + {nil, nil} -> default_start + {nil, fill} -> fill + 1 + {order, nil} -> order + 1 + {order, fill} -> max(order, fill) + 1 + end + + :host -> + case SignetRepo.one( + from(f in Fill, + where: f.chain_type == :host, + select: max(f.block_number) + ) + ) do + nil -> default_start + block -> block + 1 + end + end + end + + defp import_orders([]), do: :ok + + defp import_orders(orders) do + case Chain.import(%{signet_orders: %{params: orders}, timeout: :infinity}) do + {:ok, _} -> :ok + {:error, step, reason, _} -> {:error, {step, reason}} + end + end + + defp import_fills([], _chain_type), do: :ok + + defp import_fills(fills, chain_type) do + fills_with_chain = Enum.map(fills, &Map.put(&1, :chain_type, chain_type)) + + case Chain.import(%{signet_fills: %{params: fills_with_chain}, timeout: :infinity}) do + {:ok, _} -> :ok + {:error, step, reason, _} -> {:error, {step, reason}} + end + end +end diff --git a/apps/indexer/lib/indexer/supervisor.ex b/apps/indexer/lib/indexer/supervisor.ex index 057de8cc7305..365064e7311f 100644 --- a/apps/indexer/lib/indexer/supervisor.ex +++ b/apps/indexer/lib/indexer/supervisor.ex @@ -63,6 +63,7 @@ defmodule Indexer.Supervisor do alias Indexer.Fetcher.Arbitrum.RollupMessagesCatchup, as: ArbitrumRollupMessagesCatchup alias Indexer.Fetcher.Arbitrum.TrackingBatchesStatuses, as: ArbitrumTrackingBatchesStatuses alias Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1, as: ArbitrumTrackingMessagesOnL1 + alias Indexer.Fetcher.Signet.OrdersFetcher, as: SignetOrdersFetcher alias Indexer.Fetcher.ZkSync.BatchesStatusTracker, as: ZkSyncBatchesStatusTracker alias Indexer.Fetcher.ZkSync.TransactionBatch, as: ZkSyncTransactionBatch @@ -250,6 +251,9 @@ defmodule Indexer.Supervisor do {ArbitrumMessagesToL2Matcher.Supervisor, [[memory_monitor: memory_monitor]]}, {ArbitrumDataBackfill.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, + configure(SignetOrdersFetcher.Supervisor, [ + [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] + ]), configure(Indexer.Fetcher.Celo.ValidatorGroupVotes.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), diff --git a/apps/indexer/test/indexer/fetcher/signet/abi_test.exs b/apps/indexer/test/indexer/fetcher/signet/abi_test.exs new file mode 100644 index 000000000000..a166bea9f1f2 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/signet/abi_test.exs @@ -0,0 +1,83 @@ +defmodule Indexer.Fetcher.Signet.AbiTest do + @moduledoc """ + Unit tests for Indexer.Fetcher.Signet.Abi module. + """ + + use ExUnit.Case, async: true + + alias Indexer.Fetcher.Signet.Abi + + # Expected topic hashes computed from keccak256 of canonical event signatures + # Order(uint256,(address,uint256)[],(address,uint256,address,uint32)[]) + @expected_order_topic "0x" <> + Base.encode16( + ExKeccak.hash_256("Order(uint256,(address,uint256)[],(address,uint256,address,uint32)[])"), + case: :lower + ) + + # Filled((address,uint256,address,uint32)[]) + @expected_filled_topic "0x" <> + Base.encode16( + ExKeccak.hash_256("Filled((address,uint256,address,uint32)[])"), + case: :lower + ) + + # Sweep(address,address,uint256) + @expected_sweep_topic "0x" <> + Base.encode16(ExKeccak.hash_256("Sweep(address,address,uint256)"), case: :lower) + + describe "event topic hashes" do + test "event topics are different from each other" do + order_topic = Abi.order_event_topic() + filled_topic = Abi.filled_event_topic() + sweep_topic = Abi.sweep_event_topic() + + refute order_topic == filled_topic + refute order_topic == sweep_topic + refute filled_topic == sweep_topic + end + + test "order event topic matches expected keccak256 hash" do + assert Abi.order_event_topic() == @expected_order_topic + end + + test "filled event topic matches expected keccak256 hash" do + assert Abi.filled_event_topic() == @expected_filled_topic + end + + test "sweep event topic matches expected keccak256 hash" do + assert Abi.sweep_event_topic() == @expected_sweep_topic + end + + test "topic hashes match known-good hard-coded values" do + # These values were independently computed via keccak256 of the canonical signatures + assert Abi.order_event_topic() == + "0x80c9b8738a5ff299b770efb55e4372a5fc655294aca7145b3c529c2d89732d62" + + assert Abi.filled_event_topic() == + "0x14b3027353aba71f468d178fdede9ac211a25ae484028823bce1e6700e58e624" + + assert Abi.sweep_event_topic() == + "0xed679328aebf74ede77ae09efcf36e90244f83643dadac1c2d9f0b21a46f6ab7" + end + end + + describe "rollup_orders_event_topics/0" do + test "returns list of three topics" do + topics = Abi.rollup_orders_event_topics() + + assert length(topics) == 3 + assert Abi.order_event_topic() in topics + assert Abi.filled_event_topic() in topics + assert Abi.sweep_event_topic() in topics + end + end + + describe "host_orders_event_topics/0" do + test "returns list with only filled topic" do + topics = Abi.host_orders_event_topics() + + assert topics == [Abi.filled_event_topic()] + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/signet/event_parser_test.exs b/apps/indexer/test/indexer/fetcher/signet/event_parser_test.exs new file mode 100644 index 000000000000..86e8e18b764f --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/signet/event_parser_test.exs @@ -0,0 +1,539 @@ +defmodule Indexer.Fetcher.Signet.EventParserTest do + @moduledoc """ + Table-driven tests for ABI decoding in Indexer.Fetcher.Signet.EventParser. + + Test vectors are derived from the @signet-sh/sdk TypeScript test suite. + ABI encoding helpers construct raw event log data independently of the parser, + providing cross-validation of the manual binary decoder. + """ + + use ExUnit.Case, async: true + + import Bitwise + + alias Indexer.Fetcher.Signet.{Abi, EventParser} + + # -- ABI encoding helpers -- + + defp encode_uint256(value), do: <> + defp encode_uint32(value), do: <<0::224, value::unsigned-big-integer-size(32)>> + + defp encode_address(addr) when byte_size(addr) == 20, do: <<0::96, addr::binary>> + + defp encode_address("0x" <> hex) do + <<0::96, Base.decode16!(hex, case: :mixed)::binary>> + end + + defp encode_input_array(inputs) do + count = encode_uint256(length(inputs)) + + elements = + Enum.map(inputs, fn {token, amount} -> + encode_address(token) <> encode_uint256(amount) + end) + + IO.iodata_to_binary([count | elements]) + end + + defp encode_output_array(outputs) do + count = encode_uint256(length(outputs)) + + elements = + Enum.map(outputs, fn {token, amount, recipient, chain_id} -> + encode_address(token) <> encode_uint256(amount) <> encode_address(recipient) <> encode_uint32(chain_id) + end) + + IO.iodata_to_binary([count | elements]) + end + + defp encode_order_data(deadline, inputs, outputs) do + inputs_encoded = encode_input_array(inputs) + outputs_encoded = encode_output_array(outputs) + + # Offsets are from start of data payload (3 header words = 96 bytes) + inputs_offset = 96 + outputs_offset = inputs_offset + byte_size(inputs_encoded) + + encode_uint256(deadline) <> + encode_uint256(inputs_offset) <> + encode_uint256(outputs_offset) <> + inputs_encoded <> + outputs_encoded + end + + defp encode_filled_data(outputs) do + outputs_encoded = encode_output_array(outputs) + # Single dynamic array: offset word (32) + encoded data + encode_uint256(32) <> outputs_encoded + end + + defp encode_sweep_data(amount), do: encode_uint256(amount) + + defp to_hex(binary), do: "0x" <> Base.encode16(binary, case: :lower) + + defp build_log(opts) do + data = Keyword.fetch!(opts, :data) + topics = Keyword.get(opts, :topics, []) + tx_hash = Keyword.get(opts, :tx_hash, "0x" <> String.duplicate("ab", 32)) + block = Keyword.get(opts, :block, 100) + index = Keyword.get(opts, :index, 0) + + %{ + "data" => to_hex(data), + "topics" => topics, + "transactionHash" => tx_hash, + "blockNumber" => "0x" <> Integer.to_string(block, 16), + "logIndex" => "0x" <> Integer.to_string(index, 16) + } + end + + # -- Addresses used across tests (from @signet-sh/sdk vectors) -- + + @zero_addr "0x0000000000000000000000000000000000000000" + @usdc "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + @usdt "0xdac17f958d2ee523a2206206994597c13d831ec7" + @wbtc "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599" + @weth "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + @addr_1234 "0x1234567890123456789012345678901234567890" + @addr_1111 "0x1111111111111111111111111111111111111111" + @addr_2222 "0x2222222222222222222222222222222222222222" + @addr_3333 "0x3333333333333333333333333333333333333333" + @addr_4444 "0x4444444444444444444444444444444444444444" + @addr_6666 "0x6666666666666666666666666666666666666666" + @addr_8888 "0x8888888888888888888888888888888888888888" + @signet_token "0x96f44ddc3bc8892371305531f1a6d8ca2331fe6c" + + # -- Order event decoding tests (from vectors.json) -- + + describe "parse_rollup_logs/1 - Order events" do + @order_vectors [ + %{ + name: "minimal_order", + deadline: 0, + inputs: [{@zero_addr, 0}], + outputs: [{@zero_addr, 0, @zero_addr, 0}], + expected_inputs: [%{"token" => @zero_addr, "amount" => "0"}], + expected_outputs: [%{"token" => @zero_addr, "amount" => "0", "recipient" => @zero_addr, "chainId" => 0}] + }, + %{ + name: "multi_input", + deadline: 0x6553F100, + inputs: [{@usdc, 0xF4240}, {@usdt, 0x1E8480}, {@wbtc, 0x5F5E100}], + outputs: [{@zero_addr, 0xDE0B6B3A7640000, @addr_1234, 1}], + expected_inputs: [ + %{"token" => @usdc, "amount" => "1000000"}, + %{"token" => @usdt, "amount" => "2000000"}, + %{"token" => @wbtc, "amount" => "100000000"} + ], + expected_outputs: [ + %{"token" => @zero_addr, "amount" => "1000000000000000000", "recipient" => @addr_1234, "chainId" => 1} + ] + }, + %{ + name: "multi_output", + deadline: 0x6B49D200, + inputs: [{@usdc, 0x989680}], + outputs: [ + {@usdc, 0x2DC6C0, @addr_1111, 1}, + {@usdc, 0x2DC6C0, @addr_2222, 1}, + {@usdc, 0x3D0900, @addr_3333, 1} + ], + expected_inputs: [%{"token" => @usdc, "amount" => "10000000"}], + expected_outputs: [ + %{"token" => @usdc, "amount" => "3000000", "recipient" => @addr_1111, "chainId" => 1}, + %{"token" => @usdc, "amount" => "3000000", "recipient" => @addr_2222, "chainId" => 1}, + %{"token" => @usdc, "amount" => "4000000", "recipient" => @addr_3333, "chainId" => 1} + ] + }, + %{ + name: "cross_chain", + deadline: 0x684EE180, + inputs: [{@usdc, 0x4C4B40}], + outputs: [ + {@usdc, 0x2625A0, @addr_4444, 1}, + {@usdc, 0x2625A0, @addr_4444, 421_614} + ], + expected_inputs: [%{"token" => @usdc, "amount" => "5000000"}], + expected_outputs: [ + %{"token" => @usdc, "amount" => "2500000", "recipient" => @addr_4444, "chainId" => 1}, + %{"token" => @usdc, "amount" => "2500000", "recipient" => @addr_4444, "chainId" => 421_614} + ] + }, + %{ + name: "large_amounts", + deadline: 0xFFFFFFFFFFFFFFFF, + inputs: [{@weth, 0x21E19E0C9BAB2400000}], + outputs: [{@weth, 0x21E19E0C9BAB2400000, @addr_6666, 1}], + expected_inputs: [%{"token" => @weth, "amount" => "10000000000000000000000"}], + expected_outputs: [ + %{ + "token" => @weth, + "amount" => "10000000000000000000000", + "recipient" => @addr_6666, + "chainId" => 1 + } + ] + }, + %{ + name: "mainnet_config", + deadline: 0x65920080, + inputs: [{@usdc, 0x5F5E100}], + outputs: [ + {@signet_token, 0x2FAF080, @addr_8888, 1}, + {@signet_token, 0x2FAF080, @addr_8888, 519} + ], + expected_inputs: [%{"token" => @usdc, "amount" => "100000000"}], + expected_outputs: [ + %{"token" => @signet_token, "amount" => "50000000", "recipient" => @addr_8888, "chainId" => 1}, + %{"token" => @signet_token, "amount" => "50000000", "recipient" => @addr_8888, "chainId" => 519} + ] + } + ] + + for vector <- @order_vectors do + @vector vector + test "decodes #{@vector.name} Order event" do + v = @vector + data = encode_order_data(v.deadline, v.inputs, v.outputs) + + log = + build_log( + data: data, + topics: [Abi.order_event_topic()], + block: 42, + index: 3, + tx_hash: "0x" <> String.duplicate("01", 32) + ) + + {:ok, {[order], []}} = EventParser.parse_rollup_logs([log]) + + assert order.deadline == v.deadline + assert order.block_number == 42 + assert order.log_index == 3 + assert order.transaction_hash == "0x" <> String.duplicate("01", 32) + assert order.inputs_json == v.expected_inputs + assert order.outputs_json == v.expected_outputs + end + end + end + + # -- Filled event decoding tests -- + + describe "parse_rollup_logs/1 - Filled events" do + @fill_vectors [ + %{ + name: "minimal_fill", + outputs: [{@zero_addr, 0, @zero_addr, 0}], + expected: [%{"token" => @zero_addr, "amount" => "0", "recipient" => @zero_addr, "chainId" => 0}] + }, + %{ + name: "single_weth", + outputs: [{@weth, 1_000_000_000_000_000_000, @addr_1234, 1}], + expected: [ + %{"token" => @weth, "amount" => "1000000000000000000", "recipient" => @addr_1234, "chainId" => 1} + ] + }, + %{ + name: "multi_output", + outputs: [ + {@weth, 500_000_000_000_000_000, @addr_1111, 1}, + {@usdc, 1_000_000_000, @addr_2222, 1} + ], + expected: [ + %{"token" => @weth, "amount" => "500000000000000000", "recipient" => @addr_1111, "chainId" => 1}, + %{"token" => @usdc, "amount" => "1000000000", "recipient" => @addr_2222, "chainId" => 1} + ] + }, + %{ + name: "cross_chain", + outputs: [ + {@usdc, 500_000_000, @addr_4444, 1}, + {@usdc, 500_000_000, @addr_4444, 519} + ], + expected: [ + %{"token" => @usdc, "amount" => "500000000", "recipient" => @addr_4444, "chainId" => 1}, + %{"token" => @usdc, "amount" => "500000000", "recipient" => @addr_4444, "chainId" => 519} + ] + } + ] + + for vector <- @fill_vectors do + @vector vector + test "decodes #{@vector.name} Filled event" do + v = @vector + data = encode_filled_data(v.outputs) + + log = + build_log( + data: data, + topics: [Abi.filled_event_topic()], + block: 99, + index: 7 + ) + + {:ok, {[], [fill]}} = EventParser.parse_rollup_logs([log]) + + assert fill.block_number == 99 + assert fill.log_index == 7 + assert fill.outputs_json == v.expected + end + end + end + + # -- Sweep event decoding tests -- + + describe "parse_rollup_logs/1 - Sweep events" do + test "decodes minimal Sweep event" do + data = encode_sweep_data(0) + zero_topic = "0x" <> String.duplicate("00", 32) + + log = + build_log( + data: data, + topics: [Abi.sweep_event_topic(), zero_topic, zero_topic], + block: 10, + index: 0 + ) + + # Sweep events are only returned as part of order association, not standalone. + # With no orders, sweeps are consumed internally but result in empty orders list. + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + end + + # -- Order + Sweep association tests -- + + describe "parse_rollup_logs/1 - Order + Sweep association" do + test "order gets sweep fields when sweep exists in same tx" do + tx_hash = "0x" <> String.duplicate("aa", 32) + order_data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + sweep_data = encode_sweep_data(50) + + recipient_topic = "0x000000000000000000000000" <> String.duplicate("22", 20) + token_topic = "0x000000000000000000000000" <> String.duplicate("33", 20) + + order_log = build_log(data: order_data, topics: [Abi.order_event_topic()], tx_hash: tx_hash, block: 1, index: 0) + + sweep_log = + build_log( + data: sweep_data, + topics: [Abi.sweep_event_topic(), recipient_topic, token_topic], + tx_hash: tx_hash, + block: 1, + index: 1 + ) + + {:ok, {[order], []}} = EventParser.parse_rollup_logs([order_log, sweep_log]) + + assert order.sweep_amount == 50 + assert order.sweep_recipient == Base.decode16!(String.duplicate("22", 20), case: :lower) + assert order.sweep_token == Base.decode16!(String.duplicate("33", 20), case: :lower) + end + + test "order has no sweep fields when no sweep in tx" do + order_data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + log = build_log(data: order_data, topics: [Abi.order_event_topic()], block: 1, index: 0) + + {:ok, {[order], []}} = EventParser.parse_rollup_logs([log]) + + refute Map.has_key?(order, :sweep_recipient) + refute Map.has_key?(order, :sweep_token) + refute Map.has_key?(order, :sweep_amount) + end + + test "warns when multiple sweeps exist for same tx" do + tx_hash = "0x" <> String.duplicate("cc", 32) + order_data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + + recipient1 = "0x000000000000000000000000" <> String.duplicate("11", 20) + recipient2 = "0x000000000000000000000000" <> String.duplicate("22", 20) + token_topic = "0x000000000000000000000000" <> String.duplicate("ff", 20) + + order_log = build_log(data: order_data, topics: [Abi.order_event_topic()], tx_hash: tx_hash, block: 1, index: 0) + + sweep_log1 = + build_log( + data: encode_sweep_data(10), + topics: [Abi.sweep_event_topic(), recipient1, token_topic], + tx_hash: tx_hash, + block: 1, + index: 1 + ) + + sweep_log2 = + build_log( + data: encode_sweep_data(20), + topics: [Abi.sweep_event_topic(), recipient2, token_topic], + tx_hash: tx_hash, + block: 1, + index: 2 + ) + + # Should still succeed (uses first element from reversed accumulator, i.e. last encountered) + {:ok, {[order], []}} = EventParser.parse_rollup_logs([order_log, sweep_log1, sweep_log2]) + + assert order.sweep_amount in [10, 20] + end + end + + # -- Host filled logs tests -- + + describe "parse_host_filled_logs/1" do + test "parses only Filled events, ignores others" do + fill_data = encode_filled_data([{@usdc, 100, @addr_1111, 1}]) + fill_log = build_log(data: fill_data, topics: [Abi.filled_event_topic()], block: 50, index: 0) + noise_log = build_log(data: <<0::256>>, topics: ["0xdeadbeef" <> String.duplicate("00", 28)], block: 50, index: 1) + + {:ok, fills} = EventParser.parse_host_filled_logs([fill_log, noise_log]) + + assert length(fills) == 1 + assert hd(fills).block_number == 50 + end + + test "returns empty list for empty input" do + {:ok, []} = EventParser.parse_host_filled_logs([]) + end + + test "parses multiple Filled events" do + fill1 = + build_log( + data: encode_filled_data([{@usdc, 100, @addr_1111, 1}]), + topics: [Abi.filled_event_topic()], + block: 10, + index: 0, + tx_hash: "0x" <> String.duplicate("01", 32) + ) + + fill2 = + build_log( + data: encode_filled_data([{@weth, 200, @addr_2222, 519}]), + topics: [Abi.filled_event_topic()], + block: 11, + index: 0, + tx_hash: "0x" <> String.duplicate("02", 32) + ) + + {:ok, fills} = EventParser.parse_host_filled_logs([fill1, fill2]) + + assert length(fills) == 2 + assert Enum.at(fills, 0).block_number == 10 + assert Enum.at(fills, 1).block_number == 11 + end + end + + # -- Edge cases -- + + describe "edge cases" do + test "empty logs returns empty results" do + assert {:ok, {[], []}} = EventParser.parse_rollup_logs([]) + end + + test "logs with unrecognized topics are skipped" do + log = build_log(data: <<0::256>>, topics: ["0x" <> String.duplicate("ff", 32)]) + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + + test "max uint32 chainId (4294967295)" do + max_u32 = 0xFFFFFFFF + + data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, max_u32}]) + log = build_log(data: data, topics: [Abi.order_event_topic()], block: 1, index: 0) + + {:ok, {[order], []}} = EventParser.parse_rollup_logs([log]) + + [output] = order.outputs_json + assert output["chainId"] == max_u32 + end + + test "max uint256 amount" do + max_u256 = (1 <<< 256) - 1 + + data = encode_filled_data([{@weth, max_u256, @addr_1111, 1}]) + log = build_log(data: data, topics: [Abi.filled_event_topic()], block: 1, index: 0) + + {:ok, {[], [fill]}} = EventParser.parse_rollup_logs([log]) + + [output] = fill.outputs_json + assert output["amount"] == Integer.to_string(max_u256) + end + + test "handles atom-keyed logs" do + data = encode_order_data(500, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + + log = %{ + data: "0x" <> Base.encode16(data, case: :lower), + topics: [Abi.order_event_topic()], + transaction_hash: "0x" <> String.duplicate("dd", 32), + block_number: 77, + log_index: 2 + } + + {:ok, {[order], []}} = EventParser.parse_rollup_logs([log]) + + assert order.deadline == 500 + assert order.block_number == 77 + assert order.log_index == 2 + end + + test "handles integer block_number and log_index" do + data = encode_filled_data([{@usdc, 100, @addr_1111, 1}]) + + log = %{ + data: "0x" <> Base.encode16(data, case: :lower), + topics: [Abi.filled_event_topic()], + transaction_hash: "0x" <> String.duplicate("ee", 32), + block_number: 42, + log_index: 5 + } + + {:ok, {[], [fill]}} = EventParser.parse_rollup_logs([log]) + + assert fill.block_number == 42 + assert fill.log_index == 5 + end + end + + # -- Error path tests -- + + describe "error paths" do + test "invalid block_number causes event to be skipped" do + data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + + log = %{ + "data" => "0x" <> Base.encode16(data, case: :lower), + "topics" => [Abi.order_event_topic()], + "transactionHash" => "0x" <> String.duplicate("ab", 32), + "blockNumber" => nil, + "logIndex" => "0x0" + } + + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + + test "invalid log_index causes event to be skipped" do + data = encode_order_data(1000, [{@usdc, 100}], [{@usdc, 100, @addr_1111, 1}]) + + log = %{ + "data" => "0x" <> Base.encode16(data, case: :lower), + "topics" => [Abi.order_event_topic()], + "transactionHash" => "0x" <> String.duplicate("ab", 32), + "blockNumber" => "0x1", + "logIndex" => nil + } + + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + + test "malformed data causes event to be skipped" do + log = build_log(data: <<1, 2, 3>>, topics: [Abi.order_event_topic()], block: 1, index: 0) + + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + + test "empty data causes Filled event to be skipped" do + log = build_log(data: <<>>, topics: [Abi.filled_event_topic()], block: 1, index: 0) + + {:ok, {[], []}} = EventParser.parse_rollup_logs([log]) + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/signet/orders_fetcher_test.exs b/apps/indexer/test/indexer/fetcher/signet/orders_fetcher_test.exs new file mode 100644 index 000000000000..7e3f33f3936e --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/signet/orders_fetcher_test.exs @@ -0,0 +1,165 @@ +if Application.compile_env(:explorer, :chain_type) == :signet do + defmodule Indexer.Fetcher.Signet.OrdersFetcherTest do + @moduledoc """ + Integration tests for the Signet OrdersFetcher module. + + Tests verify the full pipeline from event fetching through + database insertion. + + Note: Orders and fills are indexed independently with no correlation. + Primary keys are: + - Orders: (transaction_hash, log_index) + - Fills: (chain_type, transaction_hash, log_index) + """ + + use Explorer.DataCase, async: false + + import Explorer.Factory + + alias Explorer.Chain + alias Explorer.Chain.Hash + alias Indexer.Fetcher.Signet.OrdersFetcher + + @moduletag :signet + + defp cast_hash!(bytes) do + {:ok, hash} = Hash.Full.cast(bytes) + hash + end + + describe "OrdersFetcher configuration" do + test "child_spec returns proper supervisor config" do + json_rpc_named_arguments = [ + transport: EthereumJSONRPC.Mox, + transport_options: [] + ] + + Application.put_env(:indexer, OrdersFetcher, + enabled: true, + rollup_orders_address: "0x1234567890123456789012345678901234567890", + recheck_interval: 1000 + ) + + child_spec = + OrdersFetcher.child_spec([ + [json_rpc_named_arguments: json_rpc_named_arguments], + [name: OrdersFetcher] + ]) + + assert child_spec.id == OrdersFetcher + assert child_spec.restart == :transient + end + end + + describe "database import via Chain.import/1" do + test "imports order through Chain.import" do + tx_hash = cast_hash!(<<1::256>>) + + order_params = %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: tx_hash, + log_index: 0, + inputs_json: [%{"token" => "0x1234", "amount" => "1000"}], + outputs_json: [%{"token" => "0x5678", "recipient" => "0x9abc", "amount" => "500", "chainId" => "1"}] + } + + assert {:ok, %{insert_signet_orders: [order]}} = + Chain.import(%{ + signet_orders: %{params: [order_params]}, + timeout: :infinity + }) + + assert order.block_number == 100 + assert order.deadline == 1_700_000_000 + end + + test "imports fill through Chain.import" do + tx_hash = cast_hash!(<<2::256>>) + + fill_params = %{ + chain_type: :rollup, + block_number: 150, + transaction_hash: tx_hash, + log_index: 1, + outputs_json: [%{"token" => "0xaaaa", "recipient" => "0xbbbb", "amount" => "1000", "chainId" => "1"}] + } + + assert {:ok, %{insert_signet_fills: [fill]}} = + Chain.import(%{ + signet_fills: %{params: [fill_params]}, + timeout: :infinity + }) + + assert fill.block_number == 150 + assert fill.chain_type == :rollup + end + + test "imports order and fill together" do + order_params = %{ + deadline: 1_700_000_000, + block_number: 100, + transaction_hash: cast_hash!(<<10::256>>), + log_index: 0, + inputs_json: [%{"token" => "0x1111", "amount" => "1000"}], + outputs_json: [%{"token" => "0x2222", "recipient" => "0x3333", "amount" => "500", "chainId" => "1"}] + } + + fill_params = %{ + chain_type: :host, + block_number: 200, + transaction_hash: cast_hash!(<<20::256>>), + log_index: 0, + outputs_json: [%{"token" => "0x2222", "recipient" => "0x3333", "amount" => "500", "chainId" => "1"}] + } + + assert {:ok, result} = + Chain.import(%{ + signet_orders: %{params: [order_params]}, + signet_fills: %{params: [fill_params]}, + timeout: :infinity + }) + + assert length(result.insert_signet_orders) == 1 + assert length(result.insert_signet_fills) == 1 + end + end + + describe "factory integration" do + test "signet_order factory creates valid order" do + order = insert(:signet_order) + + assert order.transaction_hash != nil + assert order.log_index != nil + assert order.deadline != nil + assert order.block_number != nil + assert order.inputs_json != nil + assert order.outputs_json != nil + end + + test "signet_fill factory creates valid fill" do + fill = insert(:signet_fill) + + assert fill.transaction_hash != nil + assert fill.log_index != nil + assert fill.chain_type in [:rollup, :host] + assert fill.block_number != nil + assert fill.outputs_json != nil + end + + test "factory orders can be customized" do + order = insert(:signet_order, deadline: 9_999_999_999, block_number: 42) + + assert order.deadline == 9_999_999_999 + assert order.block_number == 42 + end + + test "factory fills can be customized" do + fill = insert(:signet_fill, chain_type: :host, block_number: 123) + + assert fill.chain_type == :host + assert fill.block_number == 123 + end + end + end +end diff --git a/config/config_helper.exs b/config/config_helper.exs index 2d1fbd9c3238..126e0d1f7f1d 100644 --- a/config/config_helper.exs +++ b/config/config_helper.exs @@ -24,6 +24,7 @@ defmodule ConfigHelper do {:zilliqa, nil} => [Explorer.Repo.Zilliqa], {:zksync, nil} => [Explorer.Repo.ZkSync], {:neon, nil} => [Explorer.Repo.Neon], + {:signet, nil} => [Explorer.Repo.Signet], {:optimism, :celo} => [ Explorer.Repo.Optimism, Explorer.Repo.Celo @@ -424,7 +425,8 @@ defmodule ConfigHelper do "zilliqa" => :zilliqa, "zksync" => :zksync, "neon" => :neon, - "optimism-celo" => {:optimism, :celo} + "optimism-celo" => {:optimism, :celo}, + "signet" => :signet } @doc """ diff --git a/config/runtime.exs b/config/runtime.exs index 92f4a3df9268..62b878e9ffd1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1482,6 +1482,21 @@ config :indexer, Indexer.Fetcher.Arbitrum.DataBackfill.Supervisor, ConfigHelper.chain_type() != :arbitrum || not ConfigHelper.parse_bool_env_var("INDEXER_ARBITRUM_DATA_BACKFILL_ENABLED") +# Signet Orders Indexer configuration +config :indexer, Indexer.Fetcher.Signet.OrdersFetcher, + enabled: ConfigHelper.parse_bool_env_var("INDEXER_SIGNET_ORDERS_ENABLED", "false"), + rollup_orders_address: System.get_env("INDEXER_SIGNET_ROLLUP_ORDERS_ADDRESS"), + host_orders_address: System.get_env("INDEXER_SIGNET_HOST_ORDERS_ADDRESS"), + l1_rpc: System.get_env("INDEXER_SIGNET_L1_RPC"), + l1_rpc_block_range: ConfigHelper.parse_integer_env_var("INDEXER_SIGNET_L1_RPC_BLOCK_RANGE", 1_000), + l2_rpc_block_range: ConfigHelper.parse_integer_env_var("INDEXER_SIGNET_L2_RPC_BLOCK_RANGE", 1_000), + recheck_interval: ConfigHelper.parse_time_env_var("INDEXER_SIGNET_RECHECK_INTERVAL", "15s"), + start_block: ConfigHelper.parse_integer_env_var("INDEXER_SIGNET_START_BLOCK", 0), + failure_interval_threshold: ConfigHelper.parse_time_env_var("INDEXER_SIGNET_FAILURE_THRESHOLD", "10m") + +config :indexer, Indexer.Fetcher.Signet.OrdersFetcher.Supervisor, + enabled: ConfigHelper.parse_bool_env_var("INDEXER_SIGNET_ORDERS_ENABLED", "false") + config :indexer, Indexer.Fetcher.RootstockData.Supervisor, disabled?: ConfigHelper.chain_type() != :rsk || ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_ROOTSTOCK_DATA_FETCHER") diff --git a/config/runtime/dev.exs b/config/runtime/dev.exs index f40189052ac2..5b8ff87e0d41 100644 --- a/config/runtime/dev.exs +++ b/config/runtime/dev.exs @@ -142,7 +142,8 @@ for repo <- [ # Feature dependent repos Explorer.Repo.BridgedTokens, Explorer.Repo.ShrunkInternalTransactions, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do config :explorer, repo, database: database, diff --git a/config/runtime/prod.exs b/config/runtime/prod.exs index d00c8187744e..e7c303a57fe6 100644 --- a/config/runtime/prod.exs +++ b/config/runtime/prod.exs @@ -104,7 +104,8 @@ for repo <- [ Explorer.Repo.Stability, Explorer.Repo.Zilliqa, Explorer.Repo.ZkSync, - Explorer.Repo.Neon + Explorer.Repo.Neon, + Explorer.Repo.Signet ] do config :explorer, repo, url: ConfigHelper.parse_url_env_var("DATABASE_URL"),