diff --git a/webapp/test/utils/watch/evmWithdrawals.test.ts b/webapp/test/utils/watch/evmWithdrawals.test.ts new file mode 100644 index 00000000..0ca2d5c3 --- /dev/null +++ b/webapp/test/utils/watch/evmWithdrawals.test.ts @@ -0,0 +1,105 @@ +import { MessageDirection, MessageStatus } from '@eth-optimism/sdk' +import { hemiSepolia } from 'hemi-viem' +import { ToEvmWithdrawOperation } from 'types/tunnel' +import { createQueuedCrossChainMessenger } from 'utils/crossChainMessenger' +import { getEvmBlock, getEvmTransactionReceipt } from 'utils/evmApi' +import { createPublicProvider } from 'utils/providers' +// import { watchEvmWithdrawal } from 'utils/watch/evmWithdrawals' +import { sepolia } from 'viem/chains' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// @ts-expect-error Only adding the minimum required properties +const withdrawal: ToEvmWithdrawOperation = { + direction: MessageDirection.L2_TO_L1, + l1ChainId: sepolia.id, + l2ChainId: hemiSepolia.id, + transactionHash: '0x0000000000000000000000000000000000000004', +} + +vi.mock('utils/crossChainMessenger', () => ({ + createQueuedCrossChainMessenger: vi.fn(), +})) + +vi.mock('utils/evmApi', () => ({ + getEvmBlock: vi.fn(), + getEvmTransactionReceipt: vi.fn(), +})) + +vi.mock('utils/providers', () => ({ + createPublicProvider: vi.fn(), +})) + +describe('utils/watch/evmWithdrawals', function () { + beforeEach(function () { + vi.clearAllMocks() + vi.resetAllMocks() + vi.resetModules() + }) + + describe('watchEvmWithdrawal', async function () { + it('should return no changes if the withdrawal is pending', async function () { + const { watchEvmWithdrawal } = await import('utils/watch/evmWithdrawals') + vi.mocked(createQueuedCrossChainMessenger).mockResolvedValue({}) + vi.mocked(createPublicProvider).mockResolvedValue({}) + vi.mocked(getEvmTransactionReceipt).mockResolvedValue(null) + + const updates = await watchEvmWithdrawal(withdrawal) + + expect(updates).toEqual({}) + }) + + it('should return the updated fields with the new values', async function () { + const { watchEvmWithdrawal } = await import('utils/watch/evmWithdrawals') + const blockNumber = BigInt(123) + const newStatus = MessageStatus.READY_TO_PROVE + const timestamp = BigInt(new Date().getTime()) + const getMessageStatus = vi.fn().mockResolvedValue(newStatus) + vi.mocked(createQueuedCrossChainMessenger).mockResolvedValue({ + getMessageStatus, + }) + vi.mocked(getEvmBlock).mockResolvedValue({ timestamp }) + vi.mocked(getEvmTransactionReceipt).mockResolvedValue({ + blockNumber, + }) + + const updates = await watchEvmWithdrawal({ + ...withdrawal, + status: MessageStatus.STATE_ROOT_NOT_PUBLISHED, + }) + + expect(updates).toEqual({ + blockNumber: Number(blockNumber), + status: newStatus, + timestamp: Number(timestamp), + }) + expect(getMessageStatus).toHaveBeenCalledOnce() + expect(getMessageStatus).toHaveBeenCalledWith( + withdrawal.transactionHash, + 0, + withdrawal.direction, + ) + }) + + it('should return no updates if the withdrawal has not changed', async function () { + const newWithdrawal: ToEvmWithdrawOperation = { + ...withdrawal, + blockNumber: 789, + timestamp: new Date().getTime(), + } + const { watchEvmWithdrawal } = await import('utils/watch/evmWithdrawals') + vi.mocked(createQueuedCrossChainMessenger).mockResolvedValue({ + getMessageStatus: vi.fn().mockResolvedValue(newWithdrawal.status), + }) + vi.mocked(getEvmBlock).mockResolvedValue({ + timestamp: BigInt(newWithdrawal.timestamp), + }) + vi.mocked(getEvmTransactionReceipt).mockResolvedValue({ + blockNumber: BigInt(newWithdrawal.blockNumber), + }) + + const updates = await watchEvmWithdrawal(newWithdrawal) + + expect(updates).toEqual({}) + }) + }) +}) diff --git a/webapp/utils/watch/evmWithdrawals.ts b/webapp/utils/watch/evmWithdrawals.ts new file mode 100644 index 00000000..1f4b96c0 --- /dev/null +++ b/webapp/utils/watch/evmWithdrawals.ts @@ -0,0 +1,104 @@ +import pMemoize from 'promise-mem' +import { ToEvmWithdrawOperation } from 'types/tunnel' +import { findChainById } from 'utils/chain' +import { createQueuedCrossChainMessenger } from 'utils/crossChainMessenger' +import { getEvmBlock, getEvmTransactionReceipt } from 'utils/evmApi' +import { createPublicProvider } from 'utils/providers' +import { Chain } from 'viem' + +// Memoized cross chain messenger as this will be created by many withdrawals +const getCrossChainMessenger = pMemoize( + function (l1Chain: Chain, l2Chain: Chain) { + const l1Provider = createPublicProvider( + l1Chain.rpcUrls.default.http[0], + l1Chain, + ) + + const l2Provider = createPublicProvider( + l2Chain.rpcUrls.default.http[0], + l2Chain, + ) + + return createQueuedCrossChainMessenger({ + l1ChainId: l1Chain.id, + l1Signer: l1Provider, + l2Chain, + l2Signer: l2Provider, + }) + }, + { resolver: (l1Chain, l2Chain) => `${l1Chain.id}-${l2Chain.id}` }, +) + +const getTransactionBlockNumber = function ( + withdrawal: ToEvmWithdrawOperation, +) { + if (withdrawal.blockNumber) { + return Promise.resolve(withdrawal.blockNumber) + } + return getEvmTransactionReceipt( + withdrawal.transactionHash, + withdrawal.l2ChainId, + ).then(transactionReceipt => + // return undefined if TX is not found - might have not been confirmed yet + transactionReceipt ? Number(transactionReceipt.blockNumber) : undefined, + ) +} + +const getBlockTimestamp = (withdrawal: ToEvmWithdrawOperation) => + async function ( + blockNumber: number | undefined, + ): Promise<[number?, number?]> { + // Can't return a block if we don't know the number + if (blockNumber === undefined) { + return [] + } + // Block and timestamp already known - return them + if (withdrawal.timestamp) { + return [blockNumber, withdrawal.timestamp] + } + const { timestamp } = await getEvmBlock(blockNumber, withdrawal.l2ChainId) + return [blockNumber, Number(timestamp)] + } + +export const watchEvmWithdrawal = async function ( + withdrawal: ToEvmWithdrawOperation, +) { + const updates: Partial = {} + + // as this worker watches withdrawals to EVM chains, l1Chain will be (EVM) Chain + const l1Chain = findChainById(withdrawal.l1ChainId) as Chain + // L2 are always EVM + const l2Chain = findChainById(withdrawal.l2ChainId) as Chain + + const crossChainMessenger = await getCrossChainMessenger(l1Chain, l2Chain) + const receipt = await getEvmTransactionReceipt( + withdrawal.transactionHash, + withdrawal.l2ChainId, + ) + + if (!receipt) { + return updates + } + + const [status, [blockNumber, timestamp]] = await Promise.all([ + crossChainMessenger.getMessageStatus( + withdrawal.transactionHash, + // default value, but we want to set direction + 0, + withdrawal.direction, + ), + getTransactionBlockNumber(withdrawal).then(getBlockTimestamp(withdrawal)), + ]) + + if (withdrawal.status !== status) { + updates.status = status + } + if (withdrawal.blockNumber !== blockNumber) { + updates.blockNumber = blockNumber + } + if (withdrawal.timestamp !== timestamp) { + updates.timestamp = timestamp + } + + return updates +} diff --git a/webapp/workers/watchEvmWithdrawals.ts b/webapp/workers/watchEvmWithdrawals.ts index 7d3cfb17..810446c2 100644 --- a/webapp/workers/watchEvmWithdrawals.ts +++ b/webapp/workers/watchEvmWithdrawals.ts @@ -1,19 +1,15 @@ import { MessageStatus } from '@eth-optimism/sdk' import debugConstructor from 'debug' import PQueue from 'p-queue' -import pMemoize from 'promise-mem' import { RemoteChain } from 'types/chain' import { type ToEvmWithdrawOperation, type WithdrawTunnelOperation, } from 'types/tunnel' -import { findChainById } from 'utils/chain' -import { createQueuedCrossChainMessenger } from 'utils/crossChainMessenger' -import { getEvmBlock, getEvmTransactionReceipt } from 'utils/evmApi' -import { createPublicProvider } from 'utils/providers' import { type EnableWorkersDebug } from 'utils/typeUtilities' import { hasKeys } from 'utils/utilities' -import { type Chain, type Hash } from 'viem' +import { watchEvmWithdrawal } from 'utils/watch/evmWithdrawals' +import { type Hash } from 'viem' const queue = new PQueue({ concurrency: 2 }) @@ -62,128 +58,25 @@ const getPriority = function (withdrawal: ToEvmWithdrawOperation) { return 0 } -const getBlockTimestamp = (withdrawal: ToEvmWithdrawOperation) => - async function ( - blockNumber: number | undefined, - ): Promise<[number?, number?]> { - // Can't return a block if we don't know the number - if (blockNumber === undefined) { - return [] +const postUpdates = (withdrawal: ToEvmWithdrawOperation) => + function (updates: Partial = {}) { + if (hasKeys(updates)) { + debug('Sending changes for withdrawal %s', withdrawal.transactionHash) + } else { + debug('No changes for withdrawal %s', withdrawal.transactionHash) } - // Block and timestamp already known - return them - if (withdrawal.timestamp) { - return [blockNumber, withdrawal.timestamp] - } - const { timestamp } = await getEvmBlock(blockNumber, withdrawal.l2ChainId) - return [blockNumber, Number(timestamp)] - } - -const getTransactionBlockNumber = function ( - withdrawal: ToEvmWithdrawOperation, -) { - if (withdrawal.blockNumber) { - return Promise.resolve(withdrawal.blockNumber) - } - return getEvmTransactionReceipt( - withdrawal.transactionHash, - withdrawal.l2ChainId, - ).then(transactionReceipt => - // return undefined if TX is not found - might have not been confirmed yet - transactionReceipt ? Number(transactionReceipt.blockNumber) : undefined, - ) -} - -// Memoized cross chain messenger as this will be created by many withdrawals -const getCrossChainMessenger = pMemoize( - function (l1Chain: Chain, l2Chain: Chain) { - const l1Provider = createPublicProvider( - l1Chain.rpcUrls.default.http[0], - l1Chain, - ) - - const l2Provider = createPublicProvider( - l2Chain.rpcUrls.default.http[0], - l2Chain, - ) - - debug( - 'Creating cross chain messenger for L1 %s and L2 %s', - l1Chain.id, - l2Chain.id, - ) - - return createQueuedCrossChainMessenger({ - l1ChainId: l1Chain.id, - l1Signer: l1Provider, - l2Chain, - l2Signer: l2Provider, + worker.postMessage({ + type: getUpdateWithdrawalKey(withdrawal), + updates, }) - }, - { resolver: (l1Chain, l2Chain) => `${l1Chain.id}-${l2Chain.id}` }, -) + } const watchWithdrawal = (withdrawal: ToEvmWithdrawOperation) => // Use a queue to avoid firing lots of requests. Throttling may also not work because it throttles // for a specific period of time and depending on load, requests may take up to 5 seconds to complete // so this let us to query up to checks for status at the same time queue.add( - async function checkWithdrawalUpdates() { - // as this worker watches withdrawals to EVM chains, l1Chain will be (EVM) Chain - const l1Chain = findChainById(withdrawal.l1ChainId) as Chain - // L2 are always EVM - const l2Chain = findChainById(withdrawal.l2ChainId) as Chain - - const crossChainMessenger = await getCrossChainMessenger(l1Chain, l2Chain) - debug('Checking withdrawal %s', withdrawal.transactionHash) - const [status, [blockNumber, timestamp]] = await Promise.all([ - crossChainMessenger.getMessageStatus( - withdrawal.transactionHash, - // default value, but we want to set direction - 0, - withdrawal.direction, - ), - getTransactionBlockNumber(withdrawal).then( - getBlockTimestamp(withdrawal), - ), - ]) - const updates: Partial = {} - if (withdrawal.status !== status) { - debug( - 'Withdrawal %s status changed from %s to %s', - withdrawal.transactionHash, - withdrawal.status ?? 'none', - status, - ) - updates.status = status - } - if (withdrawal.blockNumber !== blockNumber) { - debug( - 'Saving block number %s for withdrawal %s', - blockNumber, - withdrawal.transactionHash, - ) - updates.blockNumber = blockNumber - } - if (withdrawal.timestamp !== timestamp) { - debug( - 'Saving timestamp %s for withdrawal %s', - timestamp, - withdrawal.transactionHash, - ) - updates.timestamp = timestamp - } - - if (hasKeys(updates)) { - debug('Sending changes for withdrawal %s', withdrawal.transactionHash) - } else { - debug('No changes for withdrawal %s', withdrawal.transactionHash) - } - - worker.postMessage({ - type: getUpdateWithdrawalKey(withdrawal), - updates, - }) - }, + () => watchEvmWithdrawal(withdrawal).then(postUpdates(withdrawal)), { // Give more priority to those that require polling and are not ready or are missing information // because if ready, after the operation they will change their status automatically and will have