Commit b5dd1f9f authored by smartcontracts's avatar smartcontracts Committed by GitHub

Merge pull request #2287 from ethereum-optimism/sc/healthcheck-v2

feat(rhc): refactor service using BaseServiceV2
parents 42d02bcc e264f03f
---
'@eth-optimism/replica-healthcheck': major
---
Rewrite replica-healthcheck with BaseServiceV2
REPLICA_HEALTHCHECK__ETH_NETWORK=mainnet HEALTHCHECK__REFERENCERPCPROVIDER=https://mainnet.optimism.io
REPLICA_HEALTHCHECK__ETH_NETWORK_RPC_PROVIDER=https://mainnet.optimism.io HEALTHCHECK__TARGETRPCPROVIDER=http://localhost:9991
REPLICA_HEALTHCHECK__ETH_REPLICA_RPC_PROVIDER=http://localhost:9991
REPLICA_HEALTHCHECK__L2GETH_IMAGE_TAG=0.4.7
REPLICA_HEALTHCHECK__CHECK_TX_WRITE_LATENCY=false
REPLICA_HEALTHCHECK__WALLET1_PRIVATE_KEY=
REPLICA_HEALTHCHECK__WALLET2_PRIVATE_KEY=
...@@ -4,37 +4,28 @@ ...@@ -4,37 +4,28 @@
`replica-healthcheck` is an express server to be run alongside a replica instance, to ensure that the replica is healthy. Currently, it exposes metrics on syncing stats and exits when the replica has a mismatched state root against the sequencer. `replica-healthcheck` is an express server to be run alongside a replica instance, to ensure that the replica is healthy. Currently, it exposes metrics on syncing stats and exits when the replica has a mismatched state root against the sequencer.
## Getting started
### Building and usage ## Installation
After cloning and switching to the repository, install dependencies: Clone, install, and build the Optimism monorepo:
```bash
$ yarn
``` ```
git clone https://github.com/ethereum-optimism/optimism.git
yarn install
yarn build
```
## Running the service (manual)
Use the following commands to build, use, test, and lint: Copy `.env.example` into a new file named `.env`, then set the environment variables listed there.
You can view a list of all environment variables and descriptions for each via:
```bash ```
$ yarn build yarn start --help
$ yarn start
$ yarn test
$ yarn lint
``` ```
### Configuration Once your environment variables have been set, run the relayer via:
We're using `dotenv` for our configuration. ```
To configure the project, clone this repository and copy the `env.example` file to `.env`. yarn start
Here's a list of environment variables: ```
| Variable | Purpose | Default |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| REPLICA_HEALTHCHECK\_\_ETH_NETWORK | Ethereum Layer1 and Layer2 network (mainnet,kovan) | mainnet (change to `kovan` for the test network) |
| REPLICA_HEALTHCHECK\_\_ETH_NETWORK_RPC_PROVIDER | Layer2 source of truth endpoint, used for the sync check | https://mainnet.optimism.io (change to `https://kovan.optimism.io` for the test network) |
| REPLICA_HEALTHCHECK\_\_ETH_REPLICA_RPC_PROVIDER | Layer2 local replica endpoint, used for the sync check | http://localhost:9991 |
| REPLICA_HEALTHCHECK\_\_L2GETH_IMAGE_TAG | L2geth version | 0.4.9 |
| REPLICA_HEALTHCHECK\_\_CHECK_TX_WRITE_LATENCY | Boolean for whether to perform the transaction latency check. Recommend to only use for testnets | false |
| REPLICA_HEALTHCHECK\_\_WALLET1_PRIVATE_KEY | Private key to one wallet for checking write latency | - |
| REPLICA_HEALTHCHECK\_\_WALLET2_PRIVATE_KEY | Private key to the other wallet for checking write latency | - |
...@@ -9,14 +9,13 @@ ...@@ -9,14 +9,13 @@
"dist/*" "dist/*"
], ],
"scripts": { "scripts": {
"start": "ts-node ./src/service",
"build": "tsc -p tsconfig.build.json",
"clean": "rimraf ./dist ./tsconfig.build.tsbuildinfo", "clean": "rimraf ./dist ./tsconfig.build.tsbuildinfo",
"lint": "yarn run lint:fix && yarn run lint:check", "lint": "yarn run lint:fix && yarn run lint:check",
"lint:fix": "yarn lint:check --fix",
"lint:check": "eslint . --max-warnings=0",
"build": "tsc -p tsconfig.build.json",
"pre-commit": "lint-staged", "pre-commit": "lint-staged",
"test": "ts-mocha test/*.spec.ts", "lint:fix": "yarn lint:check --fix",
"start": "ts-node ./src/exec/run-healthcheck-server.ts" "lint:check": "eslint . --max-warnings=0"
}, },
"keywords": [ "keywords": [
"optimism", "optimism",
...@@ -34,23 +33,13 @@ ...@@ -34,23 +33,13 @@
"dependencies": { "dependencies": {
"@eth-optimism/common-ts": "0.2.1", "@eth-optimism/common-ts": "0.2.1",
"@eth-optimism/core-utils": "0.8.1", "@eth-optimism/core-utils": "0.8.1",
"@eth-optimism/sdk": "^1.0.0", "@ethersproject/abstract-provider": "^5.5.1"
"dotenv": "^10.0.0",
"ethers": "^5.5.4",
"express": "^4.17.1",
"express-prom-bundle": "^6.3.6",
"lint-staged": "11.0.0",
"node-cron": "^3.0.0",
"prom-client": "^13.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.12",
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
"@types/node-cron": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^4.26.0", "@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0", "@typescript-eslint/parser": "^4.26.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"chai": "^4.3.4",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.23.4", "eslint-plugin-import": "^2.23.4",
"eslint-plugin-jsdoc": "^35.1.2", "eslint-plugin-jsdoc": "^35.1.2",
...@@ -58,8 +47,7 @@ ...@@ -58,8 +47,7 @@
"eslint-plugin-prettier": "^3.4.0", "eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"eslint-plugin-unicorn": "^32.0.1", "eslint-plugin-unicorn": "^32.0.1",
"supertest": "^6.1.4", "lint-staged": "11.0.0",
"ts-mocha": "^8.0.0",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"typescript": "^4.3.5" "typescript": "^4.3.5"
} }
......
import * as dotenv from 'dotenv'
import { HealthcheckServer, readConfig } from '..'
;(async () => {
dotenv.config()
const healthcheckServer = new HealthcheckServer(readConfig())
healthcheckServer.init()
await healthcheckServer.runSyncCheck()
})().catch((err) => {
console.log(err)
process.exit(1)
})
import { Server } from 'net'
import express from 'express'
import promBundle from 'express-prom-bundle'
import { Gauge, Histogram } from 'prom-client'
import cron from 'node-cron'
import { providers, Wallet } from 'ethers'
import { Metrics, Logger } from '@eth-optimism/common-ts'
import { sleep } from '@eth-optimism/core-utils'
import { asL2Provider } from '@eth-optimism/sdk'
import { binarySearchForMismatch } from './helpers'
export interface HealthcheckServerOptions {
network: string
gethRelease: string
sequencerRpcProvider: string
replicaRpcProvider: string
checkTxWriteLatency: boolean
txWriteOptions?: TxWriteOptions
logger: Logger
}
export interface TxWriteOptions {
wallet1PrivateKey: string
wallet2PrivateKey: string
}
export interface ReplicaMetrics {
lastMatchingStateRootHeight: Gauge<string>
replicaHeight: Gauge<string>
sequencerHeight: Gauge<string>
txWriteLatencyMs: Histogram<string>
}
export class HealthcheckServer {
protected options: HealthcheckServerOptions
protected app: express.Express
protected logger: Logger
protected metrics: ReplicaMetrics
protected replicaProvider: providers.StaticJsonRpcProvider
server: Server
constructor(options: HealthcheckServerOptions) {
this.options = options
this.app = express()
this.logger = options.logger
}
init = () => {
this.metrics = this.initMetrics()
this.server = this.initServer()
this.replicaProvider = asL2Provider(
new providers.StaticJsonRpcProvider({
url: this.options.replicaRpcProvider,
headers: { 'User-Agent': 'replica-healthcheck' },
})
)
if (this.options.checkTxWriteLatency) {
this.initTxLatencyCheck()
}
}
initMetrics = (): ReplicaMetrics => {
const metrics = new Metrics({
labels: {
network: this.options.network,
gethRelease: this.options.gethRelease,
},
})
const metricsMiddleware = promBundle({
includeMethod: true,
includePath: true,
})
this.app.use(metricsMiddleware)
return {
lastMatchingStateRootHeight: new metrics.client.Gauge({
name: 'replica_health_last_matching_state_root_height',
help: 'Height of last matching state root of replica',
registers: [metrics.registry],
}),
replicaHeight: new metrics.client.Gauge({
name: 'replica_health_height',
help: 'Block number of the latest block from the replica',
registers: [metrics.registry],
}),
sequencerHeight: new metrics.client.Gauge({
name: 'replica_health_sequencer_height',
help: 'Block number of the latest block from the sequencer',
registers: [metrics.registry],
}),
txWriteLatencyMs: new metrics.client.Histogram({
name: 'tx_write_latency_in_ms',
help: 'The latency of sending a write transaction through a replica in ms',
registers: [metrics.registry],
}),
}
}
initServer = (): Server => {
this.app.get('/', (req, res) => {
res.send(`
<head><title>Replica healthcheck</title></head>
<body>
<h1>Replica healthcheck</h1>
<p><a href="/metrics">Metrics</a></p>
</body>
</html>
`)
})
const server = this.app.listen(3000, () => {
this.logger.info('Listening on port 3000')
})
return server
}
initTxLatencyCheck = () => {
// Check latency for every Monday
cron.schedule('0 0 * * 1', this.runTxLatencyCheck)
}
runTxLatencyCheck = async () => {
const wallet1 = new Wallet(
this.options.txWriteOptions.wallet1PrivateKey,
this.replicaProvider
)
const wallet2 = new Wallet(
this.options.txWriteOptions.wallet2PrivateKey,
this.replicaProvider
)
// Send funds between the 2 addresses
try {
const res1 = await this.getLatencyForSend(wallet1, wallet2)
this.logger.info('Sent transaction from wallet1 to wallet2', {
latencyMs: res1.latencyMs,
status: res1.status,
})
const res2 = await this.getLatencyForSend(wallet2, wallet2)
this.logger.info('Sent transaction from wallet2 to wallet1', {
latencyMs: res2.latencyMs,
status: res2.status,
})
} catch (err) {
this.logger.error('Failed to get tx write latency', {
message: err.toString(),
stack: err.stack,
code: err.code,
wallet1: wallet1.address,
wallet2: wallet2.address,
})
}
}
getLatencyForSend = async (
from: Wallet,
to: Wallet
): Promise<{
latencyMs: number
status: number
}> => {
const fromBal = await from.getBalance()
if (fromBal.isZero()) {
throw new Error('Wallet balance is zero, cannot make test transaction')
}
const startTime = new Date()
const tx = await from.sendTransaction({
to: to.address,
value: fromBal.div(2), // send half
})
const { status } = await tx.wait()
const endTime = new Date()
const latencyMs = endTime.getTime() - startTime.getTime()
this.metrics.txWriteLatencyMs.observe(latencyMs)
return { latencyMs, status }
}
runSyncCheck = async () => {
const sequencerProvider = asL2Provider(
new providers.StaticJsonRpcProvider({
url: this.options.sequencerRpcProvider,
headers: { 'User-Agent': 'replica-healthcheck' },
})
)
// Continuously loop while replica runs
while (true) {
let replicaLatest = (await this.replicaProvider.getBlock('latest')) as any
const sequencerCorresponding = (await sequencerProvider.getBlock(
replicaLatest.number
)) as any
if (replicaLatest.stateRoot !== sequencerCorresponding.stateRoot) {
this.logger.error(
'Latest replica state root is mismatched from sequencer'
)
const firstMismatch = await binarySearchForMismatch(
sequencerProvider,
this.replicaProvider,
replicaLatest.number,
this.logger
)
this.logger.error('First state root mismatch found', {
blockNumber: firstMismatch,
})
this.metrics.lastMatchingStateRootHeight.set(firstMismatch)
throw new Error('Replica state root mismatched')
}
this.logger.info('State roots matching', {
blockNumber: replicaLatest.number,
})
this.metrics.lastMatchingStateRootHeight.set(replicaLatest.number)
replicaLatest = await this.replicaProvider.getBlock('latest')
const sequencerLatest = await sequencerProvider.getBlock('latest')
this.logger.info('Syncing from sequencer', {
sequencerHeight: sequencerLatest.number,
replicaHeight: replicaLatest.number,
heightDifference: sequencerLatest.number - replicaLatest.number,
})
this.metrics.replicaHeight.set(replicaLatest.number)
this.metrics.sequencerHeight.set(sequencerLatest.number)
// Fetch next block and sleep if not new
while (replicaLatest.number === sequencerCorresponding.number) {
this.logger.info(
'Replica caught up with sequencer, waiting for next block'
)
await sleep(1_000)
replicaLatest = await this.replicaProvider.getBlock('latest')
}
}
}
}
import { providers } from 'ethers'
import { Logger } from '@eth-optimism/common-ts'
import { HealthcheckServerOptions } from './healthcheck-server'
export const readEnvOrQuitProcess = (envName: string | undefined): string => {
if (!process.env[envName]) {
console.error(`Missing environment variable: ${envName}`)
process.exit(1)
}
return process.env[envName]
}
export const readConfig = (): HealthcheckServerOptions => {
const network = readEnvOrQuitProcess('REPLICA_HEALTHCHECK__ETH_NETWORK')
const gethRelease = readEnvOrQuitProcess(
'REPLICA_HEALTHCHECK__L2GETH_IMAGE_TAG'
)
const sequencerRpcProvider = readEnvOrQuitProcess(
'REPLICA_HEALTHCHECK__ETH_NETWORK_RPC_PROVIDER'
)
const replicaRpcProvider = readEnvOrQuitProcess(
'REPLICA_HEALTHCHECK__ETH_REPLICA_RPC_PROVIDER'
)
if (!['mainnet', 'kovan', 'goerli'].includes(network)) {
console.error(
'Invalid ETH_NETWORK specified. Must be one of mainnet, kovan, or goerli'
)
process.exit(1)
}
const checkTxWriteLatency =
process.env['REPLICA_HEALTHCHECK__CHECK_TX_WRITE_LATENCY'] === 'true'
let txWriteOptions
if (checkTxWriteLatency) {
const wallet1PrivateKey = readEnvOrQuitProcess(
'REPLICA_HEALTHCHECK__WALLET1_PRIVATE_KEY'
)
const wallet2PrivateKey = readEnvOrQuitProcess(
'REPLICA_HEALTHCHECK__WALLET2_PRIVATE_KEY'
)
txWriteOptions = { wallet1PrivateKey, wallet2PrivateKey }
}
const logger = new Logger({ name: 'replica-healthcheck' })
return {
network,
gethRelease,
sequencerRpcProvider,
replicaRpcProvider,
checkTxWriteLatency,
txWriteOptions,
logger,
}
}
export const binarySearchForMismatch = async (
sequencerProvider: providers.JsonRpcProvider,
replicaProvider: providers.JsonRpcProvider,
latest: number,
logger: Logger
): Promise<number> => {
logger.info(
'Executing a binary search to determine the first mismatched block...'
)
let start = 0
let end = latest
while (start !== end) {
const middle = Math.floor((start + end) / 2)
logger.info('Checking block', { blockNumber: middle })
const [replicaBlock, sequencerBlock] = await Promise.all([
replicaProvider.getBlock(middle) as any,
sequencerProvider.getBlock(middle) as any,
])
if (replicaBlock.stateRoot === sequencerBlock.stateRoot) {
logger.info('State roots still matching', { blockNumber: middle })
start = middle
} else {
logger.error('Found mismatched state roots', {
blockNumber: middle,
sequencerBlock,
replicaBlock,
})
end = middle
}
}
return end
}
export * from './healthcheck-server' export * from './service'
export * from './helpers'
import { Provider } from '@ethersproject/abstract-provider'
import { BaseServiceV2, Gauge, validators } from '@eth-optimism/common-ts'
import { sleep } from '@eth-optimism/core-utils'
type HealthcheckOptions = {
referenceRpcProvider: Provider
targetRpcProvider: Provider
onDivergenceWaitMs?: number
}
type HealthcheckMetrics = {
lastMatchingStateRootHeight: Gauge
isCurrentlyDiverged: Gauge
referenceHeight: Gauge
targetHeight: Gauge
}
type HealthcheckState = {}
export class HealthcheckService extends BaseServiceV2<
HealthcheckOptions,
HealthcheckMetrics,
HealthcheckState
> {
constructor(options?: Partial<HealthcheckOptions>) {
super({
name: 'Healthcheck',
loopIntervalMs: 5000,
options,
optionsSpec: {
referenceRpcProvider: {
validator: validators.provider,
desc: 'Provider for interacting with L1',
},
targetRpcProvider: {
validator: validators.provider,
desc: 'Provider for interacting with L2',
},
onDivergenceWaitMs: {
validator: validators.num,
desc: 'Waiting time in ms per loop when divergence is detected',
default: 60_000,
},
},
metricsSpec: {
lastMatchingStateRootHeight: {
type: Gauge,
desc: 'Highest matching state root between target and reference',
},
isCurrentlyDiverged: {
type: Gauge,
desc: 'Whether or not the two nodes are currently diverged',
},
referenceHeight: {
type: Gauge,
desc: 'Block height of the reference client',
},
targetHeight: {
type: Gauge,
desc: 'Block height of the target client',
},
},
})
}
async main() {
const targetLatest = await this.options.targetRpcProvider.getBlock('latest')
const referenceLatest = await this.options.referenceRpcProvider.getBlock(
'latest'
)
// Update these metrics first so they'll refresh no matter what.
this.metrics.targetHeight.set(targetLatest.number)
this.metrics.referenceHeight.set(referenceLatest.number)
this.logger.info(`latest block heights`, {
targetHeight: targetLatest.number,
referenceHeight: referenceLatest.number,
heightDifference: referenceLatest.number - targetLatest.number,
})
const referenceCorresponding =
await this.options.referenceRpcProvider.getBlock(targetLatest.number)
if (!referenceCorresponding) {
// This is ok, but we should log it and restart the loop.
this.logger.info(`reference client does not have block yet`, {
blockNumber: targetLatest.number,
})
return
}
// We used to use state roots here, but block hashes are even more reliable because they will
// catch discrepancies in blocks that may not impact the state. For example, if clients have
// blocks with two different timestamps, the state root will only diverge if the timestamp is
// actually used during the transaction(s) within the block.
if (referenceCorresponding.hash !== targetLatest.hash) {
this.logger.error(`reference client has different hash for block`, {
blockNumber: targetLatest.number,
})
// The main loop polls for "latest" so aren't checking every block. We need to use a binary
// search to find the first block where a mismatch occurred.
this.logger.info(`beginning binary search to find first mismatched block`)
let start = 0
let end = targetLatest.number
while (start !== end) {
const mid = Math.floor((start + end) / 2)
this.logger.info(`checking block`, { blockNumber: mid })
const blockA = await this.options.referenceRpcProvider.getBlock(mid)
const blockB = await this.options.targetRpcProvider.getBlock(mid)
if (blockA.hash === blockB.hash) {
start = mid + 1
} else {
end = mid
}
}
this.logger.info(`found first mismatched block`, { blockNumber: end })
this.metrics.lastMatchingStateRootHeight.set(end)
this.metrics.isCurrentlyDiverged.set(1)
// Old version of the service would exit here, but we want to keep looping just in case the
// the system recovers later. This is better than exiting because it means we don't have to
// restart the entire service. Running these checks once per minute will not trigger too many
// requests, so this should be fine.
await sleep(this.options.onDivergenceWaitMs)
return
}
this.logger.info(`blocks are matching`, {
blockNumber: targetLatest.number,
})
// Update latest matching state root height and reset the diverged metric in case it was set.
this.metrics.lastMatchingStateRootHeight.set(targetLatest.number)
this.metrics.isCurrentlyDiverged.set(0)
}
}
if (require.main === module) {
const service = new HealthcheckService()
service.run()
}
import request from 'supertest'
// Setup
import chai = require('chai')
const expect = chai.expect
import { Logger } from '@eth-optimism/common-ts'
import { HealthcheckServer } from '../src/healthcheck-server'
describe('HealthcheckServer', () => {
it('shoud serve correct metrics', async () => {
const logger = new Logger({ name: 'test_logger' })
const healthcheckServer = new HealthcheckServer({
network: 'kovan',
gethRelease: '0.4.20',
sequencerRpcProvider: 'http://sequencer.io',
replicaRpcProvider: 'http://replica.io',
logger,
})
try {
await healthcheckServer.init()
// Verify that the registered metrics are served at `/`
const response = await request(healthcheckServer.server)
.get('/metrics')
.send()
expect(response.status).eq(200)
expect(response.text).match(/replica_health_height gauge/)
} finally {
healthcheckServer.server.close()
}
})
})
...@@ -2942,13 +2942,6 @@ ...@@ -2942,13 +2942,6 @@
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw== integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
"@types/node-cron@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-2.0.4.tgz#6d467440762e7d3539890d477b33670c020c458f"
integrity sha512-vXzgDRWCZpuut5wJVZtluEnkNhzGojYlyMch2c4kMj7H74L8xTLytVlgQzj+/17wfcjs49aJDFBDglFSGt7GeA==
dependencies:
"@types/tz-offset" "*"
"@types/node-fetch@^2.5.10": "@types/node-fetch@^2.5.10":
version "2.5.12" version "2.5.12"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66"
...@@ -3108,11 +3101,6 @@ ...@@ -3108,11 +3101,6 @@
dependencies: dependencies:
"@sinonjs/fake-timers" "^7.1.0" "@sinonjs/fake-timers" "^7.1.0"
"@types/tz-offset@*":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@types/tz-offset/-/tz-offset-0.0.0.tgz#d58f1cebd794148d245420f8f0660305d320e565"
integrity sha512-XLD/llTSB6EBe3thkN+/I0L+yCTB6sjrcVovQdx2Cnl6N6bTzHmwe/J8mWnsXFgxLrj/emzdv8IR4evKYG2qxQ==
"@types/underscore@*": "@types/underscore@*":
version "1.11.3" version "1.11.3"
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.3.tgz#d6734f3741ce41b2630018c6b61c6745f6188c07" resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.3.tgz#d6734f3741ce41b2630018c6b61c6745f6188c07"
...@@ -11110,18 +11098,6 @@ modify-values@^1.0.0: ...@@ -11110,18 +11098,6 @@ modify-values@^1.0.0:
resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
moment-timezone@^0.5.31:
version "0.5.33"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c"
integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==
dependencies:
moment ">= 2.9.0"
"moment@>= 2.9.0":
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
mri@1.1.4: mri@1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a"
...@@ -11311,13 +11287,6 @@ node-addon-api@^3.0.2: ...@@ -11311,13 +11287,6 @@ node-addon-api@^3.0.2:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
node-cron@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.0.tgz#b33252803e430f9cd8590cf85738efa1497a9522"
integrity sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==
dependencies:
moment-timezone "^0.5.31"
node-emoji@^1.10.0, node-emoji@^1.4.1: node-emoji@^1.10.0, node-emoji@^1.4.1:
version "1.11.0" version "1.11.0"
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c"
......
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