Remove liquidity callback (#837)

* give add liquidity the reducer treatment

rename setDefaultsFromURL to setDefaultsFromURLSearch

* fix tests and crash

* rework DOM structure to make flow more natural

* allow slippage + deadline setting in add liquidity

* migrate burn

* disable token selection in mint

clear input between pairs

* reset fields between pairs

* tweak helper text

* address review comments
This commit is contained in:
Noah Zinsmeister 2020-05-27 13:13:31 -04:00 committed by GitHub
parent 7adb4b6bd6
commit 28c916ff45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 596 additions and 617 deletions

@ -1,19 +1,19 @@
describe('Remove Liquidity', () => {
it('loads the two correct tokens', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
})
it('does not crash if ETH is duplicated', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xc778417E063141139Fce010982780140Aa0cD5Ab')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('not.contain.text', 'ETH')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-tokenb-symbol').should('not.contain.text', 'ETH')
})
it('token not in storage is loaded', () => {
cy.visit('/remove/0xb290b2f9f8f108d03ff2af3ac5c8de6de31cdf6d-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'SKL')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'SKL')
cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
})
})

@ -110,8 +110,8 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps
<QuestionHelper
text={
adding
? 'When you add liquidity, you are given pool tokens that represent your position in this pool. These tokens automatically earn fees proportional to your pool share and can be redeemed at any time.'
: 'Your liquidity is represented by a pool token (ERC20). Removing will convert your position back into tokens at the current rate and proportional to the amount of each token in the pool. Any fees you accrued are included in the token amounts you receive.'
? 'When you add liquidity, you are given pool tokens representing your position. These tokens automatically earn fees proportional to your share of the pool, and can be redeemed at any time.'
: 'Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive.'
}
/>
</RowBetween>

@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import React from 'react'
import Slider from '@material-ui/core/Slider'
import { withStyles } from '@material-ui/core/styles'
import { useDebounce } from '../../hooks'
const StyledSlider = withStyles({
root: {
@ -51,36 +50,12 @@ const StyledSlider = withStyles({
interface InputSliderProps {
value: number
onChange: (val: number) => void
override?: boolean
onChange: (value: number) => void
}
export default function InputSlider({ value, onChange, override }: InputSliderProps) {
const [internalVal, setInternalVal] = useState<number>(value)
const debouncedInternalValue = useDebounce(internalVal, 100)
const handleChange = useCallback(
(e, val) => {
setInternalVal(val)
if (val !== debouncedInternalValue) {
onChange(val)
}
},
[setInternalVal, onChange, debouncedInternalValue]
)
useEffect(() => {
if (override) {
setInternalVal(value)
}
}, [override, value])
return (
<StyledSlider
value={typeof internalVal === 'number' ? internalVal : 0}
onChange={handleChange}
aria-labelledby="input-slider"
step={1}
/>
)
export default function InputSlider({ value, onChange }: InputSliderProps) {
function wrappedOnChange(_, value) {
onChange(value)
}
return <StyledSlider value={value} onChange={wrappedOnChange} aria-labelledby="input-slider" step={1} />
}

@ -151,7 +151,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se
<TYPE.black fontWeight={400} fontSize={14} color={theme.text2}>
Set slippage tolerance
</TYPE.black>
<QuestionHelper text="Your transaction will revert if the execution price changes by more than this amount after you submit your trade." />
<QuestionHelper text="Your transaction will revert if the price changes unfavorably by more than this percentage." />
</RowFixed>
<SlippageSelector>
@ -227,7 +227,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se
<TYPE.black fontSize={14} color={theme.text2}>
Deadline
</TYPE.black>
<QuestionHelper text="Deadline in minutes. If your transaction takes longer than this it will revert." />
<QuestionHelper text="Your transaction will revert if it is pending for more than this long." />
</RowFixed>
<RowFixed padding={'0 20px'}>
<OptionCustom style={{ width: '80px' }} tabIndex={-1}>

@ -29,13 +29,7 @@ function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippag
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{isExactIn ? 'Minimum received' : 'Maximum sold'}
</TYPE.black>
<QuestionHelper
text={
isExactIn
? 'Price can change between when a transaction is submitted and when it is executed. This is the minimum amount you will receive. A worse rate will cause your transaction to revert.'
: 'Price can change between when a transaction is submitted and when it is executed. This is the maximum amount you will pay. A worse rate will cause your transaction to revert.'
}
/>
<QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
</RowFixed>
<RowFixed>
<TYPE.black color={theme.text1} fontSize={14}>

@ -66,9 +66,9 @@ export default function SwapModalFooter({
<RowBetween>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{trade?.tradeType === TradeType.EXACT_INPUT ? 'Min sent' : 'Maximum sold'}
{trade?.tradeType === TradeType.EXACT_INPUT ? 'Minimum sent' : 'Maximum sold'}
</TYPE.black>
<QuestionHelper text="A boundary is set so you are protected from large price movements after you submit your trade." />
<QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
</RowFixed>
<RowFixed>
<TYPE.black fontSize={14}>

@ -21,13 +21,6 @@ export const ArrowWrapper = styled.div`
}
`
export const FixedBottom = styled.div`
position: absolute;
margin-top: 1.5rem;
width: 100%;
margin-bottom: 40px;
`
export const AdvancedDropdown = styled.div`
padding-top: calc(10px + 2rem);
padding-bottom: 10px;

@ -4,12 +4,7 @@ import styled from 'styled-components'
export const Wrapper = styled.div`
position: relative;
`
export const FixedBottom = styled.div`
position: absolute;
top: 100px;
width: 100%;
margin-bottom: 80px;
`
export const ClickableText = styled(Text)`
:hover {
cursor: pointer;

File diff suppressed because it is too large Load Diff

10
src/state/burn/actions.ts Normal file

@ -0,0 +1,10 @@
import { createAction } from '@reduxjs/toolkit'
export enum Field {
LIQUIDITY_PERCENT = 'LIQUIDITY_PERCENT',
LIQUIDITY = 'LIQUIDITY',
TOKEN_A = 'TOKEN_A',
TOKEN_B = 'TOKEN_B'
}
export const typeInput = createAction<{ field: Field; typedValue: string }>('typeInputBurn')

187
src/state/burn/hooks.ts Normal file

@ -0,0 +1,187 @@
import { useEffect, useCallback, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useActiveWeb3React } from '../../hooks'
import { AppDispatch, AppState } from '../index'
import { Field, typeInput } from './actions'
import { setDefaultsFromURLMatchParams } from '../mint/actions'
import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens'
import { Token, Pair, TokenAmount, Percent, JSBI, Route } from '@uniswap/sdk'
import { usePair } from '../../data/Reserves'
import { useTokenBalances } from '../wallet/hooks'
import { tryParseAmount } from '../swap/hooks'
import { useTotalSupply } from '../../data/TotalSupply'
const ZERO = JSBI.BigInt(0)
export function useBurnState(): AppState['burn'] {
return useSelector<AppState, AppState['burn']>(state => state.burn)
}
export function useDerivedBurnInfo(): {
tokens: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: Token }
pair?: Pair | null
route?: Route
parsedAmounts: {
[Field.LIQUIDITY_PERCENT]: Percent
[Field.LIQUIDITY]?: TokenAmount
[Field.TOKEN_A]?: TokenAmount
[Field.TOKEN_B]?: TokenAmount
}
error?: string
} {
const { account } = useActiveWeb3React()
const {
independentField,
typedValue,
[Field.TOKEN_A]: { address: tokenAAddress },
[Field.TOKEN_B]: { address: tokenBAddress }
} = useBurnState()
// tokens
const tokenA = useTokenByAddressAndAutomaticallyAdd(tokenAAddress)
const tokenB = useTokenByAddressAndAutomaticallyAdd(tokenBAddress)
const tokens: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: Token } = useMemo(
() => ({
[Field.TOKEN_A]: tokenA,
[Field.TOKEN_B]: tokenB
}),
[tokenA, tokenB]
)
// pair + totalsupply
const pair = usePair(tokens[Field.TOKEN_A], tokens[Field.TOKEN_B])
const noLiquidity =
pair === null || (!!pair && JSBI.equal(pair.reserve0.raw, ZERO) && JSBI.equal(pair.reserve1.raw, ZERO))
// route
const route =
!noLiquidity && pair && tokens[Field.TOKEN_A] ? new Route([pair], tokens[Field.TOKEN_A] as Token) : undefined
// balances
const relevantTokenBalances = useTokenBalances(account ?? undefined, [pair?.liquidityToken])
const userLiquidity: undefined | TokenAmount = relevantTokenBalances?.[pair?.liquidityToken?.address ?? '']
// liquidity values
const totalSupply = useTotalSupply(pair?.liquidityToken)
const liquidityValues: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: TokenAmount } = {
[Field.TOKEN_A]:
pair &&
tokens[Field.TOKEN_A] &&
totalSupply &&
userLiquidity &&
// this condition is a short-circuit in the case where useTokenBalance updates sooner than useTotalSupply
JSBI.greaterThanOrEqual(totalSupply.raw, userLiquidity.raw)
? new TokenAmount(
tokens[Field.TOKEN_A] as Token,
pair.getLiquidityValue(tokens[Field.TOKEN_A] as Token, totalSupply, userLiquidity, false).raw
)
: undefined,
[Field.TOKEN_B]:
pair &&
tokens[Field.TOKEN_B] &&
totalSupply &&
userLiquidity &&
// this condition is a short-circuit in the case where useTokenBalance updates sooner than useTotalSupply
JSBI.greaterThanOrEqual(totalSupply.raw, userLiquidity.raw)
? new TokenAmount(
tokens[Field.TOKEN_B] as Token,
pair.getLiquidityValue(tokens[Field.TOKEN_B] as Token, totalSupply, userLiquidity, false).raw
)
: undefined
}
let percentToRemove: Percent = new Percent('0', '100')
// user specified a %
if (independentField === Field.LIQUIDITY_PERCENT) {
percentToRemove = new Percent(typedValue, '100')
}
// user specified a specific amount of liquidity tokens
else if (independentField === Field.LIQUIDITY) {
if (pair?.liquidityToken) {
const independentAmount = tryParseAmount(typedValue, pair.liquidityToken)
if (independentAmount && userLiquidity && !independentAmount.greaterThan(userLiquidity)) {
percentToRemove = new Percent(independentAmount.raw, userLiquidity.raw)
}
}
}
// user specified a specific amount of token a or b
else {
if (tokens[independentField]) {
const independentAmount = tryParseAmount(typedValue, tokens[independentField])
if (
independentAmount &&
liquidityValues[independentField] &&
!independentAmount.greaterThan(liquidityValues[independentField] as TokenAmount)
) {
percentToRemove = new Percent(independentAmount.raw, (liquidityValues[independentField] as TokenAmount).raw)
}
}
}
const parsedAmounts: {
[Field.LIQUIDITY_PERCENT]: Percent
[Field.LIQUIDITY]?: TokenAmount
[Field.TOKEN_A]?: TokenAmount
[Field.TOKEN_B]?: TokenAmount
} = {
[Field.LIQUIDITY_PERCENT]: percentToRemove,
[Field.LIQUIDITY]:
userLiquidity && percentToRemove && percentToRemove.greaterThan('0')
? new TokenAmount(userLiquidity.token, percentToRemove.multiply(userLiquidity.raw).quotient)
: undefined,
[Field.TOKEN_A]:
tokens[Field.TOKEN_A] && percentToRemove && percentToRemove.greaterThan('0') && liquidityValues[Field.TOKEN_A]
? new TokenAmount(
tokens[Field.TOKEN_A] as Token,
percentToRemove.multiply((liquidityValues[Field.TOKEN_A] as TokenAmount).raw).quotient
)
: undefined,
[Field.TOKEN_B]:
tokens[Field.TOKEN_B] && percentToRemove && percentToRemove.greaterThan('0') && liquidityValues[Field.TOKEN_B]
? new TokenAmount(
tokens[Field.TOKEN_B] as Token,
percentToRemove.multiply((liquidityValues[Field.TOKEN_B] as TokenAmount).raw).quotient
)
: undefined
}
let error: string | undefined
if (!account) {
error = 'Connect Wallet'
}
if (!parsedAmounts[Field.LIQUIDITY] || !parsedAmounts[Field.TOKEN_A] || !parsedAmounts[Field.TOKEN_B]) {
error = error ?? 'Enter an amount'
}
return { tokens, pair, route, parsedAmounts, error }
}
export function useBurnActionHandlers(): {
onUserInput: (field: Field, typedValue: string) => void
} {
const dispatch = useDispatch<AppDispatch>()
const onUserInput = useCallback(
(field: Field, typedValue: string) => {
dispatch(typeInput({ field, typedValue }))
},
[dispatch]
)
return {
onUserInput
}
}
// updates the burn state to use the appropriate tokens, given the route
export function useDefaultsFromURLMatchParams(params: { [k: string]: string }) {
const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
if (!chainId) return
dispatch(setDefaultsFromURLMatchParams({ chainId, params }))
}, [dispatch, chainId, params])
}

51
src/state/burn/reducer.ts Normal file

@ -0,0 +1,51 @@
import { createReducer } from '@reduxjs/toolkit'
import { Field, typeInput } from './actions'
import { setDefaultsFromURLMatchParams } from '../mint/actions'
import { parseTokens } from '../mint/reducer'
export interface MintState {
readonly independentField: Field
readonly typedValue: string
readonly [Field.TOKEN_A]: {
readonly address: string
}
readonly [Field.TOKEN_B]: {
readonly address: string
}
}
const initialState: MintState = {
independentField: Field.LIQUIDITY_PERCENT,
typedValue: '0',
[Field.TOKEN_A]: {
address: ''
},
[Field.TOKEN_B]: {
address: ''
}
}
export default createReducer<MintState>(initialState, builder =>
builder
.addCase(setDefaultsFromURLMatchParams, (state, { payload: { chainId, params } }) => {
const tokens = parseTokens(chainId, params?.tokens ?? '')
return {
independentField: Field.LIQUIDITY_PERCENT,
typedValue: '0',
[Field.TOKEN_A]: {
address: tokens[0]
},
[Field.TOKEN_B]: {
address: tokens[1]
}
}
})
.addCase(typeInput, (state, { payload: { field, typedValue } }) => {
return {
...state,
independentField: field,
typedValue
}
})
)

@ -7,6 +7,7 @@ import transactions from './transactions/reducer'
import wallet from './wallet/reducer'
import swap from './swap/reducer'
import mint from './mint/reducer'
import burn from './burn/reducer'
import { updateVersion } from './user/actions'
@ -19,7 +20,8 @@ const store = configureStore({
transactions,
wallet,
swap,
mint
mint,
burn
},
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS })

@ -28,7 +28,7 @@ const initialState: MintState = {
}
}
function parseTokens(chainId: number, tokens: string): string[] {
export function parseTokens(chainId: number, tokens: string): string[] {
return (
tokens
// split by '-'