feat: add fee tier distribution badge (#1862)

* integrate with The Graph and auto-select fee tier

* restored

* addressed some design feedback

* add pulsing animation on feeAmount change

* simplify fee tier title

* adjust button radios

* addressed some design feedback

* log ReactGA events

* ignore data while fetching

* update to use new generated queries

* remove deleted file

* add usefeetierdistribution hook

* invalidate cache and standardize the outlined card

* added react ga

* fix show options logic

* address design feedback

* show % select in minified view

* updated merge error
This commit is contained in:
Justin Domingue 2021-07-01 10:56:27 -07:00 committed by GitHub
parent 5298a5ce29
commit d9c82ebf49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 364 additions and 149 deletions

@ -113,9 +113,7 @@ export const ButtonGray = styled(Base)`
color: ${({ theme }) => theme.text2};
font-size: 16px;
font-weight: 500;
&:focus {
background-color: ${({ theme, disabled }) => !disabled && darken(0.05, theme.bg2)};
}
&:hover {
background-color: ${({ theme, disabled }) => !disabled && darken(0.05, theme.bg2)};
}

@ -1,80 +1,233 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { FeeAmount } from '@uniswap/v3-sdk'
import { Token } from '@uniswap/sdk-core'
import { Trans } from '@lingui/macro'
import { AutoColumn } from 'components/Column'
import { DynamicSection } from 'pages/AddLiquidity/styled'
import { TYPE } from 'theme'
import { RowBetween } from 'components/Row'
import { ButtonRadioChecked } from 'components/Button'
import styled from 'styled-components/macro'
import { ButtonGray, ButtonRadioChecked } from 'components/Button'
import styled, { keyframes } from 'styled-components/macro'
import Badge from 'components/Badge'
import Card from 'components/Card'
import Loader from 'components/Loader'
import usePrevious from 'hooks/usePrevious'
import { useFeeTierDistribution } from 'hooks/useFeeTierDistribution'
import ReactGA from 'react-ga'
import { Box } from 'rebass'
const pulse = (color: string) => keyframes`
0% {
box-shadow: 0 0 0 0 ${color};
}
70% {
box-shadow: 0 0 0 2px ${color};
}
100% {
box-shadow: 0 0 0 0 ${color};
}
`
const ResponsiveText = styled(TYPE.label)`
line-height: 16px;
${({ theme }) => theme.mediaWidth.upToSmall`
font-size: 12px;
line-height: 12px;
`};
`
const FocusedOutlineCard = styled(Card)<{ pulsing: boolean }>`
border: 1px solid ${({ theme }) => theme.bg2};
animation: ${({ pulsing, theme }) => pulsing && pulse(theme.primary1)} 0.6s linear;
`
const FeeAmountLabel = {
[FeeAmount.LOW]: {
label: '0.05',
description: <Trans>Best for stable pairs.</Trans>,
},
[FeeAmount.MEDIUM]: {
label: '0.3',
description: <Trans>Best for most pairs.</Trans>,
},
[FeeAmount.HIGH]: {
label: '1',
description: <Trans>Best for exotic pairs.</Trans>,
},
}
const FeeTierPercentageBadge = ({ percentage }: { percentage: number | undefined }) => {
return (
<Badge>
<TYPE.label fontSize={12}>
{Boolean(percentage) ? <Trans>{percentage?.toFixed(0)}% select</Trans> : <Trans>Not created</Trans>}
</TYPE.label>
</Badge>
)
}
export default function FeeSelector({
disabled = false,
feeAmount,
handleFeePoolSelect,
token0,
token1,
}: {
disabled?: boolean
feeAmount?: FeeAmount
handleFeePoolSelect: (feeAmount: FeeAmount) => void
token0?: Token | undefined
token1?: Token | undefined
}) {
const { isLoading, isError, largestUsageFeeTier, distributions } = useFeeTierDistribution(token0, token1)
const [showOptions, setShowOptions] = useState(false)
const [pulsing, setPulsing] = useState(false)
const previousFeeAmount = usePrevious(feeAmount)
const recommended = useRef(false)
const handleFeePoolSelectWithEvent = useCallback(
(fee) => {
ReactGA.event({
category: 'FeePoolSelect',
action: 'Manual',
})
handleFeePoolSelect(fee)
},
[handleFeePoolSelect]
)
useEffect(() => {
if (feeAmount || isLoading || isError) {
return
}
if (!largestUsageFeeTier) {
// cannot recommend, open options
setShowOptions(true)
} else {
setShowOptions(false)
recommended.current = true
ReactGA.event({
category: 'FeePoolSelect',
action: ' Recommended',
})
handleFeePoolSelect(largestUsageFeeTier)
}
}, [feeAmount, isLoading, isError, largestUsageFeeTier, handleFeePoolSelect])
useEffect(() => {
setShowOptions(isError)
}, [isError])
useEffect(() => {
if (feeAmount && previousFeeAmount !== feeAmount) {
setPulsing(true)
}
}, [previousFeeAmount, feeAmount])
return (
<AutoColumn gap="16px">
<DynamicSection gap="md" disabled={disabled}>
<TYPE.label>
<Trans>Select Pool</Trans>
</TYPE.label>
<TYPE.main fontSize={14} fontWeight={400} style={{ marginBottom: '.5rem', lineHeight: '125%' }}>
<Trans>Select a pool type based on your preferred liquidity provider fee.</Trans>
</TYPE.main>
<RowBetween>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.LOW}
onClick={() => handleFeePoolSelect(FeeAmount.LOW)}
>
<AutoColumn gap="sm" justify="flex-start">
<ResponsiveText>
<Trans>0.05% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for stable pairs.</Trans>
</TYPE.main>
<FocusedOutlineCard pulsing={pulsing} onAnimationEnd={() => setPulsing(false)}>
<RowBetween>
<AutoColumn>
{!feeAmount || isLoading ? (
<>
<TYPE.label>
<Trans>Fee tier</Trans>
</TYPE.label>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>The % you will earn in fees.</Trans>
</TYPE.main>
</>
) : (
<>
<TYPE.label>
<Trans>{FeeAmountLabel[feeAmount].label}% fee tier</Trans>
</TYPE.label>
<Box style={{ width: 'fit-content', marginTop: '8px' }}>
{distributions && feeAmount && <FeeTierPercentageBadge percentage={distributions[feeAmount]} />}
</Box>
</>
)}
</AutoColumn>
</ButtonRadioChecked>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.MEDIUM}
onClick={() => handleFeePoolSelect(FeeAmount.MEDIUM)}
>
<AutoColumn gap="sm" justify="flex-start">
<ResponsiveText>
<Trans>0.3% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for most pairs.</Trans>
</TYPE.main>
</AutoColumn>
</ButtonRadioChecked>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.HIGH}
onClick={() => handleFeePoolSelect(FeeAmount.HIGH)}
>
<AutoColumn gap="sm" justify="flex-start">
<ResponsiveText>
<Trans>1% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for exotic pairs.</Trans>
</TYPE.main>
</AutoColumn>
</ButtonRadioChecked>
</RowBetween>
{isLoading ? (
<Loader size="20px" />
) : (
<ButtonGray onClick={() => setShowOptions(!showOptions)} width="auto" padding="4px" borderRadius="6px">
{showOptions ? <Trans>Hide</Trans> : <Trans>Explore</Trans>}
</ButtonGray>
)}
</RowBetween>
</FocusedOutlineCard>
{showOptions && (
<RowBetween>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.LOW}
onClick={() => handleFeePoolSelectWithEvent(FeeAmount.LOW)}
>
<AutoColumn gap="sm" justify="flex-start">
<AutoColumn justify="flex-start" gap="4px">
<ResponsiveText>
<Trans>0.05% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for stable pairs.</Trans>
</TYPE.main>
</AutoColumn>
{distributions && <FeeTierPercentageBadge percentage={distributions[FeeAmount.LOW]} />}
</AutoColumn>
</ButtonRadioChecked>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.MEDIUM}
onClick={() => handleFeePoolSelectWithEvent(FeeAmount.MEDIUM)}
>
<AutoColumn gap="sm" justify="flex-start">
<AutoColumn justify="flex-start" gap="4px">
<ResponsiveText>
<Trans>0.3% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for most pairs.</Trans>
</TYPE.main>
</AutoColumn>
{distributions && <FeeTierPercentageBadge percentage={distributions[FeeAmount.MEDIUM]} />}
</AutoColumn>
</ButtonRadioChecked>
<ButtonRadioChecked
width="32%"
active={feeAmount === FeeAmount.HIGH}
onClick={() => handleFeePoolSelectWithEvent(FeeAmount.HIGH)}
>
<AutoColumn gap="sm" justify="flex-start">
<AutoColumn justify="flex-start" gap="4px">
<ResponsiveText>
<Trans>1% fee</Trans>
</ResponsiveText>
<TYPE.main fontWeight={400} fontSize="12px" textAlign="left">
<Trans>Best for exotic pairs.</Trans>
</TYPE.main>
</AutoColumn>
{distributions && <FeeTierPercentageBadge percentage={distributions[FeeAmount.HIGH]} />}
</AutoColumn>
</ButtonRadioChecked>
</RowBetween>
)}
</DynamicSection>
</AutoColumn>
)

@ -0,0 +1,150 @@
import { FeeAmount } from '@uniswap/v3-sdk'
import { Token } from '@uniswap/sdk-core'
import { usePoolsQuery } from 'state/data/generated'
import { skipToken } from '@reduxjs/toolkit/query/react'
import { reduce } from 'lodash'
import { useBlockNumber } from 'state/application/hooks'
import ReactGA from 'react-ga'
import { useMemo } from 'react'
// maximum number of blocks past which we consider the data stale
const MAX_DATA_BLOCK_AGE = 10
export interface FeeTierDistribution {
isLoading: boolean
isError: boolean
largestUsageFeeTier?: FeeAmount | undefined
// distributions as percentages of overall liquidity
distributions?: {
[FeeAmount.LOW]: number | undefined
[FeeAmount.MEDIUM]: number | undefined
[FeeAmount.HIGH]: number | undefined
}
}
export function useFeeTierDistribution(token0: Token | undefined, token1: Token | undefined): FeeTierDistribution {
const { isFetching, isLoading, isUninitialized, isError, distributions } = usePoolTVL(token0, token1)
return useMemo(() => {
if (isLoading || isFetching || isUninitialized || isError || !distributions) {
return {
isLoading: isLoading || isFetching || !isUninitialized,
isError,
}
}
const largestUsageFeeTier = Object.keys(distributions)
.map((d) => Number(d))
.filter((d: FeeAmount) => distributions[d] !== 0 && distributions[d] !== undefined)
.reduce((a: FeeAmount, b: FeeAmount) => ((distributions[a] ?? 0) > (distributions[b] ?? 0) ? a : b), -1)
const percentages =
!isLoading && !isError && distributions
? {
[FeeAmount.LOW]: distributions[FeeAmount.LOW] ? (distributions[FeeAmount.LOW] ?? 0) * 100 : undefined,
[FeeAmount.MEDIUM]: distributions[FeeAmount.MEDIUM]
? (distributions[FeeAmount.MEDIUM] ?? 0) * 100
: undefined,
[FeeAmount.HIGH]: distributions[FeeAmount.HIGH] ? (distributions[FeeAmount.HIGH] ?? 0) * 100 : undefined,
}
: undefined
return {
isLoading,
isError,
distributions: percentages,
largestUsageFeeTier: largestUsageFeeTier === -1 ? undefined : largestUsageFeeTier,
}
}, [isLoading, isFetching, isUninitialized, isError, distributions])
}
function usePoolTVL(token0: Token | undefined, token1: Token | undefined) {
const latestBlock = useBlockNumber()
const { isLoading, isFetching, isUninitialized, isError, data } = usePoolsQuery(
token0 && token1 ? { token0: token0.address.toLowerCase(), token1: token1.address.toLowerCase() } : skipToken,
{
pollingInterval: 60000, // 1 minute
}
)
const { asToken0, asToken1, _meta } = data ?? {}
return useMemo(() => {
if (!latestBlock || !_meta || !asToken0 || !asToken1) {
return {
isLoading,
isFetching,
isUninitialized,
isError,
}
}
if (latestBlock - (_meta?.block?.number ?? 0) > MAX_DATA_BLOCK_AGE) {
ReactGA.exception({
description: `Graph stale (latest block: ${latestBlock})`,
})
return {
isLoading,
isFetching,
isUninitialized,
isError,
}
}
const all = asToken0.concat(asToken1)
// sum tvl for token0 and token1 by fee tier
const tvlByFeeTer = all.reduce<{ [feeAmount: number]: [number | undefined, number | undefined] }>(
(acc, value) => {
acc[value.feeTier][0] = (acc[value.feeTier][0] ?? 0) + Number(value.totalValueLockedToken0)
acc[value.feeTier][1] = (acc[value.feeTier][1] ?? 0) + Number(value.totalValueLockedToken1)
return acc
},
{
[FeeAmount.LOW]: [undefined, undefined],
[FeeAmount.MEDIUM]: [undefined, undefined],
[FeeAmount.HIGH]: [undefined, undefined],
}
)
// sum total tvl for token0 and token1
const [sumToken0Tvl, sumToken1Tvl] = reduce(
tvlByFeeTer,
(acc: [number, number], value) => {
acc[0] += value[0] ?? 0
acc[1] += value[1] ?? 0
return acc
},
[0, 0]
)
// returns undefined if both tvl0 and tvl1 are undefined (pool not created)
const mean = (tvl0: number | undefined, sumTvl0: number, tvl1: number | undefined, sumTvl1: number) =>
tvl0 === undefined && tvl1 === undefined ? undefined : ((tvl0 ?? 0) + (tvl1 ?? 0)) / (sumTvl0 + sumTvl1) || 0
return {
isLoading,
isFetching,
isUninitialized,
isError,
distributions: {
[FeeAmount.LOW]: mean(tvlByFeeTer[FeeAmount.LOW][0], sumToken0Tvl, tvlByFeeTer[FeeAmount.LOW][1], sumToken1Tvl),
[FeeAmount.MEDIUM]: mean(
tvlByFeeTer[FeeAmount.MEDIUM][0],
sumToken0Tvl,
tvlByFeeTer[FeeAmount.MEDIUM][1],
sumToken1Tvl
),
[FeeAmount.HIGH]: mean(
tvlByFeeTer[FeeAmount.HIGH][0],
sumToken0Tvl,
tvlByFeeTer[FeeAmount.HIGH][1],
sumToken1Tvl
),
},
}
}, [_meta, asToken0, asToken1, isLoading, isError, isFetching, isUninitialized, latestBlock])
}

@ -470,6 +470,14 @@ export default function AddLiquidity({
showCommonBases
/>
</RowBetween>
<FeeSelector
disabled={!currencyB || !currencyA}
feeAmount={feeAmount}
handleFeePoolSelect={handleFeePoolSelect}
token0={currencyA?.wrapped}
token1={currencyB?.wrapped}
/>
</AutoColumn>{' '}
</>
)}
@ -482,12 +490,6 @@ export default function AddLiquidity({
/>
) : (
<>
<FeeSelector
disabled={!currencyB || !currencyA}
feeAmount={feeAmount}
handleFeePoolSelect={handleFeePoolSelect}
/>
{noLiquidity && (
<DynamicSection disabled={!currencyA || !currencyB}>
<AutoColumn gap="md">

@ -1,9 +1,5 @@
import { createApi } from '@reduxjs/toolkit/query/react'
import { ClientError, gql, GraphQLClient } from 'graphql-request'
import { FeeAmount } from '@uniswap/v3-sdk'
import { reduce } from 'lodash'
import { FeeTierDistribution, PoolTVL } from './types'
import { SupportedChainId } from 'constants/chains'
import { AppState } from 'state'
import { BaseQueryApi, BaseQueryFn } from '@reduxjs/toolkit/dist/query/baseQueryTypes'
@ -53,7 +49,7 @@ export const api = createApi({
reducerPath: 'dataApi',
baseQuery: graphqlRequestBaseQuery(),
endpoints: (builder) => ({
getFeeTierDistribution: builder.query<FeeTierDistribution, { token0: string; token1: string }>({
getFeeTierDistribution: builder.query({
query: ({ token0, token1 }) => ({
document: gql`
query pools($token0: String!, $token1: String!) {
@ -87,62 +83,6 @@ export const api = createApi({
token1,
},
}),
transformResponse: (poolTvl: PoolTVL) => {
const all = poolTvl.asToken0.concat(poolTvl.asToken1)
// sum tvl for token0 and token1 by fee tier
const tvlByFeeTer = all.reduce<{ [feeAmount: number]: [number | undefined, number | undefined] }>(
(acc, value) => {
acc[value.feeTier][0] = (acc[value.feeTier][0] ?? 0) + Number(value.totalValueLockedToken0)
acc[value.feeTier][1] = (acc[value.feeTier][1] ?? 0) + Number(value.totalValueLockedToken1)
return acc
},
{
[FeeAmount.LOW]: [undefined, undefined],
[FeeAmount.MEDIUM]: [undefined, undefined],
[FeeAmount.HIGH]: [undefined, undefined],
}
)
// sum total tvl for token0 and token1
const [sumToken0Tvl, sumToken1Tvl] = reduce(
tvlByFeeTer,
(acc: [number, number], value) => {
acc[0] += value[0] ?? 0
acc[1] += value[1] ?? 0
return acc
},
[0, 0]
)
// returns undefined if both tvl0 and tvl1 are undefined (pool not created)
const mean = (tvl0: number | undefined, sumTvl0: number, tvl1: number | undefined, sumTvl1: number) =>
tvl0 === undefined && tvl1 === undefined ? undefined : ((tvl0 ?? 0) + (tvl1 ?? 0)) / (sumTvl0 + sumTvl1) || 0
return {
block: poolTvl._meta.block.number,
distributions: {
[FeeAmount.LOW]: mean(
tvlByFeeTer[FeeAmount.LOW][0],
sumToken0Tvl,
tvlByFeeTer[FeeAmount.LOW][1],
sumToken1Tvl
),
[FeeAmount.MEDIUM]: mean(
tvlByFeeTer[FeeAmount.MEDIUM][0],
sumToken0Tvl,
tvlByFeeTer[FeeAmount.MEDIUM][1],
sumToken1Tvl
),
[FeeAmount.HIGH]: mean(
tvlByFeeTer[FeeAmount.HIGH][0],
sumToken0Tvl,
tvlByFeeTer[FeeAmount.HIGH][1],
sumToken1Tvl
),
},
}
},
}),
}),
})

@ -1,28 +0,0 @@
import { FeeAmount } from '@uniswap/v3-sdk'
export interface PoolTVL {
_meta: {
block: {
number: number
}
}
asToken0: {
feeTier: FeeAmount
totalValueLockedToken0: number
totalValueLockedToken1: number
}[]
asToken1: {
feeTier: FeeAmount
totalValueLockedToken0: number
totalValueLockedToken1: number
}[]
}
export interface FeeTierDistribution {
block: number
distributions: {
[FeeAmount.LOW]: number | undefined
[FeeAmount.MEDIUM]: number | undefined
[FeeAmount.HIGH]: number | undefined
}
}