Commit 7934777f authored by eddie's avatar eddie Committed by GitHub

feat: permit2 flow updates (#6538)

* test: swap flow cypress tests

* fix: use default parameter

* feat: use Swap Component on TDP

* feat: auto nav for TDP tokens

* chore: merge

* chore: merge

* chore: merge

* chore: merge

* fix: remove extra inputCurrency URL parsing logic

* fix: undo last change

* fix: pass expected chain id to swap component

* fix: search for default tokens on unconnected networks if needed

* test: e2e test for l2 token

* fix: delete irrelevant tests

* fix: address comments

* fix: lint error

* test: update TDP e2e tests

* fix: use pageChainId for filter

* fix: rename chainId

* fix: typecheck

* fix: chainId bug

* fix: chainId required fixes

* fix: bad merge in e2e test

* fix: remove unused test util

* fix: remove unnecessary variable

* fix: token defaults

* fix: address comments

* fix: address comments and fix tests

* fix: e2e test formatting, remove Maybe<>

* fix: remove unused variable

* fix: use feature flag for swap component on TDP

* fix: back button

* feat: copy review screen UI from widgetg

* fix: modal padding

* feat: add final detail row

* fix: remove widget comment

* fix: update unit tests

* fix: code style consistency

* fix: remove padding from AutoColumn

* fix: update snapshots

* fix: use semantic gaps

* fix: more px and gaps

* fix: design feedbacks

* fix: button radius in summary modal

* fix: design nits

* feat: update design of summary modal

* fix: font weight and vertical spacing

* fix: update snapshots

* fix: css nits

* fix: modal flicker when refetching trade

* wip: move approval to summary modal

* wip: not working

* feat: working

* fix: fix flow

* feat: simplify states and build new modal UI

* feat: todos and differs fix

* feat: update tx status modal

* feat: split up approve and permit

* feat: error state

* feat: update success and error states

* feat: undo changes to TxConfirmationModal

* feat: remove step indicators when only one step

* feat: move content into PendingModalContent component

* fix: lint

* fix: correct modal state when moving between steps

* fix: comments

* fix: code style improvements

* feat: require trade to be defined

* fix: remove extra props from ThemedTexts

* fix: one more trans

* fix: remove unused export

* feat: remove undefined checks and other fixes

* fix: update test

* fix: add missing dollar sign

* fix: remove null check and update test

* fix: remove max width from detail row value

* fix: remove isOpen prop

* fix: isopen

* feat: refactor approval flow into a hook

* fix: tradeMeaningfullyDiffers improvement and prepareFlow fix

* fix: address  comments

* feat: add comments explaining async state

* fix: nits

* fix: address comments

* feat: permit2 e2e tests (#6541)

* wip: move approval to summary modal

* wip: not working

* feat: working

* fix: fix flow

* feat: simplify states and build new modal UI

* feat: todos and differs fix

* feat: update tx status modal

* feat: split up approve and permit

* feat: error state

* feat: update success and error states

* feat: undo changes to TxConfirmationModal

* feat: re-order functions

* wip: move approval to summary modal

* wip: not working

* feat: update permit2 e2e tests

* feat: tests passing

* fix: swap test

* fix: bad merge

* chore: merge

* fix: update tests for new modal

* fix: testid fix

* fix: test updates

* fix: reduce nesting

* test: remove line from test for debugging

* fix: update tests

* fix: more nesting in test

* fix: update test

* fix: reorganize test code
parent 2415a1e3
...@@ -104,7 +104,7 @@ describe('mini-portfolio activity history', () => { ...@@ -104,7 +104,7 @@ describe('mini-portfolio activity history', () => {
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click() cy.get(getTestSelector('confirmation-close-icon')).click()
// Check activity history tab. // Check activity history tab.
cy.get(getTestSelector('web3-status-connected')).click() cy.get(getTestSelector('web3-status-connected')).click()
......
...@@ -3,24 +3,13 @@ import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk' ...@@ -3,24 +3,13 @@ import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk'
import { DAI, USDC_MAINNET } from '../../src/constants/tokens' import { DAI, USDC_MAINNET } from '../../src/constants/tokens'
import { getTestSelector } from '../utils' import { getTestSelector } from '../utils'
const APPROVE_BUTTON = '[data-testid="swap-approve-button"]' /** Initiates a swap. */
function initiateSwap() {
/** Initiates a swap and confirms its success. */
function swaps() {
// The swap-button can be temporarily disabled following approval, & Cypress will retry clicking the disabled version.
// This ensures that we don't click until the button is enabled.
cy.get('#swap-button').should('not.have.attr', 'disabled')
// Completes the swap. // Completes the swap.
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get(getTestSelector('confirm-swap-button')).click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// Verifies that there is a successful swap notification.
cy.contains('Swapped').should('exist')
} }
// TODO(WEB-3299): Update tests to differentiate between permit2 vs token approval button text once UI is updated to indicate approval step.
describe('Permit2', () => { describe('Permit2', () => {
// The same tokens & swap-amount combination is used for all permit2 tests. // The same tokens & swap-amount combination is used for all permit2 tests.
const INPUT_TOKEN = DAI const INPUT_TOKEN = DAI
...@@ -32,7 +21,7 @@ describe('Permit2', () => { ...@@ -32,7 +21,7 @@ describe('Permit2', () => {
cy.visit(`/swap/?inputCurrency=${INPUT_TOKEN.address}&outputCurrency=${OUTPUT_TOKEN.address}`, { cy.visit(`/swap/?inputCurrency=${INPUT_TOKEN.address}&outputCurrency=${OUTPUT_TOKEN.address}`, {
ethereum: 'hardhat', ethereum: 'hardhat',
}) })
cy.get('#swap-currency-input .token-amount-input').clear().type(TEST_BALANCE_INCREMENT.toString()) cy.get('#swap-currency-input .token-amount-input').type(TEST_BALANCE_INCREMENT.toString())
}) })
/** Asserts permit2 has a max approval for spend of the input token on-chain. */ /** Asserts permit2 has a max approval for spend of the input token on-chain. */
...@@ -58,148 +47,146 @@ describe('Permit2', () => { ...@@ -58,148 +47,146 @@ describe('Permit2', () => {
} }
it('swaps when user has already approved token and permit2', () => { it('swaps when user has already approved token and permit2', () => {
cy.hardhat() cy.hardhat().then(({ approval, wallet }) => {
.then(({ approval, wallet }) => {
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }) approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }) approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN })
}) })
.then(swaps) initiateSwap()
cy.get(getTestSelector('confirmation-close-icon')).click()
// Verifies that there is a successful swap notification.
cy.contains('Swapped').should('exist')
}) })
it('swaps after completing full permit2 approval process', () => { it('swaps after completing full permit2 approval process', () => {
cy.get(APPROVE_BUTTON) initiateSwap()
.click() cy.contains('Approve permit').should('exist')
.then(() => {
const approvalTime = Date.now()
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending')
// There should be a successful Approved notification.
cy.contains('Approved').should('exist') cy.contains('Approved').should('exist')
swaps() cy.contains('Approve DAI').should('exist')
cy.contains('Confirm Swap').should('exist')
cy.then(() => {
const approvalTime = Date.now()
cy.contains('Swapped').should('exist')
expectTokenAllowanceForPermit2ToBeMax() expectTokenAllowanceForPermit2ToBeMax()
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime) expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) })
}) })
it('swaps after handling user rejection of approvals and signatures', () => { it('swaps after handling user rejection of both approval and signature', () => {
const USER_REJECTION = { code: 4001 } const USER_REJECTION = { code: 4001 }
cy.hardhat().then((hardhat) => { cy.hardhat().then((hardhat) => {
const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction') const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction')
tokenApprovalStub.rejects(USER_REJECTION) // reject token approval tokenApprovalStub.rejects(USER_REJECTION) // reject token approval
const permitApprovalStub = cy.stub(hardhat.provider, 'send') const permitApprovalStub = cy.stub(hardhat.provider, 'send')
permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // reject permit approval permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // reject permit approval
permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values
// Clicking the approve button should trigger a token approval that will be rejected by the user (tokenApprovalStub). initiateSwap()
cy.get(APPROVE_BUTTON).click()
// The swap component should prompt approval again. // tokenApprovalStub should reject here, and the modal should revert to the review state.
cy.get(APPROVE_BUTTON) cy.contains('Review Swap').should('be.visible')
.should('have.text', `Approve use of ${INPUT_TOKEN.symbol}`)
.then(() => {
tokenApprovalStub.restore() // allow token approval
cy.then(() => {
// The user is now allowing approval, but the permit2 signature will be rejected by the user (permitApprovalStub). // The user is now allowing approval, but the permit2 signature will be rejected by the user (permitApprovalStub).
cy.get(APPROVE_BUTTON).click() tokenApprovalStub.restore() // allow token approval
cy.get(APPROVE_BUTTON) })
.should('have.text', `Approve use of ${INPUT_TOKEN.symbol}`)
cy.get(getTestSelector('confirm-swap-button')).click()
cy.contains('Approve permit').should('exist')
cy.contains('Approved').should('exist')
// permitApprovalStub should reject here, and the modal should revert to the review state.
cy.contains('Review Swap')
.should('be.visible')
.then(() => { .then(() => {
permitApprovalStub.restore() // allow permit approval permitApprovalStub.restore() // allow permit approval
})
cy.get(getTestSelector('confirm-swap-button')).click()
// The swap should now be able to proceed, as the permit2 signature will be accepted by the user. // The swap should now be able to proceed, as the permit2 signature will be accepted by the user.
cy.get(APPROVE_BUTTON)
.click()
.then(() => {
const approvalTime = Date.now() const approvalTime = Date.now()
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending')
// There should be a successful Approved notification. cy.contains('Confirm Swap').should('exist')
cy.contains('Approved').should('exist') cy.contains('Swapped').should('exist')
swaps()
expectTokenAllowanceForPermit2ToBeMax() expectTokenAllowanceForPermit2ToBeMax()
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime) expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) })
}) })
})
})
})
it('swaps with existing token approval and missing permit approval', () => { it('swaps with existing token approval and missing permit approval', () => {
cy.hardhat() cy.hardhat().then(({ approval, wallet, provider }) => {
.then(({ approval, wallet }) => approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })) approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
.then(() => { cy.spy(provider, 'send').as('permitApprovalSpy')
cy.get(APPROVE_BUTTON) })
.click() cy.then(() => initiateSwap())
.then(() => { cy.then(() => {
const approvalTime = Date.now() const approvalTime = Date.now()
swaps() cy.contains('Confirm Swap').should('exist')
cy.contains('Swapped').should('exist')
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime) expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) cy.get('@permitApprovalSpy').should('have.been.calledWith', 'eth_signTypedData_v4')
}) })
}) })
it('swaps with existing permit approval and missing token approval', () => { it('swaps with existing permit approval and missing token approval', () => {
cy.hardhat() cy.hardhat().then(({ approval, wallet }) => approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }))
.then(({ approval, wallet }) => approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN })) cy.then(() => {
.then(() => { initiateSwap()
cy.get(APPROVE_BUTTON).click() })
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending') cy.then(() => {
const approvalTime = Date.now()
// There should be a successful Approved notification.
cy.contains('Approved').should('exist')
swaps() cy.contains('Confirm Swap').should('exist')
cy.contains('Swapped').should('exist')
expectTokenAllowanceForPermit2ToBeMax() expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) })
}) })
it('prompts signature when existing permit approval is expired', () => { it('prompts signature when existing permit approval is expired', () => {
const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) } const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) }
cy.hardhat() cy.hardhat().then(({ approval, wallet, provider }) => {
.then(({ approval, wallet }) => {
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }) approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, expiredAllowance) approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, expiredAllowance)
cy.spy(provider, 'send').as('permitApprovalSpy')
}) })
.then(() => { cy.then(() => {
cy.get(APPROVE_BUTTON) initiateSwap()
.click() })
.then(() => { cy.then(() => {
const approvalTime = Date.now() const approvalTime = Date.now()
swaps() cy.contains('Confirm Swap').should('exist')
cy.contains('Swapped').should('exist')
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime) expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) cy.get('@permitApprovalSpy').should('have.been.calledWith', 'eth_signTypedData_v4')
}) })
}) })
it('prompts signature when existing permit approval amount is too low', () => { it('prompts signature when existing permit approval amount is too low', () => {
const smallAllowance = { amount: 1 } const smallAllowance = { amount: 1 }
cy.hardhat() cy.hardhat().then(({ approval, wallet, provider }) => {
.then(({ approval, wallet }) => {
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }) approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, smallAllowance) approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, smallAllowance)
}) cy.spy(provider, 'send').as('permitApprovalSpy')
.then(() => { initiateSwap()
cy.get(APPROVE_BUTTON)
.click()
.then(() => {
const approvalTime = Date.now() const approvalTime = Date.now()
swaps() cy.contains('Confirm Swap').should('exist')
cy.contains('Swapped').should('exist')
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime) expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) cy.get('@permitApprovalSpy').should('have.been.calledWith', 'eth_signTypedData_v4')
}) })
}) })
...@@ -210,14 +197,14 @@ describe('Permit2', () => { ...@@ -210,14 +197,14 @@ describe('Permit2', () => {
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }, 1) approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }, 1)
}) })
.then(() => { .then(() => {
cy.get(APPROVE_BUTTON).click() initiateSwap()
const approvalTime = Date.now()
// There should be a successful Approved notification. cy.contains('Approve permit').should('exist')
cy.contains('Approved').should('exist')
swaps() cy.contains('Confirm Swap').should('exist')
cy.contains('Swapped').should('exist')
expectTokenAllowanceForPermit2ToBeMax() expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
}) })
}) })
}) })
...@@ -19,9 +19,9 @@ describe('Swap errors', () => { ...@@ -19,9 +19,9 @@ describe('Swap errors', () => {
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.contains('Transaction rejected').should('exist') cy.contains('Confirmation failed').should('exist')
cy.contains('Dismiss').click() cy.get('body').click('topRight')
cy.contains('Transaction rejected').should('not.exist') cy.contains('Confirmation failed').should('not.exist')
}) })
}) })
...@@ -43,7 +43,7 @@ describe('Swap errors', () => { ...@@ -43,7 +43,7 @@ describe('Swap errors', () => {
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click() cy.get(getTestSelector('confirmation-close-icon')).click()
// The pending transaction indicator should reflect the state. // The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
...@@ -88,7 +88,8 @@ describe('Swap errors', () => { ...@@ -88,7 +88,8 @@ describe('Swap errors', () => {
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click() cy.contains('Confirm Swap').should('exist')
cy.get(getTestSelector('confirmation-close-icon')).click()
cy.get('#swap-currency-input .token-amount-input') cy.get('#swap-currency-input .token-amount-input')
.clear() .clear()
...@@ -97,7 +98,8 @@ describe('Swap errors', () => { ...@@ -97,7 +98,8 @@ describe('Swap errors', () => {
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click() cy.contains('Confirm Swap').should('exist')
cy.get(getTestSelector('confirmation-close-icon')).click()
// The pending transaction indicator should reflect the state. // The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending')
......
...@@ -48,7 +48,7 @@ describe('Swap', () => { ...@@ -48,7 +48,7 @@ describe('Swap', () => {
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click() cy.get(getTestSelector('confirmation-close-icon')).click()
// The pending transaction indicator should reflect the state. // The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
......
...@@ -7,6 +7,8 @@ import { Z_INDEX } from 'theme/zIndex' ...@@ -7,6 +7,8 @@ import { Z_INDEX } from 'theme/zIndex'
import { isMobile } from '../../utils/userAgent' import { isMobile } from '../../utils/userAgent'
export const MODAL_TRANSITION_DURATION = 200
const AnimatedDialogOverlay = animated(DialogOverlay) const AnimatedDialogOverlay = animated(DialogOverlay)
const StyledDialogOverlay = styled(AnimatedDialogOverlay)<{ $scrollOverlay?: boolean }>` const StyledDialogOverlay = styled(AnimatedDialogOverlay)<{ $scrollOverlay?: boolean }>`
...@@ -103,7 +105,7 @@ export default function Modal({ ...@@ -103,7 +105,7 @@ export default function Modal({
hideBorder = false, hideBorder = false,
}: ModalProps) { }: ModalProps) {
const fadeTransition = useTransition(isOpen, { const fadeTransition = useTransition(isOpen, {
config: { duration: 200 }, config: { duration: MODAL_TRANSITION_DURATION },
from: { opacity: 0 }, from: { opacity: 0 },
enter: { opacity: 1 }, enter: { opacity: 1 },
leave: { opacity: 0 }, leave: { opacity: 0 },
......
import styled, { keyframes, useTheme } from 'styled-components/macro' import styled, { keyframes, useTheme } from 'styled-components/macro'
const Wrapper = styled.div` const Wrapper = styled.div<{ size?: string }>`
height: 90px; height: 90px;
width: 90px; width: 90px;
` `
...@@ -38,11 +38,11 @@ const PolyLine = styled.polyline` ...@@ -38,11 +38,11 @@ const PolyLine = styled.polyline`
animation: ${dashCheck} 0.9s 0.35s ease-in-out forwards; animation: ${dashCheck} 0.9s 0.35s ease-in-out forwards;
` `
export default function AnimatedConfirmation() { export default function AnimatedConfirmation({ className }: { className?: string }) {
const theme = useTheme() const theme = useTheme()
return ( return (
<Wrapper className="w4rAnimated_checkmark"> <Wrapper className={className}>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130.2 130.2"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130.2 130.2">
<Circle <Circle
className="path circle" className="path circle"
......
...@@ -6,16 +6,15 @@ import { getChainInfo } from 'constants/chainInfo' ...@@ -6,16 +6,15 @@ import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId, SupportedL2ChainId } from 'constants/chains' import { SupportedChainId, SupportedL2ChainId } from 'constants/chains'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs' import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs'
import { ReactNode, useCallback, useState } from 'react' import { ReactNode, useCallback, useState } from 'react'
import { AlertCircle, AlertTriangle, ArrowUpCircle, CheckCircle } from 'react-feather' import { AlertCircle, ArrowUpCircle, CheckCircle } from 'react-feather'
import { Text } from 'rebass'
import { useIsTransactionConfirmed, useTransaction } from 'state/transactions/hooks' import { useIsTransactionConfirmed, useTransaction } from 'state/transactions/hooks'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { isL2ChainId } from 'utils/chains' import { isL2ChainId } from 'utils/chains'
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink'
import Circle from '../../assets/images/blue-loader.svg' import Circle from '../../assets/images/blue-loader.svg'
import { ExternalLink, ThemedText } from '../../theme' import { ExternalLink, ThemedText } from '../../theme'
import { CloseIcon, CustomLightSpinner } from '../../theme' import { CloseIcon, CustomLightSpinner } from '../../theme'
import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink'
import { TransactionSummary } from '../AccountDetails/TransactionSummary' import { TransactionSummary } from '../AccountDetails/TransactionSummary'
import { ButtonLight, ButtonPrimary } from '../Button' import { ButtonLight, ButtonPrimary } from '../Button'
import { AutoColumn, ColumnCenter } from '../Column' import { AutoColumn, ColumnCenter } from '../Column'
...@@ -187,7 +186,7 @@ export function ConfirmationModalContent({ ...@@ -187,7 +186,7 @@ export function ConfirmationModalContent({
<Row justify="center" marginLeft="24px"> <Row justify="center" marginLeft="24px">
<ThemedText.SubHeader>{title}</ThemedText.SubHeader> <ThemedText.SubHeader>{title}</ThemedText.SubHeader>
</Row> </Row>
<CloseIcon onClick={onDismiss} data-cy="confirmation-close-icon" /> <CloseIcon onClick={onDismiss} data-testid="confirmation-close-icon" />
</Row> </Row>
{topContent()} {topContent()}
</AutoColumn> </AutoColumn>
...@@ -196,31 +195,6 @@ export function ConfirmationModalContent({ ...@@ -196,31 +195,6 @@ export function ConfirmationModalContent({
) )
} }
export function TransactionErrorContent({ message, onDismiss }: { message: ReactNode; onDismiss: () => void }) {
const theme = useTheme()
return (
<Wrapper>
<AutoColumn>
<RowBetween>
<Text fontWeight={600} fontSize={16}>
<Trans>Error</Trans>
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<AutoColumn style={{ marginTop: 20, padding: '2rem 0' }} gap="24px" justify="center">
<AlertTriangle color={theme.accentCritical} style={{ strokeWidth: 1 }} size={90} />
<ThemedText.MediumHeader textAlign="center">{message}</ThemedText.MediumHeader>
</AutoColumn>
</AutoColumn>
<BottomSection gap="12px">
<ButtonPrimary onClick={onDismiss}>
<Trans>Dismiss</Trans>
</ButtonPrimary>
</BottomSection>
</Wrapper>
)
}
function L2Content({ function L2Content({
onDismiss, onDismiss,
chainId, chainId,
...@@ -325,7 +299,7 @@ interface ConfirmationModalProps { ...@@ -325,7 +299,7 @@ interface ConfirmationModalProps {
isOpen: boolean isOpen: boolean
onDismiss: () => void onDismiss: () => void
hash?: string hash?: string
content: () => ReactNode reviewContent: () => ReactNode
attemptingTxn: boolean attemptingTxn: boolean
pendingText: ReactNode pendingText: ReactNode
currencyToAdd?: Currency currencyToAdd?: Currency
...@@ -337,7 +311,7 @@ export default function TransactionConfirmationModal({ ...@@ -337,7 +311,7 @@ export default function TransactionConfirmationModal({
attemptingTxn, attemptingTxn,
hash, hash,
pendingText, pendingText,
content, reviewContent,
currencyToAdd, currencyToAdd,
}: ConfirmationModalProps) { }: ConfirmationModalProps) {
const { chainId } = useWeb3React() const { chainId } = useWeb3React()
...@@ -359,7 +333,7 @@ export default function TransactionConfirmationModal({ ...@@ -359,7 +333,7 @@ export default function TransactionConfirmationModal({
currencyToAdd={currencyToAdd} currencyToAdd={currencyToAdd}
/> />
) : ( ) : (
content() reviewContent()
)} )}
</Modal> </Modal>
) )
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent, Trace } from '@uniswap/analytics' import { sendAnalyticsEvent, Trace, useTrace } from '@uniswap/analytics'
import { InterfaceModalName, SwapEventName, SwapPriceUpdateUserResponse } from '@uniswap/analytics-events' import {
InterfaceEventName,
InterfaceModalName,
SwapEventName,
SwapPriceUpdateUserResponse,
} from '@uniswap/analytics-events'
import { Percent } from '@uniswap/sdk-core' import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import Modal, { MODAL_TRANSITION_DURATION } from 'components/Modal'
import { useMaxAmountIn } from 'hooks/useMaxAmountIn'
import { Allowance, AllowanceState } from 'hooks/usePermit2Allowance'
import usePrevious from 'hooks/usePrevious'
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics' import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { ReactNode, useCallback, useEffect, useState } from 'react'
import { InterfaceTrade } from 'state/routing/types' import { InterfaceTrade } from 'state/routing/types'
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters' import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer' import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import TransactionConfirmationModal, { import { ConfirmationModalContent } from '../TransactionConfirmationModal'
ConfirmationModalContent, import {
TransactionErrorContent, ErrorModalContent,
} from '../TransactionConfirmationModal' PendingConfirmModalState,
PendingModalContent,
PendingModalError,
} from './PendingModalContent'
import SwapModalFooter from './SwapModalFooter' import SwapModalFooter from './SwapModalFooter'
import SwapModalHeader from './SwapModalHeader' import SwapModalHeader from './SwapModalHeader'
export enum ConfirmModalState {
REVIEWING,
APPROVING_TOKEN,
PERMITTING,
PENDING_CONFIRMATION,
}
function isInApprovalPhase(confirmModalState: ConfirmModalState) {
return confirmModalState === ConfirmModalState.APPROVING_TOKEN || confirmModalState === ConfirmModalState.PERMITTING
}
function useConfirmModalState({
trade,
allowedSlippage,
onSwap,
allowance,
doesTradeDiffer,
}: {
trade: InterfaceTrade
allowedSlippage: Percent
onSwap: () => void
allowance: Allowance
doesTradeDiffer: boolean
}) {
const [confirmModalState, setConfirmModalState] = useState<ConfirmModalState>(ConfirmModalState.REVIEWING)
const [approvalError, setApprovalError] = useState<PendingModalError>()
const [pendingModalSteps, setPendingModalSteps] = useState<PendingConfirmModalState[]>([])
// This is a function instead of a memoized value because we do _not_ want it to update as the allowance changes.
// For example, if the user needs to complete 3 steps initially, we should always show 3 step indicators
// at the bottom of the modal, even after they complete steps 1 and 2.
const prepareSwapFlow = useCallback(() => {
const steps: PendingConfirmModalState[] = []
if (allowance.state === AllowanceState.REQUIRED && allowance.needsPermit2Approval) {
steps.push(ConfirmModalState.APPROVING_TOKEN)
}
if (allowance.state === AllowanceState.REQUIRED && allowance.needsSignature) {
steps.push(ConfirmModalState.PERMITTING)
}
steps.push(ConfirmModalState.PENDING_CONFIRMATION)
setPendingModalSteps(steps)
}, [allowance])
const { chainId } = useWeb3React()
const trace = useTrace()
const maximumAmountIn = useMaxAmountIn(trade, allowedSlippage)
const startSwapFlow = useCallback(async () => {
setApprovalError(undefined)
if (allowance.state === AllowanceState.REQUIRED) {
// Starts the approval process, by triggering either the Token Approval or the Permit signature.
try {
if (allowance.needsPermit2Approval) {
setConfirmModalState(ConfirmModalState.APPROVING_TOKEN)
await allowance.approve()
sendAnalyticsEvent(InterfaceEventName.APPROVE_TOKEN_TXN_SUBMITTED, {
chain_id: chainId,
token_symbol: maximumAmountIn?.currency.symbol,
token_address: maximumAmountIn?.currency.address,
...trace,
})
} else {
setConfirmModalState(ConfirmModalState.PERMITTING)
await allowance.permit()
}
} catch (e) {
setConfirmModalState(ConfirmModalState.REVIEWING)
if (didUserReject(e)) {
return
}
console.error(e)
setApprovalError(
allowance.needsPermit2Approval ? PendingModalError.TOKEN_APPROVAL_ERROR : PendingModalError.PERMIT_ERROR
)
}
} else {
setConfirmModalState(ConfirmModalState.PENDING_CONFIRMATION)
onSwap()
}
}, [allowance, chainId, maximumAmountIn?.currency.address, maximumAmountIn?.currency.symbol, onSwap, trace])
const previousPermitNeeded = usePrevious(
allowance.state === AllowanceState.REQUIRED ? allowance.needsPermit2Approval : undefined
)
useEffect(() => {
if (
allowance.state === AllowanceState.REQUIRED &&
allowance.needsSignature &&
// If the token approval switched from missing to fulfilled, trigger the next step (permit2 signature).
!allowance.needsPermit2Approval &&
previousPermitNeeded
) {
startSwapFlow()
}
}, [allowance, previousPermitNeeded, startSwapFlow])
useEffect(() => {
// Automatically triggers the next phase if the local modal state still thinks we're in the approval phase,
// but the allowance has been set. This will automaticaly trigger the swap.
if (isInApprovalPhase(confirmModalState) && allowance.state === AllowanceState.ALLOWED) {
// Caveat: prevents swap if trade has updated mid approval flow.
if (doesTradeDiffer) {
setConfirmModalState(ConfirmModalState.REVIEWING)
return
}
startSwapFlow()
}
}, [allowance, confirmModalState, doesTradeDiffer, startSwapFlow])
const onCancel = () => {
setConfirmModalState(ConfirmModalState.REVIEWING)
setApprovalError(undefined)
}
return { startSwapFlow, prepareSwapFlow, onCancel, confirmModalState, approvalError, pendingModalSteps }
}
export default function ConfirmSwapModal({ export default function ConfirmSwapModal({
trade, trade,
originalTrade, originalTrade,
onAcceptChanges, onAcceptChanges,
allowedSlippage, allowedSlippage,
allowance,
onConfirm, onConfirm,
onDismiss, onDismiss,
swapErrorMessage, swapErrorMessage,
attemptingTxn,
txHash, txHash,
swapQuoteReceivedDate, swapQuoteReceivedDate,
fiatValueInput, fiatValueInput,
...@@ -31,9 +162,9 @@ export default function ConfirmSwapModal({ ...@@ -31,9 +162,9 @@ export default function ConfirmSwapModal({
}: { }: {
trade: InterfaceTrade trade: InterfaceTrade
originalTrade?: InterfaceTrade originalTrade?: InterfaceTrade
attemptingTxn: boolean
txHash?: string txHash?: string
allowedSlippage: Percent allowedSlippage: Percent
allowance: Allowance
onAcceptChanges: () => void onAcceptChanges: () => void
onConfirm: () => void onConfirm: () => void
swapErrorMessage?: ReactNode swapErrorMessage?: ReactNode
...@@ -42,9 +173,18 @@ export default function ConfirmSwapModal({ ...@@ -42,9 +173,18 @@ export default function ConfirmSwapModal({
fiatValueInput: { data?: number; isLoading: boolean } fiatValueInput: { data?: number; isLoading: boolean }
fiatValueOutput: { data?: number; isLoading: boolean } fiatValueOutput: { data?: number; isLoading: boolean }
}) { }) {
const showAcceptChanges = useMemo( const doesTradeDiffer = originalTrade && tradeMeaningfullyDiffers(trade, originalTrade, allowedSlippage)
() => Boolean(originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)), const { startSwapFlow, onCancel, confirmModalState, approvalError, pendingModalSteps, prepareSwapFlow } =
[originalTrade, trade] useConfirmModalState({
trade,
allowedSlippage,
onSwap: onConfirm,
allowance,
doesTradeDiffer: Boolean(doesTradeDiffer),
})
const showAcceptChanges = Boolean(
trade && doesTradeDiffer && confirmModalState !== ConfirmModalState.PENDING_CONFIRMATION
) )
const [lastExecutionPrice, setLastExecutionPrice] = useState(trade?.executionPrice) const [lastExecutionPrice, setLastExecutionPrice] = useState(trade?.executionPrice)
...@@ -57,21 +197,36 @@ export default function ConfirmSwapModal({ ...@@ -57,21 +197,36 @@ export default function ConfirmSwapModal({
}, [lastExecutionPrice, setLastExecutionPrice, trade]) }, [lastExecutionPrice, setLastExecutionPrice, trade])
const onModalDismiss = useCallback(() => { const onModalDismiss = useCallback(() => {
if (showAcceptChanges) {
// If the user dismissed the modal while showing the price update, log the event as rejected.
sendAnalyticsEvent( sendAnalyticsEvent(
SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED, SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED,
formatSwapPriceUpdatedEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED) formatSwapPriceUpdatedEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED)
) )
}
onDismiss() onDismiss()
}, [onDismiss, priceUpdate, trade]) setTimeout(() => {
// Reset local state after the modal dismiss animation finishes, to avoid UI flicker as it dismisses
onCancel()
}, MODAL_TRANSITION_DURATION)
}, [onCancel, onDismiss, priceUpdate, showAcceptChanges, trade])
const modalHeader = useCallback(() => { const modalHeader = useCallback(() => {
if (confirmModalState !== ConfirmModalState.REVIEWING && !showAcceptChanges) {
return null
}
return <SwapModalHeader trade={trade} allowedSlippage={allowedSlippage} /> return <SwapModalHeader trade={trade} allowedSlippage={allowedSlippage} />
}, [allowedSlippage, trade]) }, [allowedSlippage, confirmModalState, showAcceptChanges, trade])
const modalBottom = useCallback(() => { const modalBottom = useCallback(() => {
if (confirmModalState === ConfirmModalState.REVIEWING || showAcceptChanges) {
return ( return (
<SwapModalFooter <SwapModalFooter
onConfirm={onConfirm} onConfirm={() => {
// Calculate the necessary steps once, before starting the flow.
prepareSwapFlow()
startSwapFlow()
}}
trade={trade} trade={trade}
hash={txHash} hash={txHash}
allowedSlippage={allowedSlippage} allowedSlippage={allowedSlippage}
...@@ -84,53 +239,49 @@ export default function ConfirmSwapModal({ ...@@ -84,53 +239,49 @@ export default function ConfirmSwapModal({
onAcceptChanges={onAcceptChanges} onAcceptChanges={onAcceptChanges}
/> />
) )
}
return (
<PendingModalContent
hideStepIndicators={pendingModalSteps.length === 1}
steps={pendingModalSteps}
currentStep={confirmModalState}
approvalCurrency={trade?.inputAmount?.currency}
txHash={txHash}
/>
)
}, [ }, [
confirmModalState,
showAcceptChanges,
pendingModalSteps,
trade, trade,
onConfirm,
txHash, txHash,
allowedSlippage, allowedSlippage,
showAcceptChanges,
swapErrorMessage, swapErrorMessage,
swapQuoteReceivedDate, swapQuoteReceivedDate,
fiatValueInput, fiatValueInput,
fiatValueOutput, fiatValueOutput,
onAcceptChanges, onAcceptChanges,
prepareSwapFlow,
startSwapFlow,
]) ])
// text to show while loading return (
const pendingText = ( <Trace modal={InterfaceModalName.CONFIRM_SWAP}>
<Trans> <Modal isOpen $scrollOverlay onDismiss={onModalDismiss} maxHeight={90}>
Swapping {trade.inputAmount.toSignificant(6)} {trade.inputAmount.currency?.symbol} for{' '} {approvalError || swapErrorMessage ? (
{trade.outputAmount.toSignificant(6)} {trade.outputAmount.currency?.symbol} <ErrorModalContent
</Trans> errorType={approvalError ?? PendingModalError.CONFIRMATION_ERROR}
) onRetry={startSwapFlow}
/>
const confirmationContent = useCallback(
() =>
swapErrorMessage ? (
<TransactionErrorContent onDismiss={onModalDismiss} message={swapErrorMessage} />
) : ( ) : (
<ConfirmationModalContent <ConfirmationModalContent
title={<Trans>Review Swap</Trans>} title={confirmModalState === ConfirmModalState.REVIEWING ? <Trans>Review Swap</Trans> : undefined}
onDismiss={onModalDismiss} onDismiss={onModalDismiss}
topContent={modalHeader} topContent={modalHeader}
bottomContent={modalBottom} bottomContent={modalBottom}
/> />
), )}
[onModalDismiss, modalBottom, modalHeader, swapErrorMessage] </Modal>
)
return (
<Trace modal={InterfaceModalName.CONFIRM_SWAP}>
<TransactionConfirmationModal
isOpen
onDismiss={onModalDismiss}
attemptingTxn={attemptingTxn}
hash={txHash}
content={confirmationContent}
pendingText={pendingText}
currencyToAdd={trade.outputAmount.currency}
/>
</Trace> </Trace>
) )
} }
import { t, Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core'
import { ButtonPrimary } from 'components/Button'
import { ColumnCenter } from 'components/Column'
import Loader from 'components/Icons/LoadingSpinner'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import QuestionHelper from 'components/QuestionHelper'
import Row from 'components/Row'
import AnimatedConfirmation from 'components/TransactionConfirmationModal/AnimatedConfirmation'
import { ReactNode } from 'react'
import { AlertTriangle } from 'react-feather'
import { useIsTransactionConfirmed } from 'state/transactions/hooks'
import styled, { DefaultTheme, useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme/components/text'
import { ConfirmModalState } from './ConfirmSwapModal'
const Container = styled(ColumnCenter)`
margin: 48px 0 28px;
`
const LogoContainer = styled.div`
position: relative;
display: flex;
border-radius: 50%;
overflow: visible;
`
const LogoLayer = styled.div`
z-index: 2;
`
const StepCircle = styled.div<{ active: boolean }>`
height: 10px;
width: 10px;
border-radius: 50%;
background-color: ${({ theme, active }) => (active ? theme.accentAction : theme.textTertiary)};
outline: 3px solid ${({ theme, active }) => (active ? theme.accentActionSoft : theme.accentTextLightTertiary)};
`
const SizedAnimatedConfirmation = styled(AnimatedConfirmation)`
height: 48px;
width: 48px;
`
// TODO: switch to LoaderV2 with updated API to support changing color and size.
const LoadingIndicator = styled(Loader)`
width: calc(100% + 8px);
height: calc(100% + 8px);
top: -4px;
left: -4px;
position: absolute;
`
// This component is used for all steps after ConfirmModalState.REVIEWING
export type PendingConfirmModalState = Extract<
ConfirmModalState,
ConfirmModalState.APPROVING_TOKEN | ConfirmModalState.PERMITTING | ConfirmModalState.PENDING_CONFIRMATION
>
interface PendingModalStep {
title: ReactNode
subtitle?: ReactNode
label?: ReactNode
tooltipText?: ReactNode
logo?: ReactNode
button?: ReactNode
}
interface PendingModalContentProps {
steps: PendingConfirmModalState[]
currentStep: PendingConfirmModalState
approvalCurrency?: Currency
hideStepIndicators?: boolean
txHash?: string
}
function CurrencyLoader({ currency }: { currency?: Currency }) {
const theme = useTheme()
return (
<LogoContainer>
<LogoLayer>
<CurrencyLogo currency={currency} size="48px" />
</LogoLayer>
<LoadingIndicator stroke={theme.textTertiary} />
</LogoContainer>
)
}
function getContent(
step: PendingConfirmModalState,
approvalCurrency: Currency | undefined,
confirmed: boolean,
theme: DefaultTheme
): PendingModalStep {
switch (step) {
case ConfirmModalState.APPROVING_TOKEN:
return {
title: t`Approve permit`,
subtitle: t`Proceed in wallet`,
label: t`Why are permits required?`,
tooltipText: t`Permit2 allows token approvals to be shared and managed across different applications.`,
logo: <CurrencyLoader currency={approvalCurrency} />,
}
case ConfirmModalState.PERMITTING:
return {
title: t`Approve ${approvalCurrency?.symbol ?? 'token'}`,
subtitle: t`Proceed in wallet`,
label: t`Why are approvals required?`,
tooltipText: t`This provides the Uniswap protocol access to your token for trading. For security, this will expire after 30 days.`,
logo: <CurrencyLoader currency={approvalCurrency} />,
}
case ConfirmModalState.PENDING_CONFIRMATION:
return {
title: t`Confirm Swap`,
subtitle: t`Proceed in wallet`,
logo: confirmed ? <SizedAnimatedConfirmation /> : <Loader stroke={theme.textTertiary} size="48px" />,
}
}
}
export function PendingModalContent({
steps,
currentStep,
approvalCurrency,
txHash,
hideStepIndicators,
}: PendingModalContentProps) {
const theme = useTheme()
const confirmed = useIsTransactionConfirmed(txHash)
const { logo, title, subtitle, label, tooltipText, button } = getContent(
currentStep,
approvalCurrency,
confirmed,
theme
)
return (
<Container gap="lg">
{logo}
{/* TODO: implement animations between title/subtitles of each step. */}
<ColumnCenter gap="md">
<ThemedText.HeadlineSmall data-testid="PendingModalContent-title">{title}</ThemedText.HeadlineSmall>
{subtitle && (
<ThemedText.LabelSmall data-testid="PendingModalContent-subtitle">{subtitle}</ThemedText.LabelSmall>
)}
<Row justify="center">
{label && (
<ThemedText.Caption color="textSecondary" data-testid="PendingModalContent-label">
{label}
</ThemedText.Caption>
)}
{tooltipText && <QuestionHelper text={tooltipText} />}
</Row>
</ColumnCenter>
{button && (
<Row justify="center" data-testid="PendingModalContent-button">
{button}
</Row>
)}
{!hideStepIndicators && (
<Row gap="14px" justify="center">
{steps.map((_, i) => {
return <StepCircle key={i} active={steps.indexOf(currentStep) === i} />
})}
</Row>
)}
</Container>
)
}
export enum PendingModalError {
TOKEN_APPROVAL_ERROR,
PERMIT_ERROR,
CONFIRMATION_ERROR,
}
interface ErrorModalContentProps {
errorType: PendingModalError
onRetry: () => void
}
function getErrorContent(errorType: PendingModalError) {
switch (errorType) {
case PendingModalError.TOKEN_APPROVAL_ERROR:
return {
title: t`Token approval failed`,
label: t`Why are approvals required?`,
tooltipText: t`This provides the Uniswap protocol access to your token for trading. For security, this will expire after 30 days.`,
}
case PendingModalError.PERMIT_ERROR:
return {
title: t`Permit approval failed`,
label: t`Why are permits required?`,
tooltipText: t`Permit2 allows token approvals to be shared and managed across different applications.`,
}
case PendingModalError.CONFIRMATION_ERROR:
return {
title: t`Confirmation failed`,
}
}
}
export function ErrorModalContent({ errorType, onRetry }: ErrorModalContentProps) {
const theme = useTheme()
const { title, label, tooltipText } = getErrorContent(errorType)
return (
<Container gap="lg">
<AlertTriangle strokeWidth={1} color={theme.accentFailure} size="48px" />
<ColumnCenter gap="md">
<ThemedText.HeadlineSmall>{title}</ThemedText.HeadlineSmall>
<Row justify="center">
{label && <ThemedText.Caption color="textSecondary">{label}</ThemedText.Caption>}
{tooltipText && <QuestionHelper text={tooltipText} />}
</Row>
</ColumnCenter>
<Row justify="center">
<ButtonPrimary marginX="24px" onClick={onRetry} data-testid="pending-modal-content-retry">
<Trans>Retry</Trans>
</ButtonPrimary>
</Row>
</Container>
)
}
import { CurrencyAmount, Percent, Token } from '@uniswap/sdk-core'
import { useMemo } from 'react'
import { InterfaceTrade } from 'state/routing/types'
export function useMaxAmountIn(trade: InterfaceTrade | undefined, allowedSlippage: Percent) {
return useMemo(() => {
const maximumAmountIn = trade?.maximumAmountIn(allowedSlippage)
return maximumAmountIn?.currency.isToken ? (maximumAmountIn as CurrencyAmount<Token>) : undefined
}, [allowedSlippage, trade])
}
...@@ -25,6 +25,10 @@ interface AllowanceRequired { ...@@ -25,6 +25,10 @@ interface AllowanceRequired {
token: Token token: Token
isApprovalLoading: boolean isApprovalLoading: boolean
approveAndPermit: () => Promise<void> approveAndPermit: () => Promise<void>
approve: () => Promise<void>
permit: () => Promise<void>
needsPermit2Approval: boolean
needsSignature: boolean
} }
export type Allowance = export type Allowance =
...@@ -101,24 +105,64 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -101,24 +105,64 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
} }
}, [addTransaction, shouldRequestApproval, shouldRequestSignature, updatePermitAllowance, updateTokenAllowance]) }, [addTransaction, shouldRequestApproval, shouldRequestSignature, updatePermitAllowance, updateTokenAllowance])
const approve = useCallback(async () => {
if (shouldRequestApproval) {
const { response, info } = await updateTokenAllowance()
addTransaction(response, info)
}
}, [addTransaction, shouldRequestApproval, updateTokenAllowance])
const permit = useCallback(async () => {
if (shouldRequestSignature) {
await updatePermitAllowance()
}
}, [shouldRequestSignature, updatePermitAllowance])
return useMemo(() => { return useMemo(() => {
if (token) { if (token) {
if (!tokenAllowance || !permitAllowance) { if (!tokenAllowance || !permitAllowance) {
return { state: AllowanceState.LOADING } return { state: AllowanceState.LOADING }
} else if (!(isPermitted || isSigned)) { } else if (shouldRequestSignature) {
return { token, state: AllowanceState.REQUIRED, isApprovalLoading: false, approveAndPermit } return {
token,
state: AllowanceState.REQUIRED,
isApprovalLoading: false,
approveAndPermit,
approve,
permit,
needsPermit2Approval: !isApproved,
needsSignature: shouldRequestSignature,
}
} else if (!isApproved) { } else if (!isApproved) {
return { token, state: AllowanceState.REQUIRED, isApprovalLoading, approveAndPermit } return {
token,
state: AllowanceState.REQUIRED,
isApprovalLoading,
approveAndPermit,
approve,
permit,
needsPermit2Approval: true,
needsSignature: shouldRequestSignature,
}
} }
} }
return { token, state: AllowanceState.ALLOWED, permitSignature: !isPermitted && isSigned ? signature : undefined } return {
token,
state: AllowanceState.ALLOWED,
permitSignature: !isPermitted && isSigned ? signature : undefined,
needsPermit2Approval: false,
needsSignature: false,
}
}, [ }, [
approve,
approveAndPermit, approveAndPermit,
isApprovalLoading, isApprovalLoading,
isApproved, isApproved,
isPermitted, isPermitted,
isSigned, isSigned,
permit,
permitAllowance, permitAllowance,
shouldRequestSignature,
signature, signature,
token, token,
tokenAllowance, tokenAllowance,
......
...@@ -8,6 +8,7 @@ import { useContract } from 'hooks/useContract' ...@@ -8,6 +8,7 @@ import { useContract } from 'hooks/useContract'
import { useSingleCallResult } from 'lib/hooks/multicall' import { useSingleCallResult } from 'lib/hooks/multicall'
import ms from 'ms.macro' import ms from 'ms.macro'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
const PERMIT_EXPIRATION = ms`30d` const PERMIT_EXPIRATION = ms`30d`
const PERMIT_SIG_EXPIRATION = ms`30m` const PERMIT_SIG_EXPIRATION = ms`30m`
...@@ -55,7 +56,6 @@ export function useUpdatePermitAllowance( ...@@ -55,7 +56,6 @@ export function useUpdatePermitAllowance(
onPermitSignature: (signature: PermitSignature) => void onPermitSignature: (signature: PermitSignature) => void
) { ) {
const { account, chainId, provider } = useWeb3React() const { account, chainId, provider } = useWeb3React()
return useCallback(async () => { return useCallback(async () => {
try { try {
if (!chainId) throw new Error('missing chainId') if (!chainId) throw new Error('missing chainId')
...@@ -81,8 +81,10 @@ export function useUpdatePermitAllowance( ...@@ -81,8 +81,10 @@ export function useUpdatePermitAllowance(
onPermitSignature?.({ ...permit, signature }) onPermitSignature?.({ ...permit, signature })
return return
} catch (e: unknown) { } catch (e: unknown) {
const message = didUserReject(e) ? 'User rejected signature' : e instanceof Error ? e.message : e
const symbol = token?.symbol ?? 'Token' const symbol = token?.symbol ?? 'Token'
throw new Error(`${symbol} permit allowance failed: ${e instanceof Error ? e.message : e}`) const error = new Error(`${symbol} permit allowance failed: ${message}`)
throw error
} }
}, [account, chainId, nonce, onPermitSignature, provider, spender, token]) }, [account, chainId, nonce, onPermitSignature, provider, spender, token])
} }
...@@ -5,6 +5,7 @@ import { useTokenContract } from 'hooks/useContract' ...@@ -5,6 +5,7 @@ import { useTokenContract } from 'hooks/useContract'
import { useSingleCallResult } from 'lib/hooks/multicall' import { useSingleCallResult } from 'lib/hooks/multicall'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { ApproveTransactionInfo, TransactionType } from 'state/transactions/types' import { ApproveTransactionInfo, TransactionType } from 'state/transactions/types'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
export function useTokenAllowance( export function useTokenAllowance(
token?: Token, token?: Token,
...@@ -58,8 +59,9 @@ export function useUpdateTokenAllowance( ...@@ -58,8 +59,9 @@ export function useUpdateTokenAllowance(
}, },
} }
} catch (e: unknown) { } catch (e: unknown) {
const message = didUserReject(e) ? 'User rejected' : e instanceof Error ? e.message : e
const symbol = amount?.currency.symbol ?? 'Token' const symbol = amount?.currency.symbol ?? 'Token'
throw new Error(`${symbol} token allowance failed: ${e instanceof Error ? e.message : e}`) throw new Error(`${symbol} token allowance failed: ${message}`)
} }
}, [amount, contract, spender]) }, [amount, contract, spender])
} }
...@@ -576,7 +576,7 @@ function AddLiquidity() { ...@@ -576,7 +576,7 @@ function AddLiquidity() {
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn} attemptingTxn={attemptingTxn}
hash={txHash} hash={txHash}
content={() => ( reviewContent={() => (
<ConfirmationModalContent <ConfirmationModalContent
title={<Trans>Add Liquidity</Trans>} title={<Trans>Add Liquidity</Trans>}
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
......
...@@ -330,7 +330,7 @@ export default function AddLiquidity() { ...@@ -330,7 +330,7 @@ export default function AddLiquidity() {
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn} attemptingTxn={attemptingTxn}
hash={txHash} hash={txHash}
content={() => ( reviewContent={() => (
<ConfirmationModalContent <ConfirmationModalContent
title={noLiquidity ? <Trans>You are creating a pool</Trans> : <Trans>You will receive</Trans>} title={noLiquidity ? <Trans>You are creating a pool</Trans> : <Trans>You will receive</Trans>}
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
......
...@@ -641,7 +641,7 @@ function PositionPageContent() { ...@@ -641,7 +641,7 @@ function PositionPageContent() {
onDismiss={() => setShowConfirm(false)} onDismiss={() => setShowConfirm(false)}
attemptingTxn={collecting} attemptingTxn={collecting}
hash={collectMigrationHash ?? ''} hash={collectMigrationHash ?? ''}
content={() => ( reviewContent={() => (
<ConfirmationModalContent <ConfirmationModalContent
title={<Trans>Claim fees</Trans>} title={<Trans>Claim fees</Trans>}
onDismiss={() => setShowConfirm(false)} onDismiss={() => setShowConfirm(false)}
......
...@@ -283,7 +283,7 @@ function Remove({ tokenId }: { tokenId: BigNumber }) { ...@@ -283,7 +283,7 @@ function Remove({ tokenId }: { tokenId: BigNumber }) {
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn} attemptingTxn={attemptingTxn}
hash={txnHash ?? ''} hash={txnHash ?? ''}
content={() => ( reviewContent={() => (
<ConfirmationModalContent <ConfirmationModalContent
title={<Trans>Remove Liquidity</Trans>} title={<Trans>Remove Liquidity</Trans>}
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
......
...@@ -449,7 +449,7 @@ function RemoveLiquidity() { ...@@ -449,7 +449,7 @@ function RemoveLiquidity() {
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn} attemptingTxn={attemptingTxn}
hash={txHash ? txHash : ''} hash={txHash ? txHash : ''}
content={() => ( reviewContent={() => (
<ConfirmationModalContent <ConfirmationModalContent
title={<Trans>You will receive</Trans>} title={<Trans>You will receive</Trans>}
onDismiss={handleDismissConfirmation} onDismiss={handleDismissConfirmation}
......
...@@ -13,15 +13,14 @@ import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' ...@@ -13,15 +13,14 @@ import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer' import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { sendEvent } from 'components/analytics' import { sendEvent } from 'components/analytics'
import Loader from 'components/Icons/LoadingSpinner'
import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert' import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert'
import PriceImpactWarning from 'components/swap/PriceImpactWarning' import PriceImpactWarning from 'components/swap/PriceImpactWarning'
import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown' import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { MouseoverTooltip } from 'components/Tooltip'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { isSupportedChain, SupportedChainId } from 'constants/chains' import { isSupportedChain, SupportedChainId } from 'constants/chains'
import useENSAddress from 'hooks/useENSAddress' import useENSAddress from 'hooks/useENSAddress'
import { useMaxAmountIn } from 'hooks/useMaxAmountIn'
import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance' import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance'
import usePrevious from 'hooks/usePrevious' import usePrevious from 'hooks/usePrevious'
import { useSwapCallback } from 'hooks/useSwapCallback' import { useSwapCallback } from 'hooks/useSwapCallback'
...@@ -30,13 +29,12 @@ import JSBI from 'jsbi' ...@@ -30,13 +29,12 @@ import JSBI from 'jsbi'
import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics' import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics'
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { ArrowDown, Info } from 'react-feather' import { ArrowDown } from 'react-feather'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Text } from 'rebass' import { Text } from 'rebass'
import { InterfaceTrade } from 'state/routing/types' import { InterfaceTrade } from 'state/routing/types'
import { TradeState } from 'state/routing/types' import { TradeState } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import invariant from 'tiny-invariant'
import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers' import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage' import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { switchChain } from 'utils/switchChain' import { switchChain } from 'utils/switchChain'
...@@ -335,7 +333,7 @@ export function Swap({ ...@@ -335,7 +333,7 @@ export function Swap({
}, [navigate]) }, [navigate])
// modal and loading // modal and loading
const [{ showConfirm, tradeToConfirm, swapErrorMessage, attemptingTxn, txHash }, setSwapState] = useState<{ const [{ showConfirm, tradeToConfirm, swapErrorMessage, txHash }, setSwapState] = useState<{
showConfirm: boolean showConfirm: boolean
tradeToConfirm?: InterfaceTrade tradeToConfirm?: InterfaceTrade
attemptingTxn: boolean attemptingTxn: boolean
...@@ -363,10 +361,7 @@ export function Swap({ ...@@ -363,10 +361,7 @@ export function Swap({
currencies[Field.INPUT] && currencies[Field.OUTPUT] && parsedAmounts[independentField]?.greaterThan(JSBI.BigInt(0)) currencies[Field.INPUT] && currencies[Field.OUTPUT] && parsedAmounts[independentField]?.greaterThan(JSBI.BigInt(0))
) )
const maximumAmountIn = useMemo(() => { const maximumAmountIn = useMaxAmountIn(trade, allowedSlippage)
const maximumAmountIn = trade?.maximumAmountIn(allowedSlippage)
return maximumAmountIn?.currency.isToken ? (maximumAmountIn as CurrencyAmount<Token>) : undefined
}, [allowedSlippage, trade])
const allowance = usePermit2Allowance( const allowance = usePermit2Allowance(
maximumAmountIn ?? maximumAmountIn ??
(parsedAmounts[Field.INPUT]?.currency.isToken (parsedAmounts[Field.INPUT]?.currency.isToken
...@@ -374,25 +369,6 @@ export function Swap({ ...@@ -374,25 +369,6 @@ export function Swap({
: undefined), : undefined),
isSupportedChain(chainId) ? UNIVERSAL_ROUTER_ADDRESS(chainId) : undefined isSupportedChain(chainId) ? UNIVERSAL_ROUTER_ADDRESS(chainId) : undefined
) )
const isApprovalLoading = allowance.state === AllowanceState.REQUIRED && allowance.isApprovalLoading
const [isAllowancePending, setIsAllowancePending] = useState(false)
const updateAllowance = useCallback(async () => {
invariant(allowance.state === AllowanceState.REQUIRED)
setIsAllowancePending(true)
try {
await allowance.approveAndPermit()
sendAnalyticsEvent(InterfaceEventName.APPROVE_TOKEN_TXN_SUBMITTED, {
chain_id: chainId,
token_symbol: maximumAmountIn?.currency.symbol,
token_address: maximumAmountIn?.currency.address,
...trace,
})
} catch (e) {
console.error(e)
} finally {
setIsAllowancePending(false)
}
}, [allowance, chainId, maximumAmountIn?.currency.address, maximumAmountIn?.currency.symbol, trace])
const maxInputAmount: CurrencyAmount<Currency> | undefined = useMemo( const maxInputAmount: CurrencyAmount<Currency> | undefined = useMemo(
() => maxAmountSpend(currencyBalances[Field.INPUT]), () => maxAmountSpend(currencyBalances[Field.INPUT]),
...@@ -558,10 +534,10 @@ export function Swap({ ...@@ -558,10 +534,10 @@ export function Swap({
trade={trade} trade={trade}
originalTrade={tradeToConfirm} originalTrade={tradeToConfirm}
onAcceptChanges={handleAcceptChanges} onAcceptChanges={handleAcceptChanges}
attemptingTxn={attemptingTxn}
txHash={txHash} txHash={txHash}
allowedSlippage={allowedSlippage} allowedSlippage={allowedSlippage}
onConfirm={handleSwap} onConfirm={handleSwap}
allowance={allowance}
swapErrorMessage={swapErrorMessage} swapErrorMessage={swapErrorMessage}
onDismiss={handleConfirmDismiss} onDismiss={handleConfirmDismiss}
swapQuoteReceivedDate={swapQuoteReceivedDate} swapQuoteReceivedDate={swapQuoteReceivedDate}
...@@ -714,41 +690,6 @@ export function Swap({ ...@@ -714,41 +690,6 @@ export function Swap({
<Trans>Insufficient liquidity for this trade.</Trans> <Trans>Insufficient liquidity for this trade.</Trans>
</ThemedText.DeprecatedMain> </ThemedText.DeprecatedMain>
</GrayCard> </GrayCard>
) : isValid && allowance.state === AllowanceState.REQUIRED ? (
<ButtonPrimary
onClick={updateAllowance}
disabled={isAllowancePending || isApprovalLoading}
style={{ gap: 14 }}
data-testid="swap-approve-button"
>
{isAllowancePending ? (
<>
<Loader size="20px" />
<Trans>Approve in your wallet</Trans>
</>
) : isApprovalLoading ? (
<>
<Loader size="20px" />
<Trans>Approval pending</Trans>
</>
) : (
<>
<div style={{ height: 20 }}>
<MouseoverTooltip
text={
<Trans>
Permission is required for Uniswap to swap each token. This will expire after one month for
your security.
</Trans>
}
>
<Info size={20} />
</MouseoverTooltip>
</div>
<Trans>Approve use of {currencies[Field.INPUT]?.symbol}</Trans>
</>
)}
</ButtonPrimary>
) : ( ) : (
<ButtonError <ButtonError
onClick={() => { onClick={() => {
...@@ -765,13 +706,8 @@ export function Swap({ ...@@ -765,13 +706,8 @@ export function Swap({
} }
}} }}
id="swap-button" id="swap-button"
disabled={ data-testid="swap-button"
!isValid || disabled={!isValid || routeIsSyncing || routeIsLoading || priceImpactTooHigh}
routeIsSyncing ||
routeIsLoading ||
priceImpactTooHigh ||
allowance.state !== AllowanceState.ALLOWED
}
error={isValid && priceImpactSeverity > 2 && allowance.state === AllowanceState.ALLOWED} error={isValid && priceImpactSeverity > 2 && allowance.state === AllowanceState.ALLOWED}
> >
<Text fontSize={20} fontWeight={600}> <Text fontSize={20} fontWeight={600}>
......
import { Trade } from '@uniswap/router-sdk' import { Percent } from '@uniswap/sdk-core'
import { Currency, TradeType } from '@uniswap/sdk-core' import { InterfaceTrade } from 'state/routing/types'
/** /**
* Returns true if the trade requires a confirmation of details before we can submit it * Returns true if the trade requires a confirmation of details before we can submit it
* @param args either a pair of V2 trades or a pair of V3 trades * @param args either a pair of V2 trades or a pair of V3 trades
*/ */
export function tradeMeaningfullyDiffers( export function tradeMeaningfullyDiffers(tradeA: InterfaceTrade, tradeB: InterfaceTrade, slippage: Percent): boolean {
...args: [Trade<Currency, Currency, TradeType>, Trade<Currency, Currency, TradeType>]
): boolean {
const [tradeA, tradeB] = args
return ( return (
tradeA.tradeType !== tradeB.tradeType || tradeA.tradeType !== tradeB.tradeType ||
!tradeA.inputAmount.currency.equals(tradeB.inputAmount.currency) || !tradeA.inputAmount.currency.equals(tradeB.inputAmount.currency) ||
!tradeA.inputAmount.equalTo(tradeB.inputAmount) ||
!tradeA.outputAmount.currency.equals(tradeB.outputAmount.currency) || !tradeA.outputAmount.currency.equals(tradeB.outputAmount.currency) ||
!tradeA.outputAmount.equalTo(tradeB.outputAmount) tradeB.executionPrice.lessThan(tradeA.worstExecutionPrice(slippage))
) )
} }
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