service.ts 6.53 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
import {
  BaseServiceV2,
  StandardOptions,
  ExpressRouter,
  Gauge,
  validators,
  waitForProvider,
} from '@eth-optimism/common-ts'
import { CrossChainMessenger } from '@eth-optimism/sdk'
import { getChainId, sleep } from '@eth-optimism/core-utils'
import { Provider } from '@ethersproject/abstract-provider'
import { Event } from 'ethers'
import dateformat from 'dateformat'

import { version } from '../../package.json'

type Options = {
  l1RpcProvider: Provider
  l2RpcProvider: Provider
  startBlockNumber: number
  sleepTimeMs: number
}

type Metrics = {
  withdrawalsValidated: Gauge
  isDetectingForgeries: Gauge
  nodeConnectionFailures: Gauge
}

type State = {
  messenger: CrossChainMessenger
  highestUncheckedBlockNumber: number
  finalizationWindow: number
  forgeryDetected: boolean
}

export class WithdrawalMonitor extends BaseServiceV2<Options, Metrics, State> {
  constructor(options?: Partial<Options & StandardOptions>) {
    super({
      version,
      name: 'two-step-monitor',
      loop: true,
      options: {
        loopIntervalMs: 1000,
        ...options,
      },
      optionsSpec: {
        l1RpcProvider: {
          validator: validators.provider,
          desc: 'Provider for interacting with L1',
        },
        l2RpcProvider: {
          validator: validators.provider,
          desc: 'Provider for interacting with L2',
        },
        startBlockNumber: {
          validator: validators.num,
          default: -1,
          desc: 'L1 block number to start checking from',
          public: true,
        },
        sleepTimeMs: {
          validator: validators.num,
          default: 15000,
          desc: 'Time in ms to sleep when waiting for a node',
          public: true,
        },
      },
      metricsSpec: {
        withdrawalsValidated: {
          type: Gauge,
          desc: 'Latest L1 Block (checked and known)',
          labels: ['type'],
        },
        isDetectingForgeries: {
          type: Gauge,
          desc: '0 if state is ok. 1 or more if forged withdrawals are detected.',
        },
        nodeConnectionFailures: {
          type: Gauge,
          desc: 'Number of times node connection has failed',
          labels: ['layer', 'section'],
        },
      },
    })
  }

  async init(): Promise<void> {
    // 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',
    })

    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),
    })

    // Not detected by default.
    this.state.forgeryDetected = false

    // For now we'll just start take it from the env or the tip of the chain
    if (this.options.startBlockNumber === -1) {
      this.state.highestUncheckedBlockNumber =
        await this.options.l1RpcProvider.getBlockNumber()
    } else {
      this.state.highestUncheckedBlockNumber = this.options.startBlockNumber
    }

    this.logger.info(`starting L1 block height`, {
      startBlockNumber: this.state.highestUncheckedBlockNumber,
    })
  }

  // K8s healthcheck
  async routes(router: ExpressRouter): Promise<void> {
    router.get('/healthz', async (req, res) => {
      return res.status(200).json({
        ok: !this.state.forgeryDetected,
      })
    })
  }

  async main(): Promise<void> {
    // Get current block number
    let latestL1BlockNumber: number
    try {
      latestL1BlockNumber = await this.options.l1RpcProvider.getBlockNumber()
    } catch (err) {
      this.logger.error(`got error when connecting to node`, {
        error: err,
        node: 'l1',
        section: 'getBlockNumber',
      })
      this.metrics.nodeConnectionFailures.inc({
145
        layer: 'l1',
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
        section: 'getBlockNumber',
      })
      await sleep(this.options.sleepTimeMs)
      return
    }

    // See if we have a new unchecked block
    if (latestL1BlockNumber <= this.state.highestUncheckedBlockNumber) {
      // The RPC provider is behind us, wait a bit
      await sleep(this.options.sleepTimeMs)
      return
    }

    this.logger.info(`checking recent blocks`, {
      fromBlockNumber: this.state.highestUncheckedBlockNumber,
      toBlockNumber: latestL1BlockNumber,
    })

    // Perform the check
    let proofEvents: 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
        )
    } catch (err) {
      this.logger.error(`got error when connecting to node`, {
        error: err,
        node: 'l1',
        section: 'querying for WithdrawalProven events',
      })
      this.metrics.nodeConnectionFailures.inc({
        layer: 'l1',
        section: 'querying for WithdrawalProven events',
      })
      // connection error, wait then restart
      await sleep(this.options.sleepTimeMs)
      return
    }

    for (const proofEvent of proofEvents) {
      const exists =
        await this.state.messenger.contracts.l2.BedrockMessagePasser.sentMessages(
          proofEvent.args.withdrawalHash
        )
      const provenAt = `${
        (dateformat(
          new Date(
            (await this.options.l1RpcProvider.getBlock(proofEvent.blockHash))
              .timestamp * 1000
          )
        ),
        'mmmm dS, yyyy, h:MM:ss TT',
        true)
      } UTC`
      if (exists) {
        this.metrics.withdrawalsValidated.inc()
        this.logger.info(`valid withdrawal`, {
          withdrawalHash: proofEvent.args.withdrawalHash,
          provenAt,
        })
      } else {
        this.logger.error(`withdrawalHash not seen on L2`, {
          withdrawalHash: proofEvent.args.withdrawalHash,
          provenAt,
        })
        this.state.forgeryDetected = true
        this.metrics.isDetectingForgeries.set(1)
        return
      }
    }

    this.state.highestUncheckedBlockNumber = latestL1BlockNumber + 1

    // If we got through the above without throwing an error, we should be fine to reset.
    this.state.forgeryDetected = false
    this.metrics.isDetectingForgeries.set(0)
  }
}

if (require.main === module) {
  const service = new WithdrawalMonitor()
  service.run()
}