├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── bot-config.json ├── build_custom.bash ├── jest.config.js ├── package-lock.json ├── package.json ├── publish.log ├── src ├── abi │ ├── erc1155.json │ ├── erc20.json │ └── erc721.json ├── agent.ts ├── contants.ts ├── database.ts ├── findings.ts ├── logger.ts ├── tests │ ├── agent.real.spec.ts │ ├── agent.spec.ts │ ├── contracts │ │ ├── CustomERC1155.sol │ │ ├── CustomERC20.sol │ │ ├── CustomERC721.sol │ │ ├── ExploitMultipleParams.sol │ │ ├── ExploitNoParams.sol │ │ ├── ExploitPayable.sol │ │ ├── ExploitSelfFunded.sol │ │ ├── ExploitedProtocol.sol │ │ └── RegularContract.sol │ ├── database.spec.ts │ └── utils │ │ └── compiler.ts ├── types.ts └── utils.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | forta.config.json -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 13, 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | 'no-console': 'error', 15 | '@typescript-eslint/no-var-requires': 'off', 16 | '@typescript-eslint/ban-ts-comment': 'warn', 17 | "@typescript-eslint/no-non-null-assertion": "warn" 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | forta.config.json 5 | .idea 6 | .vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage: compile Typescript to Javascript 2 | FROM --platform=linux/x86_64 node:14.21-slim AS builder 3 | WORKDIR /app 4 | COPY . . 5 | RUN npm ci 6 | RUN npm run build 7 | 8 | # Final stage: copy compiled Javascript from previous stage and install production dependencies 9 | FROM --platform=linux/x86_64 node:14.21-slim 10 | ENV NODE_ENV=production 11 | ENV TARGET_MODE=0 12 | # Uncomment the following line to enable agent logging 13 | LABEL "network.forta.settings.agent-logs.enable"="true" 14 | WORKDIR /app 15 | COPY --from=builder /app/dist ./src 16 | COPY bot-config.json ./ 17 | COPY package*.json ./ 18 | COPY LICENSE ./ 19 | RUN npm ci --production 20 | CMD [ "npm", "run", "start:prod" ] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Forta Bot License 1.0 2 | --------------------- 3 | This Detection Bot License (“Agreement”) governs your use of the detection bot script and associated documentation files made available by each applicable Developer (as defined below) through the Forta Network (“Detection Bot”). The “Forta Network” means 4 | the collection of smart contracts found at https://github.com/forta-network/forta-contracts that are in production on the Polygon blockchain from time to time. 5 | 6 | 1. Detection Bot License. 7 | (a) The legal person or entity controlling the blockchain address listed as the “Owner” of the Detection Bot (“Developer”) hereby grants you a perpetual (subject to Sections 2 and 6), worldwide, non-exclusive, non-transferable, non-sublicensable, right to access and use the Detection Bot solely in connection with participating in the Forta Network, including, without limitation, to run the Detection Bot on a node in the Forta Network, in accordance with the terms of this Agreement. 8 | 9 | (b) The Developer hereby grants you a perpetual (subject to Sections 2 and 6), worldwide, non-exclusive, non-transferable, sublicensable, right to access and use any alerts or other data generated by the Detection Bot (the “Detection Bot Results”), in accordance with the terms of this Agreement. 10 | 11 | (c) This license shall apply to the Detection Bot so long as it is registered in the Forta Network bot registry smart contract, currently found at the blockchain address 0x61447385B019187daa48e91c55c02AF1F1f3F863, as may be updated from time to time, and has sufficient FORT staked in the Forta Network staking contract, currently found at 0xd2863157539b1D11F39ce23fC4834B62082F6874, as may be updated from time to time. 12 | 13 | (d) The foregoing licenses are contingent on the payment of applicable fees, if any, published or stated at docs.forta.network. You agree to comply with the terms and conditions of any subscription level or fee tier you select and for clarity, any breach thereof shall constitute a material breach of this Agreement and the licenses contained in this Agreement shall immediately terminate in accordance with Section 6 below. For the avoidance of doubt, you are not authorized to make the Detection Bot available to third parties or to sell or otherwise distribute the Detection Bot Results, other than in accordance with your subscription level or fee tier. 14 | 15 | (e) You acknowledge that the Detection Bot and Detection Bot Results, and all intellectual property rights therein, including those rights now known or hereafter developed or discovered, are the exclusive property of Developer or its licensors and that the license contemplated herein grants you no title or rights of ownership in the Detection Bot or Detection Bot Results or any components thereof or any other right or license to the foregoing, other than as explicitly set forth herein. Notwithstanding anything to the contrary in this Agreement, the Detection Bot may include software components provided by a third party that are subject to separate license terms, in which case those license terms will govern such software components. 16 | 17 | 2. Availability of the Detection Bot. 18 | Developer reserves the right to change, revise, update, suspend, discontinue, or otherwise modify the Detection Bot at any time, which may impact the Detection Bot Results. You agree that Developer has no liability whatsoever for any loss or damage caused by your inability to access or use the Detection Bot or Detection Bot Results. Nothing in this Agreement will be construed to obligate Developer to maintain or support the Detection Bot or to supply any corrections, updates, or releases in connection therewith. 19 | 20 | 3. Assumption of Risk. 21 | (a) You acknowledge that there are risks associated with the Detection Bot, including that the Detection Bot Results may not be accurate, and you expressly acknowledge and assume all risks. You further acknowledge that Developer cannot confirm the accuracy of the Detection Bot Results and Developer therefore is not responsible for any consequences related to or negative impacts arising from inaccurate, false, or incomplete Detection Bot Results. You understand and agree that the Detection Bot and Detection Bot Results are offered on a purely non-reliance basis and at your own risk. You further acknowledge that the Detection Bot and Detection Bot Results are offered through the Forta Network, which is a decentralized network of independent node operators and other bot developers that interact on a public blockchain over which Developer has no control. 22 | 23 | (b) You acknowledge that the regulatory regime governing blockchain technologies is uncertain and continually evolving, and new laws, regulations or policies may negatively impact the potential utility of the Detection Bot or Detection Bot Results, and you assume such risk. 24 | 25 | 4. Release. 26 | You hereby release Developer from any liability, loss or damage of any nature arising from any risk you assume pursuant to this section, as well as from any liability, loss or damage arising from use of the Detection Bot or reliance on the Detection Bot Results. 27 | 28 | 5. Limitation of Liability. 29 | (a) DISCLAIMER. THE DETECTION BOT AND DETECTION BOT RESULTS ARE EACH PROVIDED ON AN “AS IS” AND “AS AVAILABLE” BASIS. YOU AGREE THAT YOUR USE OF THE DETECTION BOT AND DETECTION BOT RESULTS WILL BE AT YOUR SOLE RISK. TO THE FULLEST EXTENT PERMITTED BY LAW, DEVELOPER DISCLAIMS ALL WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, IN CONNECTION WITH THE DETECTION BOT AND THE DETECTION BOT RESULTS AND YOUR USE THEREOF, INCLUDING, BUT NOT LIMITED TO, IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT OR TECHNICAL OPERATION OR PERFORMANCE. DEVELOPER ALSO MAKES NO WARRANTIES OR REPRESENTATIONS ABOUT THE ACCURACY OR COMPLETENESS OF THE DETECTION BOT RESULTS. 30 | 31 | (b) NO CONSEQUENTIAL DAMAGES. IN NO EVENT SHALL DEVELOPER BE LIABLE TO YOU UNDER THIS AGREEMENT (WHETHER IN TORT, IN STRICT LIABILITY, IN CONTRACT, OR OTHERWISE) FOR ANY (I) INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES, INCLUDING DAMAGES FOR LOST PROFITS, EVEN IF DEVELOPER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, OR (II) DAMAGES THAT EXCEED $100. THE EXISTENCE OF MORE THAN ONE CLAIM WILL NOT ENLARGE OR EXTEND THESE LIMITS. 32 | 33 | 6. Termination. 34 | If you materially breach this Agreement or if you violate any applicable law or regulation, you acknowledge you are prohibited from using the Detection Bot or Detection Bot results thereafter, even if you may be acting on behalf of a third party. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Attack Simulation Bot 2 | 3 | ## Description 4 | 5 | The agent detects deployment of smart contracts containing an exploit function. 6 | 7 | Using a [simulation-based approach](https://forta.org/blog/attack-simulation/), 8 | the bot predicts the result of function execution within a local blockchain fork 9 | and tracks any changes in balances of EOAs and the attacker's contract, allowing it to detect a potential attack before it occurs. 10 | 11 | --- 12 | 13 | This bot keeps track of all the changes in the balances of the native, ERC20, ERC721 and ERC1155 tokens that have left their traces in the transaction logs. 14 | It also takes into account negative changes in balances, as they help detect attacked projects, 15 | as well as include these addresses in the alert, which can notify projects before the exploit is used, keeping the assets intact. 16 | 17 | > At the moment, due to Ganache limitations, the bot cannot track changes in the native balance of EOAs (e.g. ETH, MATIC), 18 | > unless the EOAs were seen in ERC20, ERC721, ERC1155 token events. 19 | 20 | --- 21 | 22 | The bot scans each transaction for contract creation (including contracts created by contracts). 23 | As soon as new contracts are detected, their code is fetched and translated into OPCODE. 24 | This instruction machine code allows to find possible function selectors (4bytes) without having the [contract ABI](https://docs.soliditylang.org/en/v0.8.13/abi-spec.html). 25 | 26 | The bot then launches a local fork of the blockchain, within which it tries to mimic the execution of the functions observing changes in the token balances. 27 | To bring the simulation closer to real life, the bot performs transactions on behalf of the account that deployed the contract. 28 | 29 | While most exploit functions do not take any parameters, the bot tries to cover cases where the function can take up to 5 different parameters. 30 | It uses a clever way of determining the number of parameters, after which it is fuzzing them, shuffling potential values in various quantities. 31 | 32 | The bot also supports calling of `payable` functions, to which it sends the amount of ether specified in the [configuration file](./bot-config.json). 33 | 34 | ## Configuration 35 | 36 | You can configure the agent in the [bot-config.json](./bot-config.json) file. 37 | Supported token standards: native (e.g. ETH, MATIC), ERC20, ERC721, ERC1155. 38 | 39 | For native and ERC20 tokens, the threshold value is specified in dollars. 40 | As soon as the total amount of dollars exceeds the threshold, the bot fires an alert. 41 | You can specify this threshold value in `totalUsdTransferThreshold` field. 42 | 43 | To determine the USD value of tokens, the [DefiLlama Api](https://defillama.com/docs/api) is used. 44 | 45 | For tokens ERC721, ERC1155, the bot uses a threshold based on the total number of transferred tokens. 46 | For example, by setting `threshold` to `10` for an ERC721 token, the bot will fire an alert if it detects that an account has taken ownership of 11 different tokens (token IDs). 47 | For ERC1155 tokens, the bot also takes into account the value of each of the internal tokens, and sums them into one number. 48 | 49 | 50 | #### Example 51 | 52 | ```json 53 | { 54 | "developerAbbreviation": "AK", 55 | "payableFunctionEtherValue": "10", 56 | "totalUsdTransferThreshold": "30000", 57 | "totalTokensThresholdsByChain": { 58 | "1": { 59 | "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85": { 60 | "name": "ENS", 61 | "threshold": 10 62 | }, 63 | "0x495f947276749Ce646f68AC8c248420045cb7b5e": { 64 | "name": "OpenSea Shared Storefront", 65 | "threshold": 10 66 | } 67 | } 68 | } 69 | } 70 | ``` 71 | 72 | ## Supported Chains 73 | 74 | Chains with support for [Trace API](https://openethereum.github.io/JSONRPC-trace-module). 75 | 76 | - Ethereum (1) 77 | - BSC (56) 78 | - Polygon (137) 79 | - Arbitrum (42161) 80 | - Optimism (10) 81 | - Fantom (250) 82 | - Avalanche (43114) 83 | 84 | ## Alerts 85 | 86 | - AK-ATTACK-SIMULATION-0 87 | - Fired when an invoking function causes a large balance increase in an EOA or the contract containing the invoked function 88 | - Severity is always set to `critical` 89 | - Type is always set to `exploit` 90 | - Metadata: 91 | - `sighash` - function selector 92 | - `calldata` - function calldata 93 | - `contractAddress` - address of the deployed contract 94 | - `deployerAddress` - address of the contract deployer 95 | - `fundedAddress` - address where the significant increase in the balance has been found 96 | - `balanceChanges` - map object with arrays of balance changes for each account 97 | 98 | ## Test Data 99 | 100 | You can verify the work of the agent by running it with the following transactions: 101 | 102 | ```bash 103 | $ npm run tx 0x494b578bce7572e4fb8b1357ddf12754a28eec3439a62f6b14432dacda9cbb76 104 | ``` 105 | 106 | The result should be a finding of the Saddle Finance attack. 107 | 108 | ```js 109 | Finding { 110 | "name": "Potential Exploit Function", 111 | "description": "Invocation of the function 0xaf8271f7 of the created contract 0x7336f819775b1d31ea472681d70ce7a903482191 leads to large balance increase in the contract deployer or function invoker account. Tokens transferred: 3,375.538166306826437272 WETH", 112 | "alertId": "AK-ATTACK-SIMULATION-0", 113 | "protocol": "ethereum", 114 | "severity": "Critical", 115 | "type": "Exploit", 116 | "metadata": { 117 | "sighash": "0xaf8271f7", 118 | "calldata": "", 119 | "contractAddress": "0x7336f819775b1d31ea472681d70ce7a903482191", 120 | "deployerAddress": "0x63341ba917de90498f3903b199df5699b4a55ac0", 121 | "balanceChanges": "{\"0x27182842e098f60e3d576794a5bffb0777e025d3\":[{\"name\":\"USDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"value\":\"0\"}],\"0x7336f819775b1d31ea472681d70ce7a903482191\":[{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"0\"},{\"name\":\"USDT\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xdac17f958d2ee523a2206206994597c13d831ec7\",\"value\":\"0\"},{\"name\":\"DAI\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x6b175474e89094c44da98b954eedeac495271d0f\",\"value\":\"0\"},{\"name\":\"saddleUSD-V2\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x5f86558387293b6009d7896a61fcc86c17808d62\",\"value\":\"0\"},{\"name\":\"sUSD\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x57ab1ec28d129707052df4df418d58a2d46d5f51\",\"value\":\"0\"},{\"name\":\"dUSDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0x84721a3db22eb852233aeae74f9bc8477f8bcc42\",\"value\":\"0\"},{\"name\":\"USDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"value\":\"0\"}],\"0x0000000000000000000000000000000000000000\":[{\"name\":\"saddleUSD-V2\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x5f86558387293b6009d7896a61fcc86c17808d62\",\"value\":\"5.016537096730963109713838e+24\"},{\"name\":\"ETH\",\"type\":\"native\",\"decimals\":18,\"address\":\"native\",\"value\":\"1817975000000000\"},{\"name\":\"dUSDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0x84721a3db22eb852233aeae74f9bc8477f8bcc42\",\"value\":\"0\"}],\"0xa5407eae9ba41422680e2e00537571bcc53efbfd\":[{\"name\":\"sUSD\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x57ab1ec28d129707052df4df418d58a2d46d5f51\",\"value\":\"5.288082139740971886935251e+24\"},{\"name\":\"DAI\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x6b175474e89094c44da98b954eedeac495271d0f\",\"value\":\"1.810723455638732389504479e+24\"},{\"name\":\"USDT\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xdac17f958d2ee523a2206206994597c13d831ec7\",\"value\":\"1530488975938\"},{\"name\":\"USDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"value\":\"-8600828847387\"}],\"0x824dcd7b044d60df2e89b1bb888e66d8bcf41491\":[{\"name\":\"saddleUSD-V2\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x5f86558387293b6009d7896a61fcc86c17808d62\",\"value\":\"-5.016537096730963109713838e+24\"},{\"name\":\"sUSD\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x57ab1ec28d129707052df4df418d58a2d46d5f51\",\"value\":\"-5.288082139740971886935251e+24\"}],\"0xacb83e0633d6605c5001e2ab59ef3c745547c8c7\":[{\"name\":\"USDT\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xdac17f958d2ee523a2206206994597c13d831ec7\",\"value\":\"-1530488975938\"},{\"name\":\"USDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"value\":\"-1691981791323\"},{\"name\":\"DAI\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0x6b175474e89094c44da98b954eedeac495271d0f\",\"value\":\"-1.810723455638732389504479e+24\"}],\"0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc\":[{\"name\":\"USDC\",\"type\":\"ERC20\",\"decimals\":6,\"address\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"value\":\"10292810638710\"},{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"-3.375538166306826437272e+21\"}],\"0x63341ba917de90498f3903b199df5699b4a55ac0\":[{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"3.375538166306826437272e+21\"}]}" 122 | }, 123 | "addresses": [ 124 | "0x63341ba917de90498f3903b199df5699b4a55ac0", 125 | "0x7336f819775b1d31ea472681d70ce7a903482191", 126 | "0x27182842e098f60e3d576794a5bffb0777e025d3", 127 | "0x0000000000000000000000000000000000000000", 128 | "0xa5407eae9ba41422680e2e00537571bcc53efbfd", 129 | "0x824dcd7b044d60df2e89b1bb888e66d8bcf41491", 130 | "0xacb83e0633d6605c5001e2ab59ef3c745547c8c7", 131 | "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc", 132 | "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 133 | "0x84721a3db22eb852233aeae74f9bc8477f8bcc42", 134 | "0x57ab1ec28d129707052df4df418d58a2d46d5f51", 135 | "0xdac17f958d2ee523a2206206994597c13d831ec7", 136 | "0x6b175474e89094c44da98b954eedeac495271d0f", 137 | "0x5f86558387293b6009d7896a61fcc86c17808d62", 138 | "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" 139 | ] 140 | } 141 | 142 | ``` 143 | 144 | --- 145 | 146 | ```bash 147 | $ npm run tx 0xb00e71f0e812d383b618cf316a9ccf30a0c9c7f0036a469a32e651aba591bd7d 148 | ``` 149 | 150 | The result should be a finding of the Devour attack. 151 | 152 | ```js 153 | Finding { 154 | "name": "Potential Exploit Function", 155 | "description": "Invocation of the function 0x58581246 of the created contract 0x9e7f9123ce12060ec844ac56de047cc50a827201 leads to large balance increase in the contract deployer or function invoker account. Tokens transferred: 12.597815986560374826 ETH", 156 | "alertId": "AK-ATTACK-SIMULATION-0", 157 | "protocol": "ethereum", 158 | "severity": "Critical", 159 | "type": "Exploit", 160 | "metadata": { 161 | "sighash": "0x58581246", 162 | "calldata": "00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", 163 | "contractAddress": "0x9e7f9123ce12060ec844ac56de047cc50a827201", 164 | "deployerAddress": "0x9448368ff76b6698c59ca940b1ee2bf7fba0bc21", 165 | "balanceChanges": "{\"0x7a250d5630b4cf539739df2c5dacb4c659f2488d\":[{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"12597815986560374826\"}],\"0xf0fce5d65a42470a314fb440327cf564cca7c9d9\":[{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"133700055419679093\"},{\"name\":\"RESTAURANTS\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\",\"value\":\"-9.7e+30\"}],\"0x9e7f9123ce12060ec844ac56de047cc50a827201\":[{\"name\":\"DPAY\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xe5a733681bbe6cd8c764bb8078ef8e13a576dd78\",\"value\":\"0\"},{\"name\":\"RESTAURANTS\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\",\"value\":\"-2.70081024307292187656296889e+27\"}],\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\":[{\"name\":\"RESTAURANTS\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\",\"value\":\"5e+29\"}],\"0x000000000000000000000000000000000000dead\":[{\"name\":\"RESTAURANTS\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\",\"value\":\"2e+29\"}],\"0x308ad7d6e99a36a516ca311510f62052c336084d\":[{\"name\":\"RESTAURANTS\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xdffc63f92c939deb112d88735ade3b4d21b6d491\",\"value\":\"9.00270081024307292187656296889e+30\"},{\"name\":\"DPAY\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xe5a733681bbe6cd8c764bb8078ef8e13a576dd78\",\"value\":\"-9.002700810243072921876562e+24\"}],\"0x4cbcff3e46a106793a972ea7051dfd028f34517a\":[{\"name\":\"DPAY\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xe5a733681bbe6cd8c764bb8078ef8e13a576dd78\",\"value\":\"9.002700810243072921876562e+24\"},{\"name\":\"WETH\",\"type\":\"ERC20\",\"decimals\":18,\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"value\":\"-12731516041980053919\"}],\"0x9448368ff76b6698c59ca940b1ee2bf7fba0bc21\":[{\"name\":\"ETH\",\"type\":\"native\",\"decimals\":18,\"address\":\"native\",\"value\":\"12597815986560374826\"}]}" 166 | }, 167 | "addresses": [ 168 | "0x9448368ff76b6698c59ca940b1ee2bf7fba0bc21", 169 | "0x9e7f9123ce12060ec844ac56de047cc50a827201", 170 | "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", 171 | "0xf0fce5d65a42470a314fb440327cf564cca7c9d9", 172 | "0xdffc63f92c939deb112d88735ade3b4d21b6d491", 173 | "0x000000000000000000000000000000000000dead", 174 | "0x308ad7d6e99a36a516ca311510f62052c336084d", 175 | "0x4cbcff3e46a106793a972ea7051dfd028f34517a", 176 | "0xe5a733681bbe6cd8c764bb8078ef8e13a576dd78", 177 | "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" 178 | ] 179 | } 180 | ``` 181 | -------------------------------------------------------------------------------- /bot-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "developerAbbreviation": "AK", 3 | "payableFunctionEtherValue": 3, 4 | "totalUsdTransferThreshold": 9000, 5 | "defaultAnomalyScore": { 6 | "1": 0.0002 7 | }, 8 | "maliciousContractMLBotId": "0x9aaa5cd64000e8ba4fa2718a467b90055b70815d60351914cc1cbe89fe1c404c", 9 | "tornadoCashContractBotId": "0x457aa09ca38d60410c8ffa1761f535f23959195a56c9b82e0207801e86b34d99", 10 | "aztecContractBotId": "0x127e62dffbe1a9fa47448c29c3ef4e34f515745cb5df4d9324c2a0adae59eeef", 11 | "flashloanContractBotId": "0xda967b32461c6cd3280a49e8b5ff5b7486dbd130f3a603089ed4a6e3b03070e2", 12 | "totalTokensThresholdsByChain": { 13 | "1": { 14 | "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85": { 15 | "name": "ENS", 16 | "threshold": 10 17 | }, 18 | "0x495f947276749Ce646f68AC8c248420045cb7b5e": { 19 | "name": "OpenSea Shared Storefront", 20 | "threshold": 10 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /build_custom.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Building targeted version of the bot..." 4 | 5 | cp -f package.json package.json.temp 6 | cp -f forta.config.json forta.config.json.temp 7 | cp -f Dockerfile Dockerfile.temp 8 | 9 | echo "Modifying package.json, forta.config.json, Dockerfile..." 10 | 11 | npm pkg set 'name'='attack-simulation-bot-targeted' 12 | npm pkg set 'description'='This is a customized version of the attack-simulation-bot that scans exclusively for suspicious contracts flagged by other bots, resulting in faster detection of exploit functions.' 13 | npm pkg delete "chainSettings" 14 | npm pkg set 'chainSettings.default.shards'=1 --json 15 | npm pkg set 'chainSettings.default.target'=10 --json 16 | 17 | SOURCE_KEY="agentId2" 18 | DESTINATION_KEY="agentId" 19 | JSON=$(cat forta.config.json) 20 | SOURCE_VALUE=$(echo "$JSON" | jq -r ".$SOURCE_KEY") 21 | # Use jq to insert the source value into the destination key 22 | JSON=$(echo "$JSON" | jq --arg key "$DESTINATION_KEY" --arg value "$SOURCE_VALUE" '. + { ($key): $value }') 23 | # Write the updated JSON back to the file 24 | echo "$JSON" >forta.config.json 25 | 26 | # Use sed to replace the environment variable value in the Dockerfile 27 | DOCKER_FILE="Dockerfile" 28 | ENV_NAME="TARGET_MODE" 29 | NEW_VALUE="1" 30 | sed -i '' "s/\($ENV_NAME\s*=\s*\).*\$/\1$NEW_VALUE/" "$DOCKER_FILE" 31 | 32 | npm run publish 33 | 34 | echo "Restoring original configs..." 35 | 36 | mv package.json.temp package.json 37 | mv forta.config.json.temp forta.config.json 38 | mv Dockerfile.temp Dockerfile 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['dist', 'tests/utils'], 5 | moduleNameMapper: { 6 | 'js-combinatorics': '/node_modules/js-combinatorics/commonjs/combinatorics.js' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "attack-simulation-bot", 3 | "version": "0.0.20", 4 | "description": "The bot detects the deployment of smart contracts that contain exploit functions by simulating their execution within a sandbox environment.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/kovart/forta-attack-simulation" 8 | }, 9 | "chainIds": [ 10 | 1, 11 | 56, 12 | 137, 13 | 43114, 14 | 42161, 15 | 10, 16 | 250 17 | ], 18 | "chainSettings": { 19 | "1": { 20 | "shards": 1, 21 | "target": 22 22 | }, 23 | "10": { 24 | "shards": 1, 25 | "target": 20 26 | }, 27 | "56": { 28 | "shards": 1, 29 | "target": 20 30 | }, 31 | "137": { 32 | "shards": 1, 33 | "target": 20 34 | }, 35 | "250": { 36 | "shards": 1, 37 | "target": 22 38 | }, 39 | "42161": { 40 | "shards": 1, 41 | "target": 22 42 | }, 43 | "43114": { 44 | "shards": 1, 45 | "target": 22 46 | }, 47 | "default": { 48 | "shards": 1, 49 | "target": 20 50 | } 51 | }, 52 | "scripts": { 53 | "build": "tsc", 54 | "start": "npm run start:dev", 55 | "start:dev": "nodemon --watch src --watch forta.config.json -e js,ts,json --exec \"npm run build && forta-agent run\"", 56 | "start:prod": "NODE_ENV=production forta-agent run --prod", 57 | "start:docker": "docker run --rm -it $(docker build -q .)", 58 | "publish": "forta-agent publish", 59 | "publish:targeted": "bash ./build_custom.bash", 60 | "tx": "npm run build && cross-env DEBUG=1 forta-agent run --tx", 61 | "block": "npm run build && cross-env DEBUG=1 forta-agent run --block", 62 | "range": "npm run build && cross-env DEBUG=1 forta-agent run --range", 63 | "file": "npm run build && cross-env DEBUG=1 forta-agent run --file", 64 | "info": "forta-agent info", 65 | "logs": "forta-agent logs", 66 | "push": "forta-agent push", 67 | "disable": "forta-agent disable", 68 | "enable": "forta-agent enable", 69 | "keyfile": "forta-agent keyfile", 70 | "test": "jest", 71 | "test:real-world": "jest src/tests/agent.real.spec.ts", 72 | "lint": "eslint src/" 73 | }, 74 | "dependencies": { 75 | "async": "^3.2.4", 76 | "axios": "^1.1.3", 77 | "bignumber.js": "^9.0.2", 78 | "dotenv": "^16.0.3", 79 | "ethers": "5.7.1", 80 | "evm": "^0.2.0", 81 | "forta-agent": "^0.1.26", 82 | "forta-bot-analytics": "^0.0.4", 83 | "forta-helpers": "^1.0.9", 84 | "forta-sharding": "^1.0.0", 85 | "ganache": "^7.5.0", 86 | "js-combinatorics": "^2.1.1", 87 | "lru-cache": "^7.14.0", 88 | "sqlite3": "^5.1.6" 89 | }, 90 | "devDependencies": { 91 | "@openzeppelin/contracts": "^4.7.0", 92 | "@types/async": "^3.2.14", 93 | "@types/jest": "^29.2.0", 94 | "@types/nodemon": "^1.19.0", 95 | "@typescript-eslint/eslint-plugin": "^5.29.0", 96 | "cross-env": "^7.0.3", 97 | "eslint": "^7.32.0", 98 | "eslint-config-prettier": "^7.2.0", 99 | "forta-agent-tools": "^2.3.1", 100 | "jest": "^29.2.2", 101 | "nodemon": "^2.0.8", 102 | "prettier": "^2.7.1", 103 | "solc": "^0.8.15", 104 | "ts-jest": "^29.0.3", 105 | "typescript": "^4.8.4" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /publish.log: -------------------------------------------------------------------------------- 1 | Tue, 19 Jul 2022 08:04:07 GMT: successfully pushed image with reference bafybeickfbkzvc2j5fmeobrnoohsgpczmj3h2lxk6elqlgj4fakzodev6q@sha256:a4660700b7830a213210ecad8837b8420a8fee45801e75b9c98d0e20149307ce 2 | Tue, 19 Jul 2022 10:00:37 GMT: successfully pushed image with reference bafybeie6rggpoihtwbxmhve43sjpbcpswlgkxlpjfyaamh655ahskojnwi@sha256:4ba79e59443322bf8061d231613de7a2db59bc4ec121adbfa155c2f4ab5f8af6 3 | Mon, 19 Sep 2022 20:08:34 GMT: successfully pushed image with reference bafybeicu2bl36fozw6j5gi6qlx7nejqlifzymvqsi6tu4kv6wbwzrwj7vu@sha256:e15fa448b359b1be8e73a406f82f45b7a4c915524e043c24e3a00fad4b27311d 4 | Mon, 31 Oct 2022 10:01:16 GMT: successfully pushed image with reference bafybeibvsmpsmebsgyjoe4g2llf67x6d7frijipg4al53hados3y765ml4@sha256:0c2b96c7ae9da45c548ee05ff6f969e753f8ec4bcb3b238d1dc9dc39ee4323ab 5 | Mon, 31 Oct 2022 10:48:53 GMT: successfully pushed image with reference bafybeiavk4s4vfiz45vzhlpsy3uuzlgz2dwse2xkgqxmorepd7emgt7fji@sha256:4c988f300c5e4ef3c15fa7006a95b9497d4a1569856bdb57f0b8f5a824767626 6 | Mon, 28 Nov 2022 17:40:39 GMT: successfully pushed image with reference bafybeihm6fzvhi2qdviqoj55r3fm4ph44zaec42f6wjbzp47leaf4tqkui@sha256:ec45113da300e79301466560433854f774723f7c74eb387286a42010b52ed301 7 | Mon, 05 Dec 2022 14:52:54 GMT: successfully pushed image with reference bafybeiatfxyt72utye2var5na7qafha5k3qw4s2dzvtslvfm47gk6iregi@sha256:6e3fd630fadfafa556576e83a2bac795a5060250682ceb77e9f1743a8d1164b1 8 | Tue, 06 Dec 2022 18:34:14 GMT: successfully pushed image with reference bafybeihremmrlwvvs5t3nblprrehuifajoc76axvwicrdwvfqlzvrwxnqa@sha256:c07ca53e5da0384e8ebf23a9429b79500ea7c75b8f0a75759e38e0699a30c076 9 | Fri, 09 Dec 2022 09:29:57 GMT: successfully pushed image with reference bafybeic33yrfsznbj7uoiwegkrbggbf6lyy2lm7joz3b45ow26fkjhjqmi@sha256:e785558908805292480f2ae35c6a244c0fb790c230ece0c5757191fe05999d31 10 | Fri, 09 Dec 2022 10:04:32 GMT: successfully pushed image with reference bafybeiaw2h5qk6b3eywr6jeept5ccx7nrw3rfg2txpusl2wi4jokbiphia@sha256:1dec82aca780eb0910c32819e577b0d4e6e9caf54fbd2f45a6bbf8658edb57f1 11 | Fri, 09 Dec 2022 10:29:09 GMT: successfully pushed image with reference bafybeiggvkqdxr7tdcses6u2gjwnrju7clhzoxzdbtjzrt22guzqouz6wm@sha256:07a804e1227b427ec3c2f9c303554d0978106560d955a2c6c3ceed47b807e92f 12 | Mon, 30 Jan 2023 12:43:52 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmZ9GsuYUZZ5tpsKXhTEaLWecQthQVdhJBQZ8t6BFYuebJ 13 | Mon, 30 Jan 2023 20:55:16 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmcNQPjkPqgej8DRqnCLeq1mcDg132MgQmAPSeYVB47o1X 14 | Wed, 08 Feb 2023 21:53:54 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmQ7WuWSbQw57MFcfGxfrcwLpyg8dzPJsTmMbRcvv4hNT9 15 | Mon, 27 Feb 2023 20:05:02 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmTa23WXGfo4iTDWXWeBpVSkAs9TwF2FMZKEbQTYQCcnfc 16 | Fri, 17 Mar 2023 16:59:51 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmaFuQLeVM4PHVAZdYVvzYjnbx494G5oK8UN9LUycbLfgQ 17 | Fri, 17 Mar 2023 17:06:22 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmbJ1XzizEYNW8kd5xx25miNLeWAQvCsrXW3qz5QzFej7Y 18 | Tue, 21 Mar 2023 12:23:50 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmYfvHaJZXuZfrsZeK45mEY9RBGoV5WQhznKe92F5WePiA 19 | Tue, 11 Apr 2023 19:36:30 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmT2kCNqRVGbytSrG5WfzAXkhju8wngY9D98nSss5rAtHA 20 | Sun, 16 Apr 2023 12:03:07 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmVjujaoqrCULa3bhTkdGT2YSXH4FgFPb5yPp77jqCavVf 21 | Mon, 17 Apr 2023 18:35:12 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmXzUzKAuuxhZEy7Rz3CdkKtKUw6YFax2VJbL9QfS9EC1u 22 | Mon, 17 Apr 2023 18:55:53 GMT: successfully added agent id 0xb31f0db68c5231bad9c00877a3141da353970adcc14e1efe5b14c4d2d93c787f with manifest QmdSU91Wg5MJGZ9urPyFyx4ffYLahQ9SyRwx3dVj4aT6Gm 23 | Mon, 17 Apr 2023 19:57:23 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmZ1gpbvhuSPtYFwWGXDKziLaE3FAYi3Tjf47HpKzuiDUL 24 | Mon, 17 Apr 2023 20:31:58 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmUMUxSsJivNQsJj8njx4gj1kg8yKHdGkSeYDDGJG8AKFw 25 | Tue, 18 Apr 2023 09:05:20 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmXMManTz7BnmDJdhz8VMqWbNbmncZcDZKaZqci29XanRw 26 | Tue, 18 Apr 2023 09:16:49 GMT: successfully updated agent id 0xb31f0db68c5231bad9c00877a3141da353970adcc14e1efe5b14c4d2d93c787f with manifest QmQ3MxA1hrjUSh9jL4Y7Md3JodvjPJAoyRThXtzpjDccRk 27 | Tue, 25 Apr 2023 14:30:41 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmNn5ktvesbv7wG75sFRTYEpqxsmUed6AnNWJGbqCJjC7o 28 | Tue, 25 Apr 2023 14:34:48 GMT: successfully updated agent id 0xb31f0db68c5231bad9c00877a3141da353970adcc14e1efe5b14c4d2d93c787f with manifest QmNo9nmepW1bMwECs6d92vW714gPXC29CcbwTGs8LSDABx 29 | Tue, 25 Apr 2023 15:05:25 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmSQ7FiyAPrfBttkDej2mtRSFzYpAdRmfi5tYxebrPQpAu 30 | Tue, 25 Apr 2023 16:01:02 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmdpUVsgk8QWCbrZR9VqmiqDQkcvK63eME2GjCEsaYXAvV 31 | Tue, 25 Apr 2023 16:25:50 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmeooVqiFKmrbyFfAyoWPsnTgMPxN9yHxD3MhVccCjcJam 32 | Tue, 25 Apr 2023 18:28:46 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmW4BcjfTZv27RyEfqSLhHMuZfG34ExFRbyn4QDUkuTMeA 33 | Tue, 25 Apr 2023 18:42:09 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest Qmem1Z92ZhgKgq9VR4wFg8DyKpQ35g2xVQWGaUYDTxNhM5 34 | Tue, 25 Apr 2023 18:52:23 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmNuV6jj4wdkCihFcRLnSPdkFiTCyjhEm13QRSiLhY5u71 35 | Tue, 25 Apr 2023 19:13:25 GMT: successfully updated agent id 0xe8527df509859e531e58ba4154e9157eb6d9b2da202516a66ab120deabd3f9f6 with manifest QmYdK8AboyN82dphmBYrqAk8VdGEoQK9UzkwPbASsuhrhZ 36 | Tue, 25 Apr 2023 19:36:05 GMT: successfully updated agent id 0xb31f0db68c5231bad9c00877a3141da353970adcc14e1efe5b14c4d2d93c787f with manifest QmW8B4U49V43Cp95KmqKaN4tJn7MrxL3BX5i9BDDT7VQ9p 37 | -------------------------------------------------------------------------------- /src/abi/erc1155.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "anonymous": false, 4 | "inputs": [ 5 | { 6 | "indexed": true, 7 | "internalType": "address", 8 | "name": "account", 9 | "type": "address" 10 | }, 11 | { 12 | "indexed": true, 13 | "internalType": "address", 14 | "name": "operator", 15 | "type": "address" 16 | }, 17 | { 18 | "indexed": false, 19 | "internalType": "bool", 20 | "name": "approved", 21 | "type": "bool" 22 | } 23 | ], 24 | "name": "ApprovalForAll", 25 | "type": "event" 26 | }, 27 | { 28 | "anonymous": false, 29 | "inputs": [ 30 | { 31 | "indexed": true, 32 | "internalType": "address", 33 | "name": "operator", 34 | "type": "address" 35 | }, 36 | { 37 | "indexed": true, 38 | "internalType": "address", 39 | "name": "from", 40 | "type": "address" 41 | }, 42 | { 43 | "indexed": true, 44 | "internalType": "address", 45 | "name": "to", 46 | "type": "address" 47 | }, 48 | { 49 | "indexed": false, 50 | "internalType": "uint256[]", 51 | "name": "ids", 52 | "type": "uint256[]" 53 | }, 54 | { 55 | "indexed": false, 56 | "internalType": "uint256[]", 57 | "name": "values", 58 | "type": "uint256[]" 59 | } 60 | ], 61 | "name": "TransferBatch", 62 | "type": "event" 63 | }, 64 | { 65 | "anonymous": false, 66 | "inputs": [ 67 | { 68 | "indexed": true, 69 | "internalType": "address", 70 | "name": "operator", 71 | "type": "address" 72 | }, 73 | { 74 | "indexed": true, 75 | "internalType": "address", 76 | "name": "from", 77 | "type": "address" 78 | }, 79 | { 80 | "indexed": true, 81 | "internalType": "address", 82 | "name": "to", 83 | "type": "address" 84 | }, 85 | { 86 | "indexed": false, 87 | "internalType": "uint256", 88 | "name": "id", 89 | "type": "uint256" 90 | }, 91 | { 92 | "indexed": false, 93 | "internalType": "uint256", 94 | "name": "value", 95 | "type": "uint256" 96 | } 97 | ], 98 | "name": "TransferSingle", 99 | "type": "event" 100 | }, 101 | { 102 | "anonymous": false, 103 | "inputs": [ 104 | { 105 | "indexed": false, 106 | "internalType": "string", 107 | "name": "value", 108 | "type": "string" 109 | }, 110 | { 111 | "indexed": true, 112 | "internalType": "uint256", 113 | "name": "id", 114 | "type": "uint256" 115 | } 116 | ], 117 | "name": "URI", 118 | "type": "event" 119 | }, 120 | { 121 | "inputs": [ 122 | { 123 | "internalType": "address", 124 | "name": "account", 125 | "type": "address" 126 | }, 127 | { 128 | "internalType": "uint256", 129 | "name": "id", 130 | "type": "uint256" 131 | } 132 | ], 133 | "name": "balanceOf", 134 | "outputs": [ 135 | { 136 | "internalType": "uint256", 137 | "name": "", 138 | "type": "uint256" 139 | } 140 | ], 141 | "stateMutability": "view", 142 | "type": "function" 143 | }, 144 | { 145 | "inputs": [ 146 | { 147 | "internalType": "address[]", 148 | "name": "accounts", 149 | "type": "address[]" 150 | }, 151 | { 152 | "internalType": "uint256[]", 153 | "name": "ids", 154 | "type": "uint256[]" 155 | } 156 | ], 157 | "name": "balanceOfBatch", 158 | "outputs": [ 159 | { 160 | "internalType": "uint256[]", 161 | "name": "", 162 | "type": "uint256[]" 163 | } 164 | ], 165 | "stateMutability": "view", 166 | "type": "function" 167 | }, 168 | { 169 | "inputs": [ 170 | { 171 | "internalType": "address", 172 | "name": "account", 173 | "type": "address" 174 | }, 175 | { 176 | "internalType": "address", 177 | "name": "operator", 178 | "type": "address" 179 | } 180 | ], 181 | "name": "isApprovedForAll", 182 | "outputs": [ 183 | { 184 | "internalType": "bool", 185 | "name": "", 186 | "type": "bool" 187 | } 188 | ], 189 | "stateMutability": "view", 190 | "type": "function" 191 | }, 192 | { 193 | "inputs": [ 194 | { 195 | "internalType": "address", 196 | "name": "from", 197 | "type": "address" 198 | }, 199 | { 200 | "internalType": "address", 201 | "name": "to", 202 | "type": "address" 203 | }, 204 | { 205 | "internalType": "uint256[]", 206 | "name": "ids", 207 | "type": "uint256[]" 208 | }, 209 | { 210 | "internalType": "uint256[]", 211 | "name": "amounts", 212 | "type": "uint256[]" 213 | }, 214 | { 215 | "internalType": "bytes", 216 | "name": "data", 217 | "type": "bytes" 218 | } 219 | ], 220 | "name": "safeBatchTransferFrom", 221 | "outputs": [], 222 | "stateMutability": "nonpayable", 223 | "type": "function" 224 | }, 225 | { 226 | "inputs": [ 227 | { 228 | "internalType": "address", 229 | "name": "from", 230 | "type": "address" 231 | }, 232 | { 233 | "internalType": "address", 234 | "name": "to", 235 | "type": "address" 236 | }, 237 | { 238 | "internalType": "uint256", 239 | "name": "id", 240 | "type": "uint256" 241 | }, 242 | { 243 | "internalType": "uint256", 244 | "name": "amount", 245 | "type": "uint256" 246 | }, 247 | { 248 | "internalType": "bytes", 249 | "name": "data", 250 | "type": "bytes" 251 | } 252 | ], 253 | "name": "safeTransferFrom", 254 | "outputs": [], 255 | "stateMutability": "nonpayable", 256 | "type": "function" 257 | }, 258 | { 259 | "inputs": [ 260 | { 261 | "internalType": "address", 262 | "name": "operator", 263 | "type": "address" 264 | }, 265 | { 266 | "internalType": "bool", 267 | "name": "approved", 268 | "type": "bool" 269 | } 270 | ], 271 | "name": "setApprovalForAll", 272 | "outputs": [], 273 | "stateMutability": "nonpayable", 274 | "type": "function" 275 | }, 276 | { 277 | "inputs": [ 278 | { 279 | "internalType": "bytes4", 280 | "name": "interfaceId", 281 | "type": "bytes4" 282 | } 283 | ], 284 | "name": "supportsInterface", 285 | "outputs": [ 286 | { 287 | "internalType": "bool", 288 | "name": "", 289 | "type": "bool" 290 | } 291 | ], 292 | "stateMutability": "view", 293 | "type": "function" 294 | }, 295 | { 296 | "inputs": [ 297 | { 298 | "internalType": "uint256", 299 | "name": "id", 300 | "type": "uint256" 301 | } 302 | ], 303 | "name": "uri", 304 | "outputs": [ 305 | { 306 | "internalType": "string", 307 | "name": "", 308 | "type": "string" 309 | } 310 | ], 311 | "stateMutability": "view", 312 | "type": "function" 313 | } 314 | ] -------------------------------------------------------------------------------- /src/abi/erc20.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "name", 6 | "outputs": [ 7 | { 8 | "name": "", 9 | "type": "string" 10 | } 11 | ], 12 | "payable": false, 13 | "stateMutability": "view", 14 | "type": "function" 15 | }, 16 | { 17 | "constant": false, 18 | "inputs": [ 19 | { 20 | "name": "_spender", 21 | "type": "address" 22 | }, 23 | { 24 | "name": "_value", 25 | "type": "uint256" 26 | } 27 | ], 28 | "name": "approve", 29 | "outputs": [ 30 | { 31 | "name": "", 32 | "type": "bool" 33 | } 34 | ], 35 | "payable": false, 36 | "stateMutability": "nonpayable", 37 | "type": "function" 38 | }, 39 | { 40 | "constant": true, 41 | "inputs": [], 42 | "name": "totalSupply", 43 | "outputs": [ 44 | { 45 | "name": "", 46 | "type": "uint256" 47 | } 48 | ], 49 | "payable": false, 50 | "stateMutability": "view", 51 | "type": "function" 52 | }, 53 | { 54 | "constant": false, 55 | "inputs": [ 56 | { 57 | "name": "_from", 58 | "type": "address" 59 | }, 60 | { 61 | "name": "_to", 62 | "type": "address" 63 | }, 64 | { 65 | "name": "_value", 66 | "type": "uint256" 67 | } 68 | ], 69 | "name": "transferFrom", 70 | "outputs": [ 71 | { 72 | "name": "", 73 | "type": "bool" 74 | } 75 | ], 76 | "payable": false, 77 | "stateMutability": "nonpayable", 78 | "type": "function" 79 | }, 80 | { 81 | "constant": true, 82 | "inputs": [], 83 | "name": "decimals", 84 | "outputs": [ 85 | { 86 | "name": "", 87 | "type": "uint8" 88 | } 89 | ], 90 | "payable": false, 91 | "stateMutability": "view", 92 | "type": "function" 93 | }, 94 | { 95 | "constant": true, 96 | "inputs": [ 97 | { 98 | "name": "_owner", 99 | "type": "address" 100 | } 101 | ], 102 | "name": "balanceOf", 103 | "outputs": [ 104 | { 105 | "name": "balance", 106 | "type": "uint256" 107 | } 108 | ], 109 | "payable": false, 110 | "stateMutability": "view", 111 | "type": "function" 112 | }, 113 | { 114 | "constant": true, 115 | "inputs": [], 116 | "name": "symbol", 117 | "outputs": [ 118 | { 119 | "name": "", 120 | "type": "string" 121 | } 122 | ], 123 | "payable": false, 124 | "stateMutability": "view", 125 | "type": "function" 126 | }, 127 | { 128 | "constant": false, 129 | "inputs": [ 130 | { 131 | "name": "_to", 132 | "type": "address" 133 | }, 134 | { 135 | "name": "_value", 136 | "type": "uint256" 137 | } 138 | ], 139 | "name": "transfer", 140 | "outputs": [ 141 | { 142 | "name": "", 143 | "type": "bool" 144 | } 145 | ], 146 | "payable": false, 147 | "stateMutability": "nonpayable", 148 | "type": "function" 149 | }, 150 | { 151 | "constant": true, 152 | "inputs": [ 153 | { 154 | "name": "_owner", 155 | "type": "address" 156 | }, 157 | { 158 | "name": "_spender", 159 | "type": "address" 160 | } 161 | ], 162 | "name": "allowance", 163 | "outputs": [ 164 | { 165 | "name": "", 166 | "type": "uint256" 167 | } 168 | ], 169 | "payable": false, 170 | "stateMutability": "view", 171 | "type": "function" 172 | }, 173 | { 174 | "payable": true, 175 | "stateMutability": "payable", 176 | "type": "fallback" 177 | }, 178 | { 179 | "anonymous": false, 180 | "inputs": [ 181 | { 182 | "indexed": true, 183 | "name": "owner", 184 | "type": "address" 185 | }, 186 | { 187 | "indexed": true, 188 | "name": "spender", 189 | "type": "address" 190 | }, 191 | { 192 | "indexed": false, 193 | "name": "value", 194 | "type": "uint256" 195 | } 196 | ], 197 | "name": "Approval", 198 | "type": "event" 199 | }, 200 | { 201 | "anonymous": false, 202 | "inputs": [ 203 | { 204 | "indexed": true, 205 | "name": "from", 206 | "type": "address" 207 | }, 208 | { 209 | "indexed": true, 210 | "name": "to", 211 | "type": "address" 212 | }, 213 | { 214 | "indexed": false, 215 | "name": "value", 216 | "type": "uint256" 217 | } 218 | ], 219 | "name": "Transfer", 220 | "type": "event" 221 | } 222 | ] -------------------------------------------------------------------------------- /src/abi/erc721.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "anonymous": false, 4 | "inputs": [ 5 | { 6 | "indexed": true, 7 | "internalType": "address", 8 | "name": "owner", 9 | "type": "address" 10 | }, 11 | { 12 | "indexed": true, 13 | "internalType": "address", 14 | "name": "operator", 15 | "type": "address" 16 | }, 17 | { 18 | "indexed": true, 19 | "internalType": "uint256", 20 | "name": "tokenId", 21 | "type": "uint256" 22 | } 23 | ], 24 | "name": "Approval", 25 | "type": "event" 26 | }, 27 | { 28 | "anonymous": false, 29 | "inputs": [ 30 | { 31 | "indexed": true, 32 | "internalType": "address", 33 | "name": "from", 34 | "type": "address" 35 | }, 36 | { 37 | "indexed": true, 38 | "internalType": "address", 39 | "name": "to", 40 | "type": "address" 41 | }, 42 | { 43 | "indexed": true, 44 | "internalType": "uint256", 45 | "name": "tokenId", 46 | "type": "uint256" 47 | } 48 | ], 49 | "name": "Transfer", 50 | "type": "event" 51 | }, 52 | { 53 | "inputs": [ 54 | { 55 | "internalType": "address", 56 | "name": "operator", 57 | "type": "address" 58 | }, 59 | { 60 | "internalType": "uint256", 61 | "name": "tokenId", 62 | "type": "uint256" 63 | } 64 | ], 65 | "name": "approve", 66 | "outputs": [], 67 | "stateMutability": "payable", 68 | "type": "function" 69 | }, 70 | { 71 | "inputs": [ 72 | { 73 | "internalType": "address", 74 | "name": "account", 75 | "type": "address" 76 | } 77 | ], 78 | "name": "balanceOf", 79 | "outputs": [ 80 | { 81 | "internalType": "uint256", 82 | "name": "", 83 | "type": "uint256" 84 | } 85 | ], 86 | "stateMutability": "view", 87 | "type": "function" 88 | }, 89 | { 90 | "inputs": [ 91 | { 92 | "internalType": "uint256", 93 | "name": "tokenId", 94 | "type": "uint256" 95 | } 96 | ], 97 | "name": "getApproved", 98 | "outputs": [ 99 | { 100 | "internalType": "address", 101 | "name": "", 102 | "type": "address" 103 | } 104 | ], 105 | "stateMutability": "view", 106 | "type": "function" 107 | }, 108 | { 109 | "inputs": [ 110 | { 111 | "internalType": "address", 112 | "name": "account", 113 | "type": "address" 114 | }, 115 | { 116 | "internalType": "address", 117 | "name": "operator", 118 | "type": "address" 119 | } 120 | ], 121 | "name": "isApprovedForAll", 122 | "outputs": [ 123 | { 124 | "internalType": "bool", 125 | "name": "", 126 | "type": "bool" 127 | } 128 | ], 129 | "stateMutability": "view", 130 | "type": "function" 131 | }, 132 | { 133 | "inputs": [ 134 | { 135 | "internalType": "uint256", 136 | "name": "tokenId", 137 | "type": "uint256" 138 | } 139 | ], 140 | "name": "ownerOf", 141 | "outputs": [ 142 | { 143 | "internalType": "address", 144 | "name": "", 145 | "type": "address" 146 | } 147 | ], 148 | "stateMutability": "view", 149 | "type": "function" 150 | }, 151 | { 152 | "inputs": [ 153 | { 154 | "internalType": "address", 155 | "name": "from", 156 | "type": "address" 157 | }, 158 | { 159 | "internalType": "address", 160 | "name": "to", 161 | "type": "address" 162 | }, 163 | { 164 | "internalType": "uint256", 165 | "name": "tokenId", 166 | "type": "uint256" 167 | } 168 | ], 169 | "name": "safeTransferFrom", 170 | "outputs": [], 171 | "stateMutability": "payable", 172 | "type": "function" 173 | }, 174 | { 175 | "inputs": [ 176 | { 177 | "internalType": "address", 178 | "name": "from", 179 | "type": "address" 180 | }, 181 | { 182 | "internalType": "address", 183 | "name": "to", 184 | "type": "address" 185 | }, 186 | { 187 | "internalType": "uint256", 188 | "name": "tokenId", 189 | "type": "uint256" 190 | }, 191 | { 192 | "internalType": "bytes", 193 | "name": "data", 194 | "type": "bytes" 195 | } 196 | ], 197 | "name": "safeTransferFrom", 198 | "outputs": [], 199 | "stateMutability": "payable", 200 | "type": "function" 201 | }, 202 | { 203 | "inputs": [ 204 | { 205 | "internalType": "address", 206 | "name": "operator", 207 | "type": "address" 208 | }, 209 | { 210 | "internalType": "bool", 211 | "name": "status", 212 | "type": "bool" 213 | } 214 | ], 215 | "name": "setApprovalForAll", 216 | "outputs": [], 217 | "stateMutability": "nonpayable", 218 | "type": "function" 219 | }, 220 | { 221 | "inputs": [ 222 | { 223 | "internalType": "bytes4", 224 | "name": "interfaceId", 225 | "type": "bytes4" 226 | } 227 | ], 228 | "name": "supportsInterface", 229 | "outputs": [ 230 | { 231 | "internalType": "bool", 232 | "name": "", 233 | "type": "bool" 234 | } 235 | ], 236 | "stateMutability": "view", 237 | "type": "function" 238 | }, 239 | { 240 | "inputs": [ 241 | { 242 | "internalType": "address", 243 | "name": "from", 244 | "type": "address" 245 | }, 246 | { 247 | "internalType": "address", 248 | "name": "to", 249 | "type": "address" 250 | }, 251 | { 252 | "internalType": "uint256", 253 | "name": "tokenId", 254 | "type": "uint256" 255 | } 256 | ], 257 | "name": "transferFrom", 258 | "outputs": [], 259 | "stateMutability": "payable", 260 | "type": "function" 261 | }, 262 | { 263 | "inputs": [], 264 | "name": "name", 265 | "outputs": [ 266 | { 267 | "internalType": "string", 268 | "name": "", 269 | "type": "string" 270 | } 271 | ], 272 | "stateMutability": "view", 273 | "type": "function" 274 | }, 275 | { 276 | "inputs": [], 277 | "name": "symbol", 278 | "outputs": [ 279 | { 280 | "internalType": "string", 281 | "name": "", 282 | "type": "string" 283 | } 284 | ], 285 | "stateMutability": "view", 286 | "type": "function" 287 | }, 288 | { 289 | "inputs": [ 290 | { 291 | "internalType": "uint256", 292 | "name": "tokenId", 293 | "type": "uint256" 294 | } 295 | ], 296 | "name": "tokenURI", 297 | "outputs": [ 298 | { 299 | "internalType": "string", 300 | "name": "", 301 | "type": "string" 302 | } 303 | ], 304 | "stateMutability": "view", 305 | "type": "function" 306 | }, 307 | { 308 | "anonymous": false, 309 | "inputs": [ 310 | { 311 | "indexed": true, 312 | "internalType": "address", 313 | "name": "owner", 314 | "type": "address" 315 | }, 316 | { 317 | "indexed": true, 318 | "internalType": "address", 319 | "name": "operator", 320 | "type": "address" 321 | }, 322 | { 323 | "indexed": false, 324 | "internalType": "bool", 325 | "name": "approved", 326 | "type": "bool" 327 | } 328 | ], 329 | "name": "ApprovalForAll", 330 | "type": "event" 331 | } 332 | ] -------------------------------------------------------------------------------- /src/agent.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { ethers } from 'ethers'; 3 | import dotenv from 'dotenv'; 4 | import path from 'path'; 5 | import { BotSharding } from 'forta-sharding'; 6 | import { createTicker } from 'forta-helpers'; 7 | import { priorityQueue } from 'async'; 8 | import { 9 | getEthersProvider, 10 | HandleAlert, 11 | HandleTransaction, 12 | Initialize, 13 | Network, 14 | TransactionEvent, 15 | } from 'forta-agent'; 16 | import { BotAnalytics, FortaBotStorage, InMemoryBotStorage } from 'forta-bot-analytics'; 17 | 18 | import * as botUtils from './utils'; 19 | import { Logger, LoggerLevel } from './logger'; 20 | import { IDatabase, InMemoryDatabase, SqlDatabase } from './database'; 21 | import { createExploitFunctionFinding } from './findings'; 22 | import { BASE_SHARDING_CONFIG, BURN_ADDRESSES, TARGETED_SHARDING_CONFIG } from './contants'; 23 | import { 24 | BotConfig, 25 | BotEnv, 26 | CreatedContract, 27 | DataContainer, 28 | HandleContract, 29 | TokenInfo, 30 | TokenInterface, 31 | } from './types'; 32 | 33 | dotenv.config(); 34 | 35 | const ENV = process.env as BotEnv; 36 | const data = {} as DataContainer; 37 | const botConfig: BotConfig = require('../bot-config.json'); 38 | const database: IDatabase = 39 | ENV.NODE_ENV === 'production' 40 | ? new SqlDatabase(path.resolve(__dirname, './bot.db')) 41 | : new InMemoryDatabase(); 42 | 43 | const LOW_PRIORITY = 9; 44 | const NORMAL_PRIORITY = 4; 45 | const HIGH_PRIORITY = 1; 46 | 47 | const provideInitialize = ( 48 | data: DataContainer, 49 | config: BotConfig, 50 | env: BotEnv, 51 | database: IDatabase, 52 | handleContract: HandleContract, 53 | ): Initialize => { 54 | return async function initialize() { 55 | data.developerAbbreviation = config.developerAbbreviation; 56 | data.payableFunctionEtherValue = config.payableFunctionEtherValue; 57 | data.isDevelopment = env.NODE_ENV !== 'production'; 58 | data.isTargetMode = env.TARGET_MODE === '1'; 59 | data.isDebug = env.DEBUG === '1'; 60 | 61 | data.logger = new Logger(data.isDevelopment ? LoggerLevel.DEBUG : LoggerLevel.INFO); 62 | 63 | data.provider = getEthersProvider(); 64 | data.findings = []; 65 | data.totalUsdTransferThreshold = new BigNumber(config.totalUsdTransferThreshold); 66 | data.totalTokensThresholdsByAddress = {}; 67 | data.chainId = (await data.provider.getNetwork()).chainId; 68 | // normalize token addresses 69 | Object.keys(config.totalTokensThresholdsByChain[data.chainId] || {}).forEach((tokenAddress) => { 70 | const record = config.totalTokensThresholdsByChain[data.chainId][tokenAddress]; 71 | data.totalTokensThresholdsByAddress[tokenAddress.toLowerCase()] = { 72 | name: record.name, 73 | threshold: new BigNumber(record.threshold), 74 | }; 75 | }); 76 | 77 | // Sharding 78 | 79 | const shardingConfig: { [key: string]: { target: number } } = data.isTargetMode 80 | ? TARGETED_SHARDING_CONFIG 81 | : BASE_SHARDING_CONFIG; 82 | const redundancy: number = 83 | shardingConfig[data.chainId]?.target || shardingConfig['default']?.target; 84 | 85 | if (!redundancy) throw new Error('Redundancy is missing'); 86 | 87 | data.sharding = new BotSharding({ 88 | isDevelopment: data.isDevelopment, 89 | redundancy: redundancy, 90 | }); 91 | 92 | // Database 93 | 94 | data.database = database; 95 | await data.database.initialize(); 96 | 97 | data.detectedContractByAddress = new Map( 98 | (await data.database.getContracts()).map((v) => [v.address, v]), 99 | ); 100 | if (data.detectedContractByAddress.size) { 101 | data.logger.info( 102 | `Loaded ${data.detectedContractByAddress.size} contracts from local database`, 103 | ); 104 | } 105 | 106 | data.suspiciousContractByAddress = new Map(); 107 | data.contractWaitingTime = 2 * 24 * 60 * 60; // 2d 108 | 109 | // Bot Analytics 110 | 111 | data.analytics = new BotAnalytics( 112 | data.isDevelopment 113 | ? new InMemoryBotStorage(data.logger.info) 114 | : new FortaBotStorage(data.logger.info), 115 | { 116 | key: data.chainId.toString(), 117 | defaultAnomalyScore: { 118 | [BotAnalytics.GeneralAlertId]: 119 | config.defaultAnomalyScore[data.chainId] ?? config.defaultAnomalyScore[Network.MAINNET], 120 | }, 121 | syncTimeout: 60 * 60, // 1h 122 | maxSyncDelay: 60 * 24 * 60 * 60, // 60d 123 | observableInterval: 60 * 24 * 60 * 60, // 60d 124 | logFn: data.logger.info, 125 | }, 126 | ); 127 | 128 | // Async queue 129 | 130 | data.queue = priorityQueue(async (createdContract, cb) => { 131 | try { 132 | await handleContract(createdContract); 133 | } catch (e) { 134 | data.logger.error('Task error', e); 135 | } finally { 136 | await data.database.deleteContract(createdContract.address); 137 | cb(); 138 | } 139 | }, 1); 140 | 141 | // push tasks that have not yet been completed in the previous run 142 | for (const contract of await data.database.getContracts()) { 143 | data.queue.push(contract, contract.priority); 144 | } 145 | 146 | data.isInitialized = true; 147 | data.logger.debug( 148 | `Initialized. Is Development: ${data.isDevelopment}. Is Target Mode: ${data.isTargetMode}.`, 149 | ); 150 | 151 | return { 152 | alertConfig: { 153 | subscriptions: [ 154 | { 155 | botId: config.maliciousContractMLBotId, 156 | alertIds: ['SUSPICIOUS-CONTRACT-CREATION'], 157 | chainId: data.chainId, 158 | }, 159 | { 160 | botId: config.flashloanContractBotId, 161 | alertIds: ['SUSPICIOUS-FLASHLOAN-CONTRACT-CREATION', 'FLASHLOAN-CONTRACT-CREATION'], 162 | chainId: data.chainId, 163 | }, 164 | { 165 | botId: config.tornadoCashContractBotId, 166 | alertIds: ['SUSPICIOUS-CONTRACT-CREATION-TORNADO-CASH'], 167 | chainId: data.chainId, 168 | }, 169 | { 170 | botId: config.aztecContractBotId, 171 | alertIds: ['AK-AZTEC-PROTOCOL-FUNDED-ACCOUNT-DEPLOYMENT'], 172 | chainId: data.chainId, 173 | }, 174 | ], 175 | }, 176 | }; 177 | }; 178 | }; 179 | 180 | const provideHandleAlert = (data: DataContainer, config: BotConfig): HandleAlert => { 181 | return async (alertEvent) => { 182 | // temp fix of the issue with caused by receiving alerts to which the bot was previously subscribed to 183 | if ( 184 | ![ 185 | 'SUSPICIOUS-CONTRACT-CREATION', 186 | 'SUSPICIOUS-FLASHLOAN-CONTRACT-CREATION', 187 | 'FLASHLOAN-CONTRACT-CREATION', 188 | 'SUSPICIOUS-CONTRACT-CREATION-TORNADO-CASH', 189 | 'AK-AZTEC-PROTOCOL-FUNDED-ACCOUNT-DEPLOYMENT', 190 | ].includes(alertEvent.alertId || '') 191 | ) { 192 | return []; 193 | } 194 | 195 | const handlers = { 196 | [config.tornadoCashContractBotId]: { 197 | getPriority: () => NORMAL_PRIORITY, 198 | getContractAddress: () => alertEvent.alert.description?.slice(0, 42).toLowerCase(), 199 | }, 200 | [config.aztecContractBotId]: { 201 | getPriority: () => HIGH_PRIORITY, 202 | getContractAddress: () => alertEvent.alert.name?.slice(-42).toLowerCase(), 203 | }, 204 | [config.maliciousContractMLBotId]: { 205 | getPriority: () => HIGH_PRIORITY, 206 | getContractAddress: () => alertEvent.alert.description?.slice(-42).toLowerCase(), 207 | }, 208 | [config.flashloanContractBotId]: { 209 | getPriority: () => HIGH_PRIORITY, 210 | getContractAddress: () => alertEvent.alert.name?.slice(-42).toLowerCase(), 211 | }, 212 | }; 213 | 214 | for (const [botId, handler] of Object.entries(handlers)) { 215 | if (alertEvent.botId?.toLowerCase() !== botId.toLowerCase()) continue; 216 | 217 | const contractAddress = handler.getContractAddress(); 218 | const detectionPriority = handler.getPriority(); 219 | 220 | if (!contractAddress) break; 221 | 222 | // Removing is the only way to change priority in the queue 223 | let node: { data: CreatedContract; priority: number } | undefined; 224 | data.queue.remove((task) => { 225 | if (task.data.address === contractAddress) { 226 | node = task; 227 | // remove if the task priority is less than alert one (the bigger number, the less priority) 228 | return task.priority > detectionPriority; 229 | } 230 | return false; 231 | }); 232 | 233 | if (node) { 234 | if(node.priority <= detectionPriority) continue; 235 | 236 | data.queue.push(node.data, detectionPriority); 237 | if (!data.isTargetMode) { 238 | data.logger.info( 239 | `Changed scan priority of ${contractAddress} due to "${alertEvent.alertId}" alert`, 240 | ); 241 | } 242 | 243 | await data.database.updatePriority(contractAddress, detectionPriority); 244 | } else { 245 | data.suspiciousContractByAddress.set(contractAddress, { 246 | address: contractAddress, 247 | timestamp: Math.floor( 248 | new Date(alertEvent.alert.createdAt || Date.now()).valueOf() / 1000, 249 | ), 250 | priority: handler.getPriority(), 251 | }); 252 | } 253 | } 254 | 255 | return []; 256 | }; 257 | }; 258 | 259 | const provideHandleContract = ( 260 | data: DataContainer, 261 | utils: Pick< 262 | typeof botUtils, 263 | | 'generateCallData' 264 | | 'getTotalBalanceChanges' 265 | | 'getSighashes' 266 | | 'getTokenDecimals' 267 | | 'getTokenNames' 268 | | 'getEthersForkProvider' 269 | | 'getNativeTokenPrice' 270 | | 'getErc20TokenPrice' 271 | >, 272 | ): HandleContract => { 273 | return async function handleContract(createdContract: CreatedContract) { 274 | data.logger.debug('Contract', createdContract.address); 275 | 276 | const { 277 | generateCallData, 278 | getTotalBalanceChanges, 279 | getSighashes, 280 | getEthersForkProvider, 281 | getTokenDecimals, 282 | getTokenNames, 283 | getNativeTokenPrice, 284 | getErc20TokenPrice, 285 | } = utils; 286 | 287 | const provider = getEthersForkProvider(createdContract.blockNumber, [ 288 | createdContract.deployer, // we use deployer address as a transaction sender 289 | ]); 290 | const contractCode = await provider.getCode( 291 | createdContract.address, 292 | createdContract.blockNumber, 293 | ); 294 | const singer = provider.getSigner(createdContract.deployer); 295 | const sighashes = getSighashes(contractCode); 296 | 297 | const timestamp = (await data.provider.getBlock(createdContract.blockNumber)).timestamp; 298 | 299 | try { 300 | // send ethers to the sender's account 301 | // to make sure that the balance will be enough to call transactions 302 | const accounts = await provider.listAccounts(); 303 | const tx = await provider.getSigner(accounts[0]).sendTransaction({ 304 | to: createdContract.deployer, 305 | // substitute one ether to be able to complete this transaction 306 | value: (await provider.getBalance(accounts[0])).sub(ethers.utils.parseEther('1')), 307 | }); 308 | await tx.wait(); 309 | } catch (e) { 310 | data.logger.warn('error when trying to send ethers to the contract deployer', e); 311 | } 312 | 313 | // some functions require sending a certain amount of ether, 314 | // e.g. https://etherscan.io/tx/0xaf961653906aa831fa1ff7876fa6eecc10e415c7c2bffec69ee26e02bde6f4fc 315 | // so we iterate value for payable and non-payable functions 316 | for (const value of [ethers.utils.parseEther(data.payableFunctionEtherValue.toString()), 0]) { 317 | data.logger.debug('Transaction value', value.toString()); 318 | for (const sighash of sighashes) { 319 | data.logger.debug('Function', sighash); 320 | 321 | let programCounter = -1; 322 | let isSignatureFound = false; 323 | // iterate from 0 to 5 function parameters until we found that function is being executed 324 | for (let wordCount = 0; wordCount <= 5 && !isSignatureFound; wordCount++) { 325 | for await (const calldata of generateCallData({ 326 | wordCount: wordCount, 327 | addresses: [createdContract.deployer], 328 | })) { 329 | try { 330 | // execute transaction 331 | const tx = await singer.sendTransaction({ 332 | to: createdContract.address, 333 | data: sighash + calldata, 334 | value: value, 335 | }); 336 | const receipt = await tx.wait(); 337 | 338 | // if we are here, then we successfully completed the transaction 339 | if (!isSignatureFound) { 340 | isSignatureFound = true; 341 | data.logger.debug( 342 | 'Signature found by success transaction', 343 | createdContract.address, 344 | sighash, 345 | calldata, 346 | ); 347 | } 348 | 349 | // get all token transfers caused by the transaction (including native token) 350 | const { interfacesByTokenAddress, totalBalanceChangesByAddress } = 351 | await getTotalBalanceChanges({ 352 | tx, 353 | receipt, 354 | provider, 355 | }); 356 | 357 | // skip if it is a refund 358 | if ( 359 | Object.keys(totalBalanceChangesByAddress).length === 2 && 360 | totalBalanceChangesByAddress[createdContract.address] && 361 | totalBalanceChangesByAddress[createdContract.deployer] 362 | ) { 363 | const contractChanges = totalBalanceChangesByAddress[createdContract.address] || {}; 364 | const deployerChanges = 365 | totalBalanceChangesByAddress[createdContract.deployer] || {}; 366 | 367 | // check if the same tokens have been transferred 368 | let isRefund = 369 | Object.keys(deployerChanges).length === Object.keys(contractChanges).length; 370 | 371 | // check if the same amount of tokens have been transferred 372 | if (isRefund) { 373 | for (const [token, balance] of Object.entries(deployerChanges)) { 374 | if (!contractChanges[token]?.abs().eq(balance)) { 375 | isRefund = false; 376 | break; 377 | } 378 | } 379 | } 380 | 381 | if (isRefund) { 382 | let isZeroBalance = false; 383 | for (const balance of Object.values(deployerChanges)) { 384 | if (!balance.isZero()) { 385 | isZeroBalance = false; 386 | break; 387 | } 388 | } 389 | 390 | // we log only when more than 0 tokens are transferred 391 | if (!isZeroBalance) { 392 | data.logger.warn( 393 | 'Skip refund function', 394 | sighash, 395 | JSON.stringify(totalBalanceChangesByAddress), 396 | ); 397 | } 398 | 399 | break; 400 | } 401 | } 402 | 403 | // get token prices 404 | const tokenPriceByAddress: { [address: string]: number | undefined } = {}; 405 | for (const address of Object.keys(interfacesByTokenAddress)) { 406 | let price: number | undefined; 407 | if (interfacesByTokenAddress[address] === TokenInterface.NATIVE) { 408 | price = await getNativeTokenPrice(data.chainId, data.logger, timestamp); 409 | } else if (interfacesByTokenAddress[address] === TokenInterface.ERC20) { 410 | price = await getErc20TokenPrice(data.chainId, address, data.logger, timestamp); 411 | } 412 | tokenPriceByAddress[address] = price; 413 | } 414 | 415 | const decimalsByToken = await getTokenDecimals({ 416 | addresses: Object.entries(interfacesByTokenAddress) 417 | .filter(([, type]) => 418 | [TokenInterface.ERC20, TokenInterface.NATIVE].includes(type), 419 | ) 420 | .map(([address]) => address), 421 | provider: provider, 422 | }); 423 | 424 | let highlyFundedAccount: string | null = null; 425 | 426 | for (const [account, balanceByTokenAddress] of Object.entries( 427 | totalBalanceChangesByAddress, 428 | )) { 429 | // check if the account is a burn-address 430 | if (account.includes('000000000000') || BURN_ADDRESSES.includes(account)) { 431 | continue; 432 | } 433 | 434 | // check whether the account is related to a potential exploiter 435 | // or it is an unknown EOA 436 | if (![createdContract.address, createdContract.deployer].includes(account)) { 437 | const isEOA = (await provider.getCode(account)) === '0x'; 438 | if (!isEOA) continue; 439 | } 440 | 441 | let totalReceivedUsd = new BigNumber(0); 442 | for (const [tokenAddress, value] of Object.entries(balanceByTokenAddress)) { 443 | let profitValue = value; 444 | const tokenType = interfacesByTokenAddress[tokenAddress]; 445 | 446 | // check if threshold of erc721 and erc1155 tokens is exceeded 447 | if ( 448 | data.totalTokensThresholdsByAddress[tokenAddress] && 449 | profitValue.isGreaterThan( 450 | data.totalTokensThresholdsByAddress[tokenAddress].threshold, 451 | ) 452 | ) { 453 | highlyFundedAccount = account; 454 | break; 455 | } 456 | 457 | // this helps to get rid of false positives on the deployment of contracts with withdraw() functions 458 | if (tokenType === TokenInterface.NATIVE) { 459 | const withdrawValue = 460 | totalBalanceChangesByAddress[createdContract.address]?.[tokenAddress] || 461 | new BigNumber(0); 462 | 463 | // check if the deployer withdraws his ethers back from the contract 464 | if (withdrawValue.isNegative()) { 465 | // if so, then subtract the ethers that come back 466 | profitValue = profitValue.plus(withdrawValue); 467 | } 468 | } 469 | 470 | if ([TokenInterface.ERC20, TokenInterface.NATIVE].includes(tokenType)) { 471 | // add transferred value in USD 472 | totalReceivedUsd = totalReceivedUsd.plus( 473 | profitValue 474 | .div(new BigNumber(10).pow(decimalsByToken[tokenAddress] || 0)) 475 | .multipliedBy(tokenPriceByAddress[tokenAddress] || 0), 476 | ); 477 | } 478 | 479 | // check USD threshold 480 | if (totalReceivedUsd.isGreaterThan(data.totalUsdTransferThreshold)) { 481 | highlyFundedAccount = account; 482 | break; 483 | } 484 | } 485 | } 486 | 487 | // check if we found account that exceeded threshold 488 | if (highlyFundedAccount) { 489 | const tokensByAccount: { [account: string]: TokenInfo[] } = {}; 490 | const namesByToken = await getTokenNames({ 491 | addresses: Object.keys(interfacesByTokenAddress), 492 | knownTokens: data.totalTokensThresholdsByAddress, 493 | provider: provider, 494 | chainId: data.chainId, 495 | }); 496 | 497 | for (const account of Object.keys(totalBalanceChangesByAddress)) { 498 | for (const tokenAddress of Object.keys(totalBalanceChangesByAddress[account])) { 499 | const token: TokenInfo = { 500 | name: namesByToken[tokenAddress], 501 | type: interfacesByTokenAddress[tokenAddress], 502 | decimals: decimalsByToken[tokenAddress], 503 | address: tokenAddress, 504 | value: totalBalanceChangesByAddress[account][tokenAddress], 505 | }; 506 | tokensByAccount[account] = tokensByAccount[account] || []; 507 | tokensByAccount[account].push(token); 508 | } 509 | tokensByAccount[account].sort((a, b) => 510 | b.value.isGreaterThan(a.value) ? 0 : -1, 511 | ); 512 | } 513 | 514 | const involvedAddresses = new Set([ 515 | createdContract.deployer, 516 | createdContract.address, 517 | ...Object.keys(totalBalanceChangesByAddress), 518 | ...receipt.logs.map((l) => l.address.toLowerCase()), 519 | ]); 520 | 521 | data.analytics.incrementAlertTriggers(createdContract.timestamp); 522 | 523 | data.findings.push( 524 | createExploitFunctionFinding( 525 | sighash, 526 | calldata, 527 | createdContract.address, 528 | createdContract.deployer, 529 | highlyFundedAccount, 530 | tokensByAccount, 531 | [...involvedAddresses], 532 | data.analytics.getAnomalyScore(), 533 | createdContract.txHash, 534 | data.developerAbbreviation, 535 | ), 536 | ); 537 | 538 | return; 539 | } 540 | } catch (e: any) { 541 | // if not a ganache error 542 | if (!e?.data?.programCounter) { 543 | data.logger.warn('handleContract error', e); 544 | return; 545 | } 546 | 547 | // check if we faced with error caused by function execution (inner error) 548 | if (!isSignatureFound && (e.data.reason || e.data.result?.length > 2)) { 549 | data.logger.debug( 550 | 'Signature found by changed revert', 551 | createdContract.address, 552 | sighash, 553 | calldata, 554 | ); 555 | isSignatureFound = true; 556 | } 557 | 558 | if (isSignatureFound) { 559 | continue; 560 | } 561 | 562 | // the unchanged counter most likely means that we are facing the same error; 563 | // given that there is no "reason" for the error, 564 | // it is very likely a signature mismatch error. 565 | if (programCounter > -1 && programCounter === e.data.programCounter) { 566 | // increment word counter 567 | break; 568 | } 569 | 570 | // update current counter to see if it changes in the next iteration 571 | programCounter = e.data.programCounter; 572 | } 573 | } 574 | } 575 | } 576 | } 577 | }; 578 | }; 579 | 580 | const provideHandleTransaction = ( 581 | data: DataContainer, 582 | utils: Pick, 583 | initialize: Initialize, 584 | ): HandleTransaction => { 585 | let statsLoggedAt = 0; 586 | const isTimeToSyncSharding = createTicker(5 * 60 * 1000); // 5m 587 | 588 | return async function handleTransaction(txEvent: TransactionEvent) { 589 | if (!data.isInitialized) { 590 | // eslint-disable-next-line no-console 591 | console.error('DataContainer is not initialized'); 592 | await initialize(); 593 | } 594 | 595 | if (!data.isDevelopment) { 596 | await data.analytics.sync(txEvent.timestamp); 597 | 598 | if (isTimeToSyncSharding(txEvent.timestamp)) { 599 | await data.sharding.sync(txEvent.network); 600 | } 601 | 602 | if (txEvent.blockNumber % data.sharding.getShardCount() !== data.sharding.getShardIndex()) { 603 | return data.findings.splice(0); 604 | } 605 | } 606 | 607 | const { getCreatedContracts } = utils; 608 | 609 | const createdContracts: CreatedContract[] = getCreatedContracts(txEvent); 610 | 611 | // update analytics data to calculate anomaly score 612 | for (const contract of createdContracts) { 613 | data.analytics.incrementBotTriggers(txEvent.timestamp); 614 | data.detectedContractByAddress.set(contract.address, contract); 615 | await data.database.addContract(contract, LOW_PRIORITY); 616 | } 617 | 618 | // log scan queue every 10 minutes 619 | if (data.queue.length() >= 5 && Date.now() - statsLoggedAt > 10 * 60 * 1000) { 620 | const workers = data.queue.workersList(); 621 | data.logger.warn( 622 | `Scan queue: ${data.queue.length()}. ` + 623 | `Current block: ${txEvent.blockNumber}. ` + 624 | `Scanning block: ${workers[0].data.blockNumber}. ` + 625 | `Block delay: ${txEvent.blockNumber - workers[0].data.blockNumber}. ` + 626 | `Memory: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)}Mb`, 627 | ); 628 | statsLoggedAt = Date.now(); 629 | } 630 | 631 | if (data.isTargetMode) { 632 | for (const contract of data.suspiciousContractByAddress.values()) { 633 | const detectedContract = data.detectedContractByAddress.get(contract.address); 634 | if (detectedContract) { 635 | data.logger.info(`Pushed suspicious contract: ${contract.address}`); 636 | data.queue.push(detectedContract, contract.priority); 637 | data.detectedContractByAddress.delete(contract.address); 638 | data.suspiciousContractByAddress.delete(contract.address); 639 | } 640 | } 641 | } else { 642 | for (const contract of createdContracts) { 643 | let priority = LOW_PRIORITY; 644 | if (data.suspiciousContractByAddress.has(contract.address)) { 645 | priority = data.suspiciousContractByAddress.get(contract.address)!.priority; 646 | data.suspiciousContractByAddress.delete(contract.address); 647 | } 648 | data.queue.push(contract, priority); 649 | data.detectedContractByAddress.delete(contract.address); 650 | await data.database.updatePriority(contract.address, priority); 651 | } 652 | } 653 | 654 | // remove outdated detected contracts 655 | for (const contract of data.detectedContractByAddress.values()) { 656 | if (txEvent.timestamp - contract.timestamp > data.contractWaitingTime) { 657 | data.detectedContractByAddress.delete(contract.address); 658 | await data.database.deleteContract(contract.address); 659 | } 660 | } 661 | // remove outdated suspicious contracts 662 | for (const contract of data.suspiciousContractByAddress.values()) { 663 | if (txEvent.timestamp - contract.timestamp > data.contractWaitingTime) { 664 | data.suspiciousContractByAddress.delete(contract.address); 665 | } 666 | } 667 | 668 | if (data.isDebug) { 669 | await data.queue.drain(); 670 | } 671 | 672 | return data.findings.splice(0); 673 | }; 674 | }; 675 | 676 | const initialize = provideInitialize( 677 | data, 678 | botConfig, 679 | ENV, 680 | database, 681 | provideHandleContract(data, botUtils), 682 | ); 683 | 684 | export default { 685 | initialize: initialize, 686 | handleTransaction: provideHandleTransaction(data, botUtils, initialize), 687 | handleAlert: provideHandleAlert(data, botConfig), 688 | 689 | provideInitialize, 690 | provideHandleTransaction, 691 | provideHandleContract, 692 | provideHandleAlert, 693 | }; 694 | -------------------------------------------------------------------------------- /src/contants.ts: -------------------------------------------------------------------------------- 1 | // https://etherscan.io/accounts/label/burn?subcatid=1&size=100&start=0&col=1&order=asc 2 | export const BURN_ADDRESSES = [ 3 | '0x00000000000000000000045261d4ee77acdb3286', 4 | '0x0123456789012345678901234567890123456789', 5 | '0x1234567890123456789012345678901234567890', 6 | '0x1111111111111111111111111111111111111111', 7 | '0x2222222222222222222222222222222222222222', 8 | '0x3333333333333333333333333333333333333333', 9 | '0x4444444444444444444444444444444444444444', 10 | '0x5555555555555555555555555555555555555555', 11 | '0x6666666666666666666666666666666666666666', 12 | '0x7777777777777777777777777777777777777777', 13 | '0x8888888888888888888888888888888888888888', 14 | '0x9999999999999999999999999999999999999999', 15 | '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 16 | '0xdead000000000000000042069420694206942069', 17 | '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 18 | '0xffffffffffffffffffffffffffffffffffffffff', 19 | '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 20 | '0x000000000000000000000000000000000000dead', 21 | ]; 22 | 23 | export const BASE_SHARDING_CONFIG = { 24 | '1': { 25 | shards: 11, 26 | target: 2, 27 | }, 28 | '10': { 29 | shards: 10, 30 | target: 2, 31 | }, 32 | '56': { 33 | shards: 10, 34 | target: 2, 35 | }, 36 | '137': { 37 | shards: 10, 38 | target: 2, 39 | }, 40 | '250': { 41 | shards: 11, 42 | target: 2, 43 | }, 44 | '42161': { 45 | shards: 11, 46 | target: 2, 47 | }, 48 | '43114': { 49 | shards: 11, 50 | target: 2, 51 | }, 52 | default: { 53 | shards: 10, 54 | target: 2, 55 | }, 56 | }; 57 | 58 | export const TARGETED_SHARDING_CONFIG = { 59 | default: { 60 | shards: 5, 61 | target: 2, 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import sqlite3 from 'sqlite3'; 3 | 4 | import { CreatedContract } from './types'; 5 | 6 | type QueuedContract = CreatedContract & { 7 | priority: number; 8 | }; 9 | 10 | export interface IDatabase { 11 | initialize: () => Promise; 12 | addContract: (contract: CreatedContract, priority: number) => Promise; 13 | deleteContract: (address: string) => Promise; 14 | updatePriority: (address: string, priority: number) => Promise; 15 | getContracts: () => Promise; 16 | clear: () => Promise; 17 | close: (cb: ((err: Error | null) => void) | undefined) => void; 18 | } 19 | 20 | export class SqlDatabase implements IDatabase { 21 | private db: sqlite3.Database; 22 | 23 | private addStatement!: sqlite3.Statement; 24 | private updateStatement!: sqlite3.Statement; 25 | private deleteStatement!: sqlite3.Statement; 26 | 27 | constructor(filename = ':memory:') { 28 | this.db = new sqlite3.Database(filename, (err) => { 29 | if (err) return console.error(err.message); 30 | console.info('Connected to the SQlite database.'); 31 | }); 32 | 33 | this.db.on('error', (err) => console.error(err)); 34 | 35 | // force execution to be serialized 36 | this.db.serialize(); 37 | } 38 | 39 | async initialize(): Promise { 40 | this.db.run(`CREATE TABLE IF NOT EXISTS contracts ( 41 | contract_id INTEGER PRIMARY KEY AUTOINCREMENT, 42 | address CHARACTER(42) NOT NULL, 43 | deployer CHARACTER(42) NOT NULL, 44 | blockNumber INTEGER, 45 | timestamp INTEGER, 46 | txHash CHARACTER(66), 47 | priority INTEGER 48 | )`); 49 | 50 | this.db.run(`CREATE INDEX IF NOT EXISTS address_idx ON contracts(address)`); 51 | 52 | this.addStatement = this.db.prepare( 53 | 'INSERT INTO contracts (address, deployer, blockNumber, timestamp, txHash, priority) VALUES (?, ?, ?, ?, ?, ?)', 54 | ); 55 | 56 | this.updateStatement = this.db.prepare( 57 | 'UPDATE contracts SET priority = ? WHERE address = ?', 58 | ); 59 | 60 | this.deleteStatement = this.db.prepare(`DELETE FROM contracts WHERE contracts.address = ?`); 61 | } 62 | 63 | async addContract(contract: CreatedContract, priority: number) { 64 | this.addStatement.run( 65 | contract.address, 66 | contract.deployer, 67 | contract.blockNumber, 68 | contract.timestamp, 69 | contract.txHash, 70 | priority, 71 | ); 72 | } 73 | 74 | async deleteContract(address: string) { 75 | this.deleteStatement.run(address); 76 | } 77 | 78 | async updatePriority(address: string, priority: number) { 79 | this.updateStatement.run(priority, address); 80 | } 81 | 82 | async getContracts() { 83 | return (await this.all(`SELECT * FROM contracts`)) || []; 84 | } 85 | 86 | async clear() { 87 | this.db.run(`DELETE FROM contracts`); 88 | } 89 | 90 | close(cb: ((err: Error | null) => void) | undefined) { 91 | this.db.close(cb); 92 | } 93 | 94 | private promisify

( 95 | fn: (handler: (result: P, err: any) => void) => void, 96 | ): Promise

{ 97 | return new Promise((res, rej) => { 98 | fn((result: P, err: any) => { 99 | if (err) return rej(err); 100 | return res(result); 101 | }); 102 | }); 103 | } 104 | 105 | private async run(query: string, ...params: any[]): Promise { 106 | return new Promise((res, rej) => { 107 | this.db.run(query, ...params, (err: Error) => { 108 | if (err) return rej(err); 109 | return res(); 110 | }); 111 | }); 112 | } 113 | 114 | private async get

(query: string, params: object = {}): Promise

{ 115 | return new Promise((res, rej) => { 116 | this.db.get(query, params, (err: Error, result: P) => { 117 | if (err) return rej(err); 118 | return res(result); 119 | }); 120 | }); 121 | } 122 | 123 | private async all

(query: string, params: object = {}): Promise

{ 124 | return new Promise((res, rej) => { 125 | this.db.all(query, params, (err: Error, result: P) => { 126 | if (err) return rej(err); 127 | return res(result); 128 | }); 129 | }); 130 | } 131 | } 132 | 133 | export class InMemoryDatabase implements IDatabase { 134 | private contractSet = new Set(); 135 | 136 | async initialize() { 137 | // do nothing 138 | } 139 | 140 | close() { 141 | // do nothing 142 | } 143 | 144 | async addContract(contract: CreatedContract, priority: number) { 145 | this.contractSet.add({ ...contract, priority }); 146 | } 147 | 148 | async updatePriority(address: string, priority: number) { 149 | for (const contract of this.contractSet) { 150 | if (contract.address === address) { 151 | contract.priority = priority; 152 | break; 153 | } 154 | } 155 | } 156 | 157 | async deleteContract(address: string) { 158 | for (const contract of this.contractSet) { 159 | if (contract.address === address) { 160 | this.contractSet.delete(contract); 161 | break; 162 | } 163 | } 164 | } 165 | 166 | async getContracts() { 167 | return [...this.contractSet]; 168 | } 169 | 170 | async clear() { 171 | this.contractSet.clear(); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/findings.ts: -------------------------------------------------------------------------------- 1 | import { EntityType, Finding, FindingSeverity, FindingType, Label } from 'forta-agent'; 2 | import BigNumber from 'bignumber.js'; 3 | 4 | import { TokenInfo } from './types'; 5 | 6 | // funding deployer 7 | // funding the exploit contract 8 | // funding some EOA 9 | export const createExploitFunctionFinding = ( 10 | sighash: string, 11 | calldata: string, 12 | contractAddress: string, 13 | deployerAddress: string, 14 | fundedAddress: string, 15 | balanceChanges: { [account: string]: TokenInfo[] }, 16 | addresses: string[], 17 | anomalyScore: number, 18 | txHash: string, 19 | developerAbbreviation: string, 20 | ) => { 21 | // normalize addresses 22 | contractAddress = contractAddress.toLowerCase(); 23 | deployerAddress = deployerAddress.toLowerCase(); 24 | fundedAddress = fundedAddress.toLowerCase(); 25 | addresses = addresses.map((a) => a.toLowerCase()); 26 | 27 | const formatTokens = (tokens: TokenInfo[]) => { 28 | return tokens 29 | .map((token) => { 30 | const denominator = new BigNumber(10).pow(token.decimals || 0); 31 | return `${token.value.div(denominator).toFormat()} ${token.name}`; 32 | }) 33 | .join(', '); 34 | }; 35 | 36 | // generate description 37 | let description = `Invocation of the function ${sighash} of the created contract ${contractAddress} `; 38 | if (deployerAddress === fundedAddress) { 39 | description += `leads to large balance increase in the contract deployer or function invoker account. `; 40 | } else if (contractAddress === fundedAddress) { 41 | description += `leads to large balance increase in the deployed contract. `; 42 | } else { 43 | description += `leads to large balance increase in account ${fundedAddress}. `; 44 | } 45 | description += `Tokens transferred: ${formatTokens(balanceChanges[fundedAddress])}`; 46 | 47 | const labels: Label[] = [ 48 | Label.fromObject({ 49 | entityType: EntityType.Address, 50 | entity: deployerAddress, 51 | label: 'Attacker', 52 | confidence: 0.5, 53 | remove: false, 54 | }), 55 | Label.fromObject({ 56 | entityType: EntityType.Address, 57 | entity: contractAddress, 58 | label: 'Exploit', 59 | confidence: 0.5, 60 | remove: false, 61 | }), 62 | Label.fromObject({ 63 | entityType: EntityType.Transaction, 64 | entity: txHash, 65 | label: 'Exploit', 66 | confidence: 0.5, 67 | remove: false, 68 | }), 69 | ]; 70 | 71 | if (fundedAddress !== deployerAddress && fundedAddress !== contractAddress) { 72 | labels.push( 73 | Label.fromObject({ 74 | entityType: EntityType.Address, 75 | entity: fundedAddress, 76 | label: 'Attacker', 77 | confidence: 0.5, 78 | remove: false, 79 | }), 80 | ); 81 | } 82 | 83 | return Finding.from({ 84 | alertId: `${developerAbbreviation}-ATTACK-SIMULATION-0`, 85 | name: 'Potential Exploit Function', 86 | description: description, 87 | type: FindingType.Exploit, 88 | severity: FindingSeverity.Critical, 89 | addresses: addresses, 90 | labels: labels, 91 | metadata: { 92 | sighash, 93 | calldata, 94 | contractAddress, 95 | fundedAddress, 96 | deployerAddress, 97 | txHash: txHash, 98 | anomaly_score: anomalyScore.toString(), 99 | balanceChanges: JSON.stringify(balanceChanges), 100 | }, 101 | }); 102 | }; 103 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | export enum LoggerLevel { 4 | DEBUG, 5 | INFO, 6 | WARN, 7 | ERROR, 8 | } 9 | 10 | export class Logger { 11 | private scope: any[] = []; 12 | private level: LoggerLevel; 13 | 14 | public constructor(level = LoggerLevel.INFO, args?: any[]) { 15 | this.level = level; 16 | if (args) { 17 | this.scope.push(...args); 18 | } 19 | } 20 | 21 | private _log = (args: any[], level: LoggerLevel) => { 22 | if (level < this.level) return; 23 | 24 | console.log(...this.scope, ...args); 25 | }; 26 | 27 | public debug = (...args: any[]) => { 28 | this._log(args, LoggerLevel.DEBUG); 29 | }; 30 | 31 | public info = (...args: any[]) => { 32 | this._log(args, LoggerLevel.INFO); 33 | }; 34 | 35 | public warn = (...args: any[]) => { 36 | this._log(args, LoggerLevel.WARN); 37 | }; 38 | 39 | public error = (...args: any[]) => { 40 | this._log(args, LoggerLevel.ERROR); 41 | }; 42 | 43 | public clone(...args: any[]) { 44 | return new Logger(this.level, [...this.scope, ...args]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/tests/agent.real.spec.ts: -------------------------------------------------------------------------------- 1 | import { Initialize } from 'forta-agent'; 2 | 3 | const config = require('../../bot-config.json'); 4 | 5 | import { DataContainer, HandleContract } from '../types'; 6 | import { InMemoryDatabase } from '../database'; 7 | import * as botUtils from '../utils'; 8 | import agent from '../agent'; 9 | 10 | const { provideInitialize, provideHandleContract } = agent; 11 | 12 | // timestamp is used for statistics, so it can be specified arbitrarily in these tests 13 | 14 | describe('real-world tests', () => { 15 | jest.setTimeout(15 * 60 * 1000); 16 | 17 | const data: DataContainer = {} as DataContainer; 18 | let handleContract: HandleContract; 19 | let initialize: Initialize; 20 | 21 | beforeAll(() => { 22 | handleContract = provideHandleContract(data, botUtils); 23 | initialize = provideInitialize(data, config, {}, new InMemoryDatabase(), handleContract); 24 | }); 25 | 26 | beforeEach(async () => { 27 | await initialize(); 28 | }); 29 | 30 | it('contract with a withdraw function #1', async () => { 31 | await handleContract({ 32 | address: '0x1678454cf3d4a1a5b1064fe9307e909c9f0510d8', 33 | deployer: '0xe7ea0762ba7990759e75adc2ec64c70dbb1bd92e', 34 | blockNumber: 15570000, 35 | timestamp: 15570000, 36 | txHash: '0x0', 37 | }); 38 | expect(data.findings).toHaveLength(0); 39 | }); 40 | 41 | it('contract with a withdraw function #2', async () => { 42 | await handleContract({ 43 | address: '0x5c111745e05bc630ced89a63aa74254c3dcde12a', 44 | deployer: '0xc69b9b52e1e4cf384894199290cda5f9e94ae1b6', 45 | blockNumber: 15569936, 46 | timestamp: 15569936, 47 | txHash: '0x0', 48 | }); 49 | expect(data.findings).toHaveLength(0); 50 | }); 51 | 52 | it('contract with a withdraw function #3', async () => { 53 | await handleContract({ 54 | address: '0x8592cae3420d89765dcd77152a2077909e029267', 55 | deployer: '0x4ecbac578acd7f9928a3d0f7e0b7ec0d54a7e3b5', 56 | blockNumber: 15570386, 57 | timestamp: 15570386, 58 | txHash: '0x0', 59 | }); 60 | expect(data.findings).toHaveLength(0); 61 | }); 62 | 63 | it('Saddle Finance attack', async () => { 64 | await handleContract({ 65 | address: '0x7336f819775b1d31ea472681d70ce7a903482191', 66 | deployer: '0x63341ba917de90498f3903b199df5699b4a55ac0', 67 | blockNumber: 14684300, 68 | timestamp: 14684300, 69 | txHash: '0x0', 70 | }); 71 | expect(data.findings).toHaveLength(1); 72 | }); 73 | 74 | it('FEGToken attack', async () => { 75 | await handleContract({ 76 | address: '0xf02b075f514c34df0c3d5cb7ebadf50d74a6fb17', 77 | deployer: '0xf99e5f80486426e7d3e3921269ffee9c2da258e2', 78 | blockNumber: 14789154, 79 | timestamp: 14789154, 80 | txHash: '0x0', 81 | }); 82 | expect(data.findings).toHaveLength(1); 83 | }); 84 | 85 | it('Devour attack (payable function)', async () => { 86 | await handleContract({ 87 | address: '0x9e7f9123ce12060ec844ac56de047cc50a827201', 88 | deployer: '0x9448368ff76b6698c59ca940b1ee2bf7fba0bc21', 89 | blockNumber: 15503993, 90 | timestamp: 15503993, 91 | txHash: '0x0', 92 | }); 93 | expect(data.findings).toHaveLength(1); 94 | }); 95 | 96 | it('Olympus (OHM)', async () => { 97 | await handleContract({ 98 | address: '0xa29e4fe451ccfa5e7def35188919ad7077a4de8f', 99 | deployer: '0x443cf223e209e5a2c08114a2501d8f0f9ec7d9be', 100 | blockNumber: 15794354, 101 | timestamp: 15794354, 102 | txHash: '0x0', 103 | }); 104 | expect(data.findings).toHaveLength(1); 105 | }); 106 | 107 | it('Flashloan attack (DAI)', async () => { 108 | await handleContract({ 109 | address: '0x3b841fa4046d2a17d9e40609ed80a76830ec0362', 110 | deployer: '0xd4e10ce89c944771fb1ff173bf3b6557c692ef0d', 111 | blockNumber: 16062787, 112 | timestamp: 16062787, 113 | txHash: '0x0', 114 | }); 115 | expect(data.findings).toHaveLength(1); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/tests/agent.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryDatabase } from '../database'; 2 | 3 | const mockEthersProvider = jest.fn(); 4 | 5 | jest.mock('forta-agent', () => ({ 6 | ...jest.requireActual('forta-agent'), 7 | getEthersProvider: mockEthersProvider, 8 | })); 9 | 10 | import { BotAnalytics, InMemoryBotStorage } from 'forta-bot-analytics'; 11 | import { 12 | Alert, 13 | AlertEvent, 14 | Finding, 15 | FindingSeverity, 16 | FindingType, 17 | HandleAlert, 18 | HandleTransaction, 19 | Network, 20 | } from 'forta-agent'; 21 | import { TestTransactionEvent } from 'forta-agent-tools/lib/tests'; 22 | import BigNumber from 'bignumber.js'; 23 | import Ganache, { EthereumProvider } from 'ganache'; 24 | import { ethers } from 'ethers'; 25 | 26 | import { compile, CompilerArtifact } from './utils/compiler'; 27 | import { 28 | BotConfig, 29 | BotEnv, 30 | CreatedContract, 31 | DataContainer, 32 | HandleContract, 33 | TokenInfo, 34 | TokenInterface, 35 | } from '../types'; 36 | import * as botUtils from '../utils'; 37 | import { Logger, LoggerLevel } from '../logger'; 38 | import agent from '../agent'; 39 | 40 | const { provideInitialize, provideHandleContract, provideHandleTransaction, provideHandleAlert } = 41 | agent; 42 | 43 | const nominator = (decimals: number) => new BigNumber(10).pow(decimals); 44 | 45 | describe('attack simulation', () => { 46 | describe('handleContract', () => { 47 | jest.setTimeout(5 * 60 * 1000); 48 | 49 | type WithAddress = { 50 | address: string; 51 | }; 52 | 53 | type MockNativeToken = WithAddress & { 54 | name: string; 55 | decimals: number; 56 | price: number; 57 | }; 58 | 59 | type MockErc20Token = WithAddress & { 60 | symbol?: string; 61 | name?: string; 62 | decimals: number; 63 | price: number; 64 | }; 65 | 66 | type MockErc721Token = WithAddress & { 67 | name: string; 68 | }; 69 | 70 | type MockErc1155Token = WithAddress & { 71 | name: string; 72 | }; 73 | 74 | enum ExploitVaraint { 75 | ExploitNoParams = 'ExploitNoParams.sol', 76 | ExploitMultipleParams = 'ExploitMultipleParams.sol', 77 | ExploitPayable = 'ExploitPayable.sol', 78 | ExploitSelfFunded = 'ExploitSelfFunded.sol', 79 | } 80 | 81 | const testNetwork: Network = Network.POLYGON; 82 | 83 | const customERC20Artifact = compile('CustomERC20.sol'); 84 | const customERC721Artifact = compile('CustomERC721.sol'); 85 | const customERC1155Artifact = compile('CustomERC1155.sol'); 86 | const exploitedProtocolArtifact = compile('ExploitedProtocol.sol'); 87 | 88 | let handleContract: HandleContract; 89 | let ganacheProvider: EthereumProvider; 90 | let web3Provider: ethers.providers.Web3Provider; 91 | let attackerSigner: ethers.providers.JsonRpcSigner; 92 | let protocolOwnerSigner: ethers.providers.JsonRpcSigner; 93 | 94 | const mockGetNativeTokenPrice = jest.fn(); 95 | const mockGetErc20TokenPrice = jest.fn(); 96 | 97 | let mockData: DataContainer; 98 | 99 | // tokens are deployed before each test 100 | let mockNativeToken: MockNativeToken; 101 | let mockErc20Token1: MockErc20Token; 102 | let mockErc20Token2: MockErc20Token; 103 | let mockErc721Token1: MockErc721Token; 104 | let mockErc721Token2: MockErc721Token; 105 | let mockErc1155Token1: MockErc1155Token; 106 | let mockErc1155Token2: MockErc1155Token; 107 | 108 | beforeAll(async () => { 109 | // set up blockchain environment 110 | ganacheProvider = Ganache.provider({ 111 | logging: { quiet: true }, 112 | chainId: testNetwork, 113 | wallet: { 114 | defaultBalance: 100_000_000_000, 115 | }, 116 | }); 117 | web3Provider = new ethers.providers.Web3Provider(ganacheProvider as any); 118 | const accounts = await web3Provider.listAccounts(); 119 | protocolOwnerSigner = web3Provider.getSigner(accounts[0]); 120 | attackerSigner = web3Provider.getSigner(accounts[1]); 121 | 122 | // mock data container 123 | mockData = {} as any; 124 | mockData.totalUsdTransferThreshold = new BigNumber(4000); 125 | mockData.developerAbbreviation = 'AK'; 126 | mockData.payableFunctionEtherValue = 10; 127 | mockData.chainId = testNetwork; 128 | mockData.logger = new Logger(LoggerLevel.DEBUG); 129 | mockData.analytics = new BotAnalytics(new InMemoryBotStorage(), { 130 | defaultAnomalyScore: { [BotAnalytics.GeneralAlertId]: 0.123 }, 131 | syncTimeout: 60 * 60, 132 | maxSyncDelay: 31 * 24 * 60 * 60, 133 | observableInterval: 14 * 24 * 60 * 60, 134 | }); 135 | mockData.provider = web3Provider; 136 | mockData.isDevelopment = true; 137 | mockData.isDebug = false; 138 | mockData.isInitialized = true; 139 | 140 | // mock price providers 141 | mockGetNativeTokenPrice.mockImplementation((network: Network) => { 142 | if (network !== testNetwork) throw new Error('Not testing network ' + network); 143 | return mockNativeToken.price; 144 | }); 145 | mockGetErc20TokenPrice.mockImplementation((network: Network, addr: string) => { 146 | if (network !== testNetwork) throw new Error('Not testing network ' + network); 147 | if (mockErc20Token1.address === addr) return mockErc20Token1.price; 148 | if (mockErc20Token2.address === addr) return mockErc20Token2.price; 149 | return null; 150 | }); 151 | 152 | // inject mocks to functions that use third-party services 153 | handleContract = provideHandleContract(mockData, { 154 | ...botUtils, 155 | getEthersForkProvider: () => web3Provider, 156 | getNativeTokenPrice: mockGetNativeTokenPrice, 157 | getErc20TokenPrice: mockGetErc20TokenPrice, 158 | }); 159 | }); 160 | 161 | beforeEach(async () => { 162 | // since the the agent is async, it pushes findings to the data container 163 | mockData.findings = []; 164 | // re-deploy tokens to local blockchain 165 | await deployTokens(); 166 | }); 167 | 168 | afterEach(async () => { 169 | // since we don't reinitialize Ganache before each test, 170 | // we return the ether we sent to the attacker 171 | const balance = await attackerSigner.getBalance(); 172 | if (balance.gte(ethers.utils.parseEther('1'))) { 173 | await attackerSigner.sendTransaction({ 174 | to: protocolOwnerSigner._address, 175 | value: balance.sub(ethers.utils.parseEther('0.001')), 176 | }); 177 | } 178 | }); 179 | 180 | afterAll(() => { 181 | ganacheProvider.disconnect(); 182 | }); 183 | 184 | const deployContract = async ( 185 | artifact: CompilerArtifact, 186 | constructorParams: any[], 187 | signer: ethers.providers.JsonRpcSigner, 188 | value: string | number | BigNumber = 0, 189 | ) => { 190 | const factory = new ethers.ContractFactory( 191 | artifact.abi, 192 | artifact.evm.bytecode.object, 193 | signer, 194 | ); 195 | const contract = await factory.deploy(...constructorParams, { value: value.toString() }); 196 | await contract.deployed(); 197 | return contract; 198 | }; 199 | 200 | const deployTokens = async () => { 201 | // initialize and deploy tokens 202 | mockNativeToken = { 203 | name: 'MATIC', 204 | decimals: 18, 205 | price: 2, 206 | address: 'native', 207 | }; 208 | mockErc20Token1 = { 209 | name: 'TKN20-1', 210 | address: ( 211 | await deployContract(customERC20Artifact, ['', 'TKN20-1', 14], protocolOwnerSigner) 212 | ).address.toLowerCase(), 213 | decimals: 14, 214 | price: 2, 215 | }; 216 | mockErc20Token2 = { 217 | symbol: 'TKN20-2', 218 | address: ( 219 | await deployContract(customERC20Artifact, ['TKN20-2', '', 16], protocolOwnerSigner) 220 | ).address.toLowerCase(), 221 | decimals: 16, 222 | price: 10, 223 | }; 224 | mockErc721Token1 = { 225 | name: 'TKN721-1', 226 | address: ( 227 | await deployContract(customERC721Artifact, ['TKN721-1', ''], protocolOwnerSigner) 228 | ).address.toLowerCase(), 229 | }; 230 | mockErc721Token2 = { 231 | name: 'TKN721-2', 232 | address: ( 233 | await deployContract(customERC721Artifact, ['TKN721-2', ''], protocolOwnerSigner) 234 | ).address.toLowerCase(), 235 | }; 236 | mockErc1155Token1 = { 237 | name: 'TKN1155-1', 238 | address: ( 239 | await deployContract(customERC1155Artifact, [], protocolOwnerSigner) 240 | ).address.toLowerCase(), 241 | }; 242 | mockErc1155Token2 = { 243 | name: 'TKN1155-2', 244 | address: ( 245 | await deployContract(customERC1155Artifact, [], protocolOwnerSigner) 246 | ).address.toLowerCase(), 247 | }; 248 | 249 | // mock thresholds for erc721 and erc1155 tokens 250 | mockData.totalTokensThresholdsByAddress = { 251 | [mockErc721Token1.address]: { 252 | name: mockErc721Token1.name, 253 | threshold: new BigNumber(10), 254 | }, 255 | [mockErc721Token2.address]: { 256 | name: mockErc721Token2.name, 257 | threshold: new BigNumber(20), 258 | }, 259 | [mockErc1155Token1.address]: { 260 | name: mockErc1155Token1.name, 261 | threshold: new BigNumber(40), 262 | }, 263 | [mockErc1155Token2.address]: { 264 | name: mockErc1155Token2.name, 265 | threshold: new BigNumber(80), 266 | }, 267 | }; 268 | }; 269 | 270 | const deployExploitedProtocol = async ( 271 | deployerSigner: ethers.providers.JsonRpcSigner, 272 | balance: { 273 | native?: string | number | BigNumber; 274 | erc20?: { 275 | [address: string]: string | number | BigNumber; 276 | }; 277 | erc721?: { 278 | [address: string]: string | number | BigNumber; 279 | }; 280 | erc1155?: { 281 | [address: string]: (string | number | BigNumber)[]; 282 | }; 283 | }, 284 | ) => { 285 | // Since this is a test smart-contracts, all deployed tokens contain mint() function that allows to mint tokens without restriction. 286 | // This contract mints balance of passed tokens in the constructor (see ExploitedProtocol.sol). 287 | const normalize = (v: string | number | BigNumber) => new BigNumber(v).toFixed(); 288 | return await deployContract( 289 | exploitedProtocolArtifact, 290 | [ 291 | Object.keys(balance.erc20 || {}), 292 | Object.values(balance.erc20 || {}).map(normalize), 293 | Object.keys(balance.erc721 || {}), 294 | Object.values(balance.erc721 || {}).map(normalize), 295 | Object.keys(balance.erc1155 || {}), 296 | Object.values(balance.erc1155 || {}).map((arr) => arr.map(normalize)), 297 | ], 298 | deployerSigner, 299 | new BigNumber(balance.native || 0).toFixed(), 300 | ); 301 | }; 302 | 303 | const deployExploit = async ( 304 | attackerSigner: ethers.providers.JsonRpcSigner, 305 | variant: ExploitVaraint, 306 | protocolAddress: string, 307 | fundingAddress?: string, 308 | ) => { 309 | const params = [protocolAddress]; 310 | 311 | if (variant !== ExploitVaraint.ExploitSelfFunded) { 312 | params.push(fundingAddress || (await attackerSigner.getAddress())); 313 | } 314 | 315 | const exploitArtifact = await compile(variant); 316 | return await deployContract(exploitArtifact, params, attackerSigner); 317 | }; 318 | 319 | function testFinding( 320 | finding: Finding, 321 | params: { 322 | attackerAddress: string; 323 | fundedAddress: string; 324 | protocolAddress: string; 325 | exploitContract: ethers.Contract; 326 | exploitFunctionParamsNumber?: number; 327 | transferredTokens: { 328 | native?: { name: string; decimals: number; value: BigNumber }; 329 | erc20?: { name?: string; address: string; decimals: number; value: BigNumber }[]; 330 | erc721?: { name: string; address: string; value: BigNumber }[]; 331 | erc1155?: { name: string; address: string; value: BigNumber }[]; 332 | }; 333 | }, 334 | ) { 335 | const { 336 | attackerAddress, 337 | fundedAddress, 338 | protocolAddress, 339 | exploitContract, 340 | exploitFunctionParamsNumber = 0, 341 | transferredTokens, 342 | } = params; 343 | 344 | expect(finding).toBeDefined(); 345 | expect(finding.metadata.sighash).toStrictEqual( 346 | exploitContract.interface.getSighash('attack'), 347 | ); 348 | if (exploitFunctionParamsNumber > 0) { 349 | expect(finding.metadata.calldata).toHaveLength( 350 | 32 /* bytes */ * 2 /* symbols per byte */ * exploitFunctionParamsNumber /* params */, 351 | ); 352 | } else { 353 | expect(finding.metadata.calldata).toStrictEqual(''); 354 | } 355 | expect(finding.metadata.contractAddress).toStrictEqual(exploitContract.address.toLowerCase()); 356 | expect(finding.metadata.fundedAddress).toStrictEqual(fundedAddress.toLowerCase()); 357 | expect(finding.metadata.deployerAddress).toStrictEqual( 358 | exploitContract.deployTransaction.from.toLowerCase(), 359 | ); 360 | 361 | const addresses = new Set([ 362 | attackerAddress.toLowerCase(), 363 | exploitContract.address.toLowerCase(), 364 | ...(transferredTokens.erc20 || []).map((t) => t.address.toLowerCase()), 365 | ...(transferredTokens.erc721 || []).map((t) => t.address.toLowerCase()), 366 | ...(transferredTokens.erc1155 || []).map((t) => t.address.toLowerCase()), 367 | ]); 368 | 369 | // check whether we can indentify affected protocol by token events 370 | if ( 371 | [transferredTokens.erc20, transferredTokens.erc721, transferredTokens.erc1155].find( 372 | (v) => v && v.length > 0, 373 | ) 374 | ) { 375 | addresses.add(protocolAddress.toLowerCase()); 376 | } 377 | 378 | expect(finding.addresses).toEqual(expect.arrayContaining([...addresses])); 379 | 380 | const balanceChanges: TokenInfo[] = [ 381 | ...(transferredTokens.erc20 || []).map((t) => ({ ...t, type: TokenInterface.ERC20 })), 382 | ...(transferredTokens.erc721 || []).map((t) => ({ ...t, type: TokenInterface.ERC721 })), 383 | ...(transferredTokens.erc1155 || []).map((t) => ({ ...t, type: TokenInterface.ERC1155 })), 384 | ]; 385 | 386 | if (transferredTokens.native) { 387 | balanceChanges.push({ 388 | ...transferredTokens.native, 389 | type: TokenInterface.NATIVE, 390 | address: mockNativeToken.address, 391 | }); 392 | } 393 | 394 | // compare with type normalization (e.g. BigNumber -> string) 395 | expect(JSON.parse(finding.metadata.balanceChanges)[fundedAddress.toLowerCase()]).toEqual( 396 | expect.arrayContaining(JSON.parse(JSON.stringify(balanceChanges))), 397 | ); 398 | } 399 | 400 | it("should push nothing if contract doesn't transfer tokens", async () => { 401 | const regularArtifact = await compile('RegularContract.sol'); 402 | const regularContract = await deployContract(regularArtifact, [], protocolOwnerSigner); 403 | 404 | const createdContract: CreatedContract = { 405 | address: await regularContract.resolvedAddress, 406 | deployer: await protocolOwnerSigner.getAddress(), 407 | blockNumber: regularContract.deployTransaction.blockNumber!, 408 | timestamp: regularContract.deployTransaction.timestamp!, 409 | txHash: regularContract.deployTransaction.hash, 410 | }; 411 | 412 | await handleContract(createdContract); 413 | 414 | expect(mockData.findings).toStrictEqual([]); 415 | }); 416 | 417 | it('should push nothing if attack function transfers tokens not exceeding threshold', async () => { 418 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 419 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 420 | native: new BigNumber(mockData.totalUsdTransferThreshold) 421 | .div(2) 422 | .decimalPlaces(0) 423 | .div(mockNativeToken.price) 424 | .multipliedBy(nominator(mockNativeToken.decimals)), 425 | erc20: { 426 | [mockErc20Token1.address]: new BigNumber(mockData.totalUsdTransferThreshold) 427 | .div(2) 428 | .decimalPlaces(0) 429 | .div(mockErc20Token1.price) 430 | .multipliedBy(nominator(mockErc20Token1.decimals)), 431 | }, 432 | erc721: { 433 | [mockErc721Token1.address]: 434 | mockData.totalTokensThresholdsByAddress[mockErc721Token1.address].threshold, 435 | }, 436 | erc1155: { 437 | [mockErc1155Token1.address]: [ 438 | mockData.totalTokensThresholdsByAddress[mockErc1155Token1.address].threshold, 439 | ], 440 | }, 441 | }); 442 | 443 | // deploy exploit contract 444 | const exploitContract = await deployExploit( 445 | attackerSigner, 446 | ExploitVaraint.ExploitNoParams, 447 | protocolContract.address, 448 | ); 449 | 450 | await handleContract({ 451 | address: exploitContract.address, 452 | deployer: exploitContract.deployTransaction.from, 453 | blockNumber: exploitContract.deployTransaction.blockNumber!, 454 | timestamp: exploitContract.deployTransaction.timestamp!, 455 | txHash: exploitContract.deployTransaction.hash, 456 | }); 457 | 458 | // should not fire alert because we haven't exceeded the threshold 459 | expect(mockData.findings).toStrictEqual([]); 460 | }); 461 | 462 | it('should push nothing if erc20 token price is unknown', async () => { 463 | const newTokenContract = await deployContract( 464 | customERC20Artifact, 465 | ['', '', 18], 466 | protocolOwnerSigner, 467 | ); 468 | 469 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 470 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 471 | erc20: { 472 | [newTokenContract.address]: new BigNumber(1e10), 473 | }, 474 | }); 475 | 476 | // deploy exploit contract 477 | const exploitContract = await deployExploit( 478 | attackerSigner, 479 | ExploitVaraint.ExploitNoParams, 480 | protocolContract.address, 481 | ); 482 | 483 | await handleContract({ 484 | address: exploitContract.address, 485 | deployer: exploitContract.deployTransaction.from, 486 | blockNumber: exploitContract.deployTransaction.blockNumber!, 487 | timestamp: exploitContract.deployTransaction.timestamp!, 488 | txHash: exploitContract.deployTransaction.hash, 489 | }); 490 | 491 | // should not fire alert because the price of the transferred token is unknown 492 | expect(mockData.findings).toStrictEqual([]); 493 | }); 494 | 495 | it('should push nothing if function transfers unknown erc721 and erc1155 tokens', async () => { 496 | const newErc721TokenContract = await deployContract( 497 | customERC721Artifact, 498 | ['', ''], 499 | protocolOwnerSigner, 500 | ); 501 | const newErc1155TokenContract = await deployContract( 502 | customERC1155Artifact, 503 | [], 504 | protocolOwnerSigner, 505 | ); 506 | 507 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 508 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 509 | erc721: { 510 | [newErc721TokenContract.address]: new BigNumber(10), 511 | }, 512 | erc1155: { 513 | [newErc1155TokenContract.address]: [new BigNumber(20)], 514 | }, 515 | }); 516 | 517 | // deploy exploit contract 518 | const exploitContract = await deployExploit( 519 | attackerSigner, 520 | ExploitVaraint.ExploitNoParams, 521 | protocolContract.address, 522 | ); 523 | 524 | await handleContract({ 525 | address: exploitContract.address, 526 | deployer: exploitContract.deployTransaction.from, 527 | blockNumber: exploitContract.deployTransaction.blockNumber!, 528 | timestamp: exploitContract.deployTransaction.timestamp!, 529 | txHash: exploitContract.deployTransaction.hash, 530 | }); 531 | 532 | // should not fire alert because the price of the transferred tokens is unknown 533 | expect(mockData.findings).toStrictEqual([]); 534 | }); 535 | 536 | it('should push a finding if value of transferred native token exceeds threshold value', async () => { 537 | const nativeTokenTransferValue = new BigNumber(mockData.totalUsdTransferThreshold) 538 | .div(mockNativeToken.price) 539 | .multipliedBy(nominator(mockNativeToken.decimals)) 540 | .plus(1) 541 | .decimalPlaces(0); 542 | 543 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 544 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 545 | native: nativeTokenTransferValue, 546 | }); 547 | 548 | // deploy exploit contract 549 | const exploitContract = await deployExploit( 550 | attackerSigner, 551 | ExploitVaraint.ExploitNoParams, 552 | protocolContract.address, 553 | ); 554 | 555 | // handle transaction 556 | await handleContract({ 557 | address: exploitContract.address.toLowerCase(), 558 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 559 | blockNumber: exploitContract.deployTransaction.blockNumber!, 560 | timestamp: exploitContract.deployTransaction.timestamp!, 561 | txHash: exploitContract.deployTransaction.hash, 562 | }); 563 | 564 | // test finding 565 | testFinding(mockData.findings[0], { 566 | attackerAddress: await attackerSigner.getAddress(), 567 | fundedAddress: await attackerSigner.getAddress(), 568 | protocolAddress: protocolContract.address, 569 | exploitContract: exploitContract, 570 | transferredTokens: { 571 | native: { 572 | name: mockNativeToken.name, 573 | decimals: mockNativeToken.decimals, 574 | value: nativeTokenTransferValue, 575 | }, 576 | }, 577 | }); 578 | }); 579 | 580 | it('should push a finding if total value of transferred erc20 tokens exceed threshold value', async () => { 581 | const token1Value = new BigNumber(mockData.totalUsdTransferThreshold) 582 | .div(2) 583 | .decimalPlaces(0) 584 | .div(mockErc20Token1.price) 585 | .multipliedBy(nominator(mockErc20Token1.decimals)); 586 | const token2Value = new BigNumber(mockData.totalUsdTransferThreshold) 587 | .div(2) 588 | .decimalPlaces(0) 589 | .div(mockErc20Token2.price) 590 | .multipliedBy(nominator(mockErc20Token2.decimals)) 591 | .plus(1); 592 | 593 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 594 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 595 | erc20: { 596 | [mockErc20Token1.address]: token1Value, 597 | [mockErc20Token2.address]: token2Value, 598 | }, 599 | }); 600 | 601 | // deploy exploit contract 602 | const exploitContract = await deployExploit( 603 | attackerSigner, 604 | ExploitVaraint.ExploitNoParams, 605 | protocolContract.address, 606 | ); 607 | 608 | // handle transaction 609 | await handleContract({ 610 | address: exploitContract.address.toLowerCase(), 611 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 612 | blockNumber: exploitContract.deployTransaction.blockNumber!, 613 | timestamp: exploitContract.deployTransaction.timestamp!, 614 | txHash: exploitContract.deployTransaction.hash, 615 | }); 616 | 617 | // test finding 618 | testFinding(mockData.findings[0], { 619 | attackerAddress: await attackerSigner.getAddress(), 620 | fundedAddress: await attackerSigner.getAddress(), 621 | protocolAddress: protocolContract.address, 622 | exploitContract: exploitContract, 623 | transferredTokens: { 624 | erc20: [ 625 | { 626 | name: mockErc20Token1.symbol || mockErc20Token1.name, 627 | decimals: mockErc20Token1.decimals, 628 | address: mockErc20Token1.address, 629 | value: token1Value, 630 | }, 631 | { 632 | name: mockErc20Token2.symbol || mockErc20Token2.name, 633 | decimals: mockErc20Token2.decimals, 634 | address: mockErc20Token2.address, 635 | value: token2Value, 636 | }, 637 | ], 638 | }, 639 | }); 640 | }); 641 | 642 | it('should push a finding if transferred erc721 tokens exceeds threshold value', async () => { 643 | const token1Value = 644 | mockData.totalTokensThresholdsByAddress[mockErc721Token1.address].threshold.plus(1); 645 | 646 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 647 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 648 | erc721: { 649 | [mockErc721Token1.address]: token1Value, 650 | }, 651 | }); 652 | 653 | // deploy exploit contract 654 | const exploitContract = await deployExploit( 655 | attackerSigner, 656 | ExploitVaraint.ExploitNoParams, 657 | protocolContract.address, 658 | ); 659 | 660 | // handle transaction 661 | await handleContract({ 662 | address: exploitContract.address.toLowerCase(), 663 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 664 | blockNumber: exploitContract.deployTransaction.blockNumber!, 665 | timestamp: exploitContract.deployTransaction.timestamp!, 666 | txHash: exploitContract.deployTransaction.hash, 667 | }); 668 | 669 | // test finding 670 | testFinding(mockData.findings[0], { 671 | attackerAddress: await attackerSigner.getAddress(), 672 | fundedAddress: await attackerSigner.getAddress(), 673 | protocolAddress: protocolContract.address, 674 | exploitContract: exploitContract, 675 | transferredTokens: { 676 | erc721: [ 677 | { 678 | name: mockErc721Token1.name, 679 | address: mockErc721Token1.address, 680 | value: token1Value, 681 | }, 682 | ], 683 | }, 684 | }); 685 | }); 686 | 687 | it('should push a finding if sum of transferred erc1155 tokens exceeds threshold value', async () => { 688 | const tokenValue = 689 | mockData.totalTokensThresholdsByAddress[mockErc1155Token1.address].threshold.plus(2); 690 | const subToken1Value = tokenValue.div(2).decimalPlaces(0); 691 | const subToken2Value = tokenValue.minus(subToken1Value); 692 | 693 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 694 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 695 | erc1155: { 696 | [mockErc1155Token1.address]: [subToken1Value, subToken2Value], 697 | }, 698 | }); 699 | 700 | // deploy exploit contract 701 | const exploitContract = await deployExploit( 702 | attackerSigner, 703 | ExploitVaraint.ExploitNoParams, 704 | protocolContract.address, 705 | ); 706 | 707 | // handle transaction 708 | await handleContract({ 709 | address: exploitContract.address.toLowerCase(), 710 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 711 | blockNumber: exploitContract.deployTransaction.blockNumber!, 712 | timestamp: exploitContract.deployTransaction.timestamp!, 713 | txHash: exploitContract.deployTransaction.hash, 714 | }); 715 | 716 | // test finding 717 | testFinding(mockData.findings[0], { 718 | attackerAddress: await attackerSigner.getAddress(), 719 | fundedAddress: await attackerSigner.getAddress(), 720 | protocolAddress: protocolContract.address, 721 | exploitContract: exploitContract, 722 | transferredTokens: { 723 | erc1155: [ 724 | { 725 | name: mockErc1155Token1.name, 726 | address: mockErc1155Token1.address, 727 | value: tokenValue, 728 | }, 729 | ], 730 | }, 731 | }); 732 | }); 733 | 734 | it('should push a finding if attack function has multiple parameters', async () => { 735 | const nativeTokenTransferValue = new BigNumber(mockData.totalUsdTransferThreshold) 736 | .div(mockNativeToken.price) 737 | .multipliedBy(nominator(mockNativeToken.decimals)) 738 | .plus(1) 739 | .decimalPlaces(0); 740 | 741 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 742 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 743 | native: nativeTokenTransferValue, 744 | }); 745 | 746 | // deploy exploit contract 747 | const exploitContract = await deployExploit( 748 | attackerSigner, 749 | ExploitVaraint.ExploitMultipleParams, 750 | protocolContract.address, 751 | ); 752 | 753 | // handle transaction 754 | await handleContract({ 755 | address: exploitContract.address.toLowerCase(), 756 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 757 | blockNumber: exploitContract.deployTransaction.blockNumber!, 758 | timestamp: exploitContract.deployTransaction.timestamp!, 759 | txHash: exploitContract.deployTransaction.hash, 760 | }); 761 | 762 | // test finding 763 | testFinding(mockData.findings[0], { 764 | attackerAddress: await attackerSigner.getAddress(), 765 | fundedAddress: await attackerSigner.getAddress(), 766 | protocolAddress: protocolContract.address, 767 | exploitContract: exploitContract, 768 | exploitFunctionParamsNumber: 5, 769 | transferredTokens: { 770 | native: { 771 | name: mockNativeToken.name, 772 | decimals: mockNativeToken.decimals, 773 | value: nativeTokenTransferValue, 774 | }, 775 | }, 776 | }); 777 | }); 778 | 779 | it('should push a finding if attack function is payable', async () => { 780 | const nativeTokenTransferValue = new BigNumber(mockData.totalUsdTransferThreshold) 781 | .div(mockNativeToken.price) 782 | .multipliedBy(nominator(mockNativeToken.decimals)) 783 | .decimalPlaces(0) 784 | // since the attacker sends some ether when he calls the exploit function, 785 | // we should add this value to make so that the "net" profit exceeds the USD threshold 786 | .plus( 787 | new BigNumber(mockData.payableFunctionEtherValue).multipliedBy( 788 | nominator(mockNativeToken.decimals), 789 | ), 790 | ) 791 | .plus(1); 792 | 793 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 794 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 795 | native: nativeTokenTransferValue, 796 | }); 797 | 798 | // deploy exploit contract 799 | const exploitContract = await deployExploit( 800 | attackerSigner, 801 | ExploitVaraint.ExploitPayable, 802 | protocolContract.address, 803 | ); 804 | 805 | // handle transaction 806 | await handleContract({ 807 | address: exploitContract.address.toLowerCase(), 808 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 809 | blockNumber: exploitContract.deployTransaction.blockNumber!, 810 | timestamp: exploitContract.deployTransaction.timestamp!, 811 | txHash: exploitContract.deployTransaction.hash, 812 | }); 813 | 814 | // test finding 815 | testFinding(mockData.findings[0], { 816 | attackerAddress: await attackerSigner.getAddress(), 817 | fundedAddress: await attackerSigner.getAddress(), 818 | protocolAddress: protocolContract.address, 819 | exploitContract: exploitContract, 820 | exploitFunctionParamsNumber: 5, 821 | transferredTokens: { 822 | native: { 823 | name: mockNativeToken.name, 824 | decimals: mockNativeToken.decimals, 825 | // substitute the ether value we send to the payable function 826 | value: nativeTokenTransferValue.minus( 827 | new BigNumber(mockData.payableFunctionEtherValue).multipliedBy( 828 | nominator(mockNativeToken.decimals), 829 | ), 830 | ), 831 | }, 832 | }, 833 | }); 834 | }); 835 | 836 | it('should push a finding if tokens are transferred to exploit contract', async () => { 837 | const nativeTokenTransferValue = new BigNumber(mockData.totalUsdTransferThreshold) 838 | .div(mockNativeToken.price) 839 | .multipliedBy(nominator(mockNativeToken.decimals)) 840 | .decimalPlaces(0) 841 | .plus(1); 842 | 843 | // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 844 | const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 845 | native: nativeTokenTransferValue, 846 | }); 847 | 848 | // deploy exploit contract 849 | const exploitContract = await deployExploit( 850 | attackerSigner, 851 | ExploitVaraint.ExploitSelfFunded, 852 | protocolContract.address, 853 | ); 854 | 855 | // handle transaction 856 | await handleContract({ 857 | address: exploitContract.address.toLowerCase(), 858 | deployer: exploitContract.deployTransaction.from.toLowerCase(), 859 | blockNumber: exploitContract.deployTransaction.blockNumber!, 860 | timestamp: exploitContract.deployTransaction.timestamp!, 861 | txHash: exploitContract.deployTransaction.hash, 862 | }); 863 | 864 | // test finding 865 | testFinding(mockData.findings[0], { 866 | attackerAddress: await attackerSigner.getAddress(), 867 | fundedAddress: exploitContract.address, 868 | protocolAddress: protocolContract.address, 869 | exploitContract: exploitContract, 870 | transferredTokens: { 871 | native: { 872 | name: mockNativeToken.name, 873 | decimals: mockNativeToken.decimals, 874 | value: nativeTokenTransferValue, 875 | }, 876 | }, 877 | }); 878 | }); 879 | 880 | // TODO Unfortunately, I haven't found a way to get transaction trances in the Ganache yet 881 | it.todo('should push a finding if tokens are transferred to unknown EOA'); 882 | 883 | // it('should push a finding if tokens are transferred to unknown EOA', async () => { 884 | // const unknownEOA = createAddress('0x123456789'); 885 | // const nativeTokenTransferValue = new BigNumber(mockData.totalUsdTransferThreshold) 886 | // .div(mockNativeToken.price) 887 | // .multipliedBy(nominator(mockNativeToken.decimals)) 888 | // .decimalPlaces(0) 889 | // .plus(1); 890 | // 891 | // // deploy contract of the victim and mint the balance that will be transferred to the attacker's address 892 | // const protocolContract = await deployExploitedProtocol(protocolOwnerSigner, { 893 | // native: nativeTokenTransferValue, 894 | // }); 895 | // 896 | // // deploy exploit contract 897 | // const exploitContract = await deployExploit( 898 | // attackerSigner, 899 | // ExploitVaraint.ExploitNoParams, 900 | // protocolContract.address, 901 | // unknownEOA, 902 | // ); 903 | // 904 | // // handle transaction 905 | // await handleContract({ 906 | // address: exploitContract.address.toLowerCase(), 907 | // blockNumber: exploitContract.deployTransaction.blockNumber!, 908 | // deployer: exploitContract.deployTransaction.from.toLowerCase(), 909 | // }); 910 | // 911 | // // test finding 912 | // testFinding(mockData.findings[0], { 913 | // attackerAddress: await attackerSigner.getAddress(), 914 | // fundedAddress: unknownEOA, 915 | // protocolAddress: protocolContract.address, 916 | // exploitContract: exploitContract, 917 | // transferredTokens: { 918 | // native: { 919 | // name: mockNativeToken.name, 920 | // decimals: mockNativeToken.decimals, 921 | // value: nativeTokenTransferValue, 922 | // }, 923 | // }, 924 | // }); 925 | // }); 926 | 927 | it.todo( 928 | 'should push nothing if tokens are transferred to the owner because of withdraw() function', 929 | ); 930 | }); 931 | 932 | describe('handleTransaction', () => { 933 | let mockData: DataContainer; 934 | let mockTxEvent: TestTransactionEvent; 935 | const mockQueue = { 936 | push: jest.fn(), 937 | length: jest.fn().mockReturnValue(0), 938 | workersList: jest.fn().mockReturnValue([]), 939 | }; 940 | const mockBotUtils = { 941 | ...botUtils, 942 | getCreatedContracts: jest.fn(), 943 | }; 944 | const mockEnv: BotEnv = {}; 945 | const mockHandleContract = jest.fn(); 946 | const mockChainId = 1; 947 | 948 | let handleTransaction: HandleTransaction; 949 | 950 | beforeAll(() => { 951 | mockEthersProvider.mockReturnValue({ 952 | getNetwork() { 953 | return { chainId: mockChainId }; 954 | }, 955 | }); 956 | }); 957 | 958 | beforeEach(async () => { 959 | mockData = {} as DataContainer; 960 | mockTxEvent = new TestTransactionEvent(); 961 | 962 | const initialize = provideInitialize( 963 | mockData, 964 | { 965 | developerAbbreviation: 'TEST', 966 | payableFunctionEtherValue: 123456, 967 | totalUsdTransferThreshold: 123456789, 968 | maliciousContractMLBotId: 969 | '0x9aaa5cd64000e8ba4fa2718a467b90055b70815d60351914cc1cbe89fe1c404c', 970 | tornadoCashContractBotId: 971 | '0x457aa09ca38d60410c8ffa1761f535f23959195a56c9b82e0207801e86b34d99', 972 | flashloanContractBotId: 973 | '0xda967b32461c6cd3280a49e8b5ff5b7486dbd130f3a603089ed4a6e3b03070e2', 974 | aztecContractBotId: '0x127e62dffbe1a9fa47448c29c3ef4e34f515745cb5df4d9324c2a0adae59eeef', 975 | defaultAnomalyScore: { 976 | [mockChainId]: 0.123, 977 | }, 978 | totalTokensThresholdsByChain: { 979 | '1': { 980 | '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85': { 981 | name: 'ENS', 982 | threshold: 25, 983 | }, 984 | }, 985 | }, 986 | }, 987 | mockEnv, 988 | new InMemoryDatabase(), 989 | mockHandleContract, 990 | ); 991 | 992 | await initialize(); 993 | 994 | handleTransaction = provideHandleTransaction(mockData, mockBotUtils, initialize); 995 | 996 | mockData.queue = mockQueue as any; 997 | mockHandleContract.mockReset(); 998 | mockQueue.push.mockClear(); 999 | mockQueue.length.mockClear(); 1000 | mockQueue.workersList.mockClear(); 1001 | 1002 | mockBotUtils.getCreatedContracts.mockImplementation(() => []); 1003 | }); 1004 | 1005 | afterAll(() => { 1006 | mockEthersProvider.mockReset(); 1007 | }); 1008 | 1009 | it('should re-initialize if it has not been initialized yet', async () => { 1010 | const data = {} as DataContainer; 1011 | const initialize = provideInitialize( 1012 | data, 1013 | { 1014 | developerAbbreviation: 'TEST', 1015 | payableFunctionEtherValue: 123456, 1016 | totalUsdTransferThreshold: 123456789, 1017 | maliciousContractMLBotId: 1018 | '0x9aaa5cd64000e8ba4fa2718a467b90055b70815d60351914cc1cbe89fe1c404c', 1019 | tornadoCashContractBotId: 1020 | '0x457aa09ca38d60410c8ffa1761f535f23959195a56c9b82e0207801e86b34d99', 1021 | flashloanContractBotId: 1022 | '0xda967b32461c6cd3280a49e8b5ff5b7486dbd130f3a603089ed4a6e3b03070e2', 1023 | aztecContractBotId: '0x127e62dffbe1a9fa47448c29c3ef4e34f515745cb5df4d9324c2a0adae59eeef', 1024 | defaultAnomalyScore: { 1025 | [mockChainId]: 0.123, 1026 | }, 1027 | totalTokensThresholdsByChain: { 1028 | '1': { 1029 | '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85': { 1030 | name: 'ENS', 1031 | threshold: 25, 1032 | }, 1033 | }, 1034 | }, 1035 | }, 1036 | mockEnv, 1037 | new InMemoryDatabase(), 1038 | mockHandleContract, 1039 | ); 1040 | handleTransaction = provideHandleTransaction(data, botUtils, initialize); 1041 | 1042 | expect(!!data.isInitialized).toBe(false); 1043 | 1044 | await handleTransaction(mockTxEvent); 1045 | 1046 | expect(data.isInitialized).toBe(true); 1047 | }); 1048 | 1049 | it('should return empty findings if there are no new findings in data container', async () => { 1050 | const findings = await handleTransaction(mockTxEvent); 1051 | expect(findings).toStrictEqual([]); 1052 | }); 1053 | 1054 | it('should return and clear findings when handleContract push them to data container', async () => { 1055 | let findings = await handleTransaction(mockTxEvent); 1056 | expect(findings).toStrictEqual([]); 1057 | const expectedFindings = [ 1058 | Finding.from({ 1059 | alertId: '1', 1060 | name: 'Name 1', 1061 | description: 'Description 1', 1062 | type: FindingType.Unknown, 1063 | severity: FindingSeverity.Unknown, 1064 | }), 1065 | Finding.from({ 1066 | alertId: '2', 1067 | name: 'Name 2', 1068 | description: 'Description 2', 1069 | type: FindingType.Unknown, 1070 | severity: FindingSeverity.Unknown, 1071 | }), 1072 | ]; 1073 | mockData.findings.push(...expectedFindings); 1074 | findings = await handleTransaction(mockTxEvent); 1075 | expect(findings).toStrictEqual(expectedFindings); 1076 | expect(mockData.findings).toStrictEqual([]); 1077 | }); 1078 | 1079 | it('should push all detected contracts to queue if isTargetMode is disabled', async () => { 1080 | const createdContracts: CreatedContract[] = [ 1081 | { 1082 | address: '0x1', 1083 | deployer: '0x2', 1084 | timestamp: 1000, 1085 | blockNumber: 1234, 1086 | txHash: '0xHASH', 1087 | }, 1088 | { 1089 | address: '0x3', 1090 | deployer: '0x4', 1091 | timestamp: 1000, 1092 | blockNumber: 1234, 1093 | txHash: '0xHASH', 1094 | }, 1095 | ]; 1096 | 1097 | mockData.isTargetMode = false; 1098 | mockBotUtils.getCreatedContracts.mockReset().mockReturnValue(createdContracts); 1099 | 1100 | await handleTransaction(mockTxEvent); 1101 | 1102 | expect(mockQueue.push).toBeCalledTimes(createdContracts.length); 1103 | }); 1104 | 1105 | it('should push only suspicious contracts to queue if isTargetMode is enabled', async () => { 1106 | const createdContracts: CreatedContract[] = [ 1107 | { 1108 | address: '0x1', 1109 | deployer: '0x2', 1110 | timestamp: 1000, 1111 | blockNumber: 1234, 1112 | txHash: '0xHASH', 1113 | }, 1114 | { 1115 | address: '0x3', 1116 | deployer: '0x4', 1117 | timestamp: 1000, 1118 | blockNumber: 1234, 1119 | txHash: '0xHASH', 1120 | }, 1121 | ]; 1122 | 1123 | mockData.isTargetMode = true; 1124 | mockBotUtils.getCreatedContracts.mockImplementation(() => createdContracts); 1125 | 1126 | await handleTransaction(mockTxEvent); 1127 | 1128 | // should push to detected contracts 1129 | expect(mockData.detectedContractByAddress.size).toStrictEqual(createdContracts.length); 1130 | expect(mockQueue.push).not.toBeCalled(); 1131 | 1132 | // simulating result of the handleAlert() 1133 | mockData.suspiciousContractByAddress.set(createdContracts[1].address, { 1134 | ...createdContracts[1], 1135 | priority: 3, 1136 | }); 1137 | 1138 | await handleTransaction(mockTxEvent); 1139 | 1140 | expect(mockQueue.push).toBeCalledTimes(1); 1141 | expect(mockQueue.push.mock.calls[0][0]).toBe(createdContracts[1]); 1142 | expect(mockQueue.push.mock.calls[0][1]).toBe(3); 1143 | }); 1144 | 1145 | it('should clear detected and suspicious contracts if they are outdated', async () => { 1146 | const interval = 1000; 1147 | 1148 | const contract1: CreatedContract = { 1149 | address: '0x1', 1150 | deployer: '0x2', 1151 | timestamp: interval, 1152 | blockNumber: 100, 1153 | txHash: '0xHASH1', 1154 | }; 1155 | const contract2: CreatedContract = { 1156 | address: '0x3', 1157 | deployer: '0x4', 1158 | timestamp: interval, 1159 | blockNumber: 1234, 1160 | txHash: '0xHASH2', 1161 | }; 1162 | const contract3: CreatedContract = { 1163 | address: '0x5', 1164 | deployer: '0x6', 1165 | timestamp: interval * 2, 1166 | blockNumber: 200, 1167 | txHash: '0xHASH3', 1168 | }; 1169 | const contract4: CreatedContract = { 1170 | address: '0x7', 1171 | deployer: '0x8', 1172 | timestamp: interval * 3, 1173 | blockNumber: 300, 1174 | txHash: '0xHASH4', 1175 | }; 1176 | 1177 | mockData.contractWaitingTime = interval; 1178 | 1179 | mockData.detectedContractByAddress.set(contract1.address, contract1); 1180 | mockData.detectedContractByAddress.set(contract2.address, contract2); 1181 | mockData.detectedContractByAddress.set(contract3.address, contract3); 1182 | mockData.detectedContractByAddress.set(contract4.address, contract4); 1183 | mockData.suspiciousContractByAddress.set(contract1.address, { ...contract1, priority: 1 }); 1184 | mockData.suspiciousContractByAddress.set(contract2.address, { ...contract2, priority: 1 }); 1185 | mockData.suspiciousContractByAddress.set(contract3.address, { ...contract3, priority: 1 }); 1186 | mockData.suspiciousContractByAddress.set(contract4.address, { ...contract4, priority: 1 }); 1187 | 1188 | mockTxEvent.setTimestamp(interval); 1189 | 1190 | await handleTransaction(mockTxEvent); 1191 | 1192 | expect(mockData.detectedContractByAddress.size).toStrictEqual(4); 1193 | expect(mockData.suspiciousContractByAddress.size).toStrictEqual(4); 1194 | 1195 | // --------- 1196 | 1197 | mockTxEvent.setTimestamp(interval + interval + 1); 1198 | 1199 | await handleTransaction(mockTxEvent); 1200 | 1201 | expect(mockData.detectedContractByAddress.size).toStrictEqual(2); 1202 | expect(mockData.detectedContractByAddress.has(contract3.address)); 1203 | expect(mockData.detectedContractByAddress.has(contract4.address)); 1204 | expect(mockData.suspiciousContractByAddress.size).toStrictEqual(2); 1205 | expect(mockData.suspiciousContractByAddress.has(contract3.address)); 1206 | expect(mockData.suspiciousContractByAddress.has(contract4.address)); 1207 | 1208 | // --------- 1209 | 1210 | mockTxEvent.setTimestamp(interval * 3 + 1); 1211 | 1212 | await handleTransaction(mockTxEvent); 1213 | 1214 | expect(mockData.detectedContractByAddress.size).toStrictEqual(1); 1215 | expect(mockData.detectedContractByAddress.has(contract4.address)); 1216 | expect(mockData.suspiciousContractByAddress.size).toStrictEqual(1); 1217 | expect(mockData.suspiciousContractByAddress.has(contract4.address)); 1218 | 1219 | // --------- 1220 | 1221 | mockTxEvent.setTimestamp(interval * 4 + 1); 1222 | 1223 | await handleTransaction(mockTxEvent); 1224 | 1225 | expect(mockData.detectedContractByAddress.size).toStrictEqual(0); 1226 | expect(mockData.suspiciousContractByAddress.size).toStrictEqual(0); 1227 | }); 1228 | }); 1229 | 1230 | describe('handleAlert', () => { 1231 | let mockData: DataContainer; 1232 | const mockChainId = 56; 1233 | const mockEnv: BotEnv = {}; 1234 | const mockConfig: BotConfig = { 1235 | developerAbbreviation: 'TEST', 1236 | payableFunctionEtherValue: 123456, 1237 | totalUsdTransferThreshold: 123456789, 1238 | maliciousContractMLBotId: 1239 | '0x9aaa5cd64000e8ba4fa2718a467b90055b70815d60351914cc1cbe89fe1c404c', 1240 | tornadoCashContractBotId: 1241 | '0x457aa09ca38d60410c8ffa1761f535f23959195a56c9b82e0207801e86b34d99', 1242 | flashloanContractBotId: '0xda967b32461c6cd3280a49e8b5ff5b7486dbd130f3a603089ed4a6e3b03070e2', 1243 | aztecContractBotId: '0x127e62dffbe1a9fa47448c29c3ef4e34f515745cb5df4d9324c2a0adae59eeef', 1244 | defaultAnomalyScore: { 1245 | ['1']: 0.123, 1246 | }, 1247 | totalTokensThresholdsByChain: {}, 1248 | }; 1249 | const mockHandleContract = jest.fn().mockImplementation(async () => []); 1250 | 1251 | let handleAlert: HandleAlert; 1252 | 1253 | beforeAll(() => { 1254 | mockEthersProvider.mockReturnValue({ 1255 | getNetwork() { 1256 | return { chainId: mockChainId }; 1257 | }, 1258 | }); 1259 | }); 1260 | 1261 | beforeEach(() => { 1262 | mockData = {} as DataContainer; 1263 | handleAlert = provideHandleAlert(mockData, mockConfig); 1264 | provideInitialize( 1265 | mockData, 1266 | mockConfig, 1267 | mockEnv, 1268 | new InMemoryDatabase(), 1269 | mockHandleContract, 1270 | )(); 1271 | mockHandleContract.mockClear(); 1272 | }); 1273 | 1274 | afterAll(() => { 1275 | mockEthersProvider.mockReset(); 1276 | }); 1277 | 1278 | it('should return alertConfig from initialize()', async () => { 1279 | const result = await provideInitialize( 1280 | mockData, 1281 | mockConfig, 1282 | mockEnv, 1283 | new InMemoryDatabase(), 1284 | mockHandleContract, 1285 | )(); 1286 | 1287 | expect(result).toMatchObject({ 1288 | alertConfig: { 1289 | subscriptions: [ 1290 | { 1291 | botId: mockConfig.maliciousContractMLBotId, 1292 | alertIds: ['SUSPICIOUS-CONTRACT-CREATION'], 1293 | chainId: mockChainId, 1294 | }, 1295 | { 1296 | botId: mockConfig.flashloanContractBotId, 1297 | alertIds: ['SUSPICIOUS-FLASHLOAN-CONTRACT-CREATION', 'FLASHLOAN-CONTRACT-CREATION'], 1298 | chainId: mockChainId, 1299 | }, 1300 | { 1301 | botId: mockConfig.tornadoCashContractBotId, 1302 | alertIds: ['SUSPICIOUS-CONTRACT-CREATION-TORNADO-CASH'], 1303 | chainId: mockChainId, 1304 | }, 1305 | { 1306 | botId: mockConfig.aztecContractBotId, 1307 | alertIds: ['AK-AZTEC-PROTOCOL-FUNDED-ACCOUNT-DEPLOYMENT'], 1308 | chainId: mockChainId, 1309 | }, 1310 | ], 1311 | }, 1312 | }); 1313 | }); 1314 | 1315 | it('should add suspicious contract to data provider', async () => { 1316 | const tests = [ 1317 | { 1318 | contract: '0x007d52acD501F6C6242B6430B2326e8210d22ef0', 1319 | botId: mockConfig.maliciousContractMLBotId, 1320 | alertId: 'SUSPICIOUS-CONTRACT-CREATION', 1321 | description: 1322 | '0xd2db126090d3ab52a39b40632059d509aa50aca6 created contract 0x007d52acD501F6C6242B6430B2326e8210d22ef0', 1323 | }, 1324 | { 1325 | contract: '0xBEf8C6e2F8d4fCf721cAC6AdbBFBc9F75a3eF2FC', 1326 | botId: mockConfig.tornadoCashContractBotId, 1327 | alertId: 'SUSPICIOUS-CONTRACT-CREATION-TORNADO-CASH', 1328 | description: 1329 | '0xd2db126090d3ab52a39b40632059d509aa50aca6 created contract 0xBEf8C6e2F8d4fCf721cAC6AdbBFBc9F75a3eF2FC', 1330 | }, 1331 | { 1332 | contract: '0x4db0b278063a458165c0e817b1ca54fee6a82730', 1333 | botId: mockConfig.flashloanContractBotId, 1334 | alertId: 'SUSPICIOUS-FLASHLOAN-CONTRACT-CREATION', 1335 | name: 'Suspicious flashloan contract created 0x4db0b278063a458165c0e817b1ca54fee6a82730', 1336 | }, 1337 | { 1338 | contract: '0x037d52acD501F6C6242B6430B2326e8210d22ef0', 1339 | botId: mockConfig.flashloanContractBotId, 1340 | alertId: 'FLASHLOAN-CONTRACT-CREATION', 1341 | name: 'Suspicious flashloan contract created 0x027d52acD501F6C6242B6430B2326e8210d22ef0', 1342 | }, 1343 | { 1344 | contract: '0xcdb96d3aef363a036c6cf6c9b5736d79f0e404e2', 1345 | botId: mockConfig.aztecContractBotId, 1346 | alertId: 'AK-AZTEC-PROTOCOL-FUNDED-ACCOUNT-DEPLOYMENT', 1347 | name: '0xd2db126090d3ab52a39b40632059d509aa50aca6 created contract 0xcdb96d3aef363a036c6cf6c9b5736d79f0e404e2', 1348 | }, 1349 | ]; 1350 | 1351 | for (const test of tests) { 1352 | const contract = test.contract.toLowerCase(); 1353 | 1354 | await handleAlert( 1355 | new AlertEvent( 1356 | Alert.fromObject({ 1357 | alertId: test.alertId, 1358 | name: test.name, 1359 | description: test.description, 1360 | source: { 1361 | bot: { id: test.botId }, 1362 | }, 1363 | }), 1364 | ), 1365 | ); 1366 | 1367 | mockData.suspiciousContractByAddress.has(contract); 1368 | } 1369 | }); 1370 | 1371 | it('should prioritize suspicious contract in queue', async () => { 1372 | const createdContract: CreatedContract = { 1373 | address: '0x5cbb4eb2cbf21d09afe117cf5dd61b3c1aa87cf9', 1374 | timestamp: 1234, 1375 | deployer: '0x1', 1376 | blockNumber: 1234, 1377 | txHash: '0xHASH', 1378 | }; 1379 | 1380 | mockData.queue.pause(); 1381 | mockData.queue.push(createdContract, 9); 1382 | 1383 | await handleAlert( 1384 | new AlertEvent( 1385 | Alert.fromObject({ 1386 | alertId: 'SUSPICIOUS-CONTRACT-CREATION', 1387 | description: `0xd2db126090d3ab52a39b40632059d509aa50aca6 created contract ${createdContract.address}`, 1388 | source: { 1389 | bot: { id: mockConfig.maliciousContractMLBotId }, 1390 | }, 1391 | }), 1392 | ), 1393 | ); 1394 | 1395 | let node: { data: CreatedContract; priority: number } | undefined; 1396 | mockData.queue.remove((_node) => { 1397 | if (_node.data.address === createdContract.address) node = _node; 1398 | return !!node; 1399 | }); 1400 | 1401 | expect(node?.priority).toStrictEqual(1); 1402 | }); 1403 | }); 1404 | }); 1405 | -------------------------------------------------------------------------------- /src/tests/contracts/CustomERC1155.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import '@openzeppelin/contracts/token/ERC1155/ERC1155.sol'; 6 | 7 | contract CustomERC1155 is ERC1155 { 8 | constructor() public ERC1155('') {} 9 | 10 | function mint(address _account, uint256[] memory _supply) public { 11 | for (uint256 i = 0; i < _supply.length; i++) { 12 | _mint(_account, i, _supply[i], ''); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/tests/contracts/CustomERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; 6 | 7 | contract CustomERC20 is ERC20 { 8 | uint8 _decimals; 9 | 10 | constructor(string memory name, string memory symbol, uint8 decimals) public ERC20(name, symbol) { 11 | _decimals = decimals; 12 | } 13 | 14 | function mint(address account, uint256 supply) public { 15 | _mint(account, supply); 16 | } 17 | 18 | function decimals() public view override returns(uint8) { 19 | return _decimals; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/tests/contracts/CustomERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import '@openzeppelin/contracts/token/ERC721/ERC721.sol'; 6 | 7 | contract CustomERC721 is ERC721 { 8 | constructor(string memory name, string memory symbol) public ERC721(name, symbol) {} 9 | 10 | function mint(address account, uint256 tokens) public { 11 | for (uint256 i = 0; i < tokens; i++) { 12 | _mint(account, i); 13 | } 14 | } 15 | 16 | function injectExploit(address victim, address attacker) public { 17 | _setApprovalForAll(victim, attacker, true); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/tests/contracts/ExploitMultipleParams.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "./ExploitedProtocol.sol"; 6 | 7 | contract ExploitMultipleParams { 8 | address _target; 9 | address _spender; 10 | 11 | constructor(address target, address spender) public { 12 | _target = target; 13 | _spender = spender; 14 | } 15 | 16 | function trap() external { 17 | revert('It is a trap 1'); 18 | } 19 | 20 | function trap2(bytes calldata randomBytes) external { 21 | assert(false); 22 | } 23 | 24 | function trap3( 25 | address address1, 26 | address address2, 27 | uint256 num, 28 | bytes calldata some 29 | ) external { 30 | require(false, 'It is a trap 3'); 31 | } 32 | 33 | function attack(address address1, uint256 num, bytes calldata randomBytes, uint256[] calldata arr, address address2) public { 34 | ExploitedProtocol(_target).useExploit(_spender); 35 | } 36 | } -------------------------------------------------------------------------------- /src/tests/contracts/ExploitNoParams.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import './ExploitedProtocol.sol'; 6 | 7 | contract ExploitNoParams { 8 | address _target; 9 | address _spender; 10 | 11 | constructor(address target, address spender) public { 12 | _target = target; 13 | _spender = spender; 14 | } 15 | 16 | function trap() external { 17 | revert('It is a trap 1'); 18 | } 19 | 20 | function trap2(bytes calldata randomBytes) external { 21 | assert(false); 22 | } 23 | 24 | function trap3( 25 | address address1, 26 | address address2, 27 | uint256 num, 28 | bytes calldata some 29 | ) external { 30 | require(false, 'It is a trap 3'); 31 | } 32 | 33 | function attack() public { 34 | ExploitedProtocol(_target).useExploit(_spender); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/tests/contracts/ExploitPayable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "./ExploitedProtocol.sol"; 6 | 7 | contract ExploitPayable { 8 | address _target; 9 | address _spender; 10 | 11 | constructor(address target, address spender) public { 12 | _target = target; 13 | _spender = spender; 14 | } 15 | 16 | function trap() external { 17 | revert('It is a trap 1'); 18 | } 19 | 20 | function trap2(bytes calldata randomBytes) external { 21 | assert(false); 22 | } 23 | 24 | function trap3( 25 | address address1, 26 | address address2, 27 | uint256 num, 28 | bytes calldata some 29 | ) external { 30 | require(false, 'It is a trap 3'); 31 | } 32 | 33 | function attack(address address1, uint256 num, bytes calldata randomBytes, uint256[] calldata arr, address address2) public payable { 34 | ExploitedProtocol(_target).useExploit(_spender); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/tests/contracts/ExploitSelfFunded.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import './ExploitedProtocol.sol'; 6 | 7 | contract ExploitNoParams { 8 | address _target; 9 | 10 | constructor(address target) public { 11 | _target = target; 12 | } 13 | 14 | function attack() public { 15 | ExploitedProtocol(_target).useExploit(address(this)); 16 | } 17 | 18 | fallback() external payable { } 19 | } 20 | -------------------------------------------------------------------------------- /src/tests/contracts/ExploitedProtocol.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import './CustomERC20.sol'; 6 | import './CustomERC721.sol'; 7 | import './CustomERC1155.sol'; 8 | 9 | contract ExploitedProtocol { 10 | address[] _erc20Contracts; 11 | address[] _erc721Contracts; 12 | address[] _erc1155Contacts; 13 | uint256[] _erc20ContractsSupply; 14 | uint256[] _erc721TokensAmount; 15 | uint256[][] _erc1155TokensSupply; 16 | 17 | constructor( 18 | address[] memory erc20Contracts, 19 | uint256[] memory erc20ContractsSupply, 20 | address[] memory erc721Contracts, 21 | uint256[] memory erc721TokensAmount, 22 | address[] memory erc1155Contracts, 23 | uint256[][] memory erc1155TokensSupply 24 | ) public payable { 25 | _erc20Contracts = erc20Contracts; 26 | _erc721Contracts = erc721Contracts; 27 | _erc1155Contacts = erc1155Contracts; 28 | _erc20ContractsSupply = erc20ContractsSupply; 29 | _erc721TokensAmount = erc721TokensAmount; 30 | _erc1155TokensSupply = erc1155TokensSupply; 31 | 32 | for (uint256 i = 0; i < erc20Contracts.length; i++) { 33 | CustomERC20 token = CustomERC20(erc20Contracts[i]); 34 | token.mint(address(this), erc20ContractsSupply[i]); 35 | } 36 | 37 | for (uint256 i = 0; i < erc721Contracts.length; i++) { 38 | CustomERC721 token = CustomERC721(erc721Contracts[i]); 39 | token.mint(address(this), erc721TokensAmount[i]); 40 | } 41 | 42 | for (uint256 i = 0; i < erc1155Contracts.length; i++) { 43 | CustomERC1155 token = CustomERC1155(erc1155Contracts[i]); 44 | token.mint(address(this), erc1155TokensSupply[i]); 45 | } 46 | } 47 | 48 | function useExploit(address attacker) public { 49 | payable(attacker).send(address(this).balance); 50 | 51 | for (uint256 i = 0; i < _erc20Contracts.length; i++) { 52 | CustomERC20 token = CustomERC20(_erc20Contracts[i]); 53 | uint256 balance = token.balanceOf(address(this)); 54 | token.transfer( 55 | attacker, 56 | balance 57 | ); 58 | } 59 | 60 | for (uint256 i = 0; i < _erc721Contracts.length; i++) { 61 | CustomERC721 token = CustomERC721(_erc721Contracts[i]); 62 | token.setApprovalForAll(attacker, true); 63 | for (uint256 tokenId = 0; tokenId < _erc721TokensAmount[i]; tokenId++) { 64 | token.transferFrom(address(this), attacker, tokenId); 65 | } 66 | } 67 | 68 | for (uint256 i = 0; i < _erc1155Contacts.length; i++) { 69 | CustomERC1155 token = CustomERC1155(_erc1155Contacts[i]); 70 | token.setApprovalForAll(attacker, true); 71 | uint256[] memory tokensSupply = _erc1155TokensSupply[i]; 72 | uint256 singleTransferred = tokensSupply.length / 2; 73 | uint256 batchTransferred = tokensSupply.length - singleTransferred; 74 | for (uint256 tokenId = 0; tokenId < singleTransferred; tokenId++) { 75 | token.safeTransferFrom( 76 | address(this), 77 | attacker, 78 | tokenId, 79 | tokensSupply[tokenId], 80 | '' 81 | ); 82 | } 83 | uint256[] memory tokenIds = new uint256[](batchTransferred); 84 | uint256[] memory tokenValues = new uint256[](batchTransferred); 85 | for (uint256 i = 0; i < batchTransferred; i++) { 86 | uint256 tokenId = i + singleTransferred; 87 | tokenIds[i] = tokenId; 88 | tokenValues[i] = tokensSupply[tokenId]; 89 | } 90 | token.safeBatchTransferFrom(address(this), attacker, tokenIds, tokenValues, ''); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/tests/contracts/RegularContract.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; 6 | 7 | contract MyERC20Token is ERC20 { 8 | constructor() ERC20('Token20', 'T20') { 9 | _mint(msg.sender, 10000); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/database.spec.ts: -------------------------------------------------------------------------------- 1 | import { SqlDatabase } from '../database'; 2 | import { CreatedContract } from '../types'; 3 | 4 | describe('sql database', () => { 5 | let database: SqlDatabase; 6 | 7 | const contract: CreatedContract = { 8 | address: '0x77300C71071eCa35Cb673a0b7571B2907dEB7701', 9 | deployer: '0x77300C71071eCa35Cb673a0b7571B2907dEB7702', 10 | txHash: '0xc9ce00a6c9849da2084cba17f3fbaf49d0462779901fe8ea4c44889f00f4799e', 11 | timestamp: 123, 12 | blockNumber: 1234, 13 | }; 14 | 15 | beforeEach(() => { 16 | database = new SqlDatabase(':memory:'); 17 | }); 18 | 19 | it('should initialize properly', async () => { 20 | await database.initialize(); 21 | }); 22 | 23 | it('should return empty array if there are no data', async () => { 24 | await database.initialize(); 25 | 26 | const result = await database.getContracts(); 27 | 28 | expect(result).toHaveLength(0); 29 | }); 30 | 31 | it('should add contract', async () => { 32 | await database.initialize(); 33 | 34 | await database.addContract(contract, 1); 35 | 36 | const result = await database.getContracts(); 37 | 38 | expect(result).toHaveLength(1); 39 | expect(result[0]).toMatchObject(contract); 40 | }); 41 | 42 | it('should update contract', async () => { 43 | await database.initialize(); 44 | await database.addContract(contract, 5); 45 | await database.updatePriority(contract.address, 7); 46 | 47 | const result = await database.getContracts(); 48 | 49 | expect(result[0]).toMatchObject({ ...contract, priority: 7 }); 50 | }); 51 | 52 | it('should delete contract', async () => { 53 | await database.initialize(); 54 | await database.addContract(contract, 1); 55 | 56 | expect(await database.getContracts()).toHaveLength(1) 57 | 58 | await database.deleteContract(contract.address); 59 | 60 | expect(await database.getContracts()).toHaveLength(0) 61 | 62 | }); 63 | 64 | it('should clear contracts', async () => { 65 | await database.initialize(); 66 | await database.addContract(contract, 1); 67 | 68 | expect(await database.getContracts()).toHaveLength(1) 69 | 70 | await database.clear(); 71 | 72 | expect(await database.getContracts()).toHaveLength(0) 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/tests/utils/compiler.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { utils } from 'ethers'; 4 | // @ts-ignore 5 | import solc from 'solc'; 6 | 7 | const IS_CACHE_ENABLED = false; 8 | 9 | export type CompilerArtifact = { abi: any; evm: { bytecode: { object: any } } }; 10 | 11 | const fileExists = (filePath: string) => { 12 | try { 13 | if (fs.existsSync(filePath)) { 14 | return true; 15 | } 16 | } catch (err) { 17 | return false; 18 | } 19 | }; 20 | 21 | function findImport(filePath: string) { 22 | try { 23 | let fullPath: string; 24 | 25 | const shouldUseNodeModules = filePath.indexOf('@') === 0; 26 | if (shouldUseNodeModules) { 27 | fullPath = path.resolve(__dirname, '../../../node_modules/', filePath); 28 | } else { 29 | fullPath = path.resolve(__dirname, '../contracts/', filePath); 30 | } 31 | 32 | const fileContent = fs.readFileSync(fullPath, { encoding: 'utf-8' }); 33 | 34 | return { 35 | contents: fileContent, 36 | }; 37 | } catch (e: any) { 38 | return { error: e?.message || e }; 39 | } 40 | } 41 | 42 | export const compile = (filename: string): CompilerArtifact => { 43 | const fileFullPath = path.join(__dirname, '../contracts', filename); 44 | const fileContent = fs.readFileSync(fileFullPath, { encoding: 'utf8' }); 45 | const fileHash = utils.id(fileContent).slice(2, 10); 46 | 47 | const { name, ext } = path.parse(fileFullPath); 48 | const cacheFileName = name + '_' + fileHash + ext; 49 | const cacheDir = path.resolve(__dirname, '../contracts/compiled/'); 50 | const cacheFullPath = path.resolve(cacheDir, cacheFileName); 51 | 52 | let result: string; 53 | 54 | if (IS_CACHE_ENABLED && fileExists(cacheFullPath)) { 55 | result = fs.readFileSync(cacheFullPath, { encoding: 'utf-8' }); 56 | } else { 57 | const input = { 58 | sources: { 59 | [fileFullPath]: { 60 | content: fileContent, 61 | }, 62 | }, 63 | language: 'Solidity', 64 | settings: { 65 | outputSelection: { 66 | '*': { 67 | '*': ['*'], 68 | }, 69 | }, 70 | }, 71 | }; 72 | 73 | result = solc.compile(JSON.stringify(input), { import: findImport }); 74 | 75 | if (IS_CACHE_ENABLED) { 76 | // eslint-disable-next-line @typescript-eslint/no-empty-function 77 | fs.mkdir(cacheDir, { recursive: true }, () => {}); 78 | fs.writeFileSync(cacheFullPath, result, { encoding: 'utf-8' }); 79 | } 80 | } 81 | 82 | const output = JSON.parse(result); 83 | if (!output.contracts) { 84 | throw output.errors; 85 | } 86 | 87 | const artifact = output.contracts[fileFullPath]; 88 | const key = Object.keys(artifact)[0]; 89 | return artifact[key]; 90 | }; 91 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Finding } from 'forta-agent'; 3 | import { providers } from 'ethers'; 4 | import { BotSharding } from 'forta-sharding'; 5 | import { AsyncPriorityQueue } from 'async'; 6 | import { BotAnalytics } from 'forta-bot-analytics'; 7 | 8 | import { Logger } from './logger'; 9 | import { IDatabase } from './database'; 10 | 11 | export enum TokenInterface { 12 | NATIVE = 'native', 13 | ERC20 = 'ERC20', 14 | ERC721 = 'ERC721', 15 | ERC1155 = 'ERC1155', 16 | } 17 | 18 | export type TokenInfo = { 19 | name?: string; 20 | type: TokenInterface; 21 | address: string; 22 | decimals?: number; 23 | value: BigNumber; 24 | }; 25 | 26 | export type CreatedContract = { 27 | address: string; 28 | deployer: string; 29 | blockNumber: number; 30 | timestamp: number; 31 | txHash: string; 32 | }; 33 | 34 | export type HandleContract = (createdContract: CreatedContract) => Promise; 35 | 36 | export type BotEnv = { 37 | NODE_ENV?: 'production' | string; 38 | TARGET_MODE?: '1' | string; 39 | DEBUG?: '1' | string; 40 | }; 41 | 42 | export type DataContainer = { 43 | logger: Logger; 44 | provider: providers.JsonRpcProvider; 45 | queue: AsyncPriorityQueue; 46 | sharding: BotSharding; 47 | detectedContractByAddress: Map; 48 | suspiciousContractByAddress: Map< 49 | string, 50 | { address: string; timestamp: number; priority: number } 51 | >; 52 | contractWaitingTime: number; 53 | payableFunctionEtherValue: number; 54 | totalUsdTransferThreshold: BigNumber; 55 | totalTokensThresholdsByAddress: { 56 | [tokenAddress: string]: { 57 | name: string; 58 | threshold: BigNumber; 59 | }; 60 | }; 61 | findings: Finding[]; 62 | chainId: number; 63 | database: IDatabase; 64 | analytics: BotAnalytics; 65 | developerAbbreviation: string; 66 | isDevelopment: boolean; 67 | isTargetMode: boolean; 68 | isDebug: boolean; 69 | isInitialized: boolean; 70 | }; 71 | 72 | export type BotConfig = { 73 | developerAbbreviation: string; 74 | payableFunctionEtherValue: number; 75 | totalUsdTransferThreshold: number; 76 | defaultAnomalyScore: { 77 | [chainId: string]: number; 78 | }; 79 | aztecContractBotId: string; 80 | maliciousContractMLBotId: string; 81 | tornadoCashContractBotId: string; 82 | flashloanContractBotId: string; 83 | totalTokensThresholdsByChain: { 84 | [chainId: string]: { 85 | [tokenAddr: string]: { 86 | name: string; 87 | threshold: number; 88 | }; 89 | }; 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Ganache from 'ganache'; 3 | import BigNumber from 'bignumber.js'; 4 | import { BigNumber as EtherBigNumber, ethers, utils } from 'ethers'; 5 | import { LogDescription } from 'ethers/lib/utils'; 6 | import { EVM } from 'evm'; 7 | import { getJsonRpcUrl, Network, TransactionEvent } from 'forta-agent'; 8 | import { BaseN } from 'js-combinatorics'; 9 | import LRU from 'lru-cache'; 10 | import { CreatedContract, TokenInterface } from './types'; 11 | import { Logger } from './logger'; 12 | import Erc20Abi from './abi/erc20.json'; 13 | import Erc721Abi from './abi/erc721.json'; 14 | import Erc1155Abi from './abi/erc1155.json'; 15 | 16 | const erc20Iface = new ethers.utils.Interface(Erc20Abi); 17 | const erc721Iface = new ethers.utils.Interface(Erc721Abi); 18 | const erc1155Iface = new ethers.utils.Interface(Erc1155Abi); 19 | 20 | // it helps not to make a lot of requests for the same token 21 | const coinPriceCache = new LRU({ 22 | max: 300, // addresses 23 | ttl: 60 * 60 * 1000, // 60 min 24 | fetchMethod: async (key: string, _, { context = {} }) => { 25 | const [coinKey, timestamp] = key.split('/'); 26 | const url = timestamp 27 | ? `https://coins.llama.fi/prices/historical/${timestamp}` 28 | : 'https://coins.llama.fi/prices/current'; 29 | const { data } = await axios.get(`${url}/${coinKey}`); 30 | const price = data.coins[coinKey]?.price; 31 | if (price == null) { 32 | context.logger?.info('Unknown token price', coinKey); 33 | } 34 | return price; 35 | }, 36 | }); 37 | 38 | export function getEthersForkProvider(blockNumber: number, unlockedAccounts: string[]) { 39 | return new ethers.providers.Web3Provider( 40 | Ganache.provider({ 41 | logging: { quiet: true }, 42 | fork: { 43 | url: getJsonRpcUrl(), 44 | blockNumber: blockNumber, 45 | }, 46 | wallet: { 47 | totalAccounts: 1, 48 | defaultBalance: 100_000_000_000, 49 | unlockedAccounts: unlockedAccounts, 50 | }, 51 | }) as any, 52 | ); 53 | } 54 | 55 | export function getSighashes(code: string) { 56 | const evm = new EVM(code); 57 | const opcodes = evm.getOpcodes(); 58 | 59 | const sighashes: string[] = []; 60 | 61 | for (let i = 0; i < opcodes.length - 3; i++) { 62 | if ( 63 | opcodes[i].name === 'PUSH4' && 64 | opcodes[i + 1].name === 'EQ' && 65 | opcodes[i + 2].name.indexOf('PUSH') === 0 && 66 | opcodes[i + 3].name === 'JUMPI' 67 | ) { 68 | sighashes.push('0x' + opcodes[i].pushData.toString('hex')); 69 | } 70 | } 71 | 72 | return sighashes; 73 | } 74 | 75 | export function* generateCallData(opts: { wordCount: number; addresses: string[] }) { 76 | const { wordCount, addresses } = opts; 77 | 78 | if (wordCount === 0) { 79 | yield ''; 80 | } else { 81 | const params = [ 82 | utils.defaultAbiCoder.encode(['uint256'], [0]), 83 | utils.defaultAbiCoder.encode(['uint256'], [1]), 84 | utils.defaultAbiCoder.encode(['uint256'], [10_000]), 85 | utils.defaultAbiCoder.encode(['uint256'], [1_000_000]), 86 | utils.defaultAbiCoder.encode(['uint256'], [EtherBigNumber.from(10).pow(22)]), 87 | ...addresses.map((address) => utils.defaultAbiCoder.encode(['address'], [address])), 88 | ].map((v) => v.slice(2)); 89 | 90 | const it = new BaseN(params, wordCount); 91 | 92 | for (const group of it) { 93 | yield group.join(''); 94 | } 95 | } 96 | } 97 | 98 | export async function getBalanceChanges(params: { 99 | tx: ethers.providers.TransactionResponse; 100 | receipt: ethers.providers.TransactionReceipt; 101 | provider: ethers.providers.Web3Provider; 102 | }) { 103 | const { tx, receipt, provider } = params; 104 | // to unify data with different erc standards, native and erc20 tokens have their own token ID, which is always 0 105 | const generalTokenId = 0; 106 | 107 | const balanceChangesByAccount: { 108 | [account: string]: { [tokenAddress: string]: { [tokenId: string]: BigNumber } }; 109 | } = {}; 110 | const interfacesByToken: { 111 | [tokenAddress: string]: TokenInterface; 112 | } = {}; 113 | 114 | const prepareLogs = (iface: ethers.utils.Interface, logs: ethers.providers.Log[]) => 115 | logs 116 | .map((l) => { 117 | try { 118 | return { 119 | parsedLog: iface.parseLog(l), 120 | tokenAddress: l.address.toLowerCase(), 121 | }; 122 | } catch (e) { 123 | return {}; 124 | } 125 | }) 126 | .filter((v) => v.parsedLog) as { parsedLog: LogDescription; tokenAddress: string }[]; 127 | 128 | const erc20Logs = prepareLogs(erc20Iface, receipt.logs); 129 | const erc721Logs = prepareLogs(erc721Iface, receipt.logs); 130 | const erc1155Logs = prepareLogs(erc1155Iface, receipt.logs); 131 | 132 | for (const { parsedLog, tokenAddress } of erc20Logs) { 133 | if (parsedLog.name === 'Transfer') { 134 | const from = parsedLog.args.from.toLowerCase(); 135 | const to = parsedLog.args.to.toLowerCase(); 136 | const value = parsedLog.args.value.toString(); 137 | interfacesByToken[tokenAddress] = TokenInterface.ERC20; 138 | balanceChangesByAccount[from] = balanceChangesByAccount[from] || {}; 139 | balanceChangesByAccount[to] = balanceChangesByAccount[to] || {}; 140 | balanceChangesByAccount[from][tokenAddress] = balanceChangesByAccount[from][tokenAddress] || { 141 | [generalTokenId]: new BigNumber(0), 142 | }; 143 | balanceChangesByAccount[to][tokenAddress] = balanceChangesByAccount[to][tokenAddress] || { 144 | [generalTokenId]: new BigNumber(0), 145 | }; 146 | // subtract transferred value 147 | balanceChangesByAccount[from][tokenAddress][generalTokenId] = 148 | balanceChangesByAccount[from][tokenAddress][generalTokenId].minus(value); 149 | // add transferred value 150 | balanceChangesByAccount[to][tokenAddress][generalTokenId] = 151 | balanceChangesByAccount[to][tokenAddress][generalTokenId].plus(value); 152 | } 153 | } 154 | 155 | for (const { parsedLog, tokenAddress } of erc721Logs) { 156 | if (parsedLog.name === 'Transfer') { 157 | const from = parsedLog.args.from.toLowerCase(); 158 | const to = parsedLog.args.to.toLowerCase(); 159 | const tokenId = parsedLog.args.tokenId.toString(); 160 | interfacesByToken[tokenAddress] = TokenInterface.ERC721; 161 | balanceChangesByAccount[from] = balanceChangesByAccount[from] || {}; 162 | balanceChangesByAccount[to] = balanceChangesByAccount[to] || {}; 163 | balanceChangesByAccount[from][tokenAddress] = 164 | balanceChangesByAccount[from][tokenAddress] || {}; 165 | balanceChangesByAccount[to][tokenAddress] = balanceChangesByAccount[to][tokenAddress] || {}; 166 | balanceChangesByAccount[from][tokenAddress][tokenId] = new BigNumber(-1); 167 | balanceChangesByAccount[to][tokenAddress][tokenId] = new BigNumber(1); 168 | } 169 | } 170 | 171 | for (const { parsedLog, tokenAddress } of erc1155Logs) { 172 | if (!['TransferSingle', 'TransferBatch'].includes(parsedLog.name)) continue; 173 | 174 | const from = parsedLog.args.from.toLowerCase(); 175 | const to = parsedLog.args.to.toLowerCase(); 176 | interfacesByToken[tokenAddress] = TokenInterface.ERC1155; 177 | balanceChangesByAccount[to] = balanceChangesByAccount[to] || {}; 178 | balanceChangesByAccount[from] = balanceChangesByAccount[from] || {}; 179 | balanceChangesByAccount[from][tokenAddress] = balanceChangesByAccount[from][tokenAddress] || {}; 180 | balanceChangesByAccount[to][tokenAddress] = balanceChangesByAccount[to][tokenAddress] || {}; 181 | if (parsedLog.name === 'TransferSingle') { 182 | const tokenId = parsedLog.args.id.toString(); 183 | const value = parsedLog.args.value.toString(); 184 | // add value of transferred token 185 | balanceChangesByAccount[from][tokenAddress][tokenId] = ( 186 | balanceChangesByAccount[from][tokenAddress][tokenId] || new BigNumber(0) 187 | ).minus(value); 188 | balanceChangesByAccount[to][tokenAddress][tokenId] = ( 189 | balanceChangesByAccount[to][tokenAddress][tokenId] || new BigNumber(0) 190 | ).plus(value); 191 | } else if (parsedLog.name === 'TransferBatch') { 192 | const tokenIds = parsedLog.args.ids; 193 | const values = parsedLog.args[4]; // "values" is an already reserved word 194 | for (let i = 0; i < tokenIds.length; i++) { 195 | const tokenId = tokenIds[i].toString(); 196 | const value = values[i].toString(); 197 | // subtract value of transferred token 198 | balanceChangesByAccount[from][tokenAddress][tokenId] = ( 199 | balanceChangesByAccount[from][tokenAddress][tokenId] || new BigNumber(0) 200 | ).minus(value); 201 | // add value of transferred token 202 | balanceChangesByAccount[to][tokenAddress][tokenId] = ( 203 | balanceChangesByAccount[to][tokenAddress][tokenId] || new BigNumber(0) 204 | ).plus(value); 205 | } 206 | } 207 | } 208 | 209 | const sender = tx.from.toLowerCase(); 210 | const destination = tx.to!.toLowerCase(); 211 | interfacesByToken['native'] = TokenInterface.NATIVE; 212 | for (const account of new Set([...Object.keys(balanceChangesByAccount), sender, destination])) { 213 | const initialEthBalance = new BigNumber( 214 | (await provider.getBalance(account, tx.blockNumber! - 1)).toString(), 215 | ); 216 | const finalEthBalance = new BigNumber( 217 | (await provider.getBalance(account, tx.blockNumber)).toString(), 218 | ); 219 | 220 | // subtract transaction cost so that the sender has "pure" transferred value 221 | const gasCost = sender === account ? receipt.gasUsed.mul(tx.gasPrice!).toString() : 0; 222 | const diff = finalEthBalance.minus(initialEthBalance).plus(gasCost); 223 | if (!diff.isZero()) { 224 | balanceChangesByAccount[account] = balanceChangesByAccount[account] || {}; 225 | balanceChangesByAccount[account]['native'] = { 226 | [generalTokenId]: diff, 227 | }; 228 | } 229 | } 230 | 231 | return { 232 | balanceChangesByAddress: balanceChangesByAccount, 233 | interfacesByTokenAddress: interfacesByToken, 234 | }; 235 | } 236 | 237 | // version with summed token balances 238 | export async function getTotalBalanceChanges(params: { 239 | tx: ethers.providers.TransactionResponse; 240 | receipt: ethers.providers.TransactionReceipt; 241 | provider: ethers.providers.Web3Provider; 242 | }) { 243 | const { tx, receipt, provider } = params; 244 | // get all token transfers caused by the transaction (including native token) 245 | const { interfacesByTokenAddress, balanceChangesByAddress } = await getBalanceChanges({ 246 | tx, 247 | receipt, 248 | provider, 249 | }); 250 | 251 | // simplify balances by summing amount of erc721 and erc1155 tokens 252 | const totalBalanceChangesByAccount: { 253 | [account: string]: { [tokenAddress: string]: BigNumber }; 254 | } = {}; 255 | const getSum = (obj: { [x: string]: BigNumber }) => 256 | Object.values(obj).reduce((a, b) => a.plus(b), new BigNumber(0)); 257 | for (const account of Object.keys(balanceChangesByAddress)) { 258 | totalBalanceChangesByAccount[account] = totalBalanceChangesByAccount[account] || {}; 259 | for (const tokenAddress of Object.keys(balanceChangesByAddress[account])) { 260 | totalBalanceChangesByAccount[account][tokenAddress] = getSum( 261 | balanceChangesByAddress[account][tokenAddress], 262 | ); 263 | } 264 | } 265 | 266 | return { interfacesByTokenAddress, totalBalanceChangesByAddress: totalBalanceChangesByAccount }; 267 | } 268 | 269 | export async function getTokenNames(params: { 270 | addresses: string[]; 271 | knownTokens?: { [address: string]: { name?: string } }; 272 | provider: ethers.providers.Web3Provider; 273 | chainId: Network; 274 | }) { 275 | const { addresses, knownTokens = {}, provider, chainId } = params; 276 | const map: { [address: string]: string } = {}; 277 | 278 | const nativeTokenByChainId: { [chainId: number]: string } = { 279 | [Network.MAINNET]: 'ETH', 280 | [Network.BSC]: 'BNB', 281 | [Network.POLYGON]: 'MATIC', 282 | [Network.ARBITRUM]: 'ETH', 283 | [Network.AVALANCHE]: 'AVAX', 284 | [Network.FANTOM]: 'FTM', 285 | [Network.OPTIMISM]: 'ETH', 286 | }; 287 | 288 | await Promise.all( 289 | addresses.map(async (address) => { 290 | if (knownTokens[address]?.name) { 291 | map[address] = knownTokens[address].name!; 292 | return; 293 | } 294 | 295 | if (address === 'native') { 296 | map[address] = nativeTokenByChainId[chainId]; 297 | return; 298 | } 299 | 300 | let symbol, name; 301 | const contract = new ethers.Contract(address, erc20Iface, provider); 302 | 303 | try { 304 | symbol = await contract.symbol(); 305 | // eslint-disable-next-line no-empty 306 | } catch {} 307 | 308 | if (!symbol) { 309 | try { 310 | name = await contract.name(); 311 | // eslint-disable-next-line no-empty 312 | } catch {} 313 | } 314 | 315 | if (symbol || name) { 316 | map[address] = symbol || name; 317 | } 318 | }), 319 | ); 320 | 321 | return map; 322 | } 323 | 324 | export async function getTokenDecimals(params: { 325 | addresses: string[]; 326 | knownTokens?: { [address: string]: { decimals?: number } }; 327 | provider: ethers.providers.Web3Provider; 328 | }) { 329 | const { addresses, knownTokens = {}, provider } = params; 330 | 331 | const map: { [address: string]: number } = {}; 332 | 333 | await Promise.all( 334 | addresses.map(async (address) => { 335 | if (knownTokens[address]?.decimals != null) { 336 | map[address] = knownTokens[address].decimals!; 337 | return; 338 | } 339 | 340 | // TODO Have all native tokens 18 decimals? 341 | if (address === 'native') { 342 | map[address] = 18; 343 | return; 344 | } 345 | 346 | const contract = new ethers.Contract(address, erc20Iface, provider); 347 | 348 | try { 349 | const decimals = await contract.decimals(); 350 | if (decimals != null) { 351 | map[address] = decimals; 352 | } 353 | // eslint-disable-next-line no-empty 354 | } catch {} 355 | }), 356 | ); 357 | 358 | return map; 359 | } 360 | 361 | export function getCreatedContracts(txEvent: TransactionEvent): CreatedContract[] { 362 | const createdContracts: CreatedContract[] = []; 363 | const sender = txEvent.from.toLowerCase(); 364 | 365 | // in our case, we assume that deployer is actually the person who initiated the deploy transaction 366 | // even if a contract is created by another contract 367 | 368 | for (const trace of txEvent.traces) { 369 | if (trace.type === 'create') { 370 | const deployer = trace.action.from.toLowerCase(); 371 | 372 | // Parity/OpenEthereum trace format contains created address 373 | // https://github.com/NethermindEth/docs/blob/master/nethermind-utilities/cli/trace.md 374 | if (trace.result.address) { 375 | createdContracts.push({ 376 | deployer: sender, 377 | address: trace.result.address.toLowerCase(), 378 | blockNumber: txEvent.blockNumber, 379 | timestamp: txEvent.timestamp, 380 | txHash: txEvent.hash, 381 | }); 382 | continue; 383 | } 384 | 385 | // fallback to more universal way 386 | if (sender === deployer || createdContracts.find((c) => c.address === deployer)) { 387 | // for contracts creating other contracts, the nonce would be 1 388 | const nonce = sender === deployer ? txEvent.transaction.nonce : 1; 389 | const createdContract = ethers.utils.getContractAddress({ from: deployer, nonce }); 390 | createdContracts.push({ 391 | deployer: sender, 392 | address: createdContract.toLowerCase(), 393 | blockNumber: txEvent.blockNumber, 394 | timestamp: txEvent.timestamp, 395 | txHash: txEvent.hash, 396 | }); 397 | } 398 | } 399 | } 400 | 401 | if (!txEvent.to && txEvent.traces.length === 0) { 402 | createdContracts.push({ 403 | deployer: sender, 404 | address: ethers.utils.getContractAddress({ 405 | from: txEvent.from, 406 | nonce: txEvent.transaction.nonce, 407 | }).toLowerCase(), 408 | blockNumber: txEvent.blockNumber, 409 | timestamp: txEvent.timestamp, 410 | txHash: txEvent.hash, 411 | }); 412 | } 413 | 414 | return createdContracts; 415 | } 416 | 417 | export async function getNativeTokenPrice( 418 | network: Network, 419 | logger?: Logger, 420 | timestamp?: number, 421 | ): Promise { 422 | // https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc 423 | const keys: { [chain: number]: string } = { 424 | [Network.MAINNET]: 'coingecko:ethereum', 425 | [Network.BSC]: 'coingecko:binancecoin', 426 | [Network.POLYGON]: 'coingecko:matic-network', 427 | [Network.ARBITRUM]: 'coingecko:ethereum', // arbitrum doesn't have a native token 428 | [Network.FANTOM]: 'coingecko:fantom', 429 | [Network.AVALANCHE]: 'coingecko:avalanche-2', 430 | [Network.OPTIMISM]: 'coingecko:ethereum', // optimism doesn't have a native token 431 | }; 432 | 433 | if (!keys[network]) throw new Error('Not implemented yet: ' + Network[network]); 434 | 435 | try { 436 | const coinKey = [keys[network], timestamp].filter((v) => v).join('/'); 437 | return coinPriceCache.fetch(coinKey, { fetchContext: { logger } }); 438 | } catch (e) { 439 | logger?.error(e); 440 | } 441 | } 442 | 443 | export async function getErc20TokenPrice( 444 | network: Network, 445 | address: string, 446 | logger?: Logger, 447 | timestamp?: number, 448 | ): Promise { 449 | const chainKeysByNetwork: { [x: number]: string } = { 450 | [Network.MAINNET]: 'ethereum', 451 | [Network.BSC]: 'bsc', 452 | [Network.POLYGON]: 'polygon', 453 | [Network.ARBITRUM]: 'arbitrum', 454 | [Network.FANTOM]: 'fantom', 455 | [Network.AVALANCHE]: 'avax', 456 | [Network.OPTIMISM]: 'optimism', 457 | }; 458 | 459 | if (!chainKeysByNetwork[network]) throw new Error('Not implemented yet: ' + Network[network]); 460 | 461 | try { 462 | const coinKey = [`${chainKeysByNetwork[network]}:${address}`, timestamp] 463 | .filter((v) => v) 464 | .join('/'); 465 | return coinPriceCache.fetch(coinKey, { fetchContext: { logger } }); 466 | } catch (e) { 467 | logger?.error(e); 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES6", 8 | /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 9 | "module": "CommonJS", 10 | /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 15 | "declaration": true, 16 | /* Generates corresponding '.d.ts' file. */ 17 | "declarationMap": true, 18 | /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | "outDir": "dist", 22 | /* Redirect output structure to the directory. */ 23 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 26 | // "removeComments": true, /* Do not emit comments to output. */ 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 31 | "resolveJsonModule": true, 32 | /* Strict Type-Checking Options */ 33 | "strict": true, 34 | /* Enable all strict type-checking options. */ 35 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 36 | // "strictNullChecks": true, /* Enable strict null checks. */ 37 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 38 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 39 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 40 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 41 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 42 | 43 | /* Additional Checks */ 44 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 45 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 46 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 47 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 48 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 49 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 50 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 51 | 52 | /* Module Resolution Options */ 53 | "moduleResolution": "node", 54 | /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 55 | "baseUrl": ".", 56 | /* Base directory to resolve non-absolute module names. */ 57 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 58 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 59 | // "typeRoots": [], /* List of folders to include type definitions from. */ 60 | // "types": [], /* Type declaration files to be included in compilation. */ 61 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 62 | "esModuleInterop": true, 63 | /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 64 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 65 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 66 | 67 | /* Source Map Options */ 68 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 69 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 70 | "inlineSourceMap": true, 71 | /* Emit a single file with source maps instead of having a separate file. */ 72 | "inlineSources": true, 73 | /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 74 | 75 | /* Experimental Options */ 76 | "experimentalDecorators": true, 77 | /* Enables experimental support for ES7 decorators. */ 78 | "emitDecoratorMetadata": true, 79 | /* Enables experimental support for emitting type metadata for decorators. */ 80 | 81 | /* Advanced Options */ 82 | "skipLibCheck": true, 83 | /* Skip type checking of declaration files. */ 84 | "forceConsistentCasingInFileNames": true 85 | /* Disallow inconsistently-cased references to the same file. */ 86 | } 87 | } 88 | --------------------------------------------------------------------------------