diff --git a/codegen.yml b/codegen.yml index 0ac8b30463..67fc86b9f8 100644 --- a/codegen.yml +++ b/codegen.yml @@ -1,3 +1,4 @@ +overrideExisting: true schema: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3' documents: 'src/**/!(*.d).{ts,tsx}' generates: diff --git a/package.json b/package.json index 9b0f6089e0..69d8e9f7c2 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "@types/lingui__core": "^2.7.1", "@types/lingui__macro": "^2.7.4", "@types/lingui__react": "^2.8.3", + "@types/lodash.clonedeep": "^4.5.6", "@types/lodash.flatmap": "^4.5.6", "@types/luxon": "^1.24.4", + "@types/ms.macro": "^2.0.0", "@types/multicodec": "^1.0.0", "@types/node": "^13.13.5", "@types/qs": "^6.9.2", @@ -74,8 +76,10 @@ "graphql-request": "^3.4.0", "inter-ui": "^3.13.1", "lightweight-charts": "^3.3.0", + "lodash.clonedeep": "^4.5.0", "lodash.flatmap": "^4.5.0", "luxon": "^1.25.0", + "ms.macro": "^2.0.0", "multicodec": "^3.0.1", "multihashes": "^4.0.2", "node-vibrant": "^3.1.5", diff --git a/src/hooks/usePoolTickData.ts b/src/hooks/usePoolTickData.ts new file mode 100644 index 0000000000..1ec2cceb33 --- /dev/null +++ b/src/hooks/usePoolTickData.ts @@ -0,0 +1,114 @@ +import { Currency } from '@uniswap/sdk-core' +import { FeeAmount, Pool, tickToPrice, TICK_SPACINGS } from '@uniswap/v3-sdk' +import JSBI from 'jsbi' +import { PoolState, usePool } from './usePools' +import { useEffect, useMemo, useState } from 'react' +import computeSurroundingTicks from 'utils/computeSurroundingTicks' +import { useAllV3TicksQuery } from 'state/data/generated' +import { skipToken } from '@reduxjs/toolkit/query/react' +import ms from 'ms.macro' +import cloneDeep from 'lodash/cloneDeep' + +const PRICE_FIXED_DIGITS = 8 + +// Tick with fields parsed to JSBIs, and active liquidity computed. +export interface TickProcessed { + tickIdx: number + liquidityActive: JSBI + liquidityNet: JSBI + price0: string +} + +const getActiveTick = (tickCurrent: number | undefined, feeAmount: FeeAmount | undefined) => + tickCurrent && feeAmount ? Math.floor(tickCurrent / TICK_SPACINGS[feeAmount]) * TICK_SPACINGS[feeAmount] : undefined + +// Fetches all ticks for a given pool +export function useAllV3Ticks( + currencyA: Currency | undefined, + currencyB: Currency | undefined, + feeAmount: FeeAmount | undefined +) { + const poolAddress = + currencyA && currencyB && feeAmount ? Pool.getAddress(currencyA?.wrapped, currencyB?.wrapped, feeAmount) : undefined + + //TODO(judo): determine if pagination is necessary for this query + const { isLoading, isError, data } = useAllV3TicksQuery( + poolAddress ? { poolAddress: poolAddress?.toLowerCase(), skip: 0 } : skipToken, + { + pollingInterval: ms`2m`, + } + ) + + return { + isLoading, + isError, + ticks: data?.ticks, + } +} + +export function usePoolActiveLiquidity( + currencyA: Currency | undefined, + currencyB: Currency | undefined, + feeAmount: FeeAmount | undefined +): { + isLoading: boolean + isError: boolean + activeTick: number | undefined + data: TickProcessed[] +} { + const [ticksProcessed, setTicksProcessed] = useState([]) + + const pool = usePool(currencyA, currencyB, feeAmount) + + const { isLoading, isError, ticks } = useAllV3Ticks(currencyA, currencyB, feeAmount) + + // Find nearest valid tick for pool in case tick is not initialized. + const activeTick = useMemo(() => getActiveTick(pool[1]?.tickCurrent, feeAmount), [pool, feeAmount]) + + useEffect(() => { + if (!currencyA || !currencyB || !activeTick || pool[0] !== PoolState.EXISTS || !ticks || ticks.length === 0) { + setTicksProcessed([]) + return + } + + const token0 = currencyA?.wrapped + const token1 = currencyB?.wrapped + + const sortedTickData = cloneDeep(ticks) + sortedTickData.sort((a, b) => a.tickIdx - b.tickIdx) + + // find where the active tick would be to partition the array + // if the active tick is initialized, the pivot will be an element + // if not, take the previous tick as pivot + const pivot = sortedTickData.findIndex(({ tickIdx }) => tickIdx > activeTick) - 1 + + if (pivot < 0) { + // consider setting a local error + console.error('TickData pivot not found') + return + } + + const activeTickProcessed: TickProcessed = { + liquidityActive: JSBI.BigInt(pool[1]?.liquidity ?? 0), + tickIdx: activeTick, + liquidityNet: + sortedTickData[pivot].tickIdx === activeTick ? JSBI.BigInt(sortedTickData[pivot].liquidityNet) : JSBI.BigInt(0), + price0: tickToPrice(token0, token1, activeTick).toFixed(PRICE_FIXED_DIGITS), + } + + const subsequentTicks = computeSurroundingTicks(token0, token1, activeTickProcessed, sortedTickData, pivot, true) + + const previousTicks = computeSurroundingTicks(token0, token1, activeTickProcessed, sortedTickData, pivot, false) + + const newTicksProcessed = previousTicks.concat(activeTickProcessed).concat(subsequentTicks) + + setTicksProcessed(newTicksProcessed) + }, [currencyA, currencyB, activeTick, pool, ticks]) + + return { + isLoading: isLoading || pool[0] === PoolState.LOADING, + isError: isError || pool[0] === PoolState.INVALID, + activeTick, + data: ticksProcessed, + } +} diff --git a/src/state/data/slice.ts b/src/state/data/slice.ts index 3fcea69df3..cfac263388 100644 --- a/src/state/data/slice.ts +++ b/src/state/data/slice.ts @@ -49,6 +49,24 @@ export const api = createApi({ reducerPath: 'dataApi', baseQuery: graphqlRequestBaseQuery(), endpoints: (builder) => ({ + getAllV3Ticks: builder.query({ + query: ({ poolAddress, skip = 0 }) => ({ + document: gql` + query allV3Ticks($poolAddress: String!, $skip: Int!) { + ticks(first: 1000, skip: $skip, where: { poolAddress: $poolAddress }) { + tickIdx + liquidityNet + price0 + price1 + } + } + `, + variables: { + poolAddress, + skip, + }, + }), + }), getFeeTierDistribution: builder.query({ query: ({ token0, token1 }) => ({ document: gql` diff --git a/src/utils/computeSurroundingTicks.ts b/src/utils/computeSurroundingTicks.ts new file mode 100644 index 0000000000..768ab63fdb --- /dev/null +++ b/src/utils/computeSurroundingTicks.ts @@ -0,0 +1,59 @@ +import { Token } from '@uniswap/sdk-core' +import { tickToPrice } from '@uniswap/v3-sdk' +import { TickProcessed } from 'hooks/usePoolTickData' +import JSBI from 'jsbi' +import { AllV3TicksQuery } from 'state/data/generated' + +const PRICE_FIXED_DIGITS = 8 + +// Computes the numSurroundingTicks above or below the active tick. +export default function computeSurroundingTicks( + token0: Token, + token1: Token, + activeTickProcessed: TickProcessed, + sortedTickData: AllV3TicksQuery['ticks'], + pivot: number, + ascending: boolean +): TickProcessed[] { + let previousTickProcessed: TickProcessed = { + ...activeTickProcessed, + } + // Iterate outwards (either up or down depending on direction) from the active tick, + // building active liquidity for every tick. + let processedTicks: TickProcessed[] = [] + for (let i = pivot + (ascending ? 1 : -1); ascending ? i < sortedTickData.length : i >= 0; ascending ? i++ : i--) { + const tickIdx = Number(sortedTickData[i].tickIdx) + const currentTickProcessed: TickProcessed = { + liquidityActive: previousTickProcessed.liquidityActive, + tickIdx, + liquidityNet: JSBI.BigInt(sortedTickData[i].liquidityNet), + price0: tickToPrice(token0, token1, tickIdx).toFixed(PRICE_FIXED_DIGITS), + } + + // Update the active liquidity. + // If we are iterating ascending and we found an initialized tick we immediately apply + // it to the current processed tick we are building. + // If we are iterating descending, we don't want to apply the net liquidity until the following tick. + if (ascending) { + currentTickProcessed.liquidityActive = JSBI.add( + previousTickProcessed.liquidityActive, + JSBI.BigInt(sortedTickData[i].liquidityNet) + ) + } else if (!ascending && JSBI.notEqual(previousTickProcessed.liquidityNet, JSBI.BigInt(0))) { + // We are iterating descending, so look at the previous tick and apply any net liquidity. + currentTickProcessed.liquidityActive = JSBI.subtract( + previousTickProcessed.liquidityActive, + previousTickProcessed.liquidityNet + ) + } + + processedTicks.push(currentTickProcessed) + previousTickProcessed = currentTickProcessed + } + + if (!ascending) { + processedTicks = processedTicks.reverse() + } + + return processedTicks +} diff --git a/yarn.lock b/yarn.lock index 87e533ba8b..86f9d2d364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3710,6 +3710,13 @@ "@types/lingui__core" "*" "@types/react" "*" +"@types/lodash.clonedeep@^4.5.6": + version "4.5.6" + resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz#3b6c40a0affe0799a2ce823b440a6cf33571d32b" + integrity sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA== + dependencies: + "@types/lodash" "*" + "@types/lodash.flatmap@^4.5.6": version "4.5.6" resolved "https://registry.npmjs.org/@types/lodash.flatmap/-/lodash.flatmap-4.5.6.tgz" @@ -3732,6 +3739,11 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz" integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA== +"@types/ms.macro@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/ms.macro/-/ms.macro-2.0.0.tgz#a27e14d8e409fc973715701465e18be5edcfd4c0" + integrity sha512-xT4rTPFfZv2KZa0PLgB6e4RC5SuIr+8NDq2iZ4YxNtbByZRR98qbnS4WyNCQZIC5Jwn55AFok2CEqEb95w/CQQ== + "@types/multicodec@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@types/multicodec/-/multicodec-1.0.0.tgz" @@ -12458,7 +12470,7 @@ lodash.camelcase@^4.3.0: lodash.clonedeep@^4.5.0: version "4.5.0" - resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= lodash.debounce@^4.0.8: @@ -13069,6 +13081,14 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +ms.macro@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms.macro/-/ms.macro-2.0.0.tgz#ff72d230cde33797d30b9b61eb57d4940dddd66f" + integrity sha512-vkb83Sa4BZ2ynF/C1x5D8ofExja36mYW6OB7JNh6Ek0NSw3Oj4moM0nN69rfbm28aHlON52E+dTEgW+3up3x1g== + dependencies: + babel-plugin-macros "^2.0.0" + ms "^2.0.0" + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -13084,7 +13104,7 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@^2.0.0, ms@^2.1.1: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==