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 invariant from 'tiny-invariant'
import { isL2ChainId } from 'utils/chains'
import { SignatureExpiredError } from 'utils/errors'
import { NumberType, useFormatter } from 'utils/formatNumbers'
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
@ -265,6 +266,7 @@ export default function ConfirmSwapModal({
onAcceptChanges,
allowedSlippage,
allowance,
clearSwapState,
onConfirm,
onDismiss,
onCurrencySelection,
@ -280,6 +282,7 @@ export default function ConfirmSwapModal({
allowedSlippage: Percent
allowance: Allowance
onAcceptChanges: () => void
clearSwapState: () => void
onConfirm: () => void
swapError?: Error
onDismiss: () => void
@ -293,7 +296,10 @@ export default function ConfirmSwapModal({
useConfirmModalState({
trade,
allowedSlippage,
onSwap: onConfirm,
onSwap: () => {
clearSwapState()
onConfirm()
},
onCurrencySelection,
allowance,
doesTradeDiffer: Boolean(doesTradeDiffer),
@ -376,6 +382,8 @@ export default function ConfirmSwapModal({
wrapTxHash={wrapTxHash}
tokenApprovalPending={allowance.state === AllowanceState.REQUIRED && allowance.isApprovalPending}
revocationPending={allowance.state === AllowanceState.REQUIRED && allowance.isRevocationPending}
swapError={swapError}
onRetryUniswapXSignature={onConfirm}
/>
)
}, [
@ -386,13 +394,14 @@ export default function ConfirmSwapModal({
swapResult,
wrapTxHash,
allowance,
swapError,
startSwapFlow,
allowedSlippage,
fiatValueInput,
fiatValueOutput,
onAcceptChanges,
swapFailed,
swapError?.message,
startSwapFlow,
onConfirm,
])
const l2Badge = () => {
@ -410,14 +419,20 @@ export default function ConfirmSwapModal({
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 (
<Trace modal={InterfaceModalName.CONFIRM_SWAP}>
<Modal isOpen $scrollOverlay onDismiss={onModalDismiss} maxHeight={90}>
{approvalError || swapFailed ? (
<ErrorModalContent
errorType={approvalError ?? PendingModalError.CONFIRMATION_ERROR}
onRetry={startSwapFlow}
/>
{errorType ? (
<ErrorModalContent errorType={errorType} onRetry={startSwapFlow} />
) : (
<ConfirmationModalContent
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 { UniswapXOrderDetails } from 'state/signatures/types'
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 { ThemedText } from 'theme/components/text'
import { SignatureExpiredError } from 'utils/errors'
import { getExplorerLink } from 'utils/getExplorerLink'
import { ExplorerDataType } from 'utils/getExplorerLink'
import { ConfirmModalState } from '../ConfirmSwapModal'
import { slideInAnimation, slideOutAnimation } from './animations'
import {
AnimatedEntranceConfirmationIcon,
AnimatedEntranceSubmittedIcon,
@ -31,6 +33,7 @@ import {
PaperIcon,
} from './Logos'
import { TradeSummary } from './TradeSummary'
import { TransitionText } from './TransitionText'
export const PendingModalContainer = styled(ColumnCenter)`
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}`};
`
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`
position: relative;
width: 100%;
@ -77,7 +65,10 @@ const AnimationWrapper = styled.div`
const StepTitleAnimationContainer = styled(Column)<{ disableEntranceAnimation?: boolean }>`
position: absolute;
width: 100%;
height: 100%;
align-items: center;
display: flex;
flex-direction: column;
transition: display ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
${({ disableEntranceAnimation }) =>
!disableEntranceAnimation &&
@ -117,6 +108,8 @@ interface PendingModalContentProps {
hideStepIndicators?: boolean
tokenApprovalPending?: boolean
revocationPending?: boolean
swapError?: Error | string
onRetryUniswapXSignature?: () => void
}
interface ContentArgs {
@ -130,6 +123,8 @@ interface ContentArgs {
swapResult?: SwapResult
chainId?: number
order?: UniswapXOrderDetails
swapError?: Error | string
onRetryUniswapXSignature?: () => void
}
function getPendingConfirmationContent({
@ -138,7 +133,12 @@ function getPendingConfirmationContent({
trade,
chainId,
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 tradeSummary = trade ? <TradeSummary trade={trade} /> : null
if (swapPending && trade?.fillType === TradeFillType.UniswapX) {
@ -174,6 +174,19 @@ function getPendingConfirmationContent({
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 {
return {
title,
@ -194,6 +207,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
trade,
swapResult,
chainId,
swapError,
onRetryUniswapXSignature,
} = args
return useMemo(
@ -236,6 +251,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
swapPending,
swapResult,
trade,
swapError,
onRetryUniswapXSignature,
}),
}),
[
@ -248,6 +265,8 @@ function useStepContents(args: ContentArgs): Record<PendingConfirmModalState, Pe
tokenApprovalPending,
trade,
wrapPending,
swapError,
onRetryUniswapXSignature,
]
)
}
@ -261,6 +280,8 @@ export function PendingModalContent({
hideStepIndicators,
tokenApprovalPending = false,
revocationPending = false,
swapError,
onRetryUniswapXSignature,
}: PendingModalContentProps) {
const { chainId } = useWeb3React()
@ -283,6 +304,8 @@ export function PendingModalContent({
swapResult,
trade,
chainId,
swapError,
onRetryUniswapXSignature,
})
const currentStepContainerRef = useRef<HTMLDivElement>(null)
@ -341,7 +364,7 @@ export function PendingModalContent({
key={step}
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}
</ThemedText.SubHeaderLarge>
<ThemedText.LabelSmall textAlign="center">{stepContents[step].subtitle}</ThemedText.LabelSmall>

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

@ -457,6 +457,14 @@ export function Swap({
})
}, [trade])
const clearSwapState = useCallback(() => {
setSwapState((currentState) => ({
...currentState,
swapError: undefined,
swapResult: undefined,
}))
}, [])
const handleSwap = useCallback(() => {
if (!swapCallback) {
return
@ -464,11 +472,6 @@ export function Swap({
if (preTaxStablecoinPriceImpact && !confirmPriceImpactWithoutFee(preTaxStablecoinPriceImpact)) {
return
}
setSwapState((currentState) => ({
...currentState,
swapError: undefined,
swapResult: undefined,
}))
swapCallback()
.then((result) => {
setSwapState((currentState) => ({
@ -613,6 +616,7 @@ export function Swap({
onCurrencySelection={onCurrencySelection}
swapResult={swapResult}
allowedSlippage={allowedSlippage}
clearSwapState={clearSwapState}
onConfirm={handleSwap}
allowance={allowance}
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 { 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().
export class UserRejectedRequestError extends Error {
constructor(message: string) {
@ -23,3 +23,16 @@ export class WrongChainError extends Error {
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
}
}