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"