├── .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 |
--------------------------------------------------------------------------------