├── .eslintignore
├── .eslintrc.js
├── .github
├── 1inch_github_b.svg
├── 1inch_github_w.svg
├── CODEOWNERS
└── workflows
│ ├── pr.yml
│ └── release.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .swcrc
├── LICENSE
├── README.md
├── docs
├── img
│ └── multicall_with_gas.png
└── multicall.puml
├── jest.config.js
├── package.json
├── pnpm-lock.yaml
├── src
├── __snapshots__
│ └── multicall.service.test.ts.snap
├── abi
│ └── MultiCall.abi.json
├── connector
│ ├── index.ts
│ ├── provider.connector.ts
│ └── web3-provider.connector.ts
├── decode
│ ├── decode.test.ts
│ └── decode.ts
├── encode
│ ├── encode.test.ts
│ └── encode.ts
├── gas-limit.service.ts
├── index.ts
├── model
│ ├── index.ts
│ └── multicall.model.ts
├── multicall.const.ts
├── multicall.helpers.ts
├── multicall.service.test.ts
└── multicall.service.ts
├── test
├── ERC20.abi.json
├── call-by-chunks.snapshot.ts
├── call-by-gas-limit.snapshot.ts
└── multicall.service.test.ts
├── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | coverage
4 | jest.config.ts
5 | .eslintrc.js
6 | test
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@1inch'],
3 | rules: {
4 | 'max-depth': ['error', 4]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.github/1inch_github_b.svg:
--------------------------------------------------------------------------------
1 |
45 |
--------------------------------------------------------------------------------
/.github/1inch_github_w.svg:
--------------------------------------------------------------------------------
1 |
45 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @krboktv @shoom3301
2 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | ci:
7 | runs-on: ubuntu-20.04
8 | steps:
9 | - name: Checkout code into workspace directory
10 | uses: actions/checkout@v2
11 |
12 | - name: Setup Node
13 | uses: actions/setup-node@v1
14 | with:
15 | node-version: '18.x'
16 |
17 | - name: Install yarn
18 | run: npm install --global yarn
19 |
20 | - name: Install dependency
21 | run: yarn install
22 |
23 | - name: CI
24 | run: yarn run ci-pipeline
25 |
26 | - name: Security
27 | run: yarn audit
28 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish package
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*.*.*
7 |
8 | jobs:
9 | publish-to-npm:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v3
14 |
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 18
18 | registry-url: 'https://registry.npmjs.org'
19 | scope: ${{ github.repository_owner }}
20 |
21 | - name: Update package.json
22 | run: |
23 | TAG_NAME=${GITHUB_REF/refs\/tags\//}
24 | PACKAGE_VERSION=${TAG_NAME#v}
25 | echo "Updating package.json to version $PACKAGE_VERSION"
26 | cat <<< $(jq -r ".version=\"$PACKAGE_VERSION\"" package.json) > package.json
27 | cat package.json
28 |
29 | - name: Install dependencies
30 | run: yarn install --frozen-lockfile
31 |
32 | - name: Build
33 | run: yarn build
34 |
35 | - name: Publish
36 | run: yarn publish
37 | working-directory: dist
38 | env:
39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
40 |
41 | publish-to-github:
42 | runs-on: ubuntu-latest
43 | permissions:
44 | contents: read
45 | packages: write
46 | steps:
47 | - name: Checkout
48 | uses: actions/checkout@v3
49 |
50 | - uses: actions/setup-node@v3
51 | with:
52 | node-version: 18
53 | registry-url: 'https://npm.pkg.github.com'
54 | scope: ${{ github.repository_owner }}
55 |
56 | - name: Update package.json
57 | run: |
58 | TAG_NAME=${GITHUB_REF/refs\/tags\//}
59 | PACKAGE_VERSION=${TAG_NAME#v}
60 | echo "Updating package.json to version $PACKAGE_VERSION"
61 | cat <<< $(jq -r ".version=\"$PACKAGE_VERSION\"" package.json) > package.json
62 | cat package.json
63 |
64 | - name: Install dependencies
65 | run: yarn install --frozen-lockfile
66 |
67 | - name: Build
68 | run: yarn build
69 |
70 | - name: Publish
71 | run: yarn publish
72 | working-directory: dist
73 | env:
74 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | .idea
4 | coverage
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .github
2 | CHANGELOG.md
3 | coverage
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 4,
4 | "bracketSpacing": false,
5 | "singleQuote": true,
6 | "semi": false,
7 | "printWidth": 120
8 | }
9 |
--------------------------------------------------------------------------------
/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "jsc": {
3 | "parser": {
4 | "syntax": "typescript",
5 | "decorators": true
6 | },
7 | "transform": {
8 | "decoratorMetadata": true
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 1INCH LIMITED
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |

4 |
5 |
6 | # Multicall
7 |
8 | ### This is the package for high-weight optimized calls to blockchain nodes
9 |
10 |
11 | ## Installation
12 |
13 | ### Node
14 |
15 | ```
16 | npm install @1inch/multicall
17 | ```
18 |
19 | ### Yarn
20 |
21 | ```
22 | yarn add @1inch/multicall
23 | ```
24 |
25 | ## Onchain addresses
26 |
27 | - Ethereum mainnet: `0x8d035edd8e09c3283463dade67cc0d49d6868063`
28 | - BSC mainnet: `0x804708de7af615085203fa2b18eae59c5738e2a9`
29 | - Polygon mainnet: `0x0196e8a9455a90d392b46df8560c867e7df40b34`
30 | - Arbitrum One: `0x11DEE30E710B8d4a8630392781Cc3c0046365d4c`
31 | - Gnosis Chain: `0xE295aD71242373C37C5FdA7B57F26f9eA1088AFe`
32 | - Avalanche: `0xC4A8B7e29E3C8ec560cd4945c1cF3461a85a148d`
33 | - Fantom: `0xA31bB36c5164B165f9c36955EA4CcBaB42B3B28E`
34 | - Optimism: `0xE295aD71242373C37C5FdA7B57F26f9eA1088AFe`
35 | - Base: `0x138ce40d675f9a23e4d6127a8600308cf7a93381`
36 | - Aurora: `0xa0446d8804611944f1b527ecd37d7dcbe442caba`
37 | - zkSync Era: `0xae1f66df155c611c15a23f31acf5a9bf1b87907e`
38 | - Klaytn: `0xa31bb36c5164b165f9c36955ea4ccbab42b3b28e`
39 |
40 | ## Motivation
41 | The **MultiCall** contract is designed to execute multiple view calls at one time.
42 | For example, you have a list of tokens, and you need to get balances for all the items on that list.
43 |
44 | Let's try to do it in the most obvious way:
45 |
46 | ```typescript
47 | const encodeAbi = ...
48 |
49 | const provider = new Web3ProviderConnector(new Web3('...'));
50 | const walletAddress = '0x1111111111111111111111111111111111111111';
51 |
52 | const tokens = [
53 | '0x6b175474e89094c44da98b954eedeac495271d0f',
54 | '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
55 | '0xdac17f958d2ee523a2206206994597c13d831ec7'
56 | ];
57 |
58 | const contractCalls = tokens.map((tokenAddress) => {
59 | const callData = encodeAbi(
60 | ERC20ABI,
61 | tokenAddress,
62 | 'balanceOf',
63 | [walletAddress]
64 | );
65 |
66 | return provider.ethCall(
67 | tokenAddress,
68 | callData
69 | );
70 | });
71 |
72 | const balances = await Promise.all(contractCalls);
73 | ```
74 |
75 | The downside to this solution is that you make as many requests for a contract as you have tokens on the list.
76 | And if the list is large enough, you will create a significant load on the provider.
77 |
78 | ### Simple MultiCall
79 |
80 | A **multiCallService.callByChunks()** contract takes a list of requests, splits them into chunks and calls the provider in batches.
81 |
82 | #### Default params
83 | - maxChunkSize: **100**
84 | - retriesLimit: **3**
85 | - blockNumber: **'latest'**
86 |
87 | Example:
88 | ```typescript
89 | const encodeAbi = ...
90 | const provider = new Web3ProviderConnector(new Web3('...'));
91 | const walletAddress = '0x1111111111111111111111111111111111111111';
92 | const contractAddress = '0x8d035edd8e09c3283463dade67cc0d49d6868063';
93 | const multiCallService = new MultiCallService(provider, contractAddress);
94 |
95 | const tokens = [
96 | '0x6b175474e89094c44da98b954eedeac495271d0f',
97 | '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
98 | '0xdac17f958d2ee523a2206206994597c13d831ec7'
99 | ];
100 |
101 | // The parameters are optional, if not specified, the default will be used
102 | const params: MultiCallParams = {
103 | chunkSize: 100,
104 | retriesLimit: 3,
105 | blockNumber: 'latest'
106 | };
107 |
108 | const callDatas = tokens.map((tokenAddress) => {
109 | return {
110 | to: tokenAddress,
111 | data: encodeAbi(
112 | ERC20ABI,
113 | tokenAddress,
114 | 'balanceOf',
115 | [walletAddress]
116 | )
117 | };
118 | });
119 |
120 | const balances = await multiCallService.callByChunks(callDatas, params);
121 | ```
122 |
123 | Got better! Instead of making a separate request to the provider for each item, we group them into butches and make much fewer requests.
124 |
125 | >Note:
126 | > If the call to this method exceeds the gas limit, then the entire request will be reverted.
127 |
128 | ### MultiCall by gas limit
129 | **Problem:**
130 | The point is that the node has a limit for gas per call of the contract.
131 | And it may happen that by making a simple MultiCall we will not meet this limit.
132 | If the gas limit on the node is large enough, we may face a time limit on the execution of the contract method.
133 |
134 | In total, **there are 2 restrictions on a node:**
135 | - by gas
136 | - by time
137 |
138 | To avoid these limitations, there is a more advanced method:
139 | **multiCallService.callByGasLimit()**
140 |
141 | #### Default params
142 | - maxChunkSize: **500**
143 | - retriesLimit: **3**
144 | - blockNumber: **'latest'**
145 | - gasBuffer: **3000000**
146 | - maxGasLimit: **150000000**
147 |
148 | Example:
149 |
150 | ```typescript
151 | const contractAddress = '0x8d035edd8e09c3283463dade67cc0d49d6868063';
152 | const provider = new Web3ProviderConnector(new Web3('...'));
153 |
154 | const gasLimitService = new GasLimitService(provider, contractAddress);
155 | const multiCallService = new MultiCallService(provider, contractAddress);
156 |
157 | const balanceOfGasUsage = 30_000;
158 |
159 | const tokens = [
160 | '0x6b175474e89094c44da98b954eedeac495271d0f',
161 | '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
162 | '0xdac17f958d2ee523a2206206994597c13d831ec7'
163 | ];
164 |
165 | const requests: MultiCallRequest[] = tokens.map((tokenAddress) => {
166 | return {
167 | to: tokenAddress,
168 | data: provider.contractEncodeABI(
169 | ERC20ABI,
170 | tokenAddress,
171 | 'balanceOf',
172 | [walletAddress]
173 | ),
174 | gas: balanceOfGasUsage
175 | };
176 | });
177 |
178 | const gasLimit: number = await gasLimitService.calculateGasLimit();
179 |
180 | // The parameters are optional, if not specified, the default will be used
181 | const params: MultiCallParams = {
182 | maxChunkSize: 500,
183 | retriesLimit: 3,
184 | blockNumber: 'latest',
185 | gasBuffer: 100_000
186 | };
187 |
188 | const response = await multiCallService.callByGasLimit(
189 | requests,
190 | gasLimit,
191 | params
192 | );
193 | ```
194 |
195 | The idea is that we request the gas limit from the node and split the requests into chunks regarding this limit.
196 | Accordingly, we must set the gas limit for each request.
197 |
198 | >It is noteworthy that if suddenly the request does not fit into the gas limit, the entire request will not be reverted, and the request will return the results of those calls that fit into the gas limit.
199 |
200 | If the call to the contract all the same does not fit into the gas limit, then the callByGasLimit() will automatically re-request those elements that have not been fulfilled.
201 |
202 | **You can see a more detailed description of the library's work in the diagrams below.**
203 |
204 | ### GasLimitService
205 | This service is used to correctly calculate the gas limit for calling a MultiCall.
206 | The basic formula for calculating the limit is as follows:
207 | ```typescript
208 | const gasLimitForMultiCall = Math.min(gasLimitFromNode, maxGasLimit) - gasBuffer;
209 | ```
210 | Where:
211 | `gasLimitFromNode` - is the gas limit taken from the node
212 | `maxGasLimit` - limiter on top, in case the gas limit from the node is too large (may cause timeout)
213 | `gasBuffer` - is some safe buffer that allows you to avoid crossing the limit in case of unforeseen situations
214 |
215 | Example:
216 | ```typescript
217 | const gasLimitForMultiCall = (Math.min(12_000_000, 40_000_000)) - 100_000; // 11_990_000
218 | ```
219 | We believe that the multicall call should fit into 11_990_000 gas.
220 |
221 | #### Default params:
222 | - gasBuffer: **3000000**
223 | - maxGasLimit: **150000000**
224 |
225 | Params for `GasLimitService.calculateGasLimit()` are optional, if not specified, then gas limit will be requested from the node and the default params will be used.
226 |
227 | Example:
228 | ```typescript
229 | const contractAddress = '0x8d035edd8e09c3283463dade67cc0d49d6868063';
230 | const provider = new Web3ProviderConnector(new Web3('...'));
231 |
232 | const gasLimitService = new GasLimitService(provider, contractAddress);
233 |
234 | const gasLimit: number = await gasLimitService.calculateGasLimit();
235 | ```
236 |
237 | Alternatively, you can specify your own parameters:
238 | ```typescript
239 | const contractAddress = '0x8d035edd8e09c3283463dade67cc0d49d6868063';
240 | const provider = new Web3ProviderConnector(new Web3('...'));
241 |
242 | const gasLimitService = new GasLimitService(provider, contractAddress);
243 |
244 | // 190_000
245 | const gasLimit: number = await gasLimitService.calculateGasLimit({
246 | gasLimit: 200_000,
247 | maxGasLimit: 200_000,
248 | gasBuffer: 10_000,
249 |
250 | });
251 | ```
252 |
253 | ## [Contract code](https://etherscan.io/address/0x8d035edd8e09c3283463dade67cc0d49d6868063#code)
254 |
255 | ---
256 |
257 | ## Algorithm activity diagram
258 | 
259 |
260 | ---
261 |
262 | ## Algorithm visualization
263 | 
264 |
--------------------------------------------------------------------------------
/docs/img/multicall_with_gas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1inch/multicall/f1608e107a4700807639eba4ccf40ad24fbc0d6f/docs/img/multicall_with_gas.png
--------------------------------------------------------------------------------
/docs/multicall.puml:
--------------------------------------------------------------------------------
1 | @startuml
2 | |Start|
3 | start
4 | : Input data: {
5 | requests: [
6 | {to: '0x00', data: '0x001', gas: 1000},
7 | {to: '0x00', data: '0x002', gas: 2000},
8 | ...
9 | ],
10 | gasLimit: 150_000_000,
11 | params: {
12 | maxChunkSize: 500,
13 | retriesLimit: 3,
14 | blockNumber: 'latest',
15 | gasBuffer: 3000000
16 | } //
17 | };
18 | : Add index to each requests items;
19 | |Split chunks|
20 | repeat
21 | partition Split_requests_by_chunks {
22 | repeat :Each request;
23 | if (\nFit into chunk by gas limit\n AND fit by chunk size?\n) then (yes)
24 |
25 | else (no)
26 | :Switch to next chunk;
27 | endif
28 | repeat while (Has next request?) is (yes) not (no)
29 | }
30 | |Execute chunks|
31 | partition Execute_chunks(in_parallel) {
32 | repeat :Each chunk;
33 | partition Execute_chunks_with_retries {
34 | repeat :Attempt to call contract;
35 | if (Are the attempts over?) then (yes)
36 | #pink:Throw error;
37 | stop;
38 | else (no)
39 | endif;
40 | #palegreen:Call contract method 'multicallWithGasLimitation';
41 | repeat while (Response is successful?) is (no) not (yes)
42 | }
43 | repeat while (Has next chunk?) is (yes) not (no)
44 | :Concat chunks executions results;
45 | }
46 | |Process execution result|
47 | #palegreen:Save executed chunks results (append to previous);
48 |
49 | if (Are there not executed chunks?) then (yes)
50 | :Reduce maxChunkSize by half;
51 | if (New maxChunkSize is zero?) then (yes)
52 | #pink:Throw error;
53 | stop;
54 | endif;
55 | :Use not executed chunks\n as new requests;
56 | else (no)
57 | endif;
58 |
59 | repeat while (Are there any more new requests?)
60 | :Sort results by indexes;
61 | #palegreen:Return results;
62 | end
63 | @enduml
64 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testTimeout: 200000,
3 | moduleFileExtensions: ['js', 'json', 'ts'],
4 | rootDir: 'src',
5 | testRegex: '.*\\.(spec|test)\\.ts$',
6 | transform: {
7 | '^.+\\.(t|j)s$': '@swc/jest'
8 | },
9 | collectCoverageFrom: ['**/*.(t|j)s'],
10 | coverageDirectory: '../coverage',
11 | testEnvironment: 'node',
12 | forceExit: true // For close all ws connections.
13 | }
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@1inch/multicall",
3 | "version": "2.0.0",
4 | "description": "High-weight optimized call-processor",
5 | "repository": {
6 | "type": "git",
7 | "url": "ssh://git@github.com:1inch/multicall.git",
8 | "directory": "@1inch/multicall"
9 | },
10 | "scripts": {
11 | "build": "tsc",
12 | "postbuild": "cp package.json dist && cp README.md dist",
13 | "lint": "eslint ./src --ext .js,.ts",
14 | "test": "jest",
15 | "test:coverage": "jest --collectCoverage",
16 | "typecheck": "tsc --noEmit --skipLibCheck",
17 | "prettier": "prettier --write .",
18 | "ci-pipeline": "yarn run lint && yarn run test && yarn run typecheck",
19 | "set-npm-auth": "echo \"//npm.pkg.github.com/:_authToken=${NPM_AUTH_TOKEN}\" >> .npmrc",
20 | "create-index": "cti src -b && npx replace-in-file \"export * from './abi';\" \" \" ./src/index.ts",
21 | "release": "standard-version"
22 | },
23 | "dependencies": {},
24 | "devDependencies": {
25 | "ethers": "^6.13.1",
26 | "@1inch/eslint-config": "^1.4.2",
27 | "@1inch/tsconfig": "^v1.0.2",
28 | "@swc/core": "^1.3.102",
29 | "@swc/jest": "0.2.26",
30 | "@types/jest": "29.5.12",
31 | "@typescript-eslint/eslint-plugin": "5.59",
32 | "@typescript-eslint/parser": "5.51",
33 | "babel-jest": "^29.7.0",
34 | "create-ts-index": "^1.13.3",
35 | "eslint": "8.41.0",
36 | "eslint-config-prettier": "8.3",
37 | "eslint-config-standard": "17",
38 | "eslint-import-resolver-typescript": "3.5.5",
39 | "eslint-plugin-import": "2.26",
40 | "eslint-plugin-n": "16",
41 | "eslint-plugin-prettier": "4",
42 | "eslint-plugin-promise": "6",
43 | "eslint-plugin-unused-imports": "2",
44 | "husky": "^6.0.0",
45 | "istanbul-badges-readme": "^1.2.1",
46 | "jest": "29.7.0",
47 | "lint-staged": "^10.5.4",
48 | "prettier": "^2.2.1",
49 | "replace": "^1.2.1",
50 | "ts-loader": "^9.5.1",
51 | "ts-mockito": "^2.6.1",
52 | "ts-node": "^10.9.2",
53 | "tslib": "^2.2.0",
54 | "typescript": "^4.9"
55 | },
56 | "peerDependencies": {},
57 | "husky": {
58 | "hooks": {
59 | "pre-commit": "lint-staged && yarn run typecheck"
60 | }
61 | },
62 | "lint-staged": {
63 | "*.{js,ts,md,json}": [
64 | "yarn run prettier"
65 | ],
66 | "*.{js,ts}": [
67 | "yarn run lint"
68 | ]
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/__snapshots__/multicall.service.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`MultiCallService callByGasLimit() full successful multicall test 1`] = `
4 | [
5 | "0x8d035edd8e09c3283463dade67cc0d49d6868063",
6 | "0xd699fe15000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000002dc6c0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010400000000000000000000000000000000000000000000000000000000000000",
7 | "latest",
8 | ]
9 | `;
10 |
11 | exports[`MultiCallService callByGasLimit() full successful multicall test 2`] = `
12 | [
13 | "0x8d035edd8e09c3283463dade67cc0d49d6868063",
14 | "0xd699fe15000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000002dc6c0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000107000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010800000000000000000000000000000000000000000000000000000000000000",
15 | "latest",
16 | ]
17 | `;
18 |
19 | exports[`MultiCallService callByGasLimit() full successful multicall test 3`] = `
20 | [
21 | "0x8d035edd8e09c3283463dade67cc0d49d6868063",
22 | "0xd699fe15000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000002dc6c00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000090000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000109000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000",
23 | "latest",
24 | ]
25 | `;
26 |
--------------------------------------------------------------------------------
/src/abi/MultiCall.abi.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "inputs": [
4 | {
5 | "components": [
6 | {
7 | "internalType": "address",
8 | "name": "to",
9 | "type": "address"
10 | },
11 | {
12 | "internalType": "bytes",
13 | "name": "data",
14 | "type": "bytes"
15 | }
16 | ],
17 | "internalType": "struct MultiCall.Call[]",
18 | "name": "calls",
19 | "type": "tuple[]"
20 | }
21 | ],
22 | "name": "multicall",
23 | "outputs": [
24 | {
25 | "internalType": "bytes[]",
26 | "name": "results",
27 | "type": "bytes[]"
28 | }
29 | ],
30 | "stateMutability": "view",
31 | "type": "function"
32 | },
33 | {
34 | "inputs": [
35 | {
36 | "components": [
37 | {
38 | "internalType": "address",
39 | "name": "to",
40 | "type": "address"
41 | },
42 | {
43 | "internalType": "bytes",
44 | "name": "data",
45 | "type": "bytes"
46 | }
47 | ],
48 | "internalType": "struct MultiCall.Call[]",
49 | "name": "calls",
50 | "type": "tuple[]"
51 | }
52 | ],
53 | "name": "multicallWithGas",
54 | "outputs": [
55 | {
56 | "internalType": "bytes[]",
57 | "name": "results",
58 | "type": "bytes[]"
59 | },
60 | {
61 | "internalType": "uint256[]",
62 | "name": "gasUsed",
63 | "type": "uint256[]"
64 | }
65 | ],
66 | "stateMutability": "view",
67 | "type": "function"
68 | },
69 | {
70 | "inputs": [
71 | {
72 | "components": [
73 | {
74 | "internalType": "address",
75 | "name": "to",
76 | "type": "address"
77 | },
78 | {
79 | "internalType": "bytes",
80 | "name": "data",
81 | "type": "bytes"
82 | }
83 | ],
84 | "internalType": "struct MultiCall.Call[]",
85 | "name": "calls",
86 | "type": "tuple[]"
87 | },
88 | {
89 | "internalType": "uint256",
90 | "name": "gasBuffer",
91 | "type": "uint256"
92 | }
93 | ],
94 | "name": "multicallWithGasLimitation",
95 | "outputs": [
96 | {
97 | "internalType": "bytes[]",
98 | "name": "results",
99 | "type": "bytes[]"
100 | },
101 | {
102 | "internalType": "uint256",
103 | "name": "lastSuccessIndex",
104 | "type": "uint256"
105 | }
106 | ],
107 | "stateMutability": "view",
108 | "type": "function"
109 | },
110 | {
111 | "inputs": [],
112 | "name": "gasLeft",
113 | "outputs": [
114 | {
115 | "internalType": "uint256",
116 | "name": "",
117 | "type": "uint256"
118 | }
119 | ],
120 | "stateMutability": "view",
121 | "type": "function"
122 | },
123 | {
124 | "inputs": [],
125 | "name": "gaslimit",
126 | "outputs": [
127 | {
128 | "internalType": "uint256",
129 | "name": "",
130 | "type": "uint256"
131 | }
132 | ],
133 | "stateMutability": "view",
134 | "type": "function"
135 | }
136 | ]
137 |
--------------------------------------------------------------------------------
/src/connector/index.ts:
--------------------------------------------------------------------------------
1 | export * from './provider.connector'
2 | export * from './web3-provider.connector'
3 |
--------------------------------------------------------------------------------
/src/connector/provider.connector.ts:
--------------------------------------------------------------------------------
1 | export interface ProviderConnector {
2 | ethCall(contractAddress: string, callData: string, blockNumber?: string): Promise
3 | }
4 |
--------------------------------------------------------------------------------
/src/connector/web3-provider.connector.ts:
--------------------------------------------------------------------------------
1 | import {ProviderConnector} from './provider.connector'
2 |
3 | export interface IWeb3CallInfo {
4 | data: string
5 | to: string
6 | }
7 |
8 | export interface IWeb3 {
9 | eth: {
10 | call(callInfo: IWeb3CallInfo, blockNumber: number | string): Promise
11 | }
12 | }
13 |
14 | export class Web3ProviderConnector implements ProviderConnector {
15 | constructor(protected readonly web3Provider: IWeb3) {}
16 |
17 | ethCall(contractAddress: string, callData: string, blockNumber = 'latest'): Promise {
18 | return this.web3Provider.eth.call(
19 | {
20 | to: contractAddress,
21 | data: callData
22 | },
23 | blockNumber
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/decode/decode.ts:
--------------------------------------------------------------------------------
1 | // Decoding written by hand for specific ABI because all general libs are too slow for big calls
2 | // Check https://gist.github.com/vbrvk/f9faf997966a553f2cc746213b5b6304 for more details on comparison
3 |
4 | import assert from 'node:assert'
5 |
6 | // Helper function to convert a hex word to a number
7 | function wordToNumber(word: string): number {
8 | return parseInt(word, 16)
9 | }
10 |
11 | // Helper function to read a 32-byte word at a given index
12 | function readWord(index: number, data: string): string {
13 | const start = index * 64
14 | const end = start + 64
15 |
16 | if (end > data.length) {
17 | throw new Error(`out of bounds read at index ${index}`)
18 | }
19 |
20 | return data.slice(start, end)
21 | }
22 |
23 | export function decodeOutputForMulticall(hexData: string): string[] {
24 | assert(hexData.length >= 2 + 0x40, 'input too short') // 0x20 offset + 0x20 len for `results`
25 | const data = hexData.slice(2)
26 |
27 | // Read the offset to the bytes[] array (first parameter)
28 | const bytesArrayOffset = wordToNumber(readWord(0, data)) / 32
29 |
30 | // Read the length of the bytes[] array
31 | const bytesArrayLength = wordToNumber(readWord(bytesArrayOffset, data))
32 |
33 | const results: string[] = []
34 |
35 | // Parse results
36 | for (let i = 0; i < bytesArrayLength; i++) {
37 | // Get the offset to this bytes element (relative to the start of the array)
38 | const elementOffsetIndex = bytesArrayOffset + 1 + i
39 | const elementOffsetRelative = wordToNumber(readWord(elementOffsetIndex, data))
40 |
41 | // Calculate the absolute position
42 | const elementLengthPosition = bytesArrayOffset + 1 + elementOffsetRelative / 32
43 |
44 | // Read the length of this bytes element
45 | const elementLength = wordToNumber(readWord(elementLengthPosition, data))
46 |
47 | const elementPosition = elementLengthPosition + 1
48 |
49 | // If the length is 0, add an empty bytes value
50 | if (elementLength === 0) {
51 | results.push('0x')
52 | continue
53 | }
54 |
55 | const startPosition = elementPosition * 64
56 | const endPosition = startPosition + elementLength * 2
57 |
58 | if (data.length < endPosition) {
59 | throw new Error('buffer overrun')
60 | }
61 |
62 | // Add to results
63 | results.push('0x' + data.slice(startPosition, endPosition))
64 | }
65 |
66 | return results
67 | }
68 |
69 | export function decodeOutputForMulticallWithGasLimitation(hexData: string): {
70 | results: string[]
71 | lastSuccessIndex: bigint
72 | } {
73 | assert(hexData.length >= 2 + 0x60, 'input too short') // 0x20 offset + 0x20 len for `results` + 0x20 for `lastSuccessIndex`
74 | const data = hexData.slice(2)
75 |
76 | // 1. Read the offset to the bytes[] array (first parameter)
77 | const bytesArrayOffsetHex = readWord(0, data)
78 | const bytesArrayOffset = wordToNumber(bytesArrayOffsetHex) / 32
79 |
80 | // 2. Read the lastSuccessIndex (second parameter)
81 | const lastSuccessIndexHex = readWord(1, data)
82 | const lastSuccessIndex = BigInt(wordToNumber(lastSuccessIndexHex))
83 |
84 | // 3. Read the length of the bytes[] array
85 | const bytesArrayLengthHex = readWord(bytesArrayOffset, data)
86 | const bytesArrayLength = wordToNumber(bytesArrayLengthHex)
87 |
88 | // 4. Initialize the results array
89 | const results: string[] = []
90 |
91 | // 5. For each element in the array, read its offset and then its data
92 | for (let i = 0; i < bytesArrayLength; i++) {
93 | // Get the offset to this bytes element (relative to the start of the array)
94 | const elementOffsetIndex = bytesArrayOffset + 1 + i
95 | const elementOffsetHex = readWord(elementOffsetIndex, data)
96 | const elementOffsetRelative = wordToNumber(elementOffsetHex)
97 |
98 | // Calculate the absolute position
99 | const elementLengthPosition = bytesArrayOffset + 1 + elementOffsetRelative / 32
100 |
101 | // Read the length of this bytes element
102 | const elementLengthHex = readWord(elementLengthPosition, data)
103 | const elementLength = wordToNumber(elementLengthHex)
104 |
105 | const elementPosition = elementLengthPosition + 1
106 |
107 | // If the length is 0, add an empty bytes value
108 | if (elementLength === 0) {
109 | results.push('0x')
110 | continue
111 | }
112 |
113 | const startPosition = elementPosition * 64
114 | const endPosition = startPosition + elementLength * 2
115 |
116 | if (data.length < endPosition) {
117 | throw new Error('buffer overrun')
118 | }
119 |
120 | // Add to results
121 | results.push('0x' + data.slice(startPosition, endPosition))
122 | }
123 |
124 | return {
125 | results,
126 | lastSuccessIndex
127 | }
128 | }
129 |
130 | export function decodeOutputForMulticallWithGas(hexData: string): {
131 | results: string[]
132 | gasUsed: bigint[]
133 | } {
134 | assert(hexData.length >= 2 + 0x40 * 2, 'input too short') // 0x20 for offset + 0x20 for len for each array
135 | const data = hexData.slice(2)
136 |
137 | // Read the offset to the bytes[] (first parameter)
138 | const bytesArrayOffset = wordToNumber(readWord(0, data)) / 32
139 |
140 | // Read the offset to the uint256[] (second parameter)
141 | const gasUsedArrayOffset = wordToNumber(readWord(1, data)) / 32
142 |
143 | // Read the length of the bytes[] array
144 | const bytesArrayLength = wordToNumber(readWord(bytesArrayOffset, data))
145 |
146 | // Read the length of the uint256[] array
147 | const gasUsedArrayLength = wordToNumber(readWord(gasUsedArrayOffset, data))
148 |
149 | // Initialize the results array
150 | const results: string[] = []
151 | const gasUsed: bigint[] = []
152 |
153 | // Parse results
154 | for (let i = 0; i < bytesArrayLength; i++) {
155 | // Get the offset to this bytes element (relative to the start of the array)
156 | const elementOffsetIndex = bytesArrayOffset + 1 + i
157 | const elementOffsetHex = readWord(elementOffsetIndex, data)
158 | const elementOffsetRelative = wordToNumber(elementOffsetHex)
159 |
160 | // Calculate the absolute position
161 | const elementLengthPosition = bytesArrayOffset + 1 + elementOffsetRelative / 32
162 |
163 | // Read the length of this bytes element
164 | const elementLength = wordToNumber(readWord(elementLengthPosition, data))
165 |
166 | const elementPosition = elementLengthPosition + 1
167 |
168 | // If the length is 0, add an empty bytes value
169 | if (elementLength === 0) {
170 | results.push('0x')
171 | continue
172 | }
173 |
174 | const startPosition = elementPosition * 64
175 | const endPosition = startPosition + elementLength * 2
176 |
177 | if (data.length < endPosition) {
178 | throw new Error('buffer overrun')
179 | }
180 |
181 | // Add to results
182 | results.push('0x' + data.slice(startPosition, endPosition))
183 | }
184 |
185 | // Parse gasUsed
186 | for (let i = 0; i < gasUsedArrayLength; i++) {
187 | const elementPosition = gasUsedArrayOffset + 1 + i
188 | const elementHex = readWord(elementPosition, data)
189 | const element = BigInt('0x' + elementHex)
190 |
191 | gasUsed.push(element)
192 | }
193 |
194 | return {
195 | results,
196 | gasUsed
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/encode/encode.ts:
--------------------------------------------------------------------------------
1 | // Encoding written by hand for specific ABI because all general libs are too slow for big calls
2 | // Check https://gist.github.com/vbrvk/f9faf997966a553f2cc746213b5b6304 for more details on comparison
3 |
4 | // Helper function to pad a hex string to 64 characters (32 bytes)
5 | function padTo32Bytes(hexStr: string): string {
6 | const hex = hexStr.startsWith('0x') ? hexStr.slice(2) : hexStr
7 |
8 | return hex.padStart(64, '0')
9 | }
10 |
11 | const _0x40 = padTo32Bytes('0x40')
12 | const _0x20 = padTo32Bytes('0x20')
13 |
14 | // Helper function to encode a uint256
15 | function encodeUint256(value: number | bigint): string {
16 | return value.toString(16).padStart(64, '0')
17 | }
18 |
19 | // Helper function to encode an address
20 | function encodeAddress(address: string): string {
21 | return address.slice(2).padStart(64, '0')
22 | }
23 |
24 | // Helper function to calculate the padded length of bytes
25 | function getPaddedLength(bytesData: string): number {
26 | const length = bytesData.length / 2 // Convert hex length to byte length
27 |
28 | return 32 * Math.ceil(length / 32) // Round up to nearest 32 bytes
29 | }
30 |
31 | type Call = {to: string; data: string}
32 |
33 | export function encodeCalls(calls: Array | ReadonlyArray, limit?: number | bigint): string {
34 | const withLimit = limit != undefined
35 | // 1. First parameter is a dynamic array, so we start with its offset (32 bytes)
36 | let result = padTo32Bytes(withLimit ? _0x40 : _0x20) // Offset to the array (64 bytes = 0x40)
37 |
38 | if (withLimit) {
39 | // 2. Second parameter is the limit (uint256)
40 | result += encodeUint256(limit)
41 | }
42 |
43 | // 3. Length of the array
44 | result += encodeUint256(calls.length)
45 |
46 | // 4. Calculate offsets for each tuple in the array
47 | // The first tuple starts at offset 0x60 (96 bytes)
48 | let currentOffset = 32 * calls.length
49 |
50 | for (let i = 0; i < calls.length; i++) {
51 | result += encodeUint256(currentOffset)
52 |
53 | // Calculate the size of this tuple for the next offset
54 | // Each tuple has: address (32) + offset (32) + length (32) + data (padded)
55 | if (i < calls.length - 1) {
56 | const bytesData = calls[i].data.slice(2)
57 | const paddedDataLength = getPaddedLength(bytesData)
58 |
59 | // Move to the next tuple
60 | currentOffset += 32 + 32 + 32 + paddedDataLength
61 | }
62 | }
63 |
64 | // 5. Encode each tuple
65 | for (const call of calls) {
66 | // 5.1. Encode the address
67 | result += encodeAddress(call.to)
68 |
69 | // 5.2. Encode the offset to the bytes data (always 0x40 = 64 bytes from the start of the tuple)
70 | result += _0x40
71 |
72 | // 5.3. Encode the bytes data length
73 | const bytesData = call.data.slice(2)
74 | const bytesLength = bytesData.length / 2
75 | result += encodeUint256(bytesLength)
76 |
77 | // 5.4. Add the actual bytes data
78 | result += bytesData
79 |
80 | // 5.5. Add padding to align to 32 bytes
81 | const padding = (32 - (bytesLength % 32)) % 32
82 | result += '00'.repeat(padding)
83 | }
84 |
85 | return result
86 | }
87 |
--------------------------------------------------------------------------------
/src/gas-limit.service.ts:
--------------------------------------------------------------------------------
1 | import {GasLimitParams} from './model/multicall.model'
2 | import {DEFAULT_GAS_LIMIT, selectors} from './multicall.const'
3 | import {ProviderConnector} from './connector'
4 |
5 | export const defaultGasLimitParams: Pick = {
6 | gasBuffer: 3000000,
7 | maxGasLimit: 150000000
8 | }
9 |
10 | export class GasLimitService {
11 | constructor(private connector: ProviderConnector, private multiCallAddress: string) {}
12 |
13 | async calculateGasLimit(gasLimitParams: Partial = defaultGasLimitParams): Promise {
14 | const gasBuffer = gasLimitParams.gasBuffer || defaultGasLimitParams.gasBuffer
15 |
16 | const gasLimit = gasLimitParams.gasLimit ? gasLimitParams.gasLimit : await this.fetchGasLimit()
17 |
18 | const maxGasLimit = gasLimitParams.maxGasLimit || defaultGasLimitParams.maxGasLimit
19 |
20 | const minGasLimit = Math.min(gasLimit, maxGasLimit)
21 |
22 | return minGasLimit - gasBuffer
23 | }
24 |
25 | private async fetchGasLimit(): Promise {
26 | try {
27 | const callData = selectors.gaslimit
28 | const res = await this.connector.ethCall(this.multiCallAddress, callData)
29 |
30 | return parseInt(res, 16)
31 | } catch (e) {
32 | // eslint-disable-next-line no-console
33 | console.log('cannot get gas left: ', e?.toString())
34 |
35 | return DEFAULT_GAS_LIMIT
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './connector'
2 | export * from './model'
3 | export * from './gas-limit.service'
4 | export * from './multicall.const'
5 | export * from './multicall.helpers'
6 | export * from './multicall.service'
7 |
--------------------------------------------------------------------------------
/src/model/index.ts:
--------------------------------------------------------------------------------
1 | export * from './multicall.model'
2 |
--------------------------------------------------------------------------------
/src/model/multicall.model.ts:
--------------------------------------------------------------------------------
1 | export interface MultiCallRequest {
2 | to: string
3 | data: string
4 | }
5 |
6 | export interface MultiCallRequestWithGas extends MultiCallRequest {
7 | gas: number
8 | }
9 |
10 | export interface MultiCallItemWithGas extends MultiCallRequestWithGas {
11 | index: number
12 | }
13 |
14 | export interface MultiCallItemWithGasResult extends MultiCallItemWithGas {
15 | result: string
16 | }
17 |
18 | export type MultiCallChunk = MultiCallRequest[]
19 |
20 | export type MultiCallChunks = MultiCallChunk[]
21 |
22 | export type MultiCallWithGasChunk = MultiCallItemWithGas[]
23 |
24 | export type MultiCallWithGasChunks = MultiCallWithGasChunk[]
25 |
26 | export interface MultiCallWithGasContractResponse {
27 | results: string[]
28 | lastSuccessIndex: string
29 | }
30 |
31 | export interface MultiCallExecutionResult {
32 | responses: MultiCallItemWithGasResult[]
33 | notExecutedChunks: MultiCallItemWithGas[]
34 | }
35 |
36 | export interface MultiCallParams {
37 | chunkSize: number
38 | retriesLimit: number
39 | blockNumber: string | number
40 | }
41 |
42 | export interface MultiCallWithGasParams {
43 | maxChunkSize: number
44 | retriesLimit: number
45 | blockNumber: string | number
46 | gasBuffer: number
47 | }
48 |
49 | export interface GasLimitParams {
50 | gasBuffer: number
51 | gasLimit: number
52 | maxGasLimit: number
53 | }
54 |
--------------------------------------------------------------------------------
/src/multicall.const.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_GAS_LIMIT = 25000000
2 |
3 | // Ethereum mainnet
4 | export const CHAIN_1_MULTICALL_ADDRESS = '0x8d035edd8e09c3283463dade67cc0d49d6868063'
5 | // Ethereum Kovan Testnet
6 | export const CHAIN_42_MULTICALL_ADDRESS = '0x4760676f65dd07d29889DaAD1E4D08bcE6bA9b14'
7 | // Binance Smart Chain mainnet
8 | export const CHAIN_56_MULTICALL_ADDRESS = '0x804708de7af615085203fa2b18eae59c5738e2a9'
9 | // Polygon mainnet
10 | export const CHAIN_137_MULTICALL_ADDRESS = '0x0196e8a9455a90d392b46df8560c867e7df40b34'
11 |
12 | export const selectors = {
13 | multicallWithGasLimitation: '0xd699fe15',
14 | multicallWithGas: '0x489dba16',
15 | multicall: '0xcaa5c23f',
16 | gaslimit: '0x2a722839',
17 | gasLeft: '0x2ddb301b'
18 | } as const
19 |
--------------------------------------------------------------------------------
/src/multicall.helpers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MultiCallWithGasChunks,
3 | MultiCallExecutionResult,
4 | MultiCallItemWithGas,
5 | MultiCallRequestWithGas,
6 | MultiCallRequest,
7 | MultiCallChunks
8 | } from './model/multicall.model'
9 |
10 | export function requestsToMulticallItems(requests: MultiCallRequestWithGas[]): MultiCallItemWithGas[] {
11 | return requests.map((request, index) => {
12 | return {
13 | ...request,
14 | index
15 | }
16 | })
17 | }
18 |
19 | export function splitRequestsByChunksWithGas(
20 | requests: MultiCallItemWithGas[],
21 | gasLimit: number,
22 | maxChunkSize: number
23 | ): MultiCallWithGasChunks {
24 | let currentChunkIndex = 0
25 | let gasUsedByCurrentChunk = 0
26 |
27 | return requests.reduce((chunks, val) => {
28 | if (!chunks[currentChunkIndex]) {
29 | chunks[currentChunkIndex] = []
30 | }
31 |
32 | const currentChunk = chunks[currentChunkIndex]
33 |
34 | const notFitIntoCurrentChunkGasLimit = gasUsedByCurrentChunk + val.gas >= gasLimit
35 | const isChunkSizeExceeded = currentChunk.length === maxChunkSize
36 | const shouldSwitchToNextChunk = notFitIntoCurrentChunkGasLimit || isChunkSizeExceeded
37 |
38 | if (shouldSwitchToNextChunk) {
39 | if (chunks[currentChunkIndex].length === 0) {
40 | throw new Error('one of the first calls in a chunk not fit into gas limit')
41 | }
42 |
43 | currentChunkIndex++
44 | gasUsedByCurrentChunk = 0
45 | } else {
46 | gasUsedByCurrentChunk += val.gas
47 | }
48 |
49 | currentChunk.push(val)
50 |
51 | return chunks
52 | }, [] as MultiCallWithGasChunks)
53 | }
54 |
55 | export function splitRequestsByChunks(requests: MultiCallRequest[], chunkSize: number): MultiCallChunks {
56 | let currentChunkIndex = 0
57 |
58 | return requests.reduce((chunks, request) => {
59 | if (currentChunkIndex === chunkSize) {
60 | currentChunkIndex++
61 | }
62 |
63 | if (!chunks[currentChunkIndex]) {
64 | chunks[currentChunkIndex] = []
65 | }
66 |
67 | chunks[currentChunkIndex].push(request)
68 |
69 | return chunks
70 | }, [] as MultiCallChunks)
71 | }
72 |
73 | export function concatExecutionResults(results: MultiCallExecutionResult[]): MultiCallExecutionResult {
74 | return results.reduce(
75 | (acc, val) => {
76 | return {
77 | responses: acc.responses.concat(val.responses),
78 | notExecutedChunks: acc.notExecutedChunks.concat(val.notExecutedChunks)
79 | }
80 | },
81 | {
82 | responses: [],
83 | notExecutedChunks: []
84 | }
85 | )
86 | }
87 |
88 | export async function callWithRetries(retriesLimit: number, fn: () => Promise): Promise {
89 | let retriesLeft = retriesLimit
90 |
91 | let err: unknown
92 | while (retriesLeft > 0) {
93 | try {
94 | return await fn()
95 | } catch (error) {
96 | retriesLeft -= 1
97 | err = error
98 | }
99 | }
100 |
101 | throw new Error('multicall: retries exceeded', {cause: err})
102 | }
103 |
--------------------------------------------------------------------------------
/src/multicall.service.test.ts:
--------------------------------------------------------------------------------
1 | import {anything, capture, instance, mock, when, verify} from 'ts-mockito'
2 | import {Interface} from 'ethers'
3 | import {ProviderConnector} from './connector'
4 | import {defaultParamsWithGas, MultiCallService} from './multicall.service'
5 | import {MultiCallRequest, MultiCallRequestWithGas} from './model'
6 |
7 | import ABI from './abi/MultiCall.abi.json'
8 | import {CHAIN_1_MULTICALL_ADDRESS} from './multicall.const'
9 |
10 | const iface = new Interface(ABI)
11 | describe('MultiCallService', () => {
12 | const multiCallAddress = CHAIN_1_MULTICALL_ADDRESS
13 |
14 | let multiCallService: MultiCallService
15 | let connector: ProviderConnector
16 |
17 | beforeEach(() => {
18 | connector = mock()
19 | multiCallService = new MultiCallService(instance(connector), multiCallAddress)
20 | })
21 |
22 | describe('callByGasLimit() full successful multicall', () => {
23 | it('test', async () => {
24 | const gasLimit = 410
25 | const requests: MultiCallRequestWithGas[] = [
26 | {to: '0x0000000000000000000000000000000000000001', data: '0x01', gas: 100},
27 | {to: '0x0000000000000000000000000000000000000002', data: '0x02', gas: 100},
28 | {to: '0x0000000000000000000000000000000000000003', data: '0x03', gas: 100},
29 | {to: '0x0000000000000000000000000000000000000004', data: '0x04', gas: 100},
30 | {to: '0x0000000000000000000000000000000000000005', data: '0x05', gas: 100},
31 | {to: '0x0000000000000000000000000000000000000006', data: '0x06', gas: 100},
32 | {to: '0x0000000000000000000000000000000000000007', data: '0x07', gas: 100},
33 | {to: '0x0000000000000000000000000000000000000008', data: '0x08', gas: 100},
34 | {to: '0x0000000000000000000000000000000000000009', data: '0x09', gas: 100},
35 | {to: '0x0000000000000000000000000000000000000010', data: '0x10', gas: 100}
36 | ]
37 |
38 | const maxChunkSize = 3
39 | when(connector.ethCall(anything(), anything(), anything())).thenCall((_to: unknown, callData: string) => {
40 | const {calls} = iface.decodeFunctionData('multicallWithGasLimitation', callData)
41 |
42 | return iface.encodeFunctionResult('multicallWithGasLimitation', [
43 | requests.filter((r) => calls.find((c: MultiCallRequest) => r.to === c.to)).map((r) => r.to),
44 | requests.length
45 | ])
46 | })
47 |
48 | const result = await multiCallService.callByGasLimit(requests, gasLimit, {
49 | ...defaultParamsWithGas,
50 | maxChunkSize
51 | })
52 |
53 | const ethCalls = capture(connector.ethCall)
54 |
55 | expect(result).toEqual(requests.map((r) => r.to))
56 | verify(connector.ethCall(CHAIN_1_MULTICALL_ADDRESS, anything(), anything())).times(3)
57 | expect(ethCalls.first()).toMatchSnapshot()
58 | expect(ethCalls.second()).toMatchSnapshot()
59 | expect(ethCalls.third()).toMatchSnapshot()
60 | })
61 | })
62 |
63 | describe('callByGasLimit() multicall with errors', () => {
64 | function getLastSuccessIndex(requests: MultiCallRequest[]): number {
65 | // lastSuccessIndex = 1, it means that only 01, 02 responses were successful
66 | if (requests.map((i) => i.data).join('') === '0x010x020x03') {
67 | return 1
68 | }
69 |
70 | // lastSuccessIndex = 1, it means that only 04, 05 responses were successful
71 | if (requests.map((i) => i.data).join('') === '0x040x050x06') {
72 | return 1
73 | }
74 |
75 | // lastSuccessIndex = 0, it means that only 7 responses were successful
76 | if (requests.map((i) => i.data).join('') === '0x070x080x09') {
77 | return 0
78 | }
79 |
80 | return requests.length - 1
81 | }
82 |
83 | it('test', async () => {
84 | const gasLimit = 300
85 | const requests: MultiCallRequestWithGas[] = [
86 | {to: '0x0000000000000000000000000000000000000001', data: '0x01', gas: 100},
87 | {to: '0x0000000000000000000000000000000000000002', data: '0x02', gas: 100},
88 | {to: '0x0000000000000000000000000000000000000003', data: '0x03', gas: 100},
89 | {to: '0x0000000000000000000000000000000000000004', data: '0x04', gas: 100},
90 | {to: '0x0000000000000000000000000000000000000005', data: '0x05', gas: 100},
91 | {to: '0x0000000000000000000000000000000000000006', data: '0x06', gas: 100},
92 | {to: '0x0000000000000000000000000000000000000007', data: '0x07', gas: 100},
93 | {to: '0x0000000000000000000000000000000000000008', data: '0x08', gas: 100},
94 | {to: '0x0000000000000000000000000000000000000009', data: '0x09', gas: 100},
95 | {to: '0x0000000000000000000000000000000000000010', data: '0x10', gas: 100}
96 | ]
97 | const expectedRequestsByChunks = [
98 | [
99 | {to: '0x0000000000000000000000000000000000000001', data: '0x01'},
100 | {to: '0x0000000000000000000000000000000000000002', data: '0x02'},
101 | {to: '0x0000000000000000000000000000000000000003', data: '0x03'}
102 | ],
103 | [
104 | {to: '0x0000000000000000000000000000000000000004', data: '0x04'},
105 | {to: '0x0000000000000000000000000000000000000005', data: '0x05'},
106 | {to: '0x0000000000000000000000000000000000000006', data: '0x06'}
107 | ],
108 | [
109 | {to: '0x0000000000000000000000000000000000000007', data: '0x07'},
110 | {to: '0x0000000000000000000000000000000000000008', data: '0x08'},
111 | {to: '0x0000000000000000000000000000000000000009', data: '0x09'}
112 | ],
113 | [{to: '0x0000000000000000000000000000000000000010', data: '0x10'}],
114 | [
115 | {to: '0x0000000000000000000000000000000000000003', data: '0x03'},
116 | {to: '0x0000000000000000000000000000000000000006', data: '0x06'}
117 | ],
118 | [
119 | {to: '0x0000000000000000000000000000000000000008', data: '0x08'},
120 | {to: '0x0000000000000000000000000000000000000009', data: '0x09'}
121 | ]
122 | ]
123 |
124 | const maxChunkSize = 3
125 | when(connector.ethCall(anything(), anything(), anything())).thenCall((_to: unknown, callData: string) => {
126 | const {calls} = iface.decodeFunctionData('multicallWithGasLimitation', callData)
127 |
128 | return iface.encodeFunctionResult('multicallWithGasLimitation', [
129 | requests.filter((r) => calls.find((c: MultiCallRequest) => r.to === c.to)).map((r) => r.to),
130 | getLastSuccessIndex(calls)
131 | ])
132 | })
133 |
134 | const result = await multiCallService.callByGasLimit(requests, gasLimit, {
135 | ...defaultParamsWithGas,
136 | maxChunkSize
137 | })
138 |
139 | const decodeInput = (i: number): MultiCallRequest[] => {
140 | const [res] = iface.decodeFunctionData('multicallWithGasLimitation', ethCalls.byCallIndex(i)[1])
141 |
142 | return res.map(([to, data]: [string, string]) => ({to, data}))
143 | }
144 |
145 | const ethCalls = capture(connector.ethCall)
146 |
147 | expect(result).toEqual(requests.map((r) => r.to))
148 | verify(connector.ethCall(CHAIN_1_MULTICALL_ADDRESS, anything(), anything())).times(6)
149 | expect(decodeInput(0)).toEqual(expectedRequestsByChunks[0])
150 | expect(decodeInput(1)).toEqual(expectedRequestsByChunks[1])
151 | expect(decodeInput(2)).toEqual(expectedRequestsByChunks[2])
152 | expect(decodeInput(3)).toEqual(expectedRequestsByChunks[3])
153 | expect(decodeInput(4)).toEqual(expectedRequestsByChunks[4])
154 | expect(decodeInput(5)).toEqual(expectedRequestsByChunks[5])
155 | })
156 | })
157 | })
158 |
--------------------------------------------------------------------------------
/src/multicall.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MultiCallWithGasChunks,
3 | MultiCallExecutionResult,
4 | MultiCallItemWithGas,
5 | MultiCallItemWithGasResult,
6 | MultiCallWithGasParams,
7 | MultiCallRequestWithGas,
8 | MultiCallWithGasContractResponse,
9 | MultiCallParams,
10 | MultiCallRequest,
11 | MultiCallChunk
12 | } from './model/multicall.model'
13 | import {
14 | callWithRetries,
15 | concatExecutionResults,
16 | requestsToMulticallItems,
17 | splitRequestsByChunks,
18 | splitRequestsByChunksWithGas
19 | } from './multicall.helpers'
20 | import {defaultGasLimitParams} from './gas-limit.service'
21 | import {ProviderConnector} from './connector'
22 | import {encodeCalls} from './encode/encode'
23 | import {decodeOutputForMulticall, decodeOutputForMulticallWithGasLimitation} from './decode/decode'
24 | import {selectors} from './multicall.const'
25 |
26 | export const defaultParamsWithGas: MultiCallWithGasParams = {
27 | maxChunkSize: 500,
28 | retriesLimit: 3,
29 | blockNumber: 'latest',
30 | gasBuffer: defaultGasLimitParams.gasBuffer
31 | }
32 |
33 | export const defaultParamsByChunkSize: MultiCallParams = {
34 | chunkSize: 100,
35 | retriesLimit: 3,
36 | blockNumber: 'latest'
37 | }
38 |
39 | export class MultiCallService {
40 | constructor(private connector: ProviderConnector, private multiCallAddress: string) {}
41 |
42 | async callByGasLimit(
43 | requests: MultiCallRequestWithGas[],
44 | gasLimit: number,
45 | params: MultiCallWithGasParams = defaultParamsWithGas
46 | ): Promise {
47 | const multiCallItems = requestsToMulticallItems(requests)
48 |
49 | const results = await this.doMultiCall([], multiCallItems, params, gasLimit)
50 |
51 | return results
52 | .sort((a, b) => {
53 | return a.index - b.index
54 | })
55 | .map((item) => item.result)
56 | }
57 |
58 | async callByChunks(
59 | requests: MultiCallRequest[],
60 | params: MultiCallParams = defaultParamsByChunkSize
61 | ): Promise {
62 | const chunks = splitRequestsByChunks(requests, params.chunkSize)
63 |
64 | const contractCalls = chunks.map((chunk) => {
65 | return callWithRetries(params.retriesLimit, () => this.callSimpleMultiCall(chunk, params))
66 | })
67 |
68 | const results = await Promise.all(contractCalls)
69 |
70 | return results.flat()
71 | }
72 |
73 | private async doMultiCall(
74 | previousResponses: MultiCallItemWithGasResult[],
75 | requests: MultiCallItemWithGas[],
76 | params: MultiCallWithGasParams,
77 | gasLimit: number
78 | ): Promise {
79 | const chunks = splitRequestsByChunksWithGas(requests, gasLimit, params.maxChunkSize)
80 |
81 | const {responses, notExecutedChunks} = await this.executeRequests(chunks, params)
82 |
83 | const newMaxChunkSize = Math.floor(params.maxChunkSize / 2)
84 |
85 | const newResults = previousResponses.concat(responses)
86 |
87 | if (notExecutedChunks.length === 0) {
88 | return newResults
89 | }
90 |
91 | params.maxChunkSize = newMaxChunkSize
92 |
93 | if (newMaxChunkSize === 0) {
94 | throw new Error('multicall: exceeded chunks split')
95 | }
96 |
97 | return this.doMultiCall(newResults, notExecutedChunks, params, gasLimit)
98 | }
99 |
100 | private async executeRequests(
101 | chunks: MultiCallWithGasChunks,
102 | params: MultiCallWithGasParams
103 | ): Promise {
104 | const chunksResults = await Promise.all(
105 | chunks.map((chunk) => {
106 | return callWithRetries(params.retriesLimit, () =>
107 | this.callWithGasLimitationMultiCall(chunk, params)
108 | )
109 | })
110 | )
111 |
112 | const results: MultiCallExecutionResult[] = chunksResults.map((result, index) => {
113 | const chunk = chunks[index]
114 | const lastSuccessIndex = +result.lastSuccessIndex + 1
115 |
116 | const responses = chunk.map((item, i) => {
117 | return {
118 | ...item,
119 | result: result.results[i]
120 | }
121 | })
122 |
123 | return {
124 | responses: responses.slice(0, lastSuccessIndex),
125 | notExecutedChunks: chunk.slice(lastSuccessIndex, chunk.length)
126 | }
127 | })
128 |
129 | return concatExecutionResults(results)
130 | }
131 |
132 | private async callWithGasLimitationMultiCall(
133 | chunk: MultiCallChunk,
134 | params: MultiCallWithGasParams
135 | ): Promise {
136 | const callData = selectors.multicallWithGasLimitation + encodeCalls(chunk, params.gasBuffer)
137 | const response = await this.callContractMultiCall(callData, params.blockNumber)
138 |
139 | const {results, lastSuccessIndex} = decodeOutputForMulticallWithGasLimitation(response)
140 |
141 | return {results, lastSuccessIndex: lastSuccessIndex.toString()}
142 | }
143 |
144 | private async callSimpleMultiCall(chunk: MultiCallRequest[], params: MultiCallParams): Promise {
145 | const callData = selectors.multicall + encodeCalls(chunk)
146 | const response = await this.callContractMultiCall(callData, params.blockNumber)
147 |
148 | return decodeOutputForMulticall(response)
149 | }
150 |
151 | private async callContractMultiCall(callData: string, blockNumber: string | number): Promise {
152 | return await this.connector.ethCall(this.multiCallAddress, callData, blockNumber.toString())
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/test/ERC20.abi.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 | ]
223 |
--------------------------------------------------------------------------------
/test/call-by-chunks.snapshot.ts:
--------------------------------------------------------------------------------
1 | export const CALL_BY_CHUNKS_SNAPSHOT = '0x000000000000000000000000000000000000000000000000000000000000002' +
2 | '0000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000' +
3 | '0000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001c00' +
4 | '0000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000' +
5 | '0000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000280000' +
6 | '00000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000' +
7 | '0000000000000000000000000300000000000000000000000000000000000000000000000000000000000000034000000' +
8 | '0000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000' +
9 | '000000000000000000000003c000000000000000000000000000000000000000000000000000000000000004000000000' +
10 | '0000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000' +
11 | '0000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000' +
12 | '0000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000' +
13 | '0000000000000001e42950000000000000000000000000000000000000000000000000000000000000002000000000000' +
14 | '000000000000000000000000000000000000000000000068e778000000000000000000000000000000000000000000000' +
15 | '00000000000000000020000000000000000000000000000000000000000000ac544c80d308fab5dc00000000000000000' +
16 | '0000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000032b94' +
17 | 'f4138297999ba7bb670000000000000000000000000000000000000000000000000000000000000020000000000000000' +
18 | '00000000000000000000000000000000000000000068e7780000000000000000000000000000000000000000000000000' +
19 | '00000000000000200000000000000000000000000000000000000000000000000000009b1c3d24a000000000000000000' +
20 | '000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000139f' +
21 | 'd79cc4de4858d500000000000000000000000000000000000000000000000000000000000000200000000000000000000' +
22 | '000000000000000000000000000356322fc0e7122bb4f0000000000000000000000000000000000000000000000000000' +
23 | '00000000002000000000000000000000000000000000000000000001a784379d99db42000000000000000000000000000' +
24 | '00000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000022c7' +
25 | 'f8a871a800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000' +
26 | '00000000000000000002f2638310ba0e8d9000000';
27 |
--------------------------------------------------------------------------------
/test/call-by-gas-limit.snapshot.ts:
--------------------------------------------------------------------------------
1 | export const CALL_BY_GAS_LIMIT_SNAPSHOT = '0x000000000000000000000000000000000000000000000000000000000000004' +
2 | '0000000000000000000000000000000000000000000000000000000000000000b00000000000000000000000000000000000000' +
3 | '0000000000000000000000000c00000000000000000000000000000000000000000000000000000000000001800000000000000' +
4 | '0000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000' +
5 | '0000000002000000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000' +
6 | '000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000002c000' +
7 | '0000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000' +
8 | '0000000000000000000034000000000000000000000000000000000000000000000000000000000000003800000000000000000' +
9 | '0000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000' +
10 | '0000004000000000000000000000000000000000000000000000000000000000000000440000000000000000000000000000000' +
11 | '0000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000' +
12 | '0000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000' +
13 | '00000000000001e4295000000000000000000000000000000000000000000000000000000000000000200000000000000000000' +
14 | '0000000000000000000000000000000000000068e77800000000000000000000000000000000000000000000000000000000000' +
15 | '000020000000000000000000000000000000000000000000ac544c80d308fab5dc0000000000000000000000000000000000000' +
16 | '00000000000000000000000000000200000000000000000000000000000000000000000032b94f4138297999ba7bb6700000000' +
17 | '0000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000' +
18 | '000000000068e778000000000000000000000000000000000000000000000000000000000000000200000000000000000000000' +
19 | '000000000000000000000000000000009b1c3d24a00000000000000000000000000000000000000000000000000000000000000' +
20 | '0200000000000000000000000000000000000000000000000139fd79cc4de4858d5000000000000000000000000000000000000' +
21 | '00000000000000000000000000200000000000000000000000000000000000000000000000356322fc0e7122bb4f00000000000' +
22 | '0000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000001a78437' +
23 | '9d99db4200000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000' +
24 | '000000000000000000000000022c7f8a871a8000000000000000000000000000000000000000000000000000000000000000020' +
25 | '0000000000000000000000000000000000000000002f2638310ba0e8d9000000';
26 |
--------------------------------------------------------------------------------
/test/multicall.service.test.ts:
--------------------------------------------------------------------------------
1 | import {CHAIN_1_MULTICALL_ADDRESS, IWeb3CallInfo, ProviderConnector, Web3ProviderConnector} from '../src';
2 | import ERC20ABI from './ERC20.abi.json';
3 | import { GasLimitService } from '../src/gas-limit.service';
4 | import { MultiCallService } from '../src/multicall.service';
5 | import { CALL_BY_GAS_LIMIT_SNAPSHOT } from './call-by-gas-limit.snapshot';
6 | import { CALL_BY_CHUNKS_SNAPSHOT } from './call-by-chunks.snapshot';
7 |
8 | const expectedBalances = [
9 | '0',
10 | '31730000',
11 | '110000000',
12 | '208333330993600000000000000',
13 | '981142252358335672999197543',
14 | '110000000',
15 | '666193700000',
16 | '362005984341861619925',
17 | '984820985004527106895',
18 | '2000000000000000000000000',
19 | '9790020000000000',
20 | '57000000000000000000000000'
21 | ];
22 |
23 | describe('MultiCallService', () => {
24 | let provider: ProviderConnector;
25 | let gasLimitService: GasLimitService;
26 | let multiCallService: MultiCallService;
27 |
28 | const user = '0x1111111111111111111111111111111111111111';
29 | const multiCallAddress = CHAIN_1_MULTICALL_ADDRESS;
30 |
31 | const tokens = [
32 | '0x6b175474e89094c44da98b954eedeac495271d0f',
33 | '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
34 | '0xdac17f958d2ee523a2206206994597c13d831ec7',
35 | '0xb62132e35a6c13ee1ee0f84dc5d40bad8d815206',
36 | '0x1b793e49237758dbd8b752afc9eb4b329d5da016',
37 | '0xdac17f958d2ee523a2206206994597c13d831ec7',
38 | '0xa4fb385820a9eef842a419e08f8540fd7d1bf6e8',
39 | '0x77fba179c79de5b7653f68b5039af940ada60ce0',
40 | '0xbb0a009ba1eb20c5062c790432f080f6597662af',
41 | '0xdD0020B1D5Ba47A54E2EB16800D73Beb6546f91A',
42 | '0xf14922001a2fb8541a433905437ae954419c2439',
43 | '0x264dc2dedcdcbb897561a57cba5085ca416fb7b4'
44 | ];
45 |
46 | beforeEach(() => {
47 | provider = new Web3ProviderConnector({
48 | eth: {
49 | call: async (_callInfo: IWeb3CallInfo, _blockNumber: string | number): Promise => {
50 | return 'test_call_result'
51 | }
52 | }
53 | });
54 |
55 | gasLimitService = new GasLimitService(provider, multiCallAddress);
56 | multiCallService = new MultiCallService(provider, multiCallAddress);
57 | });
58 |
59 | describe('callByGasLimit()', () => {
60 | it('Should load balances for the address', async () => {
61 | jest.spyOn(provider, 'ethCall').mockImplementation(() => {
62 | return Promise.resolve(CALL_BY_GAS_LIMIT_SNAPSHOT);
63 | });
64 |
65 | const callData = tokens.map((address) => ({
66 | to: address,
67 | data: provider.contractEncodeABI(ERC20ABI, address, 'balanceOf', [user]),
68 | gas: 30000
69 | }));
70 |
71 | const gasLimit = await gasLimitService.calculateGasLimit({
72 | gasLimit: 12_000_000
73 | });
74 |
75 | const res = await multiCallService.callByGasLimit(
76 | callData,
77 | gasLimit
78 | );
79 |
80 | const balances = res.map((x) => {
81 | return provider.decodeABIParameter('uint256', x).toString()
82 | });
83 |
84 | expect(balances).toEqual(expectedBalances);
85 | });
86 | });
87 |
88 | describe('callByChunks()', () => {
89 | it('Should load balances for the address', async () => {
90 | jest.spyOn(provider, 'ethCall').mockImplementation(() => {
91 | return Promise.resolve(CALL_BY_CHUNKS_SNAPSHOT);
92 | });
93 |
94 | const callData = tokens.map((address) => ({
95 | to: address,
96 | data: provider.contractEncodeABI(ERC20ABI, address, 'balanceOf', [user])
97 | }));
98 |
99 | const res = await multiCallService.callByChunks(
100 | callData
101 | );
102 |
103 | const balances = res.map((x) => {
104 | return provider.decodeABIParameter('uint256', x).toString()
105 | });
106 |
107 | expect(balances).toEqual(expectedBalances);
108 | });
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@1inch/tsconfig",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "baseUrl": "./",
6 | "typeRoots": [
7 | "node_modules/@types",
8 | "src/types"
9 | ]
10 | },
11 | "include": [
12 | "src/**/*"
13 | ],
14 | "exclude": [
15 | "dist/**/*",
16 | "tests/**/*"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------