Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-bobcats-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@eth-optimism/common-ts': patch
---

Adds a new validator for address types.
5 changes: 5 additions & 0 deletions .changeset/ninety-pugs-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@eth-optimism/chain-mon': minor
---

Updates wd-mon inside chain-mon to support FPAC.
21 changes: 21 additions & 0 deletions packages/chain-mon/src/wd-mon/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { L2ChainID } from '@eth-optimism/sdk'

smartcontracts marked this conversation as resolved.
Show resolved Hide resolved
// TODO: Consider moving to `@eth-optimism/constants` and generating from superchain registry.
smartcontracts marked this conversation as resolved.
Show resolved Hide resolved
// @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,
}
41 changes: 0 additions & 41 deletions packages/chain-mon/src/wd-mon/helpers.ts

This file was deleted.

210 changes: 139 additions & 71 deletions packages/chain-mon/src/wd-mon/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)',
Expand Down Expand Up @@ -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.
// 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
Expand All @@ -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(),
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)
}
Expand Down
Loading
Loading