diff --git a/.changeset/honest-ants-own.md b/.changeset/honest-ants-own.md new file mode 100644 index 0000000000000..2ccadabdefe58 --- /dev/null +++ b/.changeset/honest-ants-own.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/contracts-periphery': patch +--- + +Tweaks Drippie contract for client-side ease diff --git a/.changeset/slow-numbers-knock.md b/.changeset/slow-numbers-knock.md new file mode 100644 index 0000000000000..29eda7d4c1927 --- /dev/null +++ b/.changeset/slow-numbers-knock.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/drippie-mon': minor +--- + +Release drippie-mon diff --git a/.circleci/config.yml b/.circleci/config.yml index 96c3b3c887592..b490599304998 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,6 +91,7 @@ jobs: - packages/contracts-periphery/node_modules - packages/core-utils/node_modules - packages/data-transport-layer/node_modules + - packages/drippie-mon/node_modules - packages/fault-detector/node_modules - packages/message-relayer/node_modules - packages/replica-healthcheck/node_modules @@ -538,6 +539,11 @@ workflows: package_name: fault-detector requires: - yarn-monorepo + - js-lint-test: + name: drippie-mon-tests + package_name: drippie-mon + requires: + - yarn-monorepo - js-lint-test: name: message-relayer-tests package_name: message-relayer @@ -628,6 +634,14 @@ workflows: target: fault-detector context: - optimism + - docker-publish: + name: drippie-mon-release + docker_file: ops/docker/Dockerfile.packages + docker_tags: ethereumoptimism/drippie-mon:nightly + docker_context: . + target: drippie-mon + context: + - optimism - docker-publish: name: message-relayer-release docker_file: ops/docker/Dockerfile.packages diff --git a/.github/labeler.yml b/.github/labeler.yml index e6a363ac6bb56..0682fd05e3d31 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,6 +7,7 @@ - 'packages/contracts/**/*' - 'packages/contracts-periphery/**/*' - 'packages/data-transport-layer/**/*' + - 'packages/drippie-mon/**/*' - 'packages/message-relayer/**/*' - 'packages/fault-detector/**/*' - 'patches/**/*' diff --git a/.github/workflows/publish-canary.yml b/.github/workflows/publish-canary.yml index f96719111f312..7e666e0c076a7 100644 --- a/.github/workflows/publish-canary.yml +++ b/.github/workflows/publish-canary.yml @@ -18,6 +18,7 @@ jobs: l2geth: ${{ steps.packages.outputs.l2geth }} message-relayer: ${{ steps.packages.outputs.message-relayer }} fault-detector: ${{ steps.packages.outputs.fault-detector }} + drippie-mon: ${{ steps.packages.outputs.drippie-mon }} data-transport-layer: ${{ steps.packages.outputs.data-transport-layer }} contracts: ${{ steps.packages.outputs.contracts }} gas-oracle: ${{ steps.packages.outputs.gas-oracle }} @@ -229,6 +230,33 @@ jobs: push: true tags: ethereumoptimism/fault-detector:${{ needs.canary-publish.outputs.canary-docker-tag }} + drippie-mon: + name: Publish Drippie Monitor Version ${{ needs.canary-publish.outputs.canary-docker-tag }} + needs: canary-publish + if: needs.canary-publish.outputs.drippie-mon != '' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_ACCESS_TOKEN_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN_SECRET }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./ops/docker/Dockerfile.packages + target: relayer + push: true + tags: ethereumoptimism/drippie-mon:${{ needs.canary-publish.outputs.canary-docker-tag }} + data-transport-layer: name: Publish Data Transport Layer Version ${{ needs.canary-publish.outputs.canary-docker-tag }} needs: canary-publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86b62226a4780..ac23cc7ecf25c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ jobs: l2geth: ${{ steps.packages.outputs.l2geth }} message-relayer: ${{ steps.packages.outputs.message-relayer }} fault-detector: ${{ steps.packages.outputs.fault-detector }} + drippie-mon: ${{ steps.packages.outputs.drippie-mon }} data-transport-layer: ${{ steps.packages.outputs.data-transport-layer }} contracts: ${{ steps.packages.outputs.contracts }} gas-oracle: ${{ steps.packages.outputs.gas-oracle }} @@ -372,6 +373,33 @@ jobs: push: true tags: ethereumoptimism/fault-detector:${{ needs.release.outputs.fault-detector }},ethereumoptimism/fault-detector:latest + drippie-mon: + name: Publish Drippie Monitor Version ${{ needs.release.outputs.drippie-mon }} + needs: release + if: needs.release.outputs.drippie-mon != '' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_ACCESS_TOKEN_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN_SECRET }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./ops/docker/Dockerfile.packages + target: drippie-mon + push: true + tags: ethereumoptimism/drippie-mon:${{ needs.release.outputs.drippie-mon }},ethereumoptimism/drippie-mon:latest + data-transport-layer: name: Publish Data Transport Layer Version ${{ needs.release.outputs.data-transport-layer }} needs: release diff --git a/.vscode/settings.json b/.vscode/settings.json index 403c9aedc74c6..0d53657588b21 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ {"directory": "packages/contracts", "changeProcessCWD": true }, {"directory": "packages/contracts-periphery", "changeProcessCWD": true }, {"directory": "packages/data-transport-layer", "changeProcessCWD": true }, + {"directory": "packages/drippie-mon", "changeProcessCWD": true }, {"directory": "packages/batch-submitter", "changeProcessCWD": true }, {"directory": "packages/message-relayer", "changeProcessCWD": true }, {"directory": "packages/fault-detector", "changeProcessCWD": true }, diff --git a/README.md b/README.md index 4ef11f2438581..7d13ea6717b45 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ root │ ├── contracts-periphery: Peripheral contracts for Optimism │ ├── core-utils: Low-level utilities that make building Optimism easier │ ├── data-transport-layer: Service for indexing Optimism-related L1 data -│ ├── fault-detector: +│ ├── drippie-mon: Service for monitoring Drippie instances +│ ├── fault-detector: Service for detecting Sequencer faults │ ├── integration-tests-bedrock (BEDROCK upgrade): Bedrock integration tests. │ ├── message-relayer: Tool for automatically relaying L1<>L2 messages in development │ ├── replica-healthcheck: Service for monitoring the health of a replica node diff --git a/packages/contracts-periphery/contracts/universal/drippie/Drippie.sol b/packages/contracts-periphery/contracts/universal/drippie/Drippie.sol index 0a334d1e2afb1..bc30d8131264d 100644 --- a/packages/contracts-periphery/contracts/universal/drippie/Drippie.sol +++ b/packages/contracts-periphery/contracts/universal/drippie/Drippie.sol @@ -52,22 +52,39 @@ contract Drippie is AssetReceiver { DripStatus status; DripConfig config; uint256 last; + uint256 count; } /** * Emitted when a new drip is created. */ - event DripCreated(string indexed name, DripConfig config); + event DripCreated( + // Emit name twice because indexed version is hashed. + string indexed nameref, + string name, + DripConfig config + ); /** * Emitted when a drip status is updated. */ - event DripStatusUpdated(string indexed name, DripStatus status); + event DripStatusUpdated( + // Emit name twice because indexed version is hashed. + string indexed nameref, + string name, + DripStatus status + ); /** * Emitted when a drip is executed. */ - event DripExecuted(string indexed name, address indexed executor, uint256 timestamp); + event DripExecuted( + // Emit name twice because indexed version is hashed. + string indexed nameref, + string name, + address executor, + uint256 timestamp + ); /** * Maps from drip names to drip states. @@ -109,7 +126,7 @@ contract Drippie is AssetReceiver { } // Tell the world! - emit DripCreated(_name, _config); + emit DripCreated(_name, _name, _config); } /** @@ -163,20 +180,16 @@ contract Drippie is AssetReceiver { // If we made it here then we can safely update the status. drips[_name].status = _status; - emit DripStatusUpdated(_name, drips[_name].status); + emit DripStatusUpdated(_name, _name, drips[_name].status); } /** - * Triggers a drip. This function is deliberately left as a public function because the - * assumption being made here is that setting the drip to ACTIVE is an affirmative signal that - * the drip should be executable according to the drip parameters, drip check, and drip - * interval. Note that drip parameters are read entirely from the state and are not supplied as - * user input, so there should not be any way for a non-authorized user to influence the - * behavior of the drip. + * Checks if a given drip is executable. * - * @param _name Name of the drip to trigger. + * @param _name Drip to check. + * @return True if the drip is executable, false otherwise. */ - function drip(string memory _name) external { + function executable(string memory _name) public view returns (bool) { DripState storage state = drips[_name]; // Only allow active drips to be executed, an obvious security measure. @@ -201,6 +214,29 @@ contract Drippie is AssetReceiver { "Drippie: dripcheck failed so drip is not yet ready to be triggered" ); + // Alright, we're good to execute. + return true; + } + + /** + * Triggers a drip. This function is deliberately left as a public function because the + * assumption being made here is that setting the drip to ACTIVE is an affirmative signal that + * the drip should be executable according to the drip parameters, drip check, and drip + * interval. Note that drip parameters are read entirely from the state and are not supplied as + * user input, so there should not be any way for a non-authorized user to influence the + * behavior of the drip. + * + * @param _name Name of the drip to trigger. + */ + function drip(string memory _name) external { + DripState storage state = drips[_name]; + + // Make sure the drip can be executed. + require( + executable(_name) == true, + "Drippie: drip cannot be executed at this time, try again later" + ); + // Update the last execution time for this drip before the call. Note that it's entirely // possible for a drip to be executed multiple times per block or even multiple times // within the same transaction (via re-entrancy) if the drip interval is set to zero. Users @@ -240,6 +276,7 @@ contract Drippie is AssetReceiver { ); } - emit DripExecuted(_name, msg.sender, block.timestamp); + state.count++; + emit DripExecuted(_name, _name, msg.sender, block.timestamp); } } diff --git a/packages/contracts-periphery/hardhat.config.ts b/packages/contracts-periphery/hardhat.config.ts index a348517928a8d..51964bfcc9341 100644 --- a/packages/contracts-periphery/hardhat.config.ts +++ b/packages/contracts-periphery/hardhat.config.ts @@ -27,7 +27,7 @@ const config: HardhatUserConfig = { }, }, }, - opkovan: { + 'optimism-kovan': { chainId: 69, url: 'https://kovan.optimism.io', verify: { @@ -97,7 +97,10 @@ const config: HardhatUserConfig = { }, }, namedAccounts: { - deployer: `ledger://${getenv('LEDGER_ADDRESS')}`, + deployer: { + default: `ledger://${getenv('LEDGER_ADDRESS')}`, + hardhat: 0, + }, }, } diff --git a/packages/drippie-mon/.depcheckrc b/packages/drippie-mon/.depcheckrc new file mode 100644 index 0000000000000..3a691b778839b --- /dev/null +++ b/packages/drippie-mon/.depcheckrc @@ -0,0 +1,13 @@ +ignores: [ + "@babel/eslint-parser", + "@typescript-eslint/parser", + "eslint-plugin-import", + "eslint-plugin-unicorn", + "eslint-plugin-jsdoc", + "eslint-plugin-prefer-arrow", + "eslint-plugin-react", + "@typescript-eslint/eslint-plugin", + "eslint-config-prettier", + "eslint-plugin-prettier", + "chai" +] diff --git a/packages/drippie-mon/.env.example b/packages/drippie-mon/.env.example new file mode 100644 index 0000000000000..2db348516674e --- /dev/null +++ b/packages/drippie-mon/.env.example @@ -0,0 +1,5 @@ +# RPC pointing to network where Drippie is deployed +DRIPPIE_MON__RPC= + +# Address of the Drippie contract +DRIPPIE_MON__DRIPPIE_ADDRESS= diff --git a/packages/drippie-mon/.eslintrc.js b/packages/drippie-mon/.eslintrc.js new file mode 100644 index 0000000000000..bfd2057be80bd --- /dev/null +++ b/packages/drippie-mon/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: '../../.eslintrc.js', +} diff --git a/packages/drippie-mon/.lintstagedrc.yml b/packages/drippie-mon/.lintstagedrc.yml new file mode 100644 index 0000000000000..a3035a2299b26 --- /dev/null +++ b/packages/drippie-mon/.lintstagedrc.yml @@ -0,0 +1,2 @@ +"*.{ts,js}": + - eslint diff --git a/packages/drippie-mon/.prettierrc.js b/packages/drippie-mon/.prettierrc.js new file mode 100644 index 0000000000000..6b3fa8e2ce237 --- /dev/null +++ b/packages/drippie-mon/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('../../.prettierrc.js'), +}; \ No newline at end of file diff --git a/packages/drippie-mon/LICENSE b/packages/drippie-mon/LICENSE new file mode 100644 index 0000000000000..6a7da5218bb25 --- /dev/null +++ b/packages/drippie-mon/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright 2020-2021 Optimism + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/drippie-mon/README.md b/packages/drippie-mon/README.md new file mode 100644 index 0000000000000..629a5b70f98aa --- /dev/null +++ b/packages/drippie-mon/README.md @@ -0,0 +1,22 @@ +# @eth-optimism/drippie-mon + +`drippie-mon` is a simple service for monitoring Drippie contracts. + +## Installation + +Clone, install, and build the Optimism monorepo: + +``` +git clone https://github.com/ethereum-optimism/optimism.git +yarn install +yarn build +``` + +## Running the service + +Copy `.env.example` into a new file named `.env`, then set the environment variables listed there. +Once your environment variables have been set, run via: + +``` +yarn start +``` diff --git a/packages/drippie-mon/package.json b/packages/drippie-mon/package.json new file mode 100644 index 0000000000000..70f329ca8b61e --- /dev/null +++ b/packages/drippie-mon/package.json @@ -0,0 +1,45 @@ +{ + "private": true, + "name": "@eth-optimism/drippie-mon", + "version": "0.1.0", + "description": "[Optimism] Service for monitoring Drippie instances", + "main": "dist/index", + "types": "dist/index", + "files": [ + "dist/*" + ], + "scripts": { + "start": "ts-node ./src/service.ts", + "test:coverage": "echo 'No tests defined.'", + "build": "tsc -p ./tsconfig.json", + "clean": "rimraf dist/ ./tsconfig.tsbuildinfo", + "lint": "yarn lint:fix && yarn lint:check", + "pre-commit": "lint-staged", + "lint:fix": "yarn lint:check --fix", + "lint:check": "eslint . --max-warnings=0" + }, + "keywords": [ + "optimism", + "ethereum", + "drippie", + "monitoring" + ], + "homepage": "https://github.com/ethereum-optimism/optimism/tree/develop/packages/drippie-mon#readme", + "license": "MIT", + "author": "Optimism PBC", + "repository": { + "type": "git", + "url": "https://github.com/ethereum-optimism/optimism.git" + }, + "dependencies": { + "@eth-optimism/common-ts": "0.2.9", + "@eth-optimism/contracts-periphery": "0.1.1", + "@eth-optimism/core-utils": "0.8.6", + "@eth-optimism/sdk": "1.1.7", + "ethers": "^5.6.8" + }, + "devDependencies": { + "@ethersproject/abstract-provider": "^5.6.1", + "ts-node": "^10.0.0" + } +} diff --git a/packages/drippie-mon/src/index.ts b/packages/drippie-mon/src/index.ts new file mode 100644 index 0000000000000..caf7fffa10172 --- /dev/null +++ b/packages/drippie-mon/src/index.ts @@ -0,0 +1 @@ +export * from './service' diff --git a/packages/drippie-mon/src/service.ts b/packages/drippie-mon/src/service.ts new file mode 100644 index 0000000000000..4958483a84efa --- /dev/null +++ b/packages/drippie-mon/src/service.ts @@ -0,0 +1,201 @@ +import { + BaseServiceV2, + Gauge, + Counter, + validators, +} from '@eth-optimism/common-ts' +import { Provider } from '@ethersproject/abstract-provider' +import { ethers } from 'ethers' +import * as DrippieArtifact from '@eth-optimism/contracts-periphery/artifacts/contracts/universal/drippie/Drippie.sol/Drippie.json' + +type DrippieMonOptions = { + rpc: Provider + drippieAddress: string +} + +type DrippieMonMetrics = { + metadata: Gauge + isExecutable: Gauge + executedDripCount: Gauge + unexpectedRpcErrors: Counter +} + +type DrippieMonState = { + drippie: ethers.Contract +} + +export class DrippieMonService extends BaseServiceV2< + DrippieMonOptions, + DrippieMonMetrics, + DrippieMonState +> { + constructor(options?: Partial) { + super({ + name: 'drippie-mon', + loop: true, + loopIntervalMs: 60_000, + options, + optionsSpec: { + rpc: { + validator: validators.provider, + desc: 'Provider for network where Drippie is deployed', + }, + drippieAddress: { + validator: validators.str, + desc: 'Address of Drippie contract', + }, + }, + metricsSpec: { + metadata: { + type: Gauge, + desc: 'Drippie Monitor metadata', + labels: ['version', 'address'], + }, + isExecutable: { + type: Gauge, + desc: 'Whether or not the drip is currently executable', + labels: ['name'], + }, + executedDripCount: { + type: Gauge, + desc: 'Number of times a drip has been executed', + labels: ['name'], + }, + unexpectedRpcErrors: { + type: Counter, + desc: 'Number of unexpected RPC errors', + labels: ['section', 'name'], + }, + }, + }) + } + + protected async init(): Promise { + this.state.drippie = new ethers.Contract( + this.options.drippieAddress, + DrippieArtifact.abi, + this.options.rpc + ) + + this.metrics.metadata.set( + { + // eslint-disable-next-line @typescript-eslint/no-var-requires + version: require('../package.json').version, + address: this.options.drippieAddress, + }, + 1 + ) + } + + protected async main(): Promise { + let dripCreatedEvents: ethers.Event[] + try { + dripCreatedEvents = await this.state.drippie.queryFilter( + this.state.drippie.filters.DripCreated() + ) + } catch (err) { + this.logger.info(`got unexpected RPC error`, { + section: 'creations', + name: 'NULL', + err, + }) + + this.metrics.unexpectedRpcErrors.inc({ + section: 'creations', + name: 'NULL', + }) + + return + } + + // Not the most efficient thing in the world. Will end up making one request for every drip + // created. We don't expect there to be many drips, so this is fine for now. We can also cache + // and skip any archived drips to cut down on a few requests. Worth keeping an eye on this to + // see if it's a bottleneck. + for (const event of dripCreatedEvents) { + const name = event.args.name + + let drip: any + try { + drip = await this.state.drippie.drips(name) + } catch (err) { + this.logger.info(`got unexpected RPC error`, { + section: 'drips', + name, + err, + }) + + this.metrics.unexpectedRpcErrors.inc({ + section: 'drips', + name, + }) + + continue + } + + this.logger.info(`getting drip executable status`, { + name, + count: drip.count.toNumber(), + }) + + this.metrics.executedDripCount.set( + { + name, + }, + drip.count.toNumber() + ) + + let executable: boolean + try { + // To avoid making unnecessary RPC requests, filter out any drips that we don't expect to + // be executable right now. Only active drips (status = 1) and drips that are due to be + // executed are expected to be executable (but might not be based on the dripcheck). + if ( + drip.status === 1 && + drip.last.toNumber() + drip.config.interval.toNumber() < + Date.now() / 1000 + ) { + executable = await this.state.drippie.executable(name) + } else { + executable = false + } + } catch (err) { + // All reverts include the string "Drippie:", so we can check for that. + if (err.message.includes('Drippie:')) { + // Not executable yet. + executable = false + } else { + this.logger.info(`got unexpected RPC error`, { + section: 'executable', + name, + err, + }) + + this.metrics.unexpectedRpcErrors.inc({ + section: 'executable', + name, + }) + + continue + } + } + + this.logger.info(`got drip executable status`, { + name, + executable, + }) + + this.metrics.isExecutable.set( + { + name, + }, + executable ? 1 : 0 + ) + } + } +} + +if (require.main === module) { + const service = new DrippieMonService() + service.run() +} diff --git a/packages/drippie-mon/tsconfig.json b/packages/drippie-mon/tsconfig.json new file mode 100644 index 0000000000000..5cb4fda3c5469 --- /dev/null +++ b/packages/drippie-mon/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +}