Commit 682fba21 authored by eddie's avatar eddie Committed by GitHub

feat: swap rejection error handling (#6576)

* 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

* 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

* wip: move approval to summary modal

* wip: not working

* feat: PendingModalContent tests

* feat: useMaxAmountIn

* fix: bad merge

* fix: naming

* 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

* chore: merge

* fix: update tests for new modal

* feat: add l2 chain logo to modal

* feat: add unit test

* fix: correct modal state when moving between steps

* fix: correct modal state when moving between steps

* fix: proper error handling of user rejection of swap

* feat: update e2e test

* fix: typecheck

* 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: custom error type

* fix: testid fix

* fix: add comment

* fix: tradeMeaningfullyDiffers improvement and prepareFlow fix

* fix: address  comments

* fix: headerContent prop

* feat: add comments explaining async state

* fix: test updates

* fix: nits

* fix: reduce nesting

* fix: address comments

* test: remove line from test for debugging

* fix: update tests

* fix: address  comments

* fix: update tests

* fix: more nesting in test

* fix: update test

* fix: update e2e test

* fix: update error test

* fix: reorganize test code
parent 0f5e8710
...@@ -5,6 +5,7 @@ import { getTestSelector } from '../utils' ...@@ -5,6 +5,7 @@ import { getTestSelector } from '../utils'
/** Initiates a swap. */ /** Initiates a swap. */
function initiateSwap() { function initiateSwap() {
cy.get('#swap-button').should('not.be.disabled')
// Completes the swap. // Completes the swap.
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get(getTestSelector('confirm-swap-button')).click() cy.get(getTestSelector('confirm-swap-button')).click()
......
...@@ -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('Confirmation failed').should('exist') cy.contains('Review Swap').should('exist')
cy.get('body').click('topRight') cy.get('body').click('topRight')
cy.contains('Confirmation failed').should('not.exist') cy.contains('Review Swap').should('not.exist')
}) })
}) })
......
...@@ -16,7 +16,7 @@ import { useMaxAmountIn } from 'hooks/useMaxAmountIn' ...@@ -16,7 +16,7 @@ import { useMaxAmountIn } from 'hooks/useMaxAmountIn'
import { Allowance, AllowanceState } from 'hooks/usePermit2Allowance' import { Allowance, AllowanceState } from 'hooks/usePermit2Allowance'
import usePrevious from 'hooks/usePrevious' import usePrevious from 'hooks/usePrevious'
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics' import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
import { ReactNode, useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { InterfaceTrade } from 'state/routing/types' import { InterfaceTrade } from 'state/routing/types'
import { isL2ChainId } from 'utils/chains' import { isL2ChainId } from 'utils/chains'
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters' import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
...@@ -158,7 +158,7 @@ export default function ConfirmSwapModal({ ...@@ -158,7 +158,7 @@ export default function ConfirmSwapModal({
allowance, allowance,
onConfirm, onConfirm,
onDismiss, onDismiss,
swapErrorMessage, swapError,
txHash, txHash,
swapQuoteReceivedDate, swapQuoteReceivedDate,
fiatValueInput, fiatValueInput,
...@@ -171,7 +171,7 @@ export default function ConfirmSwapModal({ ...@@ -171,7 +171,7 @@ export default function ConfirmSwapModal({
allowance: Allowance allowance: Allowance
onAcceptChanges: () => void onAcceptChanges: () => void
onConfirm: () => void onConfirm: () => void
swapErrorMessage?: ReactNode swapError?: Error
onDismiss: () => void onDismiss: () => void
swapQuoteReceivedDate?: Date swapQuoteReceivedDate?: Date
fiatValueInput: { data?: number; isLoading: boolean } fiatValueInput: { data?: number; isLoading: boolean }
...@@ -188,6 +188,14 @@ export default function ConfirmSwapModal({ ...@@ -188,6 +188,14 @@ export default function ConfirmSwapModal({
doesTradeDiffer: Boolean(doesTradeDiffer), doesTradeDiffer: Boolean(doesTradeDiffer),
}) })
const swapFailed = Boolean(swapError) && !didUserReject(swapError)
useEffect(() => {
// Reset the modal state if the user rejected the swap.
if (swapError && !swapFailed) {
onCancel()
}
}, [onCancel, swapError, swapFailed])
const showAcceptChanges = Boolean( const showAcceptChanges = Boolean(
trade && doesTradeDiffer && confirmModalState !== ConfirmModalState.PENDING_CONFIRMATION trade && doesTradeDiffer && confirmModalState !== ConfirmModalState.PENDING_CONFIRMATION
) )
...@@ -236,12 +244,12 @@ export default function ConfirmSwapModal({ ...@@ -236,12 +244,12 @@ export default function ConfirmSwapModal({
hash={txHash} hash={txHash}
allowedSlippage={allowedSlippage} allowedSlippage={allowedSlippage}
disabledConfirm={showAcceptChanges} disabledConfirm={showAcceptChanges}
swapErrorMessage={swapErrorMessage}
swapQuoteReceivedDate={swapQuoteReceivedDate} swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueInput} fiatValueInput={fiatValueInput}
fiatValueOutput={fiatValueOutput} fiatValueOutput={fiatValueOutput}
showAcceptChanges={showAcceptChanges} showAcceptChanges={showAcceptChanges}
onAcceptChanges={onAcceptChanges} onAcceptChanges={onAcceptChanges}
swapErrorMessage={swapFailed ? swapError?.message : undefined}
/> />
) )
} }
...@@ -261,11 +269,12 @@ export default function ConfirmSwapModal({ ...@@ -261,11 +269,12 @@ export default function ConfirmSwapModal({
trade, trade,
txHash, txHash,
allowedSlippage, allowedSlippage,
swapErrorMessage,
swapQuoteReceivedDate, swapQuoteReceivedDate,
fiatValueInput, fiatValueInput,
fiatValueOutput, fiatValueOutput,
onAcceptChanges, onAcceptChanges,
swapFailed,
swapError?.message,
prepareSwapFlow, prepareSwapFlow,
startSwapFlow, startSwapFlow,
]) ])
...@@ -288,7 +297,7 @@ export default function ConfirmSwapModal({ ...@@ -288,7 +297,7 @@ export default function ConfirmSwapModal({
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 || swapErrorMessage ? ( {approvalError || swapFailed ? (
<ErrorModalContent <ErrorModalContent
errorType={approvalError ?? PendingModalError.CONFIRMATION_ERROR} errorType={approvalError ?? PendingModalError.CONFIRMATION_ERROR}
onRetry={startSwapFlow} onRetry={startSwapFlow}
......
...@@ -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 { UserRejectedRequestError } from 'utils/errors'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage' import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
const PERMIT_EXPIRATION = ms`30d` const PERMIT_EXPIRATION = ms`30d`
...@@ -81,10 +82,11 @@ export function useUpdatePermitAllowance( ...@@ -81,10 +82,11 @@ 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'
const error = new Error(`${symbol} permit allowance failed: ${message}`) if (didUserReject(e)) {
throw error throw new UserRejectedRequestError(`${symbol} permit allowance failed: User rejected signature`)
}
throw new Error(`${symbol} permit allowance failed: ${e instanceof Error ? e.message : e}`)
} }
}, [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 { UserRejectedRequestError } from 'utils/errors'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage' import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
export function useTokenAllowance( export function useTokenAllowance(
...@@ -59,9 +60,11 @@ export function useUpdateTokenAllowance( ...@@ -59,9 +60,11 @@ 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: ${message}`) if (didUserReject(e)) {
throw new UserRejectedRequestError(`${symbol} token allowance failed: User rejected`)
}
throw new Error(`${symbol} token allowance failed: ${e instanceof Error ? e.message : e}`)
} }
}, [amount, contract, spender]) }, [amount, contract, spender])
} }
...@@ -12,6 +12,7 @@ import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' ...@@ -12,6 +12,7 @@ import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'
import { useCallback } from 'react' import { useCallback } from 'react'
import { trace } from 'tracing/trace' import { trace } from 'tracing/trace'
import { calculateGasMargin } from 'utils/calculateGasMargin' import { calculateGasMargin } from 'utils/calculateGasMargin'
import { UserRejectedRequestError } from 'utils/errors'
import isZero from 'utils/isZero' import isZero from 'utils/isZero'
import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'
...@@ -110,12 +111,16 @@ export function useUniversalRouterSwapCallback( ...@@ -110,12 +111,16 @@ export function useUniversalRouterSwapCallback(
} catch (swapError: unknown) { } catch (swapError: unknown) {
if (swapError instanceof ModifiedSwapError) throw swapError if (swapError instanceof ModifiedSwapError) throw swapError
// Cancellations are not failures, and must be accounted for as 'cancelled'.
if (didUserReject(swapError)) setTraceStatus('cancelled')
// GasEstimationErrors are already traced when they are thrown. // GasEstimationErrors are already traced when they are thrown.
if (!(swapError instanceof GasEstimationError)) setTraceError(swapError) if (!(swapError instanceof GasEstimationError)) setTraceError(swapError)
// Cancellations are not failures, and must be accounted for as 'cancelled'.
if (didUserReject(swapError)) {
setTraceStatus('cancelled')
// This error type allows us to distinguish between user rejections and other errors later too.
throw new UserRejectedRequestError(swapErrorToUserReadableMessage(swapError))
}
throw new Error(swapErrorToUserReadableMessage(swapError)) throw new Error(swapErrorToUserReadableMessage(swapError))
} }
}) })
......
...@@ -333,17 +333,17 @@ export function Swap({ ...@@ -333,17 +333,17 @@ export function Swap({
}, [navigate]) }, [navigate])
// modal and loading // modal and loading
const [{ showConfirm, tradeToConfirm, swapErrorMessage, txHash }, setSwapState] = useState<{ const [{ showConfirm, tradeToConfirm, swapError, txHash }, setSwapState] = useState<{
showConfirm: boolean showConfirm: boolean
tradeToConfirm?: InterfaceTrade tradeToConfirm?: InterfaceTrade
attemptingTxn: boolean attemptingTxn: boolean
swapErrorMessage?: string swapError?: Error
txHash?: string txHash?: string
}>({ }>({
showConfirm: false, showConfirm: false,
tradeToConfirm: undefined, tradeToConfirm: undefined,
attemptingTxn: false, attemptingTxn: false,
swapErrorMessage: undefined, swapError: undefined,
txHash: undefined, txHash: undefined,
}) })
...@@ -397,7 +397,7 @@ export function Swap({ ...@@ -397,7 +397,7 @@ export function Swap({
setSwapState((currentState) => ({ setSwapState((currentState) => ({
...currentState, ...currentState,
attemptingTxn: true, attemptingTxn: true,
swapErrorMessage: undefined, swapError: undefined,
txHash: undefined, txHash: undefined,
})) }))
swapCallback() swapCallback()
...@@ -405,7 +405,7 @@ export function Swap({ ...@@ -405,7 +405,7 @@ export function Swap({
setSwapState((currentState) => ({ setSwapState((currentState) => ({
...currentState, ...currentState,
attemptingTxn: false, attemptingTxn: false,
swapErrorMessage: undefined, swapError: undefined,
txHash: hash, txHash: hash,
})) }))
sendEvent({ sendEvent({
...@@ -430,7 +430,7 @@ export function Swap({ ...@@ -430,7 +430,7 @@ export function Swap({
setSwapState((currentState) => ({ setSwapState((currentState) => ({
...currentState, ...currentState,
attemptingTxn: false, attemptingTxn: false,
swapErrorMessage: error.message, swapError: error,
txHash: undefined, txHash: undefined,
})) }))
}) })
...@@ -538,7 +538,7 @@ export function Swap({ ...@@ -538,7 +538,7 @@ export function Swap({
allowedSlippage={allowedSlippage} allowedSlippage={allowedSlippage}
onConfirm={handleSwap} onConfirm={handleSwap}
allowance={allowance} allowance={allowance}
swapErrorMessage={swapErrorMessage} swapError={swapError}
onDismiss={handleConfirmDismiss} onDismiss={handleConfirmDismiss}
swapQuoteReceivedDate={swapQuoteReceivedDate} swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueTradeInput} fiatValueInput={fiatValueTradeInput}
...@@ -699,7 +699,7 @@ export function Swap({ ...@@ -699,7 +699,7 @@ export function Swap({
setSwapState({ setSwapState({
tradeToConfirm: trade, tradeToConfirm: trade,
attemptingTxn: false, attemptingTxn: false,
swapErrorMessage: undefined, swapError: undefined,
showConfirm: true, showConfirm: true,
txHash: undefined, txHash: undefined,
}) })
...@@ -725,7 +725,7 @@ export function Swap({ ...@@ -725,7 +725,7 @@ export function Swap({
</Text> </Text>
</ButtonError> </ButtonError>
)} )}
{isExpertMode && swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null} {Boolean(isExpertMode && swapError) && <SwapCallbackError error={swapError?.message} />}
</div> </div>
</AutoColumn> </AutoColumn>
</SwapWrapper> </SwapWrapper>
......
// 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) {
super(message)
this.name = 'UserRejectedRequestError'
}
}
import { t } from '@lingui/macro' import { t } from '@lingui/macro'
import { UserRejectedRequestError } from './errors'
function getReason(error: any): string | undefined { function getReason(error: any): string | undefined {
let reason: string | undefined let reason: string | undefined
while (error) { while (error) {
...@@ -26,7 +28,8 @@ export function didUserReject(error: any): boolean { ...@@ -26,7 +28,8 @@ export function didUserReject(error: any): boolean {
// For Coinbase: // For Coinbase:
reason?.match(/user denied/i) || reason?.match(/user denied/i) ||
// For Fireblocks // For Fireblocks
reason?.match(/user rejected/i) reason?.match(/user rejected/i) ||
error instanceof UserRejectedRequestError
) { ) {
return true return true
} }
......
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