From e6362212c60054b5023e7d19230b23fe90f0294d Mon Sep 17 00:00:00 2001 From: eddie <66155195+just-toby@users.noreply.github.com> Date: Thu, 28 Sep 2023 10:52:35 -0700 Subject: [PATCH] feat: uniswapx deadline (#7376) * feat: uniswapX time-to-sign * fix: animation timing * fix: bug * fix: improve props and remove memo --- src/components/swap/ConfirmSwapModal.tsx | 31 +++++-- .../PendingModalContent/TransitionText.tsx | 81 +++++++++++++++++++ .../swap/PendingModalContent/animations.ts | 16 ++++ .../swap/PendingModalContent/index.tsx | 59 +++++++++----- src/hooks/useUniswapXSwapCallback.ts | 9 ++- src/pages/Swap/index.tsx | 14 ++-- src/utils/errors.ts | 17 +++- 7 files changed, 191 insertions(+), 36 deletions(-) create mode 100644 src/components/swap/PendingModalContent/TransitionText.tsx create mode 100644 src/components/swap/PendingModalContent/animations.ts diff --git a/src/components/swap/ConfirmSwapModal.tsx b/src/components/swap/ConfirmSwapModal.tsx index c2721117f1..0d983a6f3c 100644 --- a/src/components/swap/ConfirmSwapModal.tsx +++ b/src/components/swap/ConfirmSwapModal.tsx @@ -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 ( - {approvalError || swapFailed ? ( - + {errorType ? ( + ) : ( Review swap : undefined} diff --git a/src/components/swap/PendingModalContent/TransitionText.tsx b/src/components/swap/PendingModalContent/TransitionText.tsx new file mode 100644 index 0000000000..ea8428447d --- /dev/null +++ b/src/components/swap/PendingModalContent/TransitionText.tsx @@ -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(null) + useUnmountingAnimation(initialTextRef, () => AnimationType.EXITING) + + return ( + + {!transitioned && ( + + {initialText} + + )} + {transitioned && ( + + {transitionText} + + )} + + ) +} diff --git a/src/components/swap/PendingModalContent/animations.ts b/src/components/swap/PendingModalContent/animations.ts new file mode 100644 index 0000000000..e56246ef0a --- /dev/null +++ b/src/components/swap/PendingModalContent/animations.ts @@ -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}`}; +` diff --git a/src/components/swap/PendingModalContent/index.tsx b/src/components/swap/PendingModalContent/index.tsx index 6a113c8606..fb39e9c5c3 100644 --- a/src/components/swap/PendingModalContent/index.tsx +++ b/src/components/swap/PendingModalContent/index.tsx @@ -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): 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 ? : null if (swapPending && trade?.fillType === TradeFillType.UniswapX) { @@ -174,6 +174,19 @@ function getPendingConfirmationContent({ bottomLabel: null, } } + } else if (swapError instanceof SignatureExpiredError) { + return { + title: ( + Time expired} + transitionText={Retry confirmation} + onTransition={onRetryUniswapXSignature} + /> + ), + subtitle: tradeSummary, + bottomLabel: t`Proceed in your wallet`, + } } else { return { title, @@ -194,6 +207,8 @@ function useStepContents(args: ContentArgs): Record(null) @@ -341,7 +364,7 @@ export function PendingModalContent({ key={step} ref={step === currentStep ? currentStepContainerRef : undefined} > - + {stepContents[step].title} {stepContents[step].subtitle} diff --git a/src/hooks/useUniswapXSwapCallback.ts b/src/hooks/useUniswapXSwapCallback.ts index c8dbba6b00..0fd6e8b576 100644 --- a/src/hooks/useUniswapXSwapCallback.ts +++ b/src/hooks/useUniswapXSwapCallback.ts @@ -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)) diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 260bdf35c1..3bb4694782 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -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} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 40d8d5372b..6783408a61 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -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 + } +}