From f90cc645c487bca730dd8505e44359abedbd307a Mon Sep 17 00:00:00 2001 From: Kelvin Fichter Date: Sat, 3 Feb 2024 23:33:50 -0700 Subject: [PATCH] feat: update wd-mon to work for OptimismPortal2 --- .changeset/dry-bobcats-obey.md | 5 + .changeset/ninety-pugs-clap.md | 5 + packages/chain-mon/src/wd-mon/constants.ts | 19 ++ packages/chain-mon/src/wd-mon/helpers.ts | 41 ---- packages/chain-mon/src/wd-mon/service.ts | 210 ++++++++++++------ .../common-ts/src/base-service/validators.ts | 9 + 6 files changed, 177 insertions(+), 112 deletions(-) create mode 100644 .changeset/dry-bobcats-obey.md create mode 100644 .changeset/ninety-pugs-clap.md create mode 100644 packages/chain-mon/src/wd-mon/constants.ts delete mode 100644 packages/chain-mon/src/wd-mon/helpers.ts diff --git a/.changeset/dry-bobcats-obey.md b/.changeset/dry-bobcats-obey.md new file mode 100644 index 0000000000000..fb6a943a37df6 --- /dev/null +++ b/.changeset/dry-bobcats-obey.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/common-ts': patch +--- + +Adds a new validator for address types. diff --git a/.changeset/ninety-pugs-clap.md b/.changeset/ninety-pugs-clap.md new file mode 100644 index 0000000000000..a4dfb6cbc655f --- /dev/null +++ b/.changeset/ninety-pugs-clap.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/chain-mon': minor +--- + +Updates wd-mon inside chain-mon to support FPAC. diff --git a/packages/chain-mon/src/wd-mon/constants.ts b/packages/chain-mon/src/wd-mon/constants.ts new file mode 100644 index 0000000000000..265a4beabcbce --- /dev/null +++ b/packages/chain-mon/src/wd-mon/constants.ts @@ -0,0 +1,19 @@ +import { L2ChainID } from '@eth-optimism/sdk' + +/** + * Mapping of L2ChainIDs to the L1 block numbers where the wd-mon service should start looking for + * withdrawals by default. L1 block numbers here are based on the block number in which the + * OptimismPortal proxy contract was deployed to L1. + */ +export const DEFAULT_STARTING_BLOCK_NUMBERS: { + [ChainID in L2ChainID]?: number +} = { + [L2ChainID.OPTIMISM]: 17365802 as const, + [L2ChainID.OPTIMISM_GOERLI]: 0 as const, + [L2ChainID.OPTIMISM_SEPOLIA]: 4071248 as const, + [L2ChainID.BASE_MAINNET]: 0 as const, + [L2ChainID.BASE_GOERLI]: 0 as const, + [L2ChainID.BASE_SEPOLIA]: 0 as const, + [L2ChainID.ZORA_MAINNET]: 0 as const, + [L2ChainID.ZORA_GOERLI]: 0 as const, +} diff --git a/packages/chain-mon/src/wd-mon/helpers.ts b/packages/chain-mon/src/wd-mon/helpers.ts deleted file mode 100644 index ed00acde5affc..0000000000000 --- a/packages/chain-mon/src/wd-mon/helpers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Provider } from '@ethersproject/abstract-provider' -import { Logger } from '@eth-optimism/common-ts' - -/** - * Finds - * - * @param - * @param - * @param - * @returns - */ -export const getLastFinalizedBlock = async ( - l1RpcProvider: Provider, - faultProofWindow: number, - logger: Logger -): Promise => { - let guessWindowStartBlock - try { - const l1Block = await l1RpcProvider.getBlock('latest') - - // The time corresponding to the start of the FPW, based on the current block. - const windowStartTime = l1Block.timestamp - faultProofWindow - - // Use the FPW to find the block number that is the start of the FPW. - guessWindowStartBlock = l1Block.number - faultProofWindow / 12 - - let block = await l1RpcProvider.getBlock(guessWindowStartBlock) - while (block.timestamp > windowStartTime) { - guessWindowStartBlock-- - block = await l1RpcProvider.getBlock(guessWindowStartBlock) - } - return block.number - } catch (err) { - logger.fatal('error when calling querying for block', { - errors: err, - }) - throw new Error( - `unable to find block number ${guessWindowStartBlock || 'latest'}` - ) - } -} diff --git a/packages/chain-mon/src/wd-mon/service.ts b/packages/chain-mon/src/wd-mon/service.ts index 62f4066fe8e94..367c2ee98769b 100644 --- a/packages/chain-mon/src/wd-mon/service.ts +++ b/packages/chain-mon/src/wd-mon/service.ts @@ -6,30 +6,35 @@ import { validators, waitForProvider, } from '@eth-optimism/common-ts' -import { CrossChainMessenger } from '@eth-optimism/sdk' +import { getOEContract, DEFAULT_L2_CONTRACT_ADDRESSES } from '@eth-optimism/sdk' import { getChainId, sleep } from '@eth-optimism/core-utils' import { Provider } from '@ethersproject/abstract-provider' -import { Event } from 'ethers' +import { ethers } from 'ethers' import dateformat from 'dateformat' import { version } from '../../package.json' -import { getLastFinalizedBlock as getLastFinalizedBlock } from './helpers' +import { DEFAULT_STARTING_BLOCK_NUMBERS } from './constants' type Options = { l1RpcProvider: Provider l2RpcProvider: Provider + optimismPortal: string + l2ToL1MessagePasser: string startBlockNumber: number + eventBlockRange: number sleepTimeMs: number } type Metrics = { + highestBlockNumber: Gauge withdrawalsValidated: Gauge isDetectingForgeries: Gauge nodeConnectionFailures: Gauge } type State = { - messenger: CrossChainMessenger + portal: ethers.Contract + messenger: ethers.Contract highestUncheckedBlockNumber: number faultProofWindow: number forgeryDetected: boolean @@ -54,12 +59,30 @@ export class WithdrawalMonitor extends BaseServiceV2 { validator: validators.provider, desc: 'Provider for interacting with L2', }, + optimismPortal: { + validator: validators.address, + default: null, + desc: 'Address of the OptimismPortal proxy contract on L1', + public: true, + }, + l2ToL1MessagePasser: { + validator: validators.address, + default: DEFAULT_L2_CONTRACT_ADDRESSES.BedrockMessagePasser as string, + desc: 'Address of the L2ToL1MessagePasser contract on L2', + public: true, + }, startBlockNumber: { validator: validators.num, default: -1, desc: 'L1 block number to start checking from', public: true, }, + eventBlockRange: { + validator: validators.num, + default: 2000, + desc: 'Number of blocks to query for events over per loop', + public: true, + }, sleepTimeMs: { validator: validators.num, default: 15000, @@ -68,6 +91,11 @@ export class WithdrawalMonitor extends BaseServiceV2 { }, }, metricsSpec: { + highestBlockNumber: { + type: Gauge, + desc: 'Highest block number (checked and known)', + labels: ['type'], + }, withdrawalsValidated: { type: Gauge, desc: 'Latest L1 Block (checked and known)', @@ -99,38 +127,41 @@ export class WithdrawalMonitor extends BaseServiceV2 { name: 'L2', }) - this.state.messenger = new CrossChainMessenger({ - l1SignerOrProvider: this.options.l1RpcProvider, - l2SignerOrProvider: this.options.l2RpcProvider, - l1ChainId: await getChainId(this.options.l1RpcProvider), - l2ChainId: await getChainId(this.options.l2RpcProvider), - bedrock: true, - }) + // Need L2 chain ID to resolve contract addresses. + const l2ChainId = await getChainId(this.options.l2RpcProvider) - // Not detected by default. - this.state.forgeryDetected = false + // Create the OptimismPortal contract instance. If the optimismPortal option is not provided + // then the SDK will attempt to resolve the address automatically based on the L2 chain ID. If + // the SDK isn't aware of the L2 chain ID then it will throw an error that makes it clear the + // user needs to provide this value explicitly. + this.state.portal = getOEContract('OptimismPortal', l2ChainId, { + signerOrProvider: this.options.l1RpcProvider, + address: this.options.optimismPortal, + }) - this.state.faultProofWindow = - await this.state.messenger.getChallengePeriodSeconds() - this.logger.info( - `fault proof window is ${this.state.faultProofWindow} seconds` - ) + // Create the L2ToL1MessagePasser contract instance. If the l2ToL1MessagePasser option is not + // provided then we'll use the default address which typically should be correct. It's very + // unlikely that any user would change this address so this should work in 99% of cases. If we + // really wanted to be extra safe we could do some sanity checks to make sure the contract has + // the interface we need but doesn't seem important for now. + this.state.messenger = getOEContract('L2ToL1MessagePasser', l2ChainId, { + signerOrProvider: this.options.l2RpcProvider, + address: this.options.l2ToL1MessagePasser, + }) - // Set the start block number. + // Previous versions of wd-mon would try to pick the starting block number automatically but + // this had the possibility of missing certain withdrawals if the service was restarted at the + // wrong time. Given the added complexity of finding a starting point automatically after FPAC, + // it's much easier to simply start a fixed block number than trying to do something fancy. Use + // the default configured in this service or use zero if no default is defined. + this.state.highestUncheckedBlockNumber = this.options.startBlockNumber if (this.options.startBlockNumber === -1) { - // We default to starting from the last finalized block. - this.state.highestUncheckedBlockNumber = await getLastFinalizedBlock( - this.options.l1RpcProvider, - this.state.faultProofWindow, - this.logger - ) - } else { - this.state.highestUncheckedBlockNumber = this.options.startBlockNumber + this.state.highestUncheckedBlockNumber = + DEFAULT_STARTING_BLOCK_NUMBERS[l2ChainId] || 0 } - this.logger.info(`starting L1 block height`, { - startBlockNumber: this.state.highestUncheckedBlockNumber, - }) + // Default state is that forgeries have not been detected. + this.state.forgeryDetected = false } // K8s healthcheck @@ -143,94 +174,131 @@ export class WithdrawalMonitor extends BaseServiceV2 { } async main(): Promise { - // Get current block number + // Get the latest L1 block number. let latestL1BlockNumber: number try { latestL1BlockNumber = await this.options.l1RpcProvider.getBlockNumber() } catch (err) { + // Log the issue so we can debug it. this.logger.error(`got error when connecting to node`, { error: err, node: 'l1', section: 'getBlockNumber', }) + + // Increment the metric so we can detect the issue. this.metrics.nodeConnectionFailures.inc({ layer: 'l1', section: 'getBlockNumber', }) - await sleep(this.options.sleepTimeMs) - return + + // Sleep for a little to give intermittent errors a chance to recover. + return sleep(this.options.sleepTimeMs) } - // See if we have a new unchecked block + // Update highest block number metrics so we can keep track of how the service is doing. + this.metrics.highestBlockNumber.set({ type: 'known' }, latestL1BlockNumber) + this.metrics.highestBlockNumber.set( + { type: 'checked' }, + this.state.highestUncheckedBlockNumber + ) + + // Check if the RPC provider is behind us for some reason. Can happen occasionally, + // particularly if connected to an RPC provider that load balances over multiple nodes that + // might not be perfectly in sync. if (latestL1BlockNumber <= this.state.highestUncheckedBlockNumber) { - // The RPC provider is behind us, wait a bit - await sleep(this.options.sleepTimeMs) - return + // Sleep for a little to give the RPC a chance to catch up. + return sleep(this.options.sleepTimeMs) } + // Generally better to use a relatively small block range because it means this service can be + // used alongside many different types of L1 nodes. For instance, Geth will typically only + // support a block range of 2000 blocks out of the box. + const toBlockNumber = Math.min( + this.state.highestUncheckedBlockNumber + this.options.eventBlockRange, + latestL1BlockNumber + ) + + // Useful to log this stuff just in case we get stuck or something. this.logger.info(`checking recent blocks`, { fromBlockNumber: this.state.highestUncheckedBlockNumber, - toBlockNumber: latestL1BlockNumber, + toBlockNumber, }) - // Perform the check - let proofEvents: Event[] + // Query for WithdrawalProven events within the specified block range. + let events: ethers.Event[] try { - // The query includes events in the blockNumbers given as the last two arguments - proofEvents = - await this.state.messenger.contracts.l1.OptimismPortal.queryFilter( - this.state.messenger.contracts.l1.OptimismPortal.filters.WithdrawalProven(), - this.state.highestUncheckedBlockNumber, - latestL1BlockNumber - ) + events = await this.state.portal.queryFilter( + this.state.portal.filters.WithdrawalProven(), + this.state.highestUncheckedBlockNumber, + toBlockNumber + ) } catch (err) { + // Log the issue so we can debug it. this.logger.error(`got error when connecting to node`, { error: err, node: 'l1', section: 'querying for WithdrawalProven events', }) + + // Increment the metric so we can detect the issue. this.metrics.nodeConnectionFailures.inc({ layer: 'l1', section: 'querying for WithdrawalProven events', }) - // connection error, wait then restart - await sleep(this.options.sleepTimeMs) - return + + // Sleep for a little to give intermittent errors a chance to recover. + return sleep(this.options.sleepTimeMs) } - for (const proofEvent of proofEvents) { - const exists = - await this.state.messenger.contracts.l2.BedrockMessagePasser.sentMessages( - proofEvent.args.withdrawalHash - ) - const block = await proofEvent.getBlock() - const now = new Date(block.timestamp * 1000) - const dateString = dateformat( - now, - 'mmmm dS, yyyy, h:MM:ss TT', - true // use UTC time - ) - const provenAt = `${dateString} UTC` + // Go over all the events and check if the withdrawal hash actually exists on L2. + for (const event of events) { + // Could consider using multicall here but this is efficient enough for now. + const hash = event.args.withdrawalHash + const exists = await this.state.messenger.sentMessages(hash) + + // Hopefully the withdrawal exists! if (exists) { - this.metrics.withdrawalsValidated.inc() + // Unlike below we don't grab the timestamp here because it adds an unnecessary request. this.logger.info(`valid withdrawal`, { - withdrawalHash: proofEvent.args.withdrawalHash, - provenAt, + withdrawalHash: event.args.withdrawalHash, }) + + // Bump the withdrawals metric so we can keep track. + this.metrics.withdrawalsValidated.inc() } else { + // Grab and format the timestamp so it's clear how much time is left. + const block = await event.getBlock() + const ts = `${dateformat( + new Date(block.timestamp * 1000), + 'mmmm dS, yyyy, h:MM:ss TT', + true + )} UTC` + + // Uh oh! this.logger.error(`withdrawalHash not seen on L2`, { - withdrawalHash: proofEvent.args.withdrawalHash, - provenAt, + withdrawalHash: event.args.withdrawalHash, + provenAt: ts, }) + + // Change to forgery state. this.state.forgeryDetected = true this.metrics.isDetectingForgeries.set(1) - return + + // Return early so that we never increment the highest unchecked block number and therefore + // will continue to loop on this forgery indefinitely. We probably want to change this + // behavior at some point so that we keep scanning for additional forgeries since the + // existence of one forgery likely implies the existence of many others. + return sleep(this.options.sleepTimeMs) } } - this.state.highestUncheckedBlockNumber = latestL1BlockNumber + 1 + // Increment the highest unchecked block number for the next loop. + this.state.highestUncheckedBlockNumber = toBlockNumber - // If we got through the above without throwing an error, we should be fine to reset. + // If we got through the above without throwing an error, we should be fine to reset. Only case + // where this is relevant is if something is detected as a forgery accidentally and the error + // doesn't happen again on the next loop. this.state.forgeryDetected = false this.metrics.isDetectingForgeries.set(0) } diff --git a/packages/common-ts/src/base-service/validators.ts b/packages/common-ts/src/base-service/validators.ts index a919e700ac522..0de79259a1b55 100644 --- a/packages/common-ts/src/base-service/validators.ts +++ b/packages/common-ts/src/base-service/validators.ts @@ -49,6 +49,14 @@ const logLevel = makeValidator((input) => { } }) +const address = makeValidator((input) => { + if (!ethers.utils.isHexString(input, 20)) { + throw new Error(`expected input to be an address: ${input}`) + } else { + return input as string + } +}) + export const validators = { str, bool, @@ -63,4 +71,5 @@ export const validators = { jsonRpcProvider, staticJsonRpcProvider, logLevel, + address, }