Skip to content

Commit

Permalink
feat: update wd-mon to work for OptimismPortal2
Browse files Browse the repository at this point in the history
  • Loading branch information
smartcontracts committed Feb 4, 2024
1 parent 88586b7 commit f90cc64
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 112 deletions.
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.
19 changes: 19 additions & 0 deletions packages/chain-mon/src/wd-mon/constants.ts
Original file line number Diff line number Diff line change
@@ -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,
}
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
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
Expand All @@ -54,12 +59,30 @@ export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
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,
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.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
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

0 comments on commit f90cc64

Please sign in to comment.