Commit 83554f44 authored by Moody Salem's avatar Moody Salem Committed by GitHub

perf(multicall): add unit tests and fix a bug (#845)

* start with the migrate page

* Add a bunch of tests and bump up the call size

* Show a link to the old portal, disable the WIP page

* Fix lint error
parent 320b2e38
import { ChainId, Pair, Percent, Route, Token, TokenAmount, Trade, TradeType, WETH } from '@uniswap/sdk' import { ChainId, JSBI, Pair, Percent, Route, Token, TokenAmount, Trade, TradeType, WETH } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useActiveWeb3React } from '../hooks' import { useActiveWeb3React } from '../hooks'
import { useAllTokens } from '../hooks/Tokens'
import { useV1FactoryContract } from '../hooks/useContract' import { useV1FactoryContract } from '../hooks/useContract'
import { NEVER_RELOAD, useSingleCallResult, useSingleContractMultipleData } from '../state/multicall/hooks' import { NEVER_RELOAD, useSingleCallResult, useSingleContractMultipleData } from '../state/multicall/hooks'
import { useETHBalances, useTokenBalance } from '../state/wallet/hooks' import { useETHBalances, useTokenBalance, useTokenBalances } from '../state/wallet/hooks'
function useV1PairAddress(tokenAddress?: string): string | undefined { function useV1PairAddress(tokenAddress?: string): string | undefined {
const contract = useV1FactoryContract() const contract = useV1FactoryContract()
...@@ -29,17 +30,50 @@ function useMockV1Pair(token?: Token): MockV1Pair | undefined { ...@@ -29,17 +30,50 @@ function useMockV1Pair(token?: Token): MockV1Pair | undefined {
: undefined : undefined
} }
// returns ALL v1 exchange addresses
export function useAllV1ExchangeAddresses(): string[] { export function useAllV1ExchangeAddresses(): string[] {
const factory = useV1FactoryContract() const factory = useV1FactoryContract()
const exchangeCount = useSingleCallResult(factory, 'tokenCount')?.result const exchangeCount = useSingleCallResult(factory, 'tokenCount')?.result
const parsedCount = parseInt(exchangeCount?.toString() ?? '0') const parsedCount = parseInt(exchangeCount?.toString() ?? '0')
const indices = [...Array(parsedCount).keys()].map(ix => [ix]) const indices = useMemo(() => [...Array(parsedCount).keys()].map(ix => [ix]), [parsedCount])
return ( const data = useSingleContractMultipleData(factory, 'getTokenWithId', indices, NEVER_RELOAD)
useSingleContractMultipleData(factory, 'getTokenWithId', indices, NEVER_RELOAD)
?.map(({ result }) => result?.[0]) return useMemo(() => data?.map(({ result }) => result?.[0])?.filter(x => x) ?? [], [data])
?.filter(x => x) ?? [] }
// returns all v1 exchange addresses in the user's token list
export function useAllTokenV1ExchangeAddresses(): string[] {
const allTokens = useAllTokens()
const factory = useV1FactoryContract()
const args = useMemo(() => Object.keys(allTokens).map(tokenAddress => [tokenAddress]), [allTokens])
const data = useSingleContractMultipleData(factory, 'getExchange', args, NEVER_RELOAD)
return useMemo(() => data?.map(({ result }) => result?.[0])?.filter(x => x) ?? [], [data])
}
// returns whether any of the tokens in the user's token list have liquidity on v1
export function useUserProbablyHasV1Liquidity(): boolean | undefined {
const exchangeAddresses = useAllTokenV1ExchangeAddresses()
const { account, chainId } = useActiveWeb3React()
const fakeTokens = useMemo(
() => (chainId ? exchangeAddresses.map(address => new Token(chainId, address, 18, 'UNI-V1')) : []),
[chainId, exchangeAddresses]
)
const balances = useTokenBalances(account ?? undefined, fakeTokens)
return useMemo(
() =>
Object.keys(balances).some(tokenAddress => {
const b = balances[tokenAddress]?.raw
return b && JSBI.greaterThan(b, JSBI.BigInt(0))
}),
[balances]
) )
} }
......
import { JSBI, Token } from '@uniswap/sdk'
import React, { useMemo } from 'react'
import { RouteComponentProps } from 'react-router'
import { useAllV1ExchangeAddresses } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks'
import { useTokenBalances } from '../../state/wallet/hooks'
const PLACEHOLDER_ACCOUNT = (
<div>
<h1>You must connect a wallet to use this tool.</h1>
</div>
)
/**
* Page component for migrating liquidity from V1
*/
export default function MigrateV1({}: RouteComponentProps) {
const { account, chainId } = useActiveWeb3React()
const v1ExchangeAddresses = useAllV1ExchangeAddresses()
const v1ExchangeTokens: Token[] = useMemo(() => {
return v1ExchangeAddresses.map(exchangeAddress => new Token(chainId, exchangeAddress, 18))
}, [chainId, v1ExchangeAddresses])
const tokenBalances = useTokenBalances(account, v1ExchangeTokens)
const unmigratedExchangeAddresses = useMemo(
() =>
Object.keys(tokenBalances).filter(tokenAddress =>
tokenBalances[tokenAddress] ? JSBI.greaterThan(tokenBalances[tokenAddress]?.raw, JSBI.BigInt(0)) : false
),
[tokenBalances]
)
if (!account) {
return PLACEHOLDER_ACCOUNT
}
return <div>{unmigratedExchangeAddresses?.join('\n')}</div>
}
...@@ -6,6 +6,7 @@ import { RouteComponentProps } from 'react-router-dom' ...@@ -6,6 +6,7 @@ import { RouteComponentProps } from 'react-router-dom'
import Question from '../../components/QuestionHelper' import Question from '../../components/QuestionHelper'
import SearchModal from '../../components/SearchModal' import SearchModal from '../../components/SearchModal'
import PositionCard from '../../components/PositionCard' import PositionCard from '../../components/PositionCard'
import { useUserProbablyHasV1Liquidity } from '../../data/V1'
import { useTokenBalances } from '../../state/wallet/hooks' import { useTokenBalances } from '../../state/wallet/hooks'
import { Link, TYPE } from '../../theme' import { Link, TYPE } from '../../theme'
import { Text } from 'rebass' import { Text } from 'rebass'
...@@ -58,6 +59,8 @@ export default function Pool({ history }: RouteComponentProps) { ...@@ -58,6 +59,8 @@ export default function Pool({ history }: RouteComponentProps) {
return <PositionCardWrapper key={i} dummyPair={pair} /> return <PositionCardWrapper key={i} dummyPair={pair} />
}) })
const hasV1Liquidity = useUserProbablyHasV1Liquidity()
return ( return (
<AppBody> <AppBody>
<AutoColumn gap="lg" justify="center"> <AutoColumn gap="lg" justify="center">
...@@ -92,15 +95,23 @@ export default function Pool({ history }: RouteComponentProps) { ...@@ -92,15 +95,23 @@ export default function Pool({ history }: RouteComponentProps) {
)} )}
{filteredExchangeList} {filteredExchangeList}
<Text textAlign="center" fontSize={14} style={{ padding: '.5rem 0 .5rem 0' }}> <Text textAlign="center" fontSize={14} style={{ padding: '.5rem 0 .5rem 0' }}>
{filteredExchangeList?.length !== 0 ? `Don't see a pool you joined? ` : 'Already joined a pool? '}{' '} {!hasV1Liquidity ? (
<Link <>
id="import-pool-link" {filteredExchangeList?.length !== 0 ? `Don't see a pool you joined? ` : 'Already joined a pool? '}{' '}
onClick={() => { <Link
history.push('/find') id="import-pool-link"
}} onClick={() => {
> history.push('/find')
Import it. }}
</Link> >
Import it.
</Link>
</>
) : (
<Link id="migrate-v1-liquidity-link" href="https://migrate.uniswap.exchange">
Migrate your V1 liquidity.
</Link>
)}
</Text> </Text>
</AutoColumn> </AutoColumn>
<FixedBottom> <FixedBottom>
......
import { addMulticallListeners, removeMulticallListeners } from './actions'
import reducer, { MulticallState } from './reducer'
import { Store, createStore } from '@reduxjs/toolkit'
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: '0x',
callData: '0x'
}
]
})
)
expect(store.getState()).toEqual({
callListeners: {
[1]: {
'0x-0x': {
[1]: 1
}
}
},
callResults: {}
})
})
})
describe('removeMulticallListeners', () => {
it('noop', () => {
store.dispatch(
removeMulticallListeners({
calls: [
{
address: '0x',
callData: '0x'
}
],
chainId: 1
})
)
expect(store.getState()).toEqual({ callResults: {}, callListeners: {} })
})
it('removes listeners', () => {
store.dispatch(
addMulticallListeners({
chainId: 1,
calls: [
{
address: '0x',
callData: '0x'
}
]
})
)
store.dispatch(
removeMulticallListeners({
calls: [
{
address: '0x',
callData: '0x'
}
],
chainId: 1
})
)
expect(store.getState()).toEqual({ callResults: {}, callListeners: { [1]: { '0x-0x': {} } } })
})
})
})
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
updateMulticallResults updateMulticallResults
} from './actions' } from './actions'
interface MulticallState { export interface MulticallState {
callListeners?: { callListeners?: {
// on a per-chain basis // on a per-chain basis
[chainId: number]: { [chainId: number]: {
...@@ -23,8 +23,8 @@ interface MulticallState { ...@@ -23,8 +23,8 @@ interface MulticallState {
callResults: { callResults: {
[chainId: number]: { [chainId: number]: {
[callKey: string]: { [callKey: string]: {
data: string | null data?: string | null
blockNumber: number blockNumber?: number
fetchingBlockNumber?: number fetchingBlockNumber?: number
} }
} }
...@@ -74,10 +74,13 @@ export default createReducer(initialState, builder => ...@@ -74,10 +74,13 @@ export default createReducer(initialState, builder =>
calls.forEach(call => { calls.forEach(call => {
const callKey = toCallKey(call) const callKey = toCallKey(call)
const current = state.callResults[chainId][callKey] const current = state.callResults[chainId][callKey]
if (current && current.blockNumber > fetchingBlockNumber) return if (!current) {
state.callResults[chainId][callKey] = { state.callResults[chainId][callKey] = {
...state.callResults[chainId][callKey], fetchingBlockNumber
fetchingBlockNumber }
} else {
if (current.fetchingBlockNumber ?? 0 >= fetchingBlockNumber) return
state.callResults[chainId][callKey].fetchingBlockNumber = fetchingBlockNumber
} }
}) })
}) })
...@@ -94,7 +97,7 @@ export default createReducer(initialState, builder => ...@@ -94,7 +97,7 @@ export default createReducer(initialState, builder =>
state.callResults[chainId] = state.callResults[chainId] ?? {} state.callResults[chainId] = state.callResults[chainId] ?? {}
Object.keys(results).forEach(callKey => { Object.keys(results).forEach(callKey => {
const current = state.callResults[chainId][callKey] const current = state.callResults[chainId][callKey]
if (current && current.blockNumber > blockNumber) return if (current?.blockNumber ?? 0 > blockNumber) return
state.callResults[chainId][callKey] = { state.callResults[chainId][callKey] = {
data: results[callKey], data: results[callKey],
blockNumber 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'])
})
})
})
...@@ -15,7 +15,7 @@ import { ...@@ -15,7 +15,7 @@ import {
} from './actions' } from './actions'
// chunk calls so we do not exceed the gas limit // chunk calls so we do not exceed the gas limit
const CALL_CHUNK_SIZE = 250 const CALL_CHUNK_SIZE = 500
/** /**
* From the current all listeners state, return each call key mapped to the * From the current all listeners state, return each call key mapped to the
...@@ -23,7 +23,7 @@ const CALL_CHUNK_SIZE = 250 ...@@ -23,7 +23,7 @@ const CALL_CHUNK_SIZE = 250
* @param allListeners the all listeners state * @param allListeners the all listeners state
* @param chainId the current chain id * @param chainId the current chain id
*/ */
function activeListeningKeys( export function activeListeningKeys(
allListeners: AppState['multicall']['callListeners'], allListeners: AppState['multicall']['callListeners'],
chainId?: number chainId?: number
): { [callKey: string]: number } { ): { [callKey: string]: number } {
...@@ -35,7 +35,11 @@ function activeListeningKeys( ...@@ -35,7 +35,11 @@ function activeListeningKeys(
const keyListeners = listeners[callKey] const keyListeners = listeners[callKey]
memo[callKey] = Object.keys(keyListeners) memo[callKey] = Object.keys(keyListeners)
.filter(key => keyListeners[parseInt(key)] > 0) .filter(key => {
const blocksPerFetch = parseInt(key)
if (blocksPerFetch <= 0) return false
return keyListeners[blocksPerFetch] > 0
})
.reduce((previousMin, current) => { .reduce((previousMin, current) => {
return Math.min(previousMin, parseInt(current)) return Math.min(previousMin, parseInt(current))
}, Infinity) }, Infinity)
...@@ -43,6 +47,41 @@ function activeListeningKeys( ...@@ -43,6 +47,41 @@ function activeListeningKeys(
}, {}) }, {})
} }
/**
* 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 newer than minDataBlockNumber, don't fetch it
return !(data.blockNumber && data.blockNumber >= minDataBlockNumber)
})
}
export default function Updater() { export default function Updater() {
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const state = useSelector<AppState, AppState['multicall']>(state => state.multicall) const state = useSelector<AppState, AppState['multicall']>(state => state.multicall)
...@@ -57,24 +96,7 @@ export default function Updater() { ...@@ -57,24 +96,7 @@ export default function Updater() {
}, [debouncedListeners, chainId]) }, [debouncedListeners, chainId])
const unserializedOutdatedCallKeys = useMemo(() => { const unserializedOutdatedCallKeys = useMemo(() => {
// wait for these before fetching any data return outdatedListeningKeys(state.callResults, listeningKeys, chainId, latestBlockNumber)
if (!chainId || !latestBlockNumber) return []
// no results at all, load everything
if (!state.callResults[chainId]) return Object.keys(listeningKeys)
return Object.keys(listeningKeys).filter(callKey => {
const blocksPerFetch = listeningKeys[callKey]
const data = state.callResults[chainId][callKey]
// no data, must fetch
if (!data) return true
// already fetching it
if (data.fetchingBlockNumber && data.fetchingBlockNumber >= latestBlockNumber + blocksPerFetch) return false
// data block number is older than blocksPerFetch blocks
return data.blockNumber <= latestBlockNumber - blocksPerFetch
})
}, [chainId, state.callResults, listeningKeys, latestBlockNumber]) }, [chainId, state.callResults, listeningKeys, latestBlockNumber])
const serializedOutdatedCallKeys = useMemo(() => JSON.stringify(unserializedOutdatedCallKeys.sort()), [ const serializedOutdatedCallKeys = useMemo(() => JSON.stringify(unserializedOutdatedCallKeys.sort()), [
......
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