service.ts 12.6 KB
Newer Older
1 2
import {
  BaseServiceV2,
3
  StandardOptions,
4 5 6
  ExpressRouter,
  Gauge,
  validators,
7
  waitForProvider,
8
} from '@eth-optimism/common-ts'
Mark Tyneway's avatar
Mark Tyneway committed
9 10 11 12 13 14
import {
  BedrockOutputData,
  getChainId,
  sleep,
  toRpcHexString,
} from '@eth-optimism/core-utils'
Will Cory's avatar
Will Cory committed
15
import { config } from 'dotenv'
Hamdi Allam's avatar
Hamdi Allam committed
16 17
import {
  CONTRACT_ADDRESSES,
Hamdi Allam's avatar
Hamdi Allam committed
18
  CrossChainMessenger,
Hamdi Allam's avatar
Hamdi Allam committed
19 20 21 22
  getOEContract,
  L2ChainID,
  OEL1ContractsLike,
} from '@eth-optimism/sdk'
23
import { Provider } from '@ethersproject/abstract-provider'
24
import { Contract, ethers } from 'ethers'
25 26
import dateformat from 'dateformat'

27
import { version } from '../package.json'
28 29
import {
  findFirstUnfinalizedStateBatchIndex,
30
  findOutputForIndex,
31 32
} from './helpers'

33 34 35 36
type Options = {
  l1RpcProvider: Provider
  l2RpcProvider: Provider
  startBatchIndex: number
Hamdi Allam's avatar
Hamdi Allam committed
37
  optimismPortalAddress?: string
38 39 40
}

type Metrics = {
41
  highestBatchIndex: Gauge
42
  isCurrentlyMismatched: Gauge
43
  nodeConnectionFailures: Gauge
44 45 46
}

type State = {
47
  faultProofWindow: number
48
  outputOracle: Contract
49
  messenger: CrossChainMessenger
50
  currentBatchIndex: number
51
  diverged: boolean
52 53 54
}

export class FaultDetector extends BaseServiceV2<Options, Metrics, State> {
55
  constructor(options?: Partial<Options & StandardOptions>) {
56
    super({
57
      version,
58 59
      name: 'fault-detector',
      loop: true,
60 61 62 63
      options: {
        loopIntervalMs: 1000,
        ...options,
      },
64 65 66 67 68 69 70 71 72 73 74
      optionsSpec: {
        l1RpcProvider: {
          validator: validators.provider,
          desc: 'Provider for interacting with L1',
        },
        l2RpcProvider: {
          validator: validators.provider,
          desc: 'Provider for interacting with L2',
        },
        startBatchIndex: {
          validator: validators.num,
75
          default: -1,
76
          desc: 'The L2 height to start from',
77
          public: true,
78
        },
Hamdi Allam's avatar
Hamdi Allam committed
79 80 81
        optimismPortalAddress: {
          validator: validators.str,
          default: ethers.constants.AddressZero,
82
          desc: '[Custom OP Chains] Deployed OptimismPortal contract address. Used to retrieve necessary info for ouput verification ',
83 84
          public: true,
        },
85 86
      },
      metricsSpec: {
87
        highestBatchIndex: {
88
          type: Gauge,
89 90
          desc: 'Highest batch indices (checked and known)',
          labels: ['type'],
91 92 93 94 95
        },
        isCurrentlyMismatched: {
          type: Gauge,
          desc: '0 if state is ok, 1 if state is mismatched',
        },
96
        nodeConnectionFailures: {
97
          type: Gauge,
98 99
          desc: 'Number of times node connection has failed',
          labels: ['layer', 'section'],
100 101 102 103 104
        },
      },
    })
  }

Hamdi Allam's avatar
Hamdi Allam committed
105 106 107 108 109
  /**
   * Provides the required set of addresses used by the fault detector. For recognized op-chains, this
   * will fallback to the pre-defined set of addresses from options, otherwise aborting if unset.
   *
   * Required Contracts
110
   * - OptimismPortal (used to also fetch L2OutputOracle address variable). This is the preferred address
Hamdi Allam's avatar
Hamdi Allam committed
111 112 113 114
   * since in early versions of bedrock, OptimismPortal holds the FINALIZATION_WINDOW variable instead of L2OutputOracle.
   * The retrieved L2OutputOracle address from OptimismPortal is used to query for output roots.
   *
   * @param l2ChainId op chain id
Hamdi Allam's avatar
Hamdi Allam committed
115
   * @returns OEL1ContractsLike set of L1 contracts with only the required addresses set
Hamdi Allam's avatar
Hamdi Allam committed
116
   */
Hamdi Allam's avatar
Hamdi Allam committed
117
  async getOEL1Contracts(l2ChainId: number): Promise<OEL1ContractsLike> {
Hamdi Allam's avatar
Hamdi Allam committed
118
    // CrossChainMessenger requires all address to be defined. Default to `AddressZero` to ignore unused contracts
Hamdi Allam's avatar
Hamdi Allam committed
119
    let contracts: OEL1ContractsLike = {
120 121 122
      OptimismPortal: ethers.constants.AddressZero,
      L2OutputOracle: ethers.constants.AddressZero,
      // Unused contracts
Hamdi Allam's avatar
Hamdi Allam committed
123
      AddressManager: ethers.constants.AddressZero,
124 125
      BondManager: ethers.constants.AddressZero,
      CanonicalTransactionChain: ethers.constants.AddressZero,
Hamdi Allam's avatar
Hamdi Allam committed
126 127 128 129 130
      L1CrossDomainMessenger: ethers.constants.AddressZero,
      L1StandardBridge: ethers.constants.AddressZero,
      StateCommitmentChain: ethers.constants.AddressZero,
    }

Hamdi Allam's avatar
Hamdi Allam committed
131 132 133 134 135 136 137 138 139
    const knownChainId = L2ChainID[l2ChainId] !== undefined
    if (knownChainId) {
      this.logger.info(`Recognized L2 chain id ${L2ChainID[l2ChainId]}`)

      // fallback to the predefined defaults for this chain id
      contracts = CONTRACT_ADDRESSES[l2ChainId].l1
    }

    this.logger.info('checking contract address options...')
140 141 142 143
    const portalAddress = this.options.optimismPortalAddress
    if (!knownChainId && portalAddress === ethers.constants.AddressZero) {
      this.logger.error('OptimismPortal contract unspecified')
      throw new Error(
144
        '--optimismportalcontractaddress needs to set for custom op chains'
145 146
      )
    }
Hamdi Allam's avatar
Hamdi Allam committed
147

148 149 150
    if (portalAddress !== ethers.constants.AddressZero) {
      this.logger.info('set OptimismPortal contract override')
      contracts.OptimismPortal = portalAddress
Hamdi Allam's avatar
Hamdi Allam committed
151

152
      this.logger.info('fetching L2OutputOracle contract from OptimismPortal')
153 154 155 156
      const opts = {
        portalAddress,
        signerOrProvider: this.options.l1RpcProvider,
      }
157 158
      const portalContract = getOEContract('OptimismPortal', l2ChainId, opts)
      contracts.L2OutputOracle = await portalContract.L2_ORACLE()
Hamdi Allam's avatar
Hamdi Allam committed
159 160
    }

161 162
    // ... for a known chain ids without an override, the L2OutputOracle will already
    // be set via the hardcoded default
Hamdi Allam's avatar
Hamdi Allam committed
163 164 165
    return contracts
  }

166
  async init(): Promise<void> {
167 168 169 170 171 172 173 174 175 176 177 178
    // Connect to L1.
    await waitForProvider(this.options.l1RpcProvider, {
      logger: this.logger,
      name: 'L1',
    })

    // Connect to L2.
    await waitForProvider(this.options.l2RpcProvider, {
      logger: this.logger,
      name: 'L2',
    })

Hamdi Allam's avatar
Hamdi Allam committed
179 180
    const l1ChainId = await getChainId(this.options.l1RpcProvider)
    const l2ChainId = await getChainId(this.options.l2RpcProvider)
181 182 183
    this.state.messenger = new CrossChainMessenger({
      l1SignerOrProvider: this.options.l1RpcProvider,
      l2SignerOrProvider: this.options.l2RpcProvider,
Hamdi Allam's avatar
Hamdi Allam committed
184 185
      l1ChainId,
      l2ChainId,
186
      bedrock: true,
Hamdi Allam's avatar
Hamdi Allam committed
187
      contracts: { l1: await this.getOEL1Contracts(l2ChainId) },
188 189
    })

190 191 192
    // Not diverged by default.
    this.state.diverged = false

193
    // We use this a lot, a bit cleaner to pull out to the top level of the state object.
194 195 196 197 198
    this.state.faultProofWindow =
      await this.state.messenger.getChallengePeriodSeconds()
    this.logger.info(
      `fault proof window is ${this.state.faultProofWindow} seconds`
    )
199

200
    this.state.outputOracle = this.state.messenger.contracts.l1.L2OutputOracle
201 202 203

    // Figure out where to start syncing from.
    if (this.options.startBatchIndex === -1) {
Hamdi Allam's avatar
Hamdi Allam committed
204
      this.logger.info('finding appropriate starting unfinalized batch')
205
      const firstUnfinalized = await findFirstUnfinalizedStateBatchIndex(
206 207
        this.state.outputOracle,
        this.state.faultProofWindow,
Hamdi Allam's avatar
Hamdi Allam committed
208
        this.logger
209 210 211
      )

      // We may not have an unfinalized batches in the case where no batches have been submitted
212 213
      // for the entire duration of the FAULTPROOFWINDOW. We generally do not expect this to happen on mainnet,
      // but it happens often on testnets because the FAULTPROOFWINDOW is very short.
214
      if (firstUnfinalized === undefined) {
Hamdi Allam's avatar
Hamdi Allam committed
215
        this.logger.info('no unfinalized batches found. skipping all batches.')
216
        const totalBatches = await this.state.outputOracle.nextOutputIndex()
Hamdi Allam's avatar
Hamdi Allam committed
217
        this.state.currentBatchIndex = totalBatches.toNumber() - 1
218
      } else {
219
        this.state.currentBatchIndex = firstUnfinalized
220
      }
221
    } else {
222
      this.state.currentBatchIndex = this.options.startBatchIndex
223 224
    }

Hamdi Allam's avatar
Hamdi Allam committed
225 226
    this.logger.info('starting batch', {
      batchIndex: this.state.currentBatchIndex,
227
    })
Hamdi Allam's avatar
Hamdi Allam committed
228 229 230

    // Set the initial metrics.
    this.metrics.isCurrentlyMismatched.set(0)
231 232
  }

233 234 235 236 237 238 239 240
  async routes(router: ExpressRouter): Promise<void> {
    router.get('/status', async (req, res) => {
      return res.status(200).json({
        ok: !this.state.diverged,
      })
    })
  }

241
  async main(): Promise<void> {
Hamdi Allam's avatar
Hamdi Allam committed
242 243
    const startMs = Date.now()

244 245
    let latestBatchIndex: number
    try {
246
      const totalBatches = await this.state.outputOracle.nextOutputIndex()
Hamdi Allam's avatar
Hamdi Allam committed
247
      latestBatchIndex = totalBatches.toNumber() - 1
248
    } catch (err) {
Hamdi Allam's avatar
Hamdi Allam committed
249
      this.logger.error('failed to query total # of batches', {
250 251
        error: err,
        node: 'l1',
252
        section: 'nextOutputIndex',
253
      })
254 255
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l1',
256
        section: 'nextOutputIndex',
257
      })
258 259 260 261
      await sleep(15000)
      return
    }

Hamdi Allam's avatar
Hamdi Allam committed
262
    if (this.state.currentBatchIndex > latestBatchIndex) {
Hamdi Allam's avatar
Hamdi Allam committed
263
      this.logger.info('batch index is ahead of the oracle. waiting...', {
Hamdi Allam's avatar
Hamdi Allam committed
264 265 266
        batchIndex: this.state.currentBatchIndex,
        latestBatchIndex,
      })
267 268 269
      await sleep(15000)
      return
    }
270

Hamdi Allam's avatar
Hamdi Allam committed
271 272
    this.metrics.highestBatchIndex.set({ type: 'known' }, latestBatchIndex)
    this.logger.info('checking batch', {
273
      batchIndex: this.state.currentBatchIndex,
Hamdi Allam's avatar
Hamdi Allam committed
274
      latestBatchIndex,
275 276
    })

277
    let outputData: BedrockOutputData
278
    try {
279
      outputData = await findOutputForIndex(
280
        this.state.outputOracle,
Hamdi Allam's avatar
Hamdi Allam committed
281 282
        this.state.currentBatchIndex,
        this.logger
283
      )
284
    } catch (err) {
285
      this.logger.error('failed to fetch output associated with batch', {
286
        error: err,
287
        node: 'l1',
288
        section: 'findOutputForIndex',
Hamdi Allam's avatar
Hamdi Allam committed
289
        batchIndex: this.state.currentBatchIndex,
290
      })
291 292
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l1',
293
        section: 'findOutputForIndex',
294
      })
295 296 297 298 299 300 301 302
      await sleep(15000)
      return
    }

    let latestBlock: number
    try {
      latestBlock = await this.options.l2RpcProvider.getBlockNumber()
    } catch (err) {
Hamdi Allam's avatar
Hamdi Allam committed
303
      this.logger.error('failed to query L2 block height', {
304 305 306 307
        error: err,
        node: 'l2',
        section: 'getBlockNumber',
      })
308 309 310 311
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l2',
        section: 'getBlockNumber',
      })
312 313 314 315
      await sleep(15000)
      return
    }

316
    const outputBlockNumber = outputData.l2BlockNumber
317 318 319 320 321 322 323
    if (latestBlock < outputBlockNumber) {
      this.logger.info('L2 node is behind, waiting for sync...', {
        l2BlockHeight: latestBlock,
        outputBlock: outputBlockNumber,
      })
      return
    }
324

325 326 327 328
    let outputBlock: any
    try {
      outputBlock = await (
        this.options.l2RpcProvider as ethers.providers.JsonRpcProvider
329
      ).send('eth_getBlockByNumber', [toRpcHexString(outputBlockNumber), false])
330 331 332 333 334 335 336 337 338 339 340 341 342 343
    } catch (err) {
      this.logger.error('failed to fetch output block', {
        error: err,
        node: 'l2',
        section: 'getBlock',
        block: outputBlockNumber,
      })
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l2',
        section: 'getBlock',
      })
      await sleep(15000)
      return
    }
344

345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
    let messagePasserProofResponse: any
    try {
      messagePasserProofResponse = await (
        this.options.l2RpcProvider as ethers.providers.JsonRpcProvider
      ).send('eth_getProof', [
        this.state.messenger.contracts.l2.BedrockMessagePasser.address,
        [],
        toRpcHexString(outputBlockNumber),
      ])
    } catch (err) {
      this.logger.error('failed to fetch message passer proof', {
        error: err,
        node: 'l2',
        section: 'getProof',
        block: outputBlockNumber,
      })
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l2',
        section: 'getProof',
      })
      await sleep(15000)
      return
    }
368

369 370 371 372 373 374 375 376 377
    const outputRoot = ethers.utils.solidityKeccak256(
      ['uint256', 'bytes32', 'bytes32', 'bytes32'],
      [
        0,
        outputBlock.stateRoot,
        messagePasserProofResponse.storageHash,
        outputBlock.hash,
      ]
    )
378

379
    if (outputRoot !== outputData.outputRoot) {
380 381 382 383
      this.state.diverged = true
      this.metrics.isCurrentlyMismatched.set(1)
      this.logger.error('state root mismatch', {
        blockNumber: outputBlock.number,
384
        expectedStateRoot: outputData.outputRoot,
385 386 387 388 389
        actualStateRoot: outputRoot,
        finalizationTime: dateformat(
          new Date(
            (ethers.BigNumber.from(outputBlock.timestamp).toNumber() +
              this.state.faultProofWindow) *
Mark Tyneway's avatar
Mark Tyneway committed
390
              1000
391
          ),
392 393 394 395
          'mmmm dS, yyyy, h:MM:ss TT'
        ),
      })
      return
396 397
    }

Hamdi Allam's avatar
Hamdi Allam committed
398 399
    const elapsedMs = Date.now() - startMs

400
    // Mark the current batch index as checked
Hamdi Allam's avatar
Hamdi Allam committed
401
    this.logger.info('checked batch ok', {
402
      batchIndex: this.state.currentBatchIndex,
Hamdi Allam's avatar
Hamdi Allam committed
403
      timeMs: elapsedMs,
404
    })
405
    this.metrics.highestBatchIndex.set(
Hamdi Allam's avatar
Hamdi Allam committed
406
      { type: 'checked' },
407
      this.state.currentBatchIndex
408 409
    )

410 411
    // If we got through the above without throwing an error, we should be
    // fine to reset and move onto the next batch
412
    this.state.diverged = false
413
    this.state.currentBatchIndex++
414 415 416 417 418
    this.metrics.isCurrentlyMismatched.set(0)
  }
}

if (require.main === module) {
Will Cory's avatar
Will Cory committed
419
  config()
420 421 422
  const service = new FaultDetector()
  service.run()
}