diff --git a/package.json b/package.json index d65d246d15..e0530d7c18 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/hooks/useV3Positions.ts b/src/hooks/useV3Positions.ts index 4f545104aa..add347aa30 100644 --- a/src/hooks/useV3Positions.ts +++ b/src/hooks/useV3Positions.ts @@ -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 [] diff --git a/src/state/index.ts b/src/state/index.ts index 6f6196a638..0a3200881a 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -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, diff --git a/src/state/multicall/actions.test.ts b/src/state/multicall/actions.test.ts deleted file mode 100644 index 7ec7a16f90..0000000000 --- a/src/state/multicall/actions.test.ts +++ /dev/null @@ -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' - ) - }) - }) -}) diff --git a/src/state/multicall/actions.ts b/src/state/multicall/actions.ts deleted file mode 100644 index a5a0c8e901..0000000000 --- a/src/state/multicall/actions.ts +++ /dev/null @@ -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') diff --git a/src/state/multicall/hooks.ts b/src/state/multicall/hooks.ts index b0e792b426..7333496290 100644 --- a/src/state/multicall/hooks.ts +++ b/src/state/multicall/hooks.ts @@ -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 { - readonly [key: string]: any -} +const { + useMultipleContractSingleData: _useMultipleContractSingleData, + useSingleCallResult: _useSingleCallResult, + useSingleContractMultipleData: _useSingleContractMultipleData, + useSingleContractWithCallData: _useSingleContractWithCallData, +} = multicall.hooks -type MethodArg = string | number | BigNumber -type MethodArgs = Array +// Create wrappers for hooks so consumers don't need to get latest block themselves -type OptionalMethodInputs = Array | undefined +type SkipFirstTwoParams any> = SkipFirst, 2> -function isMethodArg(x: unknown): x is MethodArg { - return BigNumber.isBigNumber(x) || ['string', 'number'].indexOf(typeof x) !== -1 +export function useMultipleContractSingleData(...args: SkipFirstTwoParams) { + const { chainId, latestBlock } = useCallContext() + return _useMultipleContractSingleData(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 useSingleCallResult(...args: SkipFirstTwoParams) { + const { chainId, latestBlock } = useCallContext() + return _useSingleCallResult(chainId, latestBlock, ...args) } -interface CallResult { - readonly valid: boolean - readonly data: string | undefined - readonly blockNumber: number | undefined +export function useSingleContractMultipleData(...args: SkipFirstTwoParams) { + const { chainId, latestBlock } = useCallContext() + return _useSingleContractMultipleData(chainId, latestBlock, ...args) } -const INVALID_RESULT: CallResult = { valid: false, blockNumber: undefined, data: undefined } - -// use this options object -export const NEVER_RELOAD: ListenerOptions = { - blocksPerFetch: Infinity, +export function useSingleContractWithCallData(...args: SkipFirstTwoParams) { + const { chainId, latestBlock } = useCallContext() + return _useSingleContractWithCallData(chainId, latestBlock, ...args) } -// 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((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 & { gasRequired?: number } = {} -): CallState[] { - const fragment = useMemo(() => contract?.interface?.getFunction(methodName), [contract, methodName]) - - // encode callDatas - const callDatas = useMemo( - () => - contract && fragment - ? callInputs.map((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((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 & { 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((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 & { 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 & { gasRequired?: number } = {} -): CallState[] { - const gasRequired = options?.gasRequired - const blocksPerFetch = options?.blocksPerFetch - - // encode calls - const calls = useMemo( - () => - contract - ? callDatas.map((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 } } diff --git a/src/state/multicall/instance.ts b/src/state/multicall/instance.ts new file mode 100644 index 0000000000..afc24ef1e3 --- /dev/null +++ b/src/state/multicall/instance.ts @@ -0,0 +1,4 @@ +import { createMulticall } from '@uniswap/redux-multicall' + +// Create a multicall instance with default settings +export const multicall = createMulticall() diff --git a/src/state/multicall/reducer.test.ts b/src/state/multicall/reducer.test.ts deleted file mode 100644 index 72e2b9948e..0000000000 --- a/src/state/multicall/reducer.test.ts +++ /dev/null @@ -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 - 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 }, - }, - }, - }) - }) - }) -}) diff --git a/src/state/multicall/reducer.ts b/src/state/multicall/reducer.ts deleted file mode 100644 index caf027d1d0..0000000000 --- a/src/state/multicall/reducer.ts +++ /dev/null @@ -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, - } - }) - }) -) diff --git a/src/state/multicall/updater.test.ts b/src/state/multicall/updater.test.ts deleted file mode 100644 index 7a0f5612cd..0000000000 --- a/src/state/multicall/updater.test.ts +++ /dev/null @@ -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']) - }) - }) -}) diff --git a/src/state/multicall/updater.tsx b/src/state/multicall/updater.tsx index 82feb3f918..c6ac015b37 100644 --- a/src/state/multicall/updater.tsx +++ b/src/state/multicall/updater.tsx @@ -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' - -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 +import { multicall } from './instance' - // 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 } diff --git a/src/state/multicall/utils.ts b/src/state/multicall/utils.ts deleted file mode 100644 index 3127adbb8d..0000000000 --- a/src/state/multicall/utils.ts +++ /dev/null @@ -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]) } : {}), - } -} diff --git a/src/types/tuple.ts b/src/types/tuple.ts new file mode 100644 index 0000000000..ecf2686f64 --- /dev/null +++ b/src/types/tuple.ts @@ -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 = O['length'] extends N + ? [O, T] + : T extends readonly [infer F, ...infer R] + ? TupleSplit + : [O, T] + +export type TakeFirst = TupleSplit[0] + +export type SkipFirst = TupleSplit[1] diff --git a/src/utils/chunkArray.test.ts b/src/utils/chunkArray.test.ts deleted file mode 100644 index fbfb3fba5e..0000000000 --- a/src/utils/chunkArray.test.ts +++ /dev/null @@ -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], - ]) - }) -}) diff --git a/src/utils/chunkArray.ts b/src/utils/chunkArray.ts deleted file mode 100644 index 04345203fa..0000000000 --- a/src/utils/chunkArray.ts +++ /dev/null @@ -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(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 -} diff --git a/yarn.lock b/yarn.lock index 0fca1ff0ad..9dc125892c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"