Commit 596ea030 authored by J M Rossy's avatar J M Rossy Committed by GitHub

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
parent e81e8a8f
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,
......
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'
)
})
})
})
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')
This diff is collapsed.
import { createMulticall } from '@uniswap/redux-multicall'
// Create a multicall instance with default settings
export const multicall = createMulticall()
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 },
},
},
})
})
})
})
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,
}
})
})
)
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'])
})
})
})
This diff is collapsed.
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]) } : {}),
}
}
// 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]
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],
])
})
})
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"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment