├── .eslintignore ├── .github └── workflows │ ├── lint.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .releaserc.json ├── .yarnrc ├── LICENSE ├── README.md ├── integration-tests ├── App.test.tsx ├── App.tsx ├── MultichainApp.test.tsx ├── MultichainApp.tsx ├── README.md ├── Updater.tsx ├── consts.ts ├── erc20.json ├── hooks.ts ├── multicall.ts ├── store.ts └── utils.ts ├── package.json ├── src ├── constants.ts ├── context.ts ├── create.ts ├── hooks.test.tsx ├── hooks.ts ├── index.ts ├── reducer.test.ts ├── slice.ts ├── types.ts ├── updater.test.ts ├── updater.tsx ├── utils │ ├── callKeys.test.ts │ ├── callKeys.ts │ ├── callState.test.tsx │ ├── callState.ts │ ├── chunkCalls.test.ts │ ├── chunkCalls.ts │ ├── retry.test.ts │ ├── retry.ts │ └── useDebounce.ts └── validation.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .github 2 | README.md 3 | /src/abi/types 4 | /dist -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | run-linters: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 14 18 | registry-url: https://registry.npmjs.org 19 | 20 | - run: yarn install --frozen-lockfile 21 | 22 | - name: Lint 23 | run: yarn lint 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | # manual trigger 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org 16 | 17 | - run: yarn install --frozen-lockfile 18 | - run: yarn contracts:compile 19 | 20 | - run: yarn test 21 | env: 22 | INFURA_PROJECT_ID: ${{ secrets.INFURA_PROJECT_ID }} 23 | INFURA_PROJECT_SECRET: ${{ secrets.INFURA_PROJECT_SECRET }} 24 | - run: yarn test:e2e 25 | env: 26 | INFURA_PROJECT_ID: ${{ secrets.INFURA_PROJECT_ID }} 27 | INFURA_PROJECT_SECRET: ${{ secrets.INFURA_PROJECT_SECRET }} 28 | 29 | - run: yarn release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_CONFIG_USERCONFIG: /dev/null 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | # manual trigger 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tests: 13 | name: Tests 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v2 18 | with: 19 | node-version: 14 20 | registry-url: https://registry.npmjs.org 21 | 22 | - run: yarn install --frozen-lockfile 23 | - run: yarn contracts:compile 24 | 25 | - name: Unit tests 26 | run: yarn test 27 | env: 28 | INFURA_PROJECT_ID: ${{ secrets.INFURA_PROJECT_ID }} 29 | INFURA_PROJECT_SECRET: ${{ secrets.INFURA_PROJECT_SECRET }} 30 | 31 | - name: Integration tests 32 | run: yarn test:e2e 33 | env: 34 | INFURA_PROJECT_ID: ${{ secrets.INFURA_PROJECT_ID }} 35 | INFURA_PROJECT_SECRET: ${{ secrets.INFURA_PROJECT_SECRET }} 36 | 37 | - name: Typechecking 38 | run: yarn build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # Compiled binary addons (https://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules/ 33 | jspm_packages/ 34 | 35 | # TypeScript cache 36 | *.tsbuildinfo 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional eslint cache 42 | .eslintcache 43 | 44 | # Optional REPL history 45 | .node_repl_history 46 | 47 | # Output of 'npm pack' 48 | *.tgz 49 | 50 | # Yarn Integrity file 51 | .yarn-integrity 52 | 53 | # dotenv environment variables file 54 | .env 55 | .env.test 56 | 57 | # parcel-bundler cache (https://parceljs.org/) 58 | .cache 59 | .parcel-cache 60 | 61 | # yarn v2 62 | .yarn/cache 63 | .yarn/unplugged 64 | .yarn/build-state.yml 65 | .yarn/install-state.gz 66 | .pnp.* 67 | 68 | # generated contract types 69 | /src/abi/types -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github 2 | README.md 3 | /src/abi/types 4 | /dist -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": "main", 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | [ 7 | "@semantic-release/git", 8 | { 9 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 10 | } 11 | ], 12 | "@semantic-release/github", 13 | "@semantic-release/npm" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | ignore-scripts true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Uniswap Labs 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiCall 2 | 3 | A React + Redux library for fetching, batching, and caching chain state via the MultiCall contract. 4 | 5 | ## Setup 6 | 7 | `yarn add @uniswap/redux-multicall` or `npm install @uniswap/redux-multicall` 8 | 9 | ## Usage 10 | 11 | The usage of this library is similar to [RTK Query](https://redux-toolkit.js.org/rtk-query/overview#create-an-api-slice). 12 | 13 | ```js 14 | // Somewhere in your app 15 | export const multicall = createMulticall({ reducerPath: 'multicall' }) 16 | 17 | // In your store's root reducer 18 | export const rootReducer = combineReducers({ 19 | // Other reducers 20 | [multicall.reducerPath]: multicall.reducer 21 | }) 22 | ``` 23 | 24 | To use the updater, you'll need an instance of the Uniswap Multicall2 contract: 25 | 26 | ```js 27 | import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json' 28 | import { Contract } from '@ethersproject/contracts' 29 | import { UniswapInterfaceMulticall } from './abi/types' 30 | 31 | const multicall2Contract = new Contract(address, MulticallABI, provider) as UniswapInterfaceMulticall 32 | ``` 33 | 34 | For a more detailed example, see basic example app in `./integration-tests` 35 | 36 | ## Alpha software 37 | 38 | The latest version of the SDK is used in production in the Uniswap Interface, 39 | but it is considered Alpha software and may contain bugs or change significantly between patch versions. 40 | If you have questions about how to use the SDK, please reach out in the `#dev-chat` channel of the Discord. 41 | Pull requests welcome! 42 | -------------------------------------------------------------------------------- /integration-tests/App.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { act, render, screen, waitFor } from '@testing-library/react' 3 | import { BigNumber } from '@ethersproject/bignumber' 4 | import React from 'react' 5 | import { App } from './App' 6 | import { sleep } from './utils' 7 | 8 | const MAX_BLOCK_AGE = 600_000 // 10 minutes 9 | 10 | describe('Use multicall in test application', () => { 11 | it('Renders correctly initially', () => { 12 | render() 13 | const h1 = screen.getByText('Hello Multicall') // H1 in Home 14 | expect(h1).toBeTruthy() 15 | const missing = screen.queryByText('Does Not Exist') 16 | expect(missing).toBeFalsy() 17 | }) 18 | 19 | it('Performs a single contract multicall query', async () => { 20 | render() 21 | 22 | // Check that block timestamp is correctly retrieved from block 23 | const timestamp1 = await waitFor(() => screen.getByTestId('blockTimestamp'), { timeout: 20_000 }) 24 | expect(timestamp1 && timestamp1?.textContent).toBeTruthy() 25 | const value1 = parseInt(timestamp1.textContent!, 10) * 1000 26 | const now1 = Date.now() 27 | expect(now1 - value1).toBeLessThan(MAX_BLOCK_AGE) 28 | 29 | // Wait for an updated block timestamp 30 | await act(() => sleep(12_000)) 31 | 32 | // Check that the block timestamp has updated correctly 33 | const timestamp2 = await waitFor(() => screen.getByTestId('blockTimestamp'), { timeout: 1_000 }) 34 | expect(timestamp2 && timestamp2.textContent).toBeTruthy() 35 | const value2 = parseInt(timestamp1.textContent!, 10) * 1000 36 | const now2 = Date.now() 37 | expect(now2 - value2).toBeLessThan(MAX_BLOCK_AGE) 38 | }, 35_000) 39 | 40 | it('Performs a multi contract multicall query', async () => { 41 | render() 42 | 43 | // Check that max token balance is correctly retrieved 44 | const balance = await waitFor(() => screen.getByTestId('maxTokenBalance'), { timeout: 20_000 }) 45 | expect(balance && balance?.textContent).toBeTruthy() 46 | const value1 = BigNumber.from(balance.textContent) 47 | expect(value1.gt(0)).toBeTruthy() 48 | }, 25_000) 49 | }) 50 | -------------------------------------------------------------------------------- /integration-tests/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { ChainId } from './consts' 4 | import { getProvider, useCurrentBlockTimestamp, useLatestBlock, useMaxTokenBalance } from './hooks' 5 | import { store } from './store' 6 | import { Updater } from './Updater' 7 | 8 | export function App() { 9 | const provider = getProvider(ChainId.MAINNET) 10 | const blockNumber = useLatestBlock(provider) 11 | return ( 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | interface HomeProps { 20 | chainId: ChainId 21 | blockNumber: number | undefined 22 | } 23 | 24 | function Home({ chainId, blockNumber }: HomeProps) { 25 | const blockTimestamp = useCurrentBlockTimestamp(chainId, blockNumber) 26 | const maxTokenBalance = useMaxTokenBalance(chainId, blockNumber) 27 | return ( 28 |
29 |

Hello Multicall

30 |

Block Timestamp:

31 | {blockTimestamp &&

{blockTimestamp}

} 32 |

Max Token Balance:

33 | {maxTokenBalance &&

{maxTokenBalance}

} 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /integration-tests/MultichainApp.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { act, render, screen, waitFor } from '@testing-library/react' 3 | import React from 'react' 4 | import { MultichainApp } from './MultichainApp' 5 | import { sleep } from './utils' 6 | 7 | const MAX_BLOCK_AGE = 600_000 // 10 minutes 8 | 9 | function parseTimestamp(value: string | null) { 10 | if (!value) throw new Error('No timestamp to parse') 11 | return value.split(',').map((v) => parseInt(v, 10) * 1000) 12 | } 13 | 14 | describe('Use multicall in test multichain application', () => { 15 | it('Renders correctly initially', () => { 16 | render() 17 | const h1 = screen.getByText('Hello Multichain Multicall') // H1 in Home 18 | expect(h1).toBeTruthy() 19 | const missing = screen.queryByText('Does Not Exist') 20 | expect(missing).toBeFalsy() 21 | }) 22 | 23 | it('Performs a multichain single contract multicall query', async () => { 24 | render() 25 | 26 | // Check that block timestamp is correctly retrieved from block 27 | const timestamps1 = await waitFor(() => screen.getByTestId('blockTimestamps'), { timeout: 20_000 }) 28 | expect(timestamps1 && timestamps1?.textContent).toBeTruthy() 29 | const values1 = parseTimestamp(timestamps1.textContent) 30 | const now1 = Date.now() 31 | expect(values1.length).toEqual(2) 32 | expect(now1 - values1[0]).toBeLessThan(MAX_BLOCK_AGE) 33 | expect(now1 - values1[1]).toBeLessThan(MAX_BLOCK_AGE) 34 | 35 | // Wait for an updated block timestamp 36 | await act(() => sleep(12_000)) 37 | 38 | // Check that the block timestamp has updated correctly 39 | const timestamps2 = await waitFor(() => screen.getByTestId('blockTimestamps'), { timeout: 1_000 }) 40 | expect(timestamps2 && timestamps2.textContent).toBeTruthy() 41 | const values2 = parseTimestamp(timestamps2.textContent) 42 | const now2 = Date.now() 43 | expect(values2.length).toEqual(2) 44 | expect(now2 - values2[0]).toBeLessThan(MAX_BLOCK_AGE) 45 | expect(now2 - values2[1]).toBeLessThan(MAX_BLOCK_AGE) 46 | }, 35_000) 47 | }) 48 | -------------------------------------------------------------------------------- /integration-tests/MultichainApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { Provider } from 'react-redux' 3 | import { ChainId } from './consts' 4 | import { getProvider, useCurrentBlockTimestampMultichain, useLatestBlock } from './hooks' 5 | import { store } from './store' 6 | import { Updater } from './Updater' 7 | 8 | export function MultichainApp() { 9 | const providerMainnet = getProvider(ChainId.MAINNET) 10 | const providerGoerli = getProvider(ChainId.GOERLI) 11 | const blockNumberMainnet = useLatestBlock(providerMainnet) 12 | const blockNumberGoerli = useLatestBlock(providerGoerli) 13 | const chains = useMemo(() => [ChainId.MAINNET, ChainId.GOERLI], []) 14 | const blocks = useMemo(() => [blockNumberMainnet, blockNumberGoerli], [blockNumberMainnet, blockNumberGoerli]) 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | interface HomeProps { 25 | chainIds: Array 26 | blockNumbers: Array 27 | } 28 | 29 | function Home({ chainIds, blockNumbers }: HomeProps) { 30 | const blockTimestamps = useCurrentBlockTimestampMultichain(chainIds, blockNumbers) 31 | const isReady = blockTimestamps.filter((b) => !!b).length >= 2 32 | return ( 33 |
34 |

Hello Multichain Multicall

35 |

Block Timestamps:

36 | {isReady &&

{blockTimestamps.join(',')}

} 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /integration-tests/README.md: -------------------------------------------------------------------------------- 1 | # Test App 2 | 3 | A basic react + redux app to test realistic usage of Multicall. 4 | See `App.test.tsx` for details. 5 | -------------------------------------------------------------------------------- /integration-tests/Updater.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ChainId } from './consts' 3 | import { useContract } from './hooks' 4 | import { multicall } from './multicall' 5 | 6 | interface Props { 7 | chainId: ChainId 8 | blockNumber: number | undefined 9 | blocksPerFetch?: number 10 | } 11 | 12 | export function Updater({ chainId, blockNumber, blocksPerFetch }: Props) { 13 | const contract = useContract(chainId) 14 | const listenerOptions = blocksPerFetch ? { blocksPerFetch } : undefined 15 | return ( 16 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /integration-tests/consts.ts: -------------------------------------------------------------------------------- 1 | export const NULL_ADDRESS = '0x0000000000000000000000000000000000000000' 2 | export const MULTICALL_ADDRESS = '0x1F98415757620B543A52E61c46B32eB19261F984' // Address on Mainnet 3 | export const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F' 4 | export const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' 5 | export const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7' 6 | 7 | export enum ChainId { 8 | MAINNET = 1, 9 | GOERLI = 5, 10 | } 11 | -------------------------------------------------------------------------------- /integration-tests/erc20.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "name", 6 | "outputs": [{ "name": "", "type": "string" }], 7 | "payable": false, 8 | "stateMutability": "view", 9 | "type": "function" 10 | }, 11 | { 12 | "constant": false, 13 | "inputs": [ 14 | { "name": "_spender", "type": "address" }, 15 | { "name": "_value", "type": "uint256" } 16 | ], 17 | "name": "approve", 18 | "outputs": [{ "name": "", "type": "bool" }], 19 | "payable": false, 20 | "stateMutability": "nonpayable", 21 | "type": "function" 22 | }, 23 | { 24 | "constant": true, 25 | "inputs": [], 26 | "name": "totalSupply", 27 | "outputs": [{ "name": "", "type": "uint256" }], 28 | "payable": false, 29 | "stateMutability": "view", 30 | "type": "function" 31 | }, 32 | { 33 | "constant": false, 34 | "inputs": [ 35 | { "name": "_from", "type": "address" }, 36 | { "name": "_to", "type": "address" }, 37 | { "name": "_value", "type": "uint256" } 38 | ], 39 | "name": "transferFrom", 40 | "outputs": [{ "name": "", "type": "bool" }], 41 | "payable": false, 42 | "stateMutability": "nonpayable", 43 | "type": "function" 44 | }, 45 | { 46 | "constant": true, 47 | "inputs": [], 48 | "name": "decimals", 49 | "outputs": [{ "name": "", "type": "uint8" }], 50 | "payable": false, 51 | "stateMutability": "view", 52 | "type": "function" 53 | }, 54 | { 55 | "constant": true, 56 | "inputs": [{ "name": "_owner", "type": "address" }], 57 | "name": "balanceOf", 58 | "outputs": [{ "name": "balance", "type": "uint256" }], 59 | "payable": false, 60 | "stateMutability": "view", 61 | "type": "function" 62 | }, 63 | { 64 | "constant": true, 65 | "inputs": [], 66 | "name": "symbol", 67 | "outputs": [{ "name": "", "type": "string" }], 68 | "payable": false, 69 | "stateMutability": "view", 70 | "type": "function" 71 | }, 72 | { 73 | "constant": false, 74 | "inputs": [ 75 | { "name": "_to", "type": "address" }, 76 | { "name": "_value", "type": "uint256" } 77 | ], 78 | "name": "transfer", 79 | "outputs": [{ "name": "", "type": "bool" }], 80 | "payable": false, 81 | "stateMutability": "nonpayable", 82 | "type": "function" 83 | }, 84 | { 85 | "constant": true, 86 | "inputs": [ 87 | { "name": "_owner", "type": "address" }, 88 | { "name": "_spender", "type": "address" } 89 | ], 90 | "name": "allowance", 91 | "outputs": [{ "name": "", "type": "uint256" }], 92 | "payable": false, 93 | "stateMutability": "view", 94 | "type": "function" 95 | }, 96 | { "payable": true, "stateMutability": "payable", "type": "fallback" }, 97 | { 98 | "anonymous": false, 99 | "inputs": [ 100 | { "indexed": true, "name": "owner", "type": "address" }, 101 | { "indexed": true, "name": "spender", "type": "address" }, 102 | { "indexed": false, "name": "value", "type": "uint256" } 103 | ], 104 | "name": "Approval", 105 | "type": "event" 106 | }, 107 | { 108 | "anonymous": false, 109 | "inputs": [ 110 | { "indexed": true, "name": "from", "type": "address" }, 111 | { "indexed": true, "name": "to", "type": "address" }, 112 | { "indexed": false, "name": "value", "type": "uint256" } 113 | ], 114 | "name": "Transfer", 115 | "type": "event" 116 | } 117 | ] 118 | -------------------------------------------------------------------------------- /integration-tests/hooks.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react' 2 | import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json' 3 | import { BigNumber } from '@ethersproject/bignumber' 4 | import { Contract } from '@ethersproject/contracts' 5 | import { InfuraProvider, JsonRpcProvider } from '@ethersproject/providers' 6 | import { Interface } from '@ethersproject/abi' 7 | import { config } from 'dotenv' 8 | import { useEffect, useMemo, useState } from 'react' 9 | 10 | import { UniswapInterfaceMulticall } from '../src/abi/types' 11 | import { ChainId, DAI_ADDRESS, MULTICALL_ADDRESS, NULL_ADDRESS, USDC_ADDRESS, USDT_ADDRESS } from './consts' 12 | import ERC20_ABI from './erc20.json' 13 | import { useMultiChainSingleContractSingleData, useMultipleContractSingleData, useSingleCallResult } from './multicall' 14 | 15 | config() 16 | if (!process.env.INFURA_PROJECT_ID) throw new Error('Tests require process.env.INFURA_PROJECT_ID') 17 | const projectId = process.env.INFURA_PROJECT_ID 18 | const projectSecret = process.env.INFURA_PROJECT_SECRET 19 | const project = projectSecret ? { projectId, projectSecret } : projectId 20 | 21 | const providerCache: Partial> = {} 22 | const MulticallInterface = new Interface(MulticallABI) 23 | const ERC20Interface = new Interface(ERC20_ABI) 24 | 25 | export function useContract(chainId: ChainId) { 26 | return useMemo(() => { 27 | return new Contract(MULTICALL_ADDRESS, MulticallABI, getProvider(chainId)) as UniswapInterfaceMulticall 28 | }, [chainId]) 29 | } 30 | 31 | export function useLatestBlock(provider: JsonRpcProvider) { 32 | const [blockNumber, setBlockNumber] = useState(undefined) 33 | useEffect(() => { 34 | if (!provider) return 35 | const onBlock = (num: number) => act(() => setBlockNumber(num)) 36 | provider.on('block', onBlock) 37 | return () => { 38 | provider.off('block', onBlock) 39 | } 40 | }, [provider, setBlockNumber]) 41 | return blockNumber 42 | } 43 | 44 | export function useCurrentBlockTimestamp(chainId: ChainId, blockNumber: number | undefined): string | undefined { 45 | const contract = useContract(chainId) 46 | const callState = useSingleCallResult(chainId, blockNumber, contract, 'getCurrentBlockTimestamp') 47 | return callState.result?.[0]?.toString() 48 | } 49 | 50 | export function useCurrentBlockTimestampMultichain( 51 | chainIds: ChainId[], 52 | blockNumbers: Array 53 | ): Array { 54 | const chainToBlock = useMemo(() => { 55 | return chainIds.reduce((result, chainId, i) => { 56 | result[chainId] = blockNumbers[i] 57 | return result 58 | }, {} as Record) 59 | }, [chainIds, blockNumbers]) 60 | 61 | const chainToAddress = useMemo(() => { 62 | return chainIds.reduce((result, chainId) => { 63 | result[chainId] = MULTICALL_ADDRESS 64 | return result 65 | }, {} as Record) 66 | }, [chainIds]) 67 | 68 | const chainToCallState = useMultiChainSingleContractSingleData( 69 | chainToBlock, 70 | chainToAddress, 71 | MulticallInterface, 72 | 'getCurrentBlockTimestamp' 73 | ) 74 | 75 | return Object.values(chainToCallState).map((callState) => callState.result?.[0]?.toString()) 76 | } 77 | 78 | export function useMaxTokenBalance(chainId: ChainId, blockNumber: number | undefined): string | undefined { 79 | const { contracts, accounts } = useMemo( 80 | () => ({ 81 | // The first element is intentionally empty to test sparse arrays; see https://github.com/Uniswap/redux-multicall/pull/21. 82 | // eslint-disable-next-line no-sparse-arrays 83 | contracts: [, USDC_ADDRESS, USDT_ADDRESS, DAI_ADDRESS], 84 | accounts: [NULL_ADDRESS], 85 | }), 86 | [] 87 | ) 88 | const results = useMultipleContractSingleData(chainId, blockNumber, contracts, ERC20Interface, 'balanceOf', accounts) 89 | let max 90 | for (const result of results) { 91 | if (!result.valid || !result.result?.length) continue 92 | const value = BigNumber.from(result.result[0]) 93 | if (!max || value.gt(max)) max = value 94 | } 95 | return max?.toString() 96 | } 97 | 98 | export function getProvider(chainId: ChainId) { 99 | if (providerCache[chainId]) return providerCache[chainId]! 100 | const name = getInfuraChainName(chainId) 101 | providerCache[chainId] = new InfuraProvider(name, project) 102 | providerCache[chainId]?.once('error', (e) => { 103 | throw e 104 | }) 105 | return providerCache[chainId]! 106 | } 107 | 108 | export function getInfuraChainName(chainId: ChainId) { 109 | switch (chainId) { 110 | case ChainId.MAINNET: 111 | return 'homestead' 112 | case ChainId.GOERLI: 113 | return 'goerli' 114 | default: 115 | throw new Error(`Unsupported eth infura chainId for ${chainId}`) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /integration-tests/multicall.ts: -------------------------------------------------------------------------------- 1 | import { createMulticall } from '../src/create' 2 | 3 | // Create a multicall instance with default settings 4 | export const multicall = createMulticall() 5 | export const { 6 | useMultipleContractSingleData, 7 | useSingleCallResult, 8 | useSingleContractMultipleData, 9 | useSingleContractWithCallData, 10 | useMultiChainMultiContractSingleData, 11 | useMultiChainSingleContractSingleData, 12 | } = multicall.hooks 13 | -------------------------------------------------------------------------------- /integration-tests/store.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from '@reduxjs/toolkit' 2 | import { multicall } from './multicall' 3 | 4 | export const rootReducer = combineReducers({ 5 | [multicall.reducerPath]: multicall.reducer, 6 | }) 7 | 8 | export const store = configureStore({ 9 | reducer: rootReducer, 10 | }) 11 | -------------------------------------------------------------------------------- /integration-tests/utils.ts: -------------------------------------------------------------------------------- 1 | export function sleep(milliseconds: number): Promise { 2 | return new Promise((resolve) => setTimeout(() => resolve(undefined), milliseconds)) 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uniswap/redux-multicall", 3 | "version": "1.1.6", 4 | "description": "A React + Redux lib for fetching and caching chain state via the MultiCall contract", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "repository": "https://github.com/Uniswap/redux-multicall", 11 | "keywords": [ 12 | "uniswap", 13 | "ethereum" 14 | ], 15 | "module": "dist/redux-multicall.esm.js", 16 | "license": "MIT", 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "scripts": { 21 | "contracts:compile": "typechain --target ethers-v5 --out-dir src/abi/types './node_modules/@uniswap/v3-periphery/artifacts/contracts/**/*Multicall*.json'", 22 | "lint": "tsdx lint .", 23 | "test": "tsdx test src", 24 | "test:e2e": "tsdx test integration-tests", 25 | "build": "tsdx build", 26 | "watch": "tsdx watch", 27 | "prepublishOnly": "yarn contracts:compile && yarn build", 28 | "release": "semantic-release" 29 | }, 30 | "devDependencies": { 31 | "@ethersproject/abi": "5", 32 | "@ethersproject/bignumber": "5", 33 | "@ethersproject/contracts": "5", 34 | "@ethersproject/providers": "5", 35 | "@reduxjs/toolkit": "^1.6.2", 36 | "@semantic-release/changelog": "^6.0.1", 37 | "@semantic-release/git": "^10.0.1", 38 | "@testing-library/jest-dom": "^5.15.1", 39 | "@testing-library/react": "^12.1.2", 40 | "@typechain/ethers-v5": "^7.2.0", 41 | "@types/jest": "^25.2.1", 42 | "@types/react": "^17.0.28", 43 | "@types/react-dom": "^17.0.14", 44 | "@uniswap/v3-periphery": "^1.3.0", 45 | "dotenv": "^16.0.3", 46 | "eslint-plugin-prettier": "3.4.1", 47 | "ethers": "^5.4.7", 48 | "prettier": "^2.4.1", 49 | "react": "^17.0.2", 50 | "react-dom": "^17.0.2", 51 | "react-redux": "^7.2.5", 52 | "semantic-release": "^19.0.5", 53 | "tsdx": "^0.14.1", 54 | "typechain": "^5.2.0" 55 | }, 56 | "peerDependencies": { 57 | "@ethersproject/abi": "5", 58 | "@ethersproject/bignumber": "5", 59 | "@ethersproject/contracts": "5", 60 | "@reduxjs/toolkit": "1", 61 | "react": ">=17", 62 | "react-redux": ">=7" 63 | }, 64 | "engines": { 65 | "node": ">=10" 66 | }, 67 | "prettier": { 68 | "printWidth": 120, 69 | "semi": false, 70 | "singleQuote": true 71 | }, 72 | "eslintConfig": { 73 | "rules": { 74 | "no-useless-computed-key": "off" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { CallResult, CallState, ListenerOptions } from './types' 2 | 3 | export const DEFAULT_BLOCKS_PER_FETCH = 1 4 | export const DEFAULT_CALL_GAS_REQUIRED = 1_000_000 5 | export const DEFAULT_CHUNK_GAS_REQUIRED = 200_000 6 | export const CHUNK_GAS_LIMIT = 100_000_000 7 | export const CONSERVATIVE_BLOCK_GAS_LIMIT = 10_000_000 // conservative, hard-coded estimate of the current block gas limit 8 | 9 | // Consts for hooks 10 | export const INVALID_RESULT: CallResult = { valid: false, blockNumber: undefined, data: undefined } 11 | export const NEVER_RELOAD: ListenerOptions = { 12 | blocksPerFetch: Infinity, 13 | } 14 | 15 | export const INVALID_CALL_STATE: CallState = { 16 | valid: false, 17 | result: undefined, 18 | loading: false, 19 | syncing: false, 20 | error: false, 21 | } 22 | export const LOADING_CALL_STATE: CallState = { 23 | valid: true, 24 | result: undefined, 25 | loading: true, 26 | syncing: true, 27 | error: false, 28 | } 29 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import type { MulticallActions } from './slice' 2 | 3 | // The shared settings and dynamically created utilities 4 | // required for the hooks and components 5 | export interface MulticallContext { 6 | reducerPath: string 7 | actions: MulticallActions 8 | } 9 | -------------------------------------------------------------------------------- /src/create.ts: -------------------------------------------------------------------------------- 1 | import type { MulticallContext } from './context' 2 | import { 3 | useMultiChainMultiContractSingleData as _useMultiChainMultiContractSingleData, 4 | useMultiChainSingleContractSingleData as _useMultiChainSingleContractSingleData, 5 | useMultipleContractSingleData as _useMultipleContractSingleData, 6 | useSingleCallResult as _useSingleCallResult, 7 | useSingleContractMultipleData as _useSingleContractMultipleData, 8 | useSingleContractWithCallData as _useSingleContractWithCallData, 9 | } from './hooks' 10 | import { createMulticallSlice } from './slice' 11 | import { createUpdater } from './updater' 12 | 13 | type RemoveFirstFromTuple = T['length'] extends 0 14 | ? undefined 15 | : ((...b: T) => void) extends (a: any, ...b: infer I) => void 16 | ? I 17 | : [] 18 | type ParamsWithoutContext any> = RemoveFirstFromTuple> 19 | 20 | export interface MulticallOptions { 21 | reducerPath?: string 22 | // More options can be added here as multicall's capabilities are extended 23 | } 24 | 25 | // Inspired by RTK Query's createApi 26 | export function createMulticall(options?: MulticallOptions) { 27 | const reducerPath = options?.reducerPath ?? 'multicall' 28 | const slice = createMulticallSlice(reducerPath) 29 | const { actions, reducer } = slice 30 | const context: MulticallContext = { reducerPath, actions } 31 | 32 | const useMultipleContractSingleData = (...args: ParamsWithoutContext) => 33 | _useMultipleContractSingleData(context, ...args) 34 | const useSingleContractMultipleData = (...args: ParamsWithoutContext) => 35 | _useSingleContractMultipleData(context, ...args) 36 | const useSingleContractWithCallData = (...args: ParamsWithoutContext) => 37 | _useSingleContractWithCallData(context, ...args) 38 | const useSingleCallResult = (...args: ParamsWithoutContext) => 39 | _useSingleCallResult(context, ...args) 40 | const useMultiChainMultiContractSingleData = ( 41 | ...args: ParamsWithoutContext 42 | ) => _useMultiChainMultiContractSingleData(context, ...args) 43 | const useMultiChainSingleContractSingleData = ( 44 | ...args: ParamsWithoutContext 45 | ) => _useMultiChainSingleContractSingleData(context, ...args) 46 | const hooks = { 47 | useMultipleContractSingleData, 48 | useSingleContractMultipleData, 49 | useSingleContractWithCallData, 50 | useSingleCallResult, 51 | useMultiChainMultiContractSingleData, 52 | useMultiChainSingleContractSingleData, 53 | } 54 | 55 | const Updater = createUpdater(context) 56 | 57 | return { 58 | reducerPath, 59 | reducer, 60 | actions, 61 | hooks, 62 | Updater, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { act } from 'react-dom/test-utils' 3 | import { combineReducers, configureStore, Store } from '@reduxjs/toolkit' 4 | import React, { useRef } from 'react' 5 | import { render, unmountComponentAtNode } from 'react-dom' 6 | import { Provider } from 'react-redux' 7 | 8 | import { useCallsDataSubscription } from './hooks' 9 | import { createMulticallSlice, MulticallActions } from './slice' 10 | import { toCallKey } from './utils/callKeys' 11 | import { MulticallContext } from './context' 12 | import { Call, ListenerOptions } from './types' 13 | 14 | describe('multicall hooks', () => { 15 | let container: HTMLDivElement | null = null 16 | let actions: MulticallActions 17 | let context: MulticallContext 18 | let store: Store 19 | beforeEach(() => { 20 | container = document.createElement('div') 21 | document.body.appendChild(container) 22 | const slice = createMulticallSlice('multicall') 23 | actions = slice.actions 24 | context = { reducerPath: 'multicall', actions } 25 | store = configureStore({ reducer: combineReducers({ multicall: slice.reducer }) }) 26 | }) 27 | afterEach(() => { 28 | if (container) { 29 | unmountComponentAtNode(container) 30 | container.remove() 31 | } 32 | container = null 33 | }) 34 | 35 | function updateCallResult(call: Call, result: string) { 36 | store.dispatch( 37 | actions.updateMulticallResults({ 38 | chainId: 1, 39 | blockNumber: 1, 40 | results: { [toCallKey(call)]: result }, 41 | }) 42 | ) 43 | } 44 | 45 | describe('useCallsDataSubscription', () => { 46 | function Caller({ 47 | calls, 48 | multicallContext, 49 | listenerOptions, 50 | }: { 51 | calls: Call[] 52 | multicallContext?: MulticallContext | any 53 | listenerOptions?: ListenerOptions 54 | }) { 55 | const data = useCallsDataSubscription(multicallContext ?? context, 1, calls, listenerOptions) 56 | return <>{calls.map((call, i) => `${toCallKey(call)}:${data[i].data}`).join(';')} 57 | } 58 | 59 | describe('stabilizes values', () => { 60 | it('returns data matching calls', () => { 61 | const callA = { address: 'a', callData: '' } 62 | const callB = { address: 'b', callData: '' } 63 | updateCallResult(callA, '0xa') 64 | updateCallResult(callB, '0xb') 65 | 66 | render( 67 | 68 | 69 | , 70 | container 71 | ) 72 | expect(container?.textContent).toBe('a-:0xa') 73 | 74 | render( 75 | 76 | 77 | , 78 | container 79 | ) 80 | expect(container?.textContent).toBe('b-:0xb') 81 | 82 | render( 83 | 84 | 85 | , 86 | container 87 | ) 88 | expect(container?.textContent).toBe('a-:0xa;b-:0xb') 89 | }) 90 | 91 | it('returns updates immediately', () => { 92 | const call = { address: 'a', callData: '' } 93 | updateCallResult(call, '0xa') 94 | 95 | render( 96 | 97 | 98 | , 99 | container 100 | ) 101 | expect(container?.textContent).toBe('a-:0xa') 102 | 103 | updateCallResult(call, '0xb') 104 | expect(container?.textContent).toBe('a-:0xb') 105 | }) 106 | 107 | it('ignores subsequent updates if data is stable', () => { 108 | function Caller({ calls }: { calls: Call[] }) { 109 | const data = useCallsDataSubscription(context, 1, calls) 110 | const { current: initialData } = useRef(data) 111 | return <>{(data === initialData).toString()} 112 | } 113 | const mock = jest.fn(Caller) 114 | const MockCaller: typeof Caller = mock 115 | 116 | const call = { address: 'a', callData: '' } 117 | updateCallResult(call, '0xa') 118 | 119 | render( 120 | 121 | 122 | , 123 | container 124 | ) 125 | expect(container?.textContent).toBe('true') 126 | 127 | // stable update 128 | updateCallResult(call, '0xa') 129 | expect(container?.textContent).toBe('true') 130 | 131 | // unrelated update 132 | updateCallResult({ address: 'b', callData: '' }, '0xb') 133 | expect(container?.textContent).toBe('true') 134 | }) 135 | }) 136 | 137 | describe('utilizes correct blocksPerFetch values from defaultListenerOptions in store', () => { 138 | it('utilizes blocksPerFetch configured in defaultListenerOptions in store', () => { 139 | const callA = { address: 'a', callData: '' } 140 | const chainId = 1 141 | const blocksPerFetch = 10 142 | updateCallResult(callA, '0xa') 143 | 144 | store.dispatch( 145 | actions.updateListenerOptions({ 146 | chainId, 147 | listenerOptions: { 148 | blocksPerFetch, 149 | }, 150 | }) 151 | ) 152 | 153 | const mockContext = { 154 | reducerPath: 'multicall', 155 | actions: { 156 | addMulticallListeners: jest 157 | .fn() 158 | .mockImplementation((arg: Object) => ({ type: 'multicall/addMulticallListeners', payload: arg })), 159 | removeMulticallListeners: jest 160 | .fn() 161 | .mockImplementation((arg: Object) => ({ type: 'multicall/removeMulticallListeners', payload: arg })), 162 | }, 163 | } 164 | 165 | act(() => { 166 | render( 167 | 168 | 169 | , 170 | container 171 | ) 172 | }) 173 | 174 | expect(mockContext.actions.addMulticallListeners).toHaveBeenCalledWith({ 175 | chainId, 176 | calls: [callA], 177 | options: { blocksPerFetch }, 178 | }) 179 | }) 180 | 181 | it('overrides blocksPerFetch configured in defaultListenerOptions in store when blocksPerFetch param is provided', () => { 182 | const callA = { address: 'a', callData: '' } 183 | const chainId = 1 184 | const blocksPerFetch = 10 185 | updateCallResult(callA, '0xa') 186 | 187 | store.dispatch( 188 | actions.updateListenerOptions({ 189 | chainId, 190 | listenerOptions: { 191 | blocksPerFetch, 192 | }, 193 | }) 194 | ) 195 | 196 | const mockContext = { 197 | reducerPath: 'multicall', 198 | actions: { 199 | addMulticallListeners: jest 200 | .fn() 201 | .mockImplementation((arg: Object) => ({ type: 'multicall/addMulticallListeners', payload: arg })), 202 | removeMulticallListeners: jest 203 | .fn() 204 | .mockImplementation((arg: Object) => ({ type: 'multicall/removeMulticallListeners', payload: arg })), 205 | }, 206 | } 207 | 208 | act(() => { 209 | render( 210 | 211 | 212 | , 213 | container 214 | ) 215 | }) 216 | 217 | expect(mockContext.actions.addMulticallListeners).toHaveBeenCalledWith({ 218 | chainId, 219 | calls: [callA], 220 | options: { blocksPerFetch: 5 }, 221 | }) 222 | }) 223 | 224 | it('defaults blocksPerFetch to DEFAULT_BLOCK_PER_FETCH constant when blocksPerFetch param is undefined and no defaultListenerOptions is provided', () => { 225 | const callA = { address: 'a', callData: '' } 226 | updateCallResult(callA, '0xa') 227 | 228 | const mockContext = { 229 | reducerPath: 'multicall', 230 | actions: { 231 | addMulticallListeners: jest 232 | .fn() 233 | .mockImplementation((arg: Object) => ({ type: 'multicall/addMulticallListeners', payload: arg })), 234 | removeMulticallListeners: jest 235 | .fn() 236 | .mockImplementation((arg: Object) => ({ type: 'multicall/removeMulticallListeners', payload: arg })), 237 | }, 238 | } 239 | 240 | act(() => { 241 | render( 242 | 243 | 244 | , 245 | container 246 | ) 247 | }) 248 | 249 | expect(mockContext.actions.addMulticallListeners).toHaveBeenCalledWith({ 250 | chainId: 1, 251 | calls: [callA], 252 | options: { blocksPerFetch: 1 }, 253 | }) 254 | }) 255 | }) 256 | }) 257 | }) 258 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from '@ethersproject/contracts' 2 | import { Interface } from '@ethersproject/abi' 3 | import { useCallback, useEffect, useMemo, useRef } from 'react' 4 | import { batch, useDispatch, useSelector } from 'react-redux' 5 | import { INVALID_CALL_STATE, INVALID_RESULT, DEFAULT_BLOCKS_PER_FETCH } from './constants' 6 | import type { MulticallContext } from './context' 7 | import type { Call, CallResult, CallState, ListenerOptions, ListenerOptionsWithGas, WithMulticallState } from './types' 8 | import { callKeysToCalls, callsToCallKeys, toCallKey } from './utils/callKeys' 9 | import { toCallState, useCallStates } from './utils/callState' 10 | import { isValidMethodArgs, MethodArg } from './validation' 11 | 12 | type OptionalMethodInputs = Array | undefined 13 | 14 | // the lowest level call for subscribing to contract data 15 | export function useCallsDataSubscription( 16 | context: MulticallContext, 17 | chainId: number | undefined, 18 | calls: Array, 19 | listenerOptions?: ListenerOptions 20 | ): CallResult[] { 21 | const { reducerPath, actions } = context 22 | const callResults = useSelector((state: WithMulticallState) => state[reducerPath].callResults) 23 | const defaultListenerOptions = useSelector((state: WithMulticallState) => state[reducerPath].listenerOptions) 24 | const dispatch = useDispatch() 25 | const serializedCallKeys: string = useMemo(() => JSON.stringify(callsToCallKeys(calls)), [calls]) 26 | 27 | // update listeners when there is an actual change that persists for at least 100ms 28 | useEffect(() => { 29 | const callKeys: string[] = JSON.parse(serializedCallKeys) 30 | const calls = callKeysToCalls(callKeys) 31 | if (!chainId || !calls) return 32 | const blocksPerFetchFromState = (defaultListenerOptions ?? {})[chainId]?.blocksPerFetch 33 | const blocksPerFetchForChain = 34 | listenerOptions?.blocksPerFetch ?? blocksPerFetchFromState ?? DEFAULT_BLOCKS_PER_FETCH 35 | 36 | dispatch( 37 | actions.addMulticallListeners({ 38 | chainId, 39 | calls, 40 | options: { blocksPerFetch: blocksPerFetchForChain }, 41 | }) 42 | ) 43 | 44 | return () => { 45 | dispatch( 46 | actions.removeMulticallListeners({ 47 | chainId, 48 | calls, 49 | options: { blocksPerFetch: blocksPerFetchForChain }, 50 | }) 51 | ) 52 | } 53 | }, [actions, chainId, dispatch, listenerOptions, serializedCallKeys, defaultListenerOptions]) 54 | 55 | const lastResults = useRef([]) 56 | return useMemo(() => { 57 | let isChanged = lastResults.current.length !== calls.length 58 | 59 | // Construct results using a for-loop to handle sparse arrays. 60 | // Array.prototype.map would skip empty entries. 61 | let results: CallResult[] = [] 62 | for (let i = 0; i < calls.length; ++i) { 63 | const call = calls[i] 64 | let result = INVALID_RESULT 65 | if (chainId && call) { 66 | const callResult = callResults[chainId]?.[toCallKey(call)] 67 | result = { 68 | valid: true, 69 | data: callResult?.data && callResult.data !== '0x' ? callResult.data : undefined, 70 | blockNumber: callResult?.blockNumber, 71 | } 72 | } 73 | 74 | isChanged = isChanged || !areCallResultsEqual(result, lastResults.current[i]) 75 | results.push(result) 76 | } 77 | 78 | // Force the results to be referentially stable if they have not changed. 79 | // This is necessary because *all* callResults are passed as deps when initially memoizing the results. 80 | if (isChanged) { 81 | lastResults.current = results 82 | } 83 | return lastResults.current 84 | }, [callResults, calls, chainId]) 85 | } 86 | 87 | function areCallResultsEqual(a: CallResult, b: CallResult) { 88 | return a.valid === b.valid && a.data === b.data && a.blockNumber === b.blockNumber 89 | } 90 | 91 | // Similar to useCallsDataSubscription above but for subscribing to 92 | // calls to multiple chains at once 93 | function useMultichainCallsDataSubscription( 94 | context: MulticallContext, 95 | chainToCalls: Record>, 96 | listenerOptions?: ListenerOptions 97 | ): Record { 98 | const { reducerPath, actions } = context 99 | const callResults = useSelector((state: WithMulticallState) => state[reducerPath].callResults) 100 | const defaultListenerOptions = useSelector((state: WithMulticallState) => state[reducerPath].listenerOptions) 101 | const dispatch = useDispatch() 102 | 103 | const serializedCallKeys: string = useMemo(() => { 104 | const sortedChainIds = getChainIds(chainToCalls).sort() 105 | const chainCallKeysTuple = sortedChainIds.map((chainId) => { 106 | const calls = chainToCalls[chainId] 107 | const callKeys = callsToCallKeys(calls) 108 | // Note, using a tuple to ensure consistent order when serialized 109 | return [chainId, callKeys] 110 | }) 111 | return JSON.stringify(chainCallKeysTuple) 112 | }, [chainToCalls]) 113 | 114 | useEffect(() => { 115 | const chainCallKeysTuples: Array<[number, string[]]> = JSON.parse(serializedCallKeys) 116 | if (!chainCallKeysTuples?.length) return 117 | 118 | batch(() => { 119 | for (const [chainId, callKeys] of chainCallKeysTuples) { 120 | const calls = callKeysToCalls(callKeys) 121 | if (!calls?.length) continue 122 | const blocksPerFetchFromState = (defaultListenerOptions ?? {})[chainId]?.blocksPerFetch 123 | const blocksPerFetchForChain = 124 | listenerOptions?.blocksPerFetch ?? blocksPerFetchFromState ?? DEFAULT_BLOCKS_PER_FETCH 125 | 126 | dispatch( 127 | actions.addMulticallListeners({ 128 | chainId, 129 | calls, 130 | options: { blocksPerFetch: blocksPerFetchForChain }, 131 | }) 132 | ) 133 | } 134 | }) 135 | 136 | return () => { 137 | batch(() => { 138 | for (const [chainId, callKeys] of chainCallKeysTuples) { 139 | const calls = callKeysToCalls(callKeys) 140 | if (!calls?.length) continue 141 | const blocksPerFetchFromState = (defaultListenerOptions ?? {})[chainId]?.blocksPerFetch 142 | const blocksPerFetchForChain = 143 | listenerOptions?.blocksPerFetch ?? blocksPerFetchFromState ?? DEFAULT_BLOCKS_PER_FETCH 144 | dispatch( 145 | actions.removeMulticallListeners({ 146 | chainId, 147 | calls, 148 | options: { blocksPerFetch: blocksPerFetchForChain }, 149 | }) 150 | ) 151 | } 152 | }) 153 | } 154 | }, [actions, dispatch, listenerOptions, serializedCallKeys, defaultListenerOptions]) 155 | 156 | return useMemo( 157 | () => 158 | getChainIds(chainToCalls).reduce((result, chainId) => { 159 | const calls = chainToCalls[chainId] 160 | result[chainId] = calls.map((call) => { 161 | if (!chainId || !call) return INVALID_RESULT 162 | const result = callResults[chainId]?.[toCallKey(call)] 163 | const data = result?.data && result.data !== '0x' ? result.data : undefined 164 | return { valid: true, data, blockNumber: result?.blockNumber } 165 | }) 166 | return result 167 | }, {} as Record), 168 | [callResults, chainToCalls] 169 | ) 170 | } 171 | 172 | // formats many calls to a single function on a single contract, with the function name and inputs specified 173 | export function useSingleContractMultipleData( 174 | context: MulticallContext, 175 | chainId: number | undefined, 176 | latestBlockNumber: number | undefined, 177 | contract: Contract | null | undefined, 178 | methodName: string, 179 | callInputs: OptionalMethodInputs[], 180 | options?: Partial 181 | ): CallState[] { 182 | const { gasRequired } = options ?? {} 183 | 184 | // Create ethers function fragment 185 | const fragment = useMemo(() => contract?.interface?.getFunction(methodName), [contract, methodName]) 186 | 187 | // Get encoded call data. Note can't use useCallData below b.c. this is for a list of CallInputs 188 | const callDatas = useMemo(() => { 189 | if (!contract || !fragment) return [] 190 | return callInputs.map((callInput) => 191 | isValidMethodArgs(callInput) ? contract.interface.encodeFunctionData(fragment, callInput) : undefined 192 | ) 193 | }, [callInputs, contract, fragment]) 194 | 195 | // Create call objects 196 | const calls = useMemo(() => { 197 | if (!contract) return [] 198 | return callDatas.map((callData) => { 199 | if (!callData) return undefined 200 | return { 201 | address: contract.address, 202 | callData, 203 | gasRequired, 204 | } 205 | }) 206 | }, [contract, callDatas, gasRequired]) 207 | 208 | // Subscribe to call data 209 | const results = useCallsDataSubscription(context, chainId, calls, options as ListenerOptions) 210 | return useCallStates(results, contract?.interface, fragment, latestBlockNumber) 211 | } 212 | 213 | export function useMultipleContractSingleData( 214 | context: MulticallContext, 215 | chainId: number | undefined, 216 | latestBlockNumber: number | undefined, 217 | addresses: (string | undefined)[], 218 | contractInterface: Interface, 219 | methodName: string, 220 | callInputs?: OptionalMethodInputs, 221 | options?: Partial 222 | ): CallState[] { 223 | const { gasRequired } = options ?? {} 224 | 225 | const { fragment, callData } = useCallData(methodName, contractInterface, callInputs) 226 | 227 | // Create call objects 228 | const calls = useMemo(() => { 229 | if (!callData) return [] 230 | return addresses.map((address) => { 231 | if (!address) return undefined 232 | return { address, callData, gasRequired } 233 | }) 234 | }, [addresses, callData, gasRequired]) 235 | 236 | // Subscribe to call data 237 | const results = useCallsDataSubscription(context, chainId, calls, options as ListenerOptions) 238 | return useCallStates(results, contractInterface, fragment, latestBlockNumber) 239 | } 240 | 241 | export function useSingleCallResult( 242 | context: MulticallContext, 243 | chainId: number | undefined, 244 | latestBlockNumber: number | undefined, 245 | contract: Contract | null | undefined, 246 | methodName: string, 247 | inputs?: OptionalMethodInputs, 248 | options?: Partial 249 | ): CallState { 250 | const callInputs = useMemo(() => [inputs], [inputs]) 251 | return ( 252 | useSingleContractMultipleData(context, chainId, latestBlockNumber, contract, methodName, callInputs, options)[0] ?? 253 | INVALID_CALL_STATE 254 | ) 255 | } 256 | 257 | // formats many calls to any number of functions on a single contract, with only the calldata specified 258 | export function useSingleContractWithCallData( 259 | context: MulticallContext, 260 | chainId: number | undefined, 261 | latestBlockNumber: number | undefined, 262 | contract: Contract | null | undefined, 263 | callDatas: string[], 264 | options?: Partial 265 | ): CallState[] { 266 | const { gasRequired } = options ?? {} 267 | 268 | // Create call objects 269 | const calls = useMemo(() => { 270 | if (!contract) return [] 271 | return callDatas.map((callData) => ({ 272 | address: contract.address, 273 | callData, 274 | gasRequired, 275 | })) 276 | }, [callDatas, contract, gasRequired]) 277 | 278 | // Subscribe to call data 279 | const results = useCallsDataSubscription(context, chainId, calls, options as ListenerOptions) 280 | const fragment = useCallback( 281 | (i: number) => contract?.interface?.getFunction(callDatas[i].substring(0, 10)), 282 | [callDatas, contract] 283 | ) 284 | return useCallStates(results, contract?.interface, fragment, latestBlockNumber) 285 | } 286 | 287 | // Similar to useMultipleContractSingleData but instead of multiple contracts on one chain, 288 | // this is for querying compatible contracts on multiple chains 289 | export function useMultiChainMultiContractSingleData( 290 | context: MulticallContext, 291 | chainToBlockNumber: Record, 292 | chainToAddresses: Record>, 293 | contractInterface: Interface, 294 | methodName: string, 295 | callInputs?: OptionalMethodInputs, 296 | options?: Partial 297 | ): Record { 298 | const { gasRequired } = options ?? {} 299 | 300 | const { fragment, callData } = useCallData(methodName, contractInterface, callInputs) 301 | 302 | // Create call objects 303 | const chainToCalls = useMemo(() => { 304 | if (!callData || !chainToAddresses) return {} 305 | return getChainIds(chainToAddresses).reduce((result, chainId) => { 306 | const addresses = chainToAddresses[chainId] 307 | const calls = addresses.map((address) => { 308 | if (!address) return undefined 309 | return { address, callData, gasRequired } 310 | }) 311 | result[chainId] = calls 312 | return result 313 | }, {} as Record>) 314 | }, [chainToAddresses, callData, gasRequired]) 315 | 316 | // Subscribe to call data 317 | const chainIdToResults = useMultichainCallsDataSubscription(context, chainToCalls, options as ListenerOptions) 318 | 319 | // TODO(WEB-2097): Multichain states are not referentially stable, because they cannot use the 320 | // same codepath (eg useCallStates). 321 | return useMemo(() => { 322 | return getChainIds(chainIdToResults).reduce((combinedResults, chainId) => { 323 | const latestBlockNumber = chainToBlockNumber?.[chainId] 324 | const results = chainIdToResults[chainId] 325 | combinedResults[chainId] = results.map((result) => 326 | toCallState(result, contractInterface, fragment, latestBlockNumber) 327 | ) 328 | return combinedResults 329 | }, {} as Record) 330 | }, [fragment, contractInterface, chainIdToResults, chainToBlockNumber]) 331 | } 332 | 333 | // Similar to useSingleCallResult but instead of one contract on one chain, 334 | // this is for querying a contract on multiple chains 335 | export function useMultiChainSingleContractSingleData( 336 | context: MulticallContext, 337 | chainToBlockNumber: Record, 338 | chainToAddress: Record, 339 | contractInterface: Interface, 340 | methodName: string, 341 | callInputs?: OptionalMethodInputs, 342 | options?: Partial 343 | ): Record { 344 | // This hook uses the more flexible useMultiChainMultiContractSingleData internally, 345 | // but transforms the inputs and outputs for convenience 346 | const chainIdToAddresses = useMemo(() => { 347 | return getChainIds(chainToAddress).reduce((result, chainId) => { 348 | result[chainId] = [chainToAddress[chainId]] 349 | return result 350 | }, {} as Record>) 351 | }, [chainToAddress]) 352 | 353 | const multiContractResults = useMultiChainMultiContractSingleData( 354 | context, 355 | chainToBlockNumber, 356 | chainIdToAddresses, 357 | contractInterface, 358 | methodName, 359 | callInputs, 360 | options 361 | ) 362 | 363 | return useMemo(() => { 364 | return getChainIds(chainToAddress).reduce((result, chainId) => { 365 | result[chainId] = multiContractResults[chainId]?.[0] ?? INVALID_CALL_STATE 366 | return result 367 | }, {} as Record) 368 | }, [chainToAddress, multiContractResults]) 369 | } 370 | 371 | function useCallData( 372 | methodName: string, 373 | contractInterface: Interface | null | undefined, 374 | callInputs: OptionalMethodInputs | undefined 375 | ) { 376 | // Create ethers function fragment 377 | const fragment = useMemo(() => contractInterface?.getFunction(methodName), [contractInterface, methodName]) 378 | // Get encoded call data 379 | const callData: string | undefined = useMemo( 380 | () => 381 | fragment && isValidMethodArgs(callInputs) 382 | ? contractInterface?.encodeFunctionData(fragment, callInputs) 383 | : undefined, 384 | [callInputs, contractInterface, fragment] 385 | ) 386 | return { fragment, callData } 387 | } 388 | 389 | function getChainIds(chainIdMap: Record) { 390 | return Object.keys(chainIdMap).map((c) => parseInt(c, 10)) 391 | } 392 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './create' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /src/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Store } from '@reduxjs/toolkit' 2 | import { createMulticallSlice, MulticallActions } from './slice' 3 | import { MulticallState } from './types' 4 | 5 | const DAI_ADDRESS = '0x6b175474e89094c44da98b954eedeac495271d0f' 6 | 7 | describe('multicall reducer', () => { 8 | let store: Store 9 | let actions: MulticallActions 10 | beforeEach(() => { 11 | const slice = createMulticallSlice('multicall') 12 | actions = slice.actions 13 | store = createStore(slice.reducer) 14 | }) 15 | 16 | it('has correct initial state', () => { 17 | expect(store.getState().callResults).toEqual({}) 18 | expect(store.getState().callListeners).toBeUndefined() 19 | expect(store.getState().listenerOptions).toBeUndefined() 20 | }) 21 | 22 | describe('addMulticallListeners', () => { 23 | it('adds listeners', () => { 24 | store.dispatch( 25 | actions.addMulticallListeners({ 26 | chainId: 1, 27 | calls: [ 28 | { 29 | address: DAI_ADDRESS, 30 | callData: '0x', 31 | }, 32 | ], 33 | options: { blocksPerFetch: 1 }, 34 | }) 35 | ) 36 | expect(store.getState()).toEqual({ 37 | callListeners: { 38 | [1]: { 39 | [`${DAI_ADDRESS}-0x`]: { 40 | [1]: 1, 41 | }, 42 | }, 43 | }, 44 | callResults: {}, 45 | }) 46 | }) 47 | }) 48 | 49 | describe('removeMulticallListeners', () => { 50 | it('noop', () => { 51 | store.dispatch( 52 | actions.removeMulticallListeners({ 53 | calls: [ 54 | { 55 | address: DAI_ADDRESS, 56 | callData: '0x', 57 | }, 58 | ], 59 | chainId: 1, 60 | options: { blocksPerFetch: 1 }, 61 | }) 62 | ) 63 | expect(store.getState()).toEqual({ callResults: {}, callListeners: {} }) 64 | }) 65 | it('removes listeners', () => { 66 | store.dispatch( 67 | actions.addMulticallListeners({ 68 | chainId: 1, 69 | calls: [ 70 | { 71 | address: DAI_ADDRESS, 72 | callData: '0x', 73 | }, 74 | ], 75 | options: { blocksPerFetch: 1 }, 76 | }) 77 | ) 78 | store.dispatch( 79 | actions.removeMulticallListeners({ 80 | calls: [ 81 | { 82 | address: DAI_ADDRESS, 83 | callData: '0x', 84 | }, 85 | ], 86 | chainId: 1, 87 | options: { blocksPerFetch: 1 }, 88 | }) 89 | ) 90 | expect(store.getState()).toEqual({ 91 | callResults: {}, 92 | callListeners: { [1]: { [`${DAI_ADDRESS}-0x`]: {} } }, 93 | }) 94 | }) 95 | }) 96 | 97 | describe('updateMulticallResults', () => { 98 | it('updates data if not present', () => { 99 | store.dispatch( 100 | actions.updateMulticallResults({ 101 | chainId: 1, 102 | blockNumber: 1, 103 | results: { 104 | abc: '0x', 105 | }, 106 | }) 107 | ) 108 | expect(store.getState()).toEqual({ 109 | callResults: { 110 | [1]: { 111 | abc: { 112 | blockNumber: 1, 113 | data: '0x', 114 | }, 115 | }, 116 | }, 117 | }) 118 | }) 119 | it('updates old data', () => { 120 | store.dispatch( 121 | actions.updateMulticallResults({ 122 | chainId: 1, 123 | blockNumber: 1, 124 | results: { 125 | abc: '0x', 126 | }, 127 | }) 128 | ) 129 | store.dispatch( 130 | actions.updateMulticallResults({ 131 | chainId: 1, 132 | blockNumber: 2, 133 | results: { 134 | abc: '0x2', 135 | }, 136 | }) 137 | ) 138 | expect(store.getState()).toEqual({ 139 | callResults: { 140 | [1]: { 141 | abc: { 142 | blockNumber: 2, 143 | data: '0x2', 144 | }, 145 | }, 146 | }, 147 | }) 148 | }) 149 | it('ignores late updates', () => { 150 | store.dispatch( 151 | actions.updateMulticallResults({ 152 | chainId: 1, 153 | blockNumber: 2, 154 | results: { 155 | abc: '0x2', 156 | }, 157 | }) 158 | ) 159 | store.dispatch( 160 | actions.updateMulticallResults({ 161 | chainId: 1, 162 | blockNumber: 1, 163 | results: { 164 | abc: '0x1', 165 | }, 166 | }) 167 | ) 168 | expect(store.getState()).toEqual({ 169 | callResults: { 170 | [1]: { 171 | abc: { 172 | blockNumber: 2, 173 | data: '0x2', 174 | }, 175 | }, 176 | }, 177 | }) 178 | }) 179 | }) 180 | describe('fetchingMulticallResults', () => { 181 | it('updates state to fetching', () => { 182 | store.dispatch( 183 | actions.fetchingMulticallResults({ 184 | chainId: 1, 185 | fetchingBlockNumber: 2, 186 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 187 | }) 188 | ) 189 | expect(store.getState()).toEqual({ 190 | callResults: { 191 | [1]: { 192 | [`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 2 }, 193 | }, 194 | }, 195 | }) 196 | }) 197 | 198 | it('updates state to fetching even if already fetching older block', () => { 199 | store.dispatch( 200 | actions.fetchingMulticallResults({ 201 | chainId: 1, 202 | fetchingBlockNumber: 2, 203 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 204 | }) 205 | ) 206 | store.dispatch( 207 | actions.fetchingMulticallResults({ 208 | chainId: 1, 209 | fetchingBlockNumber: 3, 210 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 211 | }) 212 | ) 213 | expect(store.getState()).toEqual({ 214 | callResults: { 215 | [1]: { 216 | [`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 3 }, 217 | }, 218 | }, 219 | }) 220 | }) 221 | 222 | it('does not do update if fetching newer block', () => { 223 | store.dispatch( 224 | actions.fetchingMulticallResults({ 225 | chainId: 1, 226 | fetchingBlockNumber: 2, 227 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 228 | }) 229 | ) 230 | store.dispatch( 231 | actions.fetchingMulticallResults({ 232 | chainId: 1, 233 | fetchingBlockNumber: 1, 234 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 235 | }) 236 | ) 237 | expect(store.getState()).toEqual({ 238 | callResults: { 239 | [1]: { 240 | [`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 2 }, 241 | }, 242 | }, 243 | }) 244 | }) 245 | }) 246 | 247 | describe('errorFetchingMulticallResults', () => { 248 | it('does nothing if not fetching', () => { 249 | store.dispatch( 250 | actions.errorFetchingMulticallResults({ 251 | chainId: 1, 252 | fetchingBlockNumber: 1, 253 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 254 | }) 255 | ) 256 | expect(store.getState()).toEqual({ 257 | callResults: { 258 | [1]: {}, 259 | }, 260 | }) 261 | }) 262 | it('updates block number if we were fetching', () => { 263 | store.dispatch( 264 | actions.fetchingMulticallResults({ 265 | chainId: 1, 266 | fetchingBlockNumber: 2, 267 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 268 | }) 269 | ) 270 | store.dispatch( 271 | actions.errorFetchingMulticallResults({ 272 | chainId: 1, 273 | fetchingBlockNumber: 2, 274 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 275 | }) 276 | ) 277 | expect(store.getState()).toEqual({ 278 | callResults: { 279 | [1]: { 280 | [`${DAI_ADDRESS}-0x0`]: { 281 | blockNumber: 2, 282 | // null data indicates error 283 | data: null, 284 | }, 285 | }, 286 | }, 287 | }) 288 | }) 289 | it('does nothing if not errored on latest block', () => { 290 | store.dispatch( 291 | actions.fetchingMulticallResults({ 292 | chainId: 1, 293 | fetchingBlockNumber: 3, 294 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 295 | }) 296 | ) 297 | store.dispatch( 298 | actions.errorFetchingMulticallResults({ 299 | chainId: 1, 300 | fetchingBlockNumber: 2, 301 | calls: [{ address: DAI_ADDRESS, callData: '0x0' }], 302 | }) 303 | ) 304 | expect(store.getState()).toEqual({ 305 | callResults: { 306 | [1]: { 307 | [`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 3 }, 308 | }, 309 | }, 310 | }) 311 | }) 312 | }) 313 | 314 | describe('updateListenerOptions', () => { 315 | it('initializes listenerOptions map in state if not present and updates', () => { 316 | store.dispatch( 317 | actions.updateListenerOptions({ 318 | chainId: 1, 319 | listenerOptions: { 320 | blocksPerFetch: 5, 321 | }, 322 | }) 323 | ) 324 | expect(store.getState().listenerOptions).toEqual({ 325 | 1: { 326 | blocksPerFetch: 5, 327 | }, 328 | }) 329 | }) 330 | 331 | it('updates listenerOptions map when overriding with new values for multiple chain IDs', () => { 332 | store.dispatch( 333 | actions.updateListenerOptions({ 334 | chainId: 1, 335 | listenerOptions: { 336 | blocksPerFetch: 5, 337 | }, 338 | }) 339 | ) 340 | store.dispatch( 341 | actions.updateListenerOptions({ 342 | chainId: 2, 343 | listenerOptions: { 344 | blocksPerFetch: 10, 345 | }, 346 | }) 347 | ) 348 | expect(store.getState().listenerOptions).toEqual({ 349 | 1: { 350 | blocksPerFetch: 5, 351 | }, 352 | 2: { 353 | blocksPerFetch: 10, 354 | }, 355 | }) 356 | store.dispatch( 357 | actions.updateListenerOptions({ 358 | chainId: 1, 359 | listenerOptions: { 360 | blocksPerFetch: 1, 361 | }, 362 | }) 363 | ) 364 | store.dispatch( 365 | actions.updateListenerOptions({ 366 | chainId: 2, 367 | listenerOptions: { 368 | blocksPerFetch: 100, 369 | }, 370 | }) 371 | ) 372 | expect(store.getState().listenerOptions).toEqual({ 373 | 1: { 374 | blocksPerFetch: 1, 375 | }, 376 | 2: { 377 | blocksPerFetch: 100, 378 | }, 379 | }) 380 | }) 381 | }) 382 | }) 383 | -------------------------------------------------------------------------------- /src/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { 3 | MulticallFetchingPayload, 4 | MulticallListenerPayload, 5 | MulticallResultsPayload, 6 | MulticallState, 7 | MulticallListenerOptionsPayload, 8 | } from './types' 9 | import { toCallKey } from './utils/callKeys' 10 | 11 | const initialState: MulticallState = { 12 | callResults: {}, 13 | } 14 | 15 | export function createMulticallSlice(reducerPath: string) { 16 | return createSlice({ 17 | name: reducerPath, 18 | initialState, 19 | reducers: { 20 | addMulticallListeners: (state, action: PayloadAction) => { 21 | const { 22 | calls, 23 | chainId, 24 | options: { blocksPerFetch }, 25 | } = action.payload 26 | const listeners: MulticallState['callListeners'] = state.callListeners 27 | ? state.callListeners 28 | : (state.callListeners = {}) 29 | listeners[chainId] = listeners[chainId] ?? {} 30 | calls.forEach((call) => { 31 | const callKey = toCallKey(call) 32 | listeners[chainId][callKey] = listeners[chainId][callKey] ?? {} 33 | listeners[chainId][callKey][blocksPerFetch] = (listeners[chainId][callKey][blocksPerFetch] ?? 0) + 1 34 | }) 35 | }, 36 | 37 | removeMulticallListeners: (state, action: PayloadAction) => { 38 | const { 39 | calls, 40 | chainId, 41 | options: { blocksPerFetch }, 42 | } = action.payload 43 | const listeners: MulticallState['callListeners'] = state.callListeners 44 | ? state.callListeners 45 | : (state.callListeners = {}) 46 | 47 | if (!listeners[chainId]) return 48 | calls.forEach((call) => { 49 | const callKey = toCallKey(call) 50 | if (!listeners[chainId][callKey]) return 51 | if (!listeners[chainId][callKey][blocksPerFetch]) return 52 | 53 | if (listeners[chainId][callKey][blocksPerFetch] === 1) { 54 | delete listeners[chainId][callKey][blocksPerFetch] 55 | } else { 56 | listeners[chainId][callKey][blocksPerFetch]-- 57 | } 58 | }) 59 | }, 60 | 61 | fetchingMulticallResults: (state, action: PayloadAction) => { 62 | const { chainId, fetchingBlockNumber, calls } = action.payload 63 | state.callResults[chainId] = state.callResults[chainId] ?? {} 64 | calls.forEach((call) => { 65 | const callKey = toCallKey(call) 66 | const current = state.callResults[chainId][callKey] 67 | if (!current) { 68 | state.callResults[chainId][callKey] = { 69 | fetchingBlockNumber, 70 | } 71 | } else { 72 | if ((current.fetchingBlockNumber ?? 0) >= fetchingBlockNumber) return 73 | state.callResults[chainId][callKey].fetchingBlockNumber = fetchingBlockNumber 74 | } 75 | }) 76 | }, 77 | 78 | errorFetchingMulticallResults: (state, action: PayloadAction) => { 79 | const { chainId, fetchingBlockNumber, calls } = action.payload 80 | state.callResults[chainId] = state.callResults[chainId] ?? {} 81 | calls.forEach((call) => { 82 | const callKey = toCallKey(call) 83 | const current = state.callResults[chainId][callKey] 84 | if (!current || typeof current.fetchingBlockNumber !== 'number') return // only should be dispatched if we are already fetching 85 | if (current.fetchingBlockNumber <= fetchingBlockNumber) { 86 | delete current.fetchingBlockNumber 87 | current.data = null 88 | current.blockNumber = fetchingBlockNumber 89 | } 90 | }) 91 | }, 92 | 93 | updateMulticallResults: (state, action: PayloadAction) => { 94 | const { chainId, results, blockNumber } = action.payload 95 | state.callResults[chainId] = state.callResults[chainId] ?? {} 96 | Object.keys(results).forEach((callKey) => { 97 | const current = state.callResults[chainId][callKey] 98 | if ((current?.blockNumber ?? 0) > blockNumber) return 99 | if (current?.data === results[callKey] && current?.blockNumber === blockNumber) return 100 | state.callResults[chainId][callKey] = { 101 | data: results[callKey], 102 | blockNumber, 103 | } 104 | }) 105 | }, 106 | 107 | updateListenerOptions: (state, action: PayloadAction) => { 108 | const { chainId, listenerOptions } = action.payload 109 | state.listenerOptions = state.listenerOptions ?? {} 110 | state.listenerOptions[chainId] = listenerOptions 111 | }, 112 | }, 113 | }) 114 | } 115 | 116 | export type MulticallActions = ReturnType['actions'] 117 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Call { 2 | address: string 3 | callData: string 4 | gasRequired?: number 5 | } 6 | 7 | export interface CallStateResult extends ReadonlyArray { 8 | readonly [key: string]: any 9 | } 10 | 11 | export interface CallState { 12 | readonly valid: boolean 13 | // the result, or undefined if loading or errored/no data 14 | readonly result: CallStateResult | undefined 15 | // true if the result has never been fetched 16 | readonly loading: boolean 17 | // true if the result is not for the latest block 18 | readonly syncing: boolean 19 | // true if the call was made and is synced, but the return data is invalid 20 | readonly error: boolean 21 | } 22 | 23 | export interface CallResult { 24 | readonly valid: boolean 25 | readonly data: string | undefined 26 | readonly blockNumber: number | undefined 27 | } 28 | 29 | export interface MulticallState { 30 | callListeners?: { 31 | // on a per-chain basis 32 | [chainId: number]: { 33 | // stores for each call key the listeners' preferences 34 | [callKey: string]: { 35 | // stores how many listeners there are per each blocks per fetch preference 36 | [blocksPerFetch: number]: number 37 | } 38 | } 39 | } 40 | 41 | callResults: { 42 | [chainId: number]: { 43 | [callKey: string]: { 44 | data?: string | null 45 | blockNumber?: number 46 | fetchingBlockNumber?: number 47 | } 48 | } 49 | } 50 | 51 | listenerOptions?: { 52 | [chainId: number]: ListenerOptions 53 | } 54 | } 55 | 56 | export interface WithMulticallState { 57 | [path: string]: MulticallState 58 | } 59 | 60 | export interface ListenerOptions { 61 | // how often this data should be fetched, by default 1 62 | readonly blocksPerFetch: number 63 | } 64 | 65 | export interface ListenerOptionsWithGas extends ListenerOptions { 66 | readonly gasRequired?: number 67 | } 68 | 69 | export interface MulticallListenerPayload { 70 | chainId: number 71 | calls: Call[] 72 | options: ListenerOptions 73 | } 74 | 75 | export interface MulticallFetchingPayload { 76 | chainId: number 77 | calls: Call[] 78 | fetchingBlockNumber: number 79 | } 80 | 81 | export interface MulticallResultsPayload { 82 | chainId: number 83 | blockNumber: number 84 | results: { 85 | [callKey: string]: string | null 86 | } 87 | } 88 | 89 | export interface MulticallListenerOptionsPayload { 90 | chainId: number 91 | listenerOptions: ListenerOptions 92 | } 93 | -------------------------------------------------------------------------------- /src/updater.test.ts: -------------------------------------------------------------------------------- 1 | import { activeListeningKeys, outdatedListeningKeys } from './updater' 2 | 3 | describe('multicall updater', () => { 4 | describe('#activeListeningKeys', () => { 5 | it('ignores 0, returns call key to block age key', () => { 6 | expect( 7 | activeListeningKeys( 8 | { 9 | [1]: { 10 | ['abc']: { 11 | 4: 2, // 2 listeners care about 4 block old data 12 | 1: 0, // 0 listeners care about 1 block old data 13 | }, 14 | }, 15 | }, 16 | 1 17 | ) 18 | ).toEqual({ 19 | abc: 4, 20 | }) 21 | }) 22 | it('applies min', () => { 23 | expect( 24 | activeListeningKeys( 25 | { 26 | [1]: { 27 | ['abc']: { 28 | 4: 2, // 2 listeners care about 4 block old data 29 | 3: 1, // 1 listener cares about 3 block old data 30 | 1: 0, // 0 listeners care about 1 block old data 31 | }, 32 | }, 33 | }, 34 | 1 35 | ) 36 | ).toEqual({ 37 | abc: 3, 38 | }) 39 | }) 40 | it('works for infinity', () => { 41 | expect( 42 | activeListeningKeys( 43 | { 44 | [1]: { 45 | ['abc']: { 46 | 4: 2, // 2 listeners care about 4 block old data 47 | 1: 0, // 0 listeners care about 1 block old data 48 | }, 49 | ['def']: { 50 | Infinity: 2, 51 | }, 52 | }, 53 | }, 54 | 1 55 | ) 56 | ).toEqual({ 57 | abc: 4, 58 | def: Infinity, 59 | }) 60 | }) 61 | it('multiple keys', () => { 62 | expect( 63 | activeListeningKeys( 64 | { 65 | [1]: { 66 | ['abc']: { 67 | 4: 2, // 2 listeners care about 4 block old data 68 | 1: 0, // 0 listeners care about 1 block old data 69 | }, 70 | ['def']: { 71 | 2: 1, 72 | 5: 2, 73 | }, 74 | }, 75 | }, 76 | 1 77 | ) 78 | ).toEqual({ 79 | abc: 4, 80 | def: 2, 81 | }) 82 | }) 83 | it('ignores negative numbers', () => { 84 | expect( 85 | activeListeningKeys( 86 | { 87 | [1]: { 88 | ['abc']: { 89 | 4: 2, 90 | 1: -1, 91 | [-3]: 4, 92 | }, 93 | }, 94 | }, 95 | 1 96 | ) 97 | ).toEqual({ 98 | abc: 4, 99 | }) 100 | }) 101 | it('applies min to infinity', () => { 102 | expect( 103 | activeListeningKeys( 104 | { 105 | [1]: { 106 | ['abc']: { 107 | Infinity: 2, // 2 listeners care about any data 108 | 4: 2, // 2 listeners care about 4 block old data 109 | 1: 0, // 0 listeners care about 1 block old data 110 | }, 111 | }, 112 | }, 113 | 1 114 | ) 115 | ).toEqual({ 116 | abc: 4, 117 | }) 118 | }) 119 | }) 120 | 121 | describe('#outdatedListeningKeys', () => { 122 | it('returns empty if missing block number or chain id', () => { 123 | expect(outdatedListeningKeys({}, { abc: 2 }, undefined, undefined)).toEqual([]) 124 | expect(outdatedListeningKeys({}, { abc: 2 }, 1, undefined)).toEqual([]) 125 | expect(outdatedListeningKeys({}, { abc: 2 }, undefined, 1)).toEqual([]) 126 | }) 127 | it('returns everything for no results', () => { 128 | expect(outdatedListeningKeys({}, { abc: 2, def: 3 }, 1, 1)).toEqual(['abc', 'def']) 129 | }) 130 | it('returns only outdated keys', () => { 131 | expect(outdatedListeningKeys({ [1]: { abc: { data: '0x', blockNumber: 2 } } }, { abc: 1, def: 1 }, 1, 2)).toEqual( 132 | ['def'] 133 | ) 134 | }) 135 | it('returns only keys not being fetched', () => { 136 | expect( 137 | outdatedListeningKeys( 138 | { 139 | [1]: { abc: { data: '0x', blockNumber: 2 }, def: { fetchingBlockNumber: 2 } }, 140 | }, 141 | { abc: 1, def: 1 }, 142 | 1, 143 | 2 144 | ) 145 | ).toEqual([]) 146 | }) 147 | it('returns keys being fetched for old blocks', () => { 148 | expect( 149 | outdatedListeningKeys( 150 | { [1]: { abc: { data: '0x', blockNumber: 2 }, def: { fetchingBlockNumber: 1 } } }, 151 | { abc: 1, def: 1 }, 152 | 1, 153 | 2 154 | ) 155 | ).toEqual(['def']) 156 | }) 157 | it('respects blocks per fetch', () => { 158 | expect( 159 | outdatedListeningKeys( 160 | { 161 | [1]: { 162 | abc: { data: '0x', blockNumber: 2 }, 163 | def: { data: '0x', fetchingBlockNumber: 1 }, 164 | ghi: { data: '0x', blockNumber: 1 }, 165 | }, 166 | }, 167 | { abc: 2, def: 2, ghi: Infinity }, 168 | 1, 169 | 3 170 | ) 171 | ).toEqual(['def']) 172 | }) 173 | }) 174 | }) 175 | -------------------------------------------------------------------------------- /src/updater.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, useEffect, useMemo, useRef } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import type { UniswapInterfaceMulticall } from './abi/types' 4 | import { CHUNK_GAS_LIMIT, DEFAULT_CALL_GAS_REQUIRED } from './constants' 5 | import type { MulticallContext } from './context' 6 | import type { MulticallActions } from './slice' 7 | import type { Call, MulticallState, WithMulticallState, ListenerOptions } from './types' 8 | import { parseCallKey, toCallKey } from './utils/callKeys' 9 | import chunkCalls from './utils/chunkCalls' 10 | import { retry, RetryableError } from './utils/retry' 11 | import useDebounce from './utils/useDebounce' 12 | 13 | const FETCH_RETRY_CONFIG = { 14 | n: Infinity, 15 | minWait: 1000, 16 | maxWait: 2500, 17 | } 18 | 19 | /** 20 | * Fetches a chunk of calls, enforcing a minimum block number constraint 21 | * @param multicall multicall contract to fetch against 22 | * @param chunk chunk of calls to make 23 | * @param blockNumber block number passed as the block tag in the eth_call 24 | */ 25 | async function fetchChunk( 26 | multicall: UniswapInterfaceMulticall, 27 | chunk: Call[], 28 | blockNumber: number, 29 | isDebug?: boolean 30 | ): Promise<{ success: boolean; returnData: string }[]> { 31 | console.debug('Fetching chunk', chunk, blockNumber) 32 | try { 33 | const { returnData } = await multicall.callStatic.multicall( 34 | chunk.map((obj) => ({ 35 | target: obj.address, 36 | callData: obj.callData, 37 | gasLimit: obj.gasRequired ?? DEFAULT_CALL_GAS_REQUIRED, 38 | })), 39 | // we aren't passing through the block gas limit we used to create the chunk, because it causes a problem with the integ tests 40 | { blockTag: blockNumber } 41 | ) 42 | 43 | if (isDebug) { 44 | returnData.forEach(({ gasUsed, returnData, success }, i) => { 45 | if ( 46 | !success && 47 | returnData.length === 2 && 48 | gasUsed.gte(Math.floor((chunk[i].gasRequired ?? DEFAULT_CALL_GAS_REQUIRED) * 0.95)) 49 | ) { 50 | console.warn( 51 | `A call failed due to requiring ${gasUsed.toString()} vs. allowed ${ 52 | chunk[i].gasRequired ?? DEFAULT_CALL_GAS_REQUIRED 53 | }`, 54 | chunk[i] 55 | ) 56 | } 57 | }) 58 | } 59 | 60 | return returnData 61 | } catch (e) { 62 | const error = e as any 63 | if (error.code === -32000 || error.message?.indexOf('header not found') !== -1) { 64 | throw new RetryableError(`header not found for block number ${blockNumber}`) 65 | } else if (error.code === -32603 || error.message?.indexOf('execution ran out of gas') !== -1) { 66 | if (chunk.length > 1) { 67 | if (process.env.NODE_ENV === 'development') { 68 | console.debug('Splitting a chunk in 2', chunk) 69 | } 70 | const half = Math.floor(chunk.length / 2) 71 | const [c0, c1] = await Promise.all([ 72 | fetchChunk(multicall, chunk.slice(0, half), blockNumber), 73 | fetchChunk(multicall, chunk.slice(half, chunk.length), blockNumber), 74 | ]) 75 | return c0.concat(c1) 76 | } 77 | } 78 | console.error('Failed to fetch chunk', error) 79 | throw error 80 | } 81 | } 82 | 83 | /** 84 | * From the current all listeners state, return each call key mapped to the 85 | * minimum number of blocks per fetch. This is how often each key must be fetched. 86 | * @param allListeners the all listeners state 87 | * @param chainId the current chain id 88 | */ 89 | export function activeListeningKeys( 90 | allListeners: MulticallState['callListeners'], 91 | chainId?: number 92 | ): { [callKey: string]: number } { 93 | if (!allListeners || !chainId) return {} 94 | const listeners = allListeners[chainId] 95 | if (!listeners) return {} 96 | 97 | return Object.keys(listeners).reduce<{ [callKey: string]: number }>((memo, callKey) => { 98 | const keyListeners = listeners[callKey] 99 | 100 | memo[callKey] = Object.keys(keyListeners) 101 | .filter((key) => { 102 | const blocksPerFetch = parseInt(key) 103 | if (blocksPerFetch <= 0) return false 104 | return keyListeners[blocksPerFetch] > 0 105 | }) 106 | .reduce((previousMin, current) => { 107 | return Math.min(previousMin, parseInt(current)) 108 | }, Infinity) 109 | return memo 110 | }, {}) 111 | } 112 | 113 | /** 114 | * Return the keys that need to be refetched 115 | * @param callResults current call result state 116 | * @param listeningKeys each call key mapped to how old the data can be in blocks 117 | * @param chainId the current chain id 118 | * @param latestBlockNumber the latest block number 119 | */ 120 | export function outdatedListeningKeys( 121 | callResults: MulticallState['callResults'], 122 | listeningKeys: { [callKey: string]: number }, 123 | chainId: number | undefined, 124 | latestBlockNumber: number | undefined 125 | ): string[] { 126 | if (!chainId || !latestBlockNumber) return [] 127 | const results = callResults[chainId] 128 | // no results at all, load everything 129 | if (!results) return Object.keys(listeningKeys) 130 | 131 | return Object.keys(listeningKeys).filter((callKey) => { 132 | const blocksPerFetch = listeningKeys[callKey] 133 | 134 | const data = callResults[chainId][callKey] 135 | // no data, must fetch 136 | if (!data) return true 137 | 138 | const minDataBlockNumber = latestBlockNumber - (blocksPerFetch - 1) 139 | 140 | // already fetching it for a recent enough block, don't refetch it 141 | if (data.fetchingBlockNumber && data.fetchingBlockNumber >= minDataBlockNumber) return false 142 | 143 | // if data is older than minDataBlockNumber, fetch it 144 | return !data.blockNumber || data.blockNumber < minDataBlockNumber 145 | }) 146 | } 147 | 148 | interface FetchChunkContext { 149 | actions: MulticallActions 150 | dispatch: Dispatch 151 | chainId: number 152 | latestBlockNumber: number 153 | isDebug?: boolean 154 | } 155 | 156 | function onFetchChunkSuccess( 157 | context: FetchChunkContext, 158 | chunk: Call[], 159 | result: Array<{ success: boolean; returnData: string }> 160 | ) { 161 | const { actions, dispatch, chainId, latestBlockNumber, isDebug } = context 162 | 163 | // split the returned slice into errors and results 164 | const { erroredCalls, results } = chunk.reduce<{ 165 | erroredCalls: Call[] 166 | results: { [callKey: string]: string | null } 167 | }>( 168 | (memo, call, i) => { 169 | if (result[i].success) { 170 | memo.results[toCallKey(call)] = result[i].returnData ?? null 171 | } else { 172 | memo.erroredCalls.push(call) 173 | } 174 | return memo 175 | }, 176 | { erroredCalls: [], results: {} } 177 | ) 178 | 179 | // dispatch any new results 180 | if (Object.keys(results).length > 0) 181 | dispatch( 182 | actions.updateMulticallResults({ 183 | chainId, 184 | results, 185 | blockNumber: latestBlockNumber, 186 | }) 187 | ) 188 | 189 | // dispatch any errored calls 190 | if (erroredCalls.length > 0) { 191 | if (isDebug) { 192 | result.forEach((returnData, ix) => { 193 | if (!returnData.success) { 194 | console.debug('Call failed', chunk[ix], returnData) 195 | } 196 | }) 197 | } else { 198 | console.debug('Calls errored in fetch', erroredCalls) 199 | } 200 | dispatch( 201 | actions.errorFetchingMulticallResults({ 202 | calls: erroredCalls, 203 | chainId, 204 | fetchingBlockNumber: latestBlockNumber, 205 | }) 206 | ) 207 | } 208 | } 209 | 210 | function onFetchChunkFailure(context: FetchChunkContext, chunk: Call[], error: any) { 211 | const { actions, dispatch, chainId, latestBlockNumber } = context 212 | 213 | if (error.isCancelledError) { 214 | console.debug('Cancelled fetch for blockNumber', latestBlockNumber, chunk, chainId) 215 | return 216 | } 217 | console.error('Failed to fetch multicall chunk', chunk, chainId, error) 218 | dispatch( 219 | actions.errorFetchingMulticallResults({ 220 | calls: chunk, 221 | chainId, 222 | fetchingBlockNumber: latestBlockNumber, 223 | }) 224 | ) 225 | } 226 | 227 | export interface UpdaterProps { 228 | context: MulticallContext 229 | chainId: number | undefined // For now, one updater is required for each chainId to be watched 230 | latestBlockNumber: number | undefined 231 | contract: UniswapInterfaceMulticall 232 | isDebug?: boolean 233 | listenerOptions?: ListenerOptions 234 | } 235 | 236 | function Updater(props: UpdaterProps): null { 237 | const { context, chainId, latestBlockNumber, contract, isDebug, listenerOptions } = props 238 | const { actions, reducerPath } = context 239 | const dispatch = useDispatch() 240 | 241 | // set user configured listenerOptions in state for given chain ID. 242 | useEffect(() => { 243 | if (chainId && listenerOptions) { 244 | dispatch(actions.updateListenerOptions({ chainId, listenerOptions })) 245 | } 246 | }, [chainId, listenerOptions, actions, dispatch]) 247 | 248 | const state = useSelector((state: WithMulticallState) => state[reducerPath]) 249 | 250 | // wait for listeners to settle before triggering updates 251 | const debouncedListeners = useDebounce(state.callListeners, 100) 252 | const cancellations = useRef<{ blockNumber: number; cancellations: (() => void)[] }>() 253 | 254 | const listeningKeys: { [callKey: string]: number } = useMemo(() => { 255 | return activeListeningKeys(debouncedListeners, chainId) 256 | }, [debouncedListeners, chainId]) 257 | 258 | const serializedOutdatedCallKeys = useMemo(() => { 259 | const outdatedCallKeys = outdatedListeningKeys(state.callResults, listeningKeys, chainId, latestBlockNumber) 260 | return JSON.stringify(outdatedCallKeys.sort()) 261 | }, [chainId, state.callResults, listeningKeys, latestBlockNumber]) 262 | 263 | useEffect(() => { 264 | if (!latestBlockNumber || !chainId || !contract) return 265 | 266 | const outdatedCallKeys: string[] = JSON.parse(serializedOutdatedCallKeys) 267 | if (outdatedCallKeys.length === 0) return 268 | const calls = outdatedCallKeys.map((key) => parseCallKey(key)) 269 | 270 | const chunkedCalls = chunkCalls(calls, CHUNK_GAS_LIMIT) 271 | 272 | if (cancellations.current && cancellations.current.blockNumber !== latestBlockNumber) { 273 | cancellations.current.cancellations.forEach((c) => c()) 274 | } 275 | 276 | dispatch( 277 | actions.fetchingMulticallResults({ 278 | calls, 279 | chainId, 280 | fetchingBlockNumber: latestBlockNumber, 281 | }) 282 | ) 283 | 284 | const fetchChunkContext = { 285 | actions, 286 | dispatch, 287 | chainId, 288 | latestBlockNumber, 289 | isDebug, 290 | } 291 | // Execute fetches and gather cancellation callbacks 292 | const newCancellations = chunkedCalls.map((chunk) => { 293 | const { cancel, promise } = retry( 294 | () => fetchChunk(contract, chunk, latestBlockNumber, isDebug), 295 | FETCH_RETRY_CONFIG 296 | ) 297 | promise 298 | .then((result) => onFetchChunkSuccess(fetchChunkContext, chunk, result)) 299 | .catch((error) => onFetchChunkFailure(fetchChunkContext, chunk, error)) 300 | return cancel 301 | }) 302 | 303 | cancellations.current = { 304 | blockNumber: latestBlockNumber, 305 | cancellations: newCancellations, 306 | } 307 | }, [actions, chainId, contract, dispatch, serializedOutdatedCallKeys, latestBlockNumber, isDebug]) 308 | 309 | return null 310 | } 311 | 312 | export function createUpdater(context: MulticallContext) { 313 | const UpdaterContextBound = (props: Omit) => { 314 | return 315 | } 316 | return UpdaterContextBound 317 | } 318 | -------------------------------------------------------------------------------- /src/utils/callKeys.test.ts: -------------------------------------------------------------------------------- 1 | import { callKeysToCalls, callsToCallKeys, parseCallKey, toCallKey } from './callKeys' 2 | 3 | describe('callKeys', () => { 4 | describe('#parseCallKey', () => { 5 | it('does not throw for invalid address', () => { 6 | expect(parseCallKey('0x-0x')).toEqual({ address: '0x', callData: '0x' }) 7 | }) 8 | it('does not throw for invalid calldata', () => { 9 | expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-abc')).toEqual({ 10 | address: '0x6b175474e89094c44da98b954eedeac495271d0f', 11 | callData: 'abc', 12 | }) 13 | }) 14 | it('throws for uppercase calldata', () => { 15 | expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcD')).toEqual({ 16 | address: '0x6b175474e89094c44da98b954eedeac495271d0f', 17 | callData: '0xabcD', 18 | }) 19 | }) 20 | it('parses pieces into address', () => { 21 | expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd')).toEqual({ 22 | address: '0x6b175474e89094c44da98b954eedeac495271d0f', 23 | callData: '0xabcd', 24 | }) 25 | }) 26 | }) 27 | 28 | describe('#toCallKey', () => { 29 | it('concatenates address to data', () => { 30 | expect(toCallKey({ address: '0x6b175474e89094c44da98b954eedeac495271d0f', callData: '0xabcd' })).toEqual( 31 | '0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd' 32 | ) 33 | }) 34 | }) 35 | 36 | const call1 = { address: '0x6b175474e89094c44da98b954eedeac495271d0f', callData: '0xabcd' } 37 | const call2 = { address: '0xE92a65EB6f2928e733d013F2f315f71EFD8788B4', callData: '0xdefg' } 38 | const result = [ 39 | '0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd', 40 | '0xE92a65EB6f2928e733d013F2f315f71EFD8788B4-0xdefg', 41 | ] 42 | 43 | describe('#callsToCallKeys', () => { 44 | it('Returns ordered, serialized call keys', () => { 45 | expect(callsToCallKeys([call1, call2])).toEqual(result) 46 | expect(callsToCallKeys([call2, call1])).toEqual(result) 47 | }) 48 | 49 | it('Handles empty lists', () => { 50 | expect(callsToCallKeys(undefined)).toEqual([]) 51 | expect(callsToCallKeys([undefined])).toEqual([]) 52 | }) 53 | }) 54 | 55 | describe('#callKeysToCalls', () => { 56 | it('Returns deserialized calls', () => { 57 | expect(callKeysToCalls(result)).toEqual([call1, call2]) 58 | }) 59 | 60 | it('Handles empty lists', () => { 61 | expect(callKeysToCalls([])).toEqual(null) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/utils/callKeys.ts: -------------------------------------------------------------------------------- 1 | import { Call } from '../types' 2 | 3 | export function toCallKey(call: Call): string { 4 | let key = `${call.address}-${call.callData}` 5 | if (call.gasRequired) { 6 | if (!Number.isSafeInteger(call.gasRequired)) { 7 | throw new Error(`Invalid number: ${call.gasRequired}`) 8 | } 9 | key += `-${call.gasRequired}` 10 | } 11 | return key 12 | } 13 | 14 | export function parseCallKey(callKey: string): Call { 15 | const pcs = callKey.split('-') 16 | if (![2, 3].includes(pcs.length)) { 17 | throw new Error(`Invalid call key: ${callKey}`) 18 | } 19 | return { 20 | address: pcs[0], 21 | callData: pcs[1], 22 | ...(pcs[2] ? { gasRequired: Number.parseInt(pcs[2]) } : {}), 23 | } 24 | } 25 | 26 | export function callsToCallKeys(calls?: Array) { 27 | return ( 28 | calls 29 | ?.filter((c): c is Call => Boolean(c)) 30 | ?.map(toCallKey) 31 | ?.sort() ?? [] 32 | ) 33 | } 34 | 35 | export function callKeysToCalls(callKeys: string[]) { 36 | if (!callKeys?.length) return null 37 | return callKeys.map((key) => parseCallKey(key)) 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/callState.test.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionFragment, Interface } from '@ethersproject/abi' 2 | import React, { useRef } from 'react' 3 | import { render, unmountComponentAtNode } from 'react-dom' 4 | import { toCallState, useCallStates } from './callState' 5 | 6 | describe('callState', () => { 7 | describe('#useCallStates', () => { 8 | let container: HTMLDivElement | null = null 9 | beforeEach(() => { 10 | container = document.createElement('div') 11 | document.body.appendChild(container) 12 | }) 13 | afterEach(() => { 14 | if (container) { 15 | unmountComponentAtNode(container) 16 | container.remove() 17 | } 18 | container = null 19 | }) 20 | 21 | const contractInterface = { decodeFunctionResult: () => [{}] } as unknown as Interface 22 | const fragment = {} as FunctionFragment 23 | const results = [{ valid: true, data: '0xa', blockNumber: 2 }] 24 | function Caller({ latestBlockNumber }: { latestBlockNumber: number }) { 25 | const data = useCallStates(results, contractInterface, fragment, latestBlockNumber) 26 | const last = useRef(data) 27 | return <>{data[0].result === last.current[0].result ? 'true' : 'false'} 28 | } 29 | 30 | it('Stabilizes values across renders (assuming stable interface/fragment/results)', () => { 31 | render(, container) 32 | render(, container) 33 | expect(container?.textContent).toBe('true') 34 | }) 35 | 36 | it('Returns referentially new values if data goes stale', () => { 37 | render(, container) 38 | render(, container) 39 | expect(container?.textContent).toBe('false') 40 | }) 41 | }) 42 | 43 | describe('#toCallState', () => { 44 | it('Handles missing values', () => { 45 | expect(toCallState(undefined, undefined, undefined, 1000)).toEqual({ 46 | error: false, 47 | loading: false, 48 | result: undefined, 49 | syncing: false, 50 | valid: false, 51 | }) 52 | }) 53 | 54 | it('Returns loading state', () => { 55 | const callResult = { 56 | valid: true, 57 | data: '0xabcd', 58 | blockNumber: 1000, 59 | } 60 | expect(toCallState(callResult, undefined, undefined, 1000)).toEqual({ 61 | error: false, 62 | loading: true, 63 | result: undefined, 64 | syncing: true, 65 | valid: true, 66 | }) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/utils/callState.ts: -------------------------------------------------------------------------------- 1 | import type { FunctionFragment, Interface } from '@ethersproject/abi' 2 | import { useMemo } from 'react' 3 | import { INVALID_CALL_STATE, LOADING_CALL_STATE } from '../constants' 4 | import type { CallResult, CallState, CallStateResult } from '../types' 5 | 6 | // Converts CallResult[] to CallState[], only updating if call states have changed. 7 | // Ensures that CallState results remain referentially stable when unchanged, preventing 8 | // spurious re-renders which would otherwise occur because mapping always creates a new object. 9 | export function useCallStates( 10 | results: CallResult[], 11 | contractInterface: Interface | undefined, 12 | fragment: ((i: number) => FunctionFragment | undefined) | FunctionFragment | undefined, 13 | latestBlockNumber: number | undefined 14 | ): CallState[] { 15 | // Avoid refreshing the results with every changing block number (eg latestBlockNumber). 16 | // Instead, only refresh the results if they need to be synced - if there is a result which is stale, for which blockNumber < latestBlockNumber. 17 | const syncingBlockNumber = useMemo(() => { 18 | const lowestBlockNumber = results.reduce( 19 | (memo, result) => (result.blockNumber ? Math.min(memo ?? result.blockNumber, result.blockNumber) : memo), 20 | undefined 21 | ) 22 | return Math.max(lowestBlockNumber ?? 0, latestBlockNumber ?? 0) 23 | }, [results, latestBlockNumber]) 24 | 25 | return useMemo(() => { 26 | return results.map((result, i) => { 27 | const resultFragment = typeof fragment === 'function' ? fragment(i) : fragment 28 | return toCallState(result, contractInterface, resultFragment, syncingBlockNumber) 29 | }) 30 | }, [contractInterface, fragment, results, syncingBlockNumber]) 31 | } 32 | 33 | export function toCallState( 34 | callResult: CallResult | undefined, 35 | contractInterface: Interface | undefined, 36 | fragment: FunctionFragment | undefined, 37 | syncingBlockNumber: number | undefined 38 | ): CallState { 39 | if (!callResult || !callResult.valid) { 40 | return INVALID_CALL_STATE 41 | } 42 | 43 | const { data, blockNumber } = callResult 44 | if (!blockNumber || !contractInterface || !fragment || !syncingBlockNumber) { 45 | return LOADING_CALL_STATE 46 | } 47 | 48 | const success = data && data.length > 2 49 | const syncing = blockNumber < syncingBlockNumber 50 | let result: CallStateResult | undefined = undefined 51 | if (success && data) { 52 | try { 53 | result = contractInterface.decodeFunctionResult(fragment, data) 54 | } catch (error) { 55 | console.debug('Result data parsing failed', fragment, data) 56 | return { 57 | valid: true, 58 | loading: false, 59 | error: true, 60 | syncing, 61 | result, 62 | } 63 | } 64 | } 65 | return { 66 | valid: true, 67 | loading: false, 68 | syncing, 69 | result, 70 | error: !success, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/chunkCalls.test.ts: -------------------------------------------------------------------------------- 1 | import chunkCalls from './chunkCalls' 2 | 3 | describe(chunkCalls, () => { 4 | it('each is too large', () => { 5 | expect(chunkCalls([{ gasRequired: 300 }, { gasRequired: 200 }, { gasRequired: 300 }], 400)).toEqual([ 6 | [{ gasRequired: 300 }], 7 | [{ gasRequired: 300 }], 8 | [{ gasRequired: 200 }], 9 | ]) 10 | }) 11 | it('min bins simple case', () => { 12 | expect(chunkCalls([{ gasRequired: 100 }, { gasRequired: 200 }, { gasRequired: 300 }], 400)).toEqual([ 13 | [{ gasRequired: 300 }, { gasRequired: 100 }], 14 | [{ gasRequired: 200 }], 15 | ]) 16 | }) 17 | it('all items fit', () => { 18 | expect(chunkCalls([{ gasRequired: 300 }, { gasRequired: 200 }, { gasRequired: 100 }], 600)).toEqual([ 19 | [{ gasRequired: 300 }, { gasRequired: 200 }, { gasRequired: 100 }], 20 | ]) 21 | }) 22 | it('one item too large', () => { 23 | expect(chunkCalls([{ gasRequired: 400 }, { gasRequired: 200 }, { gasRequired: 100 }], 300)).toEqual([ 24 | [{ gasRequired: 400 }], 25 | [{ gasRequired: 200 }, { gasRequired: 100 }], 26 | ]) 27 | }) 28 | it('several items too large', () => { 29 | expect( 30 | chunkCalls( 31 | [{ gasRequired: 200 }, { gasRequired: 100 }, { gasRequired: 400 }, { gasRequired: 400 }, { gasRequired: 400 }], 32 | 300 33 | ) 34 | ).toEqual([ 35 | [{ gasRequired: 400 }], 36 | [{ gasRequired: 400 }], 37 | [{ gasRequired: 400 }], 38 | [{ gasRequired: 200 }, { gasRequired: 100 }], 39 | ]) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/utils/chunkCalls.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CHUNK_GAS_REQUIRED } from '../constants' 2 | 3 | interface Bin { 4 | calls: T[] 5 | cumulativeGasLimit: number 6 | } 7 | 8 | /** 9 | * Tries to pack a list of items into as few bins as possible using the first-fit bin packing algorithm 10 | * @param calls the calls to chunk 11 | * @param chunkGasLimit the gas limit of any one chunk of calls, i.e. bin capacity 12 | * @param defaultGasRequired the default amount of gas an individual call should cost if not specified 13 | */ 14 | export default function chunkCalls( 15 | calls: T[], 16 | chunkGasLimit: number, 17 | defaultGasRequired: number = DEFAULT_CHUNK_GAS_REQUIRED 18 | ): T[][] { 19 | return ( 20 | calls 21 | // first sort by gas required 22 | .sort((c1, c2) => (c2.gasRequired ?? defaultGasRequired) - (c1.gasRequired ?? defaultGasRequired)) 23 | // then bin the calls according to the first fit algorithm 24 | .reduce[]>((bins, call) => { 25 | const gas = call.gasRequired ?? defaultGasRequired 26 | for (const bin of bins) { 27 | if (bin.cumulativeGasLimit + gas <= chunkGasLimit) { 28 | bin.calls.push(call) 29 | bin.cumulativeGasLimit += gas 30 | return bins 31 | } 32 | } 33 | // didn't find a bin for the call, make a new bin 34 | bins.push({ 35 | calls: [call], 36 | cumulativeGasLimit: gas, 37 | }) 38 | return bins 39 | }, []) 40 | // pull out just the calls from each bin 41 | .map((b) => b.calls) 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/retry.test.ts: -------------------------------------------------------------------------------- 1 | import { retry, RetryableError } from './retry' 2 | 3 | describe('retry', () => { 4 | function makeFn(fails: number, result: T, retryable = true): () => Promise { 5 | return async () => { 6 | if (fails > 0) { 7 | fails-- 8 | throw retryable ? new RetryableError('failure') : new Error('bad failure') 9 | } 10 | return result 11 | } 12 | } 13 | 14 | it('fails for non-retryable error', async () => { 15 | await expect(retry(makeFn(1, 'abc', false), { n: 3, maxWait: 0, minWait: 0 }).promise).rejects.toThrow( 16 | 'bad failure' 17 | ) 18 | }) 19 | 20 | it('works after one fail', async () => { 21 | await expect(retry(makeFn(1, 'abc'), { n: 3, maxWait: 0, minWait: 0 }).promise).resolves.toEqual('abc') 22 | }) 23 | 24 | it('works after two fails', async () => { 25 | await expect(retry(makeFn(2, 'abc'), { n: 3, maxWait: 0, minWait: 0 }).promise).resolves.toEqual('abc') 26 | }) 27 | 28 | it('throws if too many fails', async () => { 29 | await expect(retry(makeFn(4, 'abc'), { n: 3, maxWait: 0, minWait: 0 }).promise).rejects.toThrow('failure') 30 | }) 31 | 32 | it('cancel causes promise to reject', async () => { 33 | const { promise, cancel } = retry(makeFn(2, 'abc'), { n: 3, minWait: 100, maxWait: 100 }) 34 | cancel() 35 | await expect(promise).rejects.toThrow('Cancelled') 36 | }) 37 | 38 | it('cancel no-op after complete', async () => { 39 | const { promise, cancel } = retry(makeFn(0, 'abc'), { n: 3, minWait: 100, maxWait: 100 }) 40 | // defer 41 | setTimeout(cancel, 0) 42 | await expect(promise).resolves.toEqual('abc') 43 | }) 44 | 45 | async function checkTime(fn: () => Promise, min: number, max: number) { 46 | const time = new Date().getTime() 47 | await fn() 48 | const diff = new Date().getTime() - time 49 | expect(diff).toBeGreaterThanOrEqual(min) 50 | expect(diff).toBeLessThanOrEqual(max) 51 | } 52 | 53 | it('waits random amount of time between min and max', async () => { 54 | const promises = [] 55 | for (let i = 0; i < 10; i++) { 56 | promises.push( 57 | checkTime( 58 | () => expect(retry(makeFn(4, 'abc'), { n: 3, maxWait: 100, minWait: 50 }).promise).rejects.toThrow('failure'), 59 | 150, 60 | 400 61 | ) 62 | ) 63 | } 64 | await Promise.all(promises) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/utils/retry.ts: -------------------------------------------------------------------------------- 1 | // TODO de-duplicate this file with web interface 2 | // https://github.com/Uniswap/interface/blob/main/src/utils/retry.ts 3 | 4 | function wait(ms: number): Promise { 5 | return new Promise((resolve) => setTimeout(resolve, ms)) 6 | } 7 | 8 | function waitRandom(min: number, max: number): Promise { 9 | return wait(min + Math.round(Math.random() * Math.max(0, max - min))) 10 | } 11 | 12 | /** 13 | * This error is thrown if the function is cancelled before completing 14 | */ 15 | class CancelledError extends Error { 16 | public isCancelledError: true = true 17 | constructor() { 18 | super('Cancelled') 19 | } 20 | } 21 | 22 | /** 23 | * Throw this error if the function should retry 24 | */ 25 | export class RetryableError extends Error { 26 | public isRetryableError: true = true 27 | } 28 | 29 | export interface RetryOptions { 30 | n: number 31 | minWait: number 32 | maxWait: number 33 | } 34 | 35 | /** 36 | * Retries the function that returns the promise until the promise successfully resolves up to n retries 37 | * @param fn function to retry 38 | * @param n how many times to retry 39 | * @param minWait min wait between retries in ms 40 | * @param maxWait max wait between retries in ms 41 | */ 42 | export function retry( 43 | fn: () => Promise, 44 | { n, minWait, maxWait }: RetryOptions 45 | ): { promise: Promise; cancel: () => void } { 46 | let completed = false 47 | let rejectCancelled: (error: Error) => void 48 | const promise = new Promise(async (resolve, reject) => { 49 | rejectCancelled = reject 50 | while (true) { 51 | let result: T 52 | try { 53 | result = await fn() 54 | if (!completed) { 55 | resolve(result) 56 | completed = true 57 | } 58 | break 59 | } catch (error) { 60 | if (completed) { 61 | break 62 | } 63 | if (n <= 0 || !(error as any).isRetryableError) { 64 | reject(error) 65 | completed = true 66 | break 67 | } 68 | n-- 69 | } 70 | await waitRandom(minWait, maxWait) 71 | } 72 | }) 73 | return { 74 | promise, 75 | cancel: () => { 76 | if (completed) return 77 | completed = true 78 | rejectCancelled(new CancelledError()) 79 | }, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/useDebounce.ts: -------------------------------------------------------------------------------- 1 | // TODO de-duplicate this file with web interface 2 | // https://github.com/Uniswap/interface/blob/main/src/hooks/useDebounce.ts 3 | 4 | import { useEffect, useState } from 'react' 5 | 6 | // modified from https://usehooks.com/useDebounce/ 7 | export default function useDebounce(value: T, delay: number): T { 8 | const [debouncedValue, setDebouncedValue] = useState(value) 9 | 10 | useEffect(() => { 11 | // Update debounced value after delay 12 | const handler = setTimeout(() => { 13 | setDebouncedValue(value) 14 | }, delay) 15 | 16 | // Cancel the timeout if value changes (also on delay change or unmount) 17 | // This is how we prevent debounced value from updating if value is changed ... 18 | // .. within the delay period. Timeout gets cleared and restarted. 19 | return () => { 20 | clearTimeout(handler) 21 | } 22 | }, [value, delay]) 23 | 24 | return debouncedValue 25 | } 26 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from '@ethersproject/bignumber' 2 | 3 | export type MethodArg = string | number | BigNumber 4 | export type MethodArgs = Array 5 | 6 | export function isMethodArg(x: unknown): x is MethodArg { 7 | return BigNumber.isBigNumber(x) || ['string', 'number'].indexOf(typeof x) !== -1 8 | } 9 | 10 | export function isValidMethodArgs(x: unknown): x is MethodArgs | undefined { 11 | return ( 12 | x === undefined || 13 | (Array.isArray(x) && x.every((xi) => isMethodArg(xi) || (Array.isArray(xi) && xi.every(isMethodArg)))) 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "integration-tests"], 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "importHelpers": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noImplicitAny": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "resolveJsonModule": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "strictFunctionTypes": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "target": "es2018" 24 | } 25 | } 26 | --------------------------------------------------------------------------------