fix: add support for full range positions in add liquidity (#2090)

* remove arbitrary range buttons and move full range button

* better align full range to deposit

* support dragging range when in full range

* hack to support full range brushing

* restore zoom levels

* adjusted for mocks

* fix styling

* simplify type

* restore rate toggle change

* fix lower bound by looking at isSorted

* better align bottoms

* add reset button for full range positions

* only flip when not at limit

* fix +/- buttons in range selector

* add help link
This commit is contained in:
Justin Domingue 2021-07-29 13:47:03 -07:00 committed by GitHub
parent 9b7637e012
commit 952cc98df3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 91 additions and 130 deletions

@ -43,7 +43,8 @@ const FLIP_HANDLE_THRESHOLD_PX = 20
// margin to prevent tick snapping from putting the brush off screen // margin to prevent tick snapping from putting the brush off screen
const BRUSH_EXTENT_MARGIN_PX = 2 const BRUSH_EXTENT_MARGIN_PX = 2
const compare = (a1: [number, number], a2: [number, number]): boolean => a1[0] !== a2[0] || a1[1] !== a2[1] const compare = (a1: [number, number], a2: [number, number], xScale: ScaleLinear<number, number>): boolean =>
xScale(a1[0]) !== xScale(a2[0]) || xScale(a1[1]) !== xScale(a2[1])
export const Brush = ({ export const Brush = ({
id, id,
@ -62,7 +63,7 @@ export const Brush = ({
interactive: boolean interactive: boolean
brushLabelValue: (d: 'w' | 'e', x: number) => string brushLabelValue: (d: 'w' | 'e', x: number) => string
brushExtent: [number, number] brushExtent: [number, number]
setBrushExtent: (extent: [number, number]) => void setBrushExtent: (extent: [number, number], mode: string | undefined) => void
innerWidth: number innerWidth: number
innerHeight: number innerHeight: number
westHandleColor: string westHandleColor: string
@ -79,7 +80,9 @@ export const Brush = ({
const previousBrushExtent = usePrevious(brushExtent) const previousBrushExtent = usePrevious(brushExtent)
const brushed = useCallback( const brushed = useCallback(
({ type, selection }: D3BrushEvent<unknown>) => { (event: D3BrushEvent<unknown>) => {
const { type, selection, mode } = event
if (!selection) { if (!selection) {
setLocalBrushExtent(null) setLocalBrushExtent(null)
return return
@ -88,8 +91,8 @@ export const Brush = ({
const scaled = (selection as [number, number]).map(xScale.invert) as [number, number] const scaled = (selection as [number, number]).map(xScale.invert) as [number, number]
// avoid infinite render loop by checking for change // avoid infinite render loop by checking for change
if (type === 'end' && compare(brushExtent, scaled)) { if (type === 'end' && compare(brushExtent, scaled, xScale)) {
setBrushExtent(scaled) setBrushExtent(scaled, mode)
} }
setLocalBrushExtent(scaled) setLocalBrushExtent(scaled)
@ -118,7 +121,7 @@ export const Brush = ({
brushBehavior.current(select(brushRef.current)) brushBehavior.current(select(brushRef.current))
if (previousBrushExtent && compare(brushExtent, previousBrushExtent)) { if (previousBrushExtent && compare(brushExtent, previousBrushExtent, xScale)) {
select(brushRef.current) select(brushRef.current)
.transition() .transition()
.call(brushBehavior.current.move as any, brushExtent.map(xScale)) .call(brushBehavior.current.move as any, brushExtent.map(xScale))

@ -1,5 +1,6 @@
import { max, scaleLinear, ZoomTransform } from 'd3' import { max, scaleLinear, ZoomTransform } from 'd3'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Bound } from 'state/mint/v3/actions'
import { Area } from './Area' import { Area } from './Area'
import { AxisBottom } from './AxisBottom' import { AxisBottom } from './AxisBottom'
import { Brush } from './Brush' import { Brush } from './Brush'
@ -13,6 +14,7 @@ export const yAccessor = (d: ChartEntry) => d.activeLiquidity
export function Chart({ export function Chart({
id = 'liquidityChartRangeInput', id = 'liquidityChartRangeInput',
data: { series, current }, data: { series, current },
ticksAtLimit,
styles, styles,
dimensions: { width, height }, dimensions: { width, height },
margins, margins,
@ -56,7 +58,7 @@ export function Chart({
useEffect(() => { useEffect(() => {
if (!brushDomain) { if (!brushDomain) {
onBrushDomainChange(xScale.domain() as [number, number]) onBrushDomainChange(xScale.domain() as [number, number], undefined)
} }
}, [brushDomain, onBrushDomainChange, xScale]) }, [brushDomain, onBrushDomainChange, xScale])
@ -71,7 +73,13 @@ export function Chart({
// allow zooming inside the x-axis // allow zooming inside the x-axis
height height
} }
showClear={false} resetBrush={() => {
onBrushDomainChange(
[current * zoomLevels.initialMin, current * zoomLevels.initialMax] as [number, number],
'reset'
)
}}
showResetButton={Boolean(ticksAtLimit[Bound.LOWER] || ticksAtLimit[Bound.UPPER])}
zoomLevels={zoomLevels} zoomLevels={zoomLevels}
/> />
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}> <svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import { ButtonGray } from 'components/Button' import { ButtonGray } from 'components/Button'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { ScaleLinear, select, ZoomBehavior, zoom, ZoomTransform } from 'd3' import { ScaleLinear, select, ZoomBehavior, zoom, ZoomTransform, zoomIdentity } from 'd3'
import { RefreshCcw, ZoomIn, ZoomOut } from 'react-feather' import { RefreshCcw, ZoomIn, ZoomOut } from 'react-feather'
import { ZoomLevels } from './types' import { ZoomLevels } from './types'
@ -41,7 +41,8 @@ export default function Zoom({
setZoom, setZoom,
width, width,
height, height,
showClear, resetBrush,
showResetButton,
zoomLevels, zoomLevels,
}: { }: {
svg: SVGElement | null svg: SVGElement | null
@ -49,12 +50,13 @@ export default function Zoom({
setZoom: (transform: ZoomTransform) => void setZoom: (transform: ZoomTransform) => void
width: number width: number
height: number height: number
showClear: boolean resetBrush: () => void
showResetButton: boolean
zoomLevels: ZoomLevels zoomLevels: ZoomLevels
}) { }) {
const zoomBehavior = useRef<ZoomBehavior<Element, unknown>>() const zoomBehavior = useRef<ZoomBehavior<Element, unknown>>()
const [zoomIn, zoomOut, reset, initial] = useMemo( const [zoomIn, zoomOut, zoomInitial, zoomReset] = useMemo(
() => [ () => [
() => () =>
svg && svg &&
@ -73,15 +75,16 @@ export default function Zoom({
zoomBehavior.current && zoomBehavior.current &&
select(svg as Element) select(svg as Element)
.transition() .transition()
.call(zoomBehavior.current.scaleTo, 1), .call(zoomBehavior.current.scaleTo, 0.5),
() => () =>
svg && svg &&
zoomBehavior.current && zoomBehavior.current &&
select(svg as Element) select(svg as Element)
.call(zoomBehavior.current.transform, zoomIdentity.translate(0, 0).scale(1))
.transition() .transition()
.call(zoomBehavior.current.scaleTo, 0.5), .call(zoomBehavior.current.scaleTo, 0.5),
], ],
[svg, zoomBehavior] [svg]
) )
useEffect(() => { useEffect(() => {
@ -100,13 +103,19 @@ export default function Zoom({
useEffect(() => { useEffect(() => {
// reset zoom to initial on zoomLevel change // reset zoom to initial on zoomLevel change
initial() zoomInitial()
}, [initial, zoomLevels]) }, [zoomInitial, zoomLevels])
return ( return (
<Wrapper count={showClear ? 3 : 2}> <Wrapper count={showResetButton ? 3 : 2}>
{showClear && ( {showResetButton && (
<Button onClick={reset} disabled={false}> <Button
onClick={() => {
resetBrush()
zoomReset()
}}
disabled={false}
>
<RefreshCcw size={16} /> <RefreshCcw size={16} />
</Button> </Button>
)} )}

@ -87,6 +87,8 @@ export default function LiquidityChartRangeInput({
const tokenAColor = useColor(currencyA?.wrapped) const tokenAColor = useColor(currencyA?.wrapped)
const tokenBColor = useColor(currencyB?.wrapped) const tokenBColor = useColor(currencyB?.wrapped)
const isSorted = currencyA && currencyB && currencyA?.wrapped.sortsBefore(currencyB?.wrapped)
const { isLoading, isUninitialized, isError, error, formattedData } = useDensityChartData({ const { isLoading, isUninitialized, isError, error, formattedData } = useDensityChartData({
currencyA, currencyA,
currencyB, currencyB,
@ -94,7 +96,7 @@ export default function LiquidityChartRangeInput({
}) })
const onBrushDomainChangeEnded = useCallback( const onBrushDomainChangeEnded = useCallback(
(domain) => { (domain, mode) => {
let leftRangeValue = Number(domain[0]) let leftRangeValue = Number(domain[0])
const rightRangeValue = Number(domain[1]) const rightRangeValue = Number(domain[1])
@ -104,40 +106,48 @@ export default function LiquidityChartRangeInput({
batch(() => { batch(() => {
// simulate user input for auto-formatting and other validations // simulate user input for auto-formatting and other validations
leftRangeValue > 0 && onLeftRangeInput(leftRangeValue.toFixed(6)) if (
rightRangeValue > 0 && onRightRangeInput(rightRangeValue.toFixed(6)) (!ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER] || mode === 'handle' || mode === 'reset') &&
leftRangeValue > 0
) {
onLeftRangeInput(leftRangeValue.toFixed(6))
}
if ((!ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER] || mode === 'reset') && rightRangeValue > 0) {
// todo: remove this check. Upper bound for large numbers
// sometimes fails to parse to tick.
if (rightRangeValue < 1e35) {
onRightRangeInput(rightRangeValue.toFixed(6))
}
}
}) })
}, },
[onLeftRangeInput, onRightRangeInput] [isSorted, onLeftRangeInput, onRightRangeInput, ticksAtLimit]
) )
interactive = interactive && Boolean(formattedData?.length) interactive = interactive && Boolean(formattedData?.length)
const brushDomain: [number, number] | undefined = useMemo(() => { const brushDomain: [number, number] | undefined = useMemo(() => {
const isSorted = currencyA && currencyB && currencyA?.wrapped.sortsBefore(currencyB?.wrapped)
const leftPrice = isSorted ? priceLower : priceUpper?.invert() const leftPrice = isSorted ? priceLower : priceUpper?.invert()
const rightPrice = isSorted ? priceUpper : priceLower?.invert() const rightPrice = isSorted ? priceUpper : priceLower?.invert()
return leftPrice && rightPrice return leftPrice && rightPrice
? [parseFloat(leftPrice?.toSignificant(5)), parseFloat(rightPrice?.toSignificant(5))] ? [parseFloat(leftPrice?.toSignificant(6)), parseFloat(rightPrice?.toSignificant(6))]
: undefined : undefined
}, [currencyA, currencyB, priceLower, priceUpper]) }, [isSorted, priceLower, priceUpper])
const brushLabelValue = useCallback( const brushLabelValue = useCallback(
(d: 'w' | 'e', x: number) => { (d: 'w' | 'e', x: number) => {
if (!price) return '' if (!price) return ''
if (d === 'w' && ticksAtLimit[Bound.LOWER]) return '0' if (d === 'w' && ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]) return '0'
if (d === 'e' && ticksAtLimit[Bound.UPPER]) return '∞' if (d === 'e' && ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]) return '∞'
//const percent = (((x < price ? -1 : 1) * (Math.max(x, price) - Math.min(x, price))) / Math.min(x, price)) * 100
const percent = (x < price ? -1 : 1) * ((Math.max(x, price) - Math.min(x, price)) / price) * 100 const percent = (x < price ? -1 : 1) * ((Math.max(x, price) - Math.min(x, price)) / price) * 100
return price ? `${format(Math.abs(percent) > 1 ? '.2~s' : '.2~f')(percent)}%` : '' return price ? `${format(Math.abs(percent) > 1 ? '.2~s' : '.2~f')(percent)}%` : ''
}, },
[price, ticksAtLimit] [isSorted, price, ticksAtLimit]
) )
if (isError) { if (isError) {
@ -189,6 +199,7 @@ export default function LiquidityChartRangeInput({
brushDomain={brushDomain} brushDomain={brushDomain}
onBrushDomainChange={onBrushDomainChangeEnded} onBrushDomainChange={onBrushDomainChangeEnded}
zoomLevels={ZOOM_LEVELS[feeAmount ?? FeeAmount.MEDIUM]} zoomLevels={ZOOM_LEVELS[feeAmount ?? FeeAmount.MEDIUM]}
ticksAtLimit={ticksAtLimit}
/> />
</ChartWrapper> </ChartWrapper>
)} )}

@ -1,3 +1,5 @@
import { Bound } from 'state/mint/v3/actions'
export interface ChartEntry { export interface ChartEntry {
activeLiquidity: number activeLiquidity: number
price0: number price0: number
@ -30,6 +32,7 @@ export interface LiquidityChartRangeInputProps {
series: ChartEntry[] series: ChartEntry[]
current: number current: number
} }
ticksAtLimit: { [bound in Bound]?: boolean | undefined }
styles: { styles: {
area: { area: {
@ -52,7 +55,7 @@ export interface LiquidityChartRangeInputProps {
brushLabels: (d: 'w' | 'e', x: number) => string brushLabels: (d: 'w' | 'e', x: number) => string
brushDomain: [number, number] | undefined brushDomain: [number, number] | undefined
onBrushDomainChange: (domain: [number, number]) => void onBrushDomainChange: (domain: [number, number], mode: string | undefined) => void
zoomLevels: ZoomLevels zoomLevels: ZoomLevels
} }

@ -4,76 +4,18 @@ import { AutoRow } from 'components/Row'
import { TYPE } from 'theme' import { TYPE } from 'theme'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { FeeAmount } from '@uniswap/v3-sdk'
import ReactGA from 'react-ga'
const Button = styled(ButtonOutlined).attrs(() => ({ const Button = styled(ButtonOutlined).attrs(() => ({
padding: '4px', padding: '8px',
borderRadius: '8px', $borderRadius: '8px',
}))` }))`
color: ${({ theme }) => theme.text1}; color: ${({ theme }) => theme.text1};
flex: 1; flex: 1;
background-color: ${({ theme }) => theme.bg2};
` `
const RANGES = { export default function PresetsButtons({ setFullRange }: { setFullRange: () => void }) {
[FeeAmount.LOW]: [
{ label: '0.05', ticks: 5 },
{ label: '0.1', ticks: 10 },
{ label: '0.2', ticks: 20 },
],
[FeeAmount.MEDIUM]: [
{ label: '1', ticks: 100 },
{ label: '10', ticks: 953 },
{ label: '50', ticks: 4055 },
],
[FeeAmount.HIGH]: [
{ label: '2', ticks: 198 },
{ label: '10', ticks: 953 },
{ label: '80', ticks: 5878 },
],
}
interface PresetsButtonProps {
feeAmount: FeeAmount | undefined
setRange: (numTicks: number) => void
setFullRange: () => void
}
const PresetButton = ({
values: { label, ticks },
setRange,
}: {
values: {
label: string
ticks: number
}
setRange: (numTicks: number) => void
}) => (
<Button
onClick={() => {
setRange(ticks)
ReactGA.event({
category: 'Liquidity',
action: 'Preset clicked',
label: label,
})
}}
>
<TYPE.body fontSize={12}>
<Trans>+/- {label}%</Trans>
</TYPE.body>
</Button>
)
export default function PresetsButtons({ feeAmount, setRange, setFullRange }: PresetsButtonProps) {
feeAmount = feeAmount ?? FeeAmount.LOW
return ( return (
<AutoRow gap="4px" width="auto"> <AutoRow gap="4px" width="auto">
<PresetButton values={RANGES[feeAmount][0]} setRange={setRange} />
<PresetButton values={RANGES[feeAmount][1]} setRange={setRange} />
<PresetButton values={RANGES[feeAmount][2]} setRange={setRange} />
<Button onClick={() => setFullRange()}> <Button onClick={() => setFullRange()}>
<TYPE.body fontSize={12}> <TYPE.body fontSize={12}>
<Trans>Full Range</Trans> <Trans>Full Range</Trans>

@ -44,13 +44,13 @@ export default function RangeSelector({
<AutoColumn gap="md"> <AutoColumn gap="md">
<RowBetween> <RowBetween>
<StepCounter <StepCounter
value={ticksAtLimit[Bound.LOWER] ? '0' : leftPrice?.toSignificant(5) ?? ''} value={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER] ? '0' : leftPrice?.toSignificant(5) ?? ''}
onUserInput={onLeftRangeInput} onUserInput={onLeftRangeInput}
width="48%" width="48%"
decrement={isSorted ? getDecrementLower : getIncrementUpper} decrement={isSorted ? getDecrementLower : getIncrementUpper}
increment={isSorted ? getIncrementLower : getDecrementUpper} increment={isSorted ? getIncrementLower : getDecrementUpper}
decrementDisabled={ticksAtLimit[Bound.LOWER]} decrementDisabled={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]}
incrementDisabled={ticksAtLimit[Bound.LOWER]} incrementDisabled={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]}
feeAmount={feeAmount} feeAmount={feeAmount}
label={leftPrice ? `${currencyB?.symbol}` : '-'} label={leftPrice ? `${currencyB?.symbol}` : '-'}
title={<Trans>Min Price</Trans>} title={<Trans>Min Price</Trans>}
@ -58,13 +58,13 @@ export default function RangeSelector({
tokenB={currencyB?.symbol} tokenB={currencyB?.symbol}
/> />
<StepCounter <StepCounter
value={ticksAtLimit[Bound.UPPER] ? '∞' : rightPrice?.toSignificant(5) ?? ''} value={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER] ? '∞' : rightPrice?.toSignificant(5) ?? ''}
onUserInput={onRightRangeInput} onUserInput={onRightRangeInput}
width="48%" width="48%"
decrement={isSorted ? getDecrementUpper : getIncrementLower} decrement={isSorted ? getDecrementUpper : getIncrementLower}
increment={isSorted ? getIncrementUpper : getDecrementLower} increment={isSorted ? getIncrementUpper : getDecrementLower}
incrementDisabled={ticksAtLimit[Bound.UPPER]} incrementDisabled={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]}
decrementDisabled={ticksAtLimit[Bound.UPPER]} decrementDisabled={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]}
feeAmount={feeAmount} feeAmount={feeAmount}
label={rightPrice ? `${currencyB?.symbol}` : '-'} label={rightPrice ? `${currencyB?.symbol}` : '-'}
tokenA={currencyA?.symbol} tokenA={currencyA?.symbol}

@ -63,6 +63,7 @@ import { useDerivedPositionInfo } from 'hooks/useDerivedPositionInfo'
import { PositionPreview } from 'components/PositionPreview' import { PositionPreview } from 'components/PositionPreview'
import FeeSelector from 'components/FeeSelector' import FeeSelector from 'components/FeeSelector'
import RangeSelector from 'components/RangeSelector' import RangeSelector from 'components/RangeSelector'
import PresetsButtons from 'components/RangeSelector/PresetsButtons'
import RateToggle from 'components/RateToggle' import RateToggle from 'components/RateToggle'
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { AddRemoveTabs } from 'components/NavigationTabs' import { AddRemoveTabs } from 'components/NavigationTabs'
@ -604,9 +605,11 @@ export default function AddLiquidity({
currencyA={baseCurrency} currencyA={baseCurrency}
currencyB={quoteCurrency} currencyB={quoteCurrency}
handleRateToggle={() => { handleRateToggle={() => {
onLeftRangeInput((invertPrice ? priceLower : priceUpper?.invert())?.toSignificant(6) ?? '') if (!ticksAtLimit[Bound.LOWER] && !ticksAtLimit[Bound.UPPER]) {
onRightRangeInput((invertPrice ? priceUpper : priceLower?.invert())?.toSignificant(6) ?? '') onLeftRangeInput((invertPrice ? priceLower : priceUpper?.invert())?.toSignificant(6) ?? '')
onFieldAInput(formattedAmounts[Field.CURRENCY_B] ?? '') onRightRangeInput((invertPrice ? priceUpper : priceLower?.invert())?.toSignificant(6) ?? '')
onFieldAInput(formattedAmounts[Field.CURRENCY_B] ?? '')
}
history.push( history.push(
`/add/${currencyIdB as string}/${currencyIdA as string}${feeAmount ? '/' + feeAmount : ''}` `/add/${currencyIdB as string}/${currencyIdA as string}${feeAmount ? '/' + feeAmount : ''}`
) )
@ -863,6 +866,13 @@ export default function AddLiquidity({
feeAmount={feeAmount} feeAmount={feeAmount}
ticksAtLimit={ticksAtLimit} ticksAtLimit={ticksAtLimit}
/> />
{!noLiquidity && (
<PresetsButtons
setFullRange={() => {
setShowCapitalEfficiencyWarning(true)
}}
/>
)}
</AutoColumn> </AutoColumn>
</StackedItem> </StackedItem>
@ -890,7 +900,7 @@ export default function AddLiquidity({
Full range positions may earn less fees than concentrated positions. Learn more{' '} Full range positions may earn less fees than concentrated positions. Learn more{' '}
<ExternalLink <ExternalLink
style={{ color: theme.yellow3, textDecoration: 'underline' }} style={{ color: theme.yellow3, textDecoration: 'underline' }}
href={''} href={'https://help.uniswap.org/en/articles/5406286-v3-faq-liquidity-providing'}
> >
here here
</ExternalLink> </ExternalLink>

@ -542,34 +542,9 @@ export function useRangeHopCallbacks(
return '' return ''
}, [baseToken, quoteToken, tickUpper, feeAmount, pool]) }, [baseToken, quoteToken, tickUpper, feeAmount, pool])
const getSetRange = useCallback(
(numTicks: number) => {
if (baseToken && quoteToken && feeAmount && pool) {
// calculate range around current price given `numTicks`
const newPriceLower = tickToPrice(
baseToken,
quoteToken,
Math.max(TickMath.MIN_TICK, pool.tickCurrent - numTicks)
)
const newPriceUpper = tickToPrice(
baseToken,
quoteToken,
Math.min(TickMath.MAX_TICK, pool.tickCurrent + numTicks)
)
return [
newPriceLower.toSignificant(5, undefined, Rounding.ROUND_UP),
newPriceUpper.toSignificant(5, undefined, Rounding.ROUND_UP),
]
}
return ['', '']
},
[baseToken, quoteToken, feeAmount, pool]
)
const getSetFullRange = useCallback(() => { const getSetFullRange = useCallback(() => {
dispatch(setFullRange()) dispatch(setFullRange())
}, [dispatch]) }, [dispatch])
return { getDecrementLower, getIncrementLower, getDecrementUpper, getIncrementUpper, getSetRange, getSetFullRange } return { getDecrementLower, getIncrementLower, getDecrementUpper, getIncrementUpper, getSetFullRange }
} }