├── .eslintrc.js ├── .github ├── .cspell.json └── workflows │ ├── eslint.yml │ ├── mega-linter.yml │ ├── node.js.yml │ └── npm-audit.yml ├── .gitignore ├── COPYING ├── Dockerfile ├── LICENSE ├── README.md ├── SETUP.md ├── abi └── .gitkeep ├── bot-config.json.example ├── examples ├── account-balance-bot-config.json ├── address-watch-bot-config.json ├── contract-variable-monitor-config.json ├── gnosis-safe-multisig-bot-config.json ├── governance-bot-config.json ├── monitor-function-calls-bot-config.json ├── new-contract-interaction-bot-config.json ├── tornado-cash-monitor-bot-config.json └── transaction-failure-count-bot-config.json ├── package-lock.json ├── package.json └── src ├── account-balance ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md └── agent.js ├── address-watch ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js └── agent.spec.js ├── agent.js ├── agent.spec.js ├── contract-variable-monitor ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js └── agent.spec.js ├── gnosis-safe-multisig ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js ├── agent.spec.js ├── internal-abi │ ├── ERC20.json │ ├── v1.0.0 │ │ └── gnosis-safe.json │ ├── v1.1.1 │ │ └── gnosis-safe.json │ ├── v1.2.0 │ │ └── gnosis-safe.json │ └── v1.3.0 │ │ └── gnosis-safe.json └── version-utils.js ├── governance ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js ├── agent.spec.js └── internal-abi │ ├── Governor.json │ ├── GovernorCompatibilityBravo.json │ ├── GovernorCompatibilityBravoUpgradeable.json │ ├── GovernorCountingSimple.json │ ├── GovernorCountingSimpleUpgradeable.json │ ├── GovernorProposalThreshold.json │ ├── GovernorProposalThresholdUpgradeable.json │ ├── GovernorSettings.json │ ├── GovernorSettingsUpgradeable.json │ ├── GovernorTimelockCompound.json │ ├── GovernorTimelockCompoundUpgradeable.json │ ├── GovernorTimelockControl.json │ ├── GovernorTimelockControlUpgradeable.json │ ├── GovernorUpgradeable.json │ ├── GovernorVotes.json │ ├── GovernorVotesComp.json │ ├── GovernorVotesCompUpgradeable.json │ ├── GovernorVotesQuorumFraction.json │ ├── GovernorVotesQuorumFractionUpgradeable.json │ └── GovernorVotesUpgradeable.json ├── monitor-events ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js └── agent.spec.js ├── monitor-function-calls ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js └── agent.spec.js ├── new-contract-interaction ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js └── agent.spec.js ├── test-utils.js ├── tornado-cash-monitor ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md ├── agent.js ├── agent.spec.js └── internal-abi │ └── TornadoProxy.json ├── transaction-failure-count ├── .eslintrc.js ├── .gitignore ├── README.md ├── SETUP.md └── agent.js ├── utils.js ├── utils.spec.js └── validate-config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.github/.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "**/node_modules/**", 4 | "**/vscode-extension/**", 5 | "**/.git/**", 6 | ".vscode", 7 | "megalinter", 8 | "package-lock.json", 9 | "report" 10 | ], 11 | "language": "en", 12 | "noConfigSearch": true, 13 | "words": [ 14 | "aaaaaaaaaaaaaaaaaaaagaaaaaaaaaaaaaaaaaaa", 15 | "bigagent", 16 | "bignumber", 17 | "calldatas", 18 | "ctoken", 19 | "devtest", 20 | "faketokenaddress", 21 | "faketransactionhash", 22 | "forta", 23 | "ftype", 24 | "invalidaddress", 25 | "keyfile", 26 | "newtokenaddress", 27 | "nonfungible", 28 | "plusplus", 29 | "prototest", 30 | "proxied", 31 | "sush", 32 | "synthetix", 33 | "threadlets", 34 | "timelock", 35 | "typehash", 36 | "unobfuscated", 37 | "walkthrough", 38 | "watchlist", 39 | "xaaaaaaaaaaaaaaaaaaaaaaaaagaaaaaaaaaaaaaa" 40 | ], 41 | "version": "0.2" 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow uses actions that are not certified by GitHub. 3 | # They are provided by a third-party and are governed by 4 | # separate terms of service, privacy policy, and support 5 | # documentation. 6 | # ESLint is a tool for identifying and reporting on patterns 7 | # found in ECMAScript/JavaScript code. 8 | # More details at https://github.com/eslint/eslint 9 | # and https://eslint.org 10 | 11 | name: ESLint 12 | # yamllint disable-line rule:truthy 13 | on: [push] 14 | 15 | jobs: 16 | eslint: 17 | name: Run eslint scanning 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | security-events: write 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | - name: Install ESLint 26 | run: npm install 27 | - name: Create Dummy Bot Config 28 | run: touch bot-config.json 29 | - name: Run ESLint 30 | run: npx eslint . --config .eslintrc.js 31 | -------------------------------------------------------------------------------- /.github/workflows/mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # MegaLinter GitHub Action configuration file 3 | # More info at https://megalinter.github.io 4 | name: MegaLinter 5 | # yamllint disable-line rule:truthy 6 | on: 7 | # Trigger mega-linter at every push. Action will also be visible from Pull Requests to master 8 | push: # Comment this line to trigger action only on pull-requests (not recommended if you don't pay for GH Actions) 9 | pull_request: 10 | branches: [master, main] 11 | 12 | env: # Comment env block if you do not want to apply fixes 13 | # Apply linter fixes configuration 14 | APPLY_FIXES: all 15 | # Fixes will be available in the artifact 16 | APPLY_FIXES_EVENT: none 17 | APPLY_FIXES_MODE: pull_request 18 | 19 | concurrency: 20 | group: ${{ github.ref }}-${{ github.workflow }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | build: 25 | name: MegaLinter 26 | runs-on: ubuntu-latest 27 | steps: 28 | # Git Checkout 29 | - name: Checkout Code 30 | uses: actions/checkout@v3 31 | with: 32 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 33 | fetch-depth: 0 34 | 35 | # MegaLinter 36 | - name: MegaLinter 37 | id: ml 38 | # You can override MegaLinter flavor used to have faster performances 39 | # More info at https://megalinter.github.io/flavors/ 40 | uses: megalinter/megalinter/flavors/javascript@v5 41 | env: 42 | # All available variables are described in documentation 43 | # https://megalinter.github.io/configuration/ 44 | VALIDATE_ALL_CODEBASE: true # Set ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} to validate only diff with main branch 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | # ADD YOUR CUSTOM ENV VARIABLES HERE TO OVERRIDE VALUES OF .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY 47 | DISABLE: COPYPASTE 48 | DISABLE_LINTERS: JAVASCRIPT_STANDARD,JSON_PRETTIER,YAML_PRETTIER,SPELL_MISSPELL,JAVASCRIPT_ES 49 | SHOW_ELAPSED_TIME: true 50 | FILEIO_REPORTER: false 51 | SPELL_CSPELL_CONFIG_FILE: .github/.cspell.json 52 | # Treat misspellings as a warning 53 | SPELL_CSPELL_DISABLE_ERRORS: true 54 | # DISABLE_ERRORS: true # Uncomment if you want MegaLinter to detect errors but not block CI to pass 55 | 56 | # Upload MegaLinter artifacts 57 | - name: Archive production artifacts 58 | if: ${{ success() }} || ${{ failure() }} 59 | uses: actions/upload-artifact@v2 60 | with: 61 | name: MegaLinter reports 62 | path: | 63 | report 64 | mega-linter.log 65 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 4 | 5 | name: Node.js CI 6 | # yamllint disable-line rule:truthy 7 | on: [push] 8 | 9 | jobs: 10 | npm-test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | timeout-minutes: 5 15 | steps: 16 | - name: Checkout the repo 17 | uses: actions/checkout@v3 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: lts/* 22 | - name: Install Packages 23 | run: npm install 24 | - name: Create Bot Config 25 | run: cp bot-config.json.example bot-config.json 26 | - name: Standard Tests 27 | run: npm test 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-audit.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Custom workflow for running NPM audit 3 | name: NPM Vulnerability Checker 4 | # yamllint disable-line rule:truthy 5 | on: 6 | # Enabling manual test 7 | # REF: https://stackoverflow.com/questions/58933155/manual-workflow-triggers-in-github-actions 8 | workflow_dispatch: 9 | push: 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | npm-audit: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | timeout-minutes: 5 19 | 20 | steps: 21 | - name: Checkout the repo 22 | uses: actions/checkout@v3 23 | - name: Use Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: lts/* 27 | - name: Install Packages 28 | run: npm install 29 | 30 | - name: Check for vulnerabilities 31 | id: VulnerabilityCheck 32 | continue-on-error: true 33 | run: npm audit 34 | - name: List outdated packages if vulnerabilities are detected 35 | if: steps.VulnerabilityCheck.outcome == 'failure' 36 | run: npm outdated 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | forta.config.json 3 | bot-config.json 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage: obfuscate Javascript (optional) 2 | # FROM node:12-alpine as builder 3 | # WORKDIR /app 4 | # COPY . . 5 | # RUN npm install -g javascript-obfuscator 6 | # RUN javascript-obfuscator ./src --output ./dist --split-strings true --split-strings-chunk-length 3 7 | 8 | # Final stage: install production dependencies 9 | FROM node:12-alpine 10 | ENV NODE_ENV=production 11 | WORKDIR /app 12 | # if using obfuscated code from build stage: 13 | # COPY --from=builder /app/dist ./src 14 | # else if using unobfuscated code: 15 | LABEL "network.forta.settings.agent-logs.enable"="true" 16 | COPY ./src ./src 17 | COPY ./abi ./abi 18 | COPY bot-config.json ./ 19 | COPY package*.json ./ 20 | RUN npm ci --production 21 | CMD [ "npm", "run", "start:prod" ] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Forta Agent Templates 2 | 3 | Copyright (C) 2022 Arbitrary Execution 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forta Bot Templates 2 | 3 | This repository contains Forta Bot templates that can be used to quickly create and deploy bots 4 | simply by creating configuration files. These Bot templates were designed by [Arbitrary Execution](https://www.arbitraryexecution.com/). 5 | More projects are available on [Arbitrary Execution's Github](https://github.com/arbitraryexecution) 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | The following steps will take you from a completely blank template to a functional bot. 10 | 11 | 1. Copy the `bot-config.json.example` file to a new file named `bot-config.json`. 12 | 13 | 2. `developerAbbreviation` (required) - Type in your desired abbreviation to specify your name or your development 14 | team name. For example, Arbitrary Execution uses the abbreviation `"AE"` for its `developerAbbreviation` value. 15 | 16 | 3. `protocolName` (required) - Type in the name of the protocol. For example, for the Uniswap protocol you may 17 | type in `"Uniswap"` or `"Uniswap V3"`, for the SushiSwap protocol you may type in `"Sushi"` or `"SushiSwap"`, etc. 18 | 19 | 4. `protocolAbbreviation` (required) - Type in an appropriate abbreviation for the value in `protocolName`. For 20 | example, `"Uniswap"` may be abbreviated `"UNI"` and `"SushiSwap"` may be abbreviated `"SUSH"`, etc. 21 | 22 | 5. `gatherMode` (required) - This can either be `any`, or `all`. 23 | - `any` returns any/all findings, from any bot added the the template list. 24 | - `all` returns findings only if all bots had findings for that transaction **and** the containing block 25 | 26 | 6. `bots` (required) - This Object contains configuration information for all bots you'd like to run. 27 | Check the individual Bot Templates SETUP.md files for more details how to create each one. 28 | 29 | ## Validating Your Config 30 | 31 | Once you're ready to deploy, `npm run validate` will check that your `bot-config.json` file is ready to go, 32 | and should let you know if there's any issues that might pop up during your run 33 | 34 | ## Bot Templates 35 | 36 | ### [Event Monitor](src/monitor-events/SETUP.md) 37 | 38 | This bot monitors blockchain transactions for specific events emitted from specific contract 39 | addresses. Optionally, an expression can be provided for checking the value of an event argument 40 | against a predefined value. If a matching event is emitted and the expression evaluates to `true`, 41 | an alert is created. Alert type and severity are specified per event per contract address. 42 | 43 | ### [Account Balance Monitor](src/account-balance/SETUP.md) 44 | 45 | This bot monitors the account balance (in Ether) of specific addresses. Thresholds, alert type, 46 | and alert severity are specified per address. There is also a minimum alert interval that prevents 47 | the bot from emitting many alerts for the same condition. 48 | 49 | ### [Address Watch](src/address-watch/SETUP.md) 50 | 51 | This bot monitors blockchain transactions for those involving specific addresses, which may be 52 | either EOAs or contracts. Alert type and severity are both configurable. 53 | 54 | ### [Function Call Monitor](src/monitor-function-calls/SETUP.md) 55 | 56 | This bot monitors blockchain transactions for specific function calls for specific contract 57 | addresses. Optionally, an expression may be provided for checking the value of a function argument 58 | against a predefined value. If a matching function call occurs and the expression evaluates to 59 | `true`, an alert is created. Alert type and severity are specified per function per contract 60 | address. 61 | 62 | ### [Transaction Failure Count](src/transaction-failure-count/SETUP.md) 63 | 64 | This bot monitors blockchain transactions that have failed and are associated with a specific 65 | contract address. Alert type and severity are both configurable. 66 | 67 | ### [New Contract/EOA Interaction](src/new-contract-interaction/SETUP.md) 68 | 69 | This bot monitors blockchain transactions for new contracts and EOAs with few transactions 70 | interacting with specific contract addresses. Alert type and severity are specified per contract. 71 | 72 | ### [Governance Event Monitor](src/governance/SETUP.md) 73 | 74 | This bot monitors governance contracts that use the modular system of Governance contracts available 75 | from OpenZeppelin. All possible emitted events are coded into the logic of the bot, so a developer 76 | need only specify the appropriate ABI file (files all present in the repository) and contract address. 77 | All alert types and severities are set to Info. 78 | 79 | ### [Gnosis-Safe MultiSig Monitor](src/gnosis-safe-multisig/SETUP.md) 80 | 81 | This bot monitors a Gnosis-Safe MultiSig contract for emitted events and for any balance changes in 82 | Ether or ERC20 tokens. Gnosis-Safe MultiSig contract versions v1.0.0, v1.1.1, v1.2.0, and v1.3.0 are 83 | supported and the appropriate ABI files are all present in the repository. All alert types and 84 | severities are set to Info. 85 | 86 | ### [Contract Variable Monitor](src/contract-variable-monitor/SETUP.md) 87 | 88 | This bot monitors contract variables that contain numeric values for specified contract addresses. 89 | Upper and lower percent change thresholds, number of data points to collect before checking for percent changes, 90 | and alert type and severity are specified per variable per contract address. 91 | 92 | ### [Tornado Cash Monitor](src/tornado-cash-monitor/SETUP.md) 93 | 94 | This bot monitors blockchain transactions for those involving specified addresses and any address 95 | that have previously interacted with a known Tornado Cash Proxy. An observation period (in blocks) to 96 | watch addresses that have interacted with known Tornado Cash Proxies is configurable. Alert type and 97 | severity is also configurable per contract. 98 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # Multiple Bot Template 2 | 3 | ## Bot Setup Walkthrough 4 | 5 | The following steps will take you from a completely blank template to a functional bot. The only file that 6 | needs to be modified for this bot to operate correctly is the configuration file `bot-config.json` 7 | 8 | 1. Copy the `bot-config.json.example` file to a new file named `bot-config.json`. 9 | 10 | 2. For the `developerAbbreviation` key, type in your desired abbreviation to specify your name or your development 11 | team name. For example, Arbitrary Execution uses the abbreviation `"AE"` for its `developerAbbreviation` value. 12 | 13 | 3. For the `protocolName` key, type in the name of the protocol. For example, for the Uniswap protocol you may 14 | type in `"Uniswap"` or `"Uniswap V3"`, for the Sushi Swap protocol you may type in `"Sushi"` or `"SushiSwap"`, etc. 15 | 16 | 4. For the `protocolAbbreviation` key, type in an appropriate abbreviation for the value in `protocolName`. For 17 | example, `"Uniswap"` may be abbreviated `"UNI"` and `"Sushi Swap"` may be abbreviated `"SUSH"`, etc. 18 | 19 | 5. Check out the SETUP.md in each bot directory for details on configuring each bot you want to use. 20 | 21 | 6. Set up any abi files that your bots need in the following directory structure: 22 | ```text 23 | abi/ 24 | / 25 | 26 | / 27 | 28 | ``` 29 | 30 | 6. Create a new README.md file to provide a description of your bot, using examples from the Forta Github 31 | repository. Additionally, update the `name` entry in `package.json` to match the values provided in the 32 | `bot-config.json` file. 33 | 34 | 7. Move files to have the following directory structure: 35 | ```text 36 | forta-bot-templates/ 37 | Dockerfile 38 | README.md 39 | .eslintrc.js 40 | .gitignore 41 | forta.config.json 42 | package.json 43 | bot-config.json 44 | abi/ 45 | / 46 | 47 | src/ 48 | / 49 | agent.js 50 | agent.spec.js 51 | ``` 52 | 53 | 8. Install all related `npm` packages using `npm i`. This will create a `package-lock.json` file alongside 54 | package.json. 55 | 56 | 9. Once the `bot-config.json` file is populated the bot is complete. Please test the bot against transactions 57 | that contain events that should trigger the bot. Please also test the bot against transactions that should 58 | not trigger the bot. An example test is provided here. It includes a positive and negative case, but please also 59 | consider edge cases that may arise in production. 60 | 61 | 10. After sufficient testing, the bot may be published and deployed using the steps outlined in the Forta SDK 62 | documentation: 63 | 64 | -------------------------------------------------------------------------------- /abi/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arbitraryexecution/forta-bot-templates/9e54be70c570906a0289963ab880e53f02e8f838/abi/.gitkeep -------------------------------------------------------------------------------- /bot-config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "", 3 | "protocolName": "", 4 | "protocolAbbreviation": "", 5 | "gatherMode": "any", 6 | "bots": [ 7 | { 8 | "botType": "account-balance", 9 | "name": "bot_1", 10 | "contracts": { 11 | "contractName1": { 12 | "address": "", 13 | "thresholdEth": 0, 14 | "type": "Info", 15 | "severity": "Info" 16 | } 17 | }, 18 | "alertMinimumIntervalSeconds": 86400 19 | }, 20 | { 21 | "botType": "address-watch", 22 | "name": "bot_2", 23 | "contracts": { 24 | "contractName1": { 25 | "address": "", 26 | "type": "Info", 27 | "severity": "Info" 28 | } 29 | } 30 | }, 31 | { 32 | "botType": "monitor-events", 33 | "name": "bot_3", 34 | "contracts": { 35 | "contractName1": { 36 | "address": "", 37 | "abiFile": "filename1.json", 38 | "events": { 39 | "eventName": { 40 | "expression": "amount > 2000", 41 | "type": "Info", 42 | "severity": "Info" 43 | } 44 | } 45 | } 46 | } 47 | }, 48 | { 49 | "botType": "contract-variable-monitor", 50 | "name": "bot_4", 51 | "contracts": { 52 | "contractName1": { 53 | "address": "", 54 | "abiFile": "filename1.json", 55 | "variables": { 56 | "variableName1": { 57 | "type": "Info", 58 | "severity": "Info", 59 | "upperThresholdPercent": 0, 60 | "lowerThresholdPercent": 0, 61 | "numDataPoints": 10 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | { 68 | "botType": "gnosis-safe-multisig", 69 | "name": "bot_5", 70 | "contracts": { 71 | "contractName1": { 72 | "address": "", 73 | "version": "v1.1.1" 74 | } 75 | } 76 | }, 77 | { 78 | "botType": "governance", 79 | "name": "bot_6", 80 | "contracts": { 81 | "contractName1": { 82 | "address": "", 83 | "abiFile": "Governor" 84 | } 85 | } 86 | }, 87 | { 88 | "botType": "monitor-function-calls", 89 | "name": "bot_7", 90 | "contracts": { 91 | "contractName1": { 92 | "address": "", 93 | "abiFile": "contractAbiFileName1.json", 94 | "functions": { 95 | "FunctionName1": { 96 | "expression": "amount > 6000", 97 | "type": "Info", 98 | "severity": "Info" 99 | } 100 | } 101 | } 102 | } 103 | }, 104 | { 105 | "botType": "new-contract-interaction", 106 | "name": "bot_8", 107 | "contracts": { 108 | "contractName1": { 109 | "thresholdBlockCount": 7, 110 | "thresholdTransactionCount": 7, 111 | "address": "", 112 | "filteredAddresses": [ 113 | "", 114 | "" 115 | ], 116 | "type": "Info", 117 | "severity": "Info" 118 | } 119 | } 120 | }, 121 | { 122 | "botType": "tornado-cash-monitor", 123 | "name": "bot_9", 124 | "contracts": { 125 | "contractName1": { 126 | "address": "", 127 | "type": "Info", 128 | "severity": "Info" 129 | } 130 | }, 131 | "observationIntervalInBlocks": 100 132 | }, 133 | { 134 | "botType": "transaction-failure-count", 135 | "name": "bot_10", 136 | "contracts": { 137 | "contractName1": { 138 | "address": "", 139 | "transactionFailuresLimit": 10, 140 | "type": "Info", 141 | "severity": "Info" 142 | } 143 | }, 144 | "blockWindow": 25 145 | } 146 | ] 147 | } 148 | -------------------------------------------------------------------------------- /examples/account-balance-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Coinbase1", 4 | "protocolAbbreviation": "CB1", 5 | "bots": [ 6 | { 7 | "botType": "account-balance", 8 | "name": "bot_1", 9 | "contracts": { 10 | "contractName1": { 11 | "address": "0x71660c4005BA85c37ccec55d0C4493E66Fe775d3", 12 | "thresholdEth": 100000, 13 | "type": "Info", 14 | "severity": "Info" 15 | } 16 | }, 17 | "alertMinimumIntervalSeconds": 86400 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/address-watch-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Uniswap", 4 | "protocolAbbreviation": "UNI", 5 | "bots": [ 6 | { 7 | "botType": "address-watch", 8 | "name": "bot_2", 9 | "contracts": { 10 | "UniswapV3Router": { 11 | "address": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", 12 | "type": "Info", 13 | "severity": "Info" 14 | } 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /examples/contract-variable-monitor-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Centre", 4 | "protocolAbbreviation": "USDC", 5 | "bots": [ 6 | { 7 | "botType": "contract-variable-monitor", 8 | "name": "bot_3", 9 | "contracts": { 10 | "USDC": { 11 | "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 12 | "abiFile": "USDC.json", 13 | "variables": { 14 | "totalSupply": { 15 | "type": "Info", 16 | "severity": "Info", 17 | "upperThresholdPercent": 0, 18 | "lowerThresholdPercent": 0, 19 | "numDataPoints": 1 20 | } 21 | } 22 | } 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/gnosis-safe-multisig-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Compound", 4 | "protocolAbbreviation": "COMP", 5 | "bots": [ 6 | { 7 | "botType": "gnosis-safe-multisig", 8 | "name": "bot_5", 9 | "contracts": { 10 | "CompoundCommunityMultiSig": { 11 | "address": "0xbbf3f1421D886E9b2c5D716B5192aC998af2012c", 12 | "version": "v1.1.1" 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /examples/governance-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Compound", 4 | "protocolAbbreviation": "COMP", 5 | "bots": [ 6 | { 7 | "botType": "governance", 8 | "name": "bot_6", 9 | "contracts": { 10 | "CompoundGovernorBravo": { 11 | "address": "0xc0Da02939E1441F497fd74F78cE7Decb17B66529", 12 | "abiFile": "GovernorCompatibilityBravo.json" 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /examples/monitor-function-calls-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Centre", 4 | "protocolAbbreviation": "USDC", 5 | "bots": [ 6 | { 7 | "botType": "monitor-events", 8 | "name": "bot_3", 9 | "contracts": { 10 | "USDC": { 11 | "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 12 | "abiFile": "USDC.json", 13 | "events": { 14 | "Transfer": { 15 | "type": "Info", 16 | "severity": "Info" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /examples/new-contract-interaction-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Uniswap", 4 | "protocolAbbreviation": "UNI", 5 | "bots": [ 6 | { 7 | "botType": "new-contract-interaction", 8 | "name": "bot_8", 9 | "contracts": { 10 | "USDC": { 11 | "address": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", 12 | "thresholdBlockCount": 10000000, 13 | "thresholdTransactionCount": 1000000, 14 | "filteredAddresses": [], 15 | "type": "Info", 16 | "severity": "Info" 17 | } 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /examples/tornado-cash-monitor-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Uniswap", 4 | "protocolAbbreviation": "UNI", 5 | "bots": [ 6 | { 7 | "botType": "tornado-cash-monitor", 8 | "name": "bot_9", 9 | "contracts": { 10 | "USDC": { 11 | "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 12 | "type": "Info", 13 | "severity": "Info" 14 | } 15 | }, 16 | "observationIntervalInBlocks": 1000000 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /examples/transaction-failure-count-bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AE", 3 | "protocolName": "Uniswap", 4 | "protocolAbbreviation": "UNI", 5 | "bots": [ 6 | { 7 | "botType": "transaction-failure-count", 8 | "name": "bot_10", 9 | "contracts": { 10 | "1Inch": { 11 | "address": "0x1111111254fb6c44bAC0beD2854e76F90643097d", 12 | "transactionFailuresLimit": 0, 13 | "type": "Info", 14 | "severity": "Info" 15 | } 16 | }, 17 | "blockWindow": 25 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forta-developer-protocol-bigagent", 3 | "version": "0.0.1", 4 | "description": "Forta Bot template for monitoring a large combination of conditions", 5 | "scripts": { 6 | "start": "npm run start:dev", 7 | "start:dev": "nodemon --watch src --watch forta.config.json --watch agent-config.json -e js,json --exec 'forta-agent run'", 8 | "start:prod": "forta-agent run --prod", 9 | "tx": "forta-agent run --tx", 10 | "block": "forta-agent run --block", 11 | "range": "forta-agent run --range", 12 | "file": "forta-agent run --file", 13 | "publish": "forta-agent publish", 14 | "push": "forta-agent push", 15 | "disable": "forta-agent disable", 16 | "enable": "forta-agent enable", 17 | "keyfile": "forta-agent keyfile", 18 | "test": "jest agent.spec.js", 19 | "validate": "node ./src/validate-config.js" 20 | }, 21 | "dependencies": { 22 | "forta-agent": "^0.1.6", 23 | "bignumber.js": "^9.0.2", 24 | "rolling-math": "^0.0.3" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^7.32.0", 28 | "eslint-config-airbnb-base": "^14.2.1", 29 | "eslint-plugin-import": "^2.25.1", 30 | "jest": "^27.2.5", 31 | "nodemon": "^2.0.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/account-balance/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 12, 12 | }, 13 | rules: { 14 | }, 15 | overrides: [ 16 | { 17 | files: '*', 18 | rules: { 19 | 'no-console': 'off', 20 | }, 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /src/account-balance/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/account-balance/README.md: -------------------------------------------------------------------------------- 1 | # Account Balance Bot Template 2 | 3 | This bot monitors the account balances (in Ether) of addresses on the blockchain and creates an alert when 4 | the balance falls below a specified threshold value. Threshold, alert type, and alert severity are specified 5 | per address. 6 | 7 | ## [Bot Setup Walkthrough](SETUP.md) 8 | -------------------------------------------------------------------------------- /src/account-balance/SETUP.md: -------------------------------------------------------------------------------- 1 | # Account Balance Bot Template 2 | 3 | This bot monitors the account balances (in Ether) of addresses on the blockchain and creates an alert when 4 | the balance falls below a specified threshold value. Threshold, alert type, and alert severity are specified 5 | per address. An existing bot of this type may be modified to add/remove/update addresses in the bot 6 | configuration file. 7 | 8 | ## Bot Setup Walkthrough 9 | 10 | 1. `accountBalance` (required) - The Object value for this key corresponds to addresses for which we want to monitor 11 | the account balance. Each key in the Object is a name that we can specify, where that name is simply a string that 12 | we use as a label when referring to the address (the string can be any valid string that we choose, it will not 13 | affect the monitoring by the bot). The Object corresponding to each name requires an address key/value pair, a 14 | thresholdEth key and integer value, a type key and string value, and a severity key and string value for the alert. 15 | For example, to monitor the Uni contract Ether balance, we would need the address, the threshold value, 16 | and a type and severity for the alert (must be valid type and severity from Forta SDK): 17 | 18 | ```json 19 | "accountBalance": { 20 | "Uni": { 21 | "address": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", 22 | "thresholdEth": 3, 23 | "type": "Suspicious", 24 | "severity": "High" 25 | } 26 | } 27 | ``` 28 | 29 | Note that any unused entries in the configuration file must be deleted for the bot to work. The original version 30 | of the configuration file contains several placeholders to show the structure of the file, but these are not valid 31 | entries for running the bot. 32 | 33 | 2. `alertMinimumIntervalSeconds` (required) - Type in the minimum number of seconds between sending alerts. This 34 | value is necessary to avoid sending too many alerts for the same condition in each block. The default behavior 35 | is for the bot to emit an alert when the condition is met, keep a counter of how many alerts would have happened 36 | within the interval specified, then emit an alert once that interval has been exceeded. The subsequent emitted 37 | alert will contain the number of alerts that would have occurred during that interval. 38 | -------------------------------------------------------------------------------- /src/account-balance/agent.js: -------------------------------------------------------------------------------- 1 | const BigNumber = require('bignumber.js'); 2 | const { 3 | getEthersProvider, Finding, FindingSeverity, FindingType, 4 | } = require('forta-agent'); 5 | const { 6 | isFilledString, 7 | isAddress, 8 | isObject, 9 | isEmptyObject, 10 | } = require('../utils'); 11 | 12 | function createAlert( 13 | accountName, 14 | accountAddress, 15 | accountBalanceBN, 16 | thresholdBN, 17 | numAlerts, 18 | protocolName, 19 | developerAbbreviation, 20 | protocolAbbreviation, 21 | alertType, 22 | alertSeverity, 23 | ) { 24 | const name = protocolName ? `${protocolName} Account Balance` : 'Account Balance'; 25 | 26 | let alertId; 27 | if (protocolAbbreviation) { 28 | alertId = `${developerAbbreviation}-${protocolAbbreviation}-LOW-ACCOUNT-BALANCE`; 29 | } else { 30 | alertId = `${developerAbbreviation}-LOW-ACCOUNT-BALANCE`; 31 | } 32 | 33 | const findingObject = { 34 | name, 35 | description: `The ${accountName} account has a balance below ${thresholdBN.toString()} wei`, 36 | alertId, 37 | severity: FindingSeverity[alertSeverity], 38 | type: FindingType[alertType], 39 | metadata: { 40 | accountName, 41 | accountAddress, 42 | accountBalance: accountBalanceBN.toString(), 43 | threshold: thresholdBN.toString(), 44 | numAlertsSinceLastFinding: numAlerts.toString(), 45 | }, 46 | }; 47 | 48 | if (protocolName) { 49 | findingObject.protocol = protocolName; 50 | } 51 | 52 | return Finding.fromObject(findingObject); 53 | } 54 | 55 | const validateConfig = (config) => { 56 | let ok = false; 57 | let errMsg = ''; 58 | 59 | if (!isFilledString(config.developerAbbreviation)) { 60 | errMsg = 'developerAbbreviation required'; 61 | return { ok, errMsg }; 62 | } 63 | if (!isFilledString(config.protocolName)) { 64 | errMsg = 'protocolName required'; 65 | return { ok, errMsg }; 66 | } 67 | if (!isFilledString(config.protocolAbbreviation)) { 68 | errMsg = 'protocolAbbreviation required'; 69 | return { ok, errMsg }; 70 | } 71 | 72 | if (!isObject(config.contracts) || isEmptyObject(config.contracts)) { 73 | errMsg = 'contracts key required'; 74 | return { ok, errMsg }; 75 | } 76 | 77 | let account; 78 | const accounts = Object.values(config.contracts); 79 | for (let i = 0; i < accounts.length; i += 1) { 80 | account = accounts[i]; 81 | const { 82 | address, 83 | thresholdEth, 84 | type, 85 | severity, 86 | } = account; 87 | 88 | if (!isAddress(address)) { 89 | errMsg = 'invalid address'; 90 | return { ok, errMsg }; 91 | } 92 | 93 | try { 94 | // eslint-disable-next-line no-unused-vars 95 | const value = new BigNumber(thresholdEth); 96 | } catch (error) { 97 | errMsg = `Cannot convert value in thresholdEth to BigNumber: ${thresholdEth}`; 98 | return { ok, errMsg }; 99 | } 100 | 101 | // check type, this will fail if 'type' is not valid 102 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 103 | errMsg = 'invalid finding type!'; 104 | return { ok, errMsg }; 105 | } 106 | 107 | // check severity, this will fail if 'severity' is not valid 108 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 109 | errMsg = 'invalid finding severity!'; 110 | return { ok, errMsg }; 111 | } 112 | } 113 | 114 | ok = true; 115 | return { ok, errMsg }; 116 | }; 117 | 118 | const initialize = async (config) => { 119 | const botState = { ...config }; 120 | 121 | botState.alertMinimumIntervalSeconds = config.alertMinimumIntervalSeconds; 122 | 123 | const multiplier = new BigNumber(10).pow(18); 124 | 125 | botState.provider = getEthersProvider(); 126 | botState.accounts = Object.entries(config.contracts).map(([accountName, entry]) => { 127 | const accountThresholdBN = new BigNumber(entry.thresholdEth).times(multiplier); 128 | return { 129 | accountName, 130 | accountAddress: entry.address, 131 | accountThresholdBN, 132 | startTime: 0, 133 | numAlertsSinceLastFinding: 0, 134 | alertType: entry.type, 135 | alertSeverity: entry.severity, 136 | }; 137 | }); 138 | 139 | return botState; 140 | }; 141 | 142 | // upon the mining of a new block, check the specified accounts to make sure the balance of 143 | // each account has not fallen below the specified threshold 144 | const handleBlock = async (botState, blockEvent) => { 145 | const findings = []; 146 | 147 | const { 148 | accounts, provider, alertMinimumIntervalSeconds, 149 | } = botState; 150 | 151 | if (!provider) { 152 | throw new Error('handleBlock called before initialization'); 153 | } 154 | 155 | const blockTimestamp = new BigNumber(blockEvent.block.timestamp); 156 | 157 | await Promise.all(accounts.map(async (account) => { 158 | const { 159 | accountName, accountAddress, accountThresholdBN, 160 | } = account; 161 | const accountBalance = await provider.getBalance(accountAddress); 162 | 163 | const accountBalanceBN = new BigNumber(accountBalance.toString()); 164 | 165 | // If balance < threshold add an alert to the findings 166 | if (accountBalanceBN.lt(accountThresholdBN)) { 167 | // if less than the specified number of hours has elapsed, just increment the counter for 168 | // the number of alerts that would have been generated 169 | if (blockTimestamp.minus(account.startTime) < alertMinimumIntervalSeconds) { 170 | /* eslint-disable no-param-reassign */ 171 | account.numAlertsSinceLastFinding += 1; 172 | } else { 173 | findings.push(createAlert( 174 | accountName, 175 | accountAddress, 176 | accountBalanceBN.toString(), 177 | accountThresholdBN.toString(), 178 | account.numAlertsSinceLastFinding, 179 | botState.protocolName, 180 | botState.developerAbbreviation, 181 | botState.protocolAbbreviation, 182 | account.alertType, 183 | account.alertSeverity, 184 | )); 185 | 186 | // restart the alert counter and update the start time 187 | account.numAlertsSinceLastFinding = 0; 188 | account.startTime = new BigNumber(blockTimestamp.toString()); 189 | /* eslint-enable no-param-reassign */ 190 | } 191 | } 192 | })); 193 | 194 | return findings; 195 | }; 196 | 197 | module.exports = { 198 | validateConfig, 199 | initialize, 200 | handleBlock, 201 | }; 202 | -------------------------------------------------------------------------------- /src/address-watch/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/address-watch/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/address-watch/README.md: -------------------------------------------------------------------------------- 1 | # Address Watch Template 2 | 3 | This agent monitors blockchain transactions for those involving specific addresses, which may be either EOAs or contracts. 4 | Alert type is always set to Suspicious and severity is set to Low. 5 | 6 | ## [Bot Setup Walkthrough](SETUP.md) 7 | -------------------------------------------------------------------------------- /src/address-watch/SETUP.md: -------------------------------------------------------------------------------- 1 | # Address Watch Template 2 | 3 | This bot monitors blockchain transactions for those involving specific addresses, which may be either EOAs or contracts. 4 | Alert type is always set to Suspicious and severity is set to Low. An existing bot of this type may be modified to add/remove/update 5 | addresses in the bot configuration file. 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | 1. The Object value for the `contractName1` key corresponds to addresses that we want to monitor. Each 10 | key in the Object is an address (either EOA or contract), and each value is another object with three fields: 11 | -`name`: the name of the contract or EOA that will be watched 12 | -`type`: the type of finding that will be generated when transactions involving this address are detected (see 13 | Forta SDK for `Finding` types) 14 | -`severity`: the severity of the finding that will be generated when transactions involving this address are 15 | detected (see Forta SDK for `Finding` severities) 16 | -------------------------------------------------------------------------------- /src/address-watch/agent.js: -------------------------------------------------------------------------------- 1 | const { Finding, FindingSeverity, FindingType } = require('forta-agent'); 2 | const { 3 | isFilledString, 4 | isAddress, 5 | isObject, 6 | isEmptyObject, 7 | } = require('../utils'); 8 | 9 | function createAlert(botState, address, contractName, type, severity, addresses) { 10 | return Finding.fromObject({ 11 | name: `${botState.protocolName} Address Watch`, 12 | description: `Address ${address} (${contractName}) was involved in a transaction`, 13 | alertId: `${botState.developerAbbreviation}-${botState.protocolAbbreviation}-ADDRESS-WATCH`, 14 | type: FindingType[type], 15 | severity: FindingSeverity[severity], 16 | addresses, 17 | }); 18 | } 19 | 20 | const validateConfig = (config) => { 21 | let ok = false; 22 | let errMsg = ''; 23 | 24 | if (!isFilledString(config.developerAbbreviation)) { 25 | errMsg = 'developerAbbreviation required'; 26 | return { ok, errMsg }; 27 | } 28 | if (!isFilledString(config.protocolName)) { 29 | errMsg = 'protocolName required'; 30 | return { ok, errMsg }; 31 | } 32 | if (!isFilledString(config.protocolAbbreviation)) { 33 | errMsg = 'protocolAbbreviation required'; 34 | return { ok, errMsg }; 35 | } 36 | 37 | let name; 38 | let entry; 39 | const entries = Object.entries(config.contracts); 40 | for (let i = 0; i < entries.length; i += 1) { 41 | [name, entry] = entries[i]; 42 | 43 | if (!isObject(entry) || isEmptyObject(entry)) { 44 | errMsg = 'contract keys in contracts required'; 45 | return { ok, errMsg }; 46 | } 47 | 48 | if (entry.address === undefined) { 49 | errMsg = `No address found in configuration file for '${name}'`; 50 | return { ok, errMsg }; 51 | } 52 | 53 | // check that the address is a valid address 54 | if (!isAddress(entry.address)) { 55 | errMsg = 'invalid address'; 56 | return { ok, errMsg }; 57 | } 58 | 59 | // check type, this will fail if 'type' is not valid 60 | if (!Object.prototype.hasOwnProperty.call(FindingType, entry.type)) { 61 | errMsg = 'invalid finding type!'; 62 | return { ok, errMsg }; 63 | } 64 | 65 | // check severity, this will fail if 'severity' is not valid 66 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, entry.severity)) { 67 | errMsg = 'invalid finding severity!'; 68 | return { ok, errMsg }; 69 | } 70 | } 71 | 72 | ok = true; 73 | return { ok, errMsg }; 74 | }; 75 | 76 | const initialize = async (config) => { 77 | const botState = { ...config }; 78 | 79 | const { ok, errMsg } = validateConfig(config); 80 | if (!ok) { 81 | throw new Error(errMsg); 82 | } 83 | 84 | botState.contracts = config.contracts; 85 | 86 | return botState; 87 | }; 88 | 89 | const handleTransaction = async (botState, txEvent) => { 90 | const findings = []; 91 | let addresses = Object.keys(txEvent.addresses).map((address) => address.toLowerCase()); 92 | addresses = addresses.filter((address) => address !== 'undefined'); 93 | 94 | const { contracts } = botState; 95 | 96 | // check if an address in the watchlist was the initiator of the transaction 97 | Object.entries(contracts).forEach(([name, contract]) => { 98 | const { 99 | address, 100 | type, 101 | severity, 102 | } = contract; 103 | if (addresses.includes(address.toLowerCase())) { 104 | findings.push(createAlert(botState, address, name, type, severity, addresses)); 105 | } 106 | }); 107 | 108 | return findings; 109 | }; 110 | 111 | module.exports = { 112 | validateConfig, 113 | initialize, 114 | handleTransaction, 115 | }; 116 | -------------------------------------------------------------------------------- /src/address-watch/agent.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | ethers, 3 | createTransactionEvent, 4 | Finding, 5 | FindingType, 6 | FindingSeverity, 7 | } = require('forta-agent'); 8 | 9 | const { 10 | initialize, 11 | handleTransaction, 12 | } = require('./agent'); 13 | 14 | const config = { 15 | developerAbbreviation: 'DEVTEST', 16 | protocolName: 'PROTOTEST', 17 | protocolAbbreviation: 'PT', 18 | botType: 'address-watch', 19 | name: 'test-bot', 20 | contracts: { 21 | contractName1: { 22 | address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', 23 | type: 'Info', 24 | severity: 'Info', 25 | }, 26 | }, 27 | }; 28 | 29 | describe('handleTransaction', () => { 30 | let botState; 31 | beforeEach(async () => { 32 | botState = await initialize(config); 33 | }); 34 | 35 | it('returns empty findings if no address match is found', async () => { 36 | // build txEvent 37 | const txEvent = createTransactionEvent({ 38 | addresses: {}, 39 | }); 40 | txEvent.addresses[ethers.constants.AddressZero] = true; 41 | 42 | // run bot with txEvent 43 | const findings = await handleTransaction(botState, txEvent); 44 | 45 | // assertions 46 | expect(findings).toStrictEqual([]); 47 | }); 48 | 49 | it('returns a finding if a transaction participant is on the watch list', async () => { 50 | const { contracts } = config; 51 | const [[name, contract]] = Object.entries(contracts); 52 | 53 | const { 54 | address: testAddr, 55 | type, 56 | severity, 57 | } = contract; 58 | 59 | // build txEvent 60 | const txEvent = createTransactionEvent({ 61 | addresses: {}, 62 | }); 63 | txEvent.addresses[testAddr] = true; 64 | 65 | // run bot with txEvent 66 | const findings = await handleTransaction(botState, txEvent); 67 | 68 | // assertions 69 | expect(findings).toStrictEqual([ 70 | Finding.fromObject({ 71 | name: `${botState.protocolName} Address Watch`, 72 | description: `Address ${testAddr} (${name}) was involved in a transaction`, 73 | alertId: `${botState.developerAbbreviation}-${botState.protocolAbbreviation}-ADDRESS-WATCH`, 74 | type: FindingType[type], 75 | severity: FindingSeverity[severity], 76 | addresses: Object.keys(txEvent.addresses).map((address) => address.toLowerCase()), 77 | }), 78 | ]); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/agent.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | const botImports = [ 3 | { name: 'account-balance', bot: require('./account-balance/agent') }, 4 | { name: 'address-watch', bot: require('./address-watch/agent') }, 5 | { name: 'monitor-events', bot: require('./monitor-events/agent') }, 6 | { name: 'contract-variable-monitor', bot: require('./contract-variable-monitor/agent') }, 7 | { name: 'gnosis-safe-multisig', bot: require('./gnosis-safe-multisig/agent') }, 8 | { name: 'governance', bot: require('./governance/agent') }, 9 | { name: 'monitor-function-calls', bot: require('./monitor-function-calls/agent') }, 10 | { name: 'new-contract-interaction', bot: require('./new-contract-interaction/agent') }, 11 | { name: 'tornado-cash-monitor', bot: require('./tornado-cash-monitor/agent') }, 12 | { name: 'transaction-failure-count', bot: require('./transaction-failure-count/agent') }, 13 | ]; 14 | /* eslint-enable global-require */ 15 | 16 | const config = require('../bot-config.json'); 17 | 18 | const botStates = {}; 19 | const botMap = new Map(); 20 | 21 | async function generateAllBots(_config, _botMap) { 22 | const modProms = []; 23 | const modNames = []; 24 | for (let i = 0; i < botImports.length; i += 1) { 25 | const imp = botImports[i]; 26 | modProms.push(imp.bot); 27 | modNames.push(imp.name); 28 | } 29 | 30 | await Promise.all(modProms).then((data) => { 31 | for (let i = 0; i < data.length; i += 1) { 32 | const module = data[i]; 33 | const name = modNames[i]; 34 | _botMap.set(name, module); 35 | } 36 | }); 37 | 38 | const botConfigs = []; 39 | for (let i = 0; i < _config.bots.length; i += 1) { 40 | const bot = { ..._config.bots[i] }; 41 | bot.developerAbbreviation = _config.developerAbbreviation; 42 | bot.protocolAbbreviation = _config.protocolAbbreviation; 43 | bot.protocolName = _config.protocolName; 44 | botConfigs.push(bot); 45 | } 46 | 47 | return botConfigs; 48 | } 49 | 50 | /* 51 | gatherType "all" is weird. A quick rundown: 52 | 53 | Inbound blocks look like this: 54 | HandleBlock 55 | - HandleTransaction 56 | - HandleTransaction 57 | - HandleTransaction 58 | - HandleTransaction 59 | 60 | Notable outbound cases: 61 | 62 | for all positive block findings, where we only have block handling bots: 63 | HandleBlock -> [Finding, Finding, Finding] 64 | - HandleTransaction -> [] 65 | 66 | for all positive tx findings, where we only have tx handling bots: 67 | HandleBlock -> [] 68 | - HandleTransaction -> [Finding, Finding, Finding] 69 | 70 | for all positive tx and block findings: 71 | HandleBlock -> [] 72 | - HandleTransaction -> [...blockFindings[Finding], ...txFindings[Finding, Finding]] 73 | 74 | for all positive block findings, some/no tx findings: 75 | HandleBlock -> [] 76 | - HandleTransaction -> [] 77 | 78 | for only some positive block findings, all/some/no tx findings: 79 | HandleBlock -> [] 80 | - HandleTransaction -> [] 81 | 82 | Bots can have block handlers, transaction handlers, or both 83 | 84 | if there are only bots with block handlers: 85 | - run the block handlers, aggregate the results 86 | - if all bots return findings, return the findings 87 | - else return [] 88 | if there are only bots with tx handlers: 89 | - run the tx handlers, aggregate the results 90 | - if all bots return findings, return the findings 91 | - else return [] 92 | 93 | if you've got mixed tx and block handlers: 94 | for all bots with block handlers: 95 | - if there are block handlers but no cached results 96 | (i.e. not all block handlers returned findings), return [] 97 | - if all bots return findings, cache that for the tx handlers 98 | for all bots with tx handlers: 99 | - if there are block handlers but no cached results 100 | (i.e. not all block handlers returned findings), return [] 101 | - if there are no block handlers, and all bots have tx findings, return the findings 102 | - if there are block handlers, and all bots have tx findings, return the findings 103 | */ 104 | 105 | function initializeBots(_config, _botMap, _botStates) { 106 | return async function initialize() { 107 | const botConfigs = await generateAllBots(_config, _botMap); 108 | 109 | /* eslint-disable no-param-reassign */ 110 | _botStates.gatherMode = _config.gatherMode; 111 | _botStates.txHandlerCount = 0; 112 | _botStates.blockHandlerCount = 0; 113 | _botStates.cachedResults = {}; 114 | _botStates.bots = []; 115 | /* eslint-enable no-param-reassign */ 116 | 117 | const botStateProms = botConfigs.map((bot) => { 118 | const botMod = _botMap.get(bot.botType); 119 | /* eslint-disable no-param-reassign */ 120 | if (botMod.handleTransaction !== undefined) { 121 | _botStates.txHandlerCount += 1; 122 | } 123 | if (botMod.handleBlock !== undefined) { 124 | _botStates.blockHandlerCount += 1; 125 | } 126 | /* eslint-enable no-param-reassign */ 127 | 128 | if (botMod.initialize === undefined) { 129 | const botState = { ...bot }; 130 | return new Promise(() => botState); 131 | } 132 | 133 | const prom = botMod.initialize(bot); 134 | return prom; 135 | }); 136 | 137 | const results = await Promise.all(botStateProms); 138 | results.forEach((result) => _botStates.bots.push(result)); 139 | }; 140 | } 141 | 142 | function handleAllBlocks(_botMap, _botStates) { 143 | return async function handleBlock(blockEvent) { 144 | if (_botStates.blockHandlerCount === 0) { 145 | return []; 146 | } 147 | 148 | const findProms = []; 149 | for (let i = 0; i < _botStates.bots.length; i += 1) { 150 | const bot = _botStates.bots[i]; 151 | const botMod = _botMap.get(bot.botType); 152 | if (botMod.handleBlock !== undefined) { 153 | findProms.push(botMod.handleBlock(bot, blockEvent)); 154 | } 155 | } 156 | 157 | const findings = await Promise.all(findProms); 158 | 159 | if (_botStates.gatherMode === 'any') { 160 | return findings.flat(); 161 | } 162 | 163 | // At this point, we're handling the nasty edge cases of all 164 | const allFindings = findings.every((finding) => finding.length > 0); 165 | 166 | if (!allFindings) { 167 | return []; 168 | } 169 | 170 | if (_botStates.txHandlerCount === 0) { 171 | return findings.flat(); 172 | } 173 | 174 | // eslint-disable-next-line no-param-reassign 175 | _botStates.cachedResults[blockEvent.block.hash] = { 176 | txTotal: blockEvent.block.transactions.length, 177 | txDone: 0, 178 | blockFindings: findings.flat(), 179 | }; 180 | return []; 181 | }; 182 | } 183 | 184 | function handleAllTransactions(_botMap, _botStates) { 185 | return async function handleTransaction(txEvent) { 186 | // We can't have any findings if we have no handlers! 187 | if (_botStates.txHandlerCount === 0) { 188 | return []; 189 | } 190 | 191 | const blockHash = txEvent.block.hash; 192 | const cachedBlock = _botStates.cachedResults[blockHash]; 193 | 194 | // if there are block handlers, but they didn't return all positive findings 195 | if (_botStates.gatherMode === 'all' && _botStates.blockHandlerCount > 0 && cachedBlock === undefined) { 196 | return []; 197 | } 198 | 199 | const findProms = []; 200 | for (let i = 0; i < _botStates.bots.length; i += 1) { 201 | const bot = _botStates.bots[i]; 202 | const botMod = _botMap.get(bot.botType); 203 | if (botMod.handleTransaction !== undefined) { 204 | findProms.push(botMod.handleTransaction(bot, txEvent)); 205 | } 206 | } 207 | const findings = await Promise.all(findProms); 208 | 209 | if (_botStates.gatherMode === 'any') { 210 | return findings.flat(); 211 | } 212 | 213 | // At this point, we're only handling the nasty edge cases of all 214 | const allFindings = findings.every((finding) => finding.length > 0); 215 | 216 | let blockFindings = []; 217 | if (_botStates.blockHandlerCount > 0) { 218 | // Assumption: That the JS async scheduler switches threadlets on a timer in addition to 219 | // explicit yields, so without this, we *may* wind up in a race condition where we 220 | // delete the cachedResults twice if we're *very* unlucky 221 | blockFindings = cachedBlock.blockFindings; 222 | 223 | // if we've finished all the transactions for a block, delete the cachedResults 224 | /* eslint-disable no-param-reassign */ 225 | // eslint-disable-next-line max-len 226 | if (_botStates.cachedResults[blockHash].txDone + 1 >= _botStates.cachedResults[blockHash].txTotal) { 227 | delete _botStates.cachedResults[blockHash]; 228 | } else { 229 | _botStates.cachedResults[blockHash].txDone += 1; 230 | } 231 | /* eslint-enable no-param-reassign */ 232 | } 233 | 234 | // If we didn't see enough tx findings 235 | if (!allFindings) { 236 | return []; 237 | } 238 | 239 | return [...blockFindings, ...findings.flat()]; 240 | }; 241 | } 242 | 243 | module.exports = { 244 | initializeBots, 245 | initialize: initializeBots(config, botMap, botStates), 246 | handleAllBlocks, 247 | handleBlock: handleAllBlocks(botMap, botStates), 248 | handleAllTransactions, 249 | handleTransaction: handleAllTransactions(botMap, botStates), 250 | botImports, 251 | }; 252 | -------------------------------------------------------------------------------- /src/contract-variable-monitor/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/contract-variable-monitor/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/contract-variable-monitor/README.md: -------------------------------------------------------------------------------- 1 | # Contract Variable Monitor Bot Template 2 | 3 | This bot monitors contract variables that contain numeric values for specified contract addresses. 4 | Upper and lower percent change thresholds, number of data points to collect before checking for percent changes, 5 | and alert type and severity are specified per variable per contract address. 6 | 7 | ## [Bot Setup Walkthrough](SETUP.md) 8 | -------------------------------------------------------------------------------- /src/contract-variable-monitor/SETUP.md: -------------------------------------------------------------------------------- 1 | # Contract Variable Monitor Bot Template 2 | 3 | This bot monitors contract variables that contain numeric values for specified contract addresses. 4 | Upper and lower percent change thresholds, number of data points to collect before checking for percent changes, 5 | and alert type and severity are specified per variable per contract address. 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | The following steps will take you from a completely blank template to a functional bot. 10 | 11 | 1. `contracts` (required) - The Object value for this key corresponds to contracts that we want to monitor variable 12 | values for. Each key in the Object is a contract name that we can specify, where that name is simply a string that we use 13 | as a label when referring to the contract (the string can be any valid string that we choose, it will not affect the 14 | monitoring by the bot). The Object corresponding to each contract name requires an `address` key/value pair, 15 | `abiFile` key/value pair, and a `variables` key. For the `variables` key, the corresponding value is an Object 16 | containing the names of contract variables as keys. Note that each respective variable key must return 17 | a numeric value from a contract. The value for each variable name is an Object containing: 18 | * type (required) - Forta Finding Type 19 | * severity (required) - Forta Finding Severity 20 | * upperThresholdPercent (optional) - Number as a change percentage that will trigger a finding if the monitored 21 | contract variable surpasses. Note if lowerThresholdPercent is not defined then this value is required. 22 | * lowerThresholdPercent (optional) - Number as a change percentage that will trigger a finding if the monitored 23 | contract variable is lower than. Note if upperThresholdPercent is not defined then this value is required. 24 | * numDataPoints (required) - Number of data points that need to be seen before calculating change 25 | percent. Note that if too high of a number is selected the bot may fail due to high memory usage. 26 | 27 | For example, to monitor a UniswapV3Pool contract's liquidity, we would need the contract address, the 28 | ABI saved locally as a JSON formatted file, the variable name (in this case liquidity) which will have 29 | a corresponding getter function in the contract's ABI, a finding type, a finding severity, either an 30 | upper threshold change percent or a lower threshold change percent, and the number of data points needed 31 | before calculating change percent. The following shows what the contracts portion of the config file 32 | would look like for this example: 33 | 34 | ``` json 35 | "contracts": { 36 | "UniswapV3Pool": { 37 | "address": "0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8", 38 | "abiFile": "UniswapV3Pool.json", 39 | "variables": { 40 | "liquidity": { 41 | "type": "Info", 42 | "severity": "Low", 43 | "upperThresholdPercent": 15, 44 | "lowerThresholdPercent": 5, 45 | "numDataPoints": 10 46 | } 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | Note: Any unused entries in the configuration file must be deleted for the bot to work. The original version 53 | of the configuration file contains several placeholders to show the structure of the file, but these are not valid 54 | entries for running the bot. 55 | 56 | Note: If a contract is proxied by another contract, make sure that the value for the `address` key is the 57 | address of the proxy contract. 58 | 59 | 2. We can obtain the contract ABI from one of several locations. The most accurate ABI will be the one 60 | corresponding to the original contract code that was compiled and deployed onto the blockchain. This typically will 61 | come from the Github repository of the protocol being monitored. For the Uniswap example provided thus far, the 62 | deployed contracts are all present in the Uniswap Github repository here: 63 | 64 | If the aforementioned route is chosen, a solidity compiler will need to be used with the smart contract(s) to output 65 | and store the corresponding ABI. 66 | 67 | As an alternative, a protocol may publish its smart contract code on Etherscan, where users may view the code, ABI, 68 | constructor arguments, etc. For these cases, simply navigate to `http://etherscan.io`, type the contract address 69 | into the search bar, and check the `Contract` tab. If the code has been published, there should be a `Contract ABI` 70 | section where the code can be exported in JSON format or copied to the clipboard. For the case of copying the ABI, 71 | the result would look something like: 72 | 73 | ```json 74 | [ 75 | { 76 | "constant": true, 77 | "inputs": [], 78 | "name": "liquidationIncentiveMantissa", 79 | "outputs": [ 80 | { 81 | "internalType": "uint256", 82 | "name": "", 83 | "type": "uint256" 84 | } 85 | ], 86 | "payable": false, 87 | "stateMutability": "view", 88 | "type": "function" 89 | } 90 | ] 91 | ``` 92 | 93 | We need to modify the ABI to make the copied/pasted result an entry in an Array corresponding to the key "abi" 94 | in the file: 95 | 96 | ```json 97 | { 98 | "abi": [ 99 | { 100 | "constant": true, 101 | "inputs": [], 102 | "name": "liquidationIncentiveMantissa", 103 | "outputs": [ 104 | { 105 | "internalType": "uint256", 106 | "name": "", 107 | "type": "uint256" 108 | } 109 | ], 110 | "payable": false, 111 | "stateMutability": "view", 112 | "type": "function" 113 | } 114 | ] 115 | } 116 | ``` 117 | 118 | The name of the JSON formatted file containing the ABI needs to have the same path as the value provided for 119 | the `abiFile` key in the `bot-config.json` file. This will allow the bot to load the ABI correctly 120 | and call the requested getter functions corresponding to the variables listed in the config. 121 | -------------------------------------------------------------------------------- /src/contract-variable-monitor/agent.js: -------------------------------------------------------------------------------- 1 | const BigNumber = require('bignumber.js'); 2 | const { 3 | Finding, FindingSeverity, FindingType, ethers, getEthersProvider, 4 | } = require('forta-agent'); 5 | 6 | const utils = require('../utils'); 7 | const { 8 | getObjectsFromAbi, 9 | } = require('../test-utils'); 10 | 11 | // helper function to create alerts 12 | function createAlert( 13 | variableName, 14 | contractName, 15 | contractAddress, 16 | type, 17 | severity, 18 | protocolName, 19 | protocolAbbreviation, 20 | developerAbbreviation, 21 | thresholdPosition, 22 | thresholdPercentLimit, 23 | actualPercentChange, 24 | ) { 25 | return Finding.fromObject({ 26 | name: `${protocolName} Contract Variable`, 27 | description: `The ${variableName} variable value in the ${contractName} contract had a change` 28 | + ` in value over the ${thresholdPosition} threshold limit of ${thresholdPercentLimit}` 29 | + ' percent', 30 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-CONTRACT-VARIABLE`, 31 | type: FindingType[type], 32 | severity: FindingSeverity[severity], 33 | protocol: protocolName, 34 | metadata: { 35 | contractName, 36 | contractAddress, 37 | variableName, 38 | thresholdPosition, 39 | thresholdPercentLimit: thresholdPercentLimit.toString(), 40 | actualPercentChange, 41 | }, 42 | }); 43 | } 44 | 45 | const validateConfig = (config, abiOverride = null) => { 46 | let ok = false; 47 | let errMsg = ''; 48 | 49 | if (!utils.isFilledString(config.developerAbbreviation)) { 50 | errMsg = 'developerAbbreviation required'; 51 | return { ok, errMsg }; 52 | } 53 | if (!utils.isFilledString(config.protocolName)) { 54 | errMsg = 'protocolName required'; 55 | return { ok, errMsg }; 56 | } 57 | if (!utils.isFilledString(config.protocolAbbreviation)) { 58 | errMsg = 'protocolAbbreviation required'; 59 | return { ok, errMsg }; 60 | } 61 | 62 | const { contracts } = config; 63 | if (!utils.isObject(contracts) || utils.isEmptyObject(contracts)) { 64 | errMsg = 'contracts key required'; 65 | return { ok, errMsg }; 66 | } 67 | 68 | const values = Object.values(contracts); 69 | for (let i = 0; i < values.length; i += 1) { 70 | const { address, abiFile, variables } = values[i]; 71 | 72 | // check that the address is a valid address 73 | if (!utils.isAddress(address)) { 74 | errMsg = 'invalid address'; 75 | return { ok, errMsg }; 76 | } 77 | 78 | // load the ABI from the specified file 79 | // the call to getAbi will fail if the file does not exist 80 | let abi; 81 | if (abiOverride != null) { 82 | abi = abiOverride[abiFile]; 83 | } else { 84 | try { 85 | abi = utils.getAbi(config.name, abiFile); 86 | } catch (error) { 87 | console.error(error); 88 | const path = utils.buildAbiPath(config.name, abiFile); 89 | errMsg = `Unable to get abi file! ${path}`; 90 | return { ok, errMsg }; 91 | } 92 | } 93 | 94 | // get all of the function objects from the loaded ABI file 95 | const functionObjects = getObjectsFromAbi(abi, 'function'); 96 | 97 | // for all of the variable names specified, verify that their corresponding getter function 98 | // exists in the ABI 99 | let variableName; 100 | const variableNames = Object.keys(variables); 101 | for (let j = 0; j < variableNames.length; j += 1) { 102 | variableName = variableNames[j]; 103 | if (Object.keys(functionObjects).indexOf(variableName) === -1) { 104 | errMsg = 'invalid event'; 105 | return { ok, errMsg }; 106 | } 107 | 108 | // assert that the output array length for the getter function is one 109 | if (functionObjects[variableName].outputs.length !== 1) { 110 | errMsg = 'invalid variable'; 111 | return { ok, errMsg }; 112 | } 113 | 114 | // assert that the type of the output for the getter function is a (u)int type 115 | if (functionObjects[variableName].outputs[0].type.match(/^u?int/) === null) { 116 | errMsg = 'invalid getter function type'; 117 | return { ok, errMsg }; 118 | } 119 | 120 | // extract the keys from the configuration file for a specific function 121 | const { 122 | type, 123 | severity, 124 | upperThresholdPercent, 125 | lowerThresholdPercent, 126 | numDataPoints, 127 | } = variables[variableName]; 128 | 129 | // check type, this will fail if 'type' is not valid 130 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 131 | errMsg = 'invalid finding type!'; 132 | return { ok, errMsg }; 133 | } 134 | 135 | // check severity, this will fail if 'severity' is not valid 136 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 137 | errMsg = 'invalid finding severity!'; 138 | return { ok, errMsg }; 139 | } 140 | 141 | // make sure there is at least one threshold value present in the config, otherwise fail 142 | if (upperThresholdPercent === undefined && lowerThresholdPercent === undefined) { 143 | errMsg = ('Either the upperThresholdPercent or lowerThresholdPercent for the' 144 | + ` variable ${variableName} must be defined`); 145 | return { ok, errMsg }; 146 | } 147 | 148 | // if upperThresholdPercent is defined, make sure the value is a number 149 | if (upperThresholdPercent !== undefined && typeof upperThresholdPercent !== 'number') { 150 | errMsg = 'invalid upperThresholdPercent'; 151 | return { ok, errMsg }; 152 | } 153 | 154 | // if lowerThresholdPercent is defined, make sure the value is a number 155 | if (lowerThresholdPercent !== undefined && typeof lowerThresholdPercent !== 'number') { 156 | errMsg = 'invalid lowerThresholdPercent'; 157 | return { ok, errMsg }; 158 | } 159 | 160 | // make sure value for numDataPoints in config is a number 161 | if (typeof numDataPoints !== 'number') { 162 | errMsg = 'invalid numDataPoints'; 163 | return { ok, errMsg }; 164 | } 165 | } 166 | } 167 | 168 | ok = true; 169 | return { ok, errMsg }; 170 | }; 171 | 172 | const initialize = async (config, abiOverride = null) => { 173 | const botState = { ...config }; 174 | 175 | const { ok, errMsg } = validateConfig(config, abiOverride); 176 | if (!ok) { 177 | throw new Error(errMsg); 178 | } 179 | 180 | botState.variableInfoList = []; 181 | 182 | const provider = getEthersProvider(); 183 | 184 | // load the contract addresses, abis, and generate an ethers contract for each contract name 185 | // listed in the config 186 | const contractList = Object.entries(config.contracts).map(([name, entry]) => { 187 | let abi; 188 | if (abiOverride != null) { 189 | abi = abiOverride[entry.abiFile]; 190 | } else { 191 | abi = utils.getAbi(config.name, entry.abiFile); 192 | } 193 | 194 | const contract = new ethers.Contract(entry.address, abi, provider); 195 | return { name, contract }; 196 | }); 197 | 198 | contractList.forEach((contractEntry) => { 199 | const entry = config.contracts[contractEntry.name]; 200 | const { info } = utils.getVariableInfo(entry, contractEntry); 201 | botState.variableInfoList.push(...info); 202 | }); 203 | 204 | return botState; 205 | }; 206 | 207 | // this function does not use any values stored on the blockEvent 208 | // it uses each blockEvent to trigger the handler 209 | const handleBlock = async (botState) => { 210 | // for each item present in variableInfoList, attempt to invoke the getter method 211 | // corresponding to the item's name and make sure it is within the specified threshold percent 212 | const variablePromises = botState.variableInfoList.map(async (variableInfo) => { 213 | const variableFindings = []; 214 | const { 215 | name: variableName, 216 | type, 217 | severity, 218 | contractInfo, 219 | upperThresholdPercent, 220 | lowerThresholdPercent, 221 | minNumElements, 222 | pastValues, 223 | } = variableInfo; 224 | const { name: contractName, contract } = contractInfo; 225 | 226 | let newValue = await contract[variableName](); 227 | newValue = new BigNumber(newValue.toString()); 228 | const averageBN = pastValues.getAverage(); 229 | 230 | // check the current number of elements in the pastValues array 231 | if (pastValues.getNumElements() >= minNumElements) { 232 | // only check for an upperThresholdPercent change if upperThresholdPercent exists and the 233 | // new value is greater than the current average 234 | if (upperThresholdPercent !== undefined && newValue.gt(averageBN)) { 235 | const percentOver = utils.checkThreshold(upperThresholdPercent, newValue, pastValues); 236 | if (percentOver !== undefined) { 237 | variableFindings.push(createAlert( 238 | variableName, 239 | contractName, 240 | contract.address, 241 | type, 242 | severity, 243 | botState.protocolName, 244 | botState.protocolAbbreviation, 245 | botState.developerAbbreviation, 246 | 'upper', 247 | upperThresholdPercent, 248 | percentOver.toString(), 249 | )); 250 | } 251 | } 252 | 253 | // only check for a lowerThresholdPercent change if lowerThresholdPercent exists and the 254 | // new value is less than the current average 255 | if (lowerThresholdPercent !== undefined && newValue.lt(averageBN)) { 256 | const percentOver = utils.checkThreshold(lowerThresholdPercent, newValue, pastValues); 257 | if (percentOver !== undefined) { 258 | variableFindings.push(createAlert( 259 | variableName, 260 | contractName, 261 | contract.address, 262 | type, 263 | severity, 264 | botState.protocolName, 265 | botState.protocolAbbreviation, 266 | botState.developerAbbreviation, 267 | 'lower', 268 | lowerThresholdPercent, 269 | percentOver.toString(), 270 | )); 271 | } 272 | } 273 | } 274 | 275 | // add the value received in this iteration to the pastValues array 276 | pastValues.addElement(newValue); 277 | return variableFindings; 278 | }); 279 | 280 | const findings = (await Promise.all(variablePromises)).flat(); 281 | return findings; 282 | }; 283 | 284 | module.exports = { 285 | validateConfig, 286 | initialize, 287 | handleBlock, 288 | createAlert, 289 | }; 290 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | forta.config.json -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | 'no-console': ['error', { allow: ['error'] }], 16 | }, 17 | overrides: [ 18 | { 19 | files: '*', 20 | rules: { 21 | 'no-plusplus': 'off', 22 | 'no-continue': 'off', 23 | }, 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/README.md: -------------------------------------------------------------------------------- 1 | # Gnosis-Safe MultiSig Wallet Bot Template 2 | 3 | This bot monitors a Gnosis-Safe multi-signature contract address for events emitted and any 4 | changes in Ether or token balances. 5 | 6 | ## [Bot Setup Walkthrough](SETUP.md) 7 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/SETUP.md: -------------------------------------------------------------------------------- 1 | # Gnosis-Safe MultiSig Wallet Bot Template 2 | 3 | This bot monitors a Gnosis-Safe multi-signature contract address for events emitted and any 4 | changes in Ether or token balances. All alert types and severities are set to Info by default. 5 | 6 | ## Bot Setup Walkthrough 7 | 8 | 1. `version` (required) - The value for this key corresponds to the version of the contract that we want 9 | to monitor for emitted events and balance changes. The supported versions are `v1.0.0`, `v1.1.1`, 10 | `v1.2.0`, and `v1.3.0`, where JSON files containing the ABIs for those versions are located in the `./abi` 11 | directory and in their respective subdirectories. 12 | 13 | For example, to monitor the Synthetix protocolDAO multisig contract for emitted events and balance 14 | changes, the following content would be present in the `bot-config.json` file: 15 | 16 | ```json 17 | { 18 | "developerAbbreviation": "AE", 19 | "protocolName": "Synthetix", 20 | "protocolAbbreviation": "SYN", 21 | "contracts": { 22 | "contractName1": { 23 | "address": "0xEb3107117FEAd7de89Cd14D463D340A2E6917769", 24 | "version": "v1.0.0" 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | Note that any unused entries in the configuration file must be deleted for the bot to work. The 31 | original version of the configuration file contains several placeholders to show the structure of 32 | the file, but these are not valid entries for running the bot. 33 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/agent.js: -------------------------------------------------------------------------------- 1 | const BigNumber = require('bignumber.js'); 2 | const { 3 | ethers, Finding, FindingSeverity, FindingType, getEthersProvider, 4 | } = require('forta-agent'); 5 | 6 | const utils = require('../utils'); 7 | const versionUtils = require('./version-utils'); 8 | 9 | const validateConfig = (config) => { 10 | let ok = false; 11 | let errMsg = ''; 12 | 13 | if (!utils.isFilledString(config.developerAbbreviation)) { 14 | errMsg = 'developerAbbreviation required'; 15 | return { ok, errMsg }; 16 | } 17 | if (!utils.isFilledString(config.protocolName)) { 18 | errMsg = 'protocolName required'; 19 | return { ok, errMsg }; 20 | } 21 | if (!utils.isFilledString(config.protocolAbbreviation)) { 22 | errMsg = 'protocolAbbreviation required'; 23 | return { ok, errMsg }; 24 | } 25 | 26 | const { contracts } = config; 27 | if (!utils.isObject(contracts) || utils.isEmptyObject(contracts)) { 28 | errMsg = 'contracts key required'; 29 | return { ok, errMsg }; 30 | } 31 | 32 | const safes = Object.values(contracts); 33 | let safe; 34 | for (let i = 0; i < safes.length; i += 1) { 35 | safe = safes[i]; 36 | if (!utils.isObject(safe) || utils.isEmptyObject(safe)) { 37 | errMsg = 'invalid value specified'; 38 | return { ok, errMsg }; 39 | } 40 | 41 | const { address, version } = safe; 42 | 43 | // check that the address is a valid address 44 | if (!utils.isAddress(address)) { 45 | errMsg = 'invalid address'; 46 | return { ok, errMsg }; 47 | } 48 | 49 | // check that there is a corresponding file for the version indicated 50 | // eslint-disable-next-line import/no-dynamic-require,global-require 51 | const abi = utils.getInternalAbi(config.botType, `${version}/gnosis-safe.json`); 52 | 53 | if (!utils.isObject(abi) || utils.isEmptyObject(abi)) { 54 | errMsg = 'gnosis-safe abi required'; 55 | return { ok, errMsg }; 56 | } 57 | } 58 | 59 | ok = true; 60 | return { ok, errMsg }; 61 | }; 62 | 63 | const initialize = async (config) => { 64 | const botState = { ...config }; 65 | 66 | const { ok, errMsg } = validateConfig(config); 67 | if (!ok) { 68 | throw new Error(errMsg); 69 | } 70 | 71 | botState.provider = getEthersProvider(); 72 | 73 | // grab erc20 abi and create an interface 74 | const erc20Abi = utils.getInternalAbi(config.botType, 'ERC20.json'); 75 | const erc20Interface = new ethers.utils.Interface(erc20Abi); 76 | 77 | // save the erc20 ABI and Transfer signature for later use 78 | botState.erc20Abi = erc20Abi; 79 | const ftype = ethers.utils.FormatTypes.full; 80 | botState.transferSignature = erc20Interface.getEvent('Transfer').format(ftype); 81 | 82 | const safeEntries = Object.entries(config.contracts); 83 | botState.contracts = await Promise.all(safeEntries.map(async ([, entry]) => { 84 | const { version } = entry; 85 | let { address } = entry; 86 | address = address.toLowerCase(); 87 | 88 | // get the current block number to retrieve all past Transfer events 89 | const blockNumber = await botState.provider.getBlockNumber(); 90 | 91 | // look up all Transfer events to this address 92 | const topics = erc20Interface.encodeFilterTopics('Transfer', [null, address]); 93 | 94 | const filter = { 95 | fromBlock: 0, 96 | toBlock: blockNumber, 97 | topics, 98 | }; 99 | const rawLogs = await botState.provider.getLogs(filter); 100 | // extract the token addresses from the Transfer events 101 | let tokenAddresses = rawLogs.map((rawLog) => rawLog.address.toLowerCase()); 102 | 103 | // get rid of any duplicates 104 | tokenAddresses = [...new Set(tokenAddresses)]; 105 | 106 | // create ethers contract objects for each token 107 | // eslint-disable-next-line max-len 108 | const tokenContracts = tokenAddresses.map((tokenAddress) => new ethers.Contract(tokenAddress, erc20Abi, botState.provider)); 109 | 110 | // load the appropriate abi 111 | // eslint-disable-next-line import/no-dynamic-require,global-require 112 | const abi = utils.getInternalAbi(config.botType, `${version}/gnosis-safe.json`); 113 | const iface = new ethers.utils.Interface(abi); 114 | const names = Object.keys(iface.events); // filter out only the events from the abi 115 | const eventSignatures = names.map((iName) => iface.getEvent(iName).format(ftype)); 116 | 117 | const previousBalances = {}; 118 | const contract = { 119 | address, 120 | version, 121 | tokenContracts, 122 | eventSignatures, 123 | tokenAddresses, 124 | previousBalances, 125 | }; 126 | 127 | return contract; 128 | })); 129 | 130 | return botState; 131 | }; 132 | 133 | const handleTransaction = async (botState, txEvent) => { 134 | const findings = []; 135 | 136 | // if any transfers occurred to or from this safe, store the token address and create an ethers 137 | // contract object for interactions in the handleBlock function 138 | const transferLogs = txEvent.filterLog(botState.transferSignature); 139 | botState.contracts.forEach((contract) => { 140 | transferLogs.forEach((log) => { 141 | const addressLower = contract.address.toLowerCase(); 142 | // eslint-disable-next-line max-len 143 | if (log.args.from.toLowerCase() !== addressLower && log.args.to.toLowerCase() !== addressLower) { 144 | return; 145 | } 146 | 147 | const logAddressLower = log.address.toLowerCase(); 148 | const { tokenAddresses } = contract; 149 | // eslint-disable-next-line max-len 150 | if ((tokenAddresses.indexOf(logAddressLower) === -1) && tokenAddresses.push(logAddressLower)) { 151 | // eslint-disable-next-line max-len 152 | const tokenContract = new ethers.Contract(log.address, botState.erc20Abi, botState.provider); 153 | contract.tokenContracts.push(tokenContract); 154 | } 155 | }); 156 | 157 | // filter for any events emitted by the safe contract 158 | const logs = txEvent.filterLog(contract.eventSignatures, contract.address); 159 | logs.forEach((log) => { 160 | const findingObject = versionUtils.getFindings( 161 | contract.version, 162 | log.name, 163 | botState.protocolName, 164 | botState.protocolAbbreviation, 165 | botState.developerAbbreviation, 166 | contract.address, 167 | log.args, 168 | ); 169 | if (!findingObject) { 170 | return; 171 | } 172 | 173 | let addresses = Object.keys(txEvent.addresses).map((address) => address.toLowerCase()); 174 | addresses = addresses.filter((address) => address !== 'undefined'); 175 | 176 | findingObject.type = FindingType.Info; 177 | findingObject.severity = FindingSeverity.Info; 178 | findingObject.protocol = botState.protocolName; 179 | findingObject.addresses = addresses; 180 | const finding = Finding.fromObject(findingObject); 181 | findings.push(finding); 182 | }); 183 | }); 184 | 185 | return findings; 186 | }; 187 | 188 | const handleBlock = async (botState) => { 189 | const { 190 | developerAbbreviation, 191 | protocolAbbreviation, 192 | protocolName, 193 | } = botState; 194 | 195 | // find changes in eth balance and tokens for every gnosis safe address 196 | const totalFindings = await Promise.all(botState.contracts.map(async (contract) => { 197 | const { address } = contract; 198 | const ethBalance = await botState.provider.getBalance(address); 199 | const ethBalanceBN = new BigNumber(ethBalance.toString()); 200 | 201 | // created new token contract for every token transfer that's ever happened to this address 202 | const promises = contract.tokenContracts.map(async (tokenContract) => { 203 | const result = {}; 204 | try { 205 | // get the balance of each token for this specific safe 206 | const tokenBalance = await tokenContract.balanceOf(address); 207 | result[tokenContract.address.toLowerCase()] = new BigNumber(tokenBalance.toString()); 208 | } catch { 209 | result[tokenContract.address.toLowerCase()] = new BigNumber(0); 210 | } 211 | 212 | return result; 213 | }); 214 | 215 | const tokenBalances = {}; 216 | const tokenBalancesArray = await Promise.all(promises); 217 | tokenBalancesArray.forEach((entry) => { 218 | Object.entries(entry).forEach(([key, value]) => { 219 | tokenBalances[key] = value; 220 | }); 221 | }); 222 | 223 | // check the current balances against the previous balances 224 | const findings = []; 225 | Object.entries(contract.previousBalances).forEach(([key, value]) => { 226 | if (key === 'Ether') { 227 | if (!value.eq(ethBalanceBN)) { 228 | findings.push(Finding.fromObject({ 229 | name: `${protocolName} DAO Treasury MultiSig - Ether Balance Changed`, 230 | description: `Ether balance of ${address} changed by ${ethBalanceBN.minus(value).toString()}`, 231 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-ETH-BALANCE-CHANGE`, 232 | type: FindingType.Info, 233 | severity: FindingSeverity.Info, 234 | protocol: protocolName, 235 | metadata: { 236 | previousBalance: value.toString(), 237 | newBalance: ethBalanceBN.toString(), 238 | }, 239 | })); 240 | } 241 | } else if (!value.eq(tokenBalances[key])) { 242 | findings.push(Finding.fromObject({ 243 | name: `${protocolName} DAO Treasury MultiSig - Token Balance Changed`, 244 | description: `Token balance of ${address} changed by ${tokenBalances[key].minus(value).toString()}`, 245 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-TOKEN-BALANCE-CHANGE`, 246 | type: FindingType.Info, 247 | severity: FindingSeverity.Info, 248 | protocol: protocolName, 249 | metadata: { 250 | previousBalance: value.toString(), 251 | newBalance: tokenBalances[key].toString(), 252 | tokenAddress: key, 253 | }, 254 | })); 255 | } 256 | }); 257 | 258 | // update the stored balances 259 | /* eslint-disable no-param-reassign */ 260 | contract.previousBalances.Ether = ethBalanceBN; 261 | Object.entries(tokenBalances).forEach(([key, value]) => { 262 | contract.previousBalances[key] = value; 263 | }); 264 | /* eslint-enable no-param-reassign */ 265 | 266 | return findings; 267 | })); 268 | 269 | return totalFindings.flat(); 270 | }; 271 | 272 | module.exports = { 273 | validateConfig, 274 | initialize, 275 | handleTransaction, 276 | handleBlock, 277 | }; 278 | -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/internal-abi/ERC20.json: -------------------------------------------------------------------------------- 1 | { 2 | "abi": 3 | [ 4 | { 5 | "constant": true, 6 | "inputs": [], 7 | "name": "name", 8 | "outputs": [ 9 | { 10 | "name": "", 11 | "type": "string" 12 | } 13 | ], 14 | "payable": false, 15 | "stateMutability": "view", 16 | "type": "function" 17 | }, 18 | { 19 | "constant": false, 20 | "inputs": [ 21 | { 22 | "name": "_spender", 23 | "type": "address" 24 | }, 25 | { 26 | "name": "_value", 27 | "type": "uint256" 28 | } 29 | ], 30 | "name": "approve", 31 | "outputs": [ 32 | { 33 | "name": "", 34 | "type": "bool" 35 | } 36 | ], 37 | "payable": false, 38 | "stateMutability": "nonpayable", 39 | "type": "function" 40 | }, 41 | { 42 | "constant": true, 43 | "inputs": [], 44 | "name": "totalSupply", 45 | "outputs": [ 46 | { 47 | "name": "", 48 | "type": "uint256" 49 | } 50 | ], 51 | "payable": false, 52 | "stateMutability": "view", 53 | "type": "function" 54 | }, 55 | { 56 | "constant": false, 57 | "inputs": [ 58 | { 59 | "name": "_from", 60 | "type": "address" 61 | }, 62 | { 63 | "name": "_to", 64 | "type": "address" 65 | }, 66 | { 67 | "name": "_value", 68 | "type": "uint256" 69 | } 70 | ], 71 | "name": "transferFrom", 72 | "outputs": [ 73 | { 74 | "name": "", 75 | "type": "bool" 76 | } 77 | ], 78 | "payable": false, 79 | "stateMutability": "nonpayable", 80 | "type": "function" 81 | }, 82 | { 83 | "constant": true, 84 | "inputs": [], 85 | "name": "decimals", 86 | "outputs": [ 87 | { 88 | "name": "", 89 | "type": "uint8" 90 | } 91 | ], 92 | "payable": false, 93 | "stateMutability": "view", 94 | "type": "function" 95 | }, 96 | { 97 | "constant": true, 98 | "inputs": [ 99 | { 100 | "name": "_owner", 101 | "type": "address" 102 | } 103 | ], 104 | "name": "balanceOf", 105 | "outputs": [ 106 | { 107 | "name": "balance", 108 | "type": "uint256" 109 | } 110 | ], 111 | "payable": false, 112 | "stateMutability": "view", 113 | "type": "function" 114 | }, 115 | { 116 | "constant": true, 117 | "inputs": [], 118 | "name": "symbol", 119 | "outputs": [ 120 | { 121 | "name": "", 122 | "type": "string" 123 | } 124 | ], 125 | "payable": false, 126 | "stateMutability": "view", 127 | "type": "function" 128 | }, 129 | { 130 | "constant": false, 131 | "inputs": [ 132 | { 133 | "name": "_to", 134 | "type": "address" 135 | }, 136 | { 137 | "name": "_value", 138 | "type": "uint256" 139 | } 140 | ], 141 | "name": "transfer", 142 | "outputs": [ 143 | { 144 | "name": "", 145 | "type": "bool" 146 | } 147 | ], 148 | "payable": false, 149 | "stateMutability": "nonpayable", 150 | "type": "function" 151 | }, 152 | { 153 | "constant": true, 154 | "inputs": [ 155 | { 156 | "name": "_owner", 157 | "type": "address" 158 | }, 159 | { 160 | "name": "_spender", 161 | "type": "address" 162 | } 163 | ], 164 | "name": "allowance", 165 | "outputs": [ 166 | { 167 | "name": "", 168 | "type": "uint256" 169 | } 170 | ], 171 | "payable": false, 172 | "stateMutability": "view", 173 | "type": "function" 174 | }, 175 | { 176 | "payable": true, 177 | "stateMutability": "payable", 178 | "type": "fallback" 179 | }, 180 | { 181 | "anonymous": false, 182 | "inputs": [ 183 | { 184 | "indexed": true, 185 | "name": "owner", 186 | "type": "address" 187 | }, 188 | { 189 | "indexed": true, 190 | "name": "spender", 191 | "type": "address" 192 | }, 193 | { 194 | "indexed": false, 195 | "name": "value", 196 | "type": "uint256" 197 | } 198 | ], 199 | "name": "Approval", 200 | "type": "event" 201 | }, 202 | { 203 | "anonymous": false, 204 | "inputs": [ 205 | { 206 | "indexed": true, 207 | "name": "from", 208 | "type": "address" 209 | }, 210 | { 211 | "indexed": true, 212 | "name": "to", 213 | "type": "address" 214 | }, 215 | { 216 | "indexed": false, 217 | "name": "value", 218 | "type": "uint256" 219 | } 220 | ], 221 | "name": "Transfer", 222 | "type": "event" 223 | } 224 | ] 225 | } -------------------------------------------------------------------------------- /src/gnosis-safe-multisig/version-utils.js: -------------------------------------------------------------------------------- 1 | function getFindings( 2 | version, 3 | eventName, 4 | protocolName, 5 | protocolAbbreviation, 6 | developerAbbreviation, 7 | address, 8 | args, 9 | ) { 10 | const addedOwnerObject = { 11 | name: `${protocolName} DAO Treasury MultiSig - AddedOwner`, 12 | description: `Owner added to Gnosis-Safe MultiSig wallet: ${args.owner}`, 13 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-ADDED-OWNER`, 14 | metadata: { 15 | address, 16 | owner: args.owner, 17 | }, 18 | }; 19 | 20 | const approveHashObject = { 21 | name: `${protocolName} DAO Treasury MultiSig - ApproveHash`, 22 | description: `Hash ${args.approvedHash} marked as approved by owner ${args.owner}`, 23 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-APPROVE-HASH`, 24 | metadata: { 25 | address, 26 | owner: args.owner, 27 | approvedHash: args.approvedHash, 28 | }, 29 | }; 30 | 31 | const payment = args.payment ? args.payment.toString() : ''; 32 | const executionFailureObject = { 33 | name: `${protocolName} DAO Treasury MultiSig - ExecutionFailure`, 34 | description: `Failed to execute transaction with hash ${args.txHash}, payment ${payment}`, 35 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-EXECUTION-FAILURE`, 36 | metadata: { 37 | address, 38 | txHash: args.txHash, 39 | payment, 40 | }, 41 | }; 42 | 43 | const executionSuccessObject = { 44 | name: `${protocolName} DAO Treasury MultiSig - ExecutionSuccess`, 45 | description: `Succeeded executing transaction with hash ${args.txHash}, payment ${payment}`, 46 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-EXECUTION-SUCCESS`, 47 | metadata: { 48 | address, 49 | txHash: args.txHash, 50 | payment, 51 | }, 52 | }; 53 | 54 | const executionFromModuleFailureObject = { 55 | name: `${protocolName} DAO Treasury MultiSig - ExecutionFromModuleFailure`, 56 | description: `Failed executing transaction using module: ${args.module}`, 57 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-EXECUTION-FROM-MODULE-FAILURE`, 58 | metadata: { 59 | address, 60 | module: args.module, 61 | }, 62 | }; 63 | 64 | const executionFromModuleSuccessObject = { 65 | name: `${protocolName} DAO Treasury MultiSig - ExecutionFromModuleSuccess`, 66 | description: `Succeeded executing transaction using module: ${args.module}`, 67 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-EXECUTION-FROM-MODULE-SUCCESS`, 68 | metadata: { 69 | address, 70 | module: args.module, 71 | }, 72 | }; 73 | 74 | const removedOwnerObject = { 75 | name: `${protocolName} DAO Treasury MultiSig - RemovedOwner`, 76 | description: `Owner removed from Gnosis-Safe MultiSig wallet: ${args.owner}`, 77 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-REMOVED-OWNER`, 78 | metadata: { 79 | address, 80 | owner: args.owner, 81 | }, 82 | }; 83 | 84 | const threshold = args.threshold ? args.threshold.toString() : ''; 85 | const changedThresholdObject = { 86 | name: `${protocolName} DAO Treasury MultiSig - ChangedThreshold`, 87 | description: `Number of required confirmation changed to ${threshold}`, 88 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-CHANGED-THRESHOLD`, 89 | metadata: { 90 | address, 91 | threshold, 92 | }, 93 | }; 94 | 95 | const safeSetupObject = { 96 | name: `${protocolName} DAO Treasury MultiSig - SafeSetup`, 97 | description: `Initialized storage of contract by ${args.initiator} with threshold ${threshold}`, 98 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-SAFE-SETUP`, 99 | metadata: { 100 | address, 101 | initiator: args.initiator, 102 | owners: args.owners, 103 | threshold, 104 | initializer: args.initializer, 105 | fallbackHandler: args.fallbackHandler, 106 | }, 107 | }; 108 | 109 | const enabledModuleObject = { 110 | name: `${protocolName} DAO Treasury MultiSig - EnabledModule`, 111 | description: `Module ${args.module} added to the whitelist`, 112 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-ENABLED-MODULE`, 113 | metadata: { 114 | address, 115 | module: args.module, 116 | }, 117 | }; 118 | 119 | const disabledModuleObject = { 120 | name: `${protocolName} DAO Treasury MultiSig - DisabledModule`, 121 | description: `Module ${args.module} removed from the whitelist`, 122 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-DISABLED-MODULE`, 123 | metadata: { 124 | address, 125 | module: args.module, 126 | }, 127 | }; 128 | 129 | const signMsgObject = { 130 | name: `${protocolName} DAO Treasury MultiSig - SignMsg`, 131 | description: `Message signed, hash: ${args.msgHash}`, 132 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-SIGN-MSG`, 133 | metadata: { 134 | address, 135 | msgHash: args.msgHash, 136 | }, 137 | }; 138 | 139 | const changedMasterCopyObject = { 140 | name: `${protocolName} DAO Treasury MultiSig - ChangedMasterCopy`, 141 | description: `Migrated contract, master copy address: ${args.masterCopy}`, 142 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-CHANGED-MASTER-COPY`, 143 | metadata: { 144 | address, 145 | masterCopy: args.masterCopy, 146 | }, 147 | }; 148 | 149 | const executionFailedObject = { 150 | name: `${protocolName} DAO Treasury MultiSig - ExecutionFailed`, 151 | description: `Failed to execute transaction with hash ${args.txHash}`, 152 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-EXECUTION-FAILED`, 153 | metadata: { 154 | address, 155 | txHash: args.txHash, 156 | }, 157 | }; 158 | 159 | const contractCreationObject = { 160 | name: `${protocolName} DAO Treasury MultiSig - ContractCreation`, 161 | description: `New contract deployed at address ${args.newContract}`, 162 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-CONTRACT-CREATION`, 163 | metadata: { 164 | address, 165 | newContract: args.newContract, 166 | }, 167 | }; 168 | 169 | const changedFallbackHandlerObject = { 170 | name: `${protocolName} DAO Treasury MultiSig - ChangedFallbackHandler`, 171 | description: `Fallback handler changed to ${args.handler}`, 172 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-CHANGED-FALLBACK-HANDLER`, 173 | metadata: { 174 | address, 175 | handler: args.handler, 176 | }, 177 | }; 178 | 179 | const changedGuardObject = { 180 | name: `${protocolName} DAO Treasury MultiSig - ChangedGuard`, 181 | description: `Guard that checks transactions before execution changed to ${args.guard}`, 182 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-CHANGED-GUARD`, 183 | metadata: { 184 | address, 185 | guard: args.guard, 186 | }, 187 | }; 188 | 189 | const value = args.value ? args.value.toString() : ''; 190 | const safeReceivedObject = { 191 | name: `${protocolName} DAO Treasury MultiSig - SafeReceived`, 192 | description: `Safe received ether payments via fallback method. Sender: ${args.sender}, Value: ${value}`, 193 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-DAO-MULTISIG-SAFE-RECEIVED`, 194 | metadata: { 195 | address, 196 | sender: args.sender, 197 | value, 198 | }, 199 | }; 200 | 201 | const findingsObjects = { 202 | 'v1.0.0': { 203 | AddedOwner: addedOwnerObject, 204 | RemovedOwner: removedOwnerObject, 205 | ChangedThreshold: changedThresholdObject, 206 | EnabledModule: enabledModuleObject, 207 | DisabledModule: disabledModuleObject, 208 | ExecutionFailed: executionFailedObject, 209 | ContractCreation: contractCreationObject, 210 | }, 211 | 'v1.1.1': { 212 | AddedOwner: addedOwnerObject, 213 | RemovedOwner: removedOwnerObject, 214 | ChangedThreshold: changedThresholdObject, 215 | EnabledModule: enabledModuleObject, 216 | DisabledModule: disabledModuleObject, 217 | ApproveHash: approveHashObject, 218 | ChangedMasterCopy: changedMasterCopyObject, 219 | ExecutionFailure: executionFailureObject, 220 | ExecutionFromModuleFailure: executionFromModuleFailureObject, 221 | ExecutionFromModuleSuccess: executionFromModuleSuccessObject, 222 | ExecutionSuccess: executionSuccessObject, 223 | SignMsg: signMsgObject, 224 | }, 225 | 'v1.2.0': { 226 | AddedOwner: addedOwnerObject, 227 | RemovedOwner: removedOwnerObject, 228 | ChangedThreshold: changedThresholdObject, 229 | EnabledModule: enabledModuleObject, 230 | DisabledModule: disabledModuleObject, 231 | ApproveHash: approveHashObject, 232 | ChangedMasterCopy: changedMasterCopyObject, 233 | ExecutionFailure: executionFailureObject, 234 | ExecutionFromModuleFailure: executionFromModuleFailureObject, 235 | ExecutionFromModuleSuccess: executionFromModuleSuccessObject, 236 | ExecutionSuccess: executionSuccessObject, 237 | SignMsg: signMsgObject, 238 | }, 239 | 'v1.3.0': { 240 | AddedOwner: addedOwnerObject, 241 | RemovedOwner: removedOwnerObject, 242 | ChangedThreshold: changedThresholdObject, 243 | EnabledModule: enabledModuleObject, 244 | DisabledModule: disabledModuleObject, 245 | ApproveHash: approveHashObject, 246 | ChangedFallbackHandler: changedFallbackHandlerObject, 247 | ChangedGuard: changedGuardObject, 248 | ExecutionFailure: executionFailureObject, 249 | ExecutionFromModuleFailure: executionFromModuleFailureObject, 250 | ExecutionFromModuleSuccess: executionFromModuleSuccessObject, 251 | ExecutionSuccess: executionSuccessObject, 252 | SafeReceived: safeReceivedObject, 253 | SafeSetup: safeSetupObject, 254 | SignMsg: signMsgObject, 255 | }, 256 | }; 257 | 258 | const findingObject = findingsObjects[version][eventName]; 259 | 260 | return findingObject; 261 | } 262 | 263 | module.exports = { 264 | getFindings, 265 | }; 266 | -------------------------------------------------------------------------------- /src/governance/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/governance/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | 7 | -------------------------------------------------------------------------------- /src/governance/README.md: -------------------------------------------------------------------------------- 1 | # Governance Events Bot Template 2 | 3 | This bot monitors governance contracts that use the modular system of Governance contracts 4 | available from OpenZeppelin. All of the possible emitted events are coded into the logic of the 5 | bot, so that a developer only needs to update a few values in a data file to customize the 6 | bot before deployment. 7 | 8 | ## [Bot Setup Walkthrough](SETUP.md) 9 | -------------------------------------------------------------------------------- /src/governance/SETUP.md: -------------------------------------------------------------------------------- 1 | # Governance Events Bot Template 2 | 3 | This bot monitors governance contracts that use the modular system of Governance contracts 4 | available from OpenZeppelin. All of the possible emitted events are coded into the logic of the 5 | bot, so that a developer only needs to update a few values in a data file to customize the 6 | bot before deployment. 7 | 8 | [OpenZeppelin Governance Contracts](https://docs.openzeppelin.com/contracts/4.x/api/governance) 9 | 10 | ## Bot Setup Walkthrough 11 | 12 | The following steps will take you from a completely blank template to a functional bot. 13 | 14 | 1. `address` (required) - The value corresponding to this key is the address of the governance contract 15 | to monitor. 16 | 17 | 2. `abiFile` (required) - The name of the ABI file that corresponds to the governance contract to monitor. 18 | All of the supported ABI files are already present in the `./abi` subdirectory, so the only step necessary 19 | is to type the correct name of the applicable ABI file. 20 | -------------------------------------------------------------------------------- /src/governance/agent.spec.js: -------------------------------------------------------------------------------- 1 | const { Finding, createTransactionEvent, ethers } = require('forta-agent'); 2 | 3 | const { 4 | initialize, 5 | handleTransaction, 6 | } = require('./agent'); 7 | 8 | const { createMockEventLogs, getObjectsFromAbi } = require('../test-utils'); 9 | 10 | const config = { 11 | developerAbbreviation: 'DEVTEST', 12 | protocolName: 'PROTOTEST', 13 | protocolAbbreviation: 'PT', 14 | botType: 'governance', 15 | name: 'test-bot', 16 | contracts: { 17 | contractName1: { 18 | abiFile: 'Governor', 19 | address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', 20 | }, 21 | }, 22 | }; 23 | const firstContractName = Object.keys(config.contracts)[0]; 24 | 25 | const abi = [ 26 | { 27 | anonymous: false, 28 | inputs: [ 29 | { 30 | indexed: false, 31 | internalType: 'uint256', 32 | name: 'proposalId', 33 | type: 'uint256', 34 | }, 35 | { 36 | indexed: false, 37 | internalType: 'address', 38 | name: 'proposer', 39 | type: 'address', 40 | }, 41 | { 42 | indexed: false, 43 | internalType: 'address[]', 44 | name: 'targets', 45 | type: 'address[]', 46 | }, 47 | { 48 | indexed: false, 49 | internalType: 'uint256[]', 50 | name: 'values', 51 | type: 'uint256[]', 52 | }, 53 | { 54 | indexed: false, 55 | internalType: 'string[]', 56 | name: 'signatures', 57 | type: 'string[]', 58 | }, 59 | { 60 | indexed: false, 61 | internalType: 'bytes[]', 62 | name: 'calldatas', 63 | type: 'bytes[]', 64 | }, 65 | { 66 | indexed: false, 67 | internalType: 'uint256', 68 | name: 'startBlock', 69 | type: 'uint256', 70 | }, 71 | { 72 | indexed: false, 73 | internalType: 'uint256', 74 | name: 'endBlock', 75 | type: 'uint256', 76 | }, 77 | { 78 | indexed: false, 79 | internalType: 'string', 80 | name: 'description', 81 | type: 'string', 82 | }, 83 | ], 84 | name: 'ProposalCreated', 85 | type: 'event', 86 | }, 87 | { 88 | anonymous: false, 89 | inputs: [ 90 | { 91 | indexed: false, 92 | internalType: 'uint256', 93 | name: 'proposalId', 94 | type: 'uint256', 95 | }, 96 | ], 97 | name: 'ProposalCanceled', 98 | type: 'event', 99 | }, 100 | ]; 101 | 102 | const invalidEvent = { 103 | anonymous: false, 104 | inputs: [ 105 | { 106 | indexed: false, 107 | internalType: 'uint256', 108 | name: 'testValue', 109 | type: 'uint256', 110 | }, 111 | ], 112 | name: 'TESTMockEvent', 113 | type: 'event', 114 | }; 115 | 116 | // push fake event to abi before creating the interface 117 | abi.push(invalidEvent); 118 | const iface = new ethers.utils.Interface(abi); 119 | 120 | // tests 121 | describe('monitor governance contracts for emitted events', () => { 122 | describe('handleTransaction', () => { 123 | let botState; 124 | let mockTxEvent; 125 | let validEvent; 126 | let validContractAddress; 127 | const validEventName = 'ProposalCreated'; 128 | const mockContractName = 'mockContractName'; 129 | 130 | beforeEach(async () => { 131 | botState = await initialize(config); 132 | 133 | // grab the first entry from the 'contracts' key in the config file 134 | validContractAddress = config.contracts[firstContractName].address; 135 | 136 | const eventsInAbi = getObjectsFromAbi(abi, 'event'); 137 | validEvent = eventsInAbi[validEventName]; 138 | 139 | // initialize mock transaction event with default values 140 | mockTxEvent = createTransactionEvent({ 141 | logs: [ 142 | { 143 | name: '', 144 | address: '', 145 | signature: '', 146 | topics: [], 147 | data: `0x${'0'.repeat(1000)}`, 148 | args: [], 149 | }, 150 | ], 151 | }); 152 | }); 153 | 154 | it('returns empty findings if no monitored events were emitted in the transaction', async () => { 155 | const findings = await handleTransaction(botState, mockTxEvent); 156 | expect(findings).toStrictEqual([]); 157 | }); 158 | 159 | it('returns empty findings if contract address does not match', async () => { 160 | // encode event data 161 | // valid event name with valid name, signature, topic, and args 162 | const { mockArgs, mockTopics, data } = createMockEventLogs( 163 | validEvent, 164 | iface, 165 | ); 166 | 167 | // update mock transaction event 168 | const [defaultLog] = mockTxEvent.logs; 169 | defaultLog.name = mockContractName; 170 | defaultLog.address = ethers.constants.AddressZero; 171 | defaultLog.topics = mockTopics; 172 | defaultLog.args = mockArgs; 173 | defaultLog.data = data; 174 | defaultLog.signature = iface 175 | .getEvent(validEvent.name) 176 | .format(ethers.utils.FormatTypes.minimal) 177 | .substring(6); 178 | 179 | const findings = await handleTransaction(botState, mockTxEvent); 180 | 181 | expect(findings).toStrictEqual([]); 182 | }); 183 | 184 | it('returns empty findings if contract address matches but no monitored event was emitted', async () => { 185 | // encode event data - valid event with valid arguments 186 | const { mockArgs, mockTopics, data } = createMockEventLogs( 187 | invalidEvent, 188 | iface, 189 | ); 190 | 191 | // update mock transaction event 192 | const [defaultLog] = mockTxEvent.logs; 193 | defaultLog.name = mockContractName; 194 | defaultLog.address = validContractAddress; 195 | defaultLog.topics = mockTopics; 196 | defaultLog.args = mockArgs; 197 | defaultLog.data = data; 198 | defaultLog.signature = iface 199 | .getEvent(invalidEvent.name) 200 | .format(ethers.utils.FormatTypes.minimal) 201 | .substring(6); 202 | 203 | const findings = await handleTransaction(botState, mockTxEvent); 204 | 205 | expect(findings).toStrictEqual([]); 206 | }); 207 | 208 | it('returns findings if contract address matches and monitored event was emitted', async () => { 209 | // encode event data - valid event with valid arguments 210 | const { mockArgs, mockTopics, data } = createMockEventLogs( 211 | validEvent, 212 | iface, 213 | ); 214 | 215 | // update mock transaction event 216 | const [defaultLog] = mockTxEvent.logs; 217 | defaultLog.name = mockContractName; 218 | defaultLog.address = validContractAddress; 219 | defaultLog.topics = mockTopics; 220 | defaultLog.args = mockArgs; 221 | defaultLog.data = data; 222 | defaultLog.signature = iface 223 | .getEvent(validEvent.name) 224 | .format(ethers.utils.FormatTypes.minimal) 225 | .substring(6); 226 | 227 | const findings = await handleTransaction(botState, mockTxEvent); 228 | 229 | const proposal = { 230 | proposalId: '0', 231 | _values: '0', 232 | calldatas: '0xff', 233 | description: 'test', 234 | endBlock: '0', 235 | startBlock: '0', 236 | targets: ethers.constants.AddressZero, 237 | proposer: ethers.constants.AddressZero, 238 | signatures: 'test', 239 | }; 240 | const expectedFinding = Finding.fromObject({ 241 | name: `${config.protocolName} Governance Proposal Created`, 242 | description: `Governance Proposal ${proposal.proposalId} was just created`, 243 | alertId: `${config.developerAbbreviation}-${config.protocolAbbreviation}-PROPOSAL-CREATED`, 244 | type: 'Info', 245 | severity: 'Info', 246 | protocol: config.protocolName, 247 | metadata: { 248 | address: validContractAddress, 249 | ...proposal, 250 | }, 251 | addresses: [], 252 | }); 253 | 254 | expect(findings).toStrictEqual([expectedFinding]); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /src/monitor-events/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/monitor-events/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/monitor-events/README.md: -------------------------------------------------------------------------------- 1 | # Monitor Events Bot Template 2 | 3 | This bot monitors blockchain transactions for specific events emitted from specific contract addresses. Alert 4 | type and severity are specified per event per contract address. An existing bot of this type may be modified 5 | to add/remove/update events and contracts in the bot configuration file. 6 | 7 | ## [Bot Setup Walkthrough](SETUP.md) 8 | -------------------------------------------------------------------------------- /src/monitor-events/SETUP.md: -------------------------------------------------------------------------------- 1 | # Monitor Events Bot Template 2 | 3 | This bot monitors blockchain transactions for specific events emitted from specific contract addresses. Alert 4 | type and severity are specified per event per contract address. An existing bot of this type may be modified 5 | to add/remove/update events and contracts in the bot configuration file. 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | 1. `contracts` (required) - The Object value for this key corresponds to contracts that we want to monitor events 10 | for. Each key in the Object is a contract name that we can specify, where that name is simply a string that we use 11 | as a label when referring to the contract (the string can be any valid string that we choose, it will not affect the 12 | monitoring by the bot). The Object corresponding to each contract name requires an address key/value pair, abi 13 | file key/value pair, and an `events` key, `proxy` key, or both. For the case of an `events` key, the corresponding 14 | value is an Object containing the names of events as keys. The value for each event name is an Object containing: 15 | * type (required) - Forta Finding Type 16 | * severity (required) - Forta Finding Severity 17 | * expression (optional) - A string that can be evaluated as a condition check when an event is emitted. 18 | The format of the expression is ` ` (delimited by spaces) where 19 | `argument_name` is the case-sensitive name of an argument, specified in the ABI, that is emitted as part of the event, 20 | `operator` is a standard operation such as: `>=, !==, <` (a full table on supported operators can be found in the 21 | [Expression Compatibility Table](#expression-compatibility-table)), and `value` is an address, string, or number. 22 | 23 | Note: If no expression is provided, the bot will create an alert whenever the specified event is emitted. 24 | 25 | For example, to monitor the Uniswap GovernorBravo contract for emitted `NewAdmin` events, we would need the contract 26 | address, the ABI saved locally as a JSON formatted file, the exact event name corresponding to what is listed in the 27 | ABI file, and a type and severity for the alert: 28 | 29 | ``` json 30 | "contracts": { 31 | "GovernorBravo": { 32 | "address": "0x408ED6354d4973f66138C91495F2f2FCbd8724C3", 33 | "abiFile": "GovernorBravo.json", 34 | "events": { 35 | "NewAdmin": { 36 | "type": "Suspicious", 37 | "severity": "Medium" 38 | } 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | If we wanted to add an expression to check that the address of `newAdmin` emitted by the `NewAdmin` event is not the zero address, 45 | the config would look like the following: 46 | 47 | ```json 48 | "contracts": { 49 | "GovernorBravo": { 50 | "address": "0x408ED6354d4973f66138C91495F2f2FCbd8724C3", 51 | "abiFile": "GovernorBravo.json", 52 | "events": { 53 | "NewAdmin": { 54 | "expression": "newAdmin !== 0x0000000000000000000000000000000000000000", 55 | "type": "Suspicious", 56 | "severity": "Medium" 57 | } 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | Note that any unused entries in the configuration file must be deleted for the bot to work. The original version 64 | of the configuration file contains several placeholders to show the structure of the file, but these are not valid 65 | entries for running the bot. 66 | 67 | 2. If a contract is a proxy for another contract, where events will be emitted as if they are coming from the proxy 68 | instead of from the underlying implementation contract, the entry in the `bot-config.json` file may look like the 69 | following: 70 | 71 | ```json 72 | "contracts": { 73 | "TransparentUpgradableProxy": { 74 | "address": "0xEe6A57eC80ea46401049E92587E52f5Ec1c24785", 75 | "abiFile": "TransparentUpgradableProxy.json", 76 | "proxy": "NonfungibleTokenPositionDescriptor" 77 | }, 78 | "NonfungibleTokenPositionDescriptor": { 79 | "address": "0x91ae842A5Ffd8d12023116943e72A606179294f3", 80 | "abiFile": "NonfungibleTokenPositionDescriptor.json", 81 | "events": { 82 | "UpdateTokenRatioPriority": { 83 | "type": "Info", 84 | "severity": "Info" 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | In this example, all events are emitted by the address corresponding to the "TransparentUpgradableProxy" entry, but 92 | the ABI for the implementation, containing the definition of those events, is specified by the JSON formatted file 93 | corresponding to the "NonFungibleTokenPositionDescriptor" entry. What is critical here is that the string corresponding 94 | to the `proxy` key must be identical to one of the contract name keys in the `monitorEvents` Object. It is possible for 95 | the proxy contract to emit its own events and events from the underlying implementation contract. In those cases, 96 | there may be an `"events"` key with corresponding Object value for the proxy contract as well. Both sets of events 97 | will be used by the bot when monitoring blockchain transactions. 98 | 99 | 3. We can obtain the contract ABI from one of several locations. The most accurate ABI will be the one 100 | corresponding to the original contract code that was compiled and deployed onto the blockchain. This typically will 101 | come from the Github repository of the protocol being monitored. For the Uniswap example provided thus far, the 102 | deployed contracts are all present in the Uniswap Github repository here: 103 | 104 | If the aforementioned route is chosen, a solidity compiler will need to be used with the smart contract(s) to output 105 | and store the corresponding ABI. 106 | 107 | As an alternative, a protocol may publish its smart contract code on Etherscan, where users may view the code, ABI, 108 | constructor arguments, etc. For these cases, simply navigate to `http://etherscan.io`, type the contract address 109 | into the search bar, and check the `Contract` tab. If the code has been published, there should be a `Contract ABI` 110 | section where the code can be exported in JSON format or copied to the clipboard. For the case of copying the ABI, 111 | the result would look something like: 112 | 113 | ```json 114 | [ 115 | { 116 | "anonymous": false, 117 | "inputs": [ 118 | { 119 | "indexed": false, 120 | "internalType": "address", 121 | "name": "oldAdmin", 122 | "type": "address" 123 | }, 124 | { 125 | "indexed": false, 126 | "internalType": "address", 127 | "name": "newAdmin", 128 | "type": "address" 129 | } 130 | ], 131 | "name": "NewAdmin", 132 | "type": "event" 133 | } 134 | ] 135 | ``` 136 | 137 | We need to modify the ABI to make the copied/pasted result an entry in an Array corresponding to the key "abi" 138 | in the file: 139 | 140 | ```json 141 | { 142 | "abi": [ 143 | { 144 | "anonymous": false, 145 | "inputs": [ 146 | { 147 | "indexed": false, 148 | "internalType": "address", 149 | "name": "oldAdmin", 150 | "type": "address" 151 | }, 152 | { 153 | "indexed": false, 154 | "internalType": "address", 155 | "name": "newAdmin", 156 | "type": "address" 157 | } 158 | ], 159 | "name": "NewAdmin", 160 | "type": "event" 161 | } 162 | ] 163 | } 164 | ``` 165 | 166 | The name of the JSON formatted file containing the ABI needs to have the same path as the value provided for 167 | the `abiFile` key in the `bot-config.json` file. This will allow the bot to load the ABI correctly and 168 | parse transaction logs for events. 169 | 170 | ## Appendix 171 | 172 | ### Expression Compatibility Table 173 | 174 | | | __Expression__ | === | !== | >= | <= | < | > | 175 | |----------|----------------|-----|-----|----|----|---|---| 176 | | __Type__ | | | | | | | | 177 | | String | | ✅ | ✅ | | | | | 178 | | Boolean | | ✅ | ✅ | | | | | 179 | | Number | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 180 | -------------------------------------------------------------------------------- /src/monitor-events/agent.js: -------------------------------------------------------------------------------- 1 | const { 2 | Finding, FindingSeverity, FindingType, ethers, 3 | } = require('forta-agent'); 4 | 5 | const { 6 | getAbi, 7 | buildAbiPath, 8 | extractEventArgs, 9 | parseExpression, 10 | checkLogAgainstExpression, 11 | isFilledString, 12 | isAddress, 13 | isObject, 14 | isEmptyObject, 15 | } = require('../utils'); 16 | const { getObjectsFromAbi } = require('../test-utils'); 17 | 18 | // get the Array of events for a given contract 19 | function getEvents(contractEventConfig, currentContract, monitorEvents, contracts) { 20 | const proxyName = contractEventConfig.proxy; 21 | let { events } = contractEventConfig; 22 | const eventInfo = []; 23 | 24 | let eventNames = []; 25 | if (events === undefined) { 26 | if (proxyName === undefined) { 27 | return {}; // no events for this contract 28 | } 29 | } else { 30 | eventNames = Object.keys(events); 31 | } 32 | 33 | if (proxyName) { 34 | // contract is a proxy, look up the events (if any) for the contract the proxy is pointing to 35 | const proxyEvents = monitorEvents[proxyName].events; 36 | if (proxyEvents) { 37 | if (events === undefined) { 38 | events = { ...proxyEvents }; 39 | } else { 40 | events = { ...events, ...proxyEvents }; 41 | } 42 | 43 | // find the abi for the contract the proxy is pointing to and get the event signatures 44 | const [proxiedContract] = contracts.filter((contract) => proxyName === contract.name); 45 | Object.keys(proxyEvents).forEach((eventName) => { 46 | const eventObject = { 47 | name: eventName, 48 | // eslint-disable-next-line max-len 49 | signature: proxiedContract.iface.getEvent(eventName).format(ethers.utils.FormatTypes.full), 50 | type: proxyEvents[eventName].type, 51 | severity: proxyEvents[eventName].severity, 52 | }; 53 | 54 | const { expression } = proxyEvents[eventName]; 55 | if (expression !== undefined) { 56 | eventObject.expression = expression; 57 | eventObject.expressionObject = parseExpression(expression); 58 | } 59 | 60 | eventInfo.push(eventObject); 61 | }); 62 | } 63 | } 64 | 65 | eventNames.forEach((eventName) => { 66 | const eventObject = { 67 | name: eventName, 68 | signature: currentContract.iface.getEvent(eventName).format(ethers.utils.FormatTypes.full), 69 | type: events[eventName].type, 70 | severity: events[eventName].severity, 71 | }; 72 | 73 | const { expression } = events[eventName]; 74 | if (expression !== undefined) { 75 | eventObject.expression = expression; 76 | eventObject.expressionObject = parseExpression(expression); 77 | } 78 | eventInfo.push(eventObject); 79 | }); 80 | 81 | return { eventInfo }; 82 | } 83 | 84 | // helper function to create alerts 85 | function createAlert( 86 | eventName, 87 | contractName, 88 | contractAddress, 89 | eventType, 90 | eventSeverity, 91 | args, 92 | protocolName, 93 | protocolAbbreviation, 94 | developerAbbreviation, 95 | expression, 96 | addresses, 97 | ) { 98 | const eventArgs = extractEventArgs(args); 99 | const finding = Finding.fromObject({ 100 | name: `${protocolName} Monitor Event`, 101 | description: `The ${eventName} event was emitted by the ${contractName} contract`, 102 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-MONITOR-EVENT`, 103 | type: FindingType[eventType], 104 | severity: FindingSeverity[eventSeverity], 105 | protocol: protocolName, 106 | addresses, 107 | metadata: { 108 | contractName, 109 | contractAddress, 110 | eventName, 111 | ...eventArgs, 112 | }, 113 | }); 114 | 115 | if (expression !== undefined) { 116 | finding.description += ` with condition met: ${expression}`; 117 | } 118 | 119 | return Finding.fromObject(finding); 120 | } 121 | 122 | const validateConfig = (config, abiOverride = null) => { 123 | let ok = false; 124 | let errMsg = ''; 125 | 126 | if (!isFilledString(config.developerAbbreviation)) { 127 | errMsg = 'developerAbbreviation required'; 128 | return { ok, errMsg }; 129 | } 130 | if (!isFilledString(config.protocolName)) { 131 | errMsg = 'protocolName required'; 132 | return { ok, errMsg }; 133 | } 134 | if (!isFilledString(config.protocolAbbreviation)) { 135 | errMsg = 'protocolAbbreviation required'; 136 | return { ok, errMsg }; 137 | } 138 | 139 | const { contracts } = config; 140 | if (!isObject(contracts) || isEmptyObject(contracts)) { 141 | errMsg = 'contracts key required'; 142 | return { ok, errMsg }; 143 | } 144 | 145 | let entry; 146 | const entries = Object.entries(contracts); 147 | for (let i = 0; i < entries.length; i += 1) { 148 | [, entry] = entries[i]; 149 | const { address, abiFile, events } = entry; 150 | 151 | // check that the address is a valid address 152 | if (!isAddress(address)) { 153 | errMsg = 'invalid address'; 154 | return { ok, errMsg }; 155 | } 156 | 157 | // load the ABI from the specified file 158 | // the call to getAbi will fail if the file does not exist 159 | let abi; 160 | if (abiOverride != null) { 161 | abi = abiOverride[abiFile]; 162 | } else { 163 | try { 164 | abi = getAbi(config.name, abiFile); 165 | } catch (error) { 166 | console.error(error); 167 | const path = buildAbiPath(config.name, abiFile); 168 | errMsg = `Unable to get abi file! ${path}`; 169 | return { ok, errMsg }; 170 | } 171 | } 172 | 173 | const eventObjects = getObjectsFromAbi(abi, 'event'); 174 | 175 | // for all of the events specified, verify that they exist in the ABI 176 | let eventName; 177 | const eventNames = Object.keys(events); 178 | for (let j = 0; j < eventNames.length; j += 1) { 179 | eventName = eventNames[j]; 180 | if (Object.keys(eventObjects).indexOf(eventName) === -1) { 181 | errMsg = 'invalid event'; 182 | return { ok, errMsg }; 183 | } 184 | 185 | const { expression, type, severity } = events[eventName]; 186 | 187 | // the expression key can be left out, but if it's present, verify the expression 188 | if (expression !== undefined) { 189 | // if the expression is not valid, the call to parseExpression will fail 190 | const expressionObject = parseExpression(expression); 191 | 192 | // check the event definition to verify the argument name 193 | const { inputs } = eventObjects[eventName]; 194 | const argumentNames = inputs.map((inputEntry) => inputEntry.name); 195 | 196 | // verify that the argument name is present in the event Object 197 | if (argumentNames.indexOf(expressionObject.variableName) === -1) { 198 | errMsg = 'invalid argument'; 199 | return { ok, errMsg }; 200 | } 201 | } 202 | 203 | // check type, this will fail if 'type' is not valid 204 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 205 | errMsg = 'invalid finding type!'; 206 | return { ok, errMsg }; 207 | } 208 | 209 | // check severity, this will fail if 'severity' is not valid 210 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 211 | errMsg = 'invalid finding severity!'; 212 | return { ok, errMsg }; 213 | } 214 | } 215 | } 216 | 217 | ok = true; 218 | return { ok, errMsg }; 219 | }; 220 | 221 | const initialize = async (config, abiOverride = null) => { 222 | const botState = { ...config }; 223 | 224 | const { ok, errMsg } = validateConfig(config, abiOverride); 225 | if (!ok) { 226 | throw new Error(errMsg); 227 | } 228 | 229 | botState.monitorEvents = config.contracts; 230 | 231 | // load the contract addresses, abis, and ethers interfaces 232 | botState.contracts = Object.entries(botState.monitorEvents).map(([name, entry]) => { 233 | let abi; 234 | if (abiOverride != null) { 235 | abi = abiOverride[entry.abiFile]; 236 | } else { 237 | abi = getAbi(config.name, entry.abiFile); 238 | } 239 | const iface = new ethers.utils.Interface(abi); 240 | 241 | const contract = { name, address: entry.address, iface }; 242 | return contract; 243 | }); 244 | 245 | botState.contracts.forEach((contract) => { 246 | const entry = botState.monitorEvents[contract.name]; 247 | const { eventInfo } = getEvents(entry, contract, botState.monitorEvents, botState.contracts); 248 | // eslint-disable-next-line no-param-reassign 249 | contract.eventInfo = eventInfo; 250 | }); 251 | 252 | return botState; 253 | }; 254 | 255 | const handleTransaction = async (botState, txEvent) => { 256 | if (!botState.contracts) throw new Error('handleTransaction called before initialization'); 257 | 258 | const findings = []; 259 | botState.contracts.forEach((contract) => { 260 | contract.eventInfo.forEach((ev) => { 261 | const parsedLogs = txEvent.filterLog(ev.signature, contract.address); 262 | 263 | // iterate over each item in parsedLogs and evaluate expressions (if any) given in the 264 | // configuration file for each Event log, respectively 265 | parsedLogs.forEach((parsedLog) => { 266 | // if there is an expression to check, verify the condition before creating an alert 267 | if (ev.expression !== undefined) { 268 | if (!checkLogAgainstExpression(ev.expressionObject, parsedLog)) { 269 | return; 270 | } 271 | } 272 | 273 | let addresses = Object.keys(txEvent.addresses).map((address) => address.toLowerCase()); 274 | addresses = addresses.filter((address) => address !== 'undefined'); 275 | 276 | findings.push(createAlert( 277 | ev.name, 278 | contract.name, 279 | contract.address, 280 | ev.type, 281 | ev.severity, 282 | parsedLog.args, 283 | botState.protocolName, 284 | botState.protocolAbbreviation, 285 | botState.developerAbbreviation, 286 | ev.expression, 287 | addresses, 288 | )); 289 | }); 290 | }); 291 | }); 292 | 293 | return findings; 294 | }; 295 | 296 | module.exports = { 297 | validateConfig, 298 | createAlert, 299 | initialize, 300 | handleTransaction, 301 | }; 302 | -------------------------------------------------------------------------------- /src/monitor-function-calls/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | 'no-console': ['error', { allow: ['error'] }], 16 | }, 17 | overrides: [ 18 | { 19 | files: '*', 20 | rules: { 21 | 'no-plusplus': 'off', 22 | 'no-continue': 'off', 23 | }, 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /src/monitor-function-calls/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/monitor-function-calls/README.md: -------------------------------------------------------------------------------- 1 | # Function Calls Bot Template 2 | 3 | This bot monitors blockchain transactions for specific function calls from specific contract 4 | addresses, with the option to check the value of an argument against a specified value. Alert type 5 | and severity are specified per function per contract address. 6 | 7 | ## [Bot Setup Walkthrough](SETUP.md) 8 | -------------------------------------------------------------------------------- /src/monitor-function-calls/SETUP.md: -------------------------------------------------------------------------------- 1 | # Function Calls Bot Template 2 | 3 | This bot monitors blockchain transactions for specific function calls from specific contract 4 | addresses, with the option to check the value of an argument against a specified value. Alert type 5 | and severity are specified per function per contract address. 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | 1. `contracts` (required) - The Object value for this key corresponds to contracts that we want to 10 | monitor function calls for. Each key in the Object is a contract name that we can specify, where 11 | that name is simply a string that we use as a label when referring to the contract (the string can 12 | be any valid string that we choose, it will not affect the monitoring by the bot). The Object 13 | corresponding to each contract name requires an address key/value pair, abi file key/value pair, and 14 | a `functions` key. The corresponding value for the `functions` key is an Object containing the 15 | names of functions as keys. The value for each function name is an Object containing: 16 | * type (required) - Forta Finding Type 17 | * severity (required) - Forta Finding Severity 18 | * expression (optional) - A string that can be evaluated as a condition check when a function is 19 | called. The format of the expression is ` ` (delimited by 20 | spaces) where `argument_name` is the case-sensitive name of an input argument, specified in the 21 | ABI, that is provided as part trace data when the function is called, `operator` is a standard 22 | operation such as: `>=, !==, <` (a full table on supported operators can be found in the 23 | [Expression Compatibility Table](#expression-compatibility-table)), and `value` is an address, 24 | string, or number. 25 | 26 | Note: If no expression is provided, the bot will create an alert whenever the specified function is 27 | called. 28 | 29 | For example, to monitor if `createPool` was called in the Uniswap V3 Factory contract to create a 30 | pool with the WETH token as `tokenA`, we would need the contract address, the ABI saved 31 | locally as a JSON formatted file, the exact function name corresponding to what is listed in the ABI 32 | file, a type, a severity, and an expression that must be satisfied to create an alert: 33 | 34 | ```json 35 | "contracts": { 36 | "UniswapV3Factory": { 37 | "address": "0x1F98431c8aD98523631AE4a59f267346ea31F984", 38 | "abiFile": "factory.json", 39 | "functions": { 40 | "createPool": { 41 | "type": "Suspicious", 42 | "severity": "Medium", 43 | "expression": "tokenA === 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" 44 | } 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | Note that any unused entries in the configuration file must be deleted for the bot to work. The 51 | original version of the configuration file contains several placeholders to show the structure of 52 | the file, but these are not valid entries for running the bot. 53 | 54 | 2. We can obtain the contract ABI from one of several locations. The most accurate ABI will be the one corresponding 55 | to the original contract code that was compiled and deployed onto the blockchain. This typically will come from the 56 | Github repository of the protocol being monitored. For the Uniswap example provided thus far, the deployed contracts 57 | are all present in the Uniswap Github repository here: 58 | 59 | If the aforementioned route is chosen, a solidity compiler will need to be used with the smart contract(s) to output 60 | and store the corresponding ABI. 61 | 62 | As an alternative, a protocol may publish its smart contract code on Etherscan, where users may view the code, ABI, 63 | constructor arguments, etc. For these cases, simply navigate to `http://etherscan.io`, type in the contract address 64 | to the search bar, and check the `Contract` tab. If the code has been published, there should be a `Contract ABI` 65 | section where the code can be exported in JSON format or copied to the clipboard. For the case of copying the ABI, 66 | the result would look something like: 67 | 68 | ```json 69 | [ 70 | { 71 | "inputs": [ 72 | { 73 | "internalType": "address", 74 | "name": "tokenA", 75 | "type": "address" 76 | }, 77 | { 78 | "internalType": "address", 79 | "name": "tokenB", 80 | "type": "address" 81 | }, 82 | { 83 | "internalType": "uint24", 84 | "name": "fee", 85 | "type": "uint24" 86 | } 87 | ], 88 | "name": "createPool", 89 | "outputs": [ 90 | { 91 | "internalType": "address", 92 | "name": "pool", 93 | "type": "address" 94 | } 95 | ], 96 | "stateMutability": "nonpayable", 97 | "type": "function" 98 | } 99 | ] 100 | ``` 101 | 102 | We need to modify the ABI to make the copied/pasted result an entry in an Array corresponding to the key "abi" 103 | in the file: 104 | 105 | ```json 106 | { 107 | "abi": 108 | [ 109 | { 110 | "inputs": [ 111 | { 112 | "internalType": "address", 113 | "name": "tokenA", 114 | "type": "address" 115 | }, 116 | { 117 | "internalType": "address", 118 | "name": "tokenB", 119 | "type": "address" 120 | }, 121 | { 122 | "internalType": "uint24", 123 | "name": "fee", 124 | "type": "uint24" 125 | } 126 | ], 127 | "name": "createPool", 128 | "outputs": [ 129 | { 130 | "internalType": "address", 131 | "name": "pool", 132 | "type": "address" 133 | } 134 | ], 135 | "stateMutability": "nonpayable", 136 | "type": "function" 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | The name of the JSON formatted file containing the ABI needs to have the same path as the value provided for 143 | the `abiFile` key in the `bot-config.json` file. This will allow the bot to load the ABI correctly and 144 | parse transaction logs for function calls. 145 | 146 | ## Appendix 147 | 148 | ### Expression Compatibility Table 149 | 150 | | | __Expression__ | === | !== | >= | <= | < | > | 151 | |----------|----------------|-----|-----|----|----|---|---| 152 | | __Type__ | | | | | | | | 153 | | String | | ✅ | ✅ | | | | | 154 | | Boolean | | ✅ | ✅ | | | | | 155 | | Number | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 156 | -------------------------------------------------------------------------------- /src/monitor-function-calls/agent.js: -------------------------------------------------------------------------------- 1 | const { 2 | Finding, FindingSeverity, FindingType, ethers, 3 | } = require('forta-agent'); 4 | 5 | const { 6 | parseExpression, 7 | checkLogAgainstExpression, 8 | getAbi, 9 | buildAbiPath, 10 | extractFunctionArgs, 11 | isFilledString, 12 | isAddress, 13 | isObject, 14 | isEmptyObject, 15 | } = require('../utils'); 16 | const { getObjectsFromAbi } = require('../test-utils'); 17 | 18 | // helper function to create alerts 19 | function createAlert( 20 | functionName, 21 | contractName, 22 | contractAddress, 23 | functionType, 24 | functionSeverity, 25 | args, 26 | protocolName, 27 | protocolAbbreviation, 28 | developerAbbreviation, 29 | expression, 30 | ) { 31 | const functionArgs = extractFunctionArgs(args); 32 | 33 | const finding = { 34 | name: `${protocolName} Function Call`, 35 | description: `The ${functionName} function was invoked in the ${contractName} contract`, 36 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-FUNCTION-CALL`, 37 | type: FindingType[functionType], 38 | severity: FindingSeverity[functionSeverity], 39 | protocol: protocolName, 40 | metadata: { 41 | contractName, 42 | contractAddress, 43 | functionName, 44 | ...functionArgs, 45 | }, 46 | }; 47 | 48 | if (expression !== undefined) { 49 | finding.description += `, condition met: ${expression}`; 50 | } 51 | 52 | return Finding.fromObject(finding); 53 | } 54 | 55 | const validateConfig = (config, abiOverride = null) => { 56 | let ok = false; 57 | let errMsg = ''; 58 | 59 | if (!isFilledString(config.developerAbbreviation)) { 60 | errMsg = 'developerAbbreviation required'; 61 | return { ok, errMsg }; 62 | } 63 | if (!isFilledString(config.protocolName)) { 64 | errMsg = 'protocolName required'; 65 | return { ok, errMsg }; 66 | } 67 | if (!isFilledString(config.protocolAbbreviation)) { 68 | errMsg = 'protocolAbbreviation required'; 69 | return { ok, errMsg }; 70 | } 71 | 72 | const { contracts } = config; 73 | if (!isObject(contracts) || isEmptyObject(contracts)) { 74 | errMsg = 'contracts key required'; 75 | return { ok, errMsg }; 76 | } 77 | 78 | const values = Object.values(contracts); 79 | for (let i = 0; i < values.length; i += 1) { 80 | const { address, abiFile, functions } = values[i]; 81 | 82 | // check that the address is a valid address 83 | if (!isAddress(address)) { 84 | errMsg = 'invalid address'; 85 | return { ok, errMsg }; 86 | } 87 | 88 | // load the ABI from the specified file 89 | // the call to getAbi will fail if the file does not exist 90 | let abi; 91 | if (abiOverride != null) { 92 | abi = abiOverride[abiFile]; 93 | } else { 94 | try { 95 | abi = getAbi(config.name, abiFile); 96 | } catch (error) { 97 | console.error(error); 98 | const path = buildAbiPath(config.name, abiFile); 99 | errMsg = `Unable to get abi file! ${path}`; 100 | return { ok, errMsg }; 101 | } 102 | } 103 | 104 | // get all of the function objects from the loaded ABI file 105 | const functionObjects = getObjectsFromAbi(abi, 'function'); 106 | 107 | // for all of the functions specified, verify that they exist in the ABI 108 | let functionName; 109 | const functionNames = Object.keys(functions); 110 | for (let j = 0; j < functionNames.length; j += 1) { 111 | functionName = functionNames[j]; 112 | if (Object.keys(functionObjects).indexOf(functionName) === -1) { 113 | errMsg = 'invalid function'; 114 | return { ok, errMsg }; 115 | } 116 | 117 | // extract the keys from the configuration file for a specific function 118 | const { expression, type, severity } = functions[functionName]; 119 | 120 | // the expression key can be left out, but if it's present, verify the expression 121 | if (expression !== undefined) { 122 | // if the expression is not valid, the call to parseExpression will fail 123 | const expressionObject = parseExpression(expression); 124 | 125 | // check the function definition to verify the argument name 126 | const { inputs } = functionObjects[functionName]; 127 | const argumentNames = inputs.map((inputEntry) => inputEntry.name); 128 | 129 | // verify that the argument name is present in the function Object 130 | if (argumentNames.indexOf(expressionObject.variableName) === -1) { 131 | errMsg = 'invalid argument'; 132 | return { ok, errMsg }; 133 | } 134 | } 135 | 136 | // check type, this will fail if 'type' is not valid 137 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 138 | errMsg = 'invalid finding type!'; 139 | return { ok, errMsg }; 140 | } 141 | 142 | // check severity, this will fail if 'severity' is not valid 143 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 144 | errMsg = 'invalid finding severity!'; 145 | return { ok, errMsg }; 146 | } 147 | } 148 | } 149 | 150 | ok = true; 151 | return { ok, errMsg }; 152 | }; 153 | 154 | const initialize = async (config, abiOverride = null) => { 155 | const botState = { ...config }; 156 | 157 | const { ok, errMsg } = validateConfig(config, abiOverride); 158 | if (!ok) { 159 | throw new Error(errMsg); 160 | } 161 | 162 | botState.contracts = Object.keys(config.contracts).map((name) => { 163 | const { address, abiFile, functions } = config.contracts[name]; 164 | let abi; 165 | if (abiOverride != null) { 166 | abi = abiOverride[abiFile]; 167 | } else { 168 | abi = getAbi(config.name, abiFile); 169 | } 170 | 171 | const iface = new ethers.utils.Interface(abi); 172 | const functionNames = Object.keys(functions); 173 | 174 | const functionSignatures = functionNames.map((functionName) => { 175 | const { expression, type, severity } = functions[functionName]; 176 | const fragment = iface.getFunction(functionName); 177 | 178 | const result = { 179 | functionName, 180 | signature: fragment.format(ethers.utils.FormatTypes.full), 181 | functionType: type, 182 | functionSeverity: severity, 183 | }; 184 | 185 | if (expression !== undefined) { 186 | result.expression = expression; 187 | result.expressionObject = parseExpression(expression); 188 | } 189 | 190 | return result; 191 | }); 192 | 193 | const contract = { 194 | name, 195 | address, 196 | functions, 197 | functionSignatures, 198 | }; 199 | return contract; 200 | }); 201 | 202 | return botState; 203 | }; 204 | 205 | const handleTransaction = async (botState, txEvent) => { 206 | const findings = []; 207 | 208 | botState.contracts.forEach((contract) => { 209 | const { 210 | name, 211 | address, 212 | functionSignatures, 213 | } = contract; 214 | 215 | functionSignatures.forEach((entry) => { 216 | const { 217 | functionName, 218 | signature, 219 | expressionObject, 220 | expression, 221 | functionType, 222 | functionSeverity, 223 | } = entry; 224 | 225 | // filterFunction accepts either a string or an Array of strings 226 | // here we will only pass in one string at a time to keep the synchronization with 227 | // the expressions that we need to evaluate 228 | const parsedFunctions = txEvent.filterFunction(signature, address); 229 | 230 | // loop over the Array of results 231 | // the transaction may contain more than one function call to the same function 232 | parsedFunctions.forEach((parsedFunction) => { 233 | // if there is an expression to check, verify the condition before creating an alert 234 | if (expression !== undefined) { 235 | if (!checkLogAgainstExpression(expressionObject, parsedFunction)) { 236 | return; 237 | } 238 | } 239 | 240 | // create a finding 241 | findings.push(createAlert( 242 | functionName, 243 | name, 244 | address, 245 | functionType, 246 | functionSeverity, 247 | parsedFunction.args, 248 | botState.protocolName, 249 | botState.protocolAbbreviation, 250 | botState.developerAbbreviation, 251 | expression, 252 | )); 253 | }); 254 | }); 255 | }); 256 | 257 | return findings; 258 | }; 259 | 260 | module.exports = { 261 | validateConfig, 262 | initialize, 263 | handleTransaction, 264 | }; 265 | -------------------------------------------------------------------------------- /src/new-contract-interaction/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/new-contract-interaction/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/new-contract-interaction/README.md: -------------------------------------------------------------------------------- 1 | # New Contract Interaction Bot Template 2 | 3 | This bot monitors blockchain transactions for new contracts and EOAs with few transactions 4 | interacting with specific contract addresses. Alert type and severity are specified per contract. 5 | 6 | ## [Bot Setup Walkthrough](SETUP.md) 7 | -------------------------------------------------------------------------------- /src/new-contract-interaction/SETUP.md: -------------------------------------------------------------------------------- 1 | # New Contract Interaction Bot Template 2 | 3 | This bot monitors blockchain transactions for new contracts and EOAs with few transactions 4 | interacting with specific contract addresses. Alert type and severity are specified per contract. 5 | 6 | ## Bot Setup Walkthrough 7 | 8 | 1. `contracts` (required) - The Object value for this key corresponds to contracts that we want to 9 | monitor for interactions with new EOAs and/or contracts. Each key in the Object is a contract name 10 | that we can specify, where that name is simply a string that we use as a label when referring to the 11 | contract (the string can be any valid string that we choose, it will not affect the monitoring by the 12 | bot). The corresponding value for the contract name is an Object containing: 13 | * thresholdBlockCount (required) - integer, number of blocks a contract must be newer than to trigger an alert 14 | * thresholdTransactionCount (required) - integer, number of transactions an EOA must be lower than to trigger an alert 15 | * address (required) - string, contract address to monitor for interactions 16 | * filteredAddresses (optional) - array, list of addresses to exclude from interaction alerts 17 | * type (required) - string, Forta Finding Type 18 | * severity (required) - string, Forta Finding Severity 19 | 20 | For example, to monitor if the Uniswap V3 Factory contract was interacted with we would need the following: an age threshold, a transaction count threshold, a contract address, a type, and severity: 21 | 22 | ```json 23 | "contracts": { 24 | "UniswapV3Factory": { 25 | "thresholdBlockCount": 7, 26 | "thresholdTransactionCount": 7, 27 | "address": "0x1F98431c8aD98523631AE4a59f267346ea31F984", 28 | "filteredAddress": [], 29 | "type": "Suspicious", 30 | "severity": "Medium", 31 | } 32 | } 33 | ``` 34 | 35 | Note that any unused entries in the configuration file must be deleted for the bot to work. The 36 | original version of the configuration file contains several placeholders to show the structure of 37 | the file, but these are not valid entries for running the bot. 38 | -------------------------------------------------------------------------------- /src/new-contract-interaction/agent.js: -------------------------------------------------------------------------------- 1 | const { 2 | Finding, FindingSeverity, FindingType, getEthersProvider, 3 | } = require('forta-agent'); 4 | 5 | const { 6 | isFilledString, 7 | isObject, 8 | isEmptyObject, 9 | isAddress, 10 | } = require('../utils'); 11 | 12 | // helper function to create alert for contract interaction 13 | function createContractInteractionAlert( 14 | contractName, 15 | contractAddress, 16 | interactionAddress, 17 | type, 18 | severity, 19 | protocolName, 20 | protocolAbbreviation, 21 | developerAbbreviation, 22 | addresses, 23 | ) { 24 | const finding = { 25 | name: `${protocolName} New Contract Interaction`, 26 | description: `The ${contractName} contract interacted with a new contract ${interactionAddress}`, 27 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-NEW-CONTRACT-INTERACTION`, 28 | type: FindingType[type], 29 | severity: FindingSeverity[severity], 30 | protocol: protocolName, 31 | metadata: { 32 | contractName, 33 | contractAddress, 34 | interactionAddress, 35 | }, 36 | addresses, 37 | }; 38 | 39 | return Finding.fromObject(finding); 40 | } 41 | 42 | // helper function to create alert for EOA interaction 43 | function createEOAInteractionAlert( 44 | contractName, 45 | contractAddress, 46 | interactionAddress, 47 | transactionCount, 48 | type, 49 | severity, 50 | protocolName, 51 | protocolAbbreviation, 52 | developerAbbreviation, 53 | addresses, 54 | ) { 55 | const finding = { 56 | name: `${protocolName} New EOA Interaction`, 57 | description: `The ${contractName} contract interacted with a new EOA ${interactionAddress}`, 58 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-NEW-EOA-INTERACTION`, 59 | type: FindingType[type], 60 | severity: FindingSeverity[severity], 61 | protocol: protocolName, 62 | metadata: { 63 | contractName, 64 | contractAddress, 65 | interactionAddress, 66 | transactionCount, 67 | }, 68 | addresses, 69 | }; 70 | 71 | return Finding.fromObject(finding); 72 | } 73 | 74 | const validateConfig = (config) => { 75 | let ok = false; 76 | let errMsg = ''; 77 | 78 | if (!isFilledString(config.developerAbbreviation)) { 79 | errMsg = 'developerAbbreviation required'; 80 | return { ok, errMsg }; 81 | } 82 | if (!isFilledString(config.protocolName)) { 83 | errMsg = 'protocolName required'; 84 | return { ok, errMsg }; 85 | } 86 | if (!isFilledString(config.protocolAbbreviation)) { 87 | errMsg = 'protocolAbbreviation required'; 88 | return { ok, errMsg }; 89 | } 90 | 91 | const { contracts } = config; 92 | if (!isObject(contracts) || isEmptyObject(contracts)) { 93 | errMsg = 'contracts key required'; 94 | return { ok, errMsg }; 95 | } 96 | 97 | let contract; 98 | const values = Object.values(contracts); 99 | for (let i = 0; i < values.length; i += 1) { 100 | contract = values[i]; 101 | const { 102 | address, 103 | thresholdBlockCount, 104 | thresholdTransactionCount, 105 | filteredAddresses, 106 | type, 107 | severity, 108 | } = contract; 109 | 110 | // make sure value for thresholdBlockCount in config is a number 111 | if (typeof thresholdBlockCount !== 'number') { 112 | errMsg = 'invalid thresholdBlockCount'; 113 | return { ok, errMsg }; 114 | } 115 | 116 | // make sure value for thresholdTransactionCount in config is a number 117 | if (typeof thresholdTransactionCount !== 'number') { 118 | errMsg = 'invalid thresholdTransactionCount'; 119 | return { ok, errMsg }; 120 | } 121 | 122 | // check that the address is a valid address 123 | if (!isAddress(address)) { 124 | errMsg = 'invalid address'; 125 | return { ok, errMsg }; 126 | } 127 | 128 | // check that filteredAddresses is an array 129 | if (!Array.isArray(filteredAddresses)) { 130 | errMsg = 'invalid filteredAddresses'; 131 | return { ok, errMsg }; 132 | } 133 | 134 | // check that all entries in filteredAddresses are valid addresses 135 | for (let j = 0; j < filteredAddresses.length; j += 1) { 136 | if (!isAddress(filteredAddresses[j])) { 137 | errMsg = 'invalid filteredAddress'; 138 | return { ok, errMsg }; 139 | } 140 | } 141 | 142 | // check type, this will fail if 'type' is not valid 143 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 144 | errMsg = 'invalid finding type!'; 145 | return { ok, errMsg }; 146 | } 147 | 148 | // check severity, this will fail if 'severity' is not valid 149 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 150 | errMsg = 'invalid finding severity!'; 151 | return { ok, errMsg }; 152 | } 153 | } 154 | 155 | ok = true; 156 | return { ok, errMsg }; 157 | }; 158 | 159 | const initialize = async (config) => { 160 | const botState = { ...config }; 161 | 162 | const { ok, errMsg } = validateConfig(config); 163 | if (!ok) { 164 | throw new Error(errMsg); 165 | } 166 | 167 | botState.provider = getEthersProvider(); 168 | const entries = Object.entries(botState.contracts); 169 | botState.contracts = entries.map(([name, entry]) => ({ name, ...entry })); 170 | 171 | return botState; 172 | }; 173 | 174 | const handleTransaction = async (botState, txEvent) => { 175 | const findings = []; 176 | 177 | // get all addresses involved with this transaction 178 | const transactionAddresses = Object.keys(txEvent.addresses); 179 | 180 | await Promise.all(botState.contracts.map(async (contract) => { 181 | const { 182 | name, 183 | address, 184 | filteredAddresses, 185 | thresholdBlockCount, 186 | thresholdTransactionCount, 187 | type, 188 | severity, 189 | } = contract; 190 | 191 | let exclusions = [ 192 | address, 193 | ]; 194 | exclusions = exclusions.concat(filteredAddresses); 195 | // filter transaction addresses to remove specified addresses 196 | 197 | const filteredTransactionAddresses = transactionAddresses 198 | .filter((item) => !exclusions.includes(item)); 199 | 200 | let addresses = Object.keys(txEvent.addresses).map((addr) => addr.toLowerCase()); 201 | addresses = addresses.filter((addr) => addr !== 'undefined'); 202 | 203 | // transaction to can be null, it's weird, but it happens 204 | if (txEvent.transaction.to == null) { 205 | return; 206 | } 207 | 208 | // watch for recently created contracts interacting with configured contract address 209 | if (txEvent.transaction.to.toLowerCase() === address.toLowerCase()) { 210 | const contractResults = {}; 211 | const eoaAddresses = []; 212 | 213 | const results = await Promise.allSettled( 214 | filteredTransactionAddresses.map(async (transactionAddress) => { 215 | const contractCode = await botState.provider.getCode(transactionAddress); 216 | return { transactionAddress, code: contractCode }; 217 | }), 218 | ); 219 | 220 | results.forEach((result) => { 221 | if (result.status === 'fulfilled') { 222 | if (result.value.code !== '0x') { // if there's code, then it's a contract 223 | contractResults[result.value.transactionAddress] = result.value.code; 224 | } else { 225 | eoaAddresses.push(result.value.transactionAddress); // if no code, then it's an EOA 226 | } 227 | } 228 | }); 229 | 230 | await Promise.all(eoaAddresses.map(async (eoaAddress) => { 231 | const eoaTransactionCount = await botState.provider.getTransactionCount(eoaAddress); 232 | if (eoaTransactionCount < thresholdTransactionCount) { 233 | findings.push(createEOAInteractionAlert( 234 | name, 235 | address, 236 | eoaAddress, 237 | eoaTransactionCount, 238 | type, 239 | severity, 240 | botState.protocolName, 241 | botState.protocolAbbreviation, 242 | botState.developerAbbreviation, 243 | addresses, 244 | )); 245 | } 246 | })); 247 | 248 | const blockOverride = txEvent.blockNumber - thresholdBlockCount; 249 | const blockResults = await Promise.allSettled( 250 | Object.keys(contractResults).map(async (contractResult) => { 251 | const contractCode = await botState.provider.getCode(contractResult, blockOverride); 252 | return { transactionAddress: contractResult, code: contractCode }; 253 | }), 254 | ); 255 | 256 | blockResults.forEach((result) => { 257 | if (result.status === 'fulfilled') { 258 | if (result.value.code !== contractResults[result.value.transactionAddress]) { 259 | findings.push(createContractInteractionAlert( 260 | name, 261 | address, 262 | result.value.transactionAddress, 263 | type, 264 | severity, 265 | botState.protocolName, 266 | botState.protocolAbbreviation, 267 | botState.developerAbbreviation, 268 | addresses, 269 | )); 270 | } 271 | } 272 | }); 273 | } 274 | })); 275 | 276 | return findings; 277 | }; 278 | 279 | module.exports = { 280 | validateConfig, 281 | initialize, 282 | handleTransaction, 283 | createContractInteractionAlert, 284 | createEOAInteractionAlert, 285 | }; 286 | -------------------------------------------------------------------------------- /src/new-contract-interaction/agent.spec.js: -------------------------------------------------------------------------------- 1 | const mockEthersProvider = { 2 | getCode: jest.fn(), 3 | getTransactionCount: jest.fn(), 4 | }; 5 | 6 | jest.mock('forta-agent', () => ({ 7 | ...jest.requireActual('forta-agent'), 8 | getEthersProvider: jest.fn().mockReturnValue(mockEthersProvider), 9 | })); 10 | 11 | const { 12 | TransactionEvent, 13 | } = require('forta-agent'); 14 | 15 | const { 16 | initialize, 17 | handleTransaction, 18 | createContractInteractionAlert, 19 | createEOAInteractionAlert, 20 | } = require('./agent'); 21 | 22 | // mock response from ethers BaseProvider.getCode() 23 | const mockGetCodeResponseEOA = '0x'; 24 | const mockGetCodeResponseNewContract = '0x'; 25 | const mockGetCodeResponseContract = '0xabcd'; 26 | 27 | const filteredAddress = `0x3${'0'.repeat(39)}`; 28 | 29 | // utility function specific for this test module 30 | // we are intentionally not using the Forta SDK function due to issues with 31 | // jest mocking the module and interfering with default function values 32 | function createTransactionEvent(txObject) { 33 | const txEvent = new TransactionEvent( 34 | null, 35 | null, 36 | txObject.transaction, 37 | null, 38 | txObject.addresses, 39 | txObject.block, 40 | [], 41 | null, 42 | ); 43 | return txEvent; 44 | } 45 | 46 | const config = { 47 | developerAbbreviation: 'DEVTEST', 48 | protocolName: 'PROTOTEST', 49 | protocolAbbreviation: 'PT', 50 | botType: 'new-contract-interaction', 51 | name: 'test-bot', 52 | contracts: { 53 | contractName1: { 54 | thresholdBlockCount: 7, 55 | thresholdTransactionCount: 7, 56 | address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', 57 | filteredAddresses: [ 58 | '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13E', 59 | '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13F', 60 | ], 61 | type: 'Info', 62 | severity: 'Info', 63 | }, 64 | }, 65 | }; 66 | 67 | // grab first contract to test 68 | const [testContract] = Object.keys(config.contracts); 69 | const { address: testContractAddress } = config.contracts[testContract]; 70 | 71 | describe('mocked APIs should work properly', () => { 72 | describe('mock ethers getCode request', () => { 73 | it('should call getCode and return a response', async () => { 74 | mockEthersProvider.getCode.mockResolvedValue(mockGetCodeResponseEOA); 75 | const code = await mockEthersProvider.getCode(); 76 | expect(code).toEqual('0x'); 77 | }); 78 | }); 79 | 80 | describe('mock ethers getTransactionCount request', () => { 81 | it('should call getTransactionCount and return a response', async () => { 82 | mockEthersProvider.getTransactionCount.mockResolvedValue(10); 83 | const count = await mockEthersProvider.getTransactionCount(); 84 | expect(count).toEqual(10); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('new contract interaction monitoring', () => { 90 | let botState; 91 | 92 | // pass in mockEthers as the provider for handleTransaction() to use 93 | beforeAll(async () => { 94 | botState = await initialize(config); 95 | }); 96 | 97 | // reset function call count after each test 98 | afterEach(() => { 99 | mockEthersProvider.getCode.mockClear(); 100 | mockEthersProvider.getTransactionCount.mockClear(); 101 | }); 102 | 103 | describe('handleTransaction', () => { 104 | it('returns empty findings if no contracts are invoked', async () => { 105 | const txEvent = createTransactionEvent({ 106 | transaction: { 107 | to: '0x1', 108 | }, 109 | addresses: { 110 | '0x1': true, 111 | '0x2': true, 112 | }, 113 | block: { number: 10 }, 114 | }); 115 | 116 | // run forta bot 117 | const findings = await handleTransaction(botState, txEvent); 118 | 119 | // check assertions 120 | expect(findings).toStrictEqual([]); 121 | }); 122 | 123 | it('returns empty findings if the getCode function throws an error', async () => { 124 | const transactionAddress = '0x1'; 125 | 126 | const txEvent = createTransactionEvent({ 127 | transaction: { 128 | to: testContractAddress, 129 | }, 130 | addresses: { 131 | [testContractAddress]: true, 132 | [transactionAddress]: true, 133 | }, 134 | block: { number: 10 }, 135 | }); 136 | 137 | // intentionally setup the getCode function to throw an error 138 | mockEthersProvider.getCode.mockImplementation(async () => { 139 | throw new Error('FAILED'); 140 | }); 141 | 142 | // run forta bot 143 | const findings = await handleTransaction(botState, txEvent); 144 | 145 | // check assertions 146 | expect(findings).toStrictEqual([]); 147 | }); 148 | 149 | it('returns empty findings if the invocation is from an old contract', async () => { 150 | const transactionAddress = '0x1'; 151 | 152 | const txEvent = createTransactionEvent({ 153 | transaction: { 154 | to: testContractAddress, 155 | }, 156 | addresses: { 157 | [testContractAddress]: true, 158 | [transactionAddress]: true, 159 | }, 160 | block: { number: 1 }, 161 | }); 162 | 163 | mockEthersProvider.getCode.mockReturnValueOnce( 164 | mockGetCodeResponseContract, 165 | ); 166 | mockEthersProvider.getCode.mockReturnValueOnce( 167 | mockGetCodeResponseContract, 168 | ); 169 | 170 | // run forta bot 171 | const findings = await handleTransaction(botState, txEvent); 172 | 173 | expect(findings).toStrictEqual([]); 174 | }); 175 | 176 | it('returns empty findings if the invocation is from a filtered address', async () => { 177 | const transactionAddress = filteredAddress; 178 | 179 | const txEvent = createTransactionEvent({ 180 | transaction: { 181 | to: testContractAddress, 182 | }, 183 | addresses: { 184 | [testContractAddress]: true, 185 | [transactionAddress]: true, 186 | }, 187 | block: { number: 10 }, 188 | }); 189 | 190 | // run forta bot 191 | const findings = await handleTransaction(botState, txEvent); 192 | 193 | // check assertions 194 | expect(findings).toStrictEqual([]); 195 | }); 196 | 197 | it('returns a finding if a new contract was involved in the transaction', async () => { 198 | const transactionAddress = '0x1'; 199 | const blockNumber = 10; 200 | 201 | const txEvent = createTransactionEvent({ 202 | transaction: { 203 | to: testContractAddress, 204 | }, 205 | addresses: { 206 | [testContractAddress]: true, 207 | [transactionAddress]: true, 208 | }, 209 | block: { number: blockNumber }, 210 | }); 211 | 212 | mockEthersProvider.getCode.mockReturnValueOnce( 213 | mockGetCodeResponseContract, 214 | ); 215 | mockEthersProvider.getCode.mockReturnValueOnce( 216 | mockGetCodeResponseNewContract, 217 | ); 218 | 219 | // run forta bot 220 | const findings = await handleTransaction(botState, txEvent); 221 | 222 | const expectedFindings = []; 223 | botState.contracts.forEach((contract) => { 224 | const { 225 | name, address, type, severity, 226 | } = contract; 227 | 228 | let addresses = Object.keys(txEvent.addresses).map((addr) => addr.toLowerCase()); 229 | addresses = addresses.filter((addr) => addr !== 'undefined'); 230 | 231 | expectedFindings.push(createContractInteractionAlert( 232 | name, 233 | address, 234 | transactionAddress, 235 | type, 236 | severity, 237 | botState.protocolName, 238 | botState.protocolAbbreviation, 239 | botState.developerAbbreviation, 240 | addresses, 241 | )); 242 | }); 243 | 244 | expect(findings).toStrictEqual(expectedFindings); 245 | }); 246 | 247 | it('returns empty findings if the invocation is from an old EOA', async () => { 248 | const transactionAddress = '0x1'; 249 | 250 | const txEvent = createTransactionEvent({ 251 | transaction: { 252 | to: testContractAddress, 253 | }, 254 | addresses: { 255 | [testContractAddress]: true, 256 | [transactionAddress]: true, 257 | }, 258 | block: { number: 10 }, 259 | }); 260 | 261 | mockEthersProvider.getCode.mockResolvedValue(mockGetCodeResponseEOA); 262 | mockEthersProvider.getTransactionCount.mockResolvedValue(10); 263 | 264 | // run forta bot 265 | const findings = await handleTransaction(botState, txEvent); 266 | 267 | // check assertions 268 | expect(findings).toStrictEqual([]); 269 | }); 270 | 271 | it('returns a finding if a new EOA was involved in the transaction', async () => { 272 | const transactionAddress = '0x1'; 273 | 274 | const txEvent = createTransactionEvent({ 275 | transaction: { 276 | to: testContractAddress, 277 | }, 278 | addresses: { 279 | [testContractAddress]: true, 280 | [transactionAddress]: true, 281 | }, 282 | block: { number: 10 }, 283 | }); 284 | 285 | const transactionCount = 1; 286 | 287 | mockEthersProvider.getCode.mockResolvedValue(mockGetCodeResponseEOA); 288 | mockEthersProvider.getTransactionCount.mockResolvedValue( 289 | transactionCount, 290 | ); 291 | 292 | // run forta bot 293 | const findings = await handleTransaction(botState, txEvent); 294 | 295 | // check assertions 296 | const expectedFindings = []; 297 | botState.contracts.forEach((contract) => { 298 | const { 299 | name, address, type, severity, 300 | } = contract; 301 | 302 | let addresses = Object.keys(txEvent.addresses).map((addr) => addr.toLowerCase()); 303 | addresses = addresses.filter((addr) => addr !== 'undefined'); 304 | 305 | expectedFindings.push(createEOAInteractionAlert( 306 | name, 307 | address, 308 | transactionAddress, 309 | transactionCount, 310 | type, 311 | severity, 312 | botState.protocolName, 313 | botState.protocolAbbreviation, 314 | botState.developerAbbreviation, 315 | addresses, 316 | )); 317 | }); 318 | 319 | expect(findings).toStrictEqual(expectedFindings); 320 | }); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /src/test-utils.js: -------------------------------------------------------------------------------- 1 | const BigNumber = require('bignumber.js'); 2 | const { ethers } = require('forta-agent'); 3 | const utils = require('./utils'); 4 | 5 | const defaultTypeMap = { 6 | uint256: 0, 7 | 'uint256[]': [0], 8 | address: ethers.constants.AddressZero, 9 | 'address[]': [ethers.constants.AddressZero], 10 | bytes: '0xff', 11 | 'bytes[]': ['0xff'], 12 | bytes32: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, 13 | 'bytes32[]': [0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF], 14 | string: 'test', 15 | 'string[]': ['test'], 16 | }; 17 | 18 | function getObjectsFromAbi(abi, objectType) { 19 | const contractObjects = {}; 20 | abi.forEach((entry) => { 21 | if (entry.type === objectType) { 22 | contractObjects[entry.name] = entry; 23 | } 24 | }); 25 | return contractObjects; 26 | } 27 | 28 | function getEventFromConfig(abi, events) { 29 | let eventInConfig; 30 | let eventNotInConfig; 31 | let findingType; 32 | let findingSeverity; 33 | 34 | const eventsInConfig = Object.keys(events); 35 | const eventObjects = getObjectsFromAbi(abi, 'event'); 36 | Object.keys(eventObjects).forEach((name) => { 37 | if ((eventNotInConfig !== undefined) && (eventInConfig !== undefined)) { 38 | return; 39 | } 40 | 41 | if ((eventsInConfig.indexOf(name) === -1) && (eventNotInConfig === undefined)) { 42 | eventNotInConfig = eventObjects[name]; 43 | } 44 | 45 | if ((eventsInConfig.indexOf(name) !== -1) && (eventInConfig === undefined)) { 46 | eventInConfig = eventObjects[name]; 47 | findingType = events[name].type; 48 | findingSeverity = events[name].severity; 49 | } 50 | }); 51 | return { 52 | eventInConfig, eventNotInConfig, findingType, findingSeverity, 53 | }; 54 | } 55 | 56 | function getExpressionOperand(operator, value, expectedResult) { 57 | // given a value, an operator, and a corresponding expected result, return a value that 58 | // meets the expected result 59 | let leftOperand; 60 | /* eslint-disable no-case-declarations */ 61 | if (BigNumber.isBigNumber(value)) { 62 | switch (operator) { 63 | case '>=': 64 | if (expectedResult) { 65 | leftOperand = value.toString(); 66 | } else { 67 | leftOperand = value.minus(1).toString(); 68 | } 69 | break; 70 | case '<=': 71 | if (expectedResult) { 72 | leftOperand = value.toString(); 73 | } else { 74 | leftOperand = value.plus(1).toString(); 75 | } 76 | break; 77 | case '===': 78 | if (expectedResult) { 79 | leftOperand = value.toString(); 80 | } else { 81 | leftOperand = value.minus(1).toString(); 82 | } 83 | break; 84 | case '>': 85 | case '!==': 86 | if (expectedResult) { 87 | leftOperand = value.plus(1).toString(); 88 | } else { 89 | leftOperand = value.toString(); 90 | } 91 | break; 92 | case '<': 93 | if (expectedResult) { 94 | leftOperand = value.minus(1).toString(); 95 | } else { 96 | leftOperand = value.toString(); 97 | } 98 | break; 99 | default: 100 | throw new Error(`Unknown operator: ${operator}`); 101 | } 102 | } else if (utils.isAddress(value)) { 103 | switch (operator) { 104 | case '===': 105 | if (expectedResult) { 106 | leftOperand = value; 107 | } else { 108 | let temp = ethers.BigNumber.from(value); 109 | if (temp.eq(0)) { 110 | temp = temp.add(1); 111 | } else { 112 | temp = temp.sub(1); 113 | } 114 | leftOperand = ethers.utils.getAddress(ethers.utils.hexZeroPad(temp.toHexString(), 20)); 115 | } 116 | break; 117 | case '!==': 118 | if (expectedResult) { 119 | let temp = ethers.BigNumber.from(value); 120 | if (temp.eq(0)) { 121 | temp = temp.add(1); 122 | } else { 123 | temp = temp.sub(1); 124 | } 125 | leftOperand = ethers.utils.getAddress(ethers.utils.hexZeroPad(temp.toHexString(), 20)); 126 | } else { 127 | leftOperand = value; 128 | } 129 | break; 130 | default: 131 | throw new Error(`Unsupported operator ${operator} for address comparison`); 132 | } 133 | } else { 134 | throw new Error(`Unsupported variable type ${typeof (value)} for comparison`); 135 | } 136 | 137 | /* eslint-enable no-case-declarations */ 138 | return leftOperand; 139 | } 140 | 141 | function createMockEventLogs(eventObject, iface, override = undefined) { 142 | const mockArgs = []; 143 | const mockTopics = []; 144 | const eventTypes = []; 145 | const defaultData = []; 146 | const abiCoder = ethers.utils.defaultAbiCoder; 147 | 148 | // push the topic hash of the event to mockTopics - this is the first item in a topics array 149 | const fragment = iface.getEvent(eventObject.name); 150 | mockTopics.push(iface.getEventTopic(fragment)); 151 | 152 | eventObject.inputs.forEach((entry) => { 153 | let value; 154 | 155 | // check to make sure type is supported 156 | if (defaultTypeMap[entry.type] === undefined) { 157 | throw new Error(`Type ${entry.type} is not supported`); 158 | } 159 | 160 | // check to make sure any array types are not indexed 161 | if ((entry.type in ['uint256[]', 'address[]', 'bytes[]', 'bytes32[]', 'string[]']) 162 | && entry.indexed) { 163 | throw new Error(`Indexed type ${entry.type} is not supported`); 164 | } 165 | 166 | // determine whether to take the default value for the type, or if an override is given, take 167 | // that value 168 | if (override && entry.name === override.name) { 169 | ({ value } = override); 170 | } else { 171 | value = defaultTypeMap[entry.type]; 172 | } 173 | 174 | // push the values into the correct array, indexed arguments go into topics, otherwise they go 175 | // into data 176 | if (entry.indexed) { 177 | // convert to a 32 byte hex string before putting into topics Array 178 | mockTopics.push(ethers.utils.hexZeroPad(value, 32)); 179 | } else { 180 | eventTypes.push(entry.type); 181 | defaultData.push(value); 182 | } 183 | 184 | // do not overwrite reserved JS words! 185 | if (mockArgs[entry.name] == null) { 186 | mockArgs[entry.name] = value; 187 | } 188 | }); 189 | 190 | // encode the data array given the types array 191 | const data = abiCoder.encode(eventTypes, defaultData); 192 | return { mockArgs, mockTopics, data }; 193 | } 194 | 195 | // create a fake function name 196 | function getRandomCharacterString(numCharacters) { 197 | let result = ''; 198 | let charCode; 199 | for (let i = 0; i < numCharacters; i += 1) { 200 | charCode = Math.floor(Math.random() * 52); 201 | if (charCode < 26) { 202 | charCode += 65; 203 | } else { 204 | charCode += 97 - 26; 205 | } 206 | result += String.fromCharCode(charCode); 207 | } 208 | return result; 209 | } 210 | 211 | function getFunctionFromConfig(abi, functions, fakeFunctionName) { 212 | let functionInConfig; 213 | let functionNotInConfig; 214 | let findingType; 215 | let findingSeverity; 216 | 217 | const functionsInConfig = Object.keys(functions); 218 | const functionObjects = getObjectsFromAbi(abi, 'function'); 219 | Object.keys(functionObjects).forEach((name) => { 220 | if ((functionsInConfig.indexOf(name) !== -1) && (functionInConfig === undefined)) { 221 | functionInConfig = functionObjects[name]; 222 | findingType = functions[name].type; 223 | findingSeverity = functions[name].severity; 224 | } 225 | if (name === fakeFunctionName) { 226 | functionNotInConfig = functionObjects[name]; 227 | } 228 | }); 229 | return { 230 | functionInConfig, functionNotInConfig, findingType, findingSeverity, 231 | }; 232 | } 233 | 234 | function createMockFunctionArgs(functionObject, iface, override = undefined) { 235 | const mockArgs = []; 236 | const argTypes = []; 237 | const argValues = []; 238 | 239 | functionObject.inputs.forEach((entry) => { 240 | let value; 241 | 242 | // check to make sure type is supported 243 | if (defaultTypeMap[entry.type] === undefined) { 244 | throw new Error(`Type ${entry.type} is not supported`); 245 | } 246 | 247 | // determine whether to take the default value for the type, or if an override is given, take 248 | // that value 249 | if (override && entry.name === override.name) { 250 | ({ value } = override); 251 | } else { 252 | value = defaultTypeMap[entry.type]; 253 | } 254 | 255 | argTypes.push(entry.type); 256 | argValues.push(value); 257 | 258 | // do not overwrite reserved JS words! 259 | if (mockArgs[entry.name] == null) { 260 | mockArgs[entry.name] = value; 261 | } 262 | }); 263 | 264 | const data = iface.encodeFunctionData(functionObject.name, argValues); 265 | return { mockArgs, argValues, data }; 266 | } 267 | 268 | module.exports = { 269 | getObjectsFromAbi, 270 | getEventFromConfig, 271 | getFunctionFromConfig, 272 | getExpressionOperand, 273 | createMockEventLogs, 274 | createMockFunctionArgs, 275 | getRandomCharacterString, 276 | }; 277 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | }, 16 | overrides: [ 17 | { 18 | files: '*', 19 | rules: { 20 | 'no-console': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/README.md: -------------------------------------------------------------------------------- 1 | # Tornado Cash Template 2 | 3 | This bot monitors blockchain transactions for those involving specified addresses and any address 4 | that has previously interacted with a known Tornado Cash Proxy. An observation period (in blocks) to 5 | watch addresses that have interacted with known Tornado Cash Proxies is configurable. Alert type and 6 | severity are also configurable per contract. 7 | 8 | ## [Bot Setup Walkthrough](SETUP.md) 9 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/SETUP.md: -------------------------------------------------------------------------------- 1 | # Tornado Cash Template 2 | 3 | This bot monitors blockchain transactions for those involving specified addresses and any address 4 | that has previously interacted with a known Tornado Cash Proxy. An observation period (in blocks) to 5 | watch addresses that have interacted with known Tornado Cash Proxies is configurable. Alert type and 6 | severity is also configurable per contract. 7 | 8 | ## Bot Setup Walkthrough 9 | 10 | The following steps will take you from a completely blank template to a functional bot. 11 | 12 | 1. `observationIntervalInBlocks` (required) - Type in a number that corresponds to the number of blocks 13 | you would like the bot to monitor suspicious addresses for. 14 | 15 | 2. The Object value for the `contracts` key corresponds to addresses that we want to monitor. Each 16 | key in the Object is an address name, and each value is another object with three fields: 17 | * address: the address of the contract or EOA that will be watched 18 | * type: Forta Finding Type 19 | * severity: Forta Finding Severity 20 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/agent.js: -------------------------------------------------------------------------------- 1 | const { 2 | Finding, FindingSeverity, FindingType, ethers, 3 | } = require('forta-agent'); 4 | 5 | const { 6 | getInternalAbi, 7 | isFilledString, 8 | isObject, 9 | isEmptyObject, 10 | } = require('../utils'); 11 | 12 | const TORNADO_CASH_ADDRESSES = [ 13 | '0x722122dF12D4e14e13Ac3b6895a86e84145b6967', 14 | ]; 15 | 16 | function createAlert( 17 | monitoredAddress, 18 | name, 19 | suspiciousAddress, 20 | developerAbbrev, 21 | protocolName, 22 | protocolAbbrev, 23 | type, 24 | severity, 25 | ) { 26 | return Finding.fromObject({ 27 | name: `${protocolName} Tornado Cash Monitor`, 28 | description: `The ${name} address (${monitoredAddress}) was involved in a transaction` 29 | + ` with an address ${suspiciousAddress} that has previously interacted with Tornado Cash`, 30 | alertId: `${developerAbbrev}-${protocolAbbrev}-TORNADO-CASH-MONITOR`, 31 | type, 32 | severity, 33 | metadata: { 34 | monitoredAddress, 35 | name, 36 | suspiciousAddress, 37 | tornadoCashContractAddresses: TORNADO_CASH_ADDRESSES.join(','), 38 | }, 39 | }); 40 | } 41 | 42 | const validateConfig = (config) => { 43 | let ok = false; 44 | let errMsg = ''; 45 | 46 | if (!isFilledString(config.developerAbbreviation)) { 47 | errMsg = 'developerAbbreviation required'; 48 | return { ok, errMsg }; 49 | } 50 | if (!isFilledString(config.protocolName)) { 51 | errMsg = 'protocolName required'; 52 | return { ok, errMsg }; 53 | } 54 | if (!isFilledString(config.protocolAbbreviation)) { 55 | errMsg = 'protocolAbbreviation required'; 56 | return { ok, errMsg }; 57 | } 58 | 59 | if (typeof config.observationIntervalInBlocks !== 'number') { 60 | errMsg = 'observationIntervalInBlocks key required'; 61 | return { ok, errMsg }; 62 | } 63 | 64 | const { contracts } = config; 65 | if (!isObject(contracts) || isEmptyObject(contracts)) { 66 | errMsg = 'addressList key required'; 67 | return { ok, errMsg }; 68 | } 69 | 70 | let value; 71 | const entries = Object.entries(contracts); 72 | for (let i = 0; i < entries.length; i += 1) { 73 | [, value] = entries[i]; 74 | const { 75 | address, 76 | type, 77 | severity, 78 | } = value; 79 | 80 | // check that the address is a valid address 81 | if (!ethers.utils.isHexString(address, 20)) { 82 | errMsg = 'invalid address'; 83 | return { ok, errMsg }; 84 | } 85 | 86 | // check type, this will fail if 'type' is not valid 87 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 88 | errMsg = 'invalid alert type!'; 89 | return { ok, errMsg }; 90 | } 91 | 92 | // check severity, this will fail if 'severity' is not valid 93 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 94 | errMsg = 'invalid alert severity!'; 95 | return { ok, errMsg }; 96 | } 97 | } 98 | 99 | ok = true; 100 | return { ok, errMsg }; 101 | }; 102 | 103 | const initialize = async (config) => { 104 | const botState = { ...config }; 105 | 106 | const { ok, errMsg } = validateConfig(config); 107 | if (!ok) { 108 | throw new Error(errMsg); 109 | } 110 | 111 | const abi = getInternalAbi(config.botType, 'TornadoProxy.json'); 112 | botState.iface = new ethers.utils.Interface(abi); 113 | 114 | botState.addressesToMonitor = Object.entries(config.contracts).map(([addressName, entry]) => ( 115 | { 116 | name: addressName, 117 | ...entry, 118 | } 119 | )); 120 | 121 | // create an object to hold addresses that have been identified as having interacted with a 122 | // Tornado Cash Proxy 123 | botState.suspiciousAddresses = {}; 124 | 125 | return botState; 126 | }; 127 | 128 | const handleTransaction = async (botState, txEvent) => { 129 | const findings = []; 130 | 131 | // check to see if the given transaction includes deposit/withdraw calls from a tornado cash 132 | // proxy 133 | let addressesOfInterest = TORNADO_CASH_ADDRESSES.map((address) => { 134 | const filterResult = txEvent.filterFunction( 135 | botState.iface.format(ethers.utils.FormatTypes.full), 136 | address, 137 | ); 138 | 139 | if (filterResult.length > 0) { // } && txEvent.from !== undefined) { 140 | return txEvent.from; 141 | } 142 | 143 | return ''; 144 | }); 145 | 146 | // filter out any empty strings 147 | addressesOfInterest = addressesOfInterest.filter((address) => address !== ''); 148 | 149 | // for each address found to have interacted with a tornado cash proxy, add it to our 150 | // suspiciousAddresses object and instantiate a number of blocks to watch the address for; if 151 | // an address is already present in suspiciousAddresses then simply restart its block timer 152 | addressesOfInterest.forEach((address) => { 153 | // eslint-disable-next-line no-param-reassign 154 | botState.suspiciousAddresses[address] = { blockAdded: txEvent.blockNumber }; 155 | }); 156 | 157 | // iterate over the list of suspiciousAddresses and check to see if any address can be removed 158 | const addressesToRemove = []; 159 | Object.keys(botState.suspiciousAddresses).forEach((address) => { 160 | const currBlock = txEvent.blockNumber; 161 | const { blockAdded } = botState.suspiciousAddresses[address]; 162 | if ((currBlock - blockAdded) > botState.observationIntervalInBlocks) { 163 | // block is older than observationIntervalInBlocks and can be removed from 164 | // suspiciousAddresses 165 | addressesToRemove.push(address); 166 | } 167 | }); 168 | 169 | // eslint-disable-next-line no-param-reassign,max-len 170 | addressesToRemove.forEach((address) => delete botState.suspiciousAddresses[address]); 171 | 172 | // now check to see if the higher level list of addresses in txEvent contains at least one 173 | // address from suspiciousAddresses and one address from the addressesToMonitor 174 | Object.keys(botState.suspiciousAddresses).forEach((address) => { 175 | botState.addressesToMonitor.forEach((addressInfo) => { 176 | const { address: monitoredAddress } = addressInfo; 177 | if ( 178 | txEvent.addresses[address] !== undefined 179 | && txEvent.addresses[monitoredAddress] !== undefined 180 | ) { 181 | findings.push(createAlert( 182 | monitoredAddress, 183 | addressInfo.name, 184 | address, 185 | botState.developerAbbreviation, 186 | botState.protocolName, 187 | botState.protocolAbbreviation, 188 | FindingType[addressInfo.type], 189 | FindingSeverity[addressInfo.severity], 190 | )); 191 | } 192 | }); 193 | }); 194 | 195 | return findings; 196 | }; 197 | 198 | module.exports = { 199 | validateConfig, 200 | TORNADO_CASH_ADDRESSES, 201 | initialize, 202 | handleTransaction, 203 | }; 204 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/agent.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | ethers, 3 | createTransactionEvent, 4 | Finding, 5 | FindingType, 6 | FindingSeverity, 7 | } = require('forta-agent'); 8 | 9 | const { 10 | initialize, 11 | handleTransaction, 12 | TORNADO_CASH_ADDRESSES, 13 | } = require('./agent'); 14 | 15 | const config = { 16 | developerAbbreviation: 'DEVTEST', 17 | protocolName: 'PROTOTEST', 18 | protocolAbbreviation: 'PT', 19 | botType: 'tornado-cash-monitor', 20 | name: 'test-bot', 21 | observationIntervalInBlocks: 10, 22 | contracts: { 23 | contractName1: { 24 | address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', 25 | type: 'Info', 26 | severity: 'Info', 27 | }, 28 | }, 29 | }; 30 | 31 | // tests 32 | describe('handleTransaction', () => { 33 | let botState; 34 | let addressName; 35 | let testAddressInfo; 36 | let mockTrace; 37 | let mockTxEvent; 38 | let iface; 39 | const tornadoCashAddress = TORNADO_CASH_ADDRESSES[0].toLowerCase(); 40 | 41 | beforeEach(async () => { 42 | // set up test configuration parameters that won't change with each test 43 | // grab the first entry from the 'addressList' key in the configuration file 44 | const { contracts } = config; 45 | [addressName] = Object.keys(contracts); 46 | testAddressInfo = contracts[addressName]; 47 | 48 | botState = await initialize(config); 49 | 50 | // pull out the initialized interface to use for crafting test data 51 | ({ iface } = botState); 52 | 53 | // initialize mock trace object with default values 54 | mockTrace = [ 55 | { 56 | action: { 57 | to: ethers.constants.AddressZero, 58 | input: ethers.constants.HashZero, 59 | value: '0x0', 60 | from: ethers.constants.AddressZero, 61 | }, 62 | transactionHash: '0xFAKETRANSACTIONHASH', 63 | }, 64 | ]; 65 | 66 | // initialize mock transaction event with default values 67 | mockTxEvent = createTransactionEvent({ 68 | block: { 69 | number: 10000, 70 | }, 71 | addresses: {}, 72 | transaction: { 73 | to: ethers.constants.AddressZero, 74 | from: ethers.constants.AddressZero, 75 | value: '0', 76 | data: ethers.constants.HashZero, 77 | }, 78 | traces: mockTrace, 79 | }); 80 | }); 81 | 82 | it('returns no findings if no deposit/withdraw function from a tornado cash proxy was called', async () => { 83 | // run bot with empty mockTxEvent 84 | const findings = await handleTransaction(botState, mockTxEvent); 85 | 86 | // expect the suspiciousAddresses object to be empty 87 | expect(Object.keys(botState.suspiciousAddresses).length).toBe(0); 88 | 89 | // expect no findings 90 | expect(findings).toStrictEqual([]); 91 | }); 92 | 93 | it('returns no findings when a deposit/withdraw function from tornado cash is called but there are no subsequent interactions with monitored addresses', async () => { 94 | // setup some mock function arguments to be encoded as mock function data 95 | const mockFunctionArgs = [ 96 | '0x1111111111111111111111111111111111111111', 97 | ethers.utils.hexZeroPad('0xff', 32), 98 | '0xff', 99 | ]; 100 | 101 | const mockFunctionData = iface.encodeFunctionData('deposit', mockFunctionArgs); 102 | 103 | // update mock trace object with encoded function data 104 | mockTrace[0].action.input = mockFunctionData; 105 | mockTrace[0].action.to = tornadoCashAddress; 106 | mockTrace[0].action.from = ethers.utils.AddressZero; 107 | 108 | // update mock transaction event with new mock trace 109 | mockTxEvent.traces = mockTrace; 110 | mockTxEvent.transaction.to = tornadoCashAddress; 111 | mockTxEvent.transaction.from = ethers.utils.AddressZero; 112 | mockTxEvent.addresses = { 113 | [ethers.utils.AddressZero]: true, 114 | [tornadoCashAddress]: true, 115 | }; 116 | 117 | // run the bot 118 | const findings = await handleTransaction(botState, mockTxEvent); 119 | 120 | // expect the suspiciousAddresses object to contain one entry 121 | expect(Object.keys(botState.suspiciousAddresses).length).toBe(1); 122 | 123 | // expect no findings since there were no transactions involving a monitored address 124 | expect(findings).toStrictEqual([]); 125 | }); 126 | 127 | it('returns no findings when suspicious addresses have been found but subsequent interactions with monitored addresses occur outside the observation interval', async () => { 128 | const mockSuspiciousAddress = '0x2222222222222222222222222222222222222222'; 129 | 130 | // setup some mock function arguments to be encoded as mock function data 131 | const mockFunctionArgs = [ 132 | '0x1111111111111111111111111111111111111111', 133 | ethers.utils.hexZeroPad('0xff', 32), 134 | '0xff', 135 | ]; 136 | 137 | const mockFunctionData = iface.encodeFunctionData('deposit', mockFunctionArgs); 138 | 139 | // update mock trace object with encoded function data 140 | mockTrace[0].action.input = mockFunctionData; 141 | mockTrace[0].action.to = tornadoCashAddress; 142 | mockTrace[0].action.from = mockSuspiciousAddress; 143 | 144 | // update mock transaction event with new mock trace 145 | mockTxEvent.traces = mockTrace; 146 | mockTxEvent.transaction.to = tornadoCashAddress; 147 | mockTxEvent.transaction.from = mockSuspiciousAddress; 148 | mockTxEvent.addresses = { 149 | [mockSuspiciousAddress]: true, 150 | [tornadoCashAddress]: true, 151 | }; 152 | 153 | // run the bot 154 | let findings = await handleTransaction(botState, mockTxEvent); 155 | 156 | // expect the suspiciousAddresses object to contain one entry 157 | expect(Object.keys(botState.suspiciousAddresses).length).toBe(1); 158 | 159 | // expect no findings since there have not been any transactions involving a monitored address 160 | expect(findings).toStrictEqual([]); 161 | 162 | // update mock trace object to include a monitored address 163 | mockTrace[0].action.input = ethers.constants.HashZero; 164 | mockTrace[0].action.to = testAddressInfo.address; 165 | mockTrace[0].action.from = mockSuspiciousAddress; 166 | 167 | // update mock transaction event with new mock trace 168 | mockTxEvent.traces = mockTrace; 169 | mockTxEvent.transaction.to = testAddressInfo.address; 170 | mockTxEvent.transaction.from = mockSuspiciousAddress; 171 | mockTxEvent.addresses = { 172 | [mockSuspiciousAddress]: true, 173 | [testAddressInfo.address]: true, 174 | }; 175 | 176 | // update the blockNumber to be one greater than the observation interval specified in the 177 | // config file 178 | mockTxEvent.block.number = mockTxEvent.block.number + config.observationIntervalInBlocks + 1; 179 | 180 | // run the bot 181 | findings = await handleTransaction(botState, mockTxEvent); 182 | 183 | // expect the suspiciousAddresses object to contain no entries as the only entry should have 184 | // been removed since the current block number minus the block number the suspicious address 185 | // was added to the list at is greater than the observation interval 186 | expect(Object.keys(botState.suspiciousAddresses).length).toBe(0); 187 | 188 | // expect no findings 189 | expect(findings).toStrictEqual([]); 190 | }); 191 | 192 | it('returns a finding when a suspicious address was found and subsequent interactions with monitored functions have occurred within the observation interval', async () => { 193 | const mockSuspiciousAddress = '0x2222222222222222222222222222222222222222'; 194 | 195 | // setup some mock function arguments to be encoded as mock function data 196 | const mockFunctionArgs = [ 197 | '0x1111111111111111111111111111111111111111', 198 | ethers.utils.hexZeroPad('0xff', 32), 199 | '0xff', 200 | ]; 201 | 202 | const mockFunctionData = iface.encodeFunctionData('deposit', mockFunctionArgs); 203 | 204 | // update mock trace object with encoded function data 205 | mockTrace[0].action.input = mockFunctionData; 206 | mockTrace[0].action.to = tornadoCashAddress; 207 | mockTrace[0].action.from = mockSuspiciousAddress; 208 | 209 | // update mock transaction event with new mock trace 210 | mockTxEvent.traces = mockTrace; 211 | mockTxEvent.transaction.to = tornadoCashAddress; 212 | mockTxEvent.transaction.from = mockSuspiciousAddress; 213 | mockTxEvent.addresses = { 214 | [mockSuspiciousAddress]: true, 215 | [tornadoCashAddress]: true, 216 | }; 217 | 218 | // run the bot 219 | let findings = await handleTransaction(botState, mockTxEvent); 220 | 221 | // expect the suspiciousAddresses object to contain one entry 222 | expect(Object.keys(botState.suspiciousAddresses).length).toBe(1); 223 | 224 | // expect no findings since there have not been any transactions involving a monitored address 225 | expect(findings).toStrictEqual([]); 226 | 227 | // update mock trace object to include a monitored address 228 | mockTrace[0].action.input = ethers.constants.HashZero; 229 | mockTrace[0].action.to = testAddressInfo.address; 230 | mockTrace[0].action.from = mockSuspiciousAddress; 231 | 232 | // update mock transaction event with new mock trace 233 | mockTxEvent.traces = mockTrace; 234 | mockTxEvent.transaction.to = testAddressInfo.address; 235 | mockTxEvent.transaction.from = mockSuspiciousAddress; 236 | mockTxEvent.addresses = { 237 | [mockSuspiciousAddress]: true, 238 | [testAddressInfo.address]: true, 239 | }; 240 | 241 | // update the blockNumber 242 | mockTxEvent.block.number += 1; 243 | 244 | // run the bot 245 | findings = await handleTransaction(botState, mockTxEvent); 246 | 247 | const expectedFinding = [Finding.fromObject({ 248 | name: `${config.protocolName} Tornado Cash Monitor`, 249 | description: `The ${addressName} address (${testAddressInfo.address}) was involved in a` 250 | + ` transaction with an address ${mockSuspiciousAddress} that has previously interacted` 251 | + ' with Tornado Cash', 252 | alertId: `${config.developerAbbreviation}-${config.protocolAbbreviation}-TORNADO-CASH-MONITOR`, 253 | type: FindingType[testAddressInfo.type], 254 | severity: FindingSeverity[testAddressInfo.severity], 255 | metadata: { 256 | monitoredAddress: testAddressInfo.address, 257 | name: addressName, 258 | suspiciousAddress: mockSuspiciousAddress, 259 | tornadoCashContractAddresses: TORNADO_CASH_ADDRESSES.join(','), 260 | }, 261 | })]; 262 | 263 | expect(findings).toStrictEqual(expectedFinding); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /src/tornado-cash-monitor/internal-abi/TornadoProxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "abi": 3 | [ 4 | { 5 | "inputs": [ 6 | { 7 | "internalType": "contract ITornadoInstance", 8 | "name": "_tornado", 9 | "type": "address" 10 | }, 11 | { 12 | "internalType": "bytes32", 13 | "name": "_commitment", 14 | "type": "bytes32" 15 | }, 16 | { 17 | "internalType": "bytes", 18 | "name": "_encryptedNote", 19 | "type": "bytes" 20 | } 21 | ], 22 | "name": "deposit", 23 | "outputs": [], 24 | "stateMutability": "payable", 25 | "type": "function" 26 | }, 27 | { 28 | "inputs": [ 29 | { 30 | "internalType": "contract ITornadoInstance", 31 | "name": "_tornado", 32 | "type": "address" 33 | }, 34 | { 35 | "internalType": "bytes", 36 | "name": "_proof", 37 | "type": "bytes" 38 | }, 39 | { 40 | "internalType": "bytes32", 41 | "name": "_root", 42 | "type": "bytes32" 43 | }, 44 | { 45 | "internalType": "bytes32", 46 | "name": "_nullifierHash", 47 | "type": "bytes32" 48 | }, 49 | { 50 | "internalType": "address payable", 51 | "name": "_recipient", 52 | "type": "address" 53 | }, 54 | { 55 | "internalType": "address payable", 56 | "name": "_relayer", 57 | "type": "address" 58 | }, 59 | { 60 | "internalType": "uint256", 61 | "name": "_fee", 62 | "type": "uint256" 63 | }, 64 | { 65 | "internalType": "uint256", 66 | "name": "_refund", 67 | "type": "uint256" 68 | } 69 | ], 70 | "name": "withdraw", 71 | "outputs": [], 72 | "stateMutability": "payable", 73 | "type": "function" 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /src/transaction-failure-count/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 12, 12 | }, 13 | rules: { 14 | }, 15 | overrides: [ 16 | { 17 | files: '*', 18 | rules: { 19 | 'no-console': 'off', 20 | }, 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /src/transaction-failure-count/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .env 3 | forta.config.json 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /src/transaction-failure-count/README.md: -------------------------------------------------------------------------------- 1 | # Transaction Failure Count Bot Template 2 | 3 | This bot monitors the number of failed transactions to a specific contract addresses. Alert type 4 | and severity are specified per contract address. 5 | 6 | ## [Bot Setup Walkthrough](SETUP.md) 7 | -------------------------------------------------------------------------------- /src/transaction-failure-count/SETUP.md: -------------------------------------------------------------------------------- 1 | # Transaction Failure Count Bot Template 2 | 3 | This bot monitors the number of failed transactions to a specific contract addresses. Alert type 4 | and severity are specified per contract address. An existing bot of this type may be modified to 5 | to add/remove/update contracts in the bot configuration file. 6 | 7 | ## Bot Setup Walkthrough 8 | 9 | 1. `blockWindow` (required) - The Integer value for this key corresponds to how long failed 10 | transactions should be counted against a contract's failed transactions limit before being removed. 11 | 12 | 2. `contracts` (required) - The Object value for this key corresponds to contracts that we 13 | want to monitor the number of failed transactions for. Each key in the Object is a contract name that 14 | we can specify, where that name is simply a string that we use as a label when referring to the contract 15 | (the string can be any valid string that we choose, it will not affect the monitoring by the bot). 16 | The Object corresponding to each contract name requires an address key/value pair, a key/value pair 17 | for the limit of failed transactions allowed, a Finding type key/value pair, and a Finding severity 18 | key/value pair (Note that using a Finding type and/or Finding severity that is not listed in the Forta SDK 19 | will cause the bot to throw an error). For example, to monitor the Uniswap V3 Factory for failed transactions, we would need 20 | the contract address, the number of failed transactions that are allowed to occur before an alert is 21 | generated, and a type and severity for the alert: 22 | 23 | ```json 24 | "contracts": { 25 | "UniswapV3Factory": { 26 | "address": "0x1F98431c8aD98523631AE4a59f267346ea31F984", 27 | "transactionFailuresLimit": 2, 28 | "type": "Info", 29 | "severity": "Medium" 30 | } 31 | } 32 | ``` 33 | 34 | Note that any unused entries in the configuration file must be deleted for the bot to work. The original version 35 | of the configuration file contains several placeholders to show the structure of the file, but these are not valid 36 | entries for running the bot. 37 | -------------------------------------------------------------------------------- /src/transaction-failure-count/agent.js: -------------------------------------------------------------------------------- 1 | const { 2 | Finding, FindingSeverity, FindingType, getTransactionReceipt, 3 | } = require('forta-agent'); 4 | 5 | const { 6 | isObject, 7 | isEmptyObject, 8 | isFilledString, 9 | isAddress, 10 | } = require('../utils'); 11 | 12 | // formats provided data into a Forta alert 13 | function createAlert( 14 | name, 15 | address, 16 | failedTxs, 17 | threshold, 18 | blockWindow, 19 | protocolName, 20 | protocolAbbreviation, 21 | developerAbbreviation, 22 | alertType, 23 | alertSeverity, 24 | addresses, 25 | ) { 26 | return Finding.fromObject({ 27 | name: `${protocolName} Transaction Failure Count`, 28 | description: `${failedTxs.length} transactions sent to ${address} have failed in the past` 29 | + ` ${blockWindow} blocks`, 30 | alertId: `${developerAbbreviation}-${protocolAbbreviation}-FAILED-TRANSACTIONS`, 31 | protocol: protocolName, 32 | severity: FindingSeverity[alertSeverity], 33 | type: FindingType[alertType], 34 | metadata: { 35 | contractName: name, 36 | contractAddress: address, 37 | txFailureThreshold: threshold, 38 | failedTxs, 39 | }, 40 | addresses, 41 | }); 42 | } 43 | 44 | const validateConfig = (config) => { 45 | let ok = false; 46 | let errMsg = ''; 47 | 48 | if (!isFilledString(config.developerAbbreviation)) { 49 | errMsg = 'developerAbbreviation required'; 50 | return { ok, errMsg }; 51 | } 52 | if (!isFilledString(config.protocolName)) { 53 | errMsg = 'protocolName required'; 54 | return { ok, errMsg }; 55 | } 56 | if (!isFilledString(config.protocolAbbreviation)) { 57 | errMsg = 'protocolAbbreviation required'; 58 | return { ok, errMsg }; 59 | } 60 | 61 | const { contracts } = config; 62 | if (!isObject(contracts) || isEmptyObject(contracts)) { 63 | errMsg = 'contracts key required'; 64 | return { ok, errMsg }; 65 | } 66 | 67 | let entry; 68 | const entries = Object.entries(contracts); 69 | for (let i = 0; i < entries.length; i += 1) { 70 | [, entry] = entries[i]; 71 | const { 72 | address, 73 | transactionFailuresLimit, 74 | type, 75 | severity, 76 | } = entry; 77 | 78 | // check that the address is a valid address 79 | if (!isAddress(address)) { 80 | errMsg = 'invalid address'; 81 | return { ok, errMsg }; 82 | } 83 | 84 | // check that the limit is a number 85 | if (typeof transactionFailuresLimit !== 'number') { 86 | errMsg = 'invalid value for transactionFailuresLimit'; 87 | return { ok, errMsg }; 88 | } 89 | 90 | // check type, this will fail if 'type' is not valid 91 | if (!Object.prototype.hasOwnProperty.call(FindingType, type)) { 92 | errMsg = 'invalid finding type!'; 93 | return { ok, errMsg }; 94 | } 95 | 96 | // check severity, this will fail if 'severity' is not valid 97 | if (!Object.prototype.hasOwnProperty.call(FindingSeverity, severity)) { 98 | errMsg = 'invalid finding severity!'; 99 | return { ok, errMsg }; 100 | } 101 | } 102 | 103 | ok = true; 104 | return { ok, errMsg }; 105 | }; 106 | 107 | const initialize = async (config) => { 108 | const botState = { ...config }; 109 | 110 | const { ok, errMsg } = validateConfig(config); 111 | if (!ok) { 112 | throw new Error(errMsg); 113 | } 114 | 115 | botState.contracts = Object.entries(config.contracts).map(([contractName, entry]) => ({ 116 | contractName, 117 | contractAddress: entry.address.toLowerCase(), 118 | transactionFailuresLimit: entry.transactionFailuresLimit, 119 | failedTxs: {}, 120 | alertType: entry.type, 121 | alertSeverity: entry.severity, 122 | })); 123 | 124 | return botState; 125 | }; 126 | 127 | const handleTransaction = async (botState, txEvent) => { 128 | const findings = []; 129 | 130 | // check to see if any of the contracts were involved in the failed transaction 131 | const promises = botState.contracts.map(async (contract) => { 132 | const { 133 | contractName: name, 134 | contractAddress: address, 135 | transactionFailuresLimit, 136 | alertType, 137 | alertSeverity, 138 | } = contract; 139 | 140 | if (txEvent.to !== address) return; 141 | 142 | // grab the receipt for the transaction event 143 | const receipt = await getTransactionReceipt(txEvent.hash); 144 | if (receipt.status) return; 145 | 146 | /* eslint-disable no-param-reassign */ 147 | // add new occurrence 148 | contract.failedTxs[txEvent.hash] = txEvent.blockNumber; 149 | 150 | // filter out occurrences older than blockWindow 151 | Object.entries(contract.failedTxs).forEach(([hash, blockNumber]) => { 152 | if (blockNumber < txEvent.blockNumber - botState.blockWindow) { 153 | delete contract.failedTxs[hash]; 154 | } 155 | }); 156 | 157 | let addresses = Object.keys(txEvent.addresses).map((addr) => addr.toLowerCase()); 158 | addresses = addresses.filter((addr) => addr !== 'undefined'); 159 | 160 | // create finding if there are too many failed txs 161 | const failedTxHashes = Object.keys(contract.failedTxs); 162 | if (failedTxHashes.length >= transactionFailuresLimit) { 163 | findings.push( 164 | createAlert( 165 | name, 166 | address, 167 | failedTxHashes, 168 | transactionFailuresLimit, 169 | botState.blockWindow, 170 | botState.protocolName, 171 | botState.protocolAbbreviation, 172 | botState.developerAbbreviation, 173 | alertType, 174 | alertSeverity, 175 | addresses, 176 | ), 177 | ); 178 | 179 | // if we raised an alert, clear out the array of failed transactions to avoid over-alerting 180 | contract.failedTxs = {}; 181 | } 182 | /* eslint-enable no-param-reassign */ 183 | }); 184 | 185 | // wait for the promises to settle 186 | await Promise.all(promises); 187 | 188 | return findings; 189 | }; 190 | 191 | module.exports = { 192 | validateConfig, 193 | initialize, 194 | handleTransaction, 195 | createAlert, 196 | }; 197 | -------------------------------------------------------------------------------- /src/utils.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | isNumeric, 3 | isAddress, 4 | addressComparison, 5 | booleanComparison, 6 | bigNumberComparison, 7 | parseExpression, 8 | checkLogAgainstExpression, 9 | } = require('./utils'); 10 | 11 | describe('check parsing', () => { 12 | describe('check address parsing', () => { 13 | it('returns true when the value passed-in is a valid address with all numbers', () => { 14 | expect(isAddress('0x0000000000000000000000000000000000000000')).toBe(true); 15 | }); 16 | it('returns true when the value passed-in is a valid address with all lowercase letters from a-f', () => { 17 | expect(isAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toBe(true); 18 | }); 19 | it('returns true when the value passed-in is a valid address with all uppercase letters from A-F', () => { 20 | expect(isAddress('0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(true); 21 | }); 22 | it('returns true when the value passed-in is a valid address with a mix of uppercase and lowercase letters from A-F', () => { 23 | expect(isAddress('0xaaaaaaaaaaaBBBBBaaaaaaaaaFFFFFaaaaaaaaaa')).toBe(true); 24 | }); 25 | it('returns true when the value passed-in is a valid address with a mix of uppercase/lowercase letters and numbers', () => { 26 | expect(isAddress('0x000aaaaa00000000000BBBBB000000fffff00000')).toBe(true); 27 | }); 28 | it('returns false when the value passed-in is an object', () => { 29 | expect(isAddress({})).toBe(false); 30 | }); 31 | it('returns false when the value passed-in is a boolean', () => { 32 | expect(isAddress(true)).toBe(false); 33 | }); 34 | it('returns false when the value passed-in is a number', () => { 35 | expect(isAddress(111)).toBe(false); 36 | }); 37 | it('returns false when the value passed-in is undefined', () => { 38 | expect(isAddress(undefined)).toBe(false); 39 | }); 40 | it('returns false when the value passed-in is a hexadecimal string but the length is too short', () => { 41 | expect(isAddress('0x0')).toBe(false); 42 | }); 43 | it('returns false when the value passed-in is a hexadecimal string but the length is too long', () => { 44 | expect(isAddress('0x0000000000000000000000000000000000000000000000000000000000')).toBe(false); 45 | }); 46 | it('returns false when the value passed-in is a string that contains letter outside the range a-f', () => { 47 | expect(isAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaagaaaaaaaaaaaaaa')).toBe(false); 48 | }); 49 | it('returns false when the value passed-in is a string that contains letter outside the range A-F', () => { 50 | expect(isAddress('0xAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAA')).toBe(false); 51 | }); 52 | it('returns false when the value passed-in is a string that is not prefixed with 0x', () => { 53 | expect(isAddress('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toBe(false); 54 | }); 55 | }); 56 | 57 | describe('check number parsing', () => { 58 | it('returns true when the value passed-in is a numerical string', () => { 59 | expect(isNumeric('123')).toBe(true); 60 | }); 61 | it('returns true when the value passed-in is a numerical string with two decimals', () => { 62 | expect(isNumeric('123.11')).toBe(true); 63 | }); 64 | it('returns true when the value passed-in is a numerical string with leading decimals', () => { 65 | expect(isNumeric('123.00001')).toBe(true); 66 | }); 67 | it('returns true when the value passed-in is a numerical string with no leading values before decimals', () => { 68 | expect(isNumeric('.123')).toBe(true); 69 | }); 70 | it('returns true when the value passed-in is a hexidecimal address', () => { 71 | expect(isNumeric('0x00')).toBe(true); 72 | }); 73 | it('returns false when the value passed-in is an object', () => { 74 | expect(isNumeric({})).toBe(false); 75 | }); 76 | it('returns false when the value passed-in is a boolean', () => { 77 | expect(isNumeric(true)).toBe(false); 78 | }); 79 | it('returns false when the value passed-in is undefined', () => { 80 | expect(isNumeric(undefined)).toBe(false); 81 | }); 82 | }); 83 | 84 | describe('check parseExpression', () => { 85 | it('throws an error if the number of terms supplied is less than 3', () => { 86 | expect(() => { 87 | parseExpression(''); 88 | }).toThrow('Expression must contain three terms: variable operator value'); 89 | }); 90 | 91 | it('throws an error if the number of terms supplied is greater than 3', () => { 92 | expect(() => { 93 | parseExpression('123 < x < 456'); 94 | }).toThrow('Expression must contain three terms: variable operator value'); 95 | }); 96 | 97 | it('returns an object containing the addressComparison function if the expression supplied is an address condition', () => { 98 | const { comparisonFunction } = parseExpression( 99 | 'address === 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 100 | ); 101 | expect(comparisonFunction).toBe(addressComparison); 102 | }); 103 | 104 | it('returns an object containing the booleanComparison function if the expression supplied is a boolean condition', () => { 105 | const { comparisonFunction } = parseExpression('value === true'); 106 | expect(comparisonFunction).toBe(booleanComparison); 107 | }); 108 | 109 | it('returns an object containing the bigNumberComparison function if the expression supplied is a number condition', () => { 110 | const { comparisonFunction } = parseExpression('x >= 123456'); 111 | expect(comparisonFunction).toBe(bigNumberComparison); 112 | }); 113 | 114 | it('throws an error if an invalid operator is passed for an address condition', () => { 115 | expect(() => { 116 | parseExpression('address >= 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); 117 | }).toThrow('Unsupported address operator ">=": must be "===" or "!=="'); 118 | }); 119 | 120 | it('throws an error if an invalid operator is passed for a boolean condition', () => { 121 | expect(() => { 122 | parseExpression('true > false'); 123 | }).toThrow('Unsupported Boolean operator ">": must be "===" or "!=="'); 124 | }); 125 | 126 | it('throws an error if an invalid operator is passed for a number condition', () => { 127 | expect(() => { 128 | parseExpression('123 + 456'); 129 | }).toThrow('Unsupported BN operator "+": must be <, <=, ===, !==, >=, or >'); 130 | }); 131 | 132 | it('throws an error if an invalid value is present in the supplied expression', () => { 133 | expect(() => { 134 | parseExpression('123 < ~~~'); 135 | }).toThrow('Unsupported string specifying value: ~~~'); 136 | }); 137 | }); 138 | 139 | describe('check checkLogAgainstExpression', () => { 140 | it('throws an error if the argument name supplied is not present in the supplied logs', () => { 141 | const expressionObject = { 142 | variableName: 'incorrectArg', 143 | operator: '!==', 144 | comparisonFunction: undefined, 145 | value: true, 146 | }; 147 | const log = []; 148 | log.name = 'Test Event'; 149 | log.args = { result: true }; 150 | 151 | expect(() => { 152 | checkLogAgainstExpression(expressionObject, log); 153 | }).toThrow( 154 | 'Argument name incorrectArg does not match any of the arguments found in an Test Event log: result', 155 | ); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/validate-config.js: -------------------------------------------------------------------------------- 1 | const config = require('../bot-config.json'); 2 | const { botImports } = require('./agent'); 3 | const { 4 | isFilledString, 5 | isObject, 6 | isEmptyObject, 7 | } = require('./utils'); 8 | 9 | function errorMsg(msg) { 10 | console.error('\x1b[31m', 'ERROR:', '\x1b[0m', msg); 11 | } 12 | 13 | function panic(msg) { 14 | errorMsg(msg); 15 | process.exit(1); 16 | } 17 | 18 | function botName(bot) { 19 | return `${bot.name}:${bot.botType}`; 20 | } 21 | 22 | function botErr(bot, msg) { 23 | return `${botName(bot)} ${msg}`; 24 | } 25 | 26 | const validateConfig = async (botMap) => { 27 | const { 28 | developerAbbreviation, 29 | protocolName, 30 | protocolAbbreviation, 31 | gatherMode, 32 | bots, 33 | } = config; 34 | 35 | if (!isFilledString(developerAbbreviation)) { 36 | panic('developerAbbreviation not defined!'); 37 | } 38 | 39 | if (!isFilledString(protocolName)) { 40 | panic('protocolName not defined!'); 41 | } 42 | 43 | if (!isFilledString(protocolAbbreviation)) { 44 | panic('protocolAbbreviation not defined!'); 45 | } 46 | 47 | if (gatherMode !== 'any' && gatherMode !== 'all') { 48 | panic('gatherMode must be any or all'); 49 | } 50 | 51 | if (!isObject(bots) || isEmptyObject(bots)) { 52 | panic('bots must be defined and contain at least 1 bot!'); 53 | } 54 | 55 | const modProms = []; 56 | for (let i = 0; i < bots.length; i += 1) { 57 | const bot = bots[i]; 58 | 59 | if (bot.botType === undefined) { 60 | panic(`Bot ${i} has no type!`); 61 | } 62 | 63 | if (bot.name === undefined) { 64 | panic(`Bot ${i} has no name!`); 65 | } 66 | 67 | if (bot.contracts === undefined) { 68 | panic(botErr(bot, 'has no contracts!')); 69 | } 70 | 71 | const modProm = botMap.get(bot.botType); 72 | if (modProm === undefined) { 73 | panic(botErr(bot, 'module not found!')); 74 | } 75 | modProms.push(modProm); 76 | } 77 | 78 | let isValid = true; 79 | const botMods = await Promise.all(modProms); 80 | for (let i = 0; i < botMods.length; i += 1) { 81 | const bot = bots[i]; 82 | const mod = botMods[i]; 83 | 84 | console.log(`validating config for ${botName(bot)}`); 85 | 86 | const botConfig = { 87 | protocolName, 88 | protocolAbbreviation, 89 | developerAbbreviation, 90 | ...bot, 91 | }; 92 | 93 | if (mod.validateConfig === undefined) { 94 | // eslint-disable-next-line no-continue 95 | continue; 96 | } 97 | 98 | const { ok, errMsg } = mod.validateConfig(botConfig); 99 | if (!ok) { 100 | isValid = false; 101 | errorMsg(botErr(bot, `in config\n - ${errMsg}\n`)); 102 | } 103 | } 104 | 105 | return isValid; 106 | }; 107 | 108 | const main = async () => { 109 | const botMap = new Map(); 110 | for (let i = 0; i < botImports.length; i += 1) { 111 | const imp = botImports[i]; 112 | botMap.set(imp.name, imp.bot); 113 | } 114 | 115 | const isValid = await validateConfig(botMap); 116 | if (isValid) { 117 | console.log('Config validated successfully'); 118 | } else { 119 | panic('Config validation failed!'); 120 | } 121 | }; 122 | 123 | main(); 124 | --------------------------------------------------------------------------------