feat: uniswapx deadline (#7376)

* feat: uniswapX time-to-sign

* fix: animation timing

* fix: bug

* fix: improve props and remove memo
This commit is contained in:
eddie 2023-09-28 10:52:35 -07:00 committed by GitHub
parent d63bdf1887
commit e6362212c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 191 additions and 36 deletions

@ -28,6 +28,7 @@ import styled from 'styled-components'
import { ThemedText } from 'theme/components' import { ThemedText } from 'theme/components'
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant'
import { isL2ChainId } from 'utils/chains' import { isL2ChainId } from 'utils/chains'
import { SignatureExpiredError } from 'utils/errors'
import { NumberType, useFormatter } from 'utils/formatNumbers' import { NumberType, useFormatter } from 'utils/formatNumbers'
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters' import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage' import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
@ -265,6 +266,7 @@ export default function ConfirmSwapModal({
onAcceptChanges, onAcceptChanges,
allowedSlippage, allowedSlippage,
allowance, allowance,
clearSwapState,
onConfirm, onConfirm,
onDismiss, onDismiss,
onCurrencySelection, onCurrencySelection,
@ -280,6 +282,7 @@ export default function ConfirmSwapModal({
allowedSlippage: Percent allowedSlippage: Percent
allowance: Allowance allowance: Allowance
onAcceptChanges: () => void onAcceptChanges: () => void
clearSwapState: () => void
onConfirm: () => void onConfirm: () => void
swapError?: Error swapError?: Error
onDismiss: () => void onDismiss: () => void
@ -293,7 +296,10 @@ export default function ConfirmSwapModal({
useConfirmModalState({ useConfirmModalState({
trade, trade,
allowedSlippage, allowedSlippage,
onSwap: onConfirm, onSwap: () => {
clearSwapState()
onConfirm()
},
onCurrencySelection, onCurrencySelection,
allowance, allowance,
doesTradeDiffer: Boolean(doesTradeDiffer), doesTradeDiffer: Boolean(doesTradeDiffer),
@ -376,6 +382,8 @@ export default function ConfirmSwapModal({
wrapTxHash={wrapTxHash} wrapTxHash={wrapTxHash}
tokenApprovalPending={allowance.state === AllowanceState.REQUIRED && allowance.isApprovalPending} tokenApprovalPending={allowance.state === AllowanceState.REQUIRED && allowance.isApprovalPending}
revocationPending={allowance.state === AllowanceState.REQUIRED && allowance.isRevocationPending} revocationPending={allowance.state === AllowanceState.REQUIRED && allowance.isRevocationPending}
swapError={swapError}
onRetryUniswapXSignature={onConfirm}
/> />
) )
}, [ }, [
@ -386,13 +394,14 @@ export default function ConfirmSwapModal({
swapResult, swapResult,
wrapTxHash, wrapTxHash,
allowance, allowance,
swapError,
startSwapFlow,
allowedSlippage, allowedSlippage,
fiatValueInput, fiatValueInput,
fiatValueOutput, fiatValueOutput,
onAcceptChanges, onAcceptChanges,
swapFailed, swapFailed,
swapError?.message, onConfirm,
startSwapFlow,
]) ])
const l2Badge = () => { const l2Badge = () => {
@ -410,14 +419,20 @@ export default function ConfirmSwapModal({
return undefined return undefined
} }
const getErrorType = () => {
if (approvalError) return approvalError
// SignatureExpiredError is a special case. The UI is shown in the PendingModalContent component.
if (swapError instanceof SignatureExpiredError) return
if (swapError && !didUserReject(swapError)) return PendingModalError.CONFIRMATION_ERROR
return
}
const errorType = getErrorType()
return ( return (
<Trace modal={InterfaceModalName.CONFIRM_SWAP}> <Trace modal={InterfaceModalName.CONFIRM_SWAP}>
<Modal isOpen $scrollOverlay onDismiss={onModalDismiss} maxHeight={90}> <Modal isOpen $scrollOverlay onDismiss={onModalDismiss} maxHeight={90}>
{approvalError || swapFailed ? ( {errorType ? (
<ErrorModalContent <ErrorModalContent errorType={errorType} onRetry={startSwapFlow} />
errorType={approvalError ?? PendingModalError.CONFIRMATION_ERROR}
onRetry={startSwapFlow}
/>
) : ( ) : (
<ConfirmationModalContent <ConfirmationModalContent
title={confirmModalState === ConfirmModalState.REVIEWING ? <Trans>Review swap</Trans> : undefined} title={confirmModalState === ConfirmModalState.REVIEWING ? <Trans>Review swap</Trans> : undefined}

@ -0,0 +1,81 @@
import { Trans } from '@lingui/macro'
import { useUnmountingAnimation } from 'hooks/useUnmountingAnimation'
import { ReactNode, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { slideInAnimation, slideOutAnimation } from './animations'
import { AnimationType } from './Logos'
interface TransitionTextProps {
initialText: ReactNode
transitionText: ReactNode
transitionTimeMs?: number
onTransition?: () => void
}
const Container = styled.div`
position: relative;
width: 100%;
min-height: 30px;
`
const InitialTextContainer = styled.div`
width: 100%;
height: 100%;
position: absolute;
transition: display ${({ theme }) => `${theme.transition.duration.fast} ${theme.transition.timing.inOut}`};
${slideInAnimation}
&.${AnimationType.EXITING} {
${slideOutAnimation}
}
`
const TransitionTextContainer = styled.div`
width: 100%;
height: 100%;
position: absolute;
transition: display ${({ theme }) => `${theme.transition.duration.fast} ${theme.transition.timing.inOut}`};
${slideInAnimation}
&.${AnimationType.EXITING} {
${slideOutAnimation}
}
`
export function TransitionText({
initialText,
transitionText,
transitionTimeMs = 1500,
onTransition,
}: TransitionTextProps) {
const [transitioned, setTransitioned] = useState(false)
useEffect(() => {
// Transition from initial text to transition text.
const timeout = setTimeout(() => {
if (!transitioned) {
setTransitioned(true)
onTransition?.()
}
}, transitionTimeMs)
return () => clearTimeout(timeout)
}, [onTransition, transitionTimeMs, transitioned])
const initialTextRef = useRef<HTMLDivElement>(null)
useUnmountingAnimation(initialTextRef, () => AnimationType.EXITING)
return (
<Container>
{!transitioned && (
<InitialTextContainer ref={initialTextRef}>
<Trans>{initialText}</Trans>
</InitialTextContainer>
)}
{transitioned && (
<TransitionTextContainer>
<Trans>{transitionText}</Trans>
</TransitionTextContainer>
)}
</Container>
)
}

@ -0,0 +1,16 @@
import { css, keyframes } from 'styled-components'
const slideIn = keyframes`
from { opacity: 0; transform: translateX(40px) }
to { opacity: 1; transform: translateX(0px) }
`
export const slideInAnimation = css`
animation: ${slideIn} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const slideOut = keyframes`
from { opacity: 1; transform: translateX(0px) }
to { opacity: 0; transform: translateX(-40px) }
`
export const slideOutAnimation = css`
animation: ${slideOut} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`

@ -14,13 +14,15 @@ import { InterfaceTrade, TradeFillType } from 'state/routing/types'
import { useOrder } from 'state/signatures/hooks' import { useOrder } from 'state/signatures/hooks'
import { UniswapXOrderDetails } from 'state/signatures/types' import { UniswapXOrderDetails } from 'state/signatures/types'
import { useIsTransactionConfirmed, useSwapTransactionStatus } from 'state/transactions/hooks' import { useIsTransactionConfirmed, useSwapTransactionStatus } from 'state/transactions/hooks'
import styled, { css, keyframes } from 'styled-components' import styled, { css } from 'styled-components'
import { ExternalLink } from 'theme/components' import { ExternalLink } from 'theme/components'
import { ThemedText } from 'theme/components/text' import { ThemedText } from 'theme/components/text'
import { SignatureExpiredError } from 'utils/errors'
import { getExplorerLink } from 'utils/getExplorerLink' import { getExplorerLink } from 'utils/getExplorerLink'
import { ExplorerDataType } from 'utils/getExplorerLink' import { ExplorerDataType } from 'utils/getExplorerLink'
import { ConfirmModalState } from '../ConfirmSwapModal' import { ConfirmModalState } from '../ConfirmSwapModal'
import { slideInAnimation, slideOutAnimation } from './animations'
import { import {
AnimatedEntranceConfirmationIcon, AnimatedEntranceConfirmationIcon,
AnimatedEntranceSubmittedIcon, AnimatedEntranceSubmittedIcon,
@ -31,6 +33,7 @@ import {
PaperIcon, PaperIcon,
} from './Logos' } from './Logos'
import { TradeSummary } from './TradeSummary' import { TradeSummary } from './TradeSummary'
import { TransitionText } from './TransitionText'
export const PendingModalContainer = styled(ColumnCenter)` export const PendingModalContainer = styled(ColumnCenter)`
margin: 48px 0 8px; margin: 48px 0 8px;
@ -51,21 +54,6 @@ const StepCircle = styled.div<{ active: boolean }>`
transition: background-color ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`}; transition: background-color ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
` `
const slideIn = keyframes`
from { opacity: 0; transform: translateX(40px) }
to { opacity: 1; transform: translateX(0px) }
`
const slideInAnimation = css`
animation: ${slideIn} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const slideOut = keyframes`
from { opacity: 1; transform: translateX(0px) }
to { opacity: 0; transform: translateX(-40px) }
`
const slideOutAnimation = css`
animation: ${slideOut} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const AnimationWrapper = styled.div` const AnimationWrapper = styled.div`
position: relative; position: relative;
width: 100%; width: 100%;
@ -77,7 +65,10 @@ const AnimationWrapper = styled.div`
const StepTitleAnimationContainer = styled(Column)<{ disableEntranceAnimation?: boolean }>` const StepTitleAnimationContainer = styled(Column)<{ disableEntranceAnimation?: boolean }>`
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%;
align-items: center; align-items: center;
display: flex;
flex-direction: column;
transition: display ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`}; transition: display ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
${({ disableEntranceAnimation }) => ${({ disableEntranceAnimation }) =>
!disableEntranceAnimation && !disableEntranceAnimation &&
@ -117,6 +108,8 @@ interface PendingModalContentProps {
hideStepIndicators?: boolean hideStepIndicators?: boolean
tokenApprovalPending?: boolean tokenApprovalPending?: boolean
revocationPending?: boolean revocationPending?: boolean
swapError?: Error | string
onRetryUniswapXSignature?: () => void
} }
interface ContentArgs { interface ContentArgs {
@ -130,6 +123,8 @@ interface ContentArgs {
swapResult?: SwapResult swapResult?: SwapResult
chainId?: number chainId?: number
order?: UniswapXOrderDetails order?: UniswapXOrderDetails
swapError?: Error | string
onRetryUniswapXSignature?: () => void
} }
function getPendingConfirmationContent({ function getPendingConfirmationContent({
@ -138,7 +133,12 @@ function getPendingConfirmationContent({
trade, trade,
chainId, chainId,
swapResult, swapResult,
}: Pick<ContentArgs, 'swapConfirmed' | 'swapPending' | 'trade' | 'chainId' | 'swapResult'>): PendingModalStep { swapError,
onRetryUniswapXSignature,
}: Pick<
ContentArgs,
'swapConfirmed' | 'swapPending' | 'trade' | 'chainId' | 'swapResult' | 'swapError' | 'onRetryUniswapXSignature'
>): PendingModalStep {
const title = swapPending ? t`Swap submitted` : swapConfirmed ? t`Swap success!` : t`Confirm Swap` const title = swapPending ? t`Swap submitted` : swapConfirmed ? t`Swap success!` : t`Confirm Swap`
const tradeSummary = trade ? <TradeSummary trade={trade} /> : null const tradeSummary = trade ? <TradeSummary trade={trade} /> : null
if (swapPending && trade?.fillType === TradeFillType.UniswapX) { if (swapPending && trade?.fillType === TradeFillType.UniswapX) {
@ -174,6 +174,19 @@ function getPendingConfirmationContent({
bottomLabel: null, bottomLabel: null,
} }
} }
} else if (swapError instanceof SignatureExpiredError) {
return {
title: (
<TransitionText
key={swapError.id}
initialText={<Trans>Time expired</Trans>}
transitionText={<Trans>Retry confirmation</Trans>}
onTransition={onRetryUniswapXSignature}
/>
),
subtitle: tradeSummary,
bottomLabel: t`Proceed in your wallet`,
}
} else { } else {
return { return {
title, title,
@ -194,6 +207,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
trade, trade,
swapResult, swapResult,
chainId, chainId,
swapError,
onRetryUniswapXSignature,
} = args } = args
return useMemo( return useMemo(
@ -236,6 +251,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
swapPending, swapPending,
swapResult, swapResult,
trade, trade,
swapError,
onRetryUniswapXSignature,
}), }),
}), }),
[ [
@ -248,6 +265,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
tokenApprovalPending, tokenApprovalPending,
trade, trade,
wrapPending, wrapPending,
swapError,
onRetryUniswapXSignature,
] ]
) )
} }
@ -261,6 +280,8 @@ export function PendingModalContent({
hideStepIndicators, hideStepIndicators,
tokenApprovalPending = false, tokenApprovalPending = false,
revocationPending = false, revocationPending = false,
swapError,
onRetryUniswapXSignature,
}: PendingModalContentProps) { }: PendingModalContentProps) {
const { chainId } = useWeb3React() const { chainId } = useWeb3React()
@ -283,6 +304,8 @@ export function PendingModalContent({
swapResult, swapResult,
trade, trade,
chainId, chainId,
swapError,
onRetryUniswapXSignature,
}) })
const currentStepContainerRef = useRef<HTMLDivElement>(null) const currentStepContainerRef = useRef<HTMLDivElement>(null)
@ -341,7 +364,7 @@ export function PendingModalContent({
key={step} key={step}
ref={step === currentStep ? currentStepContainerRef : undefined} ref={step === currentStep ? currentStepContainerRef : undefined}
> >
<ThemedText.SubHeaderLarge textAlign="center" data-testid="pending-modal-content-title"> <ThemedText.SubHeaderLarge width="100%" textAlign="center" data-testid="pending-modal-content-title">
{stepContents[step].title} {stepContents[step].title}
</ThemedText.SubHeaderLarge> </ThemedText.SubHeaderLarge>
<ThemedText.LabelSmall textAlign="center">{stepContents[step].subtitle}</ThemedText.LabelSmall> <ThemedText.LabelSmall textAlign="center">{stepContents[step].subtitle}</ThemedText.LabelSmall>

@ -9,7 +9,7 @@ import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'
import { useCallback } from 'react' import { useCallback } from 'react'
import { DutchOrderTrade, TradeFillType } from 'state/routing/types' import { DutchOrderTrade, TradeFillType } from 'state/routing/types'
import { trace } from 'tracing/trace' import { trace } from 'tracing/trace'
import { UserRejectedRequestError } from 'utils/errors' import { SignatureExpiredError, UserRejectedRequestError } from 'utils/errors'
import { signTypedData } from 'utils/signing' import { signTypedData } from 'utils/signing'
import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'
@ -90,11 +90,14 @@ export function useUniswapXSwapCallback({
const { domain, types, values } = updatedOrder.permitData() const { domain, types, values } = updatedOrder.permitData()
const signature = await signTypedData(provider.getSigner(account), domain, types, values) const signature = await signTypedData(provider.getSigner(account), domain, types, values)
if (deadline < Math.floor(Date.now() / 1000)) { if (startTime < Math.floor(Date.now() / 1000)) {
return signDutchOrder() throw new SignatureExpiredError()
} }
return { signature, updatedOrder } return { signature, updatedOrder }
} catch (swapError) { } catch (swapError) {
if (swapError instanceof SignatureExpiredError) {
throw swapError
}
if (didUserReject(swapError)) { if (didUserReject(swapError)) {
setTraceStatus('cancelled') setTraceStatus('cancelled')
throw new UserRejectedRequestError(swapErrorToUserReadableMessage(swapError)) throw new UserRejectedRequestError(swapErrorToUserReadableMessage(swapError))

@ -457,6 +457,14 @@ export function Swap({
}) })
}, [trade]) }, [trade])
const clearSwapState = useCallback(() => {
setSwapState((currentState) => ({
...currentState,
swapError: undefined,
swapResult: undefined,
}))
}, [])
const handleSwap = useCallback(() => { const handleSwap = useCallback(() => {
if (!swapCallback) { if (!swapCallback) {
return return
@ -464,11 +472,6 @@ export function Swap({
if (preTaxStablecoinPriceImpact && !confirmPriceImpactWithoutFee(preTaxStablecoinPriceImpact)) { if (preTaxStablecoinPriceImpact && !confirmPriceImpactWithoutFee(preTaxStablecoinPriceImpact)) {
return return
} }
setSwapState((currentState) => ({
...currentState,
swapError: undefined,
swapResult: undefined,
}))
swapCallback() swapCallback()
.then((result) => { .then((result) => {
setSwapState((currentState) => ({ setSwapState((currentState) => ({
@ -613,6 +616,7 @@ export function Swap({
onCurrencySelection={onCurrencySelection} onCurrencySelection={onCurrencySelection}
swapResult={swapResult} swapResult={swapResult}
allowedSlippage={allowedSlippage} allowedSlippage={allowedSlippage}
clearSwapState={clearSwapState}
onConfirm={handleSwap} onConfirm={handleSwap}
allowance={allowance} allowance={allowance}
swapError={swapError} swapError={swapError}

@ -1,7 +1,7 @@
// You may throw an instance of this class when the user rejects a request in their wallet.
import { t } from '@lingui/macro' import { t } from '@lingui/macro'
import { v4 as uuid } from 'uuid'
// You may throw an instance of this class when the user rejects a request in their wallet.
// The benefit is that you can distinguish this error from other errors using didUserReject(). // The benefit is that you can distinguish this error from other errors using didUserReject().
export class UserRejectedRequestError extends Error { export class UserRejectedRequestError extends Error {
constructor(message: string) { constructor(message: string) {
@ -23,3 +23,16 @@ export class WrongChainError extends Error {
super(t`Your wallet is connected to the wrong network.`) super(t`Your wallet is connected to the wrong network.`)
} }
} }
export class SignatureExpiredError extends Error {
private _id: string
constructor() {
super(t`Your signature has expired.`)
this.name = 'SignatureExpiredError'
this._id = `SignatureExpiredError-${uuid()}`
}
get id(): string {
return this._id
}
}