Skip to content

Commit

Permalink
feat: surface Portals slippage/revert errors in the UI (#8294)
Browse files Browse the repository at this point in the history
* feat: surface Portals slippage errors in the UI

* fix: clear tradeQuoteSlice state on trade input mount

* fix: engrish

* fix: do not surface final quote error at input time when resetting
tradeQuoteSlice

* feat: Add finalQuoteExecutionReverted TradeQuoteerror

* feat: gm

* fix: diacritics typo

* feat: keep it DRY

* feat: paranoia

* feat: bring back try/catch

* fix: autolint rug

---------

Co-authored-by: woody <[email protected]>
  • Loading branch information
gomesalexandre and woodenfurniture authored Dec 9, 2024
1 parent 32f8068 commit c6a71ea
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { fromAssetId } from '@shapeshiftoss/caip'
import type { EvmChainAdapter } from '@shapeshiftoss/chain-adapters'
import { evm } from '@shapeshiftoss/chain-adapters'
import type { KnownChainIds } from '@shapeshiftoss/types'
import { bnOrZero, convertBasisPointsToDecimalPercentage } from '@shapeshiftoss/utils'
import {
BigNumber,
bn,
bnOrZero,
convertBasisPointsToDecimalPercentage,
} from '@shapeshiftoss/utils'
import type { Result } from '@sniptt/monads'
import { Err, Ok } from '@sniptt/monads'
import { zeroAddress } from 'viem'
Expand All @@ -20,7 +25,7 @@ import { SwapperName, TradeQuoteError } from '../../../types'
import { getInputOutputRate, makeSwapErrorRight } from '../../../utils'
import { getTreasuryAddressFromChainId, isNativeEvmAsset } from '../../utils/helpers/helpers'
import { chainIdToPortalsNetwork } from '../constants'
import { fetchPortalsTradeOrder } from '../utils/fetchPortalsTradeOrder'
import { fetchPortalsTradeOrder, PortalsError } from '../utils/fetchPortalsTradeOrder'
import { isSupportedChainId } from '../utils/helpers'

export async function getPortalsTradeQuote(
Expand Down Expand Up @@ -86,30 +91,30 @@ export async function getPortalsTradeQuote(

if (!sendAddress) return Err(makeSwapErrorRight({ message: 'missing sendAddress' }))

try {
const portalsNetwork = chainIdToPortalsNetwork[chainId as KnownChainIds]

if (!portalsNetwork) {
return Err(
makeSwapErrorRight({
message: `unsupported ChainId`,
code: TradeQuoteError.UnsupportedChain,
details: { chainId: input.chainId },
}),
)
}
const portalsNetwork = chainIdToPortalsNetwork[chainId as KnownChainIds]

const sellAssetAddress = isNativeEvmAsset(sellAsset.assetId)
? zeroAddress
: fromAssetId(sellAsset.assetId).assetReference
const buyAssetAddress = isNativeEvmAsset(buyAsset.assetId)
? zeroAddress
: fromAssetId(buyAsset.assetId).assetReference
if (!portalsNetwork) {
return Err(
makeSwapErrorRight({
message: `unsupported ChainId`,
code: TradeQuoteError.UnsupportedChain,
details: { chainId: input.chainId },
}),
)
}

const sellAssetAddress = isNativeEvmAsset(sellAsset.assetId)
? zeroAddress
: fromAssetId(sellAsset.assetId).assetReference
const buyAssetAddress = isNativeEvmAsset(buyAsset.assetId)
? zeroAddress
: fromAssetId(buyAsset.assetId).assetReference

const inputToken = `${portalsNetwork}:${sellAssetAddress}`
const outputToken = `${portalsNetwork}:${buyAssetAddress}`
const inputToken = `${portalsNetwork}:${sellAssetAddress}`
const outputToken = `${portalsNetwork}:${buyAssetAddress}`

const portalsTradeOrderResponse = await fetchPortalsTradeOrder({
try {
const maybePortalsTradeOrderResponse = await fetchPortalsTradeOrder({
sender: sendAddress,
inputToken,
outputToken,
Expand All @@ -120,6 +125,68 @@ export async function getPortalsTradeQuote(
validate: true,
swapperConfig,
})
.then(res => Ok(res))
.catch(async err => {
if (err instanceof PortalsError) {
// We assume a PortalsError was thrown because the slippage tolerance was too high during simulation
// So we attempt another (failing) call with autoslippage which will give us the actual expected slippage
const portalsExpectedSlippage = await fetchPortalsTradeOrder({
sender: sendAddress,
inputToken,
outputToken,
inputAmount: sellAmountIncludingProtocolFeesCryptoBaseUnit,
autoSlippage: true,
partner: getTreasuryAddressFromChainId(sellAsset.chainId),
feePercentage: affiliateBpsPercentage,
validate: true,
swapperConfig,
})
// This should never happen but could in very rare cases if original call failed on slippage slightly over 2.5% but this one succeeds on slightly under 2.5%
.then(res => res.context.slippageTolerancePercentage)
.catch(err => (err as PortalsError).message.match(/Expected slippage is (.*?)%/)?.[1])

// This should never happen as we don't have auto-slippage on for `/portal` as of now (2024-12-06, see https://github.com/shapeshift/web/pull/8293)
// But as soon as Portals implement auto-slippage for the estimate endpoint, we will most likely re-enable it, assuming it actually works
if (err.message.includes('Auto slippage exceeds'))
return Err(
makeSwapErrorRight({
message: err.message,
details: {
expectedSlippage: portalsExpectedSlippage
? bn(portalsExpectedSlippage).toFixed(2, BigNumber.ROUND_HALF_UP)
: undefined,
},
cause: err,
code: TradeQuoteError.FinalQuoteMaxSlippageExceeded,
}),
)
if (err.message.includes('execution reverted'))
return Err(
makeSwapErrorRight({
message: err.message,
details: {
expectedSlippage: portalsExpectedSlippage
? bn(portalsExpectedSlippage).toFixed(2, BigNumber.ROUND_HALF_UP)
: undefined,
},
cause: err,
code: TradeQuoteError.FinalQuoteExecutionReverted,
}),
)
}
return Err(
makeSwapErrorRight({
message: 'failed to get Portals quote',
cause: err,
code: TradeQuoteError.NetworkFeeEstimationFailed,
}),
)
})

if (maybePortalsTradeOrderResponse.isErr())
return Err(maybePortalsTradeOrderResponse.unwrapErr())

const portalsTradeOrderResponse = maybePortalsTradeOrderResponse.unwrap()

const {
context: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AxiosError } from 'axios'
import axios from 'axios'
import type { Address } from 'viem'

Expand All @@ -9,14 +10,22 @@ type PortalsTradeOrderParams = {
inputToken: string
inputAmount: string
outputToken: string
slippageTolerancePercentage: number
// Technically optional, but we always want to use an affiliate addy
partner: string
feePercentage?: number
// Technically optional, but we want to explicitly specify validate
validate: boolean
swapperConfig: SwapperConfig
}
} & (
| {
slippageTolerancePercentage: number
autoSlippage?: never
}
| {
slippageTolerancePercentage?: never
autoSlippage: true
}
)

type PortalsTradeOrderEstimateParams = Omit<
PortalsTradeOrderParams,
Expand Down Expand Up @@ -75,12 +84,20 @@ type PortalsTradeOrderEstimateResponse = {
}
}

export class PortalsError extends Error {
constructor(message: string) {
super(message)
this.name = 'PortalsError'
}
}

export const fetchPortalsTradeOrder = async ({
sender,
inputToken,
inputAmount,
outputToken,
slippageTolerancePercentage,
autoSlippage,
partner,
feePercentage,
validate,
Expand All @@ -97,7 +114,9 @@ export const fetchPortalsTradeOrder = async ({
validate: validate.toString(),
})

params.append('slippageTolerancePercentage', slippageTolerancePercentage.toFixed(2)) // Portals API expects a string with at most 2 decimal places
if (!autoSlippage) {
params.append('slippageTolerancePercentage', slippageTolerancePercentage.toFixed(2)) // Portals API expects a string with at most 2 decimal places
}

if (feePercentage) {
params.append('feePercentage', feePercentage.toString())
Expand All @@ -108,8 +127,15 @@ export const fetchPortalsTradeOrder = async ({
return response.data
} catch (error) {
if (axios.isAxiosError(error)) {
const message = (error as AxiosError<{ message: string }>).response?.data?.message

if (message) {
throw new PortalsError(message)
}

throw new Error(`Failed to fetch Portals trade order: ${error.message}`)
}

throw error
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/swapper/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export enum TradeQuoteError {
InvalidResponse = 'InvalidResponse',
// an assertion triggered, indicating a bug
InternalError = 'InternalError',
// The max. slippage allowed for this trade has been exceeded at final quote time, as returned by the active quote swapper's API upstream
FinalQuoteMaxSlippageExceeded = 'FinalQuoteMaxSlippageExceeded',
// Execution reverted at final quote time, as returned by the active quote swapper's API upstream
FinalQuoteExecutionReverted = 'FinalQuoteExecutionReverted',
// catch-all for unknown issues
UnknownError = 'UnknownError',
}
Expand Down
4 changes: 3 additions & 1 deletion src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,9 @@
"noQuotesAvailable": "No quotes available for this trade",
"smartContractWalletNotSupported": "Smart contract wallet not supported",
"unsafeQuote": "This amount is below the recommended minimum for this pair (%{recommendedMin} %{symbol}). This could cause your trade to fail or loss of funds.",
"rateLimitExceeded": "Rate limit exceeded, try again later"
"rateLimitExceeded": "Rate limit exceeded, try again later",
"maxSlippageExceededWithExpectedSlippage": "The maximum allowed slippage tolerance for this trade has been exceeded during simulation. Expected slippage: %{expectedSlippage}%. Try again with a higher slipppage tolerance.",
"executionRevertedWithExpectedSlippage": "Execution reverted during execution. This may be due to insufficient slippage tolerance. Expected slippage: %{expectedSlippage}%. Try again with a higher slipppage tolerance."
},
"swappingComingSoonForWallet": "Swapping for %{walletName} is coming soon",
"recentTrades": "Recent Trades",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { useLocaleFormatter } from 'hooks/useLocaleFormatter/useLocaleFormatter'
import { getTxLink } from 'lib/getTxLink'
import { fromBaseUnit } from 'lib/math'
import {
selectActiveQuoteErrors,
selectHopExecutionMetadata,
selectHopSellAccountId,
} from 'state/slices/tradeQuoteSlice/selectors'
import { TransactionExecutionState } from 'state/slices/tradeQuoteSlice/types'
import { useAppSelector } from 'state/store'

import { SwapperIcon } from '../../TradeInput/components/SwapperIcon/SwapperIcon'
import { getQuoteErrorTranslation } from '../../TradeInput/getQuoteErrorTranslation'
import { useTradeExecution } from '../hooks/useTradeExecution'
import { getChainShortName } from '../utils/getChainShortName'
import { StatusIcon } from './StatusIcon'
Expand Down Expand Up @@ -67,7 +69,12 @@ export const HopTransactionStep = ({
swap: { state: swapTxState, sellTxHash, buyTxHash, message },
} = useAppSelector(state => selectHopExecutionMetadata(state, hopExecutionMetadataFilter))

const activeQuoteErrors = useAppSelector(selectActiveQuoteErrors)
const activeQuoteError = useMemo(() => activeQuoteErrors?.[0], [activeQuoteErrors])

// An error can be either an execution error, or an error returned when attempting to get the final quote
const isError = useMemo(() => swapTxState === TransactionExecutionState.Failed, [swapTxState])
const isQuoteError = useMemo(() => !!activeQuoteError, [activeQuoteError])

const executeTrade = useTradeExecution(hopIndex, activeTradeId)

Expand Down Expand Up @@ -165,7 +172,7 @@ export const HopTransactionStep = ({
size='sm'
onClick={handleSignTx}
isLoading={isFetching}
isDisabled={!tradeQuoteQueryData}
isDisabled={!tradeQuoteQueryData || isQuoteError}
width='100%'
>
{translate('common.signTransaction')}
Expand Down Expand Up @@ -197,6 +204,7 @@ export const HopTransactionStep = ({
handleSignTx,
isFetching,
tradeQuoteQueryData,
isQuoteError,
translate,
hopIndex,
activeTradeId,
Expand Down Expand Up @@ -237,6 +245,13 @@ export const HopTransactionStep = ({
fontWeight='bold'
/>
)}
{isQuoteError && (
<Text
color='text.error'
translation={getQuoteErrorTranslation(activeQuoteError!)}
fontWeight='bold'
/>
)}
{message && <Text translation={message} color='text.subtle' />}
{txLinks.map(({ txLink, txHash }) => (
<Link isExternal color='text.link' href={txLink} key={txHash}>
Expand All @@ -246,8 +261,10 @@ export const HopTransactionStep = ({
</VStack>
)
}, [
activeQuoteError,
isBridge,
isError,
isQuoteError,
message,
toCrypto,
tradeQuoteStep.buyAmountAfterFeesCryptoBaseUnit,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Alert, AlertIcon, Divider, useColorModeValue, useMediaQuery } from '@chakra-ui/react'
import { isEvmChainId } from '@shapeshiftoss/chain-adapters'
import type { AmountDisplayMeta } from '@shapeshiftoss/swapper'
import { SwapperName } from '@shapeshiftoss/swapper'
import { SwapperName, TradeQuoteError } from '@shapeshiftoss/swapper'
import { bnOrZero, fromBaseUnit, isSome, isUtxoChainId } from '@shapeshiftoss/utils'
import type { InterpolationOptions } from 'node-polyglot'
import { useCallback, useMemo } from 'react'
Expand Down Expand Up @@ -152,15 +152,24 @@ export const ConfirmSummary = ({
])

const quoteHasError = useMemo(() => {
const tradeQuoteError = activeQuoteErrors?.[0]

// Ensures final trade quote errors are not displayed at input time for one or two render cycles as tradeQuoteSlice when reset
// if backing out from an errored final quote back to input

if (tradeQuoteError && tradeQuoteError.error === TradeQuoteError.FinalQuoteMaxSlippageExceeded)
return false
if (tradeQuoteError && tradeQuoteError.error === TradeQuoteError.FinalQuoteMaxSlippageExceeded)
return false
if (!shouldShowTradeQuoteOrAwaitInput) return false
if (hasUserEnteredAmount && !isAnyTradeQuoteLoading && !isAnySwapperQuoteAvailable) return true
return !!activeQuoteErrors?.length || !!quoteRequestErrors?.length
}, [
activeQuoteErrors?.length,
hasUserEnteredAmount,
isAnySwapperQuoteAvailable,
activeQuoteErrors,
shouldShowTradeQuoteOrAwaitInput,
hasUserEnteredAmount,
isAnyTradeQuoteLoading,
isAnySwapperQuoteAvailable,
quoteRequestErrors?.length,
])

Expand Down Expand Up @@ -225,6 +234,12 @@ export const ConfirmSummary = ({
return getQuoteRequestErrorTranslation(quoteRequestError)
case !!quoteResponseError:
return getQuoteRequestErrorTranslation(quoteResponseError)
// Ensures final trade quote errors are not displayed at input time for one or two render cycles as tradeQuoteSlice when reset
// if backing out from an errored final quote back to input
case tradeQuoteError &&
(tradeQuoteError.error === TradeQuoteError.FinalQuoteMaxSlippageExceeded ||
tradeQuoteError.error === TradeQuoteError.FinalQuoteExecutionReverted):
return 'trade.previewTrade'
case !!tradeQuoteError:
return getQuoteErrorTranslation(tradeQuoteError!)
case !isAnyTradeQuoteLoading && !isAnySwapperQuoteAvailable:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export const getQuoteErrorTranslation = (
return 'trade.errors.networkFeeEstimateFailed'
case SwapperTradeQuoteError.RateLimitExceeded:
return 'trade.errors.rateLimitExceeded'
case SwapperTradeQuoteError.FinalQuoteMaxSlippageExceeded:
return 'trade.errors.maxSlippageExceededWithExpectedSlippage'
case SwapperTradeQuoteError.FinalQuoteExecutionReverted:
return 'trade.errors.executionRevertedWithExpectedSlippage'
case TradeQuoteValidationError.UnknownError:
case SwapperTradeQuoteError.UnknownError:
case SwapperTradeQuoteError.InternalError:
Expand Down
9 changes: 9 additions & 0 deletions src/state/apis/swapper/helpers/validateTradeQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const validateTradeQuote = (
if (!quote || error) {
const tradeQuoteError = (() => {
const errorCode = error?.code
const errorDetails = error?.details
switch (errorCode) {
case SwapperTradeQuoteError.UnsupportedChain:
case SwapperTradeQuoteError.CrossChainNotSupported:
Expand All @@ -78,6 +79,14 @@ export const validateTradeQuote = (
case SwapperTradeQuoteError.InvalidResponse:
// no metadata associated with this error
return { error: errorCode }
case SwapperTradeQuoteError.FinalQuoteMaxSlippageExceeded:
case SwapperTradeQuoteError.FinalQuoteExecutionReverted: {
const { expectedSlippage }: { expectedSlippage?: string | undefined } = errorDetails ?? {}
return {
error: errorCode,
meta: { expectedSlippage: expectedSlippage ? expectedSlippage : 'Unknown' },
}
}
case SwapperTradeQuoteError.SellAmountBelowMinimum: {
const {
minAmountCryptoBaseUnit,
Expand Down

0 comments on commit c6a71ea

Please sign in to comment.