refactor: Replace multicall implementation with library (#2768)

- Replace the local implementation of multicall with the new redux-multicall lib
- Create wrappers for redux-multicall hooks to inject block number and chainId
This commit is contained in:
J M Rossy 2021-12-03 06:29:39 -05:00 committed by GitHub
parent e81e8a8f71
commit 596ea03043
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 61 additions and 1309 deletions

@ -58,6 +58,7 @@
"@uniswap/governance": "^1.0.2",
"@uniswap/liquidity-staker": "^1.0.2",
"@uniswap/merkle-distributor": "1.0.1",
"@uniswap/redux-multicall": "^1.0.0",
"@uniswap/sdk-core": "^3.0.1",
"@uniswap/token-lists": "^1.0.0-beta.27",
"@uniswap/v2-core": "1.0.0",

@ -1,6 +1,6 @@
import { BigNumber } from '@ethersproject/bignumber'
import { useMemo } from 'react'
import { Result, useSingleCallResult, useSingleContractMultipleData } from 'state/multicall/hooks'
import { CallStateResult, useSingleCallResult, useSingleContractMultipleData } from 'state/multicall/hooks'
import { PositionDetails } from 'types/position'
import { useV3NFTPositionManagerContract } from './useContract'
@ -22,7 +22,7 @@ function useV3PositionsFromTokenIds(tokenIds: BigNumber[] | undefined): UseV3Pos
if (!loading && !error && tokenIds) {
return results.map((call, i) => {
const tokenId = tokenIds[i]
const result = call.result as Result
const result = call.result as CallStateResult
return {
tokenId,
fee: result.fee,
@ -90,7 +90,7 @@ export function useV3Positions(account: string | null | undefined): UseV3Positio
if (account) {
return tokenIdResults
.map(({ result }) => result)
.filter((result): result is Result => !!result)
.filter((result): result is CallStateResult => !!result)
.map((result) => BigNumber.from(result[0]))
}
return []

@ -11,7 +11,7 @@ import lists from './lists/reducer'
import logs from './logs/slice'
import mint from './mint/reducer'
import mintV3 from './mint/v3/reducer'
import multicall from './multicall/reducer'
import { multicall } from './multicall/instance'
import { routingApi } from './routing/slice'
import swap from './swap/reducer'
import transactions from './transactions/reducer'
@ -29,7 +29,7 @@ const store = configureStore({
mintV3,
burn,
burnV3,
multicall,
multicall: multicall.reducer,
lists,
logs,
[dataApi.reducerPath]: dataApi.reducer,

@ -1,35 +0,0 @@
import { parseCallKey, toCallKey } from './utils'
describe('actions', () => {
describe('#parseCallKey', () => {
it('does not throw for invalid address', () => {
expect(parseCallKey('0x-0x')).toEqual({ address: '0x', callData: '0x' })
})
it('does not throw for invalid calldata', () => {
expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-abc')).toEqual({
address: '0x6b175474e89094c44da98b954eedeac495271d0f',
callData: 'abc',
})
})
it('throws for uppercase calldata', () => {
expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcD')).toEqual({
address: '0x6b175474e89094c44da98b954eedeac495271d0f',
callData: '0xabcD',
})
})
it('parses pieces into address', () => {
expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd')).toEqual({
address: '0x6b175474e89094c44da98b954eedeac495271d0f',
callData: '0xabcd',
})
})
})
describe('#toCallKey', () => {
it('concatenates address to data', () => {
expect(toCallKey({ address: '0x6b175474e89094c44da98b954eedeac495271d0f', callData: '0xabcd' })).toEqual(
'0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd'
)
})
})
})

@ -1,30 +0,0 @@
import { createAction } from '@reduxjs/toolkit'
import { Call } from './utils'
export interface ListenerOptions {
// how often this data should be fetched, by default 1
readonly blocksPerFetch: number
}
export const addMulticallListeners = createAction<{ chainId: number; calls: Call[]; options: ListenerOptions }>(
'multicall/addMulticallListeners'
)
export const removeMulticallListeners = createAction<{ chainId: number; calls: Call[]; options: ListenerOptions }>(
'multicall/removeMulticallListeners'
)
export const fetchingMulticallResults = createAction<{ chainId: number; calls: Call[]; fetchingBlockNumber: number }>(
'multicall/fetchingMulticallResults'
)
export const errorFetchingMulticallResults = createAction<{
chainId: number
calls: Call[]
fetchingBlockNumber: number
}>('multicall/errorFetchingMulticallResults')
export const updateMulticallResults = createAction<{
chainId: number
blockNumber: number
results: {
[callKey: string]: string | null
}
}>('multicall/updateMulticallResults')

@ -1,298 +1,43 @@
import { FunctionFragment, Interface } from '@ethersproject/abi'
import { BigNumber } from '@ethersproject/bignumber'
import { Contract } from '@ethersproject/contracts'
import { useEffect, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { useActiveWeb3React } from '../../hooks/web3'
import { SkipFirst } from '../../types/tuple'
import { useBlockNumber } from '../application/hooks'
import { addMulticallListeners, ListenerOptions, removeMulticallListeners } from './actions'
import { Call, parseCallKey, toCallKey } from './utils'
import { multicall } from './instance'
export type { CallStateResult } from '@uniswap/redux-multicall' // re-export for convenience
export { NEVER_RELOAD } from '@uniswap/redux-multicall' // re-export for convenience
export interface Result extends ReadonlyArray<any> {
readonly [key: string]: any
const {
useMultipleContractSingleData: _useMultipleContractSingleData,
useSingleCallResult: _useSingleCallResult,
useSingleContractMultipleData: _useSingleContractMultipleData,
useSingleContractWithCallData: _useSingleContractWithCallData,
} = multicall.hooks
// Create wrappers for hooks so consumers don't need to get latest block themselves
type SkipFirstTwoParams<T extends (...args: any) => any> = SkipFirst<Parameters<T>, 2>
export function useMultipleContractSingleData(...args: SkipFirstTwoParams<typeof _useMultipleContractSingleData>) {
const { chainId, latestBlock } = useCallContext()
return _useMultipleContractSingleData(chainId, latestBlock, ...args)
}
type MethodArg = string | number | BigNumber
type MethodArgs = Array<MethodArg | MethodArg[]>
type OptionalMethodInputs = Array<MethodArg | MethodArg[] | undefined> | undefined
function isMethodArg(x: unknown): x is MethodArg {
return BigNumber.isBigNumber(x) || ['string', 'number'].indexOf(typeof x) !== -1
export function useSingleCallResult(...args: SkipFirstTwoParams<typeof _useSingleCallResult>) {
const { chainId, latestBlock } = useCallContext()
return _useSingleCallResult(chainId, latestBlock, ...args)
}
function isValidMethodArgs(x: unknown): x is MethodArgs | undefined {
return (
x === undefined ||
(Array.isArray(x) && x.every((xi) => isMethodArg(xi) || (Array.isArray(xi) && xi.every(isMethodArg))))
)
export function useSingleContractMultipleData(...args: SkipFirstTwoParams<typeof _useSingleContractMultipleData>) {
const { chainId, latestBlock } = useCallContext()
return _useSingleContractMultipleData(chainId, latestBlock, ...args)
}
interface CallResult {
readonly valid: boolean
readonly data: string | undefined
readonly blockNumber: number | undefined
export function useSingleContractWithCallData(...args: SkipFirstTwoParams<typeof _useSingleContractWithCallData>) {
const { chainId, latestBlock } = useCallContext()
return _useSingleContractWithCallData(chainId, latestBlock, ...args)
}
const INVALID_RESULT: CallResult = { valid: false, blockNumber: undefined, data: undefined }
// use this options object
export const NEVER_RELOAD: ListenerOptions = {
blocksPerFetch: Infinity,
}
// the lowest level call for subscribing to contract data
function useCallsData(
calls: (Call | undefined)[],
{ blocksPerFetch }: ListenerOptions = { blocksPerFetch: 1 }
): CallResult[] {
function useCallContext() {
const { chainId } = useActiveWeb3React()
const callResults = useAppSelector((state) => state.multicall.callResults)
const dispatch = useAppDispatch()
const serializedCallKeys: string = useMemo(
() =>
JSON.stringify(
calls
?.filter((c): c is Call => Boolean(c))
?.map(toCallKey)
?.sort() ?? []
),
[calls]
)
// update listeners when there is an actual change that persists for at least 100ms
useEffect(() => {
const callKeys: string[] = JSON.parse(serializedCallKeys)
if (!chainId || callKeys.length === 0) return undefined
const calls = callKeys.map((key) => parseCallKey(key))
dispatch(
addMulticallListeners({
chainId,
calls,
options: { blocksPerFetch },
})
)
return () => {
dispatch(
removeMulticallListeners({
chainId,
calls,
options: { blocksPerFetch },
})
)
}
}, [chainId, dispatch, blocksPerFetch, serializedCallKeys])
return useMemo(
() =>
calls.map<CallResult>((call) => {
if (!chainId || !call) return INVALID_RESULT
const result = callResults[chainId]?.[toCallKey(call)]
let data
if (result?.data && result?.data !== '0x') {
data = result.data
}
return { valid: true, data, blockNumber: result?.blockNumber }
}),
[callResults, calls, chainId]
)
}
interface CallState {
readonly valid: boolean
// the result, or undefined if loading or errored/no data
readonly result: Result | undefined
// true if the result has never been fetched
readonly loading: boolean
// true if the result is not for the latest block
readonly syncing: boolean
// true if the call was made and is synced, but the return data is invalid
readonly error: boolean
}
const INVALID_CALL_STATE: CallState = { valid: false, result: undefined, loading: false, syncing: false, error: false }
const LOADING_CALL_STATE: CallState = { valid: true, result: undefined, loading: true, syncing: true, error: false }
function toCallState(
callResult: CallResult | undefined,
contractInterface: Interface | undefined,
fragment: FunctionFragment | undefined,
latestBlockNumber: number | undefined
): CallState {
if (!callResult) return INVALID_CALL_STATE
const { valid, data, blockNumber } = callResult
if (!valid) return INVALID_CALL_STATE
if (valid && !blockNumber) return LOADING_CALL_STATE
if (!contractInterface || !fragment || !latestBlockNumber) return LOADING_CALL_STATE
const success = data && data.length > 2
const syncing = (blockNumber ?? 0) < latestBlockNumber
let result: Result | undefined = undefined
if (success && data) {
try {
result = contractInterface.decodeFunctionResult(fragment, data)
} catch (error) {
console.debug('Result data parsing failed', fragment, data)
return {
valid: true,
loading: false,
error: true,
syncing,
result,
}
}
}
return {
valid: true,
loading: false,
syncing,
result,
error: !success,
}
}
// formats many calls to a single function on a single contract, with the function name and inputs specified
export function useSingleContractMultipleData(
contract: Contract | null | undefined,
methodName: string,
callInputs: OptionalMethodInputs[],
options: Partial<ListenerOptions> & { gasRequired?: number } = {}
): CallState[] {
const fragment = useMemo(() => contract?.interface?.getFunction(methodName), [contract, methodName])
// encode callDatas
const callDatas = useMemo(
() =>
contract && fragment
? callInputs.map<string | undefined>((callInput) =>
isValidMethodArgs(callInput) ? contract.interface.encodeFunctionData(fragment, callInput) : undefined
)
: [],
[callInputs, contract, fragment]
)
const gasRequired = options?.gasRequired
const blocksPerFetch = options?.blocksPerFetch
// encode calls
const calls = useMemo(
() =>
contract
? callDatas.map<Call | undefined>((callData) =>
callData
? {
address: contract.address,
callData,
gasRequired,
}
: undefined
)
: [],
[contract, callDatas, gasRequired]
)
const results = useCallsData(calls, blocksPerFetch ? { blocksPerFetch } : undefined)
const latestBlockNumber = useBlockNumber()
return useMemo(() => {
return results.map((result) => toCallState(result, contract?.interface, fragment, latestBlockNumber))
}, [results, contract, fragment, latestBlockNumber])
}
export function useMultipleContractSingleData(
addresses: (string | undefined)[],
contractInterface: Interface,
methodName: string,
callInputs?: OptionalMethodInputs,
options: Partial<ListenerOptions> & { gasRequired?: number } = {}
): CallState[] {
const fragment = useMemo(() => contractInterface.getFunction(methodName), [contractInterface, methodName])
// encode callData
const callData: string | undefined = useMemo(
() => (isValidMethodArgs(callInputs) ? contractInterface.encodeFunctionData(fragment, callInputs) : undefined),
[callInputs, contractInterface, fragment]
)
const gasRequired = options?.gasRequired
const blocksPerFetch = options?.blocksPerFetch
// encode calls
const calls = useMemo(
() =>
callData
? addresses.map<Call | undefined>((address) => {
return address
? {
address,
callData,
gasRequired,
}
: undefined
})
: [],
[addresses, callData, gasRequired]
)
const results = useCallsData(calls, blocksPerFetch ? { blocksPerFetch } : undefined)
const latestBlockNumber = useBlockNumber()
return useMemo(() => {
return results.map((result) => toCallState(result, contractInterface, fragment, latestBlockNumber))
}, [fragment, results, contractInterface, latestBlockNumber])
}
export function useSingleCallResult(
contract: Contract | null | undefined,
methodName: string,
inputs?: OptionalMethodInputs,
options: Partial<ListenerOptions> & { gasRequired?: number } = {}
): CallState {
return useSingleContractMultipleData(contract, methodName, [inputs], options)[0] ?? INVALID_CALL_STATE
}
// formats many calls to any number of functions on a single contract, with only the calldata specified
export function useSingleContractWithCallData(
contract: Contract | null | undefined,
callDatas: string[],
options: Partial<ListenerOptions> & { gasRequired?: number } = {}
): CallState[] {
const gasRequired = options?.gasRequired
const blocksPerFetch = options?.blocksPerFetch
// encode calls
const calls = useMemo(
() =>
contract
? callDatas.map<Call>((callData) => {
return {
address: contract.address,
callData,
gasRequired,
}
})
: [],
[contract, callDatas, gasRequired]
)
const results = useCallsData(calls, blocksPerFetch ? { blocksPerFetch } : undefined)
const latestBlockNumber = useBlockNumber()
return useMemo(() => {
return results.map((result, i) =>
toCallState(
result,
contract?.interface,
contract?.interface?.getFunction(callDatas[i].substring(0, 10)),
latestBlockNumber
)
)
}, [results, contract, callDatas, latestBlockNumber])
const latestBlock = useBlockNumber()
return { chainId, latestBlock }
}

@ -0,0 +1,4 @@
import { createMulticall } from '@uniswap/redux-multicall'
// Create a multicall instance with default settings
export const multicall = createMulticall()

@ -1,316 +0,0 @@
import { createStore, Store } from '@reduxjs/toolkit'
import {
addMulticallListeners,
errorFetchingMulticallResults,
fetchingMulticallResults,
removeMulticallListeners,
updateMulticallResults,
} from './actions'
import reducer, { MulticallState } from './reducer'
const DAI_ADDRESS = '0x6b175474e89094c44da98b954eedeac495271d0f'
describe('multicall reducer', () => {
let store: Store<MulticallState>
beforeEach(() => {
store = createStore(reducer)
})
it('has correct initial state', () => {
expect(store.getState().callResults).toEqual({})
expect(store.getState().callListeners).toEqual(undefined)
})
describe('addMulticallListeners', () => {
it('adds listeners', () => {
store.dispatch(
addMulticallListeners({
chainId: 1,
calls: [
{
address: DAI_ADDRESS,
callData: '0x',
},
],
options: { blocksPerFetch: 1 },
})
)
expect(store.getState()).toEqual({
callListeners: {
1: {
[`${DAI_ADDRESS}-0x`]: {
1: 1,
},
},
},
callResults: {},
})
})
})
describe('removeMulticallListeners', () => {
it('noop', () => {
store.dispatch(
removeMulticallListeners({
calls: [
{
address: DAI_ADDRESS,
callData: '0x',
},
],
chainId: 1,
options: { blocksPerFetch: 1 },
})
)
expect(store.getState()).toEqual({ callResults: {}, callListeners: {} })
})
it('removes listeners', () => {
store.dispatch(
addMulticallListeners({
chainId: 1,
calls: [
{
address: DAI_ADDRESS,
callData: '0x',
},
],
options: { blocksPerFetch: 1 },
})
)
store.dispatch(
removeMulticallListeners({
calls: [
{
address: DAI_ADDRESS,
callData: '0x',
},
],
chainId: 1,
options: { blocksPerFetch: 1 },
})
)
expect(store.getState()).toEqual({
callResults: {},
callListeners: { 1: { [`${DAI_ADDRESS}-0x`]: {} } },
})
})
})
describe('updateMulticallResults', () => {
it('updates data if not present', () => {
store.dispatch(
updateMulticallResults({
chainId: 1,
blockNumber: 1,
results: {
abc: '0x',
},
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
abc: {
blockNumber: 1,
data: '0x',
},
},
},
})
})
it('updates old data', () => {
store.dispatch(
updateMulticallResults({
chainId: 1,
blockNumber: 1,
results: {
abc: '0x',
},
})
)
store.dispatch(
updateMulticallResults({
chainId: 1,
blockNumber: 2,
results: {
abc: '0x2',
},
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
abc: {
blockNumber: 2,
data: '0x2',
},
},
},
})
})
it('ignores late updates', () => {
store.dispatch(
updateMulticallResults({
chainId: 1,
blockNumber: 2,
results: {
abc: '0x2',
},
})
)
store.dispatch(
updateMulticallResults({
chainId: 1,
blockNumber: 1,
results: {
abc: '0x1',
},
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
abc: {
blockNumber: 2,
data: '0x2',
},
},
},
})
})
})
describe('fetchingMulticallResults', () => {
it('updates state to fetching', () => {
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
[`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 2 },
},
},
})
})
it('updates state to fetching even if already fetching older block', () => {
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 3,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
[`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 3 },
},
},
})
})
it('does not do update if fetching newer block', () => {
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 1,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
[`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 2 },
},
},
})
})
})
describe('errorFetchingMulticallResults', () => {
it('does nothing if not fetching', () => {
store.dispatch(
errorFetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 1,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {},
},
})
})
it('updates block number if we were fetching', () => {
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
store.dispatch(
errorFetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
[`${DAI_ADDRESS}-0x0`]: {
blockNumber: 2,
// null data indicates error
data: null,
},
},
},
})
})
it('does nothing if not errored on latest block', () => {
store.dispatch(
fetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 3,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
store.dispatch(
errorFetchingMulticallResults({
chainId: 1,
fetchingBlockNumber: 2,
calls: [{ address: DAI_ADDRESS, callData: '0x0' }],
})
)
expect(store.getState()).toEqual({
callResults: {
1: {
[`${DAI_ADDRESS}-0x0`]: { fetchingBlockNumber: 3 },
},
},
})
})
})
})

@ -1,133 +0,0 @@
import { createReducer } from '@reduxjs/toolkit'
import {
addMulticallListeners,
errorFetchingMulticallResults,
fetchingMulticallResults,
removeMulticallListeners,
updateMulticallResults,
} from './actions'
import { toCallKey } from './utils'
export interface MulticallState {
callListeners?: {
// on a per-chain basis
[chainId: number]: {
// stores for each call key the listeners' preferences
[callKey: string]: {
// stores how many listeners there are per each blocks per fetch preference
[blocksPerFetch: number]: number
}
}
}
callResults: {
[chainId: number]: {
[callKey: string]: {
data?: string | null
blockNumber?: number
fetchingBlockNumber?: number
}
}
}
}
const initialState: MulticallState = {
callResults: {},
}
export default createReducer(initialState, (builder) =>
builder
.addCase(
addMulticallListeners,
(
state,
{
payload: {
calls,
chainId,
options: { blocksPerFetch },
},
}
) => {
const listeners: MulticallState['callListeners'] = state.callListeners
? state.callListeners
: (state.callListeners = {})
listeners[chainId] = listeners[chainId] ?? {}
calls.forEach((call) => {
const callKey = toCallKey(call)
listeners[chainId][callKey] = listeners[chainId][callKey] ?? {}
listeners[chainId][callKey][blocksPerFetch] = (listeners[chainId][callKey][blocksPerFetch] ?? 0) + 1
})
}
)
.addCase(
removeMulticallListeners,
(
state,
{
payload: {
chainId,
calls,
options: { blocksPerFetch },
},
}
) => {
const listeners: MulticallState['callListeners'] = state.callListeners
? state.callListeners
: (state.callListeners = {})
if (!listeners[chainId]) return
calls.forEach((call) => {
const callKey = toCallKey(call)
if (!listeners[chainId][callKey]) return
if (!listeners[chainId][callKey][blocksPerFetch]) return
if (listeners[chainId][callKey][blocksPerFetch] === 1) {
delete listeners[chainId][callKey][blocksPerFetch]
} else {
listeners[chainId][callKey][blocksPerFetch]--
}
})
}
)
.addCase(fetchingMulticallResults, (state, { payload: { chainId, fetchingBlockNumber, calls } }) => {
state.callResults[chainId] = state.callResults[chainId] ?? {}
calls.forEach((call) => {
const callKey = toCallKey(call)
const current = state.callResults[chainId][callKey]
if (!current) {
state.callResults[chainId][callKey] = {
fetchingBlockNumber,
}
} else {
if ((current.fetchingBlockNumber ?? 0) >= fetchingBlockNumber) return
state.callResults[chainId][callKey].fetchingBlockNumber = fetchingBlockNumber
}
})
})
.addCase(errorFetchingMulticallResults, (state, { payload: { fetchingBlockNumber, chainId, calls } }) => {
state.callResults[chainId] = state.callResults[chainId] ?? {}
calls.forEach((call) => {
const callKey = toCallKey(call)
const current = state.callResults[chainId][callKey]
if (!current || typeof current.fetchingBlockNumber !== 'number') return // only should be dispatched if we are already fetching
if (current.fetchingBlockNumber <= fetchingBlockNumber) {
delete current.fetchingBlockNumber
current.data = null
current.blockNumber = fetchingBlockNumber
}
})
})
.addCase(updateMulticallResults, (state, { payload: { chainId, results, blockNumber } }) => {
state.callResults[chainId] = state.callResults[chainId] ?? {}
Object.keys(results).forEach((callKey) => {
const current = state.callResults[chainId][callKey]
if ((current?.blockNumber ?? 0) > blockNumber) return
state.callResults[chainId][callKey] = {
data: results[callKey],
blockNumber,
}
})
})
)

@ -1,168 +0,0 @@
import { activeListeningKeys, outdatedListeningKeys } from './updater'
describe('multicall updater', () => {
describe('#activeListeningKeys', () => {
it('ignores 0, returns call key to block age key', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
4: 2, // 2 listeners care about 4 block old data
1: 0, // 0 listeners care about 1 block old data
},
},
},
1
)
).toEqual({
abc: 4,
})
})
it('applies min', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
4: 2, // 2 listeners care about 4 block old data
3: 1, // 1 listener cares about 3 block old data
1: 0, // 0 listeners care about 1 block old data
},
},
},
1
)
).toEqual({
abc: 3,
})
})
it('works for infinity', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
4: 2, // 2 listeners care about 4 block old data
1: 0, // 0 listeners care about 1 block old data
},
def: {
Infinity: 2,
},
},
},
1
)
).toEqual({
abc: 4,
def: Infinity,
})
})
it('multiple keys', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
4: 2, // 2 listeners care about 4 block old data
1: 0, // 0 listeners care about 1 block old data
},
def: {
2: 1,
5: 2,
},
},
},
1
)
).toEqual({
abc: 4,
def: 2,
})
})
it('ignores negative numbers', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
4: 2,
1: -1,
[-3]: 4,
},
},
},
1
)
).toEqual({
abc: 4,
})
})
it('applies min to infinity', () => {
expect(
activeListeningKeys(
{
1: {
abc: {
Infinity: 2, // 2 listeners care about any data
4: 2, // 2 listeners care about 4 block old data
1: 0, // 0 listeners care about 1 block old data
},
},
},
1
)
).toEqual({
abc: 4,
})
})
})
describe('#outdatedListeningKeys', () => {
it('returns empty if missing block number or chain id', () => {
expect(outdatedListeningKeys({}, { abc: 2 }, undefined, undefined)).toEqual([])
expect(outdatedListeningKeys({}, { abc: 2 }, 1, undefined)).toEqual([])
expect(outdatedListeningKeys({}, { abc: 2 }, undefined, 1)).toEqual([])
})
it('returns everything for no results', () => {
expect(outdatedListeningKeys({}, { abc: 2, def: 3 }, 1, 1)).toEqual(['abc', 'def'])
})
it('returns only outdated keys', () => {
expect(outdatedListeningKeys({ 1: { abc: { data: '0x', blockNumber: 2 } } }, { abc: 1, def: 1 }, 1, 2)).toEqual([
'def',
])
})
it('returns only keys not being fetched', () => {
expect(
outdatedListeningKeys(
{
1: { abc: { data: '0x', blockNumber: 2 }, def: { fetchingBlockNumber: 2 } },
},
{ abc: 1, def: 1 },
1,
2
)
).toEqual([])
})
it('returns keys being fetched for old blocks', () => {
expect(
outdatedListeningKeys(
{ 1: { abc: { data: '0x', blockNumber: 2 }, def: { fetchingBlockNumber: 1 } } },
{ abc: 1, def: 1 },
1,
2
)
).toEqual(['def'])
})
it('respects blocks per fetch', () => {
expect(
outdatedListeningKeys(
{ 1: { abc: { data: '0x', blockNumber: 2 }, def: { data: '0x', fetchingBlockNumber: 1 } } },
{ abc: 2, def: 2 },
1,
3
)
).toEqual(['def'])
})
})
})

@ -1,269 +1,12 @@
import { useEffect, useMemo, useRef } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { UniswapInterfaceMulticall } from 'types/v3'
import { useMulticall2Contract } from '../../hooks/useContract'
import useDebounce from '../../hooks/useDebounce'
import { useActiveWeb3React } from '../../hooks/web3'
import chunkArray from '../../utils/chunkArray'
import { retry, RetryableError } from '../../utils/retry'
import { useBlockNumber } from '../application/hooks'
import { AppState } from '../index'
import { errorFetchingMulticallResults, fetchingMulticallResults, updateMulticallResults } from './actions'
import { Call, parseCallKey, toCallKey } from './utils'
import { multicall } from './instance'
const DEFAULT_CALL_GAS_REQUIRED = 1_000_000
/**
* Fetches a chunk of calls, enforcing a minimum block number constraint
* @param multicall multicall contract to fetch against
* @param chunk chunk of calls to make
* @param blockNumber block number passed as the block tag in the eth_call
*/
async function fetchChunk(
multicall: UniswapInterfaceMulticall,
chunk: Call[],
blockNumber: number
): Promise<{ success: boolean; returnData: string }[]> {
console.debug('Fetching chunk', chunk, blockNumber)
try {
const { returnData } = await multicall.callStatic.multicall(
chunk.map((obj) => ({
target: obj.address,
callData: obj.callData,
gasLimit: obj.gasRequired ?? DEFAULT_CALL_GAS_REQUIRED,
})),
{
// we aren't passing through the block gas limit we used to create the chunk, because it causes a problem with the integ tests
blockTag: blockNumber,
}
)
if (process.env.NODE_ENV === 'development') {
returnData.forEach(({ gasUsed, returnData, success }, i) => {
if (
!success &&
returnData.length === 2 &&
gasUsed.gte(Math.floor((chunk[i].gasRequired ?? DEFAULT_CALL_GAS_REQUIRED) * 0.95))
) {
console.warn(
`A call failed due to requiring ${gasUsed.toString()} vs. allowed ${
chunk[i].gasRequired ?? DEFAULT_CALL_GAS_REQUIRED
}`,
chunk[i]
)
}
})
}
return returnData
} catch (error) {
if (error.code === -32000 || error.message?.indexOf('header not found') !== -1) {
throw new RetryableError(`header not found for block number ${blockNumber}`)
} else if (error.code === -32603 || error.message?.indexOf('execution ran out of gas') !== -1) {
if (chunk.length > 1) {
if (process.env.NODE_ENV === 'development') {
console.debug('Splitting a chunk in 2', chunk)
}
const half = Math.floor(chunk.length / 2)
const [c0, c1] = await Promise.all([
fetchChunk(multicall, chunk.slice(0, half), blockNumber),
fetchChunk(multicall, chunk.slice(half, chunk.length), blockNumber),
])
return c0.concat(c1)
}
}
console.error('Failed to fetch chunk', error)
throw error
}
}
/**
* From the current all listeners state, return each call key mapped to the
* minimum number of blocks per fetch. This is how often each key must be fetched.
* @param allListeners the all listeners state
* @param chainId the current chain id
*/
export function activeListeningKeys(
allListeners: AppState['multicall']['callListeners'],
chainId?: number
): { [callKey: string]: number } {
if (!allListeners || !chainId) return {}
const listeners = allListeners[chainId]
if (!listeners) return {}
return Object.keys(listeners).reduce<{ [callKey: string]: number }>((memo, callKey) => {
const keyListeners = listeners[callKey]
memo[callKey] = Object.keys(keyListeners)
.filter((key) => {
const blocksPerFetch = parseInt(key)
if (blocksPerFetch <= 0) return false
return keyListeners[blocksPerFetch] > 0
})
.reduce((previousMin, current) => {
return Math.min(previousMin, parseInt(current))
}, Infinity)
return memo
}, {})
}
/**
* Return the keys that need to be refetched
* @param callResults current call result state
* @param listeningKeys each call key mapped to how old the data can be in blocks
* @param chainId the current chain id
* @param latestBlockNumber the latest block number
*/
export function outdatedListeningKeys(
callResults: AppState['multicall']['callResults'],
listeningKeys: { [callKey: string]: number },
chainId: number | undefined,
latestBlockNumber: number | undefined
): string[] {
if (!chainId || !latestBlockNumber) return []
const results = callResults[chainId]
// no results at all, load everything
if (!results) return Object.keys(listeningKeys)
return Object.keys(listeningKeys).filter((callKey) => {
const blocksPerFetch = listeningKeys[callKey]
const data = callResults[chainId][callKey]
// no data, must fetch
if (!data) return true
const minDataBlockNumber = latestBlockNumber - (blocksPerFetch - 1)
// already fetching it for a recent enough block, don't refetch it
if (data.fetchingBlockNumber && data.fetchingBlockNumber >= minDataBlockNumber) return false
// if data is older than minDataBlockNumber, fetch it
return !data.blockNumber || data.blockNumber < minDataBlockNumber
})
}
export default function Updater(): null {
const dispatch = useAppDispatch()
const state = useAppSelector((state) => state.multicall)
// wait for listeners to settle before triggering updates
const debouncedListeners = useDebounce(state.callListeners, 100)
// Create Updater wrappers that pull needed info from store
export default function Updater() {
const latestBlockNumber = useBlockNumber()
const { chainId } = useActiveWeb3React()
const multicall2Contract = useMulticall2Contract()
const cancellations = useRef<{ blockNumber: number; cancellations: (() => void)[] }>()
const listeningKeys: { [callKey: string]: number } = useMemo(() => {
return activeListeningKeys(debouncedListeners, chainId)
}, [debouncedListeners, chainId])
const unserializedOutdatedCallKeys = useMemo(() => {
return outdatedListeningKeys(state.callResults, listeningKeys, chainId, latestBlockNumber)
}, [chainId, state.callResults, listeningKeys, latestBlockNumber])
const serializedOutdatedCallKeys = useMemo(
() => JSON.stringify(unserializedOutdatedCallKeys.sort()),
[unserializedOutdatedCallKeys]
)
// todo: consider getting this information from the node we are using, e.g. block.gaslimit
const chunkGasLimit = 100_000_000
useEffect(() => {
if (!latestBlockNumber || !chainId || !multicall2Contract) return
const outdatedCallKeys: string[] = JSON.parse(serializedOutdatedCallKeys)
if (outdatedCallKeys.length === 0) return
const calls = outdatedCallKeys.map((key) => parseCallKey(key))
const chunkedCalls = chunkArray(calls, chunkGasLimit)
if (cancellations.current && cancellations.current.blockNumber !== latestBlockNumber) {
cancellations.current.cancellations.forEach((c) => c())
}
dispatch(
fetchingMulticallResults({
calls,
chainId,
fetchingBlockNumber: latestBlockNumber,
})
)
cancellations.current = {
blockNumber: latestBlockNumber,
cancellations: chunkedCalls.map((chunk) => {
const { cancel, promise } = retry(() => fetchChunk(multicall2Contract, chunk, latestBlockNumber), {
n: Infinity,
minWait: 1000,
maxWait: 2500,
})
promise
.then((returnData) => {
// split the returned slice into errors and results
const { erroredCalls, results } = chunk.reduce<{
erroredCalls: Call[]
results: { [callKey: string]: string | null }
}>(
(memo, call, i) => {
if (returnData[i].success) {
memo.results[toCallKey(call)] = returnData[i].returnData ?? null
} else {
memo.erroredCalls.push(call)
}
return memo
},
{ erroredCalls: [], results: {} }
)
// dispatch any new results
if (Object.keys(results).length > 0)
dispatch(
updateMulticallResults({
chainId,
results,
blockNumber: latestBlockNumber,
})
)
// dispatch any errored calls
if (erroredCalls.length > 0) {
if (process.env.NODE_ENV === 'development') {
returnData.forEach((returnData, ix) => {
if (!returnData.success) {
console.debug('Call failed', chunk[ix], returnData)
}
})
} else {
console.debug('Calls errored in fetch', erroredCalls)
}
dispatch(
errorFetchingMulticallResults({
calls: erroredCalls,
chainId,
fetchingBlockNumber: latestBlockNumber,
})
)
}
})
.catch((error: any) => {
if (error.isCancelledError) {
console.debug('Cancelled fetch for blockNumber', latestBlockNumber, chunk, chainId)
return
}
console.error('Failed to fetch multicall chunk', chunk, chainId, error)
dispatch(
errorFetchingMulticallResults({
calls: chunk,
chainId,
fetchingBlockNumber: latestBlockNumber,
})
)
})
return cancel
}),
}
}, [chainId, multicall2Contract, dispatch, serializedOutdatedCallKeys, latestBlockNumber])
return null
return <multicall.Updater chainId={chainId} latestBlockNumber={latestBlockNumber} contract={multicall2Contract} />
}

@ -1,28 +0,0 @@
export interface Call {
address: string
callData: string
gasRequired?: number
}
export function toCallKey(call: Call): string {
let key = `${call.address}-${call.callData}`
if (call.gasRequired) {
if (!Number.isSafeInteger(call.gasRequired)) {
throw new Error(`Invalid number: ${call.gasRequired}`)
}
key += `-${call.gasRequired}`
}
return key
}
export function parseCallKey(callKey: string): Call {
const pcs = callKey.split('-')
if (![2, 3].includes(pcs.length)) {
throw new Error(`Invalid call key: ${callKey}`)
}
return {
address: pcs[0],
callData: pcs[1],
...(pcs[2] ? { gasRequired: Number.parseInt(pcs[2]) } : {}),
}
}

12
src/types/tuple.ts Normal file

@ -0,0 +1,12 @@
// From https://stackoverflow.com/a/67605309/1345206
// Used for slicing tuples (e.g. picking some subset of a param type)
export type TupleSplit<T, N extends number, O extends readonly any[] = readonly []> = O['length'] extends N
? [O, T]
: T extends readonly [infer F, ...infer R]
? TupleSplit<readonly [...R], N, readonly [...O, F]>
: [O, T]
export type TakeFirst<T extends readonly any[], N extends number> = TupleSplit<T, N>[0]
export type SkipFirst<T extends readonly any[], N extends number> = TupleSplit<T, N>[1]

@ -1,17 +0,0 @@
import chunkArray, { DEFAULT_GAS_REQUIRED } from './chunkArray'
describe('#chunkArray', () => {
it('size 1', () => {
expect(chunkArray([1, 2, 3], 1)).toEqual([[1], [2], [3]])
expect(chunkArray([1, 2, 3], DEFAULT_GAS_REQUIRED)).toEqual([[1], [2], [3]])
})
it('size gt items', () => {
expect(chunkArray([1, 2, 3], DEFAULT_GAS_REQUIRED * 3 + 1)).toEqual([[1, 2, 3]])
})
it('size exact half', () => {
expect(chunkArray([1, 2, 3, 4], DEFAULT_GAS_REQUIRED * 2 + 1)).toEqual([
[1, 2],
[3, 4],
])
})
})

@ -1,31 +0,0 @@
export const DEFAULT_GAS_REQUIRED = 200_000 // the default value for calls that don't specify gasRequired
// chunks array into chunks
// evenly distributes items among the chunks
export default function chunkArray<T>(items: T[], chunkGasLimit: number): T[][] {
const chunks: T[][] = []
let currentChunk: T[] = []
let currentChunkCumulativeGas = 0
for (let i = 0; i < items.length; i++) {
const item = items[i]
// calculate the gas required by the current item
const gasRequired = (item as { gasRequired?: number })?.gasRequired ?? DEFAULT_GAS_REQUIRED
// if the current chunk is empty, or the current item wouldn't push it over the gas limit,
// append the current item and increment the cumulative gas
if (currentChunk.length === 0 || currentChunkCumulativeGas + gasRequired < chunkGasLimit) {
currentChunk.push(item)
currentChunkCumulativeGas += gasRequired
} else {
// otherwise, push the current chunk and create a new chunk
chunks.push(currentChunk)
currentChunk = [item]
currentChunkCumulativeGas = gasRequired
}
}
if (currentChunk.length > 0) chunks.push(currentChunk)
return chunks
}

@ -4574,6 +4574,11 @@
resolved "https://registry.npmjs.org/@uniswap/merkle-distributor/-/merkle-distributor-1.0.1.tgz"
integrity sha512-5gDiTI5hrXIh5UWTrxKYjw30QQDnpl8ckDSpefldNenDlYO1RKkdUYMYpvrqGi2r7YzLYTlO6+TDlNs6O7hDRw==
"@uniswap/redux-multicall@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@uniswap/redux-multicall/-/redux-multicall-1.0.0.tgz#0cee4448909a788ea4700e5ede75ffeba05b5d75"
integrity sha512-zR6tNC3XF6JuI6PjGlZW2Hz7tTzRzzVaPJfZ01BBWBJVt/2ixJY0SH514uffD03NHYiXZA//hlPQLfw3TkIxQg==
"@uniswap/sdk-core@^3.0.0-alpha.3", "@uniswap/sdk-core@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@uniswap/sdk-core/-/sdk-core-3.0.1.tgz#d08dd68257983af64b9a5f4d6b9cf26124b4138f"