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', () => {
// Set deadline to minimum. (1 minute)
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('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist')
......@@ -174,10 +175,9 @@ describe('Swap', () => {
cy.visit('/swap')
cy.contains('Settings').should('not.exist')
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('Auto Router API').should('exist')
cy.contains('Expert Mode').should('exist')
cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Settings').should('not.exist')
})
......@@ -424,6 +424,7 @@ describe('Swap', () => {
// Set slippage to a very low value.
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('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
......
......@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>`
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 }>
export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) {
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 }) {
export function AddRemoveTabs({
adding,
creating,
defaultSlippage,
autoSlippage,
positionID,
children,
}: {
adding: boolean
creating: boolean
defaultSlippage: Percent
autoSlippage: Percent
positionID?: string | undefined
showBackLink?: boolean
children?: ReactNode | undefined
......@@ -108,7 +108,7 @@ export function AddRemoveTabs({
)}
</ThemedText.DeprecatedMediumHeader>
<Box style={{ marginRight: '.5rem' }}>{children}</Box>
<SettingsTab placeholderSlippage={defaultSlippage} />
<SettingsTab autoSlippage={autoSlippage} />
</RowBetween>
</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 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)<{
width?: string
align?: string
......@@ -18,7 +24,7 @@ const Row = styled(Box)<{
padding: ${({ padding }) => padding};
border: ${({ border }) => border};
border-radius: ${({ borderRadius }) => borderRadius};
gap: ${({ gap }) => gap};
gap: ${({ gap, theme }) => gap && (theme.grids[gap as Gap] || gap)};
`
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
import { t, Trans } from '@lingui/macro'
import { t } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-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 { useRef, useState } from 'react'
import { Settings, X } from 'react-feather'
import { Text } from 'rebass'
import styled, { useTheme } from 'styled-components/macro'
import { useRef } from 'react'
import { Settings } from 'react-feather'
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled from 'styled-components/macro'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import { useModalIsOpen, useToggleSettingsMenu } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'
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'
import MaxSlippageSettings from './MaxSlippageSettings'
import RouterPreferenceSettings from './RouterPreferenceSettings'
import TransactionDeadlineSettings from './TransactionDeadlineSettings'
const StyledMenuIcon = styled(Settings)`
height: 20px;
width: 20px;
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
`
const StyledCloseIcon = styled(X)`
height: 20px;
width: 20px;
:hover {
cursor: pointer;
}
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
......@@ -53,7 +34,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>`
padding: 0;
border-radius: 0.5rem;
height: 20px;
${({ disabled }) =>
!disabled &&
`
......@@ -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`
margin-left: 0.5rem;
......@@ -97,89 +71,32 @@ const MenuFlyout = styled.span`
right: 0rem;
z-index: 100;
color: ${({ theme }) => theme.textPrimary};
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
min-width: 18.125rem;
`};
user-select: none;
`
const Break = styled.div`
const Divider = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.deprecated_bg3};
`
const ModalContentWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
background-color: ${({ theme }) => theme.backgroundInteractive};
border-radius: 20px;
border-width: 0;
margin: 0;
background-color: ${({ theme }) => theme.backgroundOutline};
`
export default function SettingsTab({ placeholderSlippage }: { placeholderSlippage: Percent }) {
export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
const { chainId } = useWeb3React()
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
const node = useRef<HTMLDivElement | null>(null)
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)
return (
<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
disabled={!isSupportedChainId(chainId)}
onClick={toggle}
......@@ -188,72 +105,19 @@ export default function SettingsTab({ placeholderSlippage }: { placeholderSlippa
aria-label={t`Transaction Settings`}
>
<StyledMenuIcon data-testid="swap-settings-button" />
{expertMode && (
<EmojiWrapper>
<span role="img" aria-label="wizard-icon">
🧙
</span>
</EmojiWrapper>
)}
</StyledMenuButton>
{open && (
<MenuFlyout>
<AutoColumn gap="md" style={{ padding: '1rem' }}>
<Text fontWeight={600} fontSize={14}>
<Trans>Settings</Trans>
</Text>
<TransactionSettings placeholderSlippage={placeholderSlippage} />
<Text fontWeight={600} fontSize={14}>
<Trans>Interface Settings</Trans>
</Text>
{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>
<AutoColumn gap="16px" style={{ padding: '1rem' }}>
{isSupportedChainId(chainId) && <RouterPreferenceSettings />}
<Divider />
<MaxSlippageSettings autoSlippage={autoSlippage} />
{showDeadlineSettings && (
<>
<Divider />
<TransactionDeadlineSettings />
</>
)}
<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>
</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 '@
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import { useCallback, useMemo, useState } from 'react'
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.
......@@ -23,13 +24,13 @@ export function useSyncWidgetSettings() {
const [appSlippage, setAppSlippage] = useUserSlippageTolerance()
const [widgetSlippage, setWidgetSlippage] = useState<string | undefined>(
appSlippage === 'auto' ? undefined : appSlippage.toFixed(2)
appSlippage === SlippageTolerance.Auto ? undefined : appSlippage.toFixed(2)
)
const onSlippageChange = useCallback(
(widgetSlippage: Slippage) => {
setWidgetSlippage(widgetSlippage.max)
if (widgetSlippage.auto || !widgetSlippage.max) {
setAppSlippage('auto')
setAppSlippage(SlippageTolerance.Auto)
} else {
setAppSlippage(new Percent(Math.floor(Number(widgetSlippage.max) * 100), 10_000))
}
......@@ -43,11 +44,11 @@ export function useSyncWidgetSettings() {
setWidgetTtl(undefined)
setAppTtl(DEFAULT_DEADLINE_FROM_NOW)
setWidgetSlippage(undefined)
setAppSlippage('auto')
setAppSlippage(SlippageTolerance.Auto)
}, [setAppSlippage, setAppTtl])
const settings: SwapController['settings'] = useMemo(() => {
const auto = appSlippage === 'auto'
const auto = appSlippage === SlippageTolerance.Auto
return {
slippage: { auto, max: widgetSlippage },
transactionTtl: widgetTtl,
......
......@@ -25,7 +25,7 @@ const TextHeader = styled.div`
align-items: center;
`
export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Percent }) {
export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
const fiatOnRampButtonEnabled = useFiatOnRampButtonEnabled()
return (
......@@ -38,7 +38,7 @@ export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Perce
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed>
<RowFixed>
<SettingsTab placeholderSlippage={allowedSlippage} />
<SettingsTab autoSlippage={autoSlippage} />
</RowFixed>
</RowBetween>
</StyledSwapHeader>
......
......@@ -13,8 +13,9 @@ import {
} from 'lib/utils/analytics'
import { ReactNode } from 'react'
import { Text } from 'rebass'
import { RouterPreference } from 'state/routing/slice'
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 { ButtonError } from '../Button'
......@@ -123,7 +124,7 @@ export default function SwapModalFooter({
}) {
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
const [clientSideRouter] = useClientSideRouter()
const [routerPreference] = useRouterPreference()
const routes = getTokenPath(trade)
return (
......@@ -139,7 +140,7 @@ export default function SwapModalFooter({
allowedSlippage,
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi: !clientSideRouter,
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
swapQuoteReceivedDate,
routes,
fiatValueInput: fiatValueInput.data,
......
......@@ -3,6 +3,8 @@ import JSBI from 'jsbi'
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
export const DEFAULT_DEADLINE_FROM_NOW = 60 * 30
export const L2_DEADLINE_FROM_NOW = 60 * 5
......
......@@ -3,7 +3,7 @@ import { CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET } from 'constants/tokens'
import { RouterPreference } from 'state/routing/slice'
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 { useRoutingAPITrade } from '../state/routing/useRoutingAPITrade'
......@@ -38,7 +38,7 @@ beforeEach(() => {
mocked(useIsWindowVisible).mockReturnValue(true)
mocked(useAutoRouterSupported).mockReturnValue(true)
mocked(useClientSideRouter).mockReturnValue([true, () => undefined])
mocked(useRouterPreference).mockReturnValue([RouterPreference.CLIENT, () => undefined])
})
describe('#useBestV3Trade ExactIn', () => {
......
......@@ -2,10 +2,9 @@ import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { useMemo } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade, TradeState } from 'state/routing/types'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { useClientSideRouter } from 'state/user/hooks'
import { useRouterPreference } from 'state/user/hooks'
import useAutoRouterSupported from './useAutoRouterSupported'
import { useClientSideV3Trade } from './useClientSideV3Trade'
......@@ -46,12 +45,12 @@ export function useBestTrade(
const shouldGetTrade = !isAWrapTransaction && isWindowVisible
const [clientSideRouter] = useClientSideRouter()
const [routerPreference] = useRouterPreference()
const routingAPITrade = useRoutingAPITrade(
tradeType,
autoRouterSupported && shouldGetTrade ? debouncedAmount : undefined,
debouncedOtherCurrency,
clientSideRouter ? RouterPreference.CLIENT : RouterPreference.API
routerPreference
)
const isLoading = routingAPITrade.state === TradeState.LOADING
......
......@@ -607,7 +607,7 @@ function AddLiquidity() {
creating={false}
adding={true}
positionID={tokenId}
defaultSlippage={DEFAULT_ADD_IN_RANGE_SLIPPAGE_TOLERANCE}
autoSlippage={DEFAULT_ADD_IN_RANGE_SLIPPAGE_TOLERANCE}
showBackLink={!hasExistingPosition}
>
{!hasExistingPosition && (
......
......@@ -323,7 +323,7 @@ export default function AddLiquidity() {
return (
<>
<AppBody>
<AddRemoveTabs creating={isCreate} adding={true} defaultSlippage={DEFAULT_ADD_V2_SLIPPAGE_TOLERANCE} />
<AddRemoveTabs creating={isCreate} adding={true} autoSlippage={DEFAULT_ADD_V2_SLIPPAGE_TOLERANCE} />
<Wrapper>
<TransactionConfirmationModal
isOpen={showConfirm}
......
......@@ -729,7 +729,7 @@ export default function MigrateV2Pair() {
<ThemedText.DeprecatedMediumHeader>
<Trans>Migrate V2 Liquidity</Trans>
</ThemedText.DeprecatedMediumHeader>
<SettingsTab placeholderSlippage={DEFAULT_MIGRATE_SLIPPAGE_TOLERANCE} />
<SettingsTab autoSlippage={DEFAULT_MIGRATE_SLIPPAGE_TOLERANCE} />
</AutoRow>
{!account ? (
......
......@@ -297,7 +297,7 @@ function Remove({ tokenId }: { tokenId: BigNumber }) {
creating={false}
adding={false}
positionID={tokenId.toString()}
defaultSlippage={DEFAULT_REMOVE_V3_LIQUIDITY_SLIPPAGE_TOLERANCE}
autoSlippage={DEFAULT_REMOVE_V3_LIQUIDITY_SLIPPAGE_TOLERANCE}
/>
<Wrapper>
{position ? (
......
......@@ -442,7 +442,7 @@ function RemoveLiquidity() {
return (
<>
<AppBody>
<AddRemoveTabs creating={false} adding={false} defaultSlippage={DEFAULT_REMOVE_LIQUIDITY_SLIPPAGE_TOLERANCE} />
<AddRemoveTabs creating={false} adding={false} autoSlippage={DEFAULT_REMOVE_LIQUIDITY_SLIPPAGE_TOLERANCE} />
<Wrapper>
<TransactionConfirmationModal
isOpen={showConfirm}
......
......@@ -271,6 +271,7 @@ export function Swap({
const {
trade: { state: tradeState, trade },
allowedSlippage,
autoSlippage,
currencyBalances,
parsedAmount,
currencies,
......@@ -579,7 +580,7 @@ export function Swap({
onCancel={handleDismissTokenWarning}
showCancel={true}
/>
<SwapHeader allowedSlippage={allowedSlippage} />
<SwapHeader autoSlippage={autoSlippage} />
<ConfirmSwapModal
isOpen={showConfirm}
trade={trade}
......
......@@ -38,7 +38,7 @@ export const sentryEnhancer = Sentry.createReduxEnhancer({
lastUpdateVersionTimestamp: user.lastUpdateVersionTimestamp,
userLocale: user.userLocale,
userExpertMode: user.userExpertMode,
userClientSideRouter: user.userClientSideRouter,
userRouterPreference: user.userRouterPreference,
userHideClosedPositions: user.userHideClosedPositions,
userSlippageTolerance: user.userSlippageTolerance,
userSlippageToleranceHasBeenMigratedToAuto: user.userSlippageToleranceHasBeenMigratedToAuto,
......
......@@ -10,8 +10,11 @@ import { trace } from 'tracing/trace'
import { GetQuoteResult } from './types'
export enum RouterPreference {
AUTO = 'auto',
API = 'api',
CLIENT = 'client',
// Used internally for token -> USDC trades to get a USD value.
PRICE = 'price',
}
......@@ -108,7 +111,8 @@ export const routingApi = createApi({
data: {
...args,
isPrice: args.routerPreference === RouterPreference.PRICE,
isAutoRouter: args.routerPreference === RouterPreference.API,
isAutoRouter:
args.routerPreference === RouterPreference.AUTO || args.routerPreference === RouterPreference.API,
},
tags: { is_widget: false },
}
......
......@@ -85,6 +85,7 @@ export function useDerivedSwapInfo(
state: TradeState
}
allowedSlippage: Percent
autoSlippage: Percent
} {
const { account } = useWeb3React()
......@@ -135,8 +136,8 @@ export function useDerivedSwapInfo(
)
// allowed slippage is either auto slippage, or custom user defined slippage if auto slippage disabled
const autoSlippageTolerance = useAutoSlippageTolerance(trade.trade)
const allowedSlippage = useUserSlippageToleranceWithDefault(autoSlippageTolerance)
const autoSlippage = useAutoSlippageTolerance(trade.trade)
const allowedSlippage = useUserSlippageToleranceWithDefault(autoSlippage)
const inputError = useMemo(() => {
let inputError: ReactNode | undefined
......@@ -179,9 +180,10 @@ export function useDerivedSwapInfo(
parsedAmount,
inputError,
trade,
autoSlippage,
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 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', () => {
it('serializes the token', () => {
......@@ -19,3 +26,52 @@ describe('deserializeToken', () => {
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'
import JSBI from 'jsbi'
import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { RouterPreference } from 'state/routing/slice'
import { UserAddedToken } from 'types/tokens'
import { V2_FACTORY_ADDRESSES } from '../../constants/addresses'
......@@ -19,13 +20,13 @@ import {
updateHideClosedPositions,
updateHideUniswapWalletBanner,
updateUserBuyFiatFlowCompleted,
updateUserClientSideRouter,
updateUserDeadline,
updateUserExpertMode,
updateUserLocale,
updateUserRouterPreference,
updateUserSlippageTolerance,
} from './reducer'
import { SerializedPair, SerializedToken } from './types'
import { SerializedPair, SerializedToken, SlippageTolerance } from './types'
export function serializeToken(token: Token): SerializedToken {
return {
......@@ -92,42 +93,52 @@ export function useExpertModeManager(): [boolean, () => void] {
return [expertMode, toggleSetExpertMode]
}
export function useClientSideRouter(): [boolean, (userClientSideRouter: boolean) => void] {
export function useRouterPreference(): [RouterPreference, (routerPreference: RouterPreference) => void] {
const dispatch = useAppDispatch()
const clientSideRouter = useAppSelector((state) => Boolean(state.user.userClientSideRouter))
const routerPreference = useAppSelector((state) => state.user.userRouterPreference)
const setClientSideRouter = useCallback(
(newClientSideRouter: boolean) => {
dispatch(updateUserClientSideRouter({ userClientSideRouter: newClientSideRouter }))
const setRouterPreference = useCallback(
(newRouterPreference: RouterPreference) => {
dispatch(updateUserRouterPreference({ userRouterPreference: newRouterPreference }))
},
[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
*/
export function useUserSlippageTolerance(): [Percent | 'auto', (slippageTolerance: Percent | 'auto') => void] {
export function useUserSlippageTolerance(): [
Percent | SlippageTolerance.Auto,
(slippageTolerance: Percent | SlippageTolerance.Auto) => void
] {
const userSlippageToleranceRaw = useAppSelector((state) => {
return state.user.userSlippageTolerance
})
// TODO(WEB-3291): Keep `userSlippageTolerance` as Percent in Redux store and remove this conversion
const userSlippageTolerance = useMemo(
() => (userSlippageToleranceRaw === 'auto' ? 'auto' : new Percent(userSlippageToleranceRaw, 10_000)),
() =>
userSlippageToleranceRaw === SlippageTolerance.Auto
? SlippageTolerance.Auto
: new Percent(userSlippageToleranceRaw, 10_000),
[userSlippageToleranceRaw]
)
const dispatch = useAppDispatch()
const setUserSlippageTolerance = useCallback(
(userSlippageTolerance: Percent | 'auto') => {
let value: 'auto' | number
(userSlippageTolerance: Percent | SlippageTolerance.Auto) => {
let value: SlippageTolerance.Auto | number
try {
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) {
value = 'auto'
value = SlippageTolerance.Auto
}
dispatch(
updateUserSlippageTolerance({
......@@ -166,7 +177,7 @@ export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions:
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
const allowedSlippage = useUserSlippageTolerance()[0]
return useMemo(
() => (allowedSlippage === 'auto' ? defaultSlippageTolerance : allowedSlippage),
() => (allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage),
[allowedSlippage, defaultSlippageTolerance]
)
}
......
import { createStore, Store } from 'redux'
import { RouterPreference } from 'state/routing/slice'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { updateVersion } from '../global/actions'
......@@ -9,13 +10,14 @@ import reducer, {
updateHideClosedPositions,
updateHideUniswapWalletBanner,
updateSelectedWallet,
updateUserClientSideRouter,
updateUserDeadline,
updateUserExpertMode,
updateUserLocale,
updateUserRouterPreference,
updateUserSlippageTolerance,
UserState,
} from './reducer'
import { SlippageTolerance } from './types'
function buildSerializedPair(token0Address: string, token1Address: string, chainId: number) {
return {
......@@ -54,7 +56,7 @@ describe('swap reducer', () => {
} as any)
store.dispatch(updateVersion())
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', () => {
store = createStore(reducer, {
......@@ -102,10 +104,10 @@ describe('swap reducer', () => {
})
})
describe('updateUserClientSideRouter', () => {
it('updates the userClientSideRouter', () => {
store.dispatch(updateUserClientSideRouter({ userClientSideRouter: true }))
expect(store.getState().userClientSideRouter).toEqual(true)
describe('updateRouterPreference', () => {
it('updates the routerPreference', () => {
store.dispatch(updateUserRouterPreference({ userRouterPreference: RouterPreference.API }))
expect(store.getState().userRouterPreference).toEqual(RouterPreference.API)
})
})
......
import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection/types'
import { SupportedLocale } from 'constants/locales'
import { RouterPreference } from 'state/routing/slice'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { updateVersion } from '../global/actions'
import { SerializedPair, SerializedToken } from './types'
import { SerializedPair, SerializedToken, SlippageTolerance } from './types'
const currentTimestamp = () => new Date().getTime()
......@@ -20,14 +21,17 @@ export interface UserState {
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
userHideClosedPositions: boolean
// user defined slippage tolerance in bips, used in all txns
userSlippageTolerance: number | 'auto'
userSlippageToleranceHasBeenMigratedToAuto: boolean // temporary flag for migration status
userSlippageTolerance: number | SlippageTolerance.Auto
// 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
userDeadline: number
......@@ -61,9 +65,9 @@ export const initialState: UserState = {
selectedWallet: undefined,
userExpertMode: false,
userLocale: null,
userClientSideRouter: false,
userRouterPreference: RouterPreference.AUTO,
userHideClosedPositions: false,
userSlippageTolerance: 'auto',
userSlippageTolerance: SlippageTolerance.Auto,
userSlippageToleranceHasBeenMigratedToAuto: true,
userDeadline: DEFAULT_DEADLINE_FROM_NOW,
tokens: {},
......@@ -100,8 +104,8 @@ const userSlice = createSlice({
state.userDeadline = action.payload.userDeadline
state.timestamp = currentTimestamp()
},
updateUserClientSideRouter(state, action) {
state.userClientSideRouter = action.payload.userClientSideRouter
updateUserRouterPreference(state, action) {
state.userRouterPreference = action.payload.userRouterPreference
},
updateHideClosedPositions(state, action) {
state.userHideClosedPositions = action.payload.userHideClosedPositions
......@@ -130,28 +134,29 @@ const userSlice = createSlice({
},
},
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) => {
// slippage isnt being tracked in local storage, reset to default
// noinspection SuspiciousTypeOfGuard
// If `userSlippageTolerance` is not present or its value is invalid, reset to default
if (
typeof state.userSlippageTolerance !== 'number' ||
!Number.isInteger(state.userSlippageTolerance) ||
state.userSlippageTolerance < 0 ||
state.userSlippageTolerance > 5000
) {
state.userSlippageTolerance = 'auto'
state.userSlippageTolerance = SlippageTolerance.Auto
} else {
if (
!state.userSlippageToleranceHasBeenMigratedToAuto &&
[10, 50, 100].indexOf(state.userSlippageTolerance) !== -1
) {
state.userSlippageTolerance = 'auto'
state.userSlippageTolerance = SlippageTolerance.Auto
state.userSlippageToleranceHasBeenMigratedToAuto = true
}
}
// deadline isnt being tracked in local storage, reset to default
// noinspection SuspiciousTypeOfGuard
// If `userDeadline` is not present or its value is invalid, reset to default
if (
typeof state.userDeadline !== 'number' ||
!Number.isInteger(state.userDeadline) ||
......@@ -161,6 +166,11 @@ const userSlice = createSlice({
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()
})
},
......@@ -172,7 +182,7 @@ export const {
updateUserBuyFiatFlowCompleted,
updateSelectedWallet,
updateHideClosedPositions,
updateUserClientSideRouter,
updateUserRouterPreference,
updateUserDeadline,
updateUserExpertMode,
updateUserLocale,
......
......@@ -10,3 +10,7 @@ export interface SerializedPair {
token0: SerializedToken
token1: SerializedToken
}
export enum SlippageTolerance {
Auto = 'auto',
}
......@@ -12,7 +12,15 @@ import React, {
useRef,
useState,
} 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 styled, { css, keyframes } from 'styled-components/macro'
import { Z_INDEX } from 'theme/zIndex'
......@@ -511,3 +519,7 @@ export const GlowEffect = styled.div`
border-radius: 32px;
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