Commit 28c916ff authored by Noah Zinsmeister's avatar Noah Zinsmeister Committed by GitHub

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
parent 7adb4b6b
describe('Remove Liquidity', () => { describe('Remove Liquidity', () => {
it('loads the two correct tokens', () => { it('loads the two correct tokens', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85') cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH') cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR') cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
}) })
it('does not crash if ETH is duplicated', () => { it('does not crash if ETH is duplicated', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xc778417E063141139Fce010982780140Aa0cD5Ab') cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xc778417E063141139Fce010982780140Aa0cD5Ab')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH') cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('not.contain.text', 'ETH') cy.get('#remove-liquidity-tokenb-symbol').should('not.contain.text', 'ETH')
}) })
it('token not in storage is loaded', () => { it('token not in storage is loaded', () => {
cy.visit('/remove/0xb290b2f9f8f108d03ff2af3ac5c8de6de31cdf6d-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85') cy.visit('/remove/0xb290b2f9f8f108d03ff2af3ac5c8de6de31cdf6d-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'SKL') cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'SKL')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR') cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
}) })
}) })
...@@ -110,8 +110,8 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps ...@@ -110,8 +110,8 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps
<QuestionHelper <QuestionHelper
text={ text={
adding 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.' ? '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.'
: '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.' : '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> </RowBetween>
......
import React, { useState, useEffect, useCallback } from 'react' import React from 'react'
import Slider from '@material-ui/core/Slider' import Slider from '@material-ui/core/Slider'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import { useDebounce } from '../../hooks'
const StyledSlider = withStyles({ const StyledSlider = withStyles({
root: { root: {
...@@ -51,36 +50,12 @@ const StyledSlider = withStyles({ ...@@ -51,36 +50,12 @@ const StyledSlider = withStyles({
interface InputSliderProps { interface InputSliderProps {
value: number value: number
onChange: (val: number) => void onChange: (value: number) => void
override?: boolean
} }
export default function InputSlider({ value, onChange, override }: InputSliderProps) { export default function InputSlider({ value, onChange }: InputSliderProps) {
const [internalVal, setInternalVal] = useState<number>(value) function wrappedOnChange(_, value) {
const debouncedInternalValue = useDebounce(internalVal, 100) onChange(value)
}
const handleChange = useCallback( return <StyledSlider value={value} onChange={wrappedOnChange} aria-labelledby="input-slider" step={1} />
(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}
/>
)
} }
...@@ -151,7 +151,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se ...@@ -151,7 +151,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se
<TYPE.black fontWeight={400} fontSize={14} color={theme.text2}> <TYPE.black fontWeight={400} fontSize={14} color={theme.text2}>
Set slippage tolerance Set slippage tolerance
</TYPE.black> </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> </RowFixed>
<SlippageSelector> <SlippageSelector>
...@@ -227,7 +227,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se ...@@ -227,7 +227,7 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se
<TYPE.black fontSize={14} color={theme.text2}> <TYPE.black fontSize={14} color={theme.text2}>
Deadline Deadline
</TYPE.black> </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>
<RowFixed padding={'0 20px'}> <RowFixed padding={'0 20px'}>
<OptionCustom style={{ width: '80px' }} tabIndex={-1}> <OptionCustom style={{ width: '80px' }} tabIndex={-1}>
......
...@@ -29,13 +29,7 @@ function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippag ...@@ -29,13 +29,7 @@ function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippag
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}> <TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{isExactIn ? 'Minimum received' : 'Maximum sold'} {isExactIn ? 'Minimum received' : 'Maximum sold'}
</TYPE.black> </TYPE.black>
<QuestionHelper <QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
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.'
}
/>
</RowFixed> </RowFixed>
<RowFixed> <RowFixed>
<TYPE.black color={theme.text1} fontSize={14}> <TYPE.black color={theme.text1} fontSize={14}>
......
...@@ -66,9 +66,9 @@ export default function SwapModalFooter({ ...@@ -66,9 +66,9 @@ export default function SwapModalFooter({
<RowBetween> <RowBetween>
<RowFixed> <RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}> <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> </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>
<RowFixed> <RowFixed>
<TYPE.black fontSize={14}> <TYPE.black fontSize={14}>
......
...@@ -21,13 +21,6 @@ export const ArrowWrapper = styled.div` ...@@ -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` export const AdvancedDropdown = styled.div`
padding-top: calc(10px + 2rem); padding-top: calc(10px + 2rem);
padding-bottom: 10px; padding-bottom: 10px;
......
...@@ -4,12 +4,7 @@ import styled from 'styled-components' ...@@ -4,12 +4,7 @@ import styled from 'styled-components'
export const Wrapper = styled.div` export const Wrapper = styled.div`
position: relative; position: relative;
` `
export const FixedBottom = styled.div`
position: absolute;
top: 100px;
width: 100%;
margin-bottom: 80px;
`
export const ClickableText = styled(Text)` export const ClickableText = styled(Text)`
:hover { :hover {
cursor: pointer; cursor: pointer;
......
This diff is collapsed.
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')
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])
}
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' ...@@ -7,6 +7,7 @@ import transactions from './transactions/reducer'
import wallet from './wallet/reducer' import wallet from './wallet/reducer'
import swap from './swap/reducer' import swap from './swap/reducer'
import mint from './mint/reducer' import mint from './mint/reducer'
import burn from './burn/reducer'
import { updateVersion } from './user/actions' import { updateVersion } from './user/actions'
...@@ -19,7 +20,8 @@ const store = configureStore({ ...@@ -19,7 +20,8 @@ const store = configureStore({
transactions, transactions,
wallet, wallet,
swap, swap,
mint mint,
burn
}, },
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })], middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS }) preloadedState: load({ states: PERSISTED_KEYS })
......
...@@ -28,7 +28,7 @@ const initialState: MintState = { ...@@ -28,7 +28,7 @@ const initialState: MintState = {
} }
} }
function parseTokens(chainId: number, tokens: string): string[] { export function parseTokens(chainId: number, tokens: string): string[] {
return ( return (
tokens tokens
// split by '-' // split by '-'
......
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