feat: Arbitrum L1-to-L2 messages with hashed message id (#10751)

* initial implementation

* Finalized approach to handle messages with hashed id

* Documentation updated

* code review comments addressed

* Clarify plain message ID check documentation
kf/feat/rework-indices
Alexander Kolotov 4 weeks ago committed by GitHub
parent afdcc0a27e
commit c876863008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex
  2. 32
      apps/explorer/lib/explorer/chain/arbitrum/reader.ex
  3. 15
      apps/indexer/lib/indexer/block/fetcher.ex
  4. 1043
      apps/indexer/lib/indexer/buffered_task.ex
  5. 347
      apps/indexer/lib/indexer/fetcher/arbitrum/messages_to_l2_matcher.ex
  6. 139
      apps/indexer/lib/indexer/fetcher/arbitrum/messaging.ex
  7. 29
      apps/indexer/lib/indexer/fetcher/arbitrum/rollup_messages_catchup.ex
  8. 11
      apps/indexer/lib/indexer/fetcher/arbitrum/utils/db.ex
  9. 55
      apps/indexer/lib/indexer/fetcher/arbitrum/utils/helper.ex
  10. 122
      apps/indexer/lib/indexer/fetcher/arbitrum/workers/historical_messages_on_l2.ex
  11. 2
      apps/indexer/lib/indexer/supervisor.ex
  12. 17
      apps/indexer/lib/indexer/transform/arbitrum/messaging.ex
  13. 3
      config/runtime.exs

@ -66,7 +66,7 @@ defmodule EthereumJSONRPC.Transaction do
:arbitrum ->
@chain_type_fields quote(
do: [
request_id: non_neg_integer()
request_id: EthereumJSONRPC.hash()
]
)
@ -662,7 +662,7 @@ defmodule EthereumJSONRPC.Transaction do
#
# "txType": to avoid FunctionClauseError when indexing Wanchain
defp entry_to_elixir({key, value})
when key in ~w(blockHash condition creates from hash input jsonrpc publicKey raw to txType executionNode requestRecord blobVersionedHashes),
when key in ~w(blockHash condition creates from hash input jsonrpc publicKey raw to txType executionNode requestRecord blobVersionedHashes requestId),
do: {key, value}
# specific to Nethermind client
@ -670,7 +670,7 @@ defmodule EthereumJSONRPC.Transaction do
do: {"input", value}
defp entry_to_elixir({key, quantity})
when key in ~w(gas gasPrice nonce r s standardV v value type maxPriorityFeePerGas maxFeePerGas maxFeePerBlobGas requestId) and
when key in ~w(gas gasPrice nonce r s standardV v value type maxPriorityFeePerGas maxFeePerGas maxFeePerBlobGas) and
quantity != nil do
{key, quantity_to_integer(quantity)}
end

@ -24,8 +24,10 @@ defmodule Explorer.Chain.Arbitrum.Reader do
# https://github.com/OffchainLabs/go-ethereum/blob/dff302de66598c36b964b971f72d35a95148e650/core/types/transaction.go#L44C2-L50
@message_to_l2_eth_deposit 100
@message_to_l2_submit_retryable_tx 105
@zero_wei 0
@to_l2_messages_transaction_types [
@message_to_l2_eth_deposit,
@message_to_l2_submit_retryable_tx
]
@doc """
Retrieves the number of the latest L1 block where an L1-to-L2 message was discovered.
@ -838,10 +840,6 @@ defmodule Explorer.Chain.Arbitrum.Reader do
# table. A message is considered missed if there is a transaction without a
# matching message record.
#
# For transactions that could be considered ETH deposits, it checks
# that the message value is not zero, as transactions with a zero value
# cannot be a deposit.
#
# ## Returns
# - A query to retrieve missed L1-to-L2 messages.
@spec missed_messages_to_l2_query() :: Ecto.Query.t()
@ -849,10 +847,7 @@ defmodule Explorer.Chain.Arbitrum.Reader do
from(rollup_tx in Transaction,
left_join: msg in Message,
on: rollup_tx.hash == msg.completion_transaction_hash and msg.direction == :to_l2,
where:
(rollup_tx.type == ^@message_to_l2_submit_retryable_tx or
(rollup_tx.type == ^@message_to_l2_eth_deposit and rollup_tx.value != ^@zero_wei)) and
is_nil(msg.completion_transaction_hash)
where: rollup_tx.type in @to_l2_messages_transaction_types and is_nil(msg.completion_transaction_hash)
)
end
@ -1337,4 +1332,21 @@ defmodule Explorer.Chain.Arbitrum.Reader do
|> Chain.join_associations(%{:transactions => :optional})
|> Repo.all()
end
@doc """
Retrieves the message IDs of uncompleted L1-to-L2 messages.
## Returns
- A list of the message IDs of uncompleted L1-to-L2 messages.
"""
@spec get_uncompleted_l1_to_l2_messages_ids() :: [non_neg_integer()]
def get_uncompleted_l1_to_l2_messages_ids do
query =
from(msg in Message,
where: msg.direction == :to_l2 and is_nil(msg.completion_transaction_hash),
select: msg.message_id
)
Repo.all(query)
end
end

@ -17,6 +17,7 @@ defmodule Indexer.Block.Fetcher do
alias Explorer.Chain.Filecoin.PendingAddressOperation, as: FilecoinPendingAddressOperation
alias Explorer.Chain.{Address, Block, Hash, Import, Transaction, Wei}
alias Indexer.Block.Fetcher.Receipts
alias Indexer.Fetcher.Arbitrum.MessagesToL2Matcher, as: ArbitrumMessagesToL2Matcher
alias Indexer.Fetcher.Celo.EpochBlockOperations, as: CeloEpochBlockOperations
alias Indexer.Fetcher.Celo.EpochLogs, as: CeloEpochLogs
alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup
@ -186,7 +187,8 @@ defmodule Indexer.Block.Fetcher do
do: PolygonZkevmBridge.parse(blocks, logs),
else: []
),
arbitrum_xlevel_messages = ArbitrumMessaging.parse(transactions_with_receipts, logs),
{arbitrum_xlevel_messages, arbitrum_txs_for_further_handling} =
ArbitrumMessaging.parse(transactions_with_receipts, logs),
%FetchedBeneficiaries{params_set: beneficiary_params_set, errors: beneficiaries_errors} =
fetch_beneficiaries(blocks, transactions_with_receipts, json_rpc_named_arguments),
addresses =
@ -265,6 +267,9 @@ defmodule Indexer.Block.Fetcher do
update_addresses_cache(inserted[:addresses])
update_uncles_cache(inserted[:block_second_degree_relations])
update_withdrawals_cache(inserted[:withdrawals])
async_match_arbitrum_messages_to_l2(arbitrum_txs_for_further_handling)
result
else
{step, {:error, reason}} -> {:error, {step, reason}}
@ -739,4 +744,12 @@ defmodule Indexer.Block.Fetcher do
Map.put(token_transfer, :token, token)
end)
end
# Asynchronously schedules matching of Arbitrum L1-to-L2 messages where the message ID is hashed.
@spec async_match_arbitrum_messages_to_l2([map()]) :: :ok
defp async_match_arbitrum_messages_to_l2([]), do: :ok
defp async_match_arbitrum_messages_to_l2(txs_with_messages_from_l1) do
ArbitrumMessagesToL2Matcher.async_discover_match(txs_with_messages_from_l1)
end
end

File diff suppressed because it is too large Load Diff

@ -0,0 +1,347 @@
defmodule Indexer.Fetcher.Arbitrum.MessagesToL2Matcher do
@moduledoc """
Matches and processes L1-to-L2 messages in the Arbitrum protocol.
This module implements a buffered task system to handle the matching of
L1-to-L2 messages with hashed message IDs. It periodically attempts to match
unmatched messages, imports matched messages to the database, and reschedules
unmatched messages for future processing.
The matcher operates asynchronously, allowing for efficient handling of
messages even when corresponding L1 transactions are not yet indexed. This
approach prevents blocking the discovery process and ensures eventual
consistency in message matching.
Key features:
- Implements the `BufferedTask` behavior for efficient batch processing.
- Maintains a cache of uncompleted message IDs to optimize matching.
- Provides functionality to asynchronously schedule message matching.
- Automatically retries unmatched messages based on a configurable interval.
"""
use Indexer.Fetcher, restart: :permanent
use Spandex.Decorators
import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1]
require Logger
alias Indexer.BufferedTask
alias Indexer.Fetcher.Arbitrum.MessagesToL2Matcher.Supervisor, as: MessagesToL2MatcherSupervisor
alias Indexer.Fetcher.Arbitrum.Messaging, as: MessagingUtils
alias Indexer.Fetcher.Arbitrum.Utils.Db
alias Indexer.Fetcher.Arbitrum.Utils.Helper, as: ArbitrumHelper
@behaviour BufferedTask
# Since the cache for DB responses is used, it is efficient to get rid of concurrent handling of the tasks.
@default_max_batch_size 10
@default_max_concurrency 1
@flush_interval :timer.seconds(1)
@typep min_transaction :: %{
:hash => binary(),
:type => non_neg_integer(),
optional(:request_id) => non_neg_integer(),
optional(any()) => any()
}
@doc """
Defines the child specification for the MessagesToL2Matcher.
This function creates a child specification for use in a supervision tree,
configuring a `BufferedTask` process for the MessagesToL2Matcher. It sets up
the initial state and options for the task, including the recheck interval
for matching L1-to-L2 messages.
Using the same value for discovering new L1 messages interval and for the
unmatched L2 messages recheck interval ensures that message matching attempts
are synchronized with the rate of new L1 message discovery, optimizing the
process by avoiding unnecessary rechecks when no new L1 messages have been
added to the database.
## Parameters
- `init_options`: A keyword list of initial options for the BufferedTask.
- `gen_server_options`: A keyword list of options for the underlying GenServer.
## Returns
A child specification map suitable for use in a supervision tree, with the
following key properties:
- Uses `BufferedTask` as the module to start.
- Configures the MessagesToL2Matcher as the callback module for the BufferedTask.
- Sets the initial state with an empty cache of IDs of uncompleted messages and
the recheck interval from the Arbitrum.TrackingMessagesOnL1 configuration.
- Merges provided options with default options for the BufferedTask.
- Uses this module's name as the child's id in the supervision tree.
"""
def child_spec([init_options, gen_server_options]) do
messages_on_l1_interval =
Application.get_all_env(:indexer)[Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1][:recheck_interval]
buffered_task_init_options =
defaults()
|> Keyword.merge(init_options)
|> Keyword.merge(
state: %{
uncompleted_messages: %{},
recheck_interval: messages_on_l1_interval
}
)
Supervisor.child_spec({BufferedTask, [{__MODULE__, buffered_task_init_options}, gen_server_options]},
id: __MODULE__
)
end
@impl BufferedTask
def init(initial, _, _) do
initial
end
@doc """
Processes a batch of transactions with hashed message IDs for L1-to-L2 messages.
This function, implementing the `BufferedTask` behavior, handles a list of
transactions with associated timeouts. It attempts to match hashed request IDs
with uncompleted L1-to-L2 messages, updates the transactions accordingly, and
imports any successfully matched messages to the database.
The function performs the following steps:
1. Separates transactions with expired timeouts from those still delayed.
2. Attempts to update expired transactions by matching their hashed request IDs.
3. Processes updated transactions to filter and import L1-to-L2 messages.
4. Reschedules unmatched or delayed transactions for future processing.
For unmatched transactions, new timeouts are set to the current time increased
by the value of the recheck interval.
## Parameters
- `txs_with_timeouts`: A list of tuples, each containing a timeout and a
transaction with a potentially hashed request ID.
- `state`: The current state of the task, including cached IDs of uncompleted
messages and the recheck interval.
## Returns
- `{:ok, updated_state}` if all transactions were processed successfully and
no retries are needed.
- `{:retry, txs_to_retry, updated_state}` if some transactions need to be
retried, either due to unmatched request IDs or unexpired timeouts.
The returned state always includes an updated cache of IDs of uncompleted
messages.
"""
@impl BufferedTask
@spec run([{non_neg_integer(), min_transaction()}], %{
:recheck_interval => non_neg_integer(),
:uncompleted_messages => %{binary() => binary()},
optional(any()) => any()
}) ::
{:ok, %{:uncompleted_messages => %{binary() => binary()}, optional(any()) => any()}}
| {:retry, [{non_neg_integer(), min_transaction()}],
%{:uncompleted_messages => %{binary() => binary()}, optional(any()) => any()}}
def run(txs_with_timeouts, %{uncompleted_messages: cached_uncompleted_messages_ids, recheck_interval: _} = state)
when is_list(txs_with_timeouts) do
# For next handling only the transactions with expired timeouts are needed.
now = DateTime.to_unix(DateTime.utc_now(), :millisecond)
{txs, delayed_txs} =
txs_with_timeouts
|> Enum.reduce({[], []}, fn {timeout, tx}, {txs, delayed_txs} ->
if timeout > now do
{txs, [{timeout, tx} | delayed_txs]}
else
{[tx | txs], delayed_txs}
end
end)
# Check if the request Id of transactions with expired timeouts matches hashed
# ids of the uncompleted messages and update the transactions with the decoded
# request ids. If it required, the cache is updated.
# Possible outcomes:
# - no transactions were updated, because the txs list is empty, the cache is updated
# - no transactions were updated, because no matches in both cache and DB were found, the cache is updated
# - all matches were found in the cache, the cache is not updated
# - all matches were found in the DB, the cache is updated
# - some matches were found in the cache, but not all, the cache is not updated
{updated?, handled_txs, updated_cache} = update_txs_with_hashed_ids(txs, cached_uncompleted_messages_ids)
updated_state = %{state | uncompleted_messages: updated_cache}
case {updated?, txs == []} do
{false, true} ->
# There were no transactions with expired timeouts, so counters of the transactions
# updated and the transactions are scheduled for retry.
{:retry, delayed_txs, updated_state}
{false, false} ->
# Some of the transactions were with expired timeouts, but no matches were found
# for these transaction in the cache or the DB. Timeouts for such transactions
# are re-initialized and they are added to the list with transactions with
# updated counters.
txs_to_retry =
delayed_txs ++ initialize_timeouts(handled_txs, now + state.recheck_interval)
{:retry, txs_to_retry, updated_state}
{true, _} ->
{messages, txs_to_retry_wo_timeouts} = MessagingUtils.filter_l1_to_l2_messages(handled_txs)
MessagingUtils.import_to_db(messages)
if txs_to_retry_wo_timeouts == [] and delayed_txs == [] do
{:ok, updated_state}
else
# Either some of the transactions with expired timeouts don't have a matching
# request id in the cache or the DB, or there are transactions with non-expired
# timeouts. All these transactions are needed to be scheduled for retry.
txs_to_retry =
delayed_txs ++ initialize_timeouts(txs_to_retry_wo_timeouts, now + state.recheck_interval)
{:retry, txs_to_retry, updated_state}
end
end
end
@doc """
Asynchronously schedules the discovery of matches for L1-to-L2 messages.
This function schedules the processing of transactions with hashed message IDs that
require further matching.
## Parameters
- `txs_with_messages_from_l1`: A list of transactions containing L1-to-L2
messages with hashed message IDs.
## Returns
- `:ok`
"""
@spec async_discover_match([min_transaction()]) :: :ok
def async_discover_match(txs_with_messages_from_l1) do
# Do nothing in case if the indexing chain is not Arbitrum or the feature is disabled.
if MessagesToL2MatcherSupervisor.disabled?() do
:ok
else
BufferedTask.buffer(__MODULE__, Enum.map(txs_with_messages_from_l1, &{0, &1}), false)
end
end
# Retrieves and transforms uncompleted L1-to-L2 message IDs into a map of hashed IDs.
#
# This function fetches the IDs of uncompleted L1-to-L2 messages and creates a map
# where each key is the hashed hexadecimal string representation of a message ID,
# and the corresponding value is the original ID converted to a hexadecimal string.
#
# ## Returns
# A map where:
# - Keys are hashed message IDs as hexadecimal strings.
# - Values are original message IDs as 256-bit hexadecimal strings.
@spec get_hashed_ids_for_uncompleted_messages() :: %{binary() => binary()}
defp get_hashed_ids_for_uncompleted_messages do
Db.get_uncompleted_l1_to_l2_messages_ids()
|> Enum.reduce(%{}, fn id, acc ->
Map.put(
acc,
ArbitrumHelper.get_hashed_message_id_as_hex_str(id),
ArbitrumHelper.bytes_to_hex_str(<<id::size(256)>>)
)
end)
end
# Updates transactions with hashed request IDs, using cached or fresh data.
#
# This function attempts to replace hashed request IDs in transactions with their
# original IDs. It first tries using a cached set of uncompleted message IDs. If
# no matches are found in the cache, it fetches fresh data from the database.
#
# ## Parameters
# - `txs`: A list of transactions with potentially hashed request IDs.
# - `cached_uncompleted_messages_ids`: A map of cached hashed message IDs to their
# original forms.
#
# ## Returns
# A tuple containing:
# - A boolean indicating whether any transactions were updated.
# - An updated list of transactions, with some request IDs potentially replaced.
# - The map of uncompleted message IDs used for the update (either the cache or
# freshly fetched data).
#
# ## Notes
# - If the cache is used successfully, it's returned as-is, even if potentially
# outdated.
# - If the cache fails, fresh data is fetched and returned, updating the cache.
@spec update_txs_with_hashed_ids([min_transaction()], %{binary() => binary()}) ::
{boolean(), [min_transaction()], %{binary() => binary()}}
defp update_txs_with_hashed_ids([], cache), do: {false, [], cache}
defp update_txs_with_hashed_ids(txs, cached_uncompleted_messages_ids) do
# Try to use the cached DB response first. That makes sense if historical
# messages are being processed (by catchup block fetcher or by the missing
# messages handler). Since amount of txs provided to this function is limited
# it OK to inspect the cache before making a DB request.
case revise_txs_with_hashed_ids(txs, cached_uncompleted_messages_ids, true) do
{_, false} ->
# If no matches were found in the cache, try to fetch uncompleted messages from the DB.
uncompleted_messages = get_hashed_ids_for_uncompleted_messages()
{updated_txs, updated?} = revise_txs_with_hashed_ids(txs, uncompleted_messages, false)
{updated?, updated_txs, uncompleted_messages}
{updated_txs, _} ->
# There could be a case when some hashed ids were not found since the cache is outdated
# such txs will be scheduled for retry and the cache will be updated then.
{true, updated_txs, cached_uncompleted_messages_ids}
end
end
# Attempts to replace hashed request IDs in transactions with their original IDs.
#
# This function iterates through a list of transactions, trying to match their
# hashed request IDs with entries in the provided map of uncompleted messages.
# If a match is found, the transaction's request ID is updated to its original
# (non-hashed) form.
#
# ## Parameters
# - `txs`: A list of transactions with potentially hashed request IDs.
# - `uncompleted_messages`: A map of hashed message IDs to their original forms.
# - `report?`: A boolean flag indicating whether to log decoding attempts.
#
# ## Returns
# A tuple containing:
# - An updated list of transactions, with some request IDs potentially replaced.
# - A boolean indicating whether any transactions were updated.
@spec revise_txs_with_hashed_ids([min_transaction()], %{binary() => binary()}, boolean()) ::
{[min_transaction()], boolean()}
defp revise_txs_with_hashed_ids(txs, uncompleted_messages, report?) do
txs
|> Enum.reduce({[], false}, fn tx, {updated_txs, updated?} ->
if report?, do: log_info("Attempting to decode the request id #{tx.request_id} in the tx #{tx.hash}")
case Map.get(uncompleted_messages, tx.request_id) do
nil ->
{[tx | updated_txs], updated?}
id ->
{[%{tx | request_id: id} | updated_txs], true}
end
end)
end
# Assigns a uniform timeout to each transaction in the given list.
@spec initialize_timeouts([min_transaction()], non_neg_integer()) :: [{non_neg_integer(), min_transaction()}]
defp initialize_timeouts(txs_to_retry, timeout) do
txs_to_retry
|> Enum.map(&{timeout, &1})
end
defp defaults do
[
flush_interval: @flush_interval,
max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency,
max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size,
poll: false,
task_supervisor: __MODULE__.TaskSupervisor,
metadata: [fetcher: :messages_to_l2_matcher]
]
end
end

@ -14,10 +14,14 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1, log_debug: 1]
alias Explorer.Chain
alias Explorer.Chain.Arbitrum.Message
alias Indexer.Fetcher.Arbitrum.Utils.Db
require Logger
@zero_hex_prefix "0x" <> String.duplicate("0", 56)
@l2_to_l1_event_unindexed_params [
:address,
{:uint, 256},
@ -27,17 +31,6 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
:bytes
]
@type arbitrum_message :: %{
direction: :to_l2 | :from_l2,
message_id: non_neg_integer(),
originator_address: binary(),
originating_transaction_hash: binary(),
origination_timestamp: DateTime.t(),
originating_transaction_block_number: non_neg_integer(),
completion_transaction_hash: binary(),
status: :initiated | :sent | :confirmed | :relayed
}
@typep min_transaction :: %{
:hash => binary(),
:type => non_neg_integer(),
@ -60,40 +53,57 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
}
@doc """
Filters a list of rollup transactions to identify L1-to-L2 messages and composes a map for each with the related message information.
Filters rollup transactions to identify L1-to-L2 messages and categorizes them.
This function filters a list of rollup transactions, selecting those where
`request_id` is not nil and is below 2^31, indicating they are L1-to-L2
message completions. These filtered transactions are then processed to
construct a detailed message structure for each.
This function processes a list of rollup transactions, identifying those with
non-nil `request_id` fields. It then separates these into two categories:
messages with plain message IDs and transactions with hashed message IDs.
## Parameters
- `transactions`: A list of rollup transaction entries.
- `report`: An optional boolean flag (default `true`) that, when `true`, logs
the number of processed L1-to-L2 messages if any are found.
the number of identified L1-to-L2 messages and transactions requiring
further processing.
## Returns
- A list of L1-to-L2 messages with detailed information and current status. Every
map in the list compatible with the database import operation. All messages in
this context are considered `:relayed` as they represent completed actions from
L1 to L2.
A tuple containing:
- A list of L1-to-L2 messages with detailed information, ready for database
import. All messages in this context are considered `:relayed` as they
represent completed actions from L1 to L2.
- A list of transactions with hashed message IDs that require further
processing for message ID matching.
"""
@spec filter_l1_to_l2_messages([min_transaction()]) :: [arbitrum_message]
@spec filter_l1_to_l2_messages([min_transaction()], boolean()) :: [arbitrum_message]
@spec filter_l1_to_l2_messages([min_transaction()]) :: {[Message.to_import()], [min_transaction()]}
@spec filter_l1_to_l2_messages([min_transaction()], boolean()) :: {[Message.to_import()], [min_transaction()]}
def filter_l1_to_l2_messages(transactions, report \\ true)
when is_list(transactions) and is_boolean(report) do
messages =
{transactions_with_proper_message_id, transactions_with_hashed_message_id} =
transactions
|> Enum.filter(fn tx ->
tx[:request_id] != nil and Bitwise.bsr(tx[:request_id], 31) == 0
tx[:request_id] != nil
end)
|> Enum.split_with(fn tx ->
plain_message_id?(tx[:request_id])
end)
# Transform transactions with the plain message ID into messages
messages =
transactions_with_proper_message_id
|> handle_filtered_l1_to_l2_messages()
if report && not (messages == []) do
log_info("#{length(messages)} completions of L1-to-L2 messages will be imported")
if report do
if not (messages == []) do
log_info("#{length(messages)} completions of L1-to-L2 messages will be imported")
end
if not (transactions_with_hashed_message_id == []) do
log_info(
"#{length(transactions_with_hashed_message_id)} completions of L1-to-L2 messages require message ID matching discovery"
)
end
end
messages
{messages, transactions_with_hashed_message_id}
end
@doc """
@ -110,7 +120,7 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
- A list of L2-to-L1 messages with detailed information and current status. Each map
in the list is compatible with the database import operation.
"""
@spec filter_l2_to_l1_messages(maybe_improper_list(min_log, [])) :: [arbitrum_message]
@spec filter_l2_to_l1_messages(maybe_improper_list(min_log, [])) :: [Message.to_import()]
def filter_l2_to_l1_messages(logs) when is_list(logs) do
arbsys_contract = Application.get_env(:indexer, __MODULE__)[:arbsys_contract]
@ -135,7 +145,7 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
in the list compatible with the database import operation. All messages in this context
are considered `:relayed` as they represent completed actions from L1 to L2.
"""
@spec handle_filtered_l1_to_l2_messages(maybe_improper_list(min_transaction, [])) :: [arbitrum_message]
@spec handle_filtered_l1_to_l2_messages(maybe_improper_list(min_transaction, [])) :: [Message.to_import()]
def handle_filtered_l1_to_l2_messages([]) do
[]
end
@ -145,7 +155,12 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
|> Enum.map(fn tx ->
log_debug("L1 to L2 message #{tx.hash} found with the type #{tx.type}")
%{direction: :to_l2, message_id: tx.request_id, completion_transaction_hash: tx.hash, status: :relayed}
%{
direction: :to_l2,
message_id: quantity_to_integer(tx.request_id),
completion_transaction_hash: tx.hash,
status: :relayed
}
|> complete_to_params()
end)
end
@ -168,8 +183,8 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
- A list of L2-to-L1 messages with detailed information and current status, ready for
database import.
"""
@spec handle_filtered_l2_to_l1_messages([min_log]) :: [arbitrum_message]
@spec handle_filtered_l2_to_l1_messages([min_log], module()) :: [arbitrum_message]
@spec handle_filtered_l2_to_l1_messages([min_log]) :: [Message.to_import()]
@spec handle_filtered_l2_to_l1_messages([min_log], module()) :: [Message.to_import()]
def handle_filtered_l2_to_l1_messages(filtered_logs, caller \\ nil)
def handle_filtered_l2_to_l1_messages([], _) do
@ -211,21 +226,40 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
# The check if messages are executed is required only for the case when l2-to-l1
# messages are found by block catchup fetcher
updated_messages_map =
case caller do
nil ->
messages_map
_ ->
messages_map
|> find_and_update_executed_messages()
end
updated_messages_map
caller
|> case do
nil ->
messages_map
_ ->
messages_map
|> find_and_update_executed_messages()
end
|> Map.values()
end
@doc """
Imports a list of messages into the database.
## Parameters
- `messages`: A list of messages to import into the database.
## Returns
N/A
"""
@spec import_to_db([Message.to_import()]) :: :ok
def import_to_db(messages) do
{:ok, _} =
Chain.import(%{
arbitrum_messages: %{params: messages},
timeout: :infinity
})
:ok
end
# Converts an incomplete message structure into a complete parameters map for database updates.
@spec complete_to_params(map()) :: Message.to_import()
defp complete_to_params(incomplete) do
[
:direction,
@ -243,6 +277,7 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
end
# Parses an L2-to-L1 event, extracting relevant information from the event's data.
@spec l2_to_l1_event_parse(min_log()) :: {non_neg_integer(), binary(), non_neg_integer(), DateTime.t()}
defp l2_to_l1_event_parse(event) do
[
caller,
@ -260,6 +295,8 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
# Determines the status of an L2-to-L1 message based on its block number and the highest
# committed and confirmed block numbers.
@spec status_l2_to_l1_message(non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
:confirmed | :sent | :initiated
defp status_l2_to_l1_message(msg_block, highest_committed_block, highest_confirmed_block) do
cond do
highest_confirmed_block >= msg_block -> :confirmed
@ -278,6 +315,9 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
# ## Returns
# - The updated map of messages with the `completion_transaction_hash` and `status` fields updated
# for messages that have been executed.
@spec find_and_update_executed_messages(%{non_neg_integer() => Message.to_import()}) :: %{
non_neg_integer() => Message.to_import()
}
defp find_and_update_executed_messages(messages) do
messages
|> Map.keys()
@ -292,4 +332,15 @@ defmodule Indexer.Fetcher.Arbitrum.Messaging do
Map.put(messages_acc, execution.message_id, message)
end)
end
# Checks if the given request ID is a plain message ID (starts with 56 zero
# characters that correspond to 28 zero bytes).
@spec plain_message_id?(non_neg_integer()) :: boolean()
defp plain_message_id?(request_id) when byte_size(request_id) == 66 do
String.starts_with?(request_id, @zero_hex_prefix)
end
defp plain_message_id?(_) do
false
end
end

@ -41,14 +41,17 @@ defmodule Indexer.Fetcher.Arbitrum.RollupMessagesCatchup do
responsible for L1-to-L2 messages and then re-requests these transactions
through RPC. Results are utilized to construct messages. These messages are
marked as `:relayed`, indicating that they have been successfully received on
L2 and are considered completed, and are then imported into the database. This
approach is adopted because it parallels the action of re-indexing existing
transactions to include Arbitrum-specific fields, which are absent in the
currently indexed transactions. However, permanently adding these fields to the
database model for the sake of historical message catch-up is impractical.
Therefore, to avoid the extensive process of re-indexing and to minimize changes
to the database schema, fetching the required data directly from an external
node via RPC is preferred for historical message discovery.
L2 and are considered completed, and are then imported into the database. If
it is determined that a message cannot be constructed because of a hashed
message ID, the transaction is scheduled for further asynchronous processing to
match it with the corresponding L1 transaction. This approach is adopted
because it parallels the action of re-indexing existing transactions to include
Arbitrum-specific fields, which are absent in the currently indexed
transactions. However, permanently adding these fields to the database model
for the sake of historical message catch-up is impractical. Therefore, to avoid
the extensive process of re-indexing and to minimize changes to the database
schema, fetching the required data directly from an external node via RPC is
preferred for historical message discovery.
"""
use GenServer
@ -268,8 +271,14 @@ defmodule Indexer.Fetcher.Arbitrum.RollupMessagesCatchup do
# `requestId` for every transaction. This RPC request is necessary because the
# `requestId` field is not present in the transaction model of already indexed
# transactions in the database. Results are used to construct messages, which are
# subsequently stored in the database. These imported messages are marked as
# `:relayed`, signifying that they represent completed actions from L1 to L2.
# subsequently stored in the database.
#
# Messages with plain (non-hashed) request IDs are imported into the database and
# marked as `:relayed`, representing completed actions from L1 to L2.
#
# For transactions where the `requestId` represents a hashed message ID, the
# function schedules asynchronous discovery to match them with corresponding L1
# transactions.
#
# After importing the messages, the function immediately switches to the process
# of choosing a delay prior to the next iteration of historical message discovery

@ -892,6 +892,17 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db do
Reader.get_da_info_by_batch_number(batch_number)
end
@doc """
Retrieves the list of uncompleted L2-to-L1 messages IDs.
## Returns
- A list of the IDs of uncompleted L2-to-L1 messages.
"""
@spec get_uncompleted_l1_to_l2_messages_ids() :: [non_neg_integer()]
def get_uncompleted_l1_to_l2_messages_ids do
Reader.get_uncompleted_l1_to_l2_messages_ids()
end
@spec lifecycle_transaction_to_map(Arbitrum.LifecycleTransaction.t()) :: Arbitrum.LifecycleTransaction.to_import()
defp lifecycle_transaction_to_map(tx) do
[:id, :hash, :block_number, :timestamp, :status]

@ -1,6 +1,7 @@
defmodule Indexer.Fetcher.Arbitrum.Utils.Helper do
alias Explorer.Chain.Arbitrum.LifecycleTransaction
import EthereumJSONRPC, only: [quantity_to_integer: 1]
import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1]
@moduledoc """
@ -205,4 +206,58 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Helper do
end)
|> Enum.reverse()
end
@doc """
Converts a message ID to its hashed hexadecimal string representation.
This function takes a message ID (either as an integer or a hexadecimal string),
concatenates it with 256 zero bits, computes a hash of the concatenation, and
then converts the resulting hash to a hexadecimal string with a "0x" prefix.
## Parameters
- `message_id`: The message ID to be hashed and converted. Can be either a
non-negative integer or a "0x"-prefixed hexadecimal string.
## Returns
- A string representing the hashed message ID in hexadecimal format, prefixed
with "0x".
## Examples
iex> get_hashed_message_id_as_hex_str(1490421)
"0x9d1614591a3e0ba8854206a716e49ffdffc679131820fa815b989fdef9e5554d"
iex> get_hashed_message_id_as_hex_str("0x000000000000000000000000000000000000000000000000000000000016bdf5")
"0x9d1614591a3e0ba8854206a716e49ffdffc679131820fa815b989fdef9e5554d"
"""
@spec get_hashed_message_id_as_hex_str(non_neg_integer() | binary()) :: String.t()
def get_hashed_message_id_as_hex_str(message_id) do
message_id
|> hash_for_message_id()
|> bytes_to_hex_str()
end
# Calculates the hash for a given message ID.
#
# This function computes a 256-bit Keccak hash of the message ID. For integer
# inputs, it concatenates the 256-bit message ID with 256 zero bits before
# hashing. For hexadecimal string inputs, it first converts the string to an
# integer.
#
# ## Parameters
# - `message_id`: Either a non-negative integer or a "0x"-prefixed hexadecimal
# string of 66 characters (including the "0x" prefix).
#
# ## Returns
# - A binary representing the 256-bit Keccak hash of the processed message ID.
@spec hash_for_message_id(non_neg_integer() | binary()) :: binary()
defp hash_for_message_id(message_id) when is_integer(message_id) do
# As per https://github.com/OffchainLabs/nitro/blob/849348e10cf1d9c023f4748dc1211bd363422485/arbos/parse_l2.go#L40
(<<message_id::size(256)>> <> <<0::size(256)>>)
|> ExKeccak.hash_256()
end
defp hash_for_message_id(message_id) when is_binary(message_id) and byte_size(message_id) == 66 do
hash_for_message_id(quantity_to_integer(message_id))
end
end

@ -18,8 +18,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
alias EthereumJSONRPC.Transaction, as: TransactionByRPC
alias Explorer.Chain
alias Indexer.Fetcher.Arbitrum.MessagesToL2Matcher, as: ArbitrumMessagesToL2Matcher
alias Indexer.Fetcher.Arbitrum.Messaging
alias Indexer.Fetcher.Arbitrum.Utils.{Db, Rpc}
@ -127,7 +126,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
logs
|> Messaging.handle_filtered_l2_to_l1_messages(__MODULE__)
import_to_db(messages)
Messaging.import_to_db(messages)
end
{:ok, start_block}
@ -143,9 +142,14 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
then their bodies are re-requested through RPC because already indexed
transactions from the database cannot be utilized; the `requestId` field is not
included in the transaction model. The function ensures that the block range
has been indexed before proceeding with message discovery and import. The
imported messages are marked as `:relayed`, as they represent completed actions
from L1 to L2.
has been indexed before proceeding with message discovery and import.
Messages with plain (non-hashed) request IDs are imported into the database and
marked as `:relayed`, representing completed actions from L1 to L2.
For transactions where the `requestId` represents a hashed message ID, the
function schedules asynchronous discovery to match them with corresponding L1
transactions.
## Parameters
- `end_block`: The ending block number for the discovery operation. If `nil` or
@ -213,25 +217,33 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
# Discovers and processes historical messages sent from L1 to L2 within a
# specified rollup block range.
#
# This function identifies which of already indexed transactions within the
# block range contains L1-to-L2 messages and makes RPC calls to fetch
# transaction data. These transactions are then processed to construct proper
# message structures, which are imported into the database. The imported
# messages are marked as `:relayed` as they represent completed actions from L1
# to L2.
# This function identifies already indexed transactions within the block range
# that potentially contain L1-to-L2 messages. It then makes RPC calls to fetch
# complete transaction data, as the database doesn't include the Arbitrum-specific
# `requestId` field.
#
# Note: Already indexed transactions from the database cannot be used because
# the `requestId` field is not included in the transaction model.
# The fetched transactions are processed to construct proper message structures.
# Messages with plain (non-hashed) request IDs are imported into the database
# and marked as `:relayed`, representing completed actions from L1 to L2.
#
# For transactions where the `requestId` represents a hashed message ID, the
# function schedules asynchronous discovery to match them with corresponding L1
# transactions.
#
# The function processes transactions in chunks to manage memory usage and
# network load efficiently.
#
# ## Parameters
# - `start_block`: The starting block number for the discovery range.
# - `end_block`: The ending block number for the discovery range.
# - `config`: The configuration map containing settings for RPC communication
# and chunk size.
# - `config`: A map containing configuration settings, including:
# - `:rollup_rpc`: A map with RPC settings:
# - `:chunk_size`: The number of transactions to process in each chunk.
# - `:json_rpc_named_arguments`: Arguments for JSON-RPC communication.
#
# ## Returns
# - `{:ok, start_block}`: A tuple indicating successful processing, returning
# the initial starting block number.
# the initial starting block number.
@spec do_discover_historical_messages_to_l2(non_neg_integer(), non_neg_integer(), %{
:rollup_rpc => %{
:chunk_size => non_neg_integer(),
@ -253,10 +265,10 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
if transactions_length > 0 do
log_debug("#{transactions_length} historical messages to L2 discovered")
messages =
{messages, txs_for_further_handling} =
transactions
|> Enum.chunk_every(chunk_size)
|> Enum.reduce([], fn chunk, messages_acc ->
|> Enum.reduce({[], []}, fn chunk, {messages_acc, txs_acc} ->
# Since DB does not contain the field RequestId specific to Arbitrum
# all transactions will be requested from the rollup RPC endpoint.
# The catchup process intended to be run once and only for the BS instance
@ -264,19 +276,17 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
# the new field in DB
requests = build_transaction_requests(chunk)
messages =
{messages, txs_with_hashed_message_id} =
requests
|> Rpc.make_chunked_request(json_rpc_named_arguments, "eth_getTransactionByHash")
|> Enum.map(&transaction_json_to_map/1)
|> Messaging.filter_l1_to_l2_messages(false)
messages ++ messages_acc
{messages ++ messages_acc, txs_with_hashed_message_id ++ txs_acc}
end)
# Logging of zero messages is left by intent to reveal potential cases when
# not all transactions are recognized as completed L1-to-L2 messages.
log_info("#{length(messages)} completions of L1-to-L2 messages will be imported")
import_to_db(messages)
handle_messages(messages)
handle_txs_with_hashed_message_id(txs_for_further_handling)
end
{:ok, start_block}
@ -301,12 +311,60 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do
|> TransactionByRPC.elixir_to_params()
end
# Imports a list of messages into the database.
defp import_to_db(messages) do
{:ok, _} =
Chain.import(%{
arbitrum_messages: %{params: messages},
timeout: :infinity
})
# Processes and imports completed L1-to-L2 messages.
#
# This function handles a list of completed L1-to-L2 messages, logging the number
# of messages to be imported and then importing them into the database. The
# function intentionally logs even when there are zero messages to import, which
# helps identify potential cases where not all transactions are recognized as
# completed L1-to-L2 messages.
#
# ## Parameters
# - `messages`: A list of completed L1-to-L2 messages ready for import.
#
# ## Returns
# - `:ok`
@spec handle_messages([Explorer.Chain.Arbitrum.Message.to_import()]) :: :ok
defp handle_messages(messages) do
log_info("#{length(messages)} completions of L1-to-L2 messages will be imported")
Messaging.import_to_db(messages)
end
# Processes transactions with hashed message IDs for L1-to-L2 message completion.
#
# This function asynchronously handles transactions that contain L1-to-L2
# messages with hashed message IDs.
#
# The asynchronous handling is beneficial because:
# - If the corresponding L1 transaction is already indexed, the message will be
# imported after the next flush of the queued tasks buffer.
# - If the corresponding L1 transaction is not yet indexed, it will be awaited by
# the queued tasks handler.
#
# Asynchronous processing prevents locking the discovery process, which would
# occur if we waited synchronously for L1 transactions to be indexed. Another
# approach for synchronous handling is to skip a message without importing it to
# the DB when an L1 transaction is not found; the absence of the message will be
# discovered after a Blockscout instance restart. In the current asynchronous
# implementation, even if the awaiting of an L1 transaction in the queued tasks
# is terminated due to a Blockscout instance shutdown, the absence of the message
# will be discovered after the restart. The system will then attempt to match it
# with the corresponding L1 message again.
#
# ## Parameters
# - `txs_with_hashed_message_id`: A list of transactions containing L1-to-L2
# messages with hashed message IDs.
#
# ## Returns
# - `:ok`
@spec handle_txs_with_hashed_message_id([map()]) :: :ok
defp handle_txs_with_hashed_message_id([]), do: :ok
defp handle_txs_with_hashed_message_id(txs_with_hashed_message_id) do
log_info(
"#{length(txs_with_hashed_message_id)} completions of L1-to-L2 messages require message ID matching discovery"
)
ArbitrumMessagesToL2Matcher.async_discover_match(txs_with_hashed_message_id)
end
end

@ -47,6 +47,7 @@ defmodule Indexer.Supervisor do
Withdrawal
}
alias Indexer.Fetcher.Arbitrum.MessagesToL2Matcher, as: ArbitrumMessagesToL2Matcher
alias Indexer.Fetcher.Arbitrum.RollupMessagesCatchup, as: ArbitrumRollupMessagesCatchup
alias Indexer.Fetcher.Arbitrum.TrackingBatchesStatuses, as: ArbitrumTrackingBatchesStatuses
alias Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1, as: ArbitrumTrackingMessagesOnL1
@ -190,6 +191,7 @@ defmodule Indexer.Supervisor do
configure(ArbitrumRollupMessagesCatchup.Supervisor, [
[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]
]),
{ArbitrumMessagesToL2Matcher.Supervisor, [[memory_monitor: memory_monitor]]},
configure(Indexer.Fetcher.Celo.ValidatorGroupVotes.Supervisor, [
[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]
]),

@ -3,6 +3,7 @@ defmodule Indexer.Transform.Arbitrum.Messaging do
Helper functions for transforming data for Arbitrum cross-chain messages.
"""
alias Explorer.Chain.Arbitrum.Message
alias Indexer.Fetcher.Arbitrum.Messaging, as: ArbitrumMessages
require Logger
@ -11,25 +12,27 @@ defmodule Indexer.Transform.Arbitrum.Messaging do
Parses and combines lists of rollup transactions and logs to identify and process both L1-to-L2 and L2-to-L1 messages.
This function utilizes two filtering operations: one that identifies L1-to-L2
message completions from a list of transactions and another that identifies
L2-to-L1 message initiations from a list of logs. Each filter constructs
a detailed message structure for the respective direction. The function then
combines these messages into a single list suitable for database import.
message completions from a list of transactions, as well as the transactions
suspected of containing messages but requiring additional handling due to
hashed message IDs; and another that identifies L2-to-L1 message initiations
from a list of logs.
## Parameters
- `transactions`: A list of rollup transaction entries to filter for L1-to-L2 messages.
- `logs`: A list of log entries to filter for L2-to-L1 messages.
## Returns
A tuple containing:
- A combined list of detailed message maps from both L1-to-L2 completions and
L2-to-L1 initiations, ready for database import.
- A list of transactions with hashed message IDs that require further processing.
"""
@spec parse(list(), list()) :: list()
@spec parse([map()], [map()]) :: {[Message.to_import()], [map()]}
def parse(transactions, logs) do
prev_metadata = Logger.metadata()
Logger.metadata(fetcher: :arbitrum_bridge_l2)
l1_to_l2_completion_ops =
{l1_to_l2_completion_ops, transactions_with_hashed_message_id} =
transactions
|> ArbitrumMessages.filter_l1_to_l2_messages()
@ -39,6 +42,6 @@ defmodule Indexer.Transform.Arbitrum.Messaging do
Logger.reset_metadata(prev_metadata)
l1_to_l2_completion_ops ++ l2_to_l1_initiating_ops
{l1_to_l2_completion_ops ++ l2_to_l1_initiating_ops, transactions_with_hashed_message_id}
end
end

@ -973,6 +973,9 @@ config :indexer, Indexer.Fetcher.Arbitrum.RollupMessagesCatchup,
config :indexer, Indexer.Fetcher.Arbitrum.RollupMessagesCatchup.Supervisor,
enabled: ConfigHelper.parse_bool_env_var("INDEXER_ARBITRUM_BRIDGE_MESSAGES_TRACKING_ENABLED")
config :indexer, Indexer.Fetcher.Arbitrum.MessagesToL2Matcher.Supervisor,
disabled?: not ConfigHelper.parse_bool_env_var("INDEXER_ARBITRUM_BRIDGE_MESSAGES_TRACKING_ENABLED")
config :indexer, Indexer.Fetcher.RootstockData.Supervisor,
disabled?:
ConfigHelper.chain_type() != :rsk || ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_ROOTSTOCK_DATA_FETCHER")

Loading…
Cancel
Save