diff --git a/src/finalizer/utils/arbStack.ts b/src/finalizer/utils/arbStack.ts index b38e34723..319334af4 100644 --- a/src/finalizer/utils/arbStack.ts +++ b/src/finalizer/utils/arbStack.ts @@ -110,6 +110,7 @@ export async function arbStackFinalizer( undefined, redis ); + const l2BlockTime = (await averageBlockTime(spokePoolClient.spokePool.provider)).average; logger.debug({ at: `Finalizer#${networkName}Finalizer`, message: `${networkName} TokensBridged event filter`, @@ -209,6 +210,9 @@ export async function arbStackFinalizer( logger.debug({ at: `Finalizer#${networkName}Finalizer`, message: `Withdrawal event for ${amountFromWei} of ${l1TokenInfo.symbol} is too recent to finalize`, + timeUntilFinalization: `${Math.floor( + ((event.blockNumber - latestBlockToFinalize) * l2BlockTime) / 60 / 60 + )}`, }); } } catch (err) { diff --git a/src/finalizer/utils/linea/common.ts b/src/finalizer/utils/linea/common.ts index 5de1ffe90..8c7cf4bd2 100644 --- a/src/finalizer/utils/linea/common.ts +++ b/src/finalizer/utils/linea/common.ts @@ -1,7 +1,4 @@ import { LineaSDK, Message, OnChainMessageStatus } from "@consensys/linea-sdk"; -import { L1MessageServiceContract, L2MessageServiceContract } from "@consensys/linea-sdk/dist/lib/contracts"; -import { L1ClaimingService } from "@consensys/linea-sdk/dist/lib/sdk/claiming/L1ClaimingService"; -import { MessageSentEvent } from "@consensys/linea-sdk/dist/typechain/L2MessageService"; import { Linea_Adapter__factory } from "@across-protocol/contracts"; import { BigNumber, @@ -16,11 +13,12 @@ import { getNodeUrlList, getRedisCache, paginatedEventQuery, - retryAsync, CHAIN_IDs, } from "../../../utils"; import { HubPoolClient } from "../../../clients"; import { CONTRACT_ADDRESSES } from "../../../common"; +import { Log } from "../../../interfaces"; +import { L1ClaimingService, L1MessageServiceContract, L2MessageServiceContract, MessageSentEvent } from "./imports"; export type MessageWithStatus = Message & { logIndex: number; @@ -40,8 +38,12 @@ export function initLineaSdk(l1ChainId: number, l2ChainId: number): LineaSDK { } export function makeGetMessagesWithStatusByTxHash( - srcMessageService: L1MessageServiceContract | L2MessageServiceContract, - dstClaimingService: L1ClaimingService | L2MessageServiceContract + l2Provider: ethers.providers.Provider, + l1Provider: ethers.providers.Provider, + l2MessageService: L2MessageServiceContract, + l1ClaimingService: L1ClaimingService, + l1SearchConfig: EventSearchConfig, + l2SearchConfig: EventSearchConfig ) { /** * Retrieves Linea's MessageSent events for a given transaction hash and enhances them with their status. @@ -51,18 +53,16 @@ export function makeGetMessagesWithStatusByTxHash( */ return async (txHashOrReceipt: string | TransactionReceipt): Promise => { const txReceipt = - typeof txHashOrReceipt === "string" - ? await srcMessageService.provider.getTransactionReceipt(txHashOrReceipt) - : txHashOrReceipt; + typeof txHashOrReceipt === "string" ? await l2Provider.getTransactionReceipt(txHashOrReceipt) : txHashOrReceipt; if (!txReceipt) { return []; } const messages = txReceipt.logs - .filter((log) => log.address === srcMessageService.contract.address) + .filter((log) => log.address === l2MessageService.contract.address) .flatMap((log) => { - const parsedLog = srcMessageService.contract.interface.parseLog(log); + const parsedLog = l2MessageService.contract.interface.parseLog(log); if (!parsedLog || parsedLog.name !== "MessageSent") { return []; @@ -83,11 +83,17 @@ export function makeGetMessagesWithStatusByTxHash( logIndex: log.logIndex, }; }); - - // The Linea SDK MessageServiceContract constructs its own Provider without our retry logic so we retry each call - // twice with a 1 second delay between in case of intermittent RPC failures. const messageStatus = await Promise.all( - messages.map((message) => retryAsync(() => dstClaimingService.getMessageStatus(message.messageHash), 2, 1)) + messages.map((message) => + getL2L1MessageStatusUsingCustomProvider( + l1ClaimingService, + message.messageHash, + l1Provider, + l1SearchConfig, + l2Provider, + l2SearchConfig + ) + ) ); return messages.map((message, index) => ({ ...message, @@ -96,6 +102,72 @@ export function makeGetMessagesWithStatusByTxHash( }; } +// Temporary re-implementation of the SDK's `L1ClaimingService.getMessageStatus` functions that allow us to use +// our custom provider, with retry and caching logic, to get around the SDK's hardcoded logic to query events +// from 0 to "latest" which will not work on all RPC's. +async function getL2L1MessageStatusUsingCustomProvider( + messageService: L1ClaimingService, + messageHash: string, + l1Provider: ethers.providers.Provider, + l1SearchConfig: EventSearchConfig, + l2Provider: ethers.providers.Provider, + l2SearchConfig: EventSearchConfig +): Promise { + const l2Contract = getL2MessageServiceContractFromL1ClaimingService(messageService, l2Provider); + const messageEvent = await getMessageSentEventForMessageHash(messageHash, l2Contract, l2SearchConfig); + const l1Contract = getL1MessageServiceContractFromL1ClaimingService(messageService, l1Provider); + const [l2MessagingBlockAnchoredEvents, isMessageClaimed] = await Promise.all([ + getL2MessagingBlockAnchoredFromMessageSentEvent(messageEvent, l1Contract, l1SearchConfig), + l1Contract.isMessageClaimed(messageEvent.args?._nonce), + ]); + if (isMessageClaimed) { + return OnChainMessageStatus.CLAIMED; + } + if (l2MessagingBlockAnchoredEvents.length > 0) { + return OnChainMessageStatus.CLAIMABLE; + } + return OnChainMessageStatus.UNKNOWN; +} +export function getL2MessageServiceContractFromL1ClaimingService( + l1ClaimingService: L1ClaimingService, + l2Provider: ethers.providers.Provider +): Contract { + return l1ClaimingService.l2Contract.contract.connect(l2Provider); +} +export function getL1MessageServiceContractFromL1ClaimingService( + l1ClaimingService: L1ClaimingService, + l1Provider: ethers.providers.Provider +): Contract { + return l1ClaimingService.l1Contract.contract.connect(l1Provider); +} +export async function getMessageSentEventForMessageHash( + messageHash: string, + l2MessageServiceContract: Contract, + l2SearchConfig: EventSearchConfig +): Promise { + const [messageEvent] = await paginatedEventQuery( + l2MessageServiceContract, + l2MessageServiceContract.filters.MessageSent(null, null, null, null, null, null, messageHash), + l2SearchConfig + ); + if (!messageEvent) { + throw new Error(`Message hash does not exist on L2. Message hash: ${messageHash}`); + } + return messageEvent; +} +export async function getL2MessagingBlockAnchoredFromMessageSentEvent( + messageSentEvent: Log, + l1MessageServiceContract: Contract, + l1SearchConfig: EventSearchConfig +): Promise { + const l2MessagingBlockAnchoredEvents = await paginatedEventQuery( + l1MessageServiceContract, + l1MessageServiceContract.filters.L2MessagingBlockAnchored(messageSentEvent.blockNumber), + l1SearchConfig + ); + return l2MessagingBlockAnchoredEvents; +} + export async function getBlockRangeByHoursOffsets( chainId: number, fromBlockHoursOffsetToNow: number, @@ -188,13 +260,13 @@ export function determineMessageType( } export async function findMessageSentEvents( - contract: L1MessageServiceContract | L2MessageServiceContract, + contract: Contract, l1ToL2AddressesToFinalize: string[], searchConfig: EventSearchConfig ): Promise { return paginatedEventQuery( - contract.contract, - (contract.contract as Contract).filters.MessageSent(l1ToL2AddressesToFinalize, l1ToL2AddressesToFinalize), + contract, + contract.filters.MessageSent(l1ToL2AddressesToFinalize, l1ToL2AddressesToFinalize), searchConfig ) as Promise; } diff --git a/src/finalizer/utils/linea/imports.ts b/src/finalizer/utils/linea/imports.ts new file mode 100644 index 000000000..558e312fd --- /dev/null +++ b/src/finalizer/utils/linea/imports.ts @@ -0,0 +1,23 @@ +// Normally we avoid importing directly from a node_modules' /dist package but we need access to some +// of the internal classes and functions in order to replicate SDK logic so that we can by pass hardcoded +// ethers.Provider instances and use our own custom provider instead. +import { L1MessageServiceContract, L2MessageServiceContract } from "@consensys/linea-sdk/dist/lib/contracts"; +import { L1ClaimingService } from "@consensys/linea-sdk/dist/lib/sdk/claiming/L1ClaimingService"; +import { MessageSentEvent } from "@consensys/linea-sdk/dist/typechain/L2MessageService"; +import { SparseMerkleTreeFactory } from "@consensys/linea-sdk/dist/lib/sdk/merkleTree/MerkleTreeFactory"; +import { + DEFAULT_L2_MESSAGE_TREE_DEPTH, + L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE, + L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE, +} from "@consensys/linea-sdk/dist/lib/utils/constants"; + +export { + L1ClaimingService, + L1MessageServiceContract, + L2MessageServiceContract, + MessageSentEvent, + SparseMerkleTreeFactory, + DEFAULT_L2_MESSAGE_TREE_DEPTH, + L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE, + L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE, +}; diff --git a/src/finalizer/utils/linea/l1ToL2.ts b/src/finalizer/utils/linea/l1ToL2.ts index 1157c373d..93055e8e7 100644 --- a/src/finalizer/utils/linea/l1ToL2.ts +++ b/src/finalizer/utils/linea/l1ToL2.ts @@ -4,7 +4,7 @@ import { Contract } from "ethers"; import { groupBy } from "lodash"; import { HubPoolClient, SpokePoolClient } from "../../../clients"; import { CHAIN_MAX_BLOCK_LOOKBACK, CONTRACT_ADDRESSES } from "../../../common"; -import { EventSearchConfig, Signer, convertFromWei, retryAsync, winston, CHAIN_IDs } from "../../../utils"; +import { EventSearchConfig, Signer, convertFromWei, winston, CHAIN_IDs, ethers, BigNumber } from "../../../utils"; import { CrossChainMessage, FinalizerPromise } from "../../types"; import { determineMessageType, @@ -12,8 +12,28 @@ import { findMessageFromUsdcBridge, findMessageSentEvents, getBlockRangeByHoursOffsets, + getL1MessageServiceContractFromL1ClaimingService, initLineaSdk, } from "./common"; +import { L2MessageServiceContract } from "./imports"; + +const L1L2MessageStatuses = { + 0: "UNKNOWN", + 1: "CLAIMABLE", + 2: "CLAIMED", +}; +// Temporary re-implementation of the SDK's `L2MessageServiceContract.getMessageStatus` functions that allow us to use +// our custom provider, with retry and caching logic, to get around the SDK's hardcoded logic to query events +// from 0 to "latest" which will not work on all RPC's. +async function getL1ToL2MessageStatusUsingCustomProvider( + messageService: L2MessageServiceContract, + messageHash: string, + l2Provider: ethers.providers.Provider +): Promise { + const l2Contract = messageService.contract.connect(l2Provider); + const status: BigNumber = await l2Contract.inboxL1L2MessageStatus(messageHash); + return L1L2MessageStatuses[status.toString()]; +} export async function lineaL1ToL2Finalizer( logger: winston.Logger, @@ -58,7 +78,11 @@ export async function lineaL1ToL2Finalizer( }; const [wethAndRelayEvents, tokenBridgeEvents, usdcBridgeEvents] = await Promise.all([ - findMessageSentEvents(l1MessageServiceContract, l1ToL2AddressesToFinalize, searchConfig), + findMessageSentEvents( + getL1MessageServiceContractFromL1ClaimingService(lineaSdk.getL1ClaimingService(), hubPoolClient.hubPool.provider), + l1ToL2AddressesToFinalize, + searchConfig + ), findMessageFromTokenBridge(l1TokenBridge, l1MessageServiceContract, l1ToL2AddressesToFinalize, searchConfig), findMessageFromUsdcBridge(l1UsdcBridge, l1MessageServiceContract, l1ToL2AddressesToFinalize, searchConfig), ]); @@ -73,9 +97,11 @@ export async function lineaL1ToL2Finalizer( // It's unlikely that our multicall will have multiple transactions to bridge to Linea // so we can grab the statuses individually. - // The Linea SDK MessageServiceContract constructs its own Provider without our retry logic so we retry each call - // twice with a 1 second delay between in case of intermittent RPC failures. - const messageStatus = await retryAsync(() => l2MessageServiceContract.getMessageStatus(_messageHash), 2, 1); + const messageStatus = await getL1ToL2MessageStatusUsingCustomProvider( + l2MessageServiceContract, + _messageHash, + _spokePoolClient.spokePool.provider + ); return { messageSender: _from, destination: _to, diff --git a/src/finalizer/utils/linea/l2ToL1.ts b/src/finalizer/utils/linea/l2ToL1.ts index 6649b7ba7..0261afcf0 100644 --- a/src/finalizer/utils/linea/l2ToL1.ts +++ b/src/finalizer/utils/linea/l2ToL1.ts @@ -3,7 +3,19 @@ import { Wallet } from "ethers"; import { groupBy } from "lodash"; import { HubPoolClient, SpokePoolClient } from "../../../clients"; -import { Signer, winston, convertFromWei, getL1TokenInfo } from "../../../utils"; +import { + Signer, + winston, + convertFromWei, + getL1TokenInfo, + getProvider, + EventSearchConfig, + ethers, + Contract, + paginatedEventQuery, + mapAsync, + BigNumber, +} from "../../../utils"; import { FinalizerPromise, CrossChainMessage } from "../../types"; import { TokensBridged } from "../../../interfaces"; import { @@ -11,7 +23,137 @@ import { makeGetMessagesWithStatusByTxHash, MessageWithStatus, getBlockRangeByHoursOffsets, + getMessageSentEventForMessageHash, + getL1MessageServiceContractFromL1ClaimingService, + getL2MessageServiceContractFromL1ClaimingService, + getL2MessagingBlockAnchoredFromMessageSentEvent, } from "./common"; +import { CHAIN_MAX_BLOCK_LOOKBACK } from "../../../common"; +import { utils as sdkUtils } from "@across-protocol/sdk"; +import { + L1ClaimingService, + SparseMerkleTreeFactory, + DEFAULT_L2_MESSAGE_TREE_DEPTH, + L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE, + L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE, +} from "./imports"; + +// Normally we avoid importing directly from a node_modules' /dist package but we need access to some +// of the internal classes and functions in order to replicate SDK logic so that we can by pass hardcoded +// ethers.Provider instances and use our own custom provider instead. + +// Ideally we could call this function through the LineaSDK but its hardcoded to use an ethers.Provider instance +// that doesn't have our custom caching logic or ability for us to customize the block lookback. This means we can't +// use the SDK on providers that have maxBlockLookbacks constraint. So, we re-implement this function here. +async function getMessageProof( + messageHash: string, + l1ClaimingService: L1ClaimingService, + l2Provider: ethers.providers.Provider, + l1Provider: ethers.providers.Provider, + l2SearchConfig: EventSearchConfig, + l1SearchConfig: EventSearchConfig +) { + const l2Contract = getL2MessageServiceContractFromL1ClaimingService(l1ClaimingService, l2Provider); + const messageEvent = await getMessageSentEventForMessageHash(messageHash, l2Contract, l2SearchConfig); + const l1Contract = getL1MessageServiceContractFromL1ClaimingService(l1ClaimingService, l1Provider); + const [l2MessagingBlockAnchoredEvent] = await getL2MessagingBlockAnchoredFromMessageSentEvent( + messageEvent, + l1Contract, + l1SearchConfig + ); + if (!l2MessagingBlockAnchoredEvent) { + throw new Error(`L2 block number ${messageEvent.blockNumber} has not been finalized on L1.`); + } + // This SDK function is complex but only makes one l1Provider.getTransactionReceipt call so we can make this + // through the SDK rather than re-implement it. + const finalizationInfo = await getFinalizationMessagingInfo( + l1ClaimingService, + l2MessagingBlockAnchoredEvent.transactionHash + ); + const l2MessageHashesInBlockRange = await getL2MessageHashesInBlockRange(l2Contract, { + fromBlock: finalizationInfo.l2MessagingBlocksRange.startingBlock, + toBlock: finalizationInfo.l2MessagingBlocksRange.endBlock, + maxBlockLookBack: l2SearchConfig.maxBlockLookBack, + }); + const l2Messages = l1ClaimingService.getMessageSiblings( + messageHash, + l2MessageHashesInBlockRange, + finalizationInfo.treeDepth + ); + // This part is really janky because the SDK doesn't expose any helper functions that use the + // merkle tree or the merkle tree class itself. + const merkleTreeFactory = new SparseMerkleTreeFactory(DEFAULT_L2_MESSAGE_TREE_DEPTH); + const tree = merkleTreeFactory.createAndAddLeaves(l2Messages); + if (!finalizationInfo.l2MerkleRoots.includes(tree.getRoot())) { + throw new Error("Merkle tree build failed."); + } + return tree.getProof(l2Messages.indexOf(messageHash)); +} + +async function getFinalizationMessagingInfo( + l1ClaimingService: L1ClaimingService, + transactionHash: string +): Promise<{ + l2MessagingBlocksRange: { startingBlock: number; endBlock: number }; + l2MerkleRoots: string[]; + treeDepth: number; +}> { + const l1Contract = l1ClaimingService.l1Contract; + const receipt = await l1Contract.provider.getTransactionReceipt(transactionHash); + if (!receipt || receipt.logs.length === 0) { + throw new Error(`Transaction does not exist or no logs found in this transaction: ${transactionHash}.`); + } + let treeDepth = 0; + const l2MerkleRoots = []; + const blocksNumber = []; + const filteredLogs = receipt.logs.filter((log) => log.address === l1Contract.contractAddress); + for (let i = 0; i < filteredLogs.length; i++) { + const log = filteredLogs[i]; + // This part changes from the SDK: remove any logs with topic hashes that don't exist in the current SDK's ABI, + // otherwise parseLog will fail. + const topic = log.topics[0]; + if (topic !== L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE && topic !== L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE) { + continue; + } + + const parsedLog = l1Contract.contract.interface.parseLog(log); + if (topic === L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE) { + treeDepth = BigNumber.from(parsedLog.args.treeDepth).toNumber(); + l2MerkleRoots.push(parsedLog.args.l2MerkleRoot); + } else if (topic === L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE) { + blocksNumber.push(parsedLog.args.l2Block); + } + } + if (l2MerkleRoots.length === 0) { + throw new Error("No L2MerkleRootAdded events found in this transaction."); + } + if (blocksNumber.length === 0) { + throw new Error("No L2MessagingBlocksAnchored events found in this transaction."); + } + return { + l2MessagingBlocksRange: { + startingBlock: Math.min(...blocksNumber), + endBlock: Math.max(...blocksNumber), + }, + l2MerkleRoots, + treeDepth, + }; +} + +async function getL2MessageHashesInBlockRange( + l2MessageServiceContract: Contract, + l2SearchConfig: EventSearchConfig +): Promise { + const events = await paginatedEventQuery( + l2MessageServiceContract, + l2MessageServiceContract.filters.MessageSent(), + l2SearchConfig + ); + if (events.length === 0) { + throw new Error("No MessageSent events found in this block range on L2."); + } + return events.map((event) => event.args._messageHash); +} export async function lineaL2ToL1Finalizer( logger: winston.Logger, @@ -24,21 +166,44 @@ export async function lineaL2ToL1Finalizer( const l2Contract = lineaSdk.getL2Contract(); const l1Contract = lineaSdk.getL1Contract(); const l1ClaimingService = lineaSdk.getL1ClaimingService(l1Contract.contractAddress); - const getMessagesWithStatusByTxHash = makeGetMessagesWithStatusByTxHash(l2Contract, l1ClaimingService); - + // We need a longer lookback period for L1 to ensure we find all L1 events containing finalized + // L2 block heights. Use the max spoke pool client lookback for the hub chain. + const l1FromBlock = spokePoolClient[hubPoolClient.chainId].eventSearchConfig.fromBlock; + const l1ToBlock = spokePoolClient[hubPoolClient.chainId].latestBlockSearched; // Optimize block range for querying relevant source events on L2. // Linea L2->L1 messages are claimable after 6 - 32 hours - const { fromBlock, toBlock } = await getBlockRangeByHoursOffsets(l2ChainId, 24 * 8, 6); + const { fromBlock: l2FromBlock, toBlock: l2ToBlock } = await getBlockRangeByHoursOffsets(l2ChainId, 24 * 8, 6); + const l1SearchConfig = { + fromBlock: l1FromBlock, + toBlock: l1ToBlock, + maxBlockLookBack: CHAIN_MAX_BLOCK_LOOKBACK[l1ChainId] || 10_000, + }; + const l2SearchConfig = { + fromBlock: l2FromBlock, + toBlock: l2ToBlock, + maxBlockLookBack: CHAIN_MAX_BLOCK_LOOKBACK[l2ChainId] || 5_000, + }; + const l2Provider = await getProvider(l2ChainId); + const l1Provider = await getProvider(l1ChainId); + const getMessagesWithStatusByTxHash = makeGetMessagesWithStatusByTxHash( + l2Provider, + l1Provider, + l2Contract, + l1ClaimingService, + l1SearchConfig, + l2SearchConfig + ); + logger.debug({ at: "Finalizer#LineaL2ToL1Finalizer", message: "Linea TokensBridged event filter", - fromBlock, - toBlock, + l1SearchConfig, + l2SearchConfig, }); // Get src events const l2SrcEvents = spokePoolClient .getTokensBridged() - .filter(({ blockNumber }) => blockNumber >= fromBlock && blockNumber <= toBlock); + .filter(({ blockNumber }) => blockNumber >= l2FromBlock && blockNumber <= l2ToBlock); // Get Linea's MessageSent events for each src event const uniqueTxHashes = Array.from(new Set(l2SrcEvents.map((event) => event.transactionHash))); @@ -65,33 +230,28 @@ export async function lineaL2ToL1Finalizer( // Populate txns for claimable messages const populatedTxns = await Promise.all( claimable.map(async ({ message }) => { - const isProofNeeded = await l1ClaimingService.isClaimingNeedingProof(message.messageHash); - - if (isProofNeeded) { - const proof = await l1ClaimingService.getMessageProof(message.messageHash); - return l1ClaimingService.l1Contract.contract.populateTransaction.claimMessageWithProof({ - from: message.messageSender, - to: message.destination, - fee: message.fee, - value: message.value, - feeRecipient: (signer as Wallet).address, - data: message.calldata, - messageNumber: message.messageNonce, - proof: proof.proof, - leafIndex: proof.leafIndex, - merkleRoot: proof.root, - }); - } - - return l1ClaimingService.l1Contract.contract.populateTransaction.claimMessage( - message.messageSender, - message.destination, - message.fee, - message.value, - (signer as Wallet).address, - message.calldata, - message.messageNonce + // As of this upgrade, proofs are always needed to submit claims: + // https://lineascan.build/tx/0x01ef3ec3c09c4fe828ec2c0e67a3f3adf768d34026adf8948e05f7871abaa327 + const proof = await getMessageProof( + message.messageHash, + l1ClaimingService, + l2Provider, + l1Provider, + l2SearchConfig, + l1SearchConfig ); + return l1ClaimingService.l1Contract.contract.populateTransaction.claimMessageWithProof({ + from: message.messageSender, + to: message.destination, + fee: message.fee, + value: message.value, + feeRecipient: (signer as Wallet).address, + data: message.calldata, + messageNumber: message.messageNonce, + proof: proof.proof, + leafIndex: proof.leafIndex, + merkleRoot: proof.root, + }); }) ); const multicall3Call = populatedTxns.map((txn) => ({ @@ -115,14 +275,26 @@ export async function lineaL2ToL1Finalizer( return transfer; }); + const averageBlockTimeSeconds = await sdkUtils.averageBlockTime(spokePoolClient.spokePool.provider); logger.debug({ at: "Finalizer#LineaL2ToL1Finalizer", message: "Linea L2->L1 message statuses", + averageBlockTimeSeconds, + latestSpokePoolBlock: spokePoolClient.latestBlockSearched, statuses: { claimed: claimed.length, claimable: claimable.length, notReceived: unknown.length, }, + notReceivedTxns: await mapAsync(unknown, async ({ message, tokensBridged }) => { + const withdrawalBlock = tokensBridged.blockNumber; + return { + txnHash: message.txHash, + withdrawalBlock, + maturedHours: + (averageBlockTimeSeconds.average * (spokePoolClient.latestBlockSearched - withdrawalBlock)) / 60 / 60, + }; + }), }); return { callData: multicall3Call, crossChainMessages: transfers };