├── .nvmrc ├── .yarnrc ├── .gitignore ├── .prettierrc ├── tests ├── setupJest.js ├── __mocks__ │ └── ws.js ├── http.test.js ├── websocket.test.js ├── shared.js ├── aggregate.test.js └── watcher.test.js ├── src ├── index.js ├── addresses.json ├── aggregate.js ├── helpers.js └── watcher.js ├── jest.config.js ├── .circleci └── config.yml ├── .babelrc.js ├── .vscode └── launch.json ├── LICENSE ├── examples ├── umd-example.html ├── mjs-example.html └── es-example.js ├── package.json ├── types └── multicall.d.ts ├── rollup.config.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | version-tag-prefix "v" 2 | version-git-message "v%s" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | package-lock.json 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /tests/setupJest.js: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | 3 | global.fetch = fetch; 4 | jest.setMock('cross-fetch', fetch); 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import aggregate from './aggregate'; 2 | import createWatcher from './watcher'; 3 | 4 | export { aggregate, createWatcher }; 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | bail: true, 4 | rootDir: './', 5 | roots: ['src', 'tests'], 6 | testPathIgnorePatterns: ['/node_modules/'], 7 | setupFilesAfterEnv: ['/tests/setupJest.js'], 8 | testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'] 9 | }; 10 | -------------------------------------------------------------------------------- /tests/__mocks__/ws.js: -------------------------------------------------------------------------------- 1 | import { WebSocket } from 'mock-socket'; 2 | 3 | WebSocket.prototype.on = function(event, cb) { 4 | this[`on${event}`] = cb.bind(this); 5 | }; 6 | 7 | WebSocket.prototype.removeListener = function(event) { 8 | this[`on${event}`] = null; 9 | }; 10 | 11 | WebSocket.prototype.removeAllListeners = function() { 12 | this.onopen = null; 13 | this.onclose = null; 14 | this.onerror = null; 15 | this.onmessage = null; 16 | }; 17 | 18 | export { WebSocket as default }; 19 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:12 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | - dependency-cache-{{ checksum "package.json" }} 11 | - run: 12 | name: Install dependencies 13 | command: | 14 | yarn install 15 | - save_cache: 16 | paths: 17 | - node_modules 18 | key: dependency-cache-{{ checksum "package.json" }} 19 | - run: yarn test 20 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV } = process.env; 2 | 3 | module.exports = { 4 | presets: [ 5 | [ 6 | '@babel/env', 7 | { 8 | targets: { 9 | browsers: ['ie >= 11'] 10 | }, 11 | exclude: ['transform-async-to-generator', 'transform-regenerator'], 12 | modules: false, 13 | loose: true 14 | } 15 | ] 16 | ], 17 | plugins: [ 18 | // don't use `loose` mode here - need to copy symbols when spreading 19 | NODE_ENV === 'test' && '@babel/transform-modules-commonjs' 20 | ].filter(Boolean) 21 | }; 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9229, 9 | "stopOnEntry": false 10 | }, 11 | { 12 | "name": "Debug Jest Tests", 13 | "type": "node", 14 | "request": "launch", 15 | "runtimeArgs": [ 16 | "--inspect-brk", 17 | "${workspaceRoot}/node_modules/.bin/jest", 18 | "--runInBand" 19 | ], 20 | "console": "integratedTerminal", 21 | "internalConsoleOptions": "neverOpen", 22 | "port": 9229, 23 | "stopOnEntry": false 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/addresses.json: -------------------------------------------------------------------------------- 1 | { 2 | "mainnet": { 3 | "multicall": "0xeefba1e63905ef1d7acba5a8513c70307c1ce441", 4 | "rpcUrl": "https://mainnet.infura.io" 5 | }, 6 | "kovan": { 7 | "multicall": "0x2cc8688c5f75e365aaeeb4ea8d6a480405a48d2a", 8 | "rpcUrl": "https://kovan.infura.io" 9 | }, 10 | "rinkeby": { 11 | "multicall": "0x42ad527de7d4e9d9d011ac45b31d8551f8fe9821", 12 | "rpcUrl": "https://rinkeby.infura.io" 13 | }, 14 | "goerli": { 15 | "multicall": "0x77dca2c955b15e9de4dbbcf1246b4b85b651e50e", 16 | "rpcUrl": "https://rpc.slock.it/goerli" 17 | }, 18 | "xdai": { 19 | "multicall": "0xb5b692a88bdfc81ca69dcb1d924f59f0413a602a", 20 | "rpcUrl": "https://dai.poa.network" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Maker Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/http.test.js: -------------------------------------------------------------------------------- 1 | import { createWatcher } from '../src'; 2 | import { calls, mockedResults } from './shared'; 3 | 4 | const config = { 5 | rpcUrl: 'https://mocked', 6 | multicallAddress: '0x1234567890123456789012345678901234567890' 7 | }; 8 | 9 | describe('http', () => { 10 | beforeEach(() => fetch.resetMocks()); 11 | 12 | test('requests using http endpoint', async () => { 13 | const results = {}; 14 | const watcher = createWatcher([calls[0], calls[1]], config); 15 | watcher.subscribe(update => results[update.type] = update.value); 16 | watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number); 17 | 18 | fetch.mockResponse(async () => ({ 19 | body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] }) 20 | })); 21 | await watcher.start(); 22 | 23 | expect(results['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333'); 24 | expect(results['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444'); 25 | expect(results['BLOCK_NUMBER']).toEqual(123456789); 26 | 27 | fetch.mockResponse(async () => ({ 28 | body: JSON.stringify({ jsonrpc: '2.0', id: 2, result: mockedResults[1] }) 29 | })); 30 | await watcher.tap(existing => [...existing, calls[2]]); 31 | 32 | expect(results['BALANCE_OF_ETH_WHALE']).toEqual('3333.44445555'); 33 | expect(results['BALANCE_OF_MKR_WHALE']).toEqual('4444.55556666'); 34 | expect(results['PRICE_FEED_ETH_PRICE']).toEqual('1234.56789'); 35 | expect(results['BLOCK_NUMBER']).toEqual(987654321); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/websocket.test.js: -------------------------------------------------------------------------------- 1 | import WS from 'jest-websocket-mock'; 2 | import { createWatcher } from '../src'; 3 | import { calls, mockedResults } from './shared'; 4 | 5 | const config = { 6 | rpcUrl: 'wss://mocked', 7 | multicallAddress: '0x1234567890123456789012345678901234567890' 8 | }; 9 | 10 | describe('websocket', () => { 11 | beforeAll(async () => { 12 | const server = new WS('wss://mocked', { jsonProtocol: true }); 13 | server.on('connection', socket => { 14 | socket.on('message', data => { 15 | const json = JSON.parse(data); 16 | socket.send(JSON.stringify({ jsonrpc: '2.0', id: json.id, result: mockedResults[json.id - 1] })); 17 | }); 18 | }); 19 | }); 20 | 21 | test('requests using websocket endpoint', async () => { 22 | const results = {}; 23 | const watcher = createWatcher([calls[0], calls[1]], config); 24 | watcher.subscribe(update => results[update.type] = update.value); 25 | watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number); 26 | 27 | await watcher.start(); 28 | 29 | expect(results['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333'); 30 | expect(results['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444'); 31 | expect(results['BLOCK_NUMBER']).toEqual(123456789); 32 | 33 | await watcher.tap(existing => [...existing, calls[2]]); 34 | 35 | expect(results['BALANCE_OF_ETH_WHALE']).toEqual('3333.44445555'); 36 | expect(results['BALANCE_OF_MKR_WHALE']).toEqual('4444.55556666'); 37 | expect(results['PRICE_FEED_ETH_PRICE']).toEqual('1234.56789'); 38 | expect(results['BLOCK_NUMBER']).toEqual(987654321); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/shared.js: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { padLeft } from '../src/helpers'; 3 | 4 | const toWei = v => BigNumber(v).shiftedBy(18).toString(); 5 | const fromWei = v => BigNumber(v).shiftedBy(-18).toString(); 6 | const toWord = (value) => padLeft(BigNumber(value).toString(16), 64); 7 | const toWords = (...values) => values.map(toWord).join(''); 8 | 9 | const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; 10 | 11 | export const calls = [ 12 | { 13 | call: ['getEthBalance(address)(uint256)', MOCK_ADDRESS], 14 | returns: [['BALANCE_OF_ETH_WHALE', fromWei]] 15 | }, 16 | { 17 | target: MOCK_ADDRESS, 18 | call: ['balanceOf(address)(uint256)', MOCK_ADDRESS], 19 | returns: [['BALANCE_OF_MKR_WHALE', fromWei]] 20 | }, 21 | { 22 | target: MOCK_ADDRESS, 23 | call: ['peek()(uint256,bool)'], 24 | returns: [['PRICE_FEED_ETH_PRICE', fromWei], ['PRICE_FEED_ETH_SET']] 25 | }, 26 | { 27 | target: MOCK_ADDRESS, 28 | call: ['balanceOf(address)(uint256)', MOCK_ADDRESS], 29 | returns: [ 30 | ['TRANSFORM_RESULT_TO_NULL', v => { 31 | if (fromWei(v) === '2222.33334444') return null; 32 | if (fromWei(v) === '4444.55556666') return BigNumber(1); 33 | }] 34 | ] 35 | } 36 | ]; 37 | 38 | export const mockedResults = [ 39 | '0x' + 40 | toWords( 41 | 123456789, 42 | 64, 43 | 2, 44 | 64, 45 | 128, 46 | 32, 47 | toWei('1111.22223333'), 48 | 32, 49 | toWei('2222.33334444') 50 | ), 51 | '0x' + 52 | toWords( 53 | 987654321, 54 | 64, 55 | 3, 56 | 96, 57 | 160, 58 | 224, 59 | 32, 60 | toWei('3333.44445555'), 61 | 32, 62 | toWei('4444.55556666'), 63 | 64, 64 | toWei('1234.56789'), 65 | 1 66 | ) 67 | ]; 68 | 69 | export function promiseWait(ms) { 70 | return new Promise(resolve => setTimeout(resolve, ms)); 71 | } 72 | -------------------------------------------------------------------------------- /examples/umd-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Multicall Browser Example 6 | 7 | 8 | 65 | 66 | 67 |

68 | 
69 | 
70 | 


--------------------------------------------------------------------------------
/examples/mjs-example.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 |   
 5 |   Multicall Browser Example
 6 |   
 7 |   
64 | 
65 | 
66 |   

67 | 
68 | 
69 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "@makerdao/multicall",
 3 |   "version": "0.12.0",
 4 |   "description": "A blockchain state management library for dapps.",
 5 |   "contributors": [
 6 |     "Michael Elliot ",
 7 |     "Joshua Levine ",
 8 |     "Lawrence Wang "
 9 |   ],
10 |   "license": "MIT",
11 |   "keywords": [
12 |     "multicall",
13 |     "makerdao"
14 |   ],
15 |   "homepage": "https://github.com/makerdao/multicall.js#readme",
16 |   "bugs": {
17 |     "url": "https://github.com/makerdao/multicall.js/issues"
18 |   },
19 |   "repository": {
20 |     "type": "git",
21 |     "url": "https://github.com/makerdao/multicall.js.git"
22 |   },
23 |   "main": "dist/multicall.cjs.js",
24 |   "types": "types/multicall.d.ts",
25 |   "module": "dist/multicall.esm.js",
26 |   "unpkg": "dist/multicall.umd.js",
27 |   "files": [
28 |     "dist",
29 |     "src",
30 |     "LICENSE",
31 |     "README.md"
32 |   ],
33 |   "scripts": {
34 |     "clean": "rimraf dist",
35 |     "build": "rollup -c",
36 |     "watch": "rollup -w -c",
37 |     "example": "cross-env DEBUG=multicall babel-node --plugins @babel/plugin-transform-modules-commonjs examples/es-example.js",
38 |     "test": "jest",
39 |     "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
40 |     "prepublishOnly": "yarn test && yarn clean && yarn build"
41 |   },
42 |   "dependencies": {
43 |     "@babel/runtime": "^7.7.4",
44 |     "cross-fetch": "^3.0.4",
45 |     "debug": "^4.1.1",
46 |     "ethers": "^4.0.27",
47 |     "invariant": "^2.2.4",
48 |     "isomorphic-ws": "^4.0.1",
49 |     "lodash": "^4.17.11",
50 |     "ws": "^7.2.0"
51 |   },
52 |   "devDependencies": {
53 |     "@babel/cli": "^7.6.0",
54 |     "@babel/core": "^7.6.0",
55 |     "@babel/node": "^7.6.1",
56 |     "@babel/plugin-external-helpers": "^7.2.0",
57 |     "@babel/plugin-transform-runtime": "^7.7.4",
58 |     "@babel/preset-env": "^7.6.0",
59 |     "babel-jest": "^24.9.0",
60 |     "bignumber.js": "^9.0.0",
61 |     "cross-env": "^5.2.1",
62 |     "jest": "^24.9.0",
63 |     "jest-fetch-mock": "^2.1.2",
64 |     "jest-websocket-mock": "^2.0.0",
65 |     "mock-socket": "^9.0.2",
66 |     "rimraf": "^3.0.0",
67 |     "rollup": "^1.20.3",
68 |     "rollup-plugin-babel": "^4.3.3",
69 |     "rollup-plugin-bundle-size": "^1.0.3",
70 |     "rollup-plugin-commonjs": "^10.1.0",
71 |     "rollup-plugin-json": "^3.1.0",
72 |     "rollup-plugin-node-resolve": "^5.2.0",
73 |     "rollup-plugin-replace": "^2.2.0",
74 |     "rollup-plugin-terser": "^5.1.1"
75 |   },
76 |   "sideEffects": false
77 | }
78 | 


--------------------------------------------------------------------------------
/tests/aggregate.test.js:
--------------------------------------------------------------------------------
 1 | import { _makeMulticallData as makeMulticallData } from '../src/aggregate';
 2 | import { strip0x } from '../src/helpers.js';
 3 | 
 4 | describe('aggregate', () => {
 5 |   test('no args', () => {
 6 |     const calls = [
 7 |       {
 8 |         target: '0xbbf289d846208c16edc8474705c748aff07732db',
 9 |         method: 'what()',
10 |         returns: [['foo']],
11 |         returnTypes: ['uint256']
12 |       }
13 |     ];
14 |     const expected =
15 |       '0000000000000000000000000000000000000000000000000000000000000020' +
16 |       '0000000000000000000000000000000000000000000000000000000000000001' +
17 |       '0000000000000000000000000000000000000000000000000000000000000020' +
18 |       // address
19 |       '000000000000000000000000bbf289d846208c16edc8474705c748aff07732db' +
20 |       '0000000000000000000000000000000000000000000000000000000000000040' +
21 |       '0000000000000000000000000000000000000000000000000000000000000004' +
22 |       // function sig: what()
23 |       'b24bb845' +
24 |       '00000000000000000000000000000000000000000000000000000000';
25 | 
26 |     expect(strip0x(makeMulticallData(calls))).toEqual(expected);
27 |   });
28 | 
29 |   test('two calls, one with args', () => {
30 |     const calls = [
31 |       {
32 |         target: '0xbeefed1bedded2dabbed3defaced4decade5dead',
33 |         method: 'fess(address)',
34 |         args: [['0xbeefed1bedded2dabbed3defaced4decade5bead', 'address']],
35 |         returnTypes: ['uint256', 'address'],
36 |         returns: [['kay'], ['jewelers']]
37 |       },
38 |       {
39 |         target: '0xbeefed1bedded2dabbed3defaced4decade5face',
40 |         method: 'flog()',
41 |         returns: [['deBeers']],
42 |         returnTypes: ['bytes32'],
43 |       }
44 |     ];
45 |     const expected =
46 |       '0000000000000000000000000000000000000000000000000000000000000020' +
47 |       '0000000000000000000000000000000000000000000000000000000000000002' +
48 |       '0000000000000000000000000000000000000000000000000000000000000040' +
49 |       '00000000000000000000000000000000000000000000000000000000000000e0' +
50 |       // address
51 |       '000000000000000000000000beefed1bedded2dabbed3defaced4decade5dead' +
52 |       '0000000000000000000000000000000000000000000000000000000000000040' +
53 |       '0000000000000000000000000000000000000000000000000000000000000024' +
54 |       // function sig: fess(address)
55 |       'c963c57b' +
56 |       // arg
57 |       '000000000000000000000000beefed1bedded2dabbed3defaced4decade5bead' +
58 |       '00000000000000000000000000000000000000000000000000000000' +
59 |       // address
60 |       '000000000000000000000000beefed1bedded2dabbed3defaced4decade5face' +
61 |       '0000000000000000000000000000000000000000000000000000000000000040' +
62 |       '0000000000000000000000000000000000000000000000000000000000000004' +
63 |       // function sig: flog()
64 |       'a7c795fa' +
65 |       '00000000000000000000000000000000000000000000000000000000';
66 | 
67 |     expect(strip0x(makeMulticallData(calls))).toEqual(expected);
68 |   });
69 | });
70 | 


--------------------------------------------------------------------------------
/types/multicall.d.ts:
--------------------------------------------------------------------------------
  1 | /// 
  2 | 
  3 | declare module '@makerdao/multicall' {
  4 |   import { BigNumber } from 'bignumber.js';
  5 | 
  6 |   export interface IConfig {
  7 |     preset: 'mainnet' | 'kovan' | 'rinkeby' | 'goerli' | 'xdai' | 'ropsten';
  8 |     rpcUrl: string;
  9 |     multicallAddress: string;
 10 |     interval: number;
 11 |     staleBlockRetryWait: number;
 12 |     errorRetryWait: number;
 13 |   }
 14 | 
 15 |   export interface IPostProcess {
 16 |     (v: any): any;
 17 |   }
 18 | 
 19 |   export interface ICall {
 20 |     target: string;
 21 |     call: string[];
 22 |     returns: (string | IPostProcess)[][];
 23 |   }
 24 | 
 25 |   export interface IArgumentsMapping {
 26 |     [key: string]: string[];
 27 |   }
 28 | 
 29 |   export interface IKeysValues {
 30 |     [key: string]: any;
 31 |   }
 32 | 
 33 |   export interface IResult {
 34 |     blockNumber: BigNumber;
 35 |     original: IKeysValues;
 36 |     transformed: IKeysValues;
 37 |     keyToArgMap: IArgumentsMapping;
 38 |   }
 39 | 
 40 |   export interface IResponse {
 41 |     results: IResult;
 42 |   }
 43 | 
 44 |   export interface IUpdate {
 45 |     type: string;
 46 |     value: any;
 47 |     args: any[];
 48 |   }
 49 | 
 50 |   export interface ISubscription {
 51 |     unsub(): void;
 52 |   }
 53 | 
 54 |   export interface ISubscriber {
 55 |     subscribe(callback: (updates: IUpdate[]) => void): ISubscription;
 56 |   }
 57 | 
 58 |   export interface IPollData {
 59 |     id: number;
 60 |     latestBlockNumber: number;
 61 |     retry?: number;
 62 |   }
 63 | 
 64 |   export interface IState {
 65 |     model: Partial[];
 66 |     store: IKeysValues;
 67 |     storeTransformed: IKeysValues;
 68 |     keyToArgMap: IKeysValues;
 69 |     latestPromiseId: number;
 70 |     latestBlockNumber: number | null;
 71 |     id: number;
 72 |     listeners: {
 73 |       subscribe: any[];
 74 |       block: any[];
 75 |       poll: any[];
 76 |       error: any[];
 77 |     };
 78 |     handler: any | null;
 79 |     wsReconnectHandler: any | null;
 80 |     watching: boolean;
 81 |     config: Partial;
 82 |     ws: WebSocket | null;
 83 |   }
 84 | 
 85 |   export interface IWatcher {
 86 |     initialFetch: Promise;
 87 | 
 88 |     schemas: Partial[];
 89 | 
 90 |     tap(callback: (calls: Partial[]) => Partial[]): Promise;
 91 | 
 92 |     poll(): Promise;
 93 | 
 94 |     subscribe(callback: (update: IUpdate) => void): ISubscriber;
 95 | 
 96 |     batch(): ISubscriber;
 97 | 
 98 |     onNewBlock(callback: (blockNumber: number) => void): ISubscription;
 99 | 
100 |     onPoll(callback: (pollData: IPollData) => void): ISubscription;
101 | 
102 |     onError(callback: (error: Error, state: IState) => void): ISubscription;
103 | 
104 |     recreate(calls: Partial[], config: Partial): Promise;
105 | 
106 |     start(): Promise;
107 | 
108 |     stop(): undefined;
109 | 
110 |     awaitInitialFetch(): Promise;
111 |   }
112 | 
113 |   export function aggregate(calls: Partial[], config: Partial): Promise;
114 | 
115 |   export function createWatcher(calls: Partial[], config: Partial): IWatcher;
116 | }
117 | 


--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
  1 | import nodeResolve from 'rollup-plugin-node-resolve';
  2 | import babel from 'rollup-plugin-babel';
  3 | import replace from 'rollup-plugin-replace';
  4 | import { terser } from 'rollup-plugin-terser';
  5 | import json from 'rollup-plugin-json';
  6 | import commonjs from 'rollup-plugin-commonjs';
  7 | import bundleSize from 'rollup-plugin-bundle-size';
  8 | 
  9 | import pkg from './package.json';
 10 | 
 11 | const extensions = ['.js'];
 12 | const babelRuntimeVersion = pkg.dependencies['@babel/runtime'].replace(/^[^0-9]*/, '');
 13 | 
 14 | const makeExternalPredicate = externalArr => {
 15 |   if (externalArr.length === 0) {
 16 |     return () => false;
 17 |   }
 18 |   const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`);
 19 |   return id => pattern.test(id);
 20 | };
 21 | 
 22 | export default [
 23 |   // CommonJS
 24 |   {
 25 |     input: 'src/index.js',
 26 |     output: { file: 'dist/multicall.cjs.js', format: 'cjs', indent: false, sourcemap: true },
 27 |     external: makeExternalPredicate([
 28 |       ...Object.keys(pkg.dependencies || {}),
 29 |       ...Object.keys(pkg.peerDependencies || {})
 30 |     ]),
 31 |     plugins: [
 32 |       json(),
 33 |       nodeResolve({
 34 |         extensions
 35 |       }),
 36 |       babel({
 37 |         extensions,
 38 |         plugins: [['@babel/plugin-transform-runtime', { version: babelRuntimeVersion }]],
 39 |         runtimeHelpers: true
 40 |       }),
 41 |       bundleSize()
 42 |     ]
 43 |   },
 44 | 
 45 |   // ESM
 46 |   {
 47 |     input: 'src/index.js',
 48 |     output: { file: 'dist/multicall.esm.js', format: 'es', indent: false, sourcemap: true },
 49 |     external: makeExternalPredicate([
 50 |       ...Object.keys(pkg.dependencies || {}),
 51 |       ...Object.keys(pkg.peerDependencies || {})
 52 |     ]),
 53 |     plugins: [
 54 |       json(),
 55 |       nodeResolve({
 56 |         extensions
 57 |       }),
 58 |       babel({
 59 |         extensions,
 60 |         plugins: [
 61 |           ['@babel/plugin-transform-runtime', { version: babelRuntimeVersion, useESModules: true }]
 62 |         ],
 63 |         runtimeHelpers: true
 64 |       }),
 65 |       bundleSize()
 66 |     ]
 67 |   },
 68 | 
 69 |   // ESM for Browsers
 70 |   {
 71 |     input: 'src/index.js',
 72 |     output: { file: 'dist/multicall.esm.mjs', format: 'es', indent: false, sourcemap: true },
 73 |     plugins: [
 74 |       json(),
 75 |       nodeResolve({
 76 |         extensions,
 77 |         browser: true
 78 |       }),
 79 |       commonjs(),
 80 |       replace({
 81 |         'process.env.NODE_ENV': JSON.stringify('production')
 82 |       }),
 83 |       babel({
 84 |         extensions,
 85 |         exclude: 'node_modules/**'
 86 |       }),
 87 |       terser({
 88 |         compress: {
 89 |           pure_getters: true,
 90 |           unsafe: true,
 91 |           unsafe_comps: true,
 92 |           warnings: false
 93 |         }
 94 |       }),
 95 |       bundleSize()
 96 |     ]
 97 |   },
 98 | 
 99 |   // UMD
100 |   {
101 |     input: 'src/index.js',
102 |     output: {
103 |       file: 'dist/multicall.umd.js',
104 |       format: 'umd',
105 |       name: 'Multicall',
106 |       indent: false,
107 |       sourcemap: true
108 |     },
109 |     plugins: [
110 |       json(),
111 |       nodeResolve({
112 |         extensions,
113 |         browser: true
114 |       }),
115 |       commonjs(),
116 |       babel({
117 |         extensions,
118 |         exclude: 'node_modules/**'
119 |       }),
120 |       replace({
121 |         'process.env.NODE_ENV': JSON.stringify('production')
122 |       }),
123 |       terser({
124 |         compress: {
125 |           pure_getters: true,
126 |           unsafe: true,
127 |           unsafe_comps: true,
128 |           warnings: false
129 |         }
130 |       }),
131 |       bundleSize()
132 |     ]
133 |   }
134 | ];
135 | 


--------------------------------------------------------------------------------
/examples/es-example.js:
--------------------------------------------------------------------------------
  1 | import { createWatcher } from '../src';
  2 | 
  3 | const MKR_TOKEN = '0xaaf64bfcc32d0f15873a02163e7e500671a4ffcd';
  4 | const MKR_WHALE = '0xdb33dfd3d61308c33c63209845dad3e6bfb2c674';
  5 | const MKR_FISH = '0x2dfcedcb401557354d0cf174876ab17bfd6f4efd';
  6 | const PRICE_FEED_ETH = '0xa5aA4e07F5255E14F02B385b1f04b35cC50bdb66';
  7 | 
  8 | // Preset can be 'mainnet', 'kovan', 'rinkeby', 'goerli' or 'xdai'
  9 | // const config = { preset: 'kovan' };
 10 | 
 11 | // Alternatively the rpcUrl and multicallAddress can be specified manually
 12 | const config = {
 13 |   rpcUrl: 'wss://kovan.infura.io/ws/v3/58073b4a32df4105906c702f167b91d2',
 14 |   multicallAddress: '0x2cc8688c5f75e365aaeeb4ea8d6a480405a48d2a',
 15 |   interval: 1000
 16 | };
 17 | 
 18 | (async () => {
 19 |   const watcher = createWatcher(
 20 |     [
 21 |       {
 22 |         call: [
 23 |           'getEthBalance(address)(uint256)',
 24 |           '0x72776bb917751225d24c07d0663b3780b2ada67c'
 25 |         ],
 26 |         returns: [['ETH_BALANCE', val => val / 10 ** 18]]
 27 |       },
 28 |       {
 29 |         target: MKR_TOKEN,
 30 |         call: ['balanceOf(address)(uint256)', MKR_WHALE],
 31 |         returns: [['BALANCE_OF_MKR_WHALE', val => val / 10 ** 18]]
 32 |       },
 33 |       {
 34 |         target: PRICE_FEED_ETH,
 35 |         call: ['peek()(uint256,bool)'],
 36 |         returns: [
 37 |           ['PRICE_FEED_ETH_PRICE', val => val / 10 ** 18],
 38 |           ['PRICE_FEED_ETH_SET']
 39 |         ]
 40 |       }
 41 |     ],
 42 |     config
 43 |   );
 44 | 
 45 |   watcher.subscribe(update => {
 46 |     console.log(`Update: ${update.type} = ${update.value}`);
 47 |   });
 48 | 
 49 |   watcher.batch().subscribe(updates => {
 50 |     // Handle batched updates here
 51 |   });
 52 | 
 53 |   watcher.onNewBlock(blockNumber => {
 54 |     console.log(`New block: ${blockNumber}`);
 55 |   });
 56 | 
 57 |   setTimeout(async () => {
 58 |     watcher.start();
 59 | 
 60 |     await watcher.awaitInitialFetch();
 61 | 
 62 |     console.log('Initial fetch completed');
 63 | 
 64 |     // Update the calls
 65 |     setTimeout(() => {
 66 |       console.log('Updating calls...');
 67 |       const fetchWaiter = watcher.tap(calls => [
 68 |         ...calls,
 69 |         {
 70 |           target: MKR_TOKEN,
 71 |           call: ['balanceOf(address)(uint256)', MKR_FISH],
 72 |           returns: [['BALANCE_OF_MKR_FISH', val => val / 10 ** 18]]
 73 |         }
 74 |       ]);
 75 |       fetchWaiter.then(() => {
 76 |         console.log('Initial fetch completed');
 77 |       });
 78 |     }, 1000);
 79 | 
 80 |     // Recreate watcher (useful if network has changed)
 81 |     setTimeout(() => {
 82 |       console.log('Recreating with new calls and config...');
 83 |       const fetchWaiter = watcher.recreate(
 84 |         [
 85 |           {
 86 |             target: MKR_TOKEN,
 87 |             call: ['balanceOf(address)(uint256)', MKR_WHALE],
 88 |             returns: [['BALANCE_OF_MKR_WHALE', val => val / 10 ** 18]]
 89 |           }
 90 |         ],
 91 |         config
 92 |       );
 93 |       fetchWaiter.then(() => {
 94 |         console.log('Initial fetch completed');
 95 |       });
 96 |     }, 2000);
 97 | 
 98 |   }, 1);
 99 | 
100 |   // When subscribing to state updates, previously cached values will be returned immediately
101 |   // setTimeout(() => {
102 |   //   console.log(
103 |   //     'Subscribing to updates much later (will immediately return cached values)'
104 |   //   );
105 |   //   watcher.subscribe(update => {
106 |   //     console.log(
107 |   //       `Update (2nd subscription): ${update.type} = ${update.value}`
108 |   //     );
109 |   //   });
110 |   //   watcher.onNewBlock(blockNumber => {
111 |   //     console.log(`New block (2nd subscription): ${blockNumber}`);
112 |   //   });
113 |   // }, 15000);
114 | })();
115 | 
116 | (async () => {
117 |   await new Promise(res => {
118 |     setTimeout(res, 10000000);
119 |   });
120 | })();
121 | 


--------------------------------------------------------------------------------
/src/aggregate.js:
--------------------------------------------------------------------------------
  1 | import { id as keccak256 } from 'ethers/utils/hash';
  2 | import invariant from 'invariant';
  3 | import { strip0x, ethCall, encodeParameters, decodeParameters } from './helpers.js';
  4 | import memoize from 'lodash/memoize';
  5 | 
  6 | const INSIDE_EVERY_PARENTHESES = /\(.*?\)/g;
  7 | const FIRST_CLOSING_PARENTHESES = /^[^)]*\)/;
  8 | 
  9 | export function _makeMulticallData(calls) {
 10 |   const values = [
 11 |     calls.map(({ target, method, args, returnTypes }) => [
 12 |       target,
 13 |       keccak256(method).substr(0, 10) +
 14 |         (args && args.length > 0
 15 |           ? strip0x(encodeParameters(args.map(a => a[1]), args.map(a => a[0])))
 16 |           : '')
 17 |     ])
 18 |   ];
 19 |   const calldata = encodeParameters(
 20 |     [
 21 |       {
 22 |         components: [{ type: 'address' }, { type: 'bytes' }],
 23 |         name: 'data',
 24 |         type: 'tuple[]'
 25 |       }
 26 |     ],
 27 |     values
 28 |   );
 29 |   return calldata;
 30 | }
 31 | 
 32 | const makeMulticallData = memoize(_makeMulticallData, (...args) => JSON.stringify(args));
 33 | 
 34 | export default async function aggregate(calls, config) {
 35 |   calls = Array.isArray(calls) ? calls : [calls];
 36 | 
 37 |   const keyToArgMap = calls.reduce((acc, { call, returns }) => {
 38 |     const [, ...args] = call;
 39 |     if (args.length > 0) {
 40 |       for (let returnMeta of returns) {
 41 |         const [key] = returnMeta;
 42 |         acc[key] = args;
 43 |       }
 44 |     }
 45 |     return acc;
 46 |   }, {});
 47 | 
 48 |   calls = calls.map(({ call, target, returns }) => {
 49 |     if (!target) target = config.multicallAddress;
 50 |     const [method, ...argValues] = call;
 51 |     const [argTypesString, returnTypesString] = method
 52 |       .match(INSIDE_EVERY_PARENTHESES)
 53 |       .map(match => match.slice(1, -1));
 54 |     const argTypes = argTypesString.split(',').filter(e => !!e);
 55 |     invariant(
 56 |       argTypes.length === argValues.length,
 57 |       `Every method argument must have exactly one type.
 58 |           Comparing argument types ${JSON.stringify(argTypes)}
 59 |           to argument values ${JSON.stringify(argValues)}.
 60 |         `
 61 |     );
 62 |     const args = argValues.map((argValue, idx) => [argValue, argTypes[idx]]);
 63 |     const returnTypes = !!returnTypesString ? returnTypesString.split(',') : [];
 64 |     return {
 65 |       method: method.match(FIRST_CLOSING_PARENTHESES)[0],
 66 |       args,
 67 |       returnTypes,
 68 |       target,
 69 |       returns
 70 |     };
 71 |   });
 72 | 
 73 |   const callDataBytes = makeMulticallData(calls, false);
 74 |   const outerResults = await ethCall(callDataBytes, config);
 75 |   const returnTypeArray = calls
 76 |     .map(({ returnTypes }) => returnTypes)
 77 |     .reduce((acc, ele) => acc.concat(ele), []);
 78 |   const returnDataMeta = calls
 79 |     .map(({ returns }) => returns)
 80 |     .reduce((acc, ele) => acc.concat(ele), []);
 81 | 
 82 |   invariant(
 83 |     returnTypeArray.length === returnDataMeta.length,
 84 |     'Missing data needed to parse results'
 85 |   );
 86 | 
 87 |   const outerResultsDecoded = decodeParameters(['uint256', 'bytes[]'], outerResults);
 88 |   const blockNumber = outerResultsDecoded.shift();
 89 |   const parsedVals = outerResultsDecoded.reduce((acc, r) => {
 90 |     r.forEach((results, idx) => {
 91 |       const types = calls[idx].returnTypes;
 92 |       const resultsDecoded = decodeParameters(types, results);
 93 |       acc.push(
 94 |         ...resultsDecoded.map((r, idx) => {
 95 |           if (types[idx] === 'bool') return r.toString() === 'true';
 96 |           return r;
 97 |         })
 98 |       );
 99 |     });
100 |     return acc;
101 |   }, []);
102 | 
103 |   const retObj = { blockNumber, original: {}, transformed: {} };
104 | 
105 |   for (let i = 0; i < parsedVals.length; i++) {
106 |     const [name, transform] = returnDataMeta[i];
107 |     retObj.original[name] = parsedVals[i];
108 |     retObj.transformed[name] = transform !== undefined ? transform(parsedVals[i]) : parsedVals[i];
109 |   }
110 | 
111 |   return { results: retObj, keyToArgMap };
112 | }
113 | 


--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
  1 | import fetch from 'cross-fetch';
  2 | import { defaultAbiCoder } from 'ethers/utils/abi-coder';
  3 | import debug from 'debug';
  4 | const log = debug('multicall');
  5 | 
  6 | // Function signature for: aggregate((address,bytes)[])
  7 | export const AGGREGATE_SELECTOR = '0x252dba42';
  8 | 
  9 | export function strip0x(str) {
 10 |   return str.replace(/^0x/, '');
 11 | }
 12 | 
 13 | export function typesLength(types) {
 14 |   return types.length;
 15 | }
 16 | 
 17 | export function encodeParameter(type, val) {
 18 |   return encodeParameters([type], [val]);
 19 | }
 20 | 
 21 | export function encodeParameters(types, vals) {
 22 |   return defaultAbiCoder.encode(types, vals);
 23 | }
 24 | 
 25 | export function decodeParameter(type, val) {
 26 |   return decodeParameters([type], val);
 27 | }
 28 | 
 29 | export function decodeParameters(types, vals) {
 30 |   return defaultAbiCoder.decode(types, '0x' + vals.replace(/0x/i, ''));
 31 | }
 32 | 
 33 | export function padLeft(string, chars, sign) {
 34 |   var hasPrefix = /^0x/i.test(string) || typeof string === 'number';
 35 |   string = string.toString(16).replace(/^0x/i, '');
 36 |   var padding = chars - string.length + 1 >= 0 ? chars - string.length + 1 : 0;
 37 |   return (
 38 |     (hasPrefix ? '0x' : '') +
 39 |     new Array(padding).join(sign ? sign : '0') +
 40 |     string
 41 |   );
 42 | }
 43 | 
 44 | export function padRight(string, chars, sign) {
 45 |   var hasPrefix = /^0x/i.test(string) || typeof string === 'number';
 46 |   string = string.toString(16).replace(/^0x/i, '');
 47 |   var padding = chars - string.length + 1 >= 0 ? chars - string.length + 1 : 0;
 48 |   return (
 49 |     (hasPrefix ? '0x' : '') +
 50 |     string +
 51 |     new Array(padding).join(sign ? sign : '0')
 52 |   );
 53 | }
 54 | 
 55 | export function isEmpty(obj) {
 56 |   if (Array.isArray(obj)) return obj.length === 0;
 57 |   return !obj || Object.keys(obj).length === 0;
 58 | }
 59 | 
 60 | export async function ethCall(rawData, { id, web3, rpcUrl, block, multicallAddress, ws, wsResponseTimeout }) {
 61 |   const abiEncodedData = AGGREGATE_SELECTOR + strip0x(rawData);
 62 |   if (ws) {
 63 |     log('Sending via WebSocket');
 64 |     return new Promise((resolve, reject) => {
 65 |       ws.send(JSON.stringify({
 66 |         jsonrpc: '2.0',
 67 |         method: 'eth_call',
 68 |         params: [
 69 |           {
 70 |             to: multicallAddress,
 71 |             data: abiEncodedData
 72 |           },
 73 |           block || 'latest'
 74 |         ],
 75 |         id
 76 |       }));
 77 |       function onMessage(data) {
 78 |         if (typeof data !== 'string') data = data.data;
 79 |         const json = JSON.parse(data);
 80 |         if (!json.id || json.id !== id) return;
 81 |         log('Got WebSocket response id #%d', json.id);
 82 |         clearTimeout(timeoutHandle);
 83 |         ws.onmessage = null;
 84 |         resolve(json.result);
 85 |       }
 86 |       const timeoutHandle = setTimeout(() => {
 87 |         if (ws.onmessage !== onMessage) return;
 88 |         ws.onmessage = null;
 89 |         reject(new Error('WebSocket response timeout'));
 90 |       }, wsResponseTimeout);
 91 | 
 92 |       ws.onmessage = onMessage;
 93 |     });
 94 |   }
 95 |   else if (web3) {
 96 |     log('Sending via web3 provider');
 97 |     return web3.eth.call({
 98 |       to: multicallAddress,
 99 |       data: abiEncodedData
100 |     });
101 |   } else {
102 |     log('Sending via XHR fetch');
103 |     const rawResponse = await fetch(rpcUrl, {
104 |       method: 'POST',
105 |       headers: {
106 |         'Accept': 'application/json',
107 |         'Content-Type': 'application/json'
108 |       },
109 |       body: JSON.stringify({
110 |         jsonrpc: '2.0',
111 |         method: 'eth_call',
112 |         params: [
113 |           {
114 |             to: multicallAddress,
115 |             data: abiEncodedData
116 |           },
117 |           block || 'latest'
118 |         ],
119 |         id: 1
120 |       })
121 |     });
122 |     const content = await rawResponse.json();
123 |     if (!content || !content.result) {
124 |       throw new Error('Multicall received an empty response. Check your call configuration for errors.');
125 |     }
126 |     return content.result;
127 |   }
128 | }
129 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # Multicall.js Multicall
  2 | 
  3 | [![npm version](https://img.shields.io/npm/v/@makerdao/multicall.svg?style=flat-square)](https://www.npmjs.com/package/@makerdao/multicall)
  4 | 
  5 | **Multicall.js** is a lightweight JavaScript library for interacting with the [multicall](https://github.com/makerdao/multicall) smart contract.
  6 | 
  7 | Multicall allows multiple smart contract constant function calls to be grouped into a single call and the results aggregated into a single result. This reduces the number of separate JSON RPC requests that need to be sent over the network if using a remote node like Infura, and provides the guarantee that all values returned are from the same block. The latest block number is also returned along with the aggregated results.
  8 | 
  9 | ## Summary
 10 | 
 11 | - Get the return value(s) of multiple smart contract function calls in a single call
 12 | - Guarantee that all values are from the same block
 13 | - Use watchers to poll for multiple blockchain state variables/functions
 14 | - Get updates when a watcher detects state has changed
 15 | - Results from out of sync nodes are automatically ignored
 16 | - Get new block updates
 17 | 
 18 | ## Installation
 19 | 
 20 | ```bash
 21 | yarn add @makerdao/multicall
 22 | ```
 23 | 
 24 | ## Usage
 25 | 
 26 | ```javascript
 27 | import { createWatcher } from '@makerdao/multicall';
 28 | 
 29 | // Contract addresses used in this example
 30 | const MKR_TOKEN = '0xaaf64bfcc32d0f15873a02163e7e500671a4ffcd';
 31 | const MKR_WHALE = '0xdb33dfd3d61308c33c63209845dad3e6bfb2c674';
 32 | const MKR_FISH = '0x2dfcedcb401557354d0cf174876ab17bfd6f4efd';
 33 | 
 34 | // Preset can be 'mainnet', 'kovan', 'rinkeby', 'goerli' or 'xdai'
 35 | const config = { preset: 'kovan' };
 36 | 
 37 | // Create watcher
 38 | const watcher = createWatcher(
 39 |   [
 40 |     {
 41 |       target: MKR_TOKEN,
 42 |       call: ['balanceOf(address)(uint256)', MKR_WHALE],
 43 |       returns: [['BALANCE_OF_MKR_WHALE', val => val / 10 ** 18]]
 44 |     }
 45 |   ],
 46 |   config
 47 | );
 48 | 
 49 | // Subscribe to state updates
 50 | watcher.subscribe(update => {
 51 | console.log(`Update: ${update.type} = ${update.value}`);
 52 | });
 53 | 
 54 | // Subscribe to batched state updates
 55 | watcher.batch().subscribe(updates => {
 56 |   // Handle batched updates here
 57 |   // Updates are returned as { type, value } objects, e.g:
 58 |   // { type: 'BALANCE_OF_MKR_WHALE', value: 70000 }
 59 | });
 60 | 
 61 | // Subscribe to new block number updates
 62 | watcher.onNewBlock(blockNumber => {
 63 |   console.log('New block:', blockNumber);
 64 | });
 65 | 
 66 | // Start the watcher polling
 67 | watcher.start();
 68 | ```
 69 | 
 70 | ```javascript
 71 | // The JSON RPC URL and multicall contract address can also be specified in the config:
 72 | const config = {
 73 |   rpcUrl: 'https://kovan.infura.io',
 74 |   multicallAddress: '0xc49ab4d7de648a97592ed3d18720e00356b4a806'
 75 | };
 76 | ```
 77 | 
 78 | ```javascript
 79 | // Update the watcher calls using tap()
 80 | const fetchWaiter = watcher.tap(calls => [
 81 |   // Pass back existing calls...
 82 |   ...calls,
 83 |   // ...plus new calls
 84 |   {
 85 |     target: MKR_TOKEN,
 86 |     call: ['balanceOf(address)(uint256)', MKR_FISH],
 87 |     returns: [['BALANCE_OF_MKR_FISH', val => val / 10 ** 18]]
 88 |   }
 89 | ]);
 90 | // This promise resolves when the first fetch completes
 91 | fetchWaiter.then(() => {
 92 |   console.log('Initial fetch completed');
 93 | });
 94 | ```
 95 | 
 96 | ```javascript
 97 | // Recreate the watcher with new calls and config (allowing the network to be changed)
 98 | const config = { preset: 'mainnet' };
 99 | watcher.recreate(
100 |   [
101 |     {
102 |       target: MKR_TOKEN,
103 |       call: ['balanceOf(address)(uint256)', MKR_WHALE],
104 |       returns: [['BALANCE_OF_MKR_WHALE', val => val / 10 ** 18]]
105 |     }
106 |   ],
107 |   config
108 | );
109 | ```
110 | 
111 | ## Helper Functions
112 | Special variables and functions (e.g. `addr.balance`, `block.blockhash`, `block.timestamp`) can be accessed by calling their corresponding helper function.
113 | To call these helper functions simply omit the `target` property (and it will default to multicall's contract address).
114 | ```javascript
115 | const watcher = createWatcher(
116 |   [
117 |     {
118 |       call: [
119 |         'getEthBalance(address)(uint256)', 
120 |         '0x72776bb917751225d24c07d0663b3780b2ada67c'
121 |       ],
122 |       returns: [['ETH_BALANCE', val => val / 10 ** 18]]
123 |     },
124 |     {
125 |       call: ['getBlockHash(uint256)(bytes32)', 11482494],
126 |       returns: [['SPECIFIC_BLOCK_HASH_0xFF4DB']]
127 |     },
128 |     {
129 |       call: ['getLastBlockHash()(bytes32)'],
130 |       returns: [['LAST_BLOCK_HASH']]
131 |     },
132 |     {
133 |       call: ['getCurrentBlockTimestamp()(uint256)'],
134 |       returns: [['CURRENT_BLOCK_TIMESTAMP']]
135 |     },
136 |     {
137 |       call: ['getCurrentBlockDifficulty()(uint256)'],
138 |       returns: [['CURRENT_BLOCK_DIFFICULTY']]
139 |     },
140 |     {
141 |       call: ['getCurrentBlockGasLimit()(uint256)'],
142 |       returns: [['CURRENT_BLOCK_GASLIMIT']]
143 |     },
144 |     {
145 |       call: ['getCurrentBlockCoinbase()(address)'],
146 |       returns: [['CURRENT_BLOCK_COINBASE']]
147 |     }
148 |   ],
149 |   { preset: 'kovan' }
150 | );
151 | ```
152 | 
153 | ## Examples
154 | 
155 | Check out this [CodePen example](https://codepen.io/michaelelliot/pen/MxEpNX?editors=0010) for a working front-end example.
156 | 
157 | To run the example in the project, first clone this repo:
158 | 
159 | ```bash
160 | git clone https://github.com/makerdao/multicall.js
161 | ```
162 | 
163 | Then install the dependencies:
164 | 
165 | ```bash
166 | yarn
167 | ```
168 | 
169 | Finally run the example script (`examples/es-example.js`):
170 | 
171 | ```bash
172 | yarn example
173 | ```
174 | 
175 | ## Test
176 | 
177 | To run tests use:
178 | 
179 | ```bash
180 | yarn test
181 | ```
182 | 


--------------------------------------------------------------------------------
/tests/watcher.test.js:
--------------------------------------------------------------------------------
  1 | import { createWatcher } from '../src';
  2 | import { calls, mockedResults, promiseWait } from './shared';
  3 | 
  4 | const config = {
  5 |   rpcUrl: 'https://mocked',
  6 |   multicallAddress: '0x1234567890123456789012345678901234567890'
  7 | };
  8 | 
  9 | describe('watcher', () => {
 10 |   beforeEach(() => fetch.resetMocks());
 11 | 
 12 |   test('schemas set correctly', async () => {
 13 |     const watcher = createWatcher([calls[0], calls[1], calls[2]], config);
 14 |     expect(watcher.schemas).toEqual([calls[0], calls[1], calls[2]]);
 15 |   });
 16 | 
 17 |   test('await initial fetch', async () => {
 18 |     const results = {};
 19 |     const watcher = createWatcher([calls[0], calls[1]], config);
 20 |     watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number);
 21 | 
 22 |     fetch.mockResponse(async () => ({
 23 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
 24 |     }));
 25 |     watcher.start();
 26 | 
 27 |     expect(results['BLOCK_NUMBER']).toEqual(undefined);
 28 |     await watcher.initialFetch;
 29 |     expect(results['BLOCK_NUMBER']).toEqual(123456789);
 30 |   });
 31 | 
 32 |   test('subscription updates (separate and batched)', async () => {
 33 |     const results = {};
 34 |     const batchedResults = {};
 35 |     const watcher = createWatcher([calls[0], calls[1]], config);
 36 |     watcher.subscribe(update => results[update.type] = update.value);
 37 |     watcher.batch().subscribe(updates => updates.forEach(update => batchedResults[update.type] = update.value));
 38 |     watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number);
 39 |     fetch.mockResponse(async () => ({
 40 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
 41 |     }));
 42 |     await watcher.start();
 43 | 
 44 |     expect(results['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333');
 45 |     expect(results['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444');
 46 |     expect(batchedResults['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333');
 47 |     expect(batchedResults['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444');
 48 |     expect(results['BLOCK_NUMBER']).toEqual(123456789);
 49 |   });
 50 | 
 51 |   test('subscription updates after schema changed', async () => {
 52 |     const results = {};
 53 |     const watcher = createWatcher([calls[0], calls[1]], config);
 54 |     watcher.subscribe(update => results[update.type] = update.value);
 55 |     watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number);
 56 |     fetch.mockResponse(async () => ({
 57 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
 58 |     }));
 59 |     await watcher.start();
 60 | 
 61 |     expect(results['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333');
 62 |     expect(results['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444');
 63 |     expect(results['BLOCK_NUMBER']).toEqual(123456789);
 64 | 
 65 |     fetch.mockResponse(async () => ({
 66 |       body: JSON.stringify({ jsonrpc: '2.0', id: 2, result: mockedResults[1] })
 67 |     }));
 68 |     await watcher.tap(existing => [...existing, calls[2]]);
 69 | 
 70 |     expect(results['BALANCE_OF_ETH_WHALE']).toEqual('3333.44445555');
 71 |     expect(results['BALANCE_OF_MKR_WHALE']).toEqual('4444.55556666');
 72 |     expect(results['PRICE_FEED_ETH_PRICE']).toEqual('1234.56789');
 73 |     expect(results['BLOCK_NUMBER']).toEqual(987654321);
 74 |   });
 75 | 
 76 |   test('subscription updates after watcher recreated', async () => {
 77 |     const results = {};
 78 |     const watcher = createWatcher([calls[0], calls[1]], config);
 79 |     watcher.subscribe(update => results[update.type] = update.value);
 80 |     watcher.onNewBlock(number => results['BLOCK_NUMBER'] = number);
 81 |     fetch.mockResponse(async () => ({
 82 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
 83 |     }));
 84 |     await watcher.start();
 85 | 
 86 |     expect(results['BALANCE_OF_ETH_WHALE']).toEqual('1111.22223333');
 87 |     expect(results['BALANCE_OF_MKR_WHALE']).toEqual('2222.33334444');
 88 |     expect(results['BLOCK_NUMBER']).toEqual(123456789);
 89 | 
 90 |     fetch.mockResponse(async () => ({
 91 |       body: JSON.stringify({ jsonrpc: '2.0', id: 2, result: mockedResults[1] })
 92 |     }));
 93 |     await watcher.recreate([calls[0], calls[1], calls[2]], config);
 94 | 
 95 |     expect(results['BALANCE_OF_ETH_WHALE']).toEqual('3333.44445555');
 96 |     expect(results['BALANCE_OF_MKR_WHALE']).toEqual('4444.55556666');
 97 |     expect(results['PRICE_FEED_ETH_PRICE']).toEqual('1234.56789');
 98 |     expect(results['BLOCK_NUMBER']).toEqual(987654321);
 99 |   });
100 | 
101 |   test('onError listener', async (done) => {
102 |     const watcher = createWatcher([], config);
103 |     watcher.onError(() => done());
104 |     fetch.mockResponse(async () => ({
105 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
106 |     }));
107 |     await watcher.start();
108 |   });
109 | 
110 |   test('onPoll listener', async (done) => {
111 |     const watcher = createWatcher([calls[0], calls[1]], config);
112 |     watcher.onPoll(({ id, latestBlockNumber }) => {
113 |       if (id === 1) expect(latestBlockNumber).toEqual(null);
114 |       else if (id === 2) {
115 |         expect(latestBlockNumber).toEqual(123456789);
116 |         done();
117 |       }
118 |     });
119 | 
120 |     fetch.mockResponse(async () => ({
121 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
122 |     }));
123 |     await watcher.start();
124 | 
125 |     fetch.mockResponse(async () => ({
126 |       body: JSON.stringify({ jsonrpc: '2.0', id: 2, result: mockedResults[1] })
127 |     }));
128 |     await watcher.tap(existing => [...existing, calls[2]]);
129 |   });
130 | 
131 |   test('null result from transform that changes to BigNumber', async () => {
132 |     const results = {};
133 |     const watcher = createWatcher([calls[0], calls[3]], config);
134 |     watcher.subscribe(update => results[update.type] = update.value);
135 |     watcher.onError(err => results['ERROR'] = err);
136 |     fetch.mockResponse(async () => ({
137 |       body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: mockedResults[0] })
138 |     }));
139 |     await watcher.start();
140 | 
141 |     expect(results['TRANSFORM_RESULT_TO_NULL']).toEqual(null);
142 |     expect(results['ERROR']).toBeUndefined();
143 | 
144 |     fetch.mockResponse(async () => ({
145 |       body: JSON.stringify({ jsonrpc: '2.0', id: 2, result: mockedResults[1] })
146 |     }));
147 |     await watcher.tap(existing => [...existing, calls[2]]);
148 | 
149 |     expect(results['TRANSFORM_RESULT_TO_NULL'].toString()).toEqual('1');
150 |     expect(results['ERROR']).toBeUndefined();
151 |   });
152 | });
153 | 


--------------------------------------------------------------------------------
/src/watcher.js:
--------------------------------------------------------------------------------
  1 | import WebSocket from 'isomorphic-ws';
  2 | import aggregate from './aggregate';
  3 | import { isEmpty } from './helpers';
  4 | import addresses from './addresses.json';
  5 | import debug from 'debug';
  6 | const log = debug('multicall');
  7 | 
  8 | const reWsEndpoint = /^wss?:\/\//i;
  9 | 
 10 | function isNewState(type, value, store) {
 11 |   return (
 12 |     store[type] === undefined ||
 13 |     (value !== null &&
 14 |     store[type] !== null &&
 15 |     typeof value === 'object' &&
 16 |     typeof value.toString === 'function' &&
 17 |     typeof store[type] === 'object' &&
 18 |     typeof store[type].toString === 'function'
 19 |       ? value.toString() !== store[type].toString()
 20 |       : value !== store[type])
 21 |   );
 22 | }
 23 | 
 24 | function prepareConfig(config) {
 25 |   config = {
 26 |     interval: 1000,
 27 |     staleBlockRetryWait: 3000,
 28 |     errorRetryWait: 5000,
 29 |     wsResponseTimeout: 5000,
 30 |     wsReconnectTimeout: 5000,
 31 |     ...config
 32 |   };
 33 |   if (config.preset !== undefined) {
 34 |     if (addresses[config.preset] !== undefined) {
 35 |       config.multicallAddress = addresses[config.preset].multicall;
 36 |       config.rpcUrl = addresses[config.preset].rpcUrl;
 37 |     } else throw new Error(`Unknown preset ${config.preset}`);
 38 |   }
 39 |   return config;
 40 | }
 41 | 
 42 | export default function createWatcher(model, config) {
 43 |   const state = {
 44 |     model: [...model],
 45 |     store: {},
 46 |     storeTransformed: {},
 47 |     keyToArgMap: {},
 48 |     latestPromiseId: 0,
 49 |     latestBlockNumber: null,
 50 |     id: 0,
 51 |     listeners: {
 52 |       subscribe: [],
 53 |       block: [],
 54 |       poll: [],
 55 |       error: []
 56 |     },
 57 |     handler: null,
 58 |     wsReconnectHandler: null,
 59 |     watching: false,
 60 |     config: prepareConfig(config),
 61 |     ws: null
 62 |   };
 63 | 
 64 |   function reconnectWebSocket(timeout) {
 65 |     clearTimeout(state.handler);
 66 |     state.handler = null;
 67 |     clearTimeout(state.wsReconnectHandler);
 68 |     state.wsReconnectHandler = setTimeout(() => {
 69 |       destroyWebSocket();
 70 |       setupWebSocket();
 71 |     }, timeout);
 72 |   }
 73 | 
 74 |   function setupWebSocket() {
 75 |     if (reWsEndpoint.test(state.config.rpcUrl)) {
 76 |       log(`Connecting to WebSocket ${state.config.rpcUrl}...`);
 77 |       state.ws = new WebSocket(state.config.rpcUrl);
 78 |       state.ws.onopen = () => {
 79 |         log('WebSocket connected');
 80 |         if (state.handler) throw new Error('Existing poll setTimeout handler set');
 81 |         if (state.watching) {
 82 |           poll.call({
 83 |             state,
 84 |             interval: 0,
 85 |             resolveFetchPromise: state.initialFetchResolver
 86 |           });
 87 |         }
 88 |       };
 89 |       state.ws.onclose = err => {
 90 |         log('WebSocket closed: %s', JSON.stringify(err));
 91 |         log(`Reconnecting in ${state.config.wsReconnectTimeout / 1000} seconds.`);
 92 |         reconnectWebSocket(state.config.wsReconnectTimeout);
 93 |       };
 94 |       state.ws.onerror = err => {
 95 |         log('WebSocket error: %s', JSON.stringify(err));
 96 |         log(`Reconnecting in ${state.config.wsReconnectTimeout / 1000} seconds.`);
 97 |         reconnectWebSocket(state.config.wsReconnectTimeout);
 98 |       };
 99 |     }
100 |   }
101 | 
102 |   function destroyWebSocket() {
103 |     log('destroyWebSocket()');
104 |     state.ws.onopen = null;
105 |     state.ws.onclose = null;
106 |     state.ws.onerror = null;
107 |     state.ws.onmessage = null;
108 |     state.ws.close();
109 |   }
110 | 
111 |   setupWebSocket();
112 | 
113 |   state.initialFetchPromise = new Promise(resolve => {
114 |     state.initialFetchResolver = resolve;
115 |   });
116 | 
117 |   function subscribe(listener, id, batch = false) {
118 |     if (!isEmpty(state.storeTransformed)) {
119 |       const events = Object.entries(state.storeTransformed).map(([type, value]) => ({
120 |         type,
121 |         value,
122 |         args: state.keyToArgMap[type] || []
123 |       }));
124 |       batch ? listener(events) : events.forEach(listener);
125 |     }
126 |     state.listeners.subscribe.push({ listener, id, batch });
127 |   }
128 | 
129 |   function alertListeners(events) {
130 |     if (!isEmpty(events))
131 |       state.listeners.subscribe.forEach(({ listener, batch }) =>
132 |         batch ? listener(events) : events.forEach(listener)
133 |       );
134 |   }
135 | 
136 |   function poll() {
137 |     const interval = this.interval !== undefined ? this.interval : this.state.config.interval;
138 |     log('poll() called, %s%s', 'interval: ' + interval, this.retry ? ', retry: ' + this.retry : '');
139 |     this.state.handler = setTimeout(async () => {
140 |       try {
141 |         if (!this.state.handler) return;
142 | 
143 |         this.state.latestPromiseId++;
144 |         const promiseId = this.state.latestPromiseId;
145 | 
146 |         state.listeners.poll.forEach(({ listener }) =>
147 |           listener({
148 |             id: promiseId,
149 |             latestBlockNumber: this.state.latestBlockNumber,
150 |             ...(this.retry ? { retry: this.retry } : {})
151 |           })
152 |         );
153 | 
154 |         const {
155 |           results: {
156 |             blockNumber,
157 |             original: { ...data },
158 |             transformed: { ...dataTransformed }
159 |           },
160 |           keyToArgMap
161 |         } = await aggregate(this.state.model, {
162 |           ...this.state.config,
163 |           ws: this.state.ws,
164 |           id: this.state.latestPromiseId
165 |         });
166 | 
167 |         if (this.state.cancelPromiseId === promiseId) return;
168 | 
169 |         if (typeof this.resolveFetchPromise === 'function') this.resolveFetchPromise();
170 | 
171 |         if (this.state.latestBlockNumber !== null && blockNumber < this.state.latestBlockNumber) {
172 |           // Retry if blockNumber is lower than latestBlockNumber
173 |           log(
174 |             `Stale block returned, retrying in ${this.state.config.staleBlockRetryWait /
175 |               1000} seconds`
176 |           );
177 |           poll.call({
178 |             state: this.state,
179 |             interval: this.state.config.staleBlockRetryWait,
180 |             retry: this.retry ? this.retry + 1 : 1
181 |           });
182 |         } else {
183 |           if (
184 |             this.state.latestBlockNumber === null ||
185 |             (this.state.latestBlockNumber !== null && blockNumber > this.state.latestBlockNumber)
186 |           ) {
187 |             this.state.latestBlockNumber = parseInt(blockNumber);
188 |             state.listeners.block.forEach(({ listener }) => listener(this.state.latestBlockNumber));
189 |           }
190 |           const events = Object.entries(data)
191 |             .filter(([type, value]) => isNewState(type, value, this.state.store))
192 |             .map(([type]) => ({
193 |               type,
194 |               value: dataTransformed[type],
195 |               args: keyToArgMap[type] || []
196 |             }));
197 |           this.state.store = { ...data };
198 |           this.state.storeTransformed = { ...dataTransformed };
199 |           this.state.keyToArgMap = { ...keyToArgMap };
200 |           alertListeners(events);
201 |           poll.call({ state: this.state });
202 |         }
203 |       } catch (err) {
204 |         log('Error: %s', err.message);
205 |         state.listeners.error.forEach(({ listener }) => listener(err, this.state));
206 |         if (!this.state.handler) return;
207 |         // Retry on error
208 |         log(`Error occured, retrying in ${this.state.config.errorRetryWait / 1000} seconds`);
209 |         poll.call({
210 |           state: this.state,
211 |           interval: this.state.config.errorRetryWait,
212 |           retry: this.retry ? this.retry + 1 : 1
213 |         });
214 |       }
215 |     }, interval);
216 |   }
217 | 
218 |   const watcher = {
219 |     tap(transform) {
220 |       log('watcher.tap() called');
221 |       const nextModel = transform([...state.model]);
222 |       state.model = [...nextModel];
223 |       return this.poll();
224 |     },
225 |     poll() {
226 |       log('watcher.poll() called');
227 |       let resolveFetchPromise;
228 |       const fetchPromise = new Promise(resolve => {
229 |         resolveFetchPromise = resolve;
230 |       });
231 |       if (state.watching && (!state.ws || state.ws.readyState === WebSocket.OPEN)) {
232 |         clearTimeout(state.handler);
233 |         state.handler = null;
234 |         poll.call({ state, interval: 0, resolveFetchPromise });
235 |         return fetchPromise;
236 |       }
237 |       return Promise.resolve();
238 |     },
239 |     subscribe(listener) {
240 |       const id = state.id++;
241 |       subscribe(listener, id, false);
242 |       return {
243 |         unsub() {
244 |           state.listeners.subscribe = state.listeners.subscribe.filter(({ id: _id }) => _id !== id);
245 |         }
246 |       };
247 |     },
248 |     batch() {
249 |       return {
250 |         subscribe(listener) {
251 |           const id = state.id++;
252 |           subscribe(listener, id, true);
253 |           return {
254 |             unsub() {
255 |               state.listeners.subscribe = state.listeners.subscribe.filter(({ id: _id }) => _id !== id);
256 |             }
257 |           };
258 |         }
259 |       };
260 |     },
261 |     onNewBlock(listener) {
262 |       const id = state.id++;
263 |       state.latestBlockNumber && listener(state.latestBlockNumber);
264 |       state.listeners.block.push({ listener, id });
265 |       return {
266 |         unsub() {
267 |           state.listeners.block = state.listeners.block.filter(({ id: _id }) => _id !== id);
268 |         }
269 |       };
270 |     },
271 |     onPoll(listener) {
272 |       const id = state.id++;
273 |       state.listeners.poll.push({ listener, id });
274 |       return {
275 |         unsub() {
276 |           state.listeners.poll = state.listeners.poll.filter(({ id: _id }) => _id !== id);
277 |         }
278 |       };
279 |     },
280 |     onError(listener) {
281 |       const id = state.id++;
282 |       state.listeners.error.push({ listener, id });
283 |       return {
284 |         unsub() {
285 |           state.listeners.error = state.listeners.error.filter(({ id: _id }) => _id !== id);
286 |         }
287 |       };
288 |     },
289 |     start() {
290 |       log('watcher.start() called');
291 |       state.watching = true;
292 |       if (!state.ws || state.ws.readyState === WebSocket.OPEN) {
293 |         poll.call({
294 |           state,
295 |           interval: 0,
296 |           resolveFetchPromise: state.initialFetchResolver
297 |         });
298 |       }
299 |       return state.initialFetchPromise;
300 |     },
301 |     stop() {
302 |       log('watcher.stop() called');
303 |       clearTimeout(state.handler);
304 |       state.handler = null;
305 |       clearTimeout(state.wsReconnectHandler);
306 |       state.wsReconnectHandler = null;
307 |       state.watching = false;
308 |     },
309 |     recreate(model, config) {
310 |       log('watcher.recreate() called');
311 |       clearTimeout(state.handler);
312 |       state.handler = null;
313 |       clearTimeout(state.wsReconnectHandler);
314 |       state.wsReconnectHandler = null;
315 |       if (state.ws) destroyWebSocket();
316 |       state.ws = null;
317 |       state.config = prepareConfig(config);
318 |       state.model = [...model];
319 |       state.store = {};
320 |       state.storeTransformed = {};
321 |       state.latestBlockNumber = null;
322 |       state.cancelPromiseId = state.latestPromiseId;
323 |       setupWebSocket();
324 |       if (state.watching && !state.ws) {
325 |         let resolveFetchPromise;
326 |         const fetchPromise = new Promise(resolve => {
327 |           resolveFetchPromise = resolve;
328 |         });
329 |         poll.call({
330 |           state,
331 |           interval: 0,
332 |           resolveFetchPromise
333 |         });
334 |         return fetchPromise;
335 |       }
336 |       return Promise.resolve();
337 |     },
338 |     awaitInitialFetch() {
339 |       return state.initialFetchPromise;
340 |     },
341 |     get initialFetch() {
342 |       return state.initialFetchPromise;
343 |     },
344 |     get schemas() {
345 |       return state.model;
346 |     }
347 |   };
348 | 
349 |   return watcher;
350 | }
351 | 


--------------------------------------------------------------------------------