From b87d6a2fefa2890e0c565ed917ab6e3df3dac8f3 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Thu, 18 Apr 2024 12:19:49 -0400 Subject: [PATCH] Price Impact Warning (#5635) * price impact warning done * i18n stuff * i18n stuff but intro bug * fix i18n and fix bug with sliderXPosition in positive dir * add missing return statement * add time estimates, and add colors depending on high/severe * fix exchange rate bubble pushing the swap warning component down * fix exchange rate bubble pushing the swap warning component down * fix position * add unknown price warning * added quote errors to price warning component * change colors for warnings --- src/__swaps__/screens/Swap/Swap.tsx | 7 +- .../Swap/components/ExchangeRateBubble.tsx | 3 +- .../screens/Swap/components/SwapWarning.tsx | 107 ++++++++++ .../Swap/hooks/useAnimatedSwapStyles.ts | 31 ++- .../Swap/hooks/useSwapInputsController.ts | 5 +- .../screens/Swap/hooks/useSwapWarning.ts | 198 ++++++++++++++++++ .../screens/Swap/providers/swap-provider.tsx | 11 +- src/languages/en_US.json | 16 ++ 8 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 src/__swaps__/screens/Swap/components/SwapWarning.tsx create mode 100644 src/__swaps__/screens/Swap/hooks/useSwapWarning.ts diff --git a/src/__swaps__/screens/Swap/Swap.tsx b/src/__swaps__/screens/Swap/Swap.tsx index 4aa8d3962cb..f121bf5a504 100644 --- a/src/__swaps__/screens/Swap/Swap.tsx +++ b/src/__swaps__/screens/Swap/Swap.tsx @@ -16,6 +16,7 @@ import { SwapInputAsset } from '@/__swaps__/screens/Swap/components/controls/Swa import { SwapOutputAsset } from '@/__swaps__/screens/Swap/components/controls/SwapOutputAsset'; import { SwapNavbar } from '@/__swaps__/screens/Swap/components/SwapNavbar'; import { SwapAmountInputs } from '@/__swaps__/screens/Swap/components/controls/SwapAmountInputs'; +import { SwapWarning } from './components/SwapWarning'; /** README * This prototype is largely driven by Reanimated and Gesture Handler, which @@ -63,7 +64,11 @@ export function SwapScreen() { - + + + + + diff --git a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx index afc55c97413..5df989a953c 100644 --- a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx +++ b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx @@ -9,7 +9,6 @@ import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider export const ExchangeRateBubble = () => { const { isDarkMode } = useColorMode(); const { AnimatedSwapStyles, SwapInputController } = useSwapContext(); - const [exchangeRateIndex, setExchangeRateIndex] = useState(0); const assetToSellPrice = useSharedValue(0); @@ -149,7 +148,7 @@ export const ExchangeRateBubble = () => { justifyContent="center" paddingHorizontal="24px" paddingVertical="12px" - style={[AnimatedSwapStyles.hideWhenInputsExpanded, { alignSelf: 'center' }]} + style={[AnimatedSwapStyles.hideWhenInputsExpandedOrPriceImpact, { alignSelf: 'center', position: 'absolute', top: 4 }]} > { + const { AnimatedSwapStyles, SwapWarning } = useSwapContext(); + + const red = useForegroundColor('red'); + const orange = useForegroundColor('orange'); + + const colorMap = { + [SwapWarningType.severe]: red, + [SwapWarningType.unknown]: red, + [SwapWarningType.long_wait]: orange, + [SwapWarningType.none]: orange, + [SwapWarningType.high]: orange, + + // swap quote errors + [SwapWarningType.no_quote_available]: red, + [SwapWarningType.insufficient_liquidity]: red, + [SwapWarningType.fee_on_transfer]: red, + [SwapWarningType.no_route_found]: red, + }; + + const warningMessagesPrefix: Record = { + [SwapWarningType.none]: { + title: '', + subtext: '', + }, + [SwapWarningType.high]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.price_impact.you_are_losing)}`, + subtext: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + addDisplayToTitle: true, + }, + [SwapWarningType.unknown]: { + title: `􀇿 ${SwapWarning.swapWarning.value.display}`, + subtext: i18n.t(i18n.l.exchange.price_impact.unknown_price.description), + }, + [SwapWarningType.severe]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.price_impact.you_are_losing)}`, + subtext: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + addDisplayToTitle: true, + }, + [SwapWarningType.long_wait]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.price_impact.long_wait.title)}`, + subtext: `${i18n.t(i18n.l.exchange.price_impact.long_wait.description)}`, + addDisplayToTitle: true, + }, + + // swap quote errors + [SwapWarningType.no_quote_available]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.quote_errors.no_quote_available)}`, + subtext: '', + }, + [SwapWarningType.insufficient_liquidity]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.quote_errors.insufficient_liquidity)}`, + subtext: '', + }, + [SwapWarningType.fee_on_transfer]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.quote_errors.fee_on_transfer)}`, + subtext: '', + }, + [SwapWarningType.no_route_found]: { + title: `􀇿 ${i18n.t(i18n.l.exchange.quote_errors.no_route_found)}`, + subtext: '', + }, + }; + + const warningTitle = useDerivedValue(() => { + const potentialTitle = warningMessagesPrefix[SwapWarning.swapWarning.value.type].title; + const addDisplayToTitle = warningMessagesPrefix[SwapWarning.swapWarning.value.type].addDisplayToTitle; + return addDisplayToTitle ? `${potentialTitle} ${SwapWarning.swapWarning.value.display}` : potentialTitle; + }); + + const warningSubtext = useDerivedValue(() => { + return warningMessagesPrefix[SwapWarning.swapWarning.value.type].subtext; + }); + + const warningStyles = useAnimatedStyle(() => ({ + color: colorMap[SwapWarning.swapWarning.value.type], + })); + + const warningSubtextStyles = useAnimatedStyle(() => ({ + display: warningSubtext.value.trim() !== '' ? 'flex' : 'none', + })); + + return ( + + + + + + + + + ); +}; diff --git a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts index 56a3a249b27..fbd2f3ff7ac 100644 --- a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts @@ -10,15 +10,18 @@ import { } from '@/__swaps__/screens/Swap/constants'; import { opacityWorklet } from '@/__swaps__/utils/swaps'; import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { SwapWarningType, useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; import { spinnerExitConfig } from '@/__swaps__/components/animations/AnimatedSpinner'; export function useAnimatedSwapStyles({ SwapInputController, + SwapWarning, inputProgress, outputProgress, isFetching, }: { SwapInputController: ReturnType; + SwapWarning: ReturnType; inputProgress: SharedValue; outputProgress: SharedValue; isFetching: SharedValue; @@ -45,10 +48,29 @@ export function useAnimatedSwapStyles({ }; }); - const hideWhenInputsExpanded = useAnimatedStyle(() => { + const hideWhenInputsExpandedOrNoPriceImpact = useAnimatedStyle(() => { return { - opacity: inputProgress.value > 0 || outputProgress.value > 0 ? withTiming(0, fadeConfig) : withTiming(1, fadeConfig), - pointerEvents: inputProgress.value > 0 || outputProgress.value > 0 ? 'none' : 'auto', + opacity: + SwapWarning.swapWarning.value.type === SwapWarningType.none || inputProgress.value > 0 || outputProgress.value > 0 + ? withTiming(0, fadeConfig) + : withTiming(1, fadeConfig), + pointerEvents: + SwapWarning.swapWarning.value.type === SwapWarningType.none || inputProgress.value > 0 || outputProgress.value > 0 + ? 'none' + : 'auto', + }; + }); + + const hideWhenInputsExpandedOrPriceImpact = useAnimatedStyle(() => { + return { + opacity: + SwapWarning.swapWarning.value.type !== SwapWarningType.none || inputProgress.value > 0 || outputProgress.value > 0 + ? withTiming(0, fadeConfig) + : withTiming(1, fadeConfig), + pointerEvents: + SwapWarning.swapWarning.value.type !== SwapWarningType.none || inputProgress.value > 0 || outputProgress.value > 0 + ? 'none' + : 'auto', }; }); @@ -163,7 +185,8 @@ export function useAnimatedSwapStyles({ return { flipButtonStyle, focusedSearchStyle, - hideWhenInputsExpanded, + hideWhenInputsExpandedOrPriceImpact, + hideWhenInputsExpandedOrNoPriceImpact, inputStyle, inputTokenListStyle, keyboardStyle, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index 9af2612a5f6..b5b7af4957b 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -413,6 +413,9 @@ export function useSwapInputsController({ quoteParams.swapType === SwapType.crossChain ? await getCrosschainQuote(quoteParams) : await getQuote(quoteParams) ) as Quote | CrosschainQuote | QuoteError; + quote.value = quoteResponse; + logger.debug(`[useSwapInputsController] quote response`, { quoteResponse }); + // todo - show quote error if (!quoteResponse || (quoteResponse as QuoteError)?.error) { logger.debug(`[useSwapInputsController] quote error`, { error: quoteResponse }); @@ -501,7 +504,6 @@ export function useSwapInputsController({ // TODO: Need to convert big number to native value properly here... // example: "fee": "3672850000000000", fee.value = isWrapOrUnwrapEth ? '0' : data.feeInEth.toString(); - quote.value = data; inputValues.modify(values => { return { @@ -1050,6 +1052,7 @@ export function useSwapInputsController({ assetToSellIconUrl, assetToBuySymbol, assetToBuyIconUrl, + quote, source, slippage, flashbots, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts b/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts new file mode 100644 index 00000000000..098a4638c67 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts @@ -0,0 +1,198 @@ +import { useCallback } from 'react'; +import * as i18n from '@/languages'; +import { useAccountSettings } from '@/hooks'; +import { SharedValue, runOnJS, runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import { convertAmountToNativeDisplay, divide, greaterThanOrEqualTo, subtract } from '@/__swaps__/utils/numbers'; +import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { BigNumberish } from '@/__swaps__/utils/hex'; +import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; +import { getCrossChainTimeEstimate, getQuoteServiceTime } from '@/__swaps__/utils/swaps'; + +const highPriceImpactThreshold = 0.05; +const severePriceImpactThreshold = 0.1; + +export enum SwapWarningType { + unknown = 'unknown', + none = 'none', + high = 'high', + severe = 'severe', + long_wait = 'long_wait', + + // quote errors + no_quote_available = 501, + insufficient_liquidity = 502, + fee_on_transfer = 503, + no_route_found = 504, +} + +export interface SwapWarning { + type: SwapWarningType; + display: string; +} + +export interface SwapTimeEstimate { + isLongWait: boolean; + timeEstimate?: number; + timeEstimateDisplay: string; +} + +type UsePriceImpactWarningProps = { + SwapInputController: ReturnType; + sliderXPosition: SharedValue; + isFetching: SharedValue; +}; + +export const useSwapWarning = ({ SwapInputController, isFetching, sliderXPosition }: UsePriceImpactWarningProps) => { + const { nativeCurrency: currentCurrency } = useAccountSettings(); + + const timeEstimate = useSharedValue(null); + + const swapWarning = useSharedValue({ + type: SwapWarningType.none, + display: '', + }); + + const getWarning = useCallback( + ({ + inputNativeValue, + outputNativeValue, + quote, + isFetching, + }: { + inputNativeValue: BigNumberish; + outputNativeValue: BigNumberish; + quote: Quote | CrosschainQuote | QuoteError | null; + isFetching: boolean; + }) => { + const updateWarning = (values: SwapWarning) => { + 'worklet'; + + swapWarning.modify(prev => ({ + ...prev, + ...values, + })); + }; + + const nativeAmountImpact = subtract(inputNativeValue, outputNativeValue); + const impact = divide(nativeAmountImpact, inputNativeValue); + const display = convertAmountToNativeDisplay(nativeAmountImpact, currentCurrency); + + /** + * ORDER IS IMPORTANT HERE + * + * We want to show quote errors first if they exist, then + * we want to show severe/high price impact warnings if they exists + * if those are not present, we want to show the long wait warning + * if there is no warnings at all, we want to show none + */ + if (!isFetching && (quote as QuoteError).error) { + const quoteError = quote as QuoteError; + + switch (quoteError.error_code) { + default: + case SwapWarningType.no_quote_available: { + runOnUI(updateWarning)({ + type: SwapWarningType.no_quote_available, + display: i18n.t(i18n.l.exchange.quote_errors.no_quote_available), + }); + break; + } + case SwapWarningType.insufficient_liquidity: { + runOnUI(updateWarning)({ + type: SwapWarningType.insufficient_liquidity, + display: i18n.t(i18n.l.exchange.quote_errors.insufficient_liquidity), + }); + break; + } + case SwapWarningType.fee_on_transfer: { + runOnUI(updateWarning)({ + type: SwapWarningType.fee_on_transfer, + display: i18n.t(i18n.l.exchange.quote_errors.fee_on_transfer), + }); + break; + } + case SwapWarningType.no_route_found: { + runOnUI(updateWarning)({ + type: SwapWarningType.no_route_found, + display: i18n.t(i18n.l.exchange.quote_errors.no_route_found), + }); + break; + } + } + } + if (!isFetching && !(quote as QuoteError).error && (!inputNativeValue || !outputNativeValue)) { + runOnUI(updateWarning)({ + type: SwapWarningType.unknown, + display: i18n.t(i18n.l.exchange.price_impact.unknown_price.title), + }); + } else if (!isFetching && greaterThanOrEqualTo(impact, severePriceImpactThreshold)) { + console.log(impact, display); + runOnUI(updateWarning)({ + type: SwapWarningType.severe, + display, + }); + } else if (!isFetching && greaterThanOrEqualTo(impact, highPriceImpactThreshold)) { + runOnUI(updateWarning)({ + type: SwapWarningType.high, + display, + }); + } else if (!(quote as QuoteError).error) { + const serviceTime = getQuoteServiceTime({ + quote: quote as CrosschainQuote, + }); + + const estimatedTimeOfArrival = serviceTime + ? getCrossChainTimeEstimate({ + serviceTime, + }) + : null; + timeEstimate.value = estimatedTimeOfArrival; + if (estimatedTimeOfArrival?.isLongWait) { + runOnUI(updateWarning)({ + type: SwapWarningType.long_wait, + display: estimatedTimeOfArrival.timeEstimateDisplay, + }); + return; + } + } else { + runOnUI(updateWarning)({ + type: SwapWarningType.none, + display, + }); + } + }, + [currentCurrency, swapWarning, timeEstimate] + ); + + useAnimatedReaction( + () => ({ + inputNativeValue: SwapInputController.inputValues.value.inputNativeValue, + outputNativeValue: SwapInputController.inputValues.value.outputNativeValue, + quote: SwapInputController.quote.value, + isFetching: isFetching.value, + sliderXPosition: sliderXPosition.value, + }), + (current, previous) => { + if (current?.isFetching) { + swapWarning.value = { display: '', type: SwapWarningType.none }; + return; + } + // NOTE: While the user is scrubbing the slider, we don't want to show the price impact warning. + if (previous?.sliderXPosition && previous?.sliderXPosition !== current.sliderXPosition) { + swapWarning.value = { display: '', type: SwapWarningType.none }; + return; + } + + if (previous?.inputNativeValue !== current.inputNativeValue || previous?.outputNativeValue !== current.outputNativeValue) { + runOnJS(getWarning)({ + inputNativeValue: current.inputNativeValue, + outputNativeValue: current.outputNativeValue, + quote: current.quote, + isFetching: current.isFetching, + }); + } + } + ); + + return { swapWarning, timeEstimate }; +}; diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 160ecfa969e..82d2ee415da 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -8,6 +8,7 @@ import { useAnimatedSwapStyles } from '@/__swaps__/screens/Swap/hooks/useAnimate import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles'; import { useSwapNavigation } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; interface SwapContextType { inputProgress: SharedValue; @@ -20,6 +21,7 @@ interface SwapContextType { AnimatedSwapStyles: ReturnType; SwapTextStyles: ReturnType; SwapNavigation: ReturnType; + SwapWarning: ReturnType; confirmButtonIcon: Readonly>; confirmButtonLabel: Readonly>; @@ -54,7 +56,13 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { outputProgress, }); - const AnimatedSwapStyles = useAnimatedSwapStyles({ SwapInputController, inputProgress, outputProgress, isFetching }); + const SwapWarning = useSwapWarning({ + SwapInputController, + sliderXPosition, + isFetching, + }); + + const AnimatedSwapStyles = useAnimatedSwapStyles({ SwapInputController, SwapWarning, inputProgress, outputProgress, isFetching }); const SwapTextStyles = useSwapTextStyles({ ...SwapInputController, focusedInput, @@ -118,6 +126,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { AnimatedSwapStyles, SwapTextStyles, SwapNavigation, + SwapWarning, confirmButtonIcon, confirmButtonLabel, confirmButtonIconStyle, diff --git a/src/languages/en_US.json b/src/languages/en_US.json index d194b0a1f1c..43c6a811d6c 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -561,9 +561,25 @@ "nothing_here": "Nothing here!", "nothing_to_send": "Nothing to send" }, + "quote_errors": { + "no_quote_available": "No quote available", + "insufficient_liquidity": "Insufficient liquidity", + "fee_on_transfer": "Fee on Transfer Token", + "no_route_found": "No route found" + }, "price_impact": { + "long_wait": { + "title": "Long wait", + "description": "Approx." + }, + "unknown_price": { + "title": "%{symbol} Market Value Unknown", + "description": "If you decide to continue, be sure that you are satisfied receiving the quoted amount." + }, "losing_prefix": "Losing", "small_market": "Small Market", + "small_market_try_smaller_amount": "Small market — try a smaller amount", + "you_are_losing": "You're losing", "no_data": "Market Value Unknown", "label": "Possible loss", "no_data_subtitle": "If you decide to continue, be sure that you are satisfied with the quoted amount"