Commit 1ed50c44 authored by smartcontracts's avatar smartcontracts Committed by GitHub

feat: update wd-mon to work for OptimismPortal2 (#9334)

parent 31845fd2
---
'@eth-optimism/common-ts': patch
---
Adds a new validator for address types.
---
'@eth-optimism/chain-mon': minor
---
Updates wd-mon inside chain-mon to support FPAC.
import { L2ChainID } from '@eth-optimism/sdk'
// TODO: Consider moving to `@eth-optimism/constants` and generating from superchain registry.
// @see https://github.com/ethereum-optimism/optimism/pull/9041
/**
* 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]: 8299684 as const,
[L2ChainID.OPTIMISM_SEPOLIA]: 4071248 as const,
[L2ChainID.BASE_MAINNET]: 17482143 as const,
[L2ChainID.BASE_GOERLI]: 8411116 as const,
[L2ChainID.BASE_SEPOLIA]: 4370901 as const,
[L2ChainID.ZORA_MAINNET]: 17473938 as const,
}
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<number> => {
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'}`
)
}
}
...@@ -6,30 +6,35 @@ import { ...@@ -6,30 +6,35 @@ import {
validators, validators,
waitForProvider, waitForProvider,
} from '@eth-optimism/common-ts' } 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 { getChainId, sleep } from '@eth-optimism/core-utils'
import { Provider } from '@ethersproject/abstract-provider' import { Provider } from '@ethersproject/abstract-provider'
import { Event } from 'ethers' import { ethers } from 'ethers'
import dateformat from 'dateformat' import dateformat from 'dateformat'
import { version } from '../../package.json' import { version } from '../../package.json'
import { getLastFinalizedBlock as getLastFinalizedBlock } from './helpers' import { DEFAULT_STARTING_BLOCK_NUMBERS } from './constants'
type Options = { type Options = {
l1RpcProvider: Provider l1RpcProvider: Provider
l2RpcProvider: Provider l2RpcProvider: Provider
optimismPortalAddress: string
l2ToL1MessagePasserAddress: string
startBlockNumber: number startBlockNumber: number
eventBlockRange: number
sleepTimeMs: number sleepTimeMs: number
} }
type Metrics = { type Metrics = {
highestBlockNumber: Gauge
withdrawalsValidated: Gauge withdrawalsValidated: Gauge
isDetectingForgeries: Gauge isDetectingForgeries: Gauge
nodeConnectionFailures: Gauge nodeConnectionFailures: Gauge
} }
type State = { type State = {
messenger: CrossChainMessenger portal: ethers.Contract
messenger: ethers.Contract
highestUncheckedBlockNumber: number highestUncheckedBlockNumber: number
faultProofWindow: number faultProofWindow: number
forgeryDetected: boolean forgeryDetected: boolean
...@@ -54,12 +59,30 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> { ...@@ -54,12 +59,30 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
validator: validators.provider, validator: validators.provider,
desc: 'Provider for interacting with L2', desc: 'Provider for interacting with L2',
}, },
optimismPortalAddress: {
validator: validators.address,
default: null,
desc: 'Address of the OptimismPortal proxy contract on L1',
public: true,
},
l2ToL1MessagePasserAddress: {
validator: validators.address,
default: DEFAULT_L2_CONTRACT_ADDRESSES.BedrockMessagePasser as string,
desc: 'Address of the L2ToL1MessagePasser contract on L2',
public: true,
},
startBlockNumber: { startBlockNumber: {
validator: validators.num, validator: validators.num,
default: -1, default: -1,
desc: 'L1 block number to start checking from', desc: 'L1 block number to start checking from',
public: true, public: true,
}, },
eventBlockRange: {
validator: validators.num,
default: 2000,
desc: 'Number of blocks to query for events over per loop',
public: true,
},
sleepTimeMs: { sleepTimeMs: {
validator: validators.num, validator: validators.num,
default: 15000, default: 15000,
...@@ -68,6 +91,11 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> { ...@@ -68,6 +91,11 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
}, },
}, },
metricsSpec: { metricsSpec: {
highestBlockNumber: {
type: Gauge,
desc: 'Highest block number (checked and known)',
labels: ['type'],
},
withdrawalsValidated: { withdrawalsValidated: {
type: Gauge, type: Gauge,
desc: 'Latest L1 Block (checked and known)', desc: 'Latest L1 Block (checked and known)',
...@@ -99,38 +127,41 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> { ...@@ -99,38 +127,41 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
name: 'L2', name: 'L2',
}) })
this.state.messenger = new CrossChainMessenger({ // Need L2 chain ID to resolve contract addresses.
l1SignerOrProvider: this.options.l1RpcProvider, const l2ChainId = await getChainId(this.options.l2RpcProvider)
l2SignerOrProvider: this.options.l2RpcProvider,
l1ChainId: await getChainId(this.options.l1RpcProvider),
l2ChainId: await getChainId(this.options.l2RpcProvider),
bedrock: true,
})
// Not detected by default. // Create the OptimismPortal contract instance. If the optimismPortal option is not provided
this.state.forgeryDetected = false // 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.optimismPortalAddress,
})
this.state.faultProofWindow = // Create the L2ToL1MessagePasser contract instance. If the l2ToL1MessagePasser option is not
await this.state.messenger.getChallengePeriodSeconds() // provided then we'll use the default address which typically should be correct. It's very
this.logger.info( // unlikely that any user would change this address so this should work in 99% of cases. If we
`fault proof window is ${this.state.faultProofWindow} seconds` // 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.l2ToL1MessagePasserAddress,
})
// Set the start block number. // Previous versions of wd-mon would try to pick the starting block number automatically but
if (this.options.startBlockNumber === -1) { // this had the possibility of missing certain withdrawals if the service was restarted at the
// We default to starting from the last finalized block. // wrong time. Given the added complexity of finding a starting point automatically after FPAC,
this.state.highestUncheckedBlockNumber = await getLastFinalizedBlock( // it's much easier to simply start a fixed block number than trying to do something fancy. Use
this.options.l1RpcProvider, // the default configured in this service or use zero if no default is defined.
this.state.faultProofWindow,
this.logger
)
} else {
this.state.highestUncheckedBlockNumber = this.options.startBlockNumber this.state.highestUncheckedBlockNumber = this.options.startBlockNumber
if (this.options.startBlockNumber === -1) {
this.state.highestUncheckedBlockNumber =
DEFAULT_STARTING_BLOCK_NUMBERS[l2ChainId] || 0
} }
this.logger.info(`starting L1 block height`, { // Default state is that forgeries have not been detected.
startBlockNumber: this.state.highestUncheckedBlockNumber, this.state.forgeryDetected = false
})
} }
// K8s healthcheck // K8s healthcheck
...@@ -143,94 +174,131 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> { ...@@ -143,94 +174,131 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
} }
async main(): Promise<void> { async main(): Promise<void> {
// Get current block number // Get the latest L1 block number.
let latestL1BlockNumber: number let latestL1BlockNumber: number
try { try {
latestL1BlockNumber = await this.options.l1RpcProvider.getBlockNumber() latestL1BlockNumber = await this.options.l1RpcProvider.getBlockNumber()
} catch (err) { } catch (err) {
// Log the issue so we can debug it.
this.logger.error(`got error when connecting to node`, { this.logger.error(`got error when connecting to node`, {
error: err, error: err,
node: 'l1', node: 'l1',
section: 'getBlockNumber', section: 'getBlockNumber',
}) })
// Increment the metric so we can detect the issue.
this.metrics.nodeConnectionFailures.inc({ this.metrics.nodeConnectionFailures.inc({
layer: 'l1', layer: 'l1',
section: 'getBlockNumber', 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) { if (latestL1BlockNumber <= this.state.highestUncheckedBlockNumber) {
// The RPC provider is behind us, wait a bit // Sleep for a little to give the RPC a chance to catch up.
await sleep(this.options.sleepTimeMs) return sleep(this.options.sleepTimeMs)
return
} }
// 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`, { this.logger.info(`checking recent blocks`, {
fromBlockNumber: this.state.highestUncheckedBlockNumber, fromBlockNumber: this.state.highestUncheckedBlockNumber,
toBlockNumber: latestL1BlockNumber, toBlockNumber,
}) })
// Perform the check // Query for WithdrawalProven events within the specified block range.
let proofEvents: Event[] let events: ethers.Event[]
try { try {
// The query includes events in the blockNumbers given as the last two arguments events = await this.state.portal.queryFilter(
proofEvents = this.state.portal.filters.WithdrawalProven(),
await this.state.messenger.contracts.l1.OptimismPortal.queryFilter(
this.state.messenger.contracts.l1.OptimismPortal.filters.WithdrawalProven(),
this.state.highestUncheckedBlockNumber, this.state.highestUncheckedBlockNumber,
latestL1BlockNumber toBlockNumber
) )
} catch (err) { } catch (err) {
// Log the issue so we can debug it.
this.logger.error(`got error when connecting to node`, { this.logger.error(`got error when connecting to node`, {
error: err, error: err,
node: 'l1', node: 'l1',
section: 'querying for WithdrawalProven events', section: 'querying for WithdrawalProven events',
}) })
// Increment the metric so we can detect the issue.
this.metrics.nodeConnectionFailures.inc({ this.metrics.nodeConnectionFailures.inc({
layer: 'l1', layer: 'l1',
section: 'querying for WithdrawalProven events', section: 'querying for WithdrawalProven events',
}) })
// connection error, wait then restart
await sleep(this.options.sleepTimeMs) // Sleep for a little to give intermittent errors a chance to recover.
return return sleep(this.options.sleepTimeMs)
} }
for (const proofEvent of proofEvents) { // Go over all the events and check if the withdrawal hash actually exists on L2.
const exists = for (const event of events) {
await this.state.messenger.contracts.l2.BedrockMessagePasser.sentMessages( // Could consider using multicall here but this is efficient enough for now.
proofEvent.args.withdrawalHash const hash = event.args.withdrawalHash
) const exists = await this.state.messenger.sentMessages(hash)
const block = await proofEvent.getBlock()
const now = new Date(block.timestamp * 1000) // Hopefully the withdrawal exists!
const dateString = dateformat(
now,
'mmmm dS, yyyy, h:MM:ss TT',
true // use UTC time
)
const provenAt = `${dateString} UTC`
if (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`, { this.logger.info(`valid withdrawal`, {
withdrawalHash: proofEvent.args.withdrawalHash, withdrawalHash: event.args.withdrawalHash,
provenAt,
}) })
// Bump the withdrawals metric so we can keep track.
this.metrics.withdrawalsValidated.inc()
} else { } 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`, { this.logger.error(`withdrawalHash not seen on L2`, {
withdrawalHash: proofEvent.args.withdrawalHash, withdrawalHash: event.args.withdrawalHash,
provenAt, provenAt: ts,
}) })
// Change to forgery state.
this.state.forgeryDetected = true this.state.forgeryDetected = true
this.metrics.isDetectingForgeries.set(1) 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.state.forgeryDetected = false
this.metrics.isDetectingForgeries.set(0) this.metrics.isDetectingForgeries.set(0)
} }
......
...@@ -49,6 +49,14 @@ const logLevel = makeValidator<LogLevel>((input) => { ...@@ -49,6 +49,14 @@ const logLevel = makeValidator<LogLevel>((input) => {
} }
}) })
const address = makeValidator<string>((input) => {
if (!ethers.utils.isHexString(input, 20)) {
throw new Error(`expected input to be an address: ${input}`)
} else {
return input as `0x${string}`
}
})
export const validators = { export const validators = {
str, str,
bool, bool,
...@@ -63,4 +71,5 @@ export const validators = { ...@@ -63,4 +71,5 @@ export const validators = {
jsonRpcProvider, jsonRpcProvider,
staticJsonRpcProvider, staticJsonRpcProvider,
logLevel, logLevel,
address,
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment