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