Commit c383a0a0 authored by Mike Grabowski's avatar Mike Grabowski Committed by GitHub

feat: new Settings menu for Swap (#6480)

* feat: initial commit

* remove extra check

* chore: divider fix

* feat: switch from boolean to routerPreference enum

* chore: align name

* chore: fix two errors

* chore: clean up

* chore: entire radio button clickable

* chore: remove unused toggle component

* Revert "chore: remove unused toggle component"

This reverts commit 42858a02b5bcd572682db38025b69f8b1426a8c1.

* feat: rewrite slippage

* feat: Slippage

* chore: tbd tomorrow

* Update src/state/user/reducer.ts
Co-authored-by: default avatarTina <59578595+tinaszheng@users.noreply.github.com>

* feat: replace auto with Slippage enum

* chore: add todo for deadline

* chore: cleanup

* feat: improve autoslippage

* chore: replace price with auto

* chore: fix lint

* test: add coverage for Expand

* chore: fix tests

* chore: review feedback part 1

* chore: rework warning

* chore: add jira tickets

* feat: add tests for useUserSlippageTolerance

* chore: add some more

* chore: one more

* add tests for slippage

* chore: add unit tests for transactionsettings

* remove

* revet changes to improve coverage

* chore: update to figma caption

* chore

* chore

* chore: update wording

* Update src/components/Expand/index.tsx
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>

* fix: issue with new value, update confusing migration comment

* chore: remove opacity animation temporarily

* chore: update snapshot test

* chore: fix e2e + update comment

* chore: fix tests

* chore: fix tests

---------
Co-authored-by: default avatarTina <59578595+tinaszheng@users.noreply.github.com>
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>
parent d6e92804
...@@ -97,6 +97,7 @@ describe('Swap', () => { ...@@ -97,6 +97,7 @@ describe('Swap', () => {
// Set deadline to minimum. (1 minute) // Set deadline to minimum. (1 minute)
cy.get(getTestSelector('open-settings-dialog-button')).click() cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('transaction-deadline-settings')).click()
cy.get(getTestSelector('deadline-input')).clear().type(DEADLINE_MINUTES.toString()) cy.get(getTestSelector('deadline-input')).clear().type(DEADLINE_MINUTES.toString())
cy.get('body').click('topRight') cy.get('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist') cy.get(getTestSelector('deadline-input')).should('not.exist')
...@@ -174,10 +175,9 @@ describe('Swap', () => { ...@@ -174,10 +175,9 @@ describe('Swap', () => {
cy.visit('/swap') cy.visit('/swap')
cy.contains('Settings').should('not.exist') cy.contains('Settings').should('not.exist')
cy.get(getTestSelector('swap-settings-button')).click() cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Slippage tolerance').should('exist') cy.contains('Max slippage').should('exist')
cy.contains('Transaction deadline').should('exist') cy.contains('Transaction deadline').should('exist')
cy.contains('Auto Router API').should('exist') cy.contains('Auto Router API').should('exist')
cy.contains('Expert Mode').should('exist')
cy.get(getTestSelector('swap-settings-button')).click() cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Settings').should('not.exist') cy.contains('Settings').should('not.exist')
}) })
...@@ -424,6 +424,7 @@ describe('Swap', () => { ...@@ -424,6 +424,7 @@ describe('Swap', () => {
// Set slippage to a very low value. // Set slippage to a very low value.
cy.get(getTestSelector('open-settings-dialog-button')).click() cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('0.01') cy.get(getTestSelector('slippage-input')).clear().type('0.01')
cy.get('body').click('topRight') cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist') cy.get(getTestSelector('slippage-input')).should('not.exist')
......
...@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>` ...@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>`
overflow: hidden; overflow: hidden;
` `
// TODO(WEB-3288): Replace this component to use `components/Expand` under the hood
type ExpandoRowProps = PropsWithChildren<{ title?: string; numItems: number; isExpanded: boolean; toggle: () => void }> type ExpandoRowProps = PropsWithChildren<{ title?: string; numItems: number; isExpanded: boolean; toggle: () => void }>
export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) { export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) {
if (numItems === 0) return null if (numItems === 0) return null
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Expand renders correctly 1`] = `
<DocumentFragment>
.c1 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c2 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c3 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 12px;
}
.c4 {
cursor: pointer;
-webkit-box-pack: end;
-webkit-justify-content: flex-end;
-ms-flex-pack: end;
justify-content: flex-end;
width: unset;
}
.c5 {
color: #7780A0;
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
-webkit-transition: -webkit-transform 250ms;
-webkit-transition: transform 250ms;
transition: transform 250ms;
}
<div
class="c0"
>
<div
class="c1 c2 c3"
>
<span>
Header
</span>
<div
aria-expanded="false"
class="c1 c2 c4"
>
<span>
Button
</span>
<svg
class="c5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="6 9 12 15 18 9"
/>
</svg>
</div>
</div>
</div>
</DocumentFragment>
`;
import { fireEvent, render, screen } from 'test-utils/render'
import Expand from './index'
describe('Expand', () => {
it('renders correctly', () => {
const { asFragment } = render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
Body
</Expand>
)
expect(asFragment()).toMatchSnapshot()
})
it('toggles children on button press', () => {
render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
Body
</Expand>
)
const button = screen.getByText('Button')
fireEvent.click(button)
expect(screen.queryByText('Body')).not.toBeNull()
fireEvent.click(button)
expect(screen.queryByText('Body')).toBeNull()
})
})
import Column from 'components/Column'
import React, { PropsWithChildren, ReactElement, useState } from 'react'
import { ChevronDown } from 'react-feather'
import styled from 'styled-components/macro'
import Row, { RowBetween } from '../Row'
const ButtonContainer = styled(Row)`
cursor: pointer;
justify-content: flex-end;
width: unset;
`
const ExpandIcon = styled(ChevronDown)<{ $isExpanded: boolean }>`
color: ${({ theme }) => theme.textSecondary};
transform: ${({ $isExpanded }) => ($isExpanded ? 'rotate(180deg)' : 'rotate(0deg)')};
transition: transform ${({ theme }) => theme.transition.duration.medium};
`
export default function Expand({
header,
button,
children,
testId,
}: PropsWithChildren<{
header: ReactElement
button: ReactElement
testId?: string
}>) {
const [isExpanded, setExpanded] = useState(false)
return (
<Column gap="md">
<RowBetween>
{header}
<ButtonContainer data-testid={testId} onClick={() => setExpanded(!isExpanded)} aria-expanded={isExpanded}>
{button}
<ExpandIcon $isExpanded={isExpanded} />
</ButtonContainer>
</RowBetween>
{isExpanded && children}
</Column>
)
}
...@@ -57,13 +57,13 @@ export function FindPoolTabs({ origin }: { origin: string }) { ...@@ -57,13 +57,13 @@ export function FindPoolTabs({ origin }: { origin: string }) {
export function AddRemoveTabs({ export function AddRemoveTabs({
adding, adding,
creating, creating,
defaultSlippage, autoSlippage,
positionID, positionID,
children, children,
}: { }: {
adding: boolean adding: boolean
creating: boolean creating: boolean
defaultSlippage: Percent autoSlippage: Percent
positionID?: string | undefined positionID?: string | undefined
showBackLink?: boolean showBackLink?: boolean
children?: ReactNode | undefined children?: ReactNode | undefined
...@@ -108,7 +108,7 @@ export function AddRemoveTabs({ ...@@ -108,7 +108,7 @@ export function AddRemoveTabs({
)} )}
</ThemedText.DeprecatedMediumHeader> </ThemedText.DeprecatedMediumHeader>
<Box style={{ marginRight: '.5rem' }}>{children}</Box> <Box style={{ marginRight: '.5rem' }}>{children}</Box>
<SettingsTab placeholderSlippage={defaultSlippage} /> <SettingsTab autoSlippage={autoSlippage} />
</RowBetween> </RowBetween>
</Tabs> </Tabs>
) )
......
import { RowBetween } from 'components/Row'
import { darken } from 'polished'
import { PropsWithChildren } from 'react'
import styled from 'styled-components/macro'
const Button = styled.button<{ isActive?: boolean; activeElement?: boolean }>`
align-items: center;
background: transparent;
border: 2px solid ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.backgroundOutline)};
border-radius: 50%;
cursor: pointer;
display: flex;
outline: none;
padding: 5px;
width: fit-content;
`
const ButtonFill = styled.span<{ isActive?: boolean }>`
background: ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.textTertiary)};
border-radius: 50%;
:hover {
background: ${({ isActive, theme }) =>
isActive ? darken(0.05, theme.accentAction) : darken(0.05, theme.deprecated_bg4)};
color: ${({ isActive, theme }) => (isActive ? theme.white : theme.textTertiary)};
}
width: 10px;
height: 10px;
opacity: ${({ isActive }) => (isActive ? 1 : 0)};
`
const Container = styled(RowBetween)`
cursor: pointer;
`
interface RadioProps {
className?: string
isActive: boolean
toggle: () => void
}
export default function Radio({ className, isActive, children, toggle }: PropsWithChildren<RadioProps>) {
return (
<Container className={className} onClick={toggle}>
{children}
<Button isActive={isActive}>
<ButtonFill isActive={isActive} />
</Button>
</Container>
)
}
import { Box } from 'rebass/styled-components' import { Box } from 'rebass/styled-components'
import styled from 'styled-components/macro' import styled, { DefaultTheme } from 'styled-components/macro'
type Gap = keyof DefaultTheme['grids']
// TODO(WEB-3289):
// Setting `width: 100%` by default prevents composability in complex flex layouts.
// Same applies to `RowFixed` and its negative margins. This component needs to be
// further investigated and improved to make UI work easier.
const Row = styled(Box)<{ const Row = styled(Box)<{
width?: string width?: string
align?: string align?: string
...@@ -18,7 +24,7 @@ const Row = styled(Box)<{ ...@@ -18,7 +24,7 @@ const Row = styled(Box)<{
padding: ${({ padding }) => padding}; padding: ${({ padding }) => padding};
border: ${({ border }) => border}; border: ${({ border }) => border};
border-radius: ${({ borderRadius }) => borderRadius}; border-radius: ${({ borderRadius }) => borderRadius};
gap: ${({ gap }) => gap}; gap: ${({ gap, theme }) => gap && (theme.grids[gap as Gap] || gap)};
` `
export const RowBetween = styled(Row)` export const RowBetween = styled(Row)`
......
import styled from 'styled-components/macro'
import Row from '../../Row'
export const Input = styled.input`
width: 100%;
display: flex;
flex: 1;
font-size: 16px;
border: 0;
outline: none;
background: transparent;
text-align: right;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
::placeholder {
color: ${({ theme }) => theme.textTertiary};
}
`
export const InputContainer = styled(Row)<{ error?: boolean }>`
padding: 8px 16px;
border-radius: 12px;
width: auto;
flex: 1;
input {
color: ${({ theme, error }) => (error ? theme.accentFailure : theme.textPrimary)};
}
border: 1px solid ${({ theme, error }) => (error ? theme.accentFailure : theme.deprecated_bg3)};
${({ theme, error }) =>
error
? `
border: 1px solid ${theme.accentFailure};
:focus-within {
border-color: ${theme.accentFailureSoft};
}
`
: `
border: 1px solid ${theme.backgroundOutline};
:focus-within {
border-color: ${theme.accentActiveSoft};
}
`}
`
import { Percent } from '@uniswap/sdk-core'
import store from 'state'
import { updateUserSlippageTolerance } from 'state/user/reducer'
import { SlippageTolerance } from 'state/user/types'
import { fireEvent, render, screen } from 'test-utils/render'
import MaxSlippageSettings from '.'
const AUTO_SLIPPAGE = new Percent(5, 10_000)
const renderAndExpandSlippageSettings = () => {
render(<MaxSlippageSettings autoSlippage={AUTO_SLIPPAGE} />)
// By default, the button to expand Slippage component and show `input` will have `Auto` label
fireEvent.click(screen.getByText('Auto'))
}
// Switch to custom mode by tapping on `Custom` label
const switchToCustomSlippage = () => {
fireEvent.click(screen.getByText('Custom'))
}
const getSlippageInput = () => screen.getByTestId('slippage-input') as HTMLInputElement
describe('MaxSlippageSettings', () => {
describe('input', () => {
// Restore to default slippage before each unit test
beforeEach(() => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: SlippageTolerance.Auto }))
})
it('does not render auto slippage as a value, but a placeholder', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
expect(getSlippageInput().value).toBe('')
})
it('renders custom slippage above the input', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
fireEvent.change(getSlippageInput(), { target: { value: '0.5' } })
expect(screen.queryAllByText('0.50%').length).toEqual(1)
})
it('updates input value on blur with the slippage in store', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '0.5' } })
fireEvent.blur(input)
expect(input.value).toBe('0.50')
})
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '50' } })
fireEvent.change(input, { target: { value: '500' } })
fireEvent.blur(input)
expect(input.value).toBe('50.00')
})
it('does not allow to enter more than 2 digits after the decimal point', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '0.01' } })
fireEvent.change(input, { target: { value: '0.011' } })
expect(input.value).toBe('0.01')
})
it('does not accept non-numerical values', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: 'c' } })
expect(input.value).toBe('')
})
it('does not set slippage when user enters `.` value', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '.' } })
expect(input.value).toBe('.')
fireEvent.blur(input)
expect(input.value).toBe('')
})
})
})
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import Expand from 'components/Expand'
import QuestionHelper from 'components/QuestionHelper'
import Row, { RowBetween } from 'components/Row'
import React, { useState } from 'react'
import { useUserSlippageTolerance } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
import styled from 'styled-components/macro'
import { CautionTriangle, ThemedText } from 'theme'
import { Input, InputContainer } from '../Input'
enum SlippageError {
InvalidInput = 'InvalidInput',
}
const Option = styled(Row)<{ isActive: boolean }>`
width: auto;
cursor: pointer;
padding: 6px 12px;
text-align: center;
gap: 4px;
border-radius: 12px;
background: ${({ isActive, theme }) => (isActive ? theme.backgroundInteractive : 'transparent')};
pointer-events: ${({ isActive }) => isActive && 'none'};
`
const Switch = styled(Row)`
width: auto;
padding: 4px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
`
const NUMBER_WITH_MAX_TWO_DECIMAL_PLACES = /^(?:\d*\.\d{0,2}|\d+)$/
const MINIMUM_RECOMMENDED_SLIPPAGE = new Percent(5, 10_000)
const MAXIMUM_RECOMMENDED_SLIPPAGE = new Percent(1, 100)
export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Percent }) {
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
// In order to trigger `custom` mode, we need to set `userSlippageTolerance` to a value that is not `auto`.
// To do so, we use `autoSlippage` value. However, since users are likely to change that value,
// we render it as a placeholder instead of a value.
const defaultSlippageInputValue =
userSlippageTolerance !== SlippageTolerance.Auto && !userSlippageTolerance.equalTo(autoSlippage)
? userSlippageTolerance.toFixed(2)
: ''
// If user has previously entered a custom slippage, we want to show that value in the input field
// instead of a placeholder.
const [slippageInput, setSlippageInput] = useState(defaultSlippageInputValue)
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
const parseSlippageInput = (value: string) => {
// Do not allow non-numerical characters in the input field or more than two decimals
if (value.length > 0 && !NUMBER_WITH_MAX_TWO_DECIMAL_PLACES.test(value)) {
return
}
setSlippageInput(value)
setSlippageError(false)
// If the input is empty, set the slippage to the default
if (value.length === 0) {
setUserSlippageTolerance(SlippageTolerance.Auto)
return
}
if (value === '.') {
return
}
// Parse user input and set the slippage if valid, error otherwise
try {
const parsed = Math.floor(Number.parseFloat(value) * 100)
if (parsed > 5000) {
setSlippageError(SlippageError.InvalidInput)
} else {
setUserSlippageTolerance(new Percent(parsed, 10_000))
}
} catch (e) {
setSlippageError(SlippageError.InvalidInput)
}
}
const tooLow =
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.lessThan(MINIMUM_RECOMMENDED_SLIPPAGE)
const tooHigh =
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.greaterThan(MAXIMUM_RECOMMENDED_SLIPPAGE)
return (
<Expand
testId="max-slippage-settings"
header={
<Row width="auto">
<ThemedText.BodySecondary>
<Trans>Max slippage</Trans>
</ThemedText.BodySecondary>
<QuestionHelper
text={
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
}
/>
</Row>
}
button={
<ThemedText.BodyPrimary>
{userSlippageTolerance === SlippageTolerance.Auto ? (
<Trans>Auto</Trans>
) : (
`${userSlippageTolerance.toFixed(2)}%`
)}
</ThemedText.BodyPrimary>
}
>
<RowBetween gap="md">
<Switch>
<Option
onClick={() => {
// Reset the input field when switching to auto
setSlippageInput('')
setUserSlippageTolerance(SlippageTolerance.Auto)
}}
isActive={userSlippageTolerance === SlippageTolerance.Auto}
>
<ThemedText.BodyPrimary>
<Trans>Auto</Trans>
</ThemedText.BodyPrimary>
</Option>
<Option
onClick={() => {
// When switching to custom slippage, use `auto` value as a default.
setUserSlippageTolerance(autoSlippage)
}}
isActive={userSlippageTolerance !== SlippageTolerance.Auto}
>
<ThemedText.BodyPrimary>
<Trans>Custom</Trans>
</ThemedText.BodyPrimary>
</Option>
</Switch>
<InputContainer gap="md" error={!!slippageError}>
<Input
data-testid="slippage-input"
placeholder={autoSlippage.toFixed(2)}
value={slippageInput}
onChange={(e) => parseSlippageInput(e.target.value)}
onBlur={() => {
// When the input field is blurred, reset the input field to the default value
setSlippageInput(defaultSlippageInputValue)
setSlippageError(false)
}}
/>
<ThemedText.BodyPrimary>%</ThemedText.BodyPrimary>
</InputContainer>
</RowBetween>
{tooLow || tooHigh ? (
<RowBetween gap="md">
<CautionTriangle />
<ThemedText.Caption color="accentWarning">
{tooLow ? (
<Trans>
Slippage below {MINIMUM_RECOMMENDED_SLIPPAGE.toFixed(2)}% may result in a failed transaction
</Trans>
) : (
<Trans>Your transaction may be frontrun and result in an unfavorable trade.</Trans>
)}
</ThemedText.Caption>
</RowBetween>
) : null}
</Expand>
)
}
import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import Radio from 'components/Radio'
import { RowBetween, RowFixed } from 'components/Row'
import Toggle from 'components/Toggle'
import { RouterPreference } from 'state/routing/slice'
import { useRouterPreference } from 'state/user/hooks'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
const Preference = styled(Radio)`
background-color: ${({ theme }) => theme.backgroundModule};
padding: 12px 16px;
`
const PreferencesContainer = styled(Column)`
gap: 1.5px;
border-radius: 12px;
overflow: hidden;
`
export default function RouterPreferenceSettings() {
const [routerPreference, setRouterPreference] = useRouterPreference()
const isAutoRoutingActive = routerPreference === RouterPreference.AUTO
return (
<Column gap="md">
<RowBetween gap="sm">
<RowFixed>
<Column gap="xs">
<ThemedText.BodySecondary>
<Trans>Auto Router API</Trans>
</ThemedText.BodySecondary>
<ThemedText.Caption color="textSecondary">
<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>
</ThemedText.Caption>
</Column>
</RowFixed>
<Toggle
id="toggle-optimized-router-button"
isActive={isAutoRoutingActive}
toggle={() => setRouterPreference(isAutoRoutingActive ? RouterPreference.API : RouterPreference.AUTO)}
/>
</RowBetween>
{!isAutoRoutingActive && (
<PreferencesContainer>
<Preference
isActive={routerPreference === RouterPreference.API}
toggle={() => setRouterPreference(RouterPreference.API)}
>
<Column gap="xs">
<ThemedText.BodyPrimary>
<Trans>Uniswap API</Trans>
</ThemedText.BodyPrimary>
<ThemedText.Caption color="textSecondary">
<Trans>Finds the best route on the Uniswap Protocol using the Uniswap Labs Routing API.</Trans>
</ThemedText.Caption>
</Column>
</Preference>
<Preference
isActive={routerPreference === RouterPreference.CLIENT}
toggle={() => setRouterPreference(RouterPreference.CLIENT)}
>
<Column gap="xs">
<ThemedText.BodyPrimary>
<Trans>Uniswap client</Trans>
</ThemedText.BodyPrimary>
<ThemedText.Caption color="textSecondary">
<Trans>
Finds the best route on the Uniswap Protocol through your browser. May result in high latency and
prices.
</Trans>
</ThemedText.Caption>
</Column>
</Preference>
</PreferencesContainer>
)}
</Column>
)
}
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import store from 'state'
import { updateUserDeadline } from 'state/user/reducer'
import { fireEvent, render, screen } from 'test-utils/render'
import TransactionDeadlineSettings from '.'
const renderAndExpandTransactionDeadlineSettings = () => {
render(<TransactionDeadlineSettings />)
// By default, the button to expand Slippage component and show `input` will have `<deadline>m` label
fireEvent.click(screen.getByText(`${DEFAULT_DEADLINE_FROM_NOW / 60}m`))
}
const getDeadlineInput = () => screen.getByTestId('deadline-input') as HTMLInputElement
describe('TransactionDeadlineSettings', () => {
describe('input', () => {
// Restore to default transaction deadline before each unit test
beforeEach(() => {
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW }))
})
it('does not render default deadline as a value, but a placeholder', () => {
renderAndExpandTransactionDeadlineSettings()
expect(getDeadlineInput().value).toBe('')
})
it('renders custom deadline above the input', () => {
renderAndExpandTransactionDeadlineSettings()
fireEvent.change(getDeadlineInput(), { target: { value: '50' } })
expect(screen.queryAllByText('50m').length).toEqual(1)
})
it('marks deadline as invalid if it is greater than 4320m (3 days) or 0m', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: '4321' } })
fireEvent.change(input, { target: { value: '0' } })
fireEvent.blur(input)
expect(input.value).toBe('')
})
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '4321' } })
// Label renders latest correct value, at this point input is higlighted as invalid
expect(screen.queryAllByText('5m').length).toEqual(1)
fireEvent.blur(input)
expect(input.value).toBe('5')
})
it('does not accept non-numerical values', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: 'c' } })
expect(input.value).toBe('')
})
})
})
import { Trans } from '@lingui/macro'
import Expand from 'components/Expand'
import QuestionHelper from 'components/QuestionHelper'
import Row from 'components/Row'
import { Input, InputContainer } from 'components/Settings/Input'
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import ms from 'ms.macro'
import React, { useState } from 'react'
import { useUserTransactionTTL } from 'state/user/hooks'
import { ThemedText } from 'theme'
enum DeadlineError {
InvalidInput = 'InvalidInput',
}
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
const NUMBERS_ONLY = /^[0-9\b]+$/
export default function TransactionDeadlineSettings() {
const [deadline, setDeadline] = useUserTransactionTTL()
const defaultInputValue = deadline && deadline !== DEFAULT_DEADLINE_FROM_NOW ? (deadline / 60).toString() : ''
// If user has previously entered a custom deadline, we want to show that value in the input field
// instead of a placeholder by defualt
const [deadlineInput, setDeadlineInput] = useState(defaultInputValue)
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
function parseCustomDeadline(value: string) {
// Do not allow non-numerical characters in the input field
if (value.length > 0 && !NUMBERS_ONLY.test(value)) {
return
}
setDeadlineInput(value)
setDeadlineError(false)
// If the input is empty, set the deadline to the default
if (value.length === 0) {
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
return
}
// Parse user input and set the deadline if valid, error otherwise
try {
const parsed: number = Number.parseInt(value) * 60
if (parsed === 0 || parsed > THREE_DAYS_IN_SECONDS) {
setDeadlineError(DeadlineError.InvalidInput)
} else {
setDeadline(parsed)
}
} catch (error) {
setDeadlineError(DeadlineError.InvalidInput)
}
}
return (
<Expand
testId="transaction-deadline-settings"
header={
<Row width="auto">
<ThemedText.BodySecondary>
<Trans>Transaction deadline</Trans>
</ThemedText.BodySecondary>
<QuestionHelper
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
/>
</Row>
}
button={<Trans>{deadline / 60}m</Trans>}
>
<Row>
<InputContainer gap="md" error={!!deadlineError}>
<Input
data-testid="deadline-input"
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
value={deadlineInput}
onChange={(e) => parseCustomDeadline(e.target.value)}
onBlur={() => {
// When the input field is blurred, reset the input field to the current deadline
setDeadlineInput(defaultInputValue)
setDeadlineError(false)
}}
/>
<ThemedText.BodyPrimary>
<Trans>minutes</Trans>
</ThemedText.BodyPrimary>
</InputContainer>
</Row>
</Expand>
)
}
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { t, Trans } from '@lingui/macro' import { t } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core' import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { sendEvent } from 'components/analytics' import { AutoColumn } from 'components/Column'
import { L2_CHAIN_IDS } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter' import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
import { useRef, useState } from 'react' import { useRef } from 'react'
import { Settings, X } from 'react-feather' import { Settings } from 'react-feather'
import { Text } from 'rebass' import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
import styled, { useTheme } from 'styled-components/macro' import { ApplicationModal } from 'state/application/reducer'
import styled from 'styled-components/macro'
import { useOnClickOutside } from '../../hooks/useOnClickOutside' import MaxSlippageSettings from './MaxSlippageSettings'
import { useModalIsOpen, useToggleSettingsMenu } from '../../state/application/hooks' import RouterPreferenceSettings from './RouterPreferenceSettings'
import { ApplicationModal } from '../../state/application/reducer' import TransactionDeadlineSettings from './TransactionDeadlineSettings'
import { useClientSideRouter, useExpertModeManager } from '../../state/user/hooks'
import { ThemedText } from '../../theme'
import { ButtonError } from '../Button'
import { AutoColumn } from '../Column'
import Modal from '../Modal'
import QuestionHelper from '../QuestionHelper'
import { RowBetween, RowFixed } from '../Row'
import Toggle from '../Toggle'
import TransactionSettings from '../TransactionSettings'
const StyledMenuIcon = styled(Settings)` const StyledMenuIcon = styled(Settings)`
height: 20px; height: 20px;
width: 20px; width: 20px;
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
`
const StyledCloseIcon = styled(X)`
height: 20px;
width: 20px;
:hover {
cursor: pointer;
}
> * { > * {
stroke: ${({ theme }) => theme.textSecondary}; stroke: ${({ theme }) => theme.textSecondary};
} }
...@@ -53,7 +34,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>` ...@@ -53,7 +34,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>`
padding: 0; padding: 0;
border-radius: 0.5rem; border-radius: 0.5rem;
height: 20px; height: 20px;
${({ disabled }) => ${({ disabled }) =>
!disabled && !disabled &&
` `
...@@ -65,12 +45,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>` ...@@ -65,12 +45,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>`
} }
`} `}
` `
const EmojiWrapper = styled.div`
position: absolute;
bottom: -6px;
right: 0px;
font-size: 14px;
`
const StyledMenu = styled.div` const StyledMenu = styled.div`
margin-left: 0.5rem; margin-left: 0.5rem;
...@@ -97,89 +71,32 @@ const MenuFlyout = styled.span` ...@@ -97,89 +71,32 @@ const MenuFlyout = styled.span`
right: 0rem; right: 0rem;
z-index: 100; z-index: 100;
color: ${({ theme }) => theme.textPrimary}; color: ${({ theme }) => theme.textPrimary};
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium` ${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
min-width: 18.125rem; min-width: 18.125rem;
`}; `};
user-select: none; user-select: none;
` `
const Break = styled.div` const Divider = styled.div`
width: 100%; width: 100%;
height: 1px; height: 1px;
background-color: ${({ theme }) => theme.deprecated_bg3}; border-width: 0;
` margin: 0;
background-color: ${({ theme }) => theme.backgroundOutline};
const ModalContentWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
background-color: ${({ theme }) => theme.backgroundInteractive};
border-radius: 20px;
` `
export default function SettingsTab({ placeholderSlippage }: { placeholderSlippage: Percent }) { export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
const { chainId } = useWeb3React() const { chainId } = useWeb3React()
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
const node = useRef<HTMLDivElement | null>(null) const node = useRef<HTMLDivElement | null>(null)
const open = useModalIsOpen(ApplicationModal.SETTINGS) const open = useModalIsOpen(ApplicationModal.SETTINGS)
const toggle = useToggleSettingsMenu()
const theme = useTheme()
const [expertMode, toggleExpertMode] = useExpertModeManager()
const [clientSideRouter, setClientSideRouter] = useClientSideRouter()
// show confirmation view before turning on
const [showConfirmation, setShowConfirmation] = useState(false)
const toggle = useToggleSettingsMenu()
useOnClickOutside(node, open ? toggle : undefined) useOnClickOutside(node, open ? toggle : undefined)
return ( return (
<StyledMenu ref={node}> <StyledMenu ref={node}>
<Modal isOpen={showConfirmation} onDismiss={() => setShowConfirmation(false)} maxHeight={100}>
<ModalContentWrapper>
<AutoColumn gap="lg">
<RowBetween style={{ padding: '0 2rem' }}>
<div />
<Text fontWeight={500} fontSize={20}>
<Trans>Are you sure?</Trans>
</Text>
<StyledCloseIcon onClick={() => setShowConfirmation(false)} />
</RowBetween>
<Break />
<AutoColumn gap="lg" style={{ padding: '0 2rem' }}>
<Text fontWeight={500} fontSize={20}>
<Trans>
Expert mode turns off the confirm transaction prompt and allows high slippage trades that often result
in bad rates and lost funds.
</Trans>
</Text>
<Text fontWeight={600} fontSize={20}>
<Trans>ONLY USE THIS MODE IF YOU KNOW WHAT YOU ARE DOING.</Trans>
</Text>
<ButtonError
error={true}
padding="12px"
onClick={() => {
const confirmWord = t`confirm`
if (window.prompt(t`Please type the word "${confirmWord}" to enable expert mode.`) === confirmWord) {
toggleExpertMode()
setShowConfirmation(false)
}
}}
>
<Text fontSize={20} fontWeight={500} id="confirm-expert-mode">
<Trans>Turn On Expert Mode</Trans>
</Text>
</ButtonError>
</AutoColumn>
</AutoColumn>
</ModalContentWrapper>
</Modal>
<StyledMenuButton <StyledMenuButton
disabled={!isSupportedChainId(chainId)} disabled={!isSupportedChainId(chainId)}
onClick={toggle} onClick={toggle}
...@@ -188,72 +105,19 @@ export default function SettingsTab({ placeholderSlippage }: { placeholderSlippa ...@@ -188,72 +105,19 @@ export default function SettingsTab({ placeholderSlippage }: { placeholderSlippa
aria-label={t`Transaction Settings`} aria-label={t`Transaction Settings`}
> >
<StyledMenuIcon data-testid="swap-settings-button" /> <StyledMenuIcon data-testid="swap-settings-button" />
{expertMode && (
<EmojiWrapper>
<span role="img" aria-label="wizard-icon">
🧙
</span>
</EmojiWrapper>
)}
</StyledMenuButton> </StyledMenuButton>
{open && ( {open && (
<MenuFlyout> <MenuFlyout>
<AutoColumn gap="md" style={{ padding: '1rem' }}> <AutoColumn gap="16px" style={{ padding: '1rem' }}>
<Text fontWeight={600} fontSize={14}> {isSupportedChainId(chainId) && <RouterPreferenceSettings />}
<Trans>Settings</Trans> <Divider />
</Text> <MaxSlippageSettings autoSlippage={autoSlippage} />
<TransactionSettings placeholderSlippage={placeholderSlippage} /> {showDeadlineSettings && (
<Text fontWeight={600} fontSize={14}> <>
<Trans>Interface Settings</Trans> <Divider />
</Text> <TransactionDeadlineSettings />
{isSupportedChainId(chainId) && ( </>
<RowBetween>
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Auto Router API</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper text={<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>} />
</RowFixed>
<Toggle
id="toggle-optimized-router-button"
isActive={!clientSideRouter}
toggle={() => {
sendEvent({
category: 'Routing',
action: clientSideRouter ? 'enable routing API' : 'disable routing API',
})
setClientSideRouter(!clientSideRouter)
}}
/>
</RowBetween>
)} )}
<RowBetween>
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Expert Mode</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={
<Trans>Allow high price impact trades and skip the confirm screen. Use at your own risk.</Trans>
}
/>
</RowFixed>
<Toggle
id="toggle-expert-mode-button"
isActive={expertMode}
toggle={
expertMode
? () => {
toggleExpertMode()
setShowConfirmation(false)
}
: () => {
toggle()
setShowConfirmation(true)
}
}
/>
</RowBetween>
</AutoColumn> </AutoColumn>
</MenuFlyout> </MenuFlyout>
)} )}
......
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { L2_CHAIN_IDS } from 'constants/chains'
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import ms from 'ms.macro'
import { darken } from 'polished'
import { useState } from 'react'
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from '../../theme'
import { AutoColumn } from '../Column'
import QuestionHelper from '../QuestionHelper'
import { RowBetween, RowFixed } from '../Row'
enum SlippageError {
InvalidInput = 'InvalidInput',
}
enum DeadlineError {
InvalidInput = 'InvalidInput',
}
const FancyButton = styled.button`
color: ${({ theme }) => theme.textPrimary};
align-items: center;
height: 2rem;
border-radius: 36px;
font-size: 1rem;
width: auto;
min-width: 3.5rem;
border: 1px solid ${({ theme }) => theme.deprecated_bg3};
outline: none;
background: ${({ theme }) => theme.deprecated_bg1};
:hover {
border: 1px solid ${({ theme }) => theme.deprecated_bg4};
}
:focus {
border: 1px solid ${({ theme }) => theme.accentAction};
}
`
const Option = styled(FancyButton)<{ active: boolean }>`
margin-right: 8px;
border-radius: 12px;
:hover {
cursor: pointer;
}
background-color: ${({ active, theme }) => active && theme.accentAction};
color: ${({ active, theme }) => (active ? theme.white : theme.textPrimary)};
`
const Input = styled.input`
background: ${({ theme }) => theme.deprecated_bg1};
font-size: 16px;
border-radius: 12px;
width: auto;
outline: none;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
color: ${({ theme, color }) => (color === 'red' ? theme.accentFailure : theme.textPrimary)};
text-align: right;
::placeholder {
color: ${({ theme }) => theme.textTertiary};
}
`
const OptionCustom = styled(FancyButton)<{ active?: boolean; warning?: boolean }>`
height: 2rem;
position: relative;
padding: 0 0.75rem;
border-radius: 12px;
flex: 1;
border: ${({ theme, active, warning }) =>
active
? `1px solid ${warning ? theme.accentFailure : theme.accentAction}`
: warning && `1px solid ${theme.accentFailure}`};
:hover {
border: ${({ theme, active, warning }) =>
active && `1px solid ${warning ? darken(0.1, theme.accentFailure) : darken(0.1, theme.accentAction)}`};
}
input {
width: 100%;
height: 100%;
border: 0px;
border-radius: 2rem;
}
`
const SlippageEmojiContainer = styled.span`
color: #f3841e;
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToSmall`
display: none;
`}
`
interface TransactionSettingsProps {
placeholderSlippage: Percent // varies according to the context in which the settings dialog is placed
}
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
export default function TransactionSettings({ placeholderSlippage }: TransactionSettingsProps) {
const { chainId } = useWeb3React()
const theme = useTheme()
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
const [deadline, setDeadline] = useUserTransactionTTL()
const [slippageInput, setSlippageInput] = useState('')
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
const [deadlineInput, setDeadlineInput] = useState('')
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
function parseSlippageInput(value: string) {
// populate what the user typed and clear the error
setSlippageInput(value)
setSlippageError(false)
if (value.length === 0) {
setUserSlippageTolerance('auto')
} else {
const parsed = Math.floor(Number.parseFloat(value) * 100)
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 5000) {
setUserSlippageTolerance('auto')
if (value !== '.') {
setSlippageError(SlippageError.InvalidInput)
}
} else {
setUserSlippageTolerance(new Percent(parsed, 10_000))
}
}
}
const tooLow = userSlippageTolerance !== 'auto' && userSlippageTolerance.lessThan(new Percent(5, 10_000))
const tooHigh = userSlippageTolerance !== 'auto' && userSlippageTolerance.greaterThan(new Percent(1, 100))
function parseCustomDeadline(value: string) {
// populate what the user typed and clear the error
setDeadlineInput(value)
setDeadlineError(false)
if (value.length === 0) {
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
} else {
try {
const parsed: number = Math.floor(Number.parseFloat(value) * 60)
if (!Number.isInteger(parsed) || parsed < 60 || parsed > THREE_DAYS_IN_SECONDS) {
setDeadlineError(DeadlineError.InvalidInput)
} else {
setDeadline(parsed)
}
} catch (error) {
console.error(error)
setDeadlineError(DeadlineError.InvalidInput)
}
}
}
const showCustomDeadlineRow = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
return (
<AutoColumn gap="md">
<AutoColumn gap="sm">
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Slippage tolerance</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
}
/>
</RowFixed>
<RowBetween>
<Option
onClick={() => {
parseSlippageInput('')
}}
active={userSlippageTolerance === 'auto'}
>
<Trans>Auto</Trans>
</Option>
<OptionCustom active={userSlippageTolerance !== 'auto'} warning={!!slippageError} tabIndex={-1}>
<RowBetween>
{tooLow || tooHigh ? (
<SlippageEmojiContainer>
<span role="img" aria-label="warning">
⚠️
</span>
</SlippageEmojiContainer>
) : null}
<Input
data-testid="slippage-input"
placeholder={placeholderSlippage.toFixed(2)}
value={
slippageInput.length > 0
? slippageInput
: userSlippageTolerance === 'auto'
? ''
: userSlippageTolerance.toFixed(2)
}
onChange={(e) => parseSlippageInput(e.target.value)}
onBlur={() => {
setSlippageInput('')
setSlippageError(false)
}}
color={slippageError ? 'red' : ''}
/>
%
</RowBetween>
</OptionCustom>
</RowBetween>
{slippageError || tooLow || tooHigh ? (
<RowBetween
style={{
fontSize: '14px',
paddingTop: '7px',
color: slippageError ? 'red' : '#F3841E',
}}
>
{slippageError ? (
<Trans>Enter a valid slippage percentage</Trans>
) : tooLow ? (
<Trans>Your transaction may fail</Trans>
) : (
<Trans>Your transaction may be frontrun</Trans>
)}
</RowBetween>
) : null}
</AutoColumn>
{showCustomDeadlineRow && (
<AutoColumn gap="sm">
<RowFixed>
<ThemedText.DeprecatedBlack fontSize={14} fontWeight={400} color={theme.textSecondary}>
<Trans>Transaction deadline</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
/>
</RowFixed>
<RowFixed>
<OptionCustom style={{ width: '80px' }} warning={!!deadlineError} tabIndex={-1}>
<Input
data-testid="deadline-input"
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
value={
deadlineInput.length > 0
? deadlineInput
: deadline === DEFAULT_DEADLINE_FROM_NOW
? ''
: (deadline / 60).toString()
}
onChange={(e) => parseCustomDeadline(e.target.value)}
onBlur={() => {
setDeadlineInput('')
setDeadlineError(false)
}}
color={deadlineError ? 'red' : ''}
/>
</OptionCustom>
<ThemedText.DeprecatedBody style={{ paddingLeft: '8px' }} fontSize={14}>
<Trans>minutes</Trans>
</ThemedText.DeprecatedBody>
</RowFixed>
</AutoColumn>
)}
</AutoColumn>
)
}
...@@ -3,6 +3,7 @@ import { RouterPreference, Slippage, SwapController, SwapEventHandlers } from '@ ...@@ -3,6 +3,7 @@ import { RouterPreference, Slippage, SwapController, SwapEventHandlers } from '@
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc' import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks' import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
/** /**
* Integrates the Widget's settings, keeping the widget and app settings in sync. * Integrates the Widget's settings, keeping the widget and app settings in sync.
...@@ -23,13 +24,13 @@ export function useSyncWidgetSettings() { ...@@ -23,13 +24,13 @@ export function useSyncWidgetSettings() {
const [appSlippage, setAppSlippage] = useUserSlippageTolerance() const [appSlippage, setAppSlippage] = useUserSlippageTolerance()
const [widgetSlippage, setWidgetSlippage] = useState<string | undefined>( const [widgetSlippage, setWidgetSlippage] = useState<string | undefined>(
appSlippage === 'auto' ? undefined : appSlippage.toFixed(2) appSlippage === SlippageTolerance.Auto ? undefined : appSlippage.toFixed(2)
) )
const onSlippageChange = useCallback( const onSlippageChange = useCallback(
(widgetSlippage: Slippage) => { (widgetSlippage: Slippage) => {
setWidgetSlippage(widgetSlippage.max) setWidgetSlippage(widgetSlippage.max)
if (widgetSlippage.auto || !widgetSlippage.max) { if (widgetSlippage.auto || !widgetSlippage.max) {
setAppSlippage('auto') setAppSlippage(SlippageTolerance.Auto)
} else { } else {
setAppSlippage(new Percent(Math.floor(Number(widgetSlippage.max) * 100), 10_000)) setAppSlippage(new Percent(Math.floor(Number(widgetSlippage.max) * 100), 10_000))
} }
...@@ -43,11 +44,11 @@ export function useSyncWidgetSettings() { ...@@ -43,11 +44,11 @@ export function useSyncWidgetSettings() {
setWidgetTtl(undefined) setWidgetTtl(undefined)
setAppTtl(DEFAULT_DEADLINE_FROM_NOW) setAppTtl(DEFAULT_DEADLINE_FROM_NOW)
setWidgetSlippage(undefined) setWidgetSlippage(undefined)
setAppSlippage('auto') setAppSlippage(SlippageTolerance.Auto)
}, [setAppSlippage, setAppTtl]) }, [setAppSlippage, setAppTtl])
const settings: SwapController['settings'] = useMemo(() => { const settings: SwapController['settings'] = useMemo(() => {
const auto = appSlippage === 'auto' const auto = appSlippage === SlippageTolerance.Auto
return { return {
slippage: { auto, max: widgetSlippage }, slippage: { auto, max: widgetSlippage },
transactionTtl: widgetTtl, transactionTtl: widgetTtl,
......
...@@ -25,7 +25,7 @@ const TextHeader = styled.div` ...@@ -25,7 +25,7 @@ const TextHeader = styled.div`
align-items: center; align-items: center;
` `
export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Percent }) { export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
const fiatOnRampButtonEnabled = useFiatOnRampButtonEnabled() const fiatOnRampButtonEnabled = useFiatOnRampButtonEnabled()
return ( return (
...@@ -38,7 +38,7 @@ export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Perce ...@@ -38,7 +38,7 @@ export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Perce
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />} {fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed> </RowFixed>
<RowFixed> <RowFixed>
<SettingsTab placeholderSlippage={allowedSlippage} /> <SettingsTab autoSlippage={autoSlippage} />
</RowFixed> </RowFixed>
</RowBetween> </RowBetween>
</StyledSwapHeader> </StyledSwapHeader>
......
...@@ -13,8 +13,9 @@ import { ...@@ -13,8 +13,9 @@ import {
} from 'lib/utils/analytics' } from 'lib/utils/analytics'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Text } from 'rebass' import { Text } from 'rebass'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade } from 'state/routing/types' import { InterfaceTrade } from 'state/routing/types'
import { useClientSideRouter, useUserSlippageTolerance } from 'state/user/hooks' import { useRouterPreference, useUserSlippageTolerance } from 'state/user/hooks'
import { computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedPriceImpact } from 'utils/prices'
import { ButtonError } from '../Button' import { ButtonError } from '../Button'
...@@ -123,7 +124,7 @@ export default function SwapModalFooter({ ...@@ -123,7 +124,7 @@ export default function SwapModalFooter({
}) { }) {
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto' const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
const [clientSideRouter] = useClientSideRouter() const [routerPreference] = useRouterPreference()
const routes = getTokenPath(trade) const routes = getTokenPath(trade)
return ( return (
...@@ -139,7 +140,7 @@ export default function SwapModalFooter({ ...@@ -139,7 +140,7 @@ export default function SwapModalFooter({
allowedSlippage, allowedSlippage,
transactionDeadlineSecondsSinceEpoch, transactionDeadlineSecondsSinceEpoch,
isAutoSlippage, isAutoSlippage,
isAutoRouterApi: !clientSideRouter, isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
swapQuoteReceivedDate, swapQuoteReceivedDate,
routes, routes,
fiatValueInput: fiatValueInput.data, fiatValueInput: fiatValueInput.data,
......
...@@ -3,6 +3,8 @@ import JSBI from 'jsbi' ...@@ -3,6 +3,8 @@ import JSBI from 'jsbi'
export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
// TODO(WEB-3290): Convert the deadline to minutes and remove unecessary conversions from
// seconds to minutes in the codebase.
// 30 minutes, denominated in seconds // 30 minutes, denominated in seconds
export const DEFAULT_DEADLINE_FROM_NOW = 60 * 30 export const DEFAULT_DEADLINE_FROM_NOW = 60 * 30
export const L2_DEADLINE_FROM_NOW = 60 * 5 export const L2_DEADLINE_FROM_NOW = 60 * 5
......
...@@ -3,7 +3,7 @@ import { CurrencyAmount, TradeType } from '@uniswap/sdk-core' ...@@ -3,7 +3,7 @@ import { CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET } from 'constants/tokens' import { DAI, USDC_MAINNET } from 'constants/tokens'
import { RouterPreference } from 'state/routing/slice' import { RouterPreference } from 'state/routing/slice'
import { TradeState } from 'state/routing/types' import { TradeState } from 'state/routing/types'
import { useClientSideRouter } from 'state/user/hooks' import { useRouterPreference } from 'state/user/hooks'
import { mocked } from 'test-utils/mocked' import { mocked } from 'test-utils/mocked'
import { useRoutingAPITrade } from '../state/routing/useRoutingAPITrade' import { useRoutingAPITrade } from '../state/routing/useRoutingAPITrade'
...@@ -38,7 +38,7 @@ beforeEach(() => { ...@@ -38,7 +38,7 @@ beforeEach(() => {
mocked(useIsWindowVisible).mockReturnValue(true) mocked(useIsWindowVisible).mockReturnValue(true)
mocked(useAutoRouterSupported).mockReturnValue(true) mocked(useAutoRouterSupported).mockReturnValue(true)
mocked(useClientSideRouter).mockReturnValue([true, () => undefined]) mocked(useRouterPreference).mockReturnValue([RouterPreference.CLIENT, () => undefined])
}) })
describe('#useBestV3Trade ExactIn', () => { describe('#useBestV3Trade ExactIn', () => {
......
...@@ -2,10 +2,9 @@ import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' ...@@ -2,10 +2,9 @@ import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { useMemo } from 'react' import { useMemo } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade, TradeState } from 'state/routing/types' import { InterfaceTrade, TradeState } from 'state/routing/types'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade' import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { useClientSideRouter } from 'state/user/hooks' import { useRouterPreference } from 'state/user/hooks'
import useAutoRouterSupported from './useAutoRouterSupported' import useAutoRouterSupported from './useAutoRouterSupported'
import { useClientSideV3Trade } from './useClientSideV3Trade' import { useClientSideV3Trade } from './useClientSideV3Trade'
...@@ -46,12 +45,12 @@ export function useBestTrade( ...@@ -46,12 +45,12 @@ export function useBestTrade(
const shouldGetTrade = !isAWrapTransaction && isWindowVisible const shouldGetTrade = !isAWrapTransaction && isWindowVisible
const [clientSideRouter] = useClientSideRouter() const [routerPreference] = useRouterPreference()
const routingAPITrade = useRoutingAPITrade( const routingAPITrade = useRoutingAPITrade(
tradeType, tradeType,
autoRouterSupported && shouldGetTrade ? debouncedAmount : undefined, autoRouterSupported && shouldGetTrade ? debouncedAmount : undefined,
debouncedOtherCurrency, debouncedOtherCurrency,
clientSideRouter ? RouterPreference.CLIENT : RouterPreference.API routerPreference
) )
const isLoading = routingAPITrade.state === TradeState.LOADING const isLoading = routingAPITrade.state === TradeState.LOADING
......
...@@ -607,7 +607,7 @@ function AddLiquidity() { ...@@ -607,7 +607,7 @@ function AddLiquidity() {
creating={false} creating={false}
adding={true} adding={true}
positionID={tokenId} positionID={tokenId}
defaultSlippage={DEFAULT_ADD_IN_RANGE_SLIPPAGE_TOLERANCE} autoSlippage={DEFAULT_ADD_IN_RANGE_SLIPPAGE_TOLERANCE}
showBackLink={!hasExistingPosition} showBackLink={!hasExistingPosition}
> >
{!hasExistingPosition && ( {!hasExistingPosition && (
......
...@@ -323,7 +323,7 @@ export default function AddLiquidity() { ...@@ -323,7 +323,7 @@ export default function AddLiquidity() {
return ( return (
<> <>
<AppBody> <AppBody>
<AddRemoveTabs creating={isCreate} adding={true} defaultSlippage={DEFAULT_ADD_V2_SLIPPAGE_TOLERANCE} /> <AddRemoveTabs creating={isCreate} adding={true} autoSlippage={DEFAULT_ADD_V2_SLIPPAGE_TOLERANCE} />
<Wrapper> <Wrapper>
<TransactionConfirmationModal <TransactionConfirmationModal
isOpen={showConfirm} isOpen={showConfirm}
......
...@@ -729,7 +729,7 @@ export default function MigrateV2Pair() { ...@@ -729,7 +729,7 @@ export default function MigrateV2Pair() {
<ThemedText.DeprecatedMediumHeader> <ThemedText.DeprecatedMediumHeader>
<Trans>Migrate V2 Liquidity</Trans> <Trans>Migrate V2 Liquidity</Trans>
</ThemedText.DeprecatedMediumHeader> </ThemedText.DeprecatedMediumHeader>
<SettingsTab placeholderSlippage={DEFAULT_MIGRATE_SLIPPAGE_TOLERANCE} /> <SettingsTab autoSlippage={DEFAULT_MIGRATE_SLIPPAGE_TOLERANCE} />
</AutoRow> </AutoRow>
{!account ? ( {!account ? (
......
...@@ -297,7 +297,7 @@ function Remove({ tokenId }: { tokenId: BigNumber }) { ...@@ -297,7 +297,7 @@ function Remove({ tokenId }: { tokenId: BigNumber }) {
creating={false} creating={false}
adding={false} adding={false}
positionID={tokenId.toString()} positionID={tokenId.toString()}
defaultSlippage={DEFAULT_REMOVE_V3_LIQUIDITY_SLIPPAGE_TOLERANCE} autoSlippage={DEFAULT_REMOVE_V3_LIQUIDITY_SLIPPAGE_TOLERANCE}
/> />
<Wrapper> <Wrapper>
{position ? ( {position ? (
......
...@@ -442,7 +442,7 @@ function RemoveLiquidity() { ...@@ -442,7 +442,7 @@ function RemoveLiquidity() {
return ( return (
<> <>
<AppBody> <AppBody>
<AddRemoveTabs creating={false} adding={false} defaultSlippage={DEFAULT_REMOVE_LIQUIDITY_SLIPPAGE_TOLERANCE} /> <AddRemoveTabs creating={false} adding={false} autoSlippage={DEFAULT_REMOVE_LIQUIDITY_SLIPPAGE_TOLERANCE} />
<Wrapper> <Wrapper>
<TransactionConfirmationModal <TransactionConfirmationModal
isOpen={showConfirm} isOpen={showConfirm}
......
...@@ -271,6 +271,7 @@ export function Swap({ ...@@ -271,6 +271,7 @@ export function Swap({
const { const {
trade: { state: tradeState, trade }, trade: { state: tradeState, trade },
allowedSlippage, allowedSlippage,
autoSlippage,
currencyBalances, currencyBalances,
parsedAmount, parsedAmount,
currencies, currencies,
...@@ -579,7 +580,7 @@ export function Swap({ ...@@ -579,7 +580,7 @@ export function Swap({
onCancel={handleDismissTokenWarning} onCancel={handleDismissTokenWarning}
showCancel={true} showCancel={true}
/> />
<SwapHeader allowedSlippage={allowedSlippage} /> <SwapHeader autoSlippage={autoSlippage} />
<ConfirmSwapModal <ConfirmSwapModal
isOpen={showConfirm} isOpen={showConfirm}
trade={trade} trade={trade}
......
...@@ -38,7 +38,7 @@ export const sentryEnhancer = Sentry.createReduxEnhancer({ ...@@ -38,7 +38,7 @@ export const sentryEnhancer = Sentry.createReduxEnhancer({
lastUpdateVersionTimestamp: user.lastUpdateVersionTimestamp, lastUpdateVersionTimestamp: user.lastUpdateVersionTimestamp,
userLocale: user.userLocale, userLocale: user.userLocale,
userExpertMode: user.userExpertMode, userExpertMode: user.userExpertMode,
userClientSideRouter: user.userClientSideRouter, userRouterPreference: user.userRouterPreference,
userHideClosedPositions: user.userHideClosedPositions, userHideClosedPositions: user.userHideClosedPositions,
userSlippageTolerance: user.userSlippageTolerance, userSlippageTolerance: user.userSlippageTolerance,
userSlippageToleranceHasBeenMigratedToAuto: user.userSlippageToleranceHasBeenMigratedToAuto, userSlippageToleranceHasBeenMigratedToAuto: user.userSlippageToleranceHasBeenMigratedToAuto,
......
...@@ -10,8 +10,11 @@ import { trace } from 'tracing/trace' ...@@ -10,8 +10,11 @@ import { trace } from 'tracing/trace'
import { GetQuoteResult } from './types' import { GetQuoteResult } from './types'
export enum RouterPreference { export enum RouterPreference {
AUTO = 'auto',
API = 'api', API = 'api',
CLIENT = 'client', CLIENT = 'client',
// Used internally for token -> USDC trades to get a USD value.
PRICE = 'price', PRICE = 'price',
} }
...@@ -108,7 +111,8 @@ export const routingApi = createApi({ ...@@ -108,7 +111,8 @@ export const routingApi = createApi({
data: { data: {
...args, ...args,
isPrice: args.routerPreference === RouterPreference.PRICE, isPrice: args.routerPreference === RouterPreference.PRICE,
isAutoRouter: args.routerPreference === RouterPreference.API, isAutoRouter:
args.routerPreference === RouterPreference.AUTO || args.routerPreference === RouterPreference.API,
}, },
tags: { is_widget: false }, tags: { is_widget: false },
} }
......
...@@ -85,6 +85,7 @@ export function useDerivedSwapInfo( ...@@ -85,6 +85,7 @@ export function useDerivedSwapInfo(
state: TradeState state: TradeState
} }
allowedSlippage: Percent allowedSlippage: Percent
autoSlippage: Percent
} { } {
const { account } = useWeb3React() const { account } = useWeb3React()
...@@ -135,8 +136,8 @@ export function useDerivedSwapInfo( ...@@ -135,8 +136,8 @@ export function useDerivedSwapInfo(
) )
// allowed slippage is either auto slippage, or custom user defined slippage if auto slippage disabled // allowed slippage is either auto slippage, or custom user defined slippage if auto slippage disabled
const autoSlippageTolerance = useAutoSlippageTolerance(trade.trade) const autoSlippage = useAutoSlippageTolerance(trade.trade)
const allowedSlippage = useUserSlippageToleranceWithDefault(autoSlippageTolerance) const allowedSlippage = useUserSlippageToleranceWithDefault(autoSlippage)
const inputError = useMemo(() => { const inputError = useMemo(() => {
let inputError: ReactNode | undefined let inputError: ReactNode | undefined
...@@ -179,9 +180,10 @@ export function useDerivedSwapInfo( ...@@ -179,9 +180,10 @@ export function useDerivedSwapInfo(
parsedAmount, parsedAmount,
inputError, inputError,
trade, trade,
autoSlippage,
allowedSlippage, allowedSlippage,
}), }),
[allowedSlippage, currencies, currencyBalances, inputError, parsedAmount, trade] [allowedSlippage, autoSlippage, currencies, currencyBalances, inputError, parsedAmount, trade]
) )
} }
......
import { act } from '@testing-library/react'
import { Percent } from '@uniswap/sdk-core'
import { USDC_MAINNET } from 'constants/tokens' import { USDC_MAINNET } from 'constants/tokens'
import store from 'state'
import { RouterPreference } from 'state/routing/slice'
import { renderHook } from 'test-utils/render'
import { deserializeToken, serializeToken } from './hooks' import { deserializeToken, serializeToken, useRouterPreference, useUserSlippageTolerance } from './hooks'
import { updateUserSlippageTolerance } from './reducer'
import { SlippageTolerance } from './types'
describe('serializeToken', () => { describe('serializeToken', () => {
it('serializes the token', () => { it('serializes the token', () => {
...@@ -19,3 +26,52 @@ describe('deserializeToken', () => { ...@@ -19,3 +26,52 @@ describe('deserializeToken', () => {
expect(deserializeToken(serializeToken(USDC_MAINNET))).toEqual(USDC_MAINNET) expect(deserializeToken(serializeToken(USDC_MAINNET))).toEqual(USDC_MAINNET)
}) })
}) })
describe('useUserSlippageTolerance', () => {
it('returns `auto` when user has not set a custom slippage', () => {
const {
result: {
current: [slippage],
},
} = renderHook(() => useUserSlippageTolerance())
expect(slippage).toEqual(SlippageTolerance.Auto)
})
it('returns `Percent` when user has set a custom slippage', () => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: 50 }))
const {
result: {
current: [slippage],
},
} = renderHook(() => useUserSlippageTolerance())
expect(slippage).toBeInstanceOf(Percent)
})
it('stores default slippage as `auto`', () => {
const {
result: {
current: [, setSlippage],
},
} = renderHook(() => useUserSlippageTolerance())
act(() => setSlippage(SlippageTolerance.Auto))
expect(store.getState().user.userSlippageTolerance).toBe(SlippageTolerance.Auto)
})
it('stores custom slippage as `number`', () => {
const {
result: {
current: [, setSlippage],
},
} = renderHook(() => useUserSlippageTolerance())
act(() => setSlippage(new Percent(5, 10_000)))
expect(store.getState().user.userSlippageTolerance).toBe(5)
})
})
describe('useRouterPreference', () => {
it('returns `auto` by default', () => {
const {
result: {
current: [routerPreference],
},
} = renderHook(() => useRouterPreference())
expect(routerPreference).toBe(RouterPreference.AUTO)
})
})
...@@ -7,6 +7,7 @@ import { L2_DEADLINE_FROM_NOW } from 'constants/misc' ...@@ -7,6 +7,7 @@ import { L2_DEADLINE_FROM_NOW } from 'constants/misc'
import JSBI from 'jsbi' import JSBI from 'jsbi'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks' import { useAppDispatch, useAppSelector } from 'state/hooks'
import { RouterPreference } from 'state/routing/slice'
import { UserAddedToken } from 'types/tokens' import { UserAddedToken } from 'types/tokens'
import { V2_FACTORY_ADDRESSES } from '../../constants/addresses' import { V2_FACTORY_ADDRESSES } from '../../constants/addresses'
...@@ -19,13 +20,13 @@ import { ...@@ -19,13 +20,13 @@ import {
updateHideClosedPositions, updateHideClosedPositions,
updateHideUniswapWalletBanner, updateHideUniswapWalletBanner,
updateUserBuyFiatFlowCompleted, updateUserBuyFiatFlowCompleted,
updateUserClientSideRouter,
updateUserDeadline, updateUserDeadline,
updateUserExpertMode, updateUserExpertMode,
updateUserLocale, updateUserLocale,
updateUserRouterPreference,
updateUserSlippageTolerance, updateUserSlippageTolerance,
} from './reducer' } from './reducer'
import { SerializedPair, SerializedToken } from './types' import { SerializedPair, SerializedToken, SlippageTolerance } from './types'
export function serializeToken(token: Token): SerializedToken { export function serializeToken(token: Token): SerializedToken {
return { return {
...@@ -92,42 +93,52 @@ export function useExpertModeManager(): [boolean, () => void] { ...@@ -92,42 +93,52 @@ export function useExpertModeManager(): [boolean, () => void] {
return [expertMode, toggleSetExpertMode] return [expertMode, toggleSetExpertMode]
} }
export function useClientSideRouter(): [boolean, (userClientSideRouter: boolean) => void] { export function useRouterPreference(): [RouterPreference, (routerPreference: RouterPreference) => void] {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const clientSideRouter = useAppSelector((state) => Boolean(state.user.userClientSideRouter)) const routerPreference = useAppSelector((state) => state.user.userRouterPreference)
const setClientSideRouter = useCallback( const setRouterPreference = useCallback(
(newClientSideRouter: boolean) => { (newRouterPreference: RouterPreference) => {
dispatch(updateUserClientSideRouter({ userClientSideRouter: newClientSideRouter })) dispatch(updateUserRouterPreference({ userRouterPreference: newRouterPreference }))
}, },
[dispatch] [dispatch]
) )
return [clientSideRouter, setClientSideRouter] return [routerPreference, setRouterPreference]
} }
/** /**
* Return the user's slippage tolerance, from the redux store, and a function to update the slippage tolerance * Return the user's slippage tolerance, from the redux store, and a function to update the slippage tolerance
*/ */
export function useUserSlippageTolerance(): [Percent | 'auto', (slippageTolerance: Percent | 'auto') => void] { export function useUserSlippageTolerance(): [
Percent | SlippageTolerance.Auto,
(slippageTolerance: Percent | SlippageTolerance.Auto) => void
] {
const userSlippageToleranceRaw = useAppSelector((state) => { const userSlippageToleranceRaw = useAppSelector((state) => {
return state.user.userSlippageTolerance return state.user.userSlippageTolerance
}) })
// TODO(WEB-3291): Keep `userSlippageTolerance` as Percent in Redux store and remove this conversion
const userSlippageTolerance = useMemo( const userSlippageTolerance = useMemo(
() => (userSlippageToleranceRaw === 'auto' ? 'auto' : new Percent(userSlippageToleranceRaw, 10_000)), () =>
userSlippageToleranceRaw === SlippageTolerance.Auto
? SlippageTolerance.Auto
: new Percent(userSlippageToleranceRaw, 10_000),
[userSlippageToleranceRaw] [userSlippageToleranceRaw]
) )
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const setUserSlippageTolerance = useCallback( const setUserSlippageTolerance = useCallback(
(userSlippageTolerance: Percent | 'auto') => { (userSlippageTolerance: Percent | SlippageTolerance.Auto) => {
let value: 'auto' | number let value: SlippageTolerance.Auto | number
try { try {
value = value =
userSlippageTolerance === 'auto' ? 'auto' : JSBI.toNumber(userSlippageTolerance.multiply(10_000).quotient) userSlippageTolerance === SlippageTolerance.Auto
? SlippageTolerance.Auto
: JSBI.toNumber(userSlippageTolerance.multiply(10_000).quotient)
} catch (error) { } catch (error) {
value = 'auto' value = SlippageTolerance.Auto
} }
dispatch( dispatch(
updateUserSlippageTolerance({ updateUserSlippageTolerance({
...@@ -166,7 +177,7 @@ export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions: ...@@ -166,7 +177,7 @@ export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions:
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent { export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
const allowedSlippage = useUserSlippageTolerance()[0] const allowedSlippage = useUserSlippageTolerance()[0]
return useMemo( return useMemo(
() => (allowedSlippage === 'auto' ? defaultSlippageTolerance : allowedSlippage), () => (allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage),
[allowedSlippage, defaultSlippageTolerance] [allowedSlippage, defaultSlippageTolerance]
) )
} }
......
import { createStore, Store } from 'redux' import { createStore, Store } from 'redux'
import { RouterPreference } from 'state/routing/slice'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { updateVersion } from '../global/actions' import { updateVersion } from '../global/actions'
...@@ -9,13 +10,14 @@ import reducer, { ...@@ -9,13 +10,14 @@ import reducer, {
updateHideClosedPositions, updateHideClosedPositions,
updateHideUniswapWalletBanner, updateHideUniswapWalletBanner,
updateSelectedWallet, updateSelectedWallet,
updateUserClientSideRouter,
updateUserDeadline, updateUserDeadline,
updateUserExpertMode, updateUserExpertMode,
updateUserLocale, updateUserLocale,
updateUserRouterPreference,
updateUserSlippageTolerance, updateUserSlippageTolerance,
UserState, UserState,
} from './reducer' } from './reducer'
import { SlippageTolerance } from './types'
function buildSerializedPair(token0Address: string, token1Address: string, chainId: number) { function buildSerializedPair(token0Address: string, token1Address: string, chainId: number) {
return { return {
...@@ -54,7 +56,7 @@ describe('swap reducer', () => { ...@@ -54,7 +56,7 @@ describe('swap reducer', () => {
} as any) } as any)
store.dispatch(updateVersion()) store.dispatch(updateVersion())
expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW) expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW)
expect(store.getState().userSlippageTolerance).toEqual('auto') expect(store.getState().userSlippageTolerance).toEqual(SlippageTolerance.Auto)
}) })
it('sets allowed slippage and deadline to auto', () => { it('sets allowed slippage and deadline to auto', () => {
store = createStore(reducer, { store = createStore(reducer, {
...@@ -102,10 +104,10 @@ describe('swap reducer', () => { ...@@ -102,10 +104,10 @@ describe('swap reducer', () => {
}) })
}) })
describe('updateUserClientSideRouter', () => { describe('updateRouterPreference', () => {
it('updates the userClientSideRouter', () => { it('updates the routerPreference', () => {
store.dispatch(updateUserClientSideRouter({ userClientSideRouter: true })) store.dispatch(updateUserRouterPreference({ userRouterPreference: RouterPreference.API }))
expect(store.getState().userClientSideRouter).toEqual(true) expect(store.getState().userRouterPreference).toEqual(RouterPreference.API)
}) })
}) })
......
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection/types' import { ConnectionType } from 'connection/types'
import { SupportedLocale } from 'constants/locales' import { SupportedLocale } from 'constants/locales'
import { RouterPreference } from 'state/routing/slice'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { updateVersion } from '../global/actions' import { updateVersion } from '../global/actions'
import { SerializedPair, SerializedToken } from './types' import { SerializedPair, SerializedToken, SlippageTolerance } from './types'
const currentTimestamp = () => new Date().getTime() const currentTimestamp = () => new Date().getTime()
...@@ -20,14 +21,17 @@ export interface UserState { ...@@ -20,14 +21,17 @@ export interface UserState {
userExpertMode: boolean userExpertMode: boolean
userClientSideRouter: boolean // whether routes should be calculated with the client side router only // which router should be used to calculate trades
userRouterPreference: RouterPreference
// hides closed (inactive) positions across the app // hides closed (inactive) positions across the app
userHideClosedPositions: boolean userHideClosedPositions: boolean
// user defined slippage tolerance in bips, used in all txns // user defined slippage tolerance in bips, used in all txns
userSlippageTolerance: number | 'auto' userSlippageTolerance: number | SlippageTolerance.Auto
userSlippageToleranceHasBeenMigratedToAuto: boolean // temporary flag for migration status
// flag to indicate whether the user has been migrated from the old slippage tolerance values
userSlippageToleranceHasBeenMigratedToAuto: boolean
// deadline set by user in minutes, used in all txns // deadline set by user in minutes, used in all txns
userDeadline: number userDeadline: number
...@@ -61,9 +65,9 @@ export const initialState: UserState = { ...@@ -61,9 +65,9 @@ export const initialState: UserState = {
selectedWallet: undefined, selectedWallet: undefined,
userExpertMode: false, userExpertMode: false,
userLocale: null, userLocale: null,
userClientSideRouter: false, userRouterPreference: RouterPreference.AUTO,
userHideClosedPositions: false, userHideClosedPositions: false,
userSlippageTolerance: 'auto', userSlippageTolerance: SlippageTolerance.Auto,
userSlippageToleranceHasBeenMigratedToAuto: true, userSlippageToleranceHasBeenMigratedToAuto: true,
userDeadline: DEFAULT_DEADLINE_FROM_NOW, userDeadline: DEFAULT_DEADLINE_FROM_NOW,
tokens: {}, tokens: {},
...@@ -100,8 +104,8 @@ const userSlice = createSlice({ ...@@ -100,8 +104,8 @@ const userSlice = createSlice({
state.userDeadline = action.payload.userDeadline state.userDeadline = action.payload.userDeadline
state.timestamp = currentTimestamp() state.timestamp = currentTimestamp()
}, },
updateUserClientSideRouter(state, action) { updateUserRouterPreference(state, action) {
state.userClientSideRouter = action.payload.userClientSideRouter state.userRouterPreference = action.payload.userRouterPreference
}, },
updateHideClosedPositions(state, action) { updateHideClosedPositions(state, action) {
state.userHideClosedPositions = action.payload.userHideClosedPositions state.userHideClosedPositions = action.payload.userHideClosedPositions
...@@ -130,28 +134,29 @@ const userSlice = createSlice({ ...@@ -130,28 +134,29 @@ const userSlice = createSlice({
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// After adding a new property to the state, its value will be `undefined` (instead of the default)
// for all existing users with a previous version of the state in their localStorage.
// In order to avoid this, we need to set a default value for each new property manually during hydration.
builder.addCase(updateVersion, (state) => { builder.addCase(updateVersion, (state) => {
// slippage isnt being tracked in local storage, reset to default // If `userSlippageTolerance` is not present or its value is invalid, reset to default
// noinspection SuspiciousTypeOfGuard
if ( if (
typeof state.userSlippageTolerance !== 'number' || typeof state.userSlippageTolerance !== 'number' ||
!Number.isInteger(state.userSlippageTolerance) || !Number.isInteger(state.userSlippageTolerance) ||
state.userSlippageTolerance < 0 || state.userSlippageTolerance < 0 ||
state.userSlippageTolerance > 5000 state.userSlippageTolerance > 5000
) { ) {
state.userSlippageTolerance = 'auto' state.userSlippageTolerance = SlippageTolerance.Auto
} else { } else {
if ( if (
!state.userSlippageToleranceHasBeenMigratedToAuto && !state.userSlippageToleranceHasBeenMigratedToAuto &&
[10, 50, 100].indexOf(state.userSlippageTolerance) !== -1 [10, 50, 100].indexOf(state.userSlippageTolerance) !== -1
) { ) {
state.userSlippageTolerance = 'auto' state.userSlippageTolerance = SlippageTolerance.Auto
state.userSlippageToleranceHasBeenMigratedToAuto = true state.userSlippageToleranceHasBeenMigratedToAuto = true
} }
} }
// deadline isnt being tracked in local storage, reset to default // If `userDeadline` is not present or its value is invalid, reset to default
// noinspection SuspiciousTypeOfGuard
if ( if (
typeof state.userDeadline !== 'number' || typeof state.userDeadline !== 'number' ||
!Number.isInteger(state.userDeadline) || !Number.isInteger(state.userDeadline) ||
...@@ -161,6 +166,11 @@ const userSlice = createSlice({ ...@@ -161,6 +166,11 @@ const userSlice = createSlice({
state.userDeadline = DEFAULT_DEADLINE_FROM_NOW state.userDeadline = DEFAULT_DEADLINE_FROM_NOW
} }
// If `userRouterPreference` is not present, reset to default
if (typeof state.userRouterPreference !== 'string') {
state.userRouterPreference = RouterPreference.AUTO
}
state.lastUpdateVersionTimestamp = currentTimestamp() state.lastUpdateVersionTimestamp = currentTimestamp()
}) })
}, },
...@@ -172,7 +182,7 @@ export const { ...@@ -172,7 +182,7 @@ export const {
updateUserBuyFiatFlowCompleted, updateUserBuyFiatFlowCompleted,
updateSelectedWallet, updateSelectedWallet,
updateHideClosedPositions, updateHideClosedPositions,
updateUserClientSideRouter, updateUserRouterPreference,
updateUserDeadline, updateUserDeadline,
updateUserExpertMode, updateUserExpertMode,
updateUserLocale, updateUserLocale,
......
...@@ -10,3 +10,7 @@ export interface SerializedPair { ...@@ -10,3 +10,7 @@ export interface SerializedPair {
token0: SerializedToken token0: SerializedToken
token1: SerializedToken token1: SerializedToken
} }
export enum SlippageTolerance {
Auto = 'auto',
}
...@@ -12,7 +12,15 @@ import React, { ...@@ -12,7 +12,15 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { ArrowLeft, CheckCircle, Copy, ExternalLink as ExternalLinkIconFeather, Icon, X } from 'react-feather' import {
AlertTriangle,
ArrowLeft,
CheckCircle,
Copy,
ExternalLink as ExternalLinkIconFeather,
Icon,
X,
} from 'react-feather'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled, { css, keyframes } from 'styled-components/macro' import styled, { css, keyframes } from 'styled-components/macro'
import { Z_INDEX } from 'theme/zIndex' import { Z_INDEX } from 'theme/zIndex'
...@@ -511,3 +519,7 @@ export const GlowEffect = styled.div` ...@@ -511,3 +519,7 @@ export const GlowEffect = styled.div`
border-radius: 32px; border-radius: 32px;
box-shadow: ${({ theme }) => theme.networkDefaultShadow}; box-shadow: ${({ theme }) => theme.networkDefaultShadow};
` `
export const CautionTriangle = styled(AlertTriangle)`
color: ${({ theme }) => theme.accentWarning};
`
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