Commit b004d1ad authored by Kelvin Fichter's avatar Kelvin Fichter

feat(fd): support Bedrock networks

Updates the fault detector to support Bedrock networks. Bedrock support
is slightly hacky because we still also need to support the legacy
system until mainnet has been upgraded to Bedrock. We will want to
update the fault detector again once mainnet is upgraded to simplify the
service and remove the legacy components.
parent bb4b84c0
---
'@eth-optimism/fault-detector': minor
---
Updates the fault detector to support Bedrock networks.
ignores: [
"@babel/eslint-parser",
"@types/level",
"@typescript-eslint/parser",
"eslint-plugin-import",
"eslint-plugin-unicorn",
......
......@@ -52,7 +52,7 @@
"ethers": "^5.7.0",
"express": "^4.17.1",
"express-prom-bundle": "^6.3.6",
"level": "^6.0.1",
"level6": "npm:level@^6.0.1",
"levelup": "^4.4.0"
},
"devDependencies": {
......
/* Imports: External */
import { BaseService, LegacyMetrics } from '@eth-optimism/common-ts'
import { LevelUp } from 'levelup'
import level from 'level'
import level from 'level6'
import { Counter } from 'prom-client'
/* Imports: Internal */
......
import { Contract } from 'ethers'
import { Contract, BigNumber } from 'ethers'
export interface OutputOracle<TSubmissionEventArgs> {
contract: Contract
filter: any
getTotalElements: () => Promise<BigNumber>
getEventIndex: (args: TSubmissionEventArgs) => BigNumber
}
/**
* Partial event interface, meant to reduce the size of the event cache to avoid
......@@ -41,27 +48,32 @@ const getCache = (
}
/**
* Updates the event cache for the SCC.
* Updates the event cache for a contract and event.
*
* @param scc The State Commitment Chain contract.
* @param contract Contract to update cache for.
* @param filter Event filter to use.
*/
export const updateStateBatchEventCache = async (
scc: Contract
export const updateOracleCache = async <TSubmissionEventArgs>(
oracle: OutputOracle<TSubmissionEventArgs>
): Promise<void> => {
const cache = getCache(scc.address)
const cache = getCache(oracle.contract.address)
let currentBlock = cache.highestBlock
const endingBlock = await scc.provider.getBlockNumber()
const endingBlock = await oracle.contract.provider.getBlockNumber()
let step = endingBlock - currentBlock
let failures = 0
while (currentBlock < endingBlock) {
try {
const events = await scc.queryFilter(
scc.filters.StateBatchAppended(),
const events = await oracle.contract.queryFilter(
oracle.filter,
currentBlock,
currentBlock + step
)
// Throw the events into the cache.
for (const event of events) {
cache.eventCache[event.args._batchIndex.toNumber()] = {
cache.eventCache[
oracle.getEventIndex(event.args as TSubmissionEventArgs).toNumber()
] = {
blockNumber: event.blockNumber,
transactionHash: event.transactionHash,
args: event.args,
......@@ -97,15 +109,15 @@ export const updateStateBatchEventCache = async (
/**
* Finds the Event that corresponds to a given state batch by index.
*
* @param scc StateCommitmentChain contract.
* @param oracle Output oracle contract
* @param index State batch index to search for.
* @returns Event corresponding to the batch.
*/
export const findEventForStateBatch = async (
scc: Contract,
export const findEventForStateBatch = async <TSubmissionEventArgs>(
oracle: OutputOracle<TSubmissionEventArgs>,
index: number
): Promise<PartialEvent> => {
const cache = getCache(scc.address)
const cache = getCache(oracle.contract.address)
// Try to find the event in cache first.
if (cache.eventCache[index]) {
......@@ -113,7 +125,7 @@ export const findEventForStateBatch = async (
}
// Update the event cache if we don't have the event.
await updateStateBatchEventCache(scc)
await updateOracleCache(oracle)
// Event better be in cache now!
if (cache.eventCache[index] === undefined) {
......@@ -126,23 +138,23 @@ export const findEventForStateBatch = async (
/**
* Finds the first state batch index that has not yet passed the fault proof window.
*
* @param scc StateCommitmentChain contract.
* @param oracle Output oracle contract.
* @returns Starting state root batch index.
*/
export const findFirstUnfinalizedStateBatchIndex = async (
scc: Contract
export const findFirstUnfinalizedStateBatchIndex = async <TSubmissionEventArgs>(
oracle: OutputOracle<TSubmissionEventArgs>,
fpw: number
): Promise<number> => {
const fpw = (await scc.FRAUD_PROOF_WINDOW()).toNumber()
const latestBlock = await scc.provider.getBlock('latest')
const totalBatches = (await scc.getTotalBatches()).toNumber()
const latestBlock = await oracle.contract.provider.getBlock('latest')
const totalBatches = (await oracle.getTotalElements()).toNumber()
// Perform a binary search to find the next batch that will pass the challenge period.
let lo = 0
let hi = totalBatches
while (lo !== hi) {
const mid = Math.floor((lo + hi) / 2)
const event = await findEventForStateBatch(scc, mid)
const block = await scc.provider.getBlock(event.blockNumber)
const event = await findEventForStateBatch(oracle, mid)
const block = await oracle.contract.provider.getBlock(event.blockNumber)
if (block.timestamp + fpw < latestBlock.timestamp) {
lo = mid + 1
......
......@@ -9,21 +9,23 @@ import {
import { getChainId, sleep, toRpcHexString } from '@eth-optimism/core-utils'
import { CrossChainMessenger } from '@eth-optimism/sdk'
import { Provider } from '@ethersproject/abstract-provider'
import { Contract, ethers, Transaction } from 'ethers'
import { ethers, Transaction } from 'ethers'
import dateformat from 'dateformat'
import { version } from '../package.json'
import {
findFirstUnfinalizedStateBatchIndex,
findEventForStateBatch,
updateStateBatchEventCache,
PartialEvent,
OutputOracle,
updateOracleCache,
} from './helpers'
type Options = {
l1RpcProvider: Provider
l2RpcProvider: Provider
startBatchIndex: number
bedrock: boolean
}
type Metrics = {
......@@ -34,7 +36,7 @@ type Metrics = {
type State = {
fpw: number
scc: Contract
oo: OutputOracle<any>
messenger: CrossChainMessenger
highestCheckedBatchIndex: number
diverged: boolean
......@@ -65,6 +67,12 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
desc: 'Batch index to start checking from',
public: true,
},
bedrock: {
validator: validators.bool,
default: false,
desc: 'Whether or not the service is running against a Bedrock chain',
public: true,
},
},
metricsSpec: {
highestBatchIndex: {
......@@ -103,24 +111,42 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
l2SignerOrProvider: this.options.l2RpcProvider,
l1ChainId: await getChainId(this.options.l1RpcProvider),
l2ChainId: await getChainId(this.options.l2RpcProvider),
bedrock: this.options.bedrock,
})
// Not diverged by default.
this.state.diverged = false
// We use this a lot, a bit cleaner to pull out to the top level of the state object.
this.state.scc = this.state.messenger.contracts.l1.StateCommitmentChain
this.state.fpw = (await this.state.scc.FRAUD_PROOF_WINDOW()).toNumber()
this.state.fpw = await this.state.messenger.getChallengePeriodSeconds()
if (this.options.bedrock) {
const oo = this.state.messenger.contracts.l1.L2OutputOracle
this.state.oo = {
contract: oo,
filter: oo.filters.OutputProposed(),
getTotalElements: async () => oo.latestOutputIndex(),
getEventIndex: (args) => args.l2OutputIndex,
}
} else {
const oo = this.state.messenger.contracts.l1.StateCommitmentChain
this.state.oo = {
contract: oo,
filter: oo.filters.StateBatchAppended(),
getTotalElements: async () => oo.getTotalBatches(),
getEventIndex: (args) => args._batchIndex,
}
}
// Populate the event cache.
this.logger.info(`warming event cache, this might take a while...`)
await updateStateBatchEventCache(this.state.scc)
await updateOracleCache(this.state.oo)
// Figure out where to start syncing from.
if (this.options.startBatchIndex === -1) {
this.logger.info(`finding appropriate starting height`)
const firstUnfinalized = await findFirstUnfinalizedStateBatchIndex(
this.state.scc
this.state.oo,
this.state.fpw
)
// We may not have an unfinalized batches in the case where no batches have been submitted
......@@ -129,7 +155,7 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
if (firstUnfinalized === undefined) {
this.logger.info(`no unfinalized batches found, starting from latest`)
this.state.highestCheckedBatchIndex = (
await this.state.scc.getTotalBatches()
await this.state.oo.getTotalElements()
).toNumber()
} else {
this.state.highestCheckedBatchIndex = firstUnfinalized
......@@ -141,6 +167,14 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
this.logger.info(`starting height`, {
startBatchIndex: this.state.highestCheckedBatchIndex,
})
// Set the initial metrics.
this.metrics.highestBatchIndex.set(
{
type: 'checked',
},
this.state.highestCheckedBatchIndex
)
}
async routes(router: ExpressRouter): Promise<void> {
......@@ -154,7 +188,7 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
async main(): Promise<void> {
let latestBatchIndex: number
try {
latestBatchIndex = (await this.state.scc.getTotalBatches()).toNumber()
latestBatchIndex = (await this.state.oo.getTotalElements()).toNumber()
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
......@@ -189,7 +223,7 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
let event: PartialEvent
try {
event = await findEventForStateBatch(
this.state.scc,
this.state.oo,
this.state.highestCheckedBatchIndex
)
} catch (err) {
......@@ -206,34 +240,6 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
return
}
let batchTransaction: Transaction
try {
batchTransaction = await this.options.l1RpcProvider.getTransaction(
event.transactionHash
)
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
node: 'l1',
section: 'getTransaction',
})
this.metrics.nodeConnectionFailures.inc({
layer: 'l1',
section: 'getTransaction',
})
await sleep(15000)
return
}
const [stateRoots] = this.state.scc.interface.decodeFunctionData(
'appendStateBatch',
batchTransaction.data
)
const batchStart = event.args._prevTotalElements.toNumber() + 1
const batchSize = event.args._batchSize.toNumber()
const batchEnd = batchStart + batchSize
let latestBlock: number
try {
latestBlock = await this.options.l2RpcProvider.getBlockNumber()
......@@ -251,55 +257,80 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
return
}
if (latestBlock < batchEnd) {
this.logger.info(`node is behind, waiting for sync`, {
batchEnd,
latestBlock,
})
return
}
if (this.options.bedrock) {
if (latestBlock < event.args.l2BlockNumber.toNumber()) {
this.logger.info(`node is behind, waiting for sync`, {
batchEnd: event.args.l2BlockNumber.toNumber(),
latestBlock,
})
return
}
// `getBlockRange` has a limit of 1000 blocks, so we have to break this request out into
// multiple requests of maximum 1000 blocks in the case that batchSize > 1000.
let blocks: any[] = []
for (let i = 0; i < batchSize; i += 1000) {
let newBlocks: any[]
let targetBlock: any
try {
newBlocks = await (
targetBlock = await (
this.options.l2RpcProvider as ethers.providers.JsonRpcProvider
).send('eth_getBlockRange', [
toRpcHexString(batchStart + i),
toRpcHexString(batchStart + i + Math.min(batchSize - i, 1000) - 1),
).send('eth_getBlockByNumber', [
toRpcHexString(event.args.l2BlockNumber.toNumber()),
false,
])
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
node: 'l2',
section: 'getBlockRange',
section: 'getBlock',
})
this.metrics.nodeConnectionFailures.inc({
layer: 'l2',
section: 'getBlockRange',
section: 'getBlock',
})
await sleep(15000)
return
}
blocks = blocks.concat(newBlocks)
}
let messagePasserProofResponse: any
try {
messagePasserProofResponse = await (
this.options.l2RpcProvider as ethers.providers.JsonRpcProvider
).send('eth_getProof', [
this.state.messenger.contracts.l2.BedrockMessagePasser.address,
[],
toRpcHexString(event.args.l2BlockNumber.toNumber()),
])
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
node: 'l2',
section: 'getProof',
})
this.metrics.nodeConnectionFailures.inc({
layer: 'l2',
section: 'getProof',
})
await sleep(15000)
return
}
const outputRoot = ethers.utils.solidityKeccak256(
['uint256', 'bytes32', 'bytes32', 'bytes32'],
[
0,
targetBlock.stateRoot,
messagePasserProofResponse.storageHash,
targetBlock.hash,
]
)
for (const [i, stateRoot] of stateRoots.entries()) {
if (blocks[i].stateRoot !== stateRoot) {
if (outputRoot !== event.args.outputRoot) {
this.state.diverged = true
this.metrics.isCurrentlyMismatched.set(1)
this.logger.error(`state root mismatch`, {
blockNumber: blocks[i].number,
expectedStateRoot: blocks[i].stateRoot,
actualStateRoot: stateRoot,
blockNumber: targetBlock.number,
expectedStateRoot: event.args.outputRoot,
actualStateRoot: outputRoot,
finalizationTime: dateformat(
new Date(
(ethers.BigNumber.from(blocks[i].timestamp).toNumber() +
(ethers.BigNumber.from(targetBlock.timestamp).toNumber() +
this.state.fpw) *
1000
),
......@@ -308,8 +339,99 @@ export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
})
return
}
} else {
let batchTransaction: Transaction
try {
batchTransaction = await this.options.l1RpcProvider.getTransaction(
event.transactionHash
)
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
node: 'l1',
section: 'getTransaction',
})
this.metrics.nodeConnectionFailures.inc({
layer: 'l1',
section: 'getTransaction',
})
await sleep(15000)
return
}
const [stateRoots] = this.state.oo.contract.interface.decodeFunctionData(
'appendStateBatch',
batchTransaction.data
)
const batchStart = event.args._prevTotalElements.toNumber() + 1
const batchSize = event.args._batchSize.toNumber()
const batchEnd = batchStart + batchSize
if (latestBlock < batchEnd) {
this.logger.info(`node is behind, waiting for sync`, {
batchEnd,
latestBlock,
})
return
}
// `getBlockRange` has a limit of 1000 blocks, so we have to break this request out into
// multiple requests of maximum 1000 blocks in the case that batchSize > 1000.
let blocks: any[] = []
for (let i = 0; i < batchSize; i += 1000) {
let newBlocks: any[]
try {
newBlocks = await (
this.options.l2RpcProvider as ethers.providers.JsonRpcProvider
).send('eth_getBlockRange', [
toRpcHexString(batchStart + i),
toRpcHexString(batchStart + i + Math.min(batchSize - i, 1000) - 1),
false,
])
} catch (err) {
this.logger.error(`got error when connecting to node`, {
error: err,
node: 'l2',
section: 'getBlockRange',
})
this.metrics.nodeConnectionFailures.inc({
layer: 'l2',
section: 'getBlockRange',
})
await sleep(15000)
return
}
blocks = blocks.concat(newBlocks)
}
for (const [i, stateRoot] of stateRoots.entries()) {
if (blocks[i].stateRoot !== stateRoot) {
this.state.diverged = true
this.metrics.isCurrentlyMismatched.set(1)
this.logger.error(`state root mismatch`, {
blockNumber: blocks[i].number,
expectedStateRoot: blocks[i].stateRoot,
actualStateRoot: stateRoot,
finalizationTime: dateformat(
new Date(
(ethers.BigNumber.from(blocks[i].timestamp).toNumber() +
this.state.fpw) *
1000
),
'mmmm dS, yyyy, h:MM:ss TT'
),
})
return
}
}
}
this.logger.info(`checked batch ok`, {
batchIndex: this.state.highestCheckedBatchIndex,
})
this.state.highestCheckedBatchIndex++
this.metrics.highestBatchIndex.set(
{
......
......@@ -12,6 +12,7 @@ import { expect } from './setup'
import {
findEventForStateBatch,
findFirstUnfinalizedStateBatchIndex,
OutputOracle,
} from '../src'
describe('helpers', () => {
......@@ -28,6 +29,7 @@ describe('helpers', () => {
let AddressManager: Contract
let ChainStorageContainer: Contract
let StateCommitmentChain: Contract
let oracle: OutputOracle<any>
beforeEach(async () => {
// Set up fakes
FakeBondManager = await smock.fake(getContractInterface('BondManager'))
......@@ -67,6 +69,13 @@ describe('helpers', () => {
// Set up mock returns
FakeCanonicalTransactionChain.getTotalElements.returns(1000000000) // just needs to be large
FakeBondManager.isCollateralized.returns(true)
oracle = {
contract: StateCommitmentChain,
filter: StateCommitmentChain.filters.StateBatchAppended(),
getTotalElements: async () => StateCommitmentChain.getTotalBatches(),
getEventIndex: (args: any) => args._batchIndex,
}
})
describe('findEventForStateBatch', () => {
......@@ -79,7 +88,7 @@ describe('helpers', () => {
})
it('should return the event', async () => {
const event = await findEventForStateBatch(StateCommitmentChain, 0)
const event = await findEventForStateBatch(oracle, 0)
expect(event.args._batchIndex).to.equal(0)
})
......@@ -88,7 +97,7 @@ describe('helpers', () => {
describe('when the event does not exist', () => {
it('should throw an error', async () => {
await expect(
findEventForStateBatch(StateCommitmentChain, 0)
findEventForStateBatch(oracle, 0)
).to.eventually.be.rejectedWith('unable to find event for batch')
})
})
......@@ -119,7 +128,8 @@ describe('helpers', () => {
it('should find the first batch older than the FPW', async () => {
const first = await findFirstUnfinalizedStateBatchIndex(
StateCommitmentChain
oracle,
challengeWindowSeconds
)
expect(first).to.equal(1)
......@@ -144,7 +154,8 @@ describe('helpers', () => {
it('should return zero', async () => {
const first = await findFirstUnfinalizedStateBatchIndex(
StateCommitmentChain
oracle,
challengeWindowSeconds
)
expect(first).to.equal(0)
......@@ -177,7 +188,8 @@ describe('helpers', () => {
it('should return undefined', async () => {
const first = await findFirstUnfinalizedStateBatchIndex(
StateCommitmentChain
oracle,
challengeWindowSeconds
)
expect(first).to.equal(undefined)
......
......@@ -12090,7 +12090,7 @@ level-ws@^2.0.0:
readable-stream "^3.1.0"
xtend "^4.0.1"
level@^6.0.1:
"level6@npm:level@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/level/-/level-6.0.1.tgz#dc34c5edb81846a6de5079eac15706334b0d7cd6"
integrity sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==
......
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