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 {
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
optimismPortalAddress: string
l2ToL1MessagePasserAddress: 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<Options, Metrics, State> {
validator: validators.provider,
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: {
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<Options, Metrics, State> {
},
},
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<Options, Metrics, State> {
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.optimismPortalAddress,
})
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.l2ToL1MessagePasserAddress,
})
// Set the start block number.
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 {
// 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) {
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<Options, Metrics, State> {
}
async main(): Promise<void> {
// 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(),
events = await this.state.portal.queryFilter(
this.state.portal.filters.WithdrawalProven(),
this.state.highestUncheckedBlockNumber,
latestL1BlockNumber
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)
}
......
......@@ -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 = {
str,
bool,
......@@ -63,4 +71,5 @@ export const validators = {
jsonRpcProvider,
staticJsonRpcProvider,
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