Skip to content

Commit

Permalink
Merge pull request #777 from hemilabs/fix-not-found-tx-watcher
Browse files Browse the repository at this point in the history
Fix getting the Status when a withdrawal TX is not completed
  • Loading branch information
gndelia authored Jan 21, 2025
2 parents 25bc36f + db4cd92 commit 20255be
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 120 deletions.
105 changes: 105 additions & 0 deletions webapp/test/utils/watch/evmWithdrawals.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
})
104 changes: 104 additions & 0 deletions webapp/utils/watch/evmWithdrawals.ts
Original file line number Diff line number Diff line change
@@ -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<ToEvmWithdrawOperation> = {}

// 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
}
133 changes: 13 additions & 120 deletions webapp/workers/watchEvmWithdrawals.ts
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -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<ToEvmWithdrawOperation> = {}) {
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 <concurrency> 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<ToEvmWithdrawOperation> = {}
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
Expand Down

0 comments on commit 20255be

Please sign in to comment.