diff --git a/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx b/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx index 4805dc577bb..f4752f619a8 100644 --- a/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx +++ b/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx @@ -1,10 +1,10 @@ +import { ButtonPressAnimation } from '@/components/animations'; +import { IS_IOS } from '@/env'; import ConditionalWrap from 'conditional-wrap'; import React from 'react'; import { StyleProp, ViewProps, ViewStyle } from 'react-native'; import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import Animated, { AnimatedStyle, runOnJS, useAnimatedGestureHandler } from 'react-native-reanimated'; -import { ButtonPressAnimation } from '@/components/animations'; -import { IS_IOS } from '@/env'; export type GestureHandlerButtonProps = { buttonPressWrapperStyleIOS?: StyleProp; diff --git a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx index 3219599cfc2..d81509a07e1 100644 --- a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx +++ b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx @@ -1,13 +1,15 @@ /* eslint-disable no-nested-ternary */ import React from 'react'; -import { StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; -import Animated, { DerivedValue, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'; +import { StyleProp, StyleSheet, TextStyle, ViewProps, ViewStyle } from 'react-native'; +import Animated, { DerivedValue, useAnimatedProps, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'; -import { AnimatedText, Box, Column, Columns, globalColors, useColorMode, useForegroundColor } from '@/design-system'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; import { getColorValueForThemeWorklet } from '@/__swaps__/utils/swaps'; +import { AnimatedText, Box, Column, Columns, globalColors, useColorMode, useForegroundColor } from '@/design-system'; import { GestureHandlerV1Button } from './GestureHandlerV1Button'; +const AnimatedGestureHandlerV1Button = Animated.createAnimatedComponent(GestureHandlerV1Button); + export const SwapActionButton = ({ asset, borderRadius, @@ -23,6 +25,8 @@ export const SwapActionButton = ({ scaleTo, small, style, + disabled, + isLoading, }: { asset: DerivedValue; borderRadius?: number; @@ -38,6 +42,8 @@ export const SwapActionButton = ({ scaleTo?: number; small?: boolean; style?: ViewStyle; + disabled?: DerivedValue; + isLoading?: DerivedValue; }) => { const { isDarkMode } = useColorMode(); const fallbackColor = useForegroundColor('label'); @@ -80,6 +86,8 @@ export const SwapActionButton = ({ }, shadowOpacity: isDarkMode ? 0.2 : small ? 0.2 : 0.36, shadowRadius: isDarkMode ? 26 : small ? 9 : 15, + // we don't want to change the opacity when it's loading + opacity: !isLoading?.value && disabled?.value ? 0.6 : 1, }; }); @@ -97,15 +105,20 @@ export const SwapActionButton = ({ return rightIcon; }); + const buttonAnimatedProps = useAnimatedProps(() => { + return { + pointerEvents: (disabled?.value ? 'none' : 'box-only') as ViewProps['pointerEvents'], + disableButtonPressWrapper: disabled?.value, + scaleTo: scaleTo || (hugContent ? undefined : 0.925), + }; + }); + return ( - - + ); }; diff --git a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx index 48ec3a71371..1d9f7a703b2 100644 --- a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx +++ b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { getSoftMenuBarHeight } from 'react-native-extra-dimensions-android'; import { PanGestureHandler } from 'react-native-gesture-handler'; -import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated'; +import Animated, { useAnimatedStyle, useDerivedValue, withSpring } from 'react-native-reanimated'; import { Box, Column, Columns, Separator, globalColors, useColorMode } from '@/design-system'; import { safeAreaInsetValues } from '@/utils'; @@ -21,15 +21,8 @@ import { SwapActionButton } from './SwapActionButton'; export function SwapBottomPanel() { const { isDarkMode } = useColorMode(); - const { - confirmButtonIcon, - confirmButtonIconStyle, - confirmButtonLabel, - internalSelectedOutputAsset, - AnimatedSwapStyles, - SwapNavigation, - configProgress, - } = useSwapContext(); + const { confirmButtonIconStyle, confirmButtonProps, internalSelectedOutputAsset, AnimatedSwapStyles, SwapNavigation, configProgress } = + useSwapContext(); const { swipeToDismissGestureHandler, gestureY } = useBottomPanelGestureHandler(); @@ -50,6 +43,10 @@ export function SwapBottomPanel() { }; }); + const icon = useDerivedValue(() => confirmButtonProps.value.icon); + const label = useDerivedValue(() => confirmButtonProps.value.label); + const disabled = useDerivedValue(() => confirmButtonProps.value.disabled); + return ( // @ts-expect-error Property 'children' does not exist on type @@ -80,11 +77,12 @@ export function SwapBottomPanel() { diff --git a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx index f27c8a0ac30..f47c8ee3168 100644 --- a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx @@ -1,20 +1,20 @@ import MaskedView from '@react-native-masked-view/masked-view'; import React from 'react'; -import { StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet } from 'react-native'; import Animated, { useDerivedValue } from 'react-native-reanimated'; import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; import { AnimatedText, Box, Column, Columns, Stack, useColorMode } from '@/design-system'; +import { BalanceBadge } from '@/__swaps__/screens/Swap/components/BalanceBadge'; +import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; import { SwapActionButton } from '@/__swaps__/screens/Swap/components/SwapActionButton'; -import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { SwapInput } from '@/__swaps__/screens/Swap/components/SwapInput'; -import { BalanceBadge } from '@/__swaps__/screens/Swap/components/BalanceBadge'; import { TokenList } from '@/__swaps__/screens/Swap/components/TokenList/TokenList'; import { BASE_INPUT_WIDTH, INPUT_INNER_WIDTH, INPUT_PADDING, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; -import { IS_ANDROID } from '@/env'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { IS_ANDROID } from '@/env'; import { AnimatedSwapCoinIcon } from './AnimatedSwapCoinIcon'; import * as i18n from '@/languages'; diff --git a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx index e639139a9ed..34869a38cad 100644 --- a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx @@ -1,25 +1,26 @@ +import { AnimatedText, Box, Column, Columns, Stack, useColorMode } from '@/design-system'; import MaskedView from '@react-native-masked-view/masked-view'; import React, { useCallback } from 'react'; -import { StyleSheet, StatusBar } from 'react-native'; +import { StatusBar, StyleSheet } from 'react-native'; import Animated, { runOnJS, useDerivedValue } from 'react-native-reanimated'; import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; -import { AnimatedText, Box, Column, Columns, Stack, useColorMode } from '@/design-system'; + +import { AnimatedSwapCoinIcon } from '@/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon'; +import { BalanceBadge } from '@/__swaps__/screens/Swap/components/BalanceBadge'; +import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; import { SwapActionButton } from '@/__swaps__/screens/Swap/components/SwapActionButton'; -import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { SwapInput } from '@/__swaps__/screens/Swap/components/SwapInput'; -import { BalanceBadge } from '@/__swaps__/screens/Swap/components/BalanceBadge'; -import { AnimatedSwapCoinIcon } from '@/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon'; import { TokenList } from '@/__swaps__/screens/Swap/components/TokenList/TokenList'; import { BASE_INPUT_WIDTH, INPUT_INNER_WIDTH, INPUT_PADDING, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; -import { IS_ANDROID } from '@/env'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { ChainId } from '@/__swaps__/types/chains'; +import { IS_ANDROID } from '@/env'; +import * as i18n from '@/languages'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; import { useSwapsStore } from '@/state/swaps/swapsStore'; import { ethereumUtils } from '@/utils'; -import { ChainId } from '@/__swaps__/types/chains'; -import * as i18n from '@/languages'; const SELECT_LABEL = i18n.t(i18n.l.swap.select); const NO_BALANCE_LABEL = i18n.t(i18n.l.swap.no_balance); diff --git a/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts index 898a47797ea..3b08d90bbe5 100644 --- a/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts +++ b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts @@ -1,13 +1,11 @@ -import { greaterThanWorklet, toScaledIntegerWorklet } from '@/__swaps__/safe-math/SafeMath'; import { ChainId } from '@/__swaps__/types/chains'; import { weiToGwei } from '@/__swaps__/utils/ethereum'; import { add, convertAmountToNativeDisplayWorklet, formatNumber, multiply } from '@/__swaps__/utils/numbers'; import ethereumUtils, { useNativeAssetForNetwork } from '@/utils/ethereumUtils'; -import { useMemo, useState } from 'react'; -import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; -import { useDebouncedCallback } from 'use-debounce'; +import { useMemo } from 'react'; import { formatUnits } from 'viem'; -import { useSwapContext } from '../providers/swap-provider'; + +import { useSyncedSwapQuoteStore } from '../providers/SyncSwapStateAndSharedValues'; import { GasSettings } from './useCustomGas'; import { useSwapEstimatedGasLimit } from './useSwapEstimatedGasLimit'; import { useAccountSettings } from '@/hooks'; @@ -20,6 +18,11 @@ function safeBigInt(value: string) { } } +export function calculateGasFee(gasSettings: GasSettings, gasLimit: string) { + const amount = gasSettings.isEIP1559 ? add(gasSettings.maxBaseFee, gasSettings.maxPriorityFee) : gasSettings.gasPrice; + return multiply(gasLimit, amount); +} + export function useEstimatedGasFee({ chainId, gasLimit, @@ -36,66 +39,23 @@ export function useEstimatedGasFee({ return useMemo(() => { if (!gasLimit || !gasSettings || !nativeNetworkAsset?.price) return; - const amount = gasSettings.isEIP1559 ? add(gasSettings.maxBaseFee, gasSettings.maxPriorityFee) : gasSettings.gasPrice; - - const totalWei = multiply(gasLimit, amount); + const fee = calculateGasFee(gasSettings, gasLimit); const networkAssetPrice = nativeNetworkAsset.price.value?.toString(); - if (!networkAssetPrice) return `${formatNumber(weiToGwei(totalWei))} Gwei`; + if (!networkAssetPrice) return `${formatNumber(weiToGwei(fee))} Gwei`; - const gasAmount = formatUnits(safeBigInt(totalWei), nativeNetworkAsset.decimals).toString(); - const feeInUserCurrency = multiply(networkAssetPrice, gasAmount); + const feeFormatted = formatUnits(safeBigInt(fee), nativeNetworkAsset.decimals).toString(); + const feeInUserCurrency = multiply(networkAssetPrice, feeFormatted); return convertAmountToNativeDisplayWorklet(feeInUserCurrency, nativeCurrency); }, [gasLimit, gasSettings, nativeCurrency, nativeNetworkAsset]); } export function useSwapEstimatedGasFee(gasSettings: GasSettings | undefined) { - const { internalSelectedInputAsset: assetToSell, internalSelectedOutputAsset: assetToBuy, quote } = useSwapContext(); - - const [state, setState] = useState({ - assetToBuy: assetToBuy.value, - assetToSell: assetToSell.value, - chainId: assetToSell.value?.chainId ?? ChainId.mainnet, - quote: quote.value, - }); - - const debouncedStateSet = useDebouncedCallback(setState, 100, { leading: false, trailing: true }); - - // Updates the state as a single block in response to quote changes to ensure the gas fee is cleanly updated once - useAnimatedReaction( - () => quote.value, - (current, previous) => { - if (!assetToSell.value || !assetToBuy.value || !current || !previous || 'error' in current) return; - - const isSwappingMoreThanAvailableBalance = greaterThanWorklet( - current.sellAmount.toString(), - toScaledIntegerWorklet(assetToSell.value.balance.amount, assetToSell.value.decimals) - ); - - // Skip gas fee recalculation if the user is trying to swap more than their available balance, as it isn't - // needed and was previously resulting in errors in useEstimatedGasFee. - if (isSwappingMoreThanAvailableBalance) return; - - if (current !== previous) { - runOnJS(debouncedStateSet)({ - assetToBuy: assetToBuy.value, - assetToSell: assetToSell.value, - chainId: assetToSell.value?.chainId ?? ChainId.mainnet, - quote: current, - }); - } - } - ); - - const { data: gasLimit, isFetching } = useSwapEstimatedGasLimit( - { chainId: state.chainId, quote: state.quote, assetToSell: state.assetToSell }, - { - enabled: !!state.quote && !!state.assetToSell && !!state.assetToBuy && !('error' in quote), - } - ); + const { assetToSell, chainId = ChainId.mainnet, quote } = useSyncedSwapQuoteStore(); + const { data: estimatedGasLimit, isFetching } = useSwapEstimatedGasLimit({ chainId, assetToSell, quote }); - const estimatedFee = useEstimatedGasFee({ chainId: state.chainId, gasLimit, gasSettings }); + const estimatedFee = useEstimatedGasFee({ chainId, gasLimit: estimatedGasLimit, gasSettings }); return useMemo(() => ({ isLoading: isFetching, data: estimatedFee }), [estimatedFee, isFetching]); } diff --git a/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx b/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx new file mode 100644 index 00000000000..3557f0ddb6f --- /dev/null +++ b/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx @@ -0,0 +1,109 @@ +import { greaterThanWorklet, toScaledIntegerWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; +import { ChainId } from '@/__swaps__/types/chains'; +import { add } from '@/__swaps__/utils/numbers'; +import { ParsedAddressAsset } from '@/entities'; +import { lessThan } from '@/helpers/utilities'; +import { useUserNativeNetworkAsset } from '@/resources/assets/useUserAsset'; +import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; +import { debounce } from 'lodash'; +import { useEffect } from 'react'; +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; +import { formatUnits } from 'viem'; +import { create } from 'zustand'; +import { calculateGasFee } from '../hooks/useEstimatedGasFee'; +import { useSelectedGas } from '../hooks/useSelectedGas'; +import { useSwapEstimatedGasLimit } from '../hooks/useSwapEstimatedGasLimit'; +import { useSwapContext } from './swap-provider'; + +type InternalSyncedSwapState = { + assetToBuy: ExtendedAnimatedAssetWithColors | undefined; + assetToSell: ExtendedAnimatedAssetWithColors | undefined; + chainId: ChainId | undefined; + quote: Quote | CrosschainQuote | QuoteError | null; +}; +export const useSyncedSwapQuoteStore = create(() => ({ + assetToBuy: undefined, + assetToSell: undefined, + chainId: undefined, + quote: null, +})); +const setInternalSyncedSwapStore = debounce((state: InternalSyncedSwapState) => useSyncedSwapQuoteStore.setState(state), 100, { + leading: false, + trailing: true, +}); + +export const SyncQuoteSharedValuesToState = () => { + const { internalSelectedInputAsset: assetToSell, internalSelectedOutputAsset: assetToBuy, quote } = useSwapContext(); + + // Updates the state as a single block in response to quote changes to ensure the gas fee is cleanly updated once + useAnimatedReaction( + () => quote.value, + (current, previous) => { + if (!assetToSell.value || !assetToBuy.value || !current || 'error' in current) return; + + const isSwappingMoreThanAvailableBalance = greaterThanWorklet( + current.sellAmount.toString(), + toScaledIntegerWorklet(assetToSell.value.balance.amount, assetToSell.value.decimals) + ); + + // Skip gas fee recalculation if the user is trying to swap more than their available balance, as it isn't + // needed and was previously resulting in errors in useEstimatedGasFee. + if (isSwappingMoreThanAvailableBalance) return; + + if (!previous || current !== previous) { + runOnJS(setInternalSyncedSwapStore)({ + assetToBuy: assetToBuy.value, + assetToSell: assetToSell.value, + chainId: assetToSell.value?.chainId, + quote: current, + }); + } + } + ); + + return null; +}; + +const getHasEnoughFundsForGas = (quote: Quote, gasFee: string, nativeNetworkAsset: ParsedAddressAsset | undefined) => { + if (!nativeNetworkAsset) return false; + const userBalance = nativeNetworkAsset.balance?.amount || '0'; + + const quoteValue = quote.value?.toString() || '0'; + const totalNativeSpentInTx = formatUnits(BigInt(add(quoteValue, gasFee)), nativeNetworkAsset.decimals); + + return lessThan(totalNativeSpentInTx, userBalance); +}; + +export function SyncGasStateToSharedValues() { + const { hasEnoughFundsForGas, internalSelectedInputAsset, SwapInputController } = useSwapContext(); + + const { assetToSell, chainId = ChainId.mainnet, quote } = useSyncedSwapQuoteStore(); + + const gasSettings = useSelectedGas(chainId); + const { data: userNativeNetworkAsset } = useUserNativeNetworkAsset(chainId); + const { data: estimatedGasLimit, isFetching } = useSwapEstimatedGasLimit({ chainId, assetToSell, quote }); + + useEffect(() => { + hasEnoughFundsForGas.value = false; + if (!gasSettings || !estimatedGasLimit || !quote || 'error' in quote) return; + + const gasFee = calculateGasFee(gasSettings, estimatedGasLimit); + hasEnoughFundsForGas.value = getHasEnoughFundsForGas(quote, gasFee, userNativeNetworkAsset); + + return () => { + hasEnoughFundsForGas.value = false; + }; + }, [ + estimatedGasLimit, + gasSettings, + hasEnoughFundsForGas, + quote, + internalSelectedInputAsset.value?.balance.amount, + SwapInputController.inputValues.value.inputAmount, + userNativeNetworkAsset, + isFetching, + ]); + + return null; +} diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index e261abba5c8..7dddad7542c 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -1,6 +1,6 @@ // @refresh import React, { ReactNode, createContext, useCallback, useContext, useEffect, useRef } from 'react'; -import { StyleProp, TextStyle, TextInput, NativeModules, InteractionManager } from 'react-native'; +import { InteractionManager, NativeModules, StyleProp, TextInput, TextStyle } from 'react-native'; import { AnimatedRef, DerivedValue, @@ -13,41 +13,43 @@ import { useSharedValue, } from 'react-native-reanimated'; -import * as i18n from '@/languages'; -import { SwapAssetType, inputKeys } from '@/__swaps__/types/swap'; import { INITIAL_SLIDER_POSITION, SLIDER_COLLAPSED_HEIGHT, SLIDER_HEIGHT, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { useAnimatedSwapStyles } from '@/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles'; -import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles'; -import { useSwapNavigation, NavigationSteps } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { NavigationSteps, useSwapNavigation } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; +import { useSwapSettings } from '@/__swaps__/screens/Swap/hooks/useSwapSettings'; +import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles'; +import { SwapWarningType, useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; +import { userAssetsQueryKey as swapsUserAssetsQueryKey } from '@/__swaps__/screens/Swap/resources/assets/userAssets'; import { AddressOrEth, ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; -import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; -import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; -import { swapsStore, useSwapsStore } from '@/state/swaps/swapsStore'; -import { parseAssetAndExtend } from '@/__swaps__/utils/swaps'; import { ChainId } from '@/__swaps__/types/chains'; +import { SwapAssetType, inputKeys } from '@/__swaps__/types/swap'; +import { parseAssetAndExtend } from '@/__swaps__/utils/swaps'; +import { getFlashbotsProvider, getProviderForNetwork, isHardHat } from '@/handlers/web3'; +import { WrappedAlert as Alert } from '@/helpers/alert'; +import { useAccountSettings } from '@/hooks'; +import * as i18n from '@/languages'; import { RainbowError, logger } from '@/logger'; -import { QuoteTypeMap, RapSwapActionParameters } from '@/raps/references'; +import { loadWallet } from '@/model/wallet'; import { Navigation } from '@/navigation'; -import { WrappedAlert as Alert } from '@/helpers/alert'; import Routes from '@/navigation/routesNames'; -import { ethereumUtils } from '@/utils'; -import { getFlashbotsProvider, getProviderForNetwork, isHardHat } from '@/handlers/web3'; -import { loadWallet } from '@/model/wallet'; import { walletExecuteRap } from '@/raps/execute'; +import { QuoteTypeMap, RapSwapActionParameters } from '@/raps/references'; import { queryClient } from '@/react-query'; -import { userAssetsQueryKey as swapsUserAssetsQueryKey } from '@/__swaps__/screens/Swap/resources/assets/userAssets'; import { userAssetsQueryKey } from '@/resources/assets/UserAssetsQuery'; -import { useAccountSettings } from '@/hooks'; -import { getGasSettingsBySpeed, getSelectedGas, getSelectedGasSpeed } from '../hooks/useSelectedGas'; +import { swapsStore } from '@/state/swaps/swapsStore'; +import { ethereumUtils } from '@/utils'; +import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; + +import { equalWorklet, lessThanOrEqualToWorklet, toScaledIntegerWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { analyticsV2 } from '@/analytics'; import { LegacyTransactionGasParamAmounts, TransactionGasParamAmounts } from '@/entities'; -import { equalWorklet } from '@/__swaps__/safe-math/SafeMath'; -import { useSwapSettings } from '../hooks/useSwapSettings'; -import { useSwapOutputQuotesDisabled } from '../hooks/useSwapOutputQuotesDisabled'; import { getNetworkObj } from '@/networks'; import { userAssetsStore } from '@/state/assets/userAssets'; -import { analyticsV2 } from '@/analytics'; import { Address } from 'viem'; +import { getGasSettingsBySpeed, getSelectedGas, getSelectedGasSpeed } from '../hooks/useSelectedGas'; +import { useSwapOutputQuotesDisabled } from '../hooks/useSwapOutputQuotesDisabled'; +import { SyncGasStateToSharedValues, SyncQuoteSharedValuesToState } from './SyncSwapStateAndSharedValues'; const swapping = i18n.t(i18n.l.swap.actions.swapping); const tapToSwap = i18n.t(i18n.l.swap.actions.tap_to_swap); @@ -55,6 +57,9 @@ const save = i18n.t(i18n.l.swap.actions.save); const enterAmount = i18n.t(i18n.l.swap.actions.enter_amount); const review = i18n.t(i18n.l.swap.actions.review); const fetchingPrices = i18n.t(i18n.l.swap.actions.fetching_prices); +const selectToken = i18n.t(i18n.l.swap.actions.select_token); +const errorLabel = i18n.t(i18n.l.swap.actions.error); +const insufficientFunds = i18n.t(i18n.l.swap.actions.insufficient_funds); interface SwapContextType { isFetching: SharedValue; @@ -99,9 +104,16 @@ interface SwapContextType { SwapNavigation: ReturnType; SwapWarning: ReturnType; - confirmButtonIcon: Readonly>; - confirmButtonLabel: Readonly>; + confirmButtonProps: Readonly< + SharedValue<{ + label: string; + icon?: string; + disabled?: boolean; + }> + >; confirmButtonIconStyle: StyleProp; + + hasEnoughFundsForGas: SharedValue; } const SwapContext = createContext(undefined); @@ -430,7 +442,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }; runOnUI(updateChainId)(chainId); - useSwapsStore.setState({ selectedOutputChainId: chainId }); + swapsStore.setState({ selectedOutputChainId: chainId }); }; const updateAssetValue = useCallback( @@ -502,19 +514,19 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { : asset; if (isSameAsOtherAsset) { - useSwapsStore.setState({ + swapsStore.setState({ [type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]: flippedAssetOrNull, [type]: otherSelectedAsset, }); } else { - useSwapsStore.setState({ [type]: assetToSet }); + swapsStore.setState({ [type]: assetToSet }); } } else { SwapInputController.quoteFetchingInterval.start(); } const shouldUpdateSelectedOutputChainId = - type === SwapAssetType.inputAsset && useSwapsStore.getState().selectedOutputChainId !== extendedAsset?.chainId; + type === SwapAssetType.inputAsset && swapsStore.getState().selectedOutputChainId !== extendedAsset?.chainId; const shouldUpdateAnimatedSelectedOutputChainId = type === SwapAssetType.inputAsset && selectedOutputChainId.value !== extendedAsset?.chainId; @@ -528,7 +540,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { chainSetTimeoutId.current = setTimeout(() => { InteractionManager.runAfterInteractions(() => { if (shouldUpdateSelectedOutputChainId) { - useSwapsStore.setState({ + swapsStore.setState({ selectedOutputChainId: extendedAsset?.chainId ?? ChainId.mainnet, }); } @@ -566,63 +578,69 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }; }, []); - const confirmButtonIcon = useDerivedValue(() => { + const hasEnoughFundsForGas = useSharedValue(false); + + const confirmButtonProps = useDerivedValue(() => { if (isSwapping.value) { - return ''; + return { label: swapping, disabled: true }; } if (configProgress.value === NavigationSteps.SHOW_REVIEW) { - return '􀎽'; - } else if (configProgress.value === NavigationSteps.SHOW_GAS) { - return '􀆅'; + return { icon: '􀎽', label: tapToSwap, disabled: false }; + } + + if (configProgress.value === NavigationSteps.SHOW_GAS) { + return { icon: '􀆅', label: save, disabled: false }; + } + + const hasSelectedAssets = internalSelectedInputAsset.value && internalSelectedOutputAsset.value; + if (!hasSelectedAssets) { + return { label: selectToken, disabled: true }; } - if (isQuoteStale.value === 1 && sliderPressProgress.value === 0) { - return ''; + if (isFetching.value) { + return { label: fetchingPrices, disabled: true }; } const isInputZero = equalWorklet(SwapInputController.inputValues.value.inputAmount, 0); const isOutputZero = equalWorklet(SwapInputController.inputValues.value.outputAmount, 0); - if ( - (isInputZero && isOutputZero) || - isFetching.value || - (SwapInputController.inputMethod.value === 'slider' && SwapInputController.percentageToSwap.value === 0) - ) { - return ''; - } else { - return '􀕹'; + const isQuoteError = quote.value && 'error' in quote.value; + if (isQuoteError) { + return { label: errorLabel, disabled: true }; } - }); - const confirmButtonLabel = useDerivedValue(() => { - if (isSwapping.value) { - return swapping; + if (SwapInputController.percentageToSwap.value === 0 || isInputZero || isOutputZero) { + return { label: enterAmount, disabled: true }; } - if (configProgress.value === NavigationSteps.SHOW_REVIEW) { - return tapToSwap; - } else if (configProgress.value === NavigationSteps.SHOW_GAS) { - return save; - } + const inputAsset = internalSelectedInputAsset.value; + const sellAmount = (() => { + const inputAmount = SwapInputController.inputValues.value.inputAmount; + if (!quote.value || 'error' in quote.value) return inputAmount; + return quote.value.sellAmount.toString(); + })(); - if (isFetching.value || (isQuoteStale.value === 1 && SwapInputController.inputMethod.value !== 'slider')) { - return fetchingPrices; - } + const enoughFundsForSwap = + inputAsset && lessThanOrEqualToWorklet(sellAmount, toScaledIntegerWorklet(inputAsset.balance.amount, inputAsset.decimals)); - const isInputZero = equalWorklet(SwapInputController.inputValues.value.inputAmount, 0); - const isOutputZero = equalWorklet(SwapInputController.inputValues.value.outputAmount, 0); + if (!isFetching && (!hasEnoughFundsForGas.value || !enoughFundsForSwap)) { + return { label: insufficientFunds, disabled: true }; + } - if (SwapInputController.inputMethod.value !== 'slider' && (isInputZero || isOutputZero) && !isFetching.value) { - return enterAmount; - } else if ( - SwapInputController.inputMethod.value === 'slider' && - (SwapInputController.percentageToSwap.value === 0 || isInputZero || isOutputZero) + if ( + [SwapWarningType.no_quote_available, SwapWarningType.no_route_found, SwapWarningType.insufficient_liquidity].includes( + SwapWarning.swapWarning.value.type + ) ) { - return enterAmount; - } else { - return review; + return { icon: '􀕹', label: review, disabled: true }; + } + + if (isQuoteError) { + return { label: errorLabel, disabled: true }; } + + return { icon: '􀕹', label: review, disabled: false }; }); const confirmButtonIconStyle = useAnimatedStyle(() => { @@ -681,12 +699,15 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { SwapNavigation, SwapWarning, - confirmButtonIcon, - confirmButtonLabel, + confirmButtonProps, confirmButtonIconStyle, + + hasEnoughFundsForGas, }} > {children} + + ); }; diff --git a/src/__swaps__/screens/Swap/resources/assets/userAssets.ts b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts index 8ea6259ca4a..0be2f7b1605 100644 --- a/src/__swaps__/screens/Swap/resources/assets/userAssets.ts +++ b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts @@ -1,7 +1,8 @@ /* eslint-disable no-await-in-loop */ import { useQuery } from '@tanstack/react-query'; -import { Address } from 'viem'; import { ADDYS_API_KEY } from 'react-native-dotenv'; +import { Address } from 'viem'; + import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; import { SupportedCurrencyKey, diff --git a/src/languages/en_US.json b/src/languages/en_US.json index db5c8c01884..41d4841ed83 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -1939,7 +1939,10 @@ "enter_amount": "Enter Amount", "review": "Review", "fetching_prices": "Fetching", - "swapping": "Swapping" + "swapping": "Swapping", + "select_token": "Select Token", + "insufficient_funds": "Insufficient Funds", + "error": "Error" }, "aggregators": { "rainbow": "Rainbow" diff --git a/src/resources/assets/assetSelectors.ts b/src/resources/assets/assetSelectors.ts index db8a15c9c92..c5fb9bf467b 100644 --- a/src/resources/assets/assetSelectors.ts +++ b/src/resources/assets/assetSelectors.ts @@ -1,8 +1,8 @@ -import { RainbowAddressAssets } from './types'; -import isEmpty from 'lodash/isEmpty'; -import isNil from 'lodash/isNil'; import { ParsedAddressAsset } from '@/entities'; import { parseAssetsNative } from '@/parsers'; +import isEmpty from 'lodash/isEmpty'; +import isNil from 'lodash/isNil'; +import { RainbowAddressAssets } from './types'; const EMPTY_ARRAY: any = []; diff --git a/src/resources/assets/useUserAsset.ts b/src/resources/assets/useUserAsset.ts index 96c5b7a6b34..7a61c9f2558 100644 --- a/src/resources/assets/useUserAsset.ts +++ b/src/resources/assets/useUserAsset.ts @@ -1,7 +1,11 @@ +import { ChainId } from '@/__swaps__/types/chains'; +import { Network } from '@/helpers'; import { getIsHardhatConnected } from '@/handlers/web3'; import { useAccountSettings } from '@/hooks'; +import { getNetworkObj } from '@/networks'; import { selectUserAssetWithUniqueId } from '@/resources/assets/assetSelectors'; import { useUserAssets } from '@/resources/assets/UserAssetsQuery'; +import { getNetworkFromChainId } from '@/utils/ethereumUtils'; export function useUserAsset(uniqueId: string) { const { accountAddress, nativeCurrency } = useAccountSettings(); @@ -18,3 +22,16 @@ export function useUserAsset(uniqueId: string) { } ); } + +export const getNetworkNativeAssetUniqueId = (chainId: ChainId) => { + const network = getNetworkFromChainId(chainId); + const { nativeCurrency } = getNetworkObj(network); + const { mainnetAddress, address } = nativeCurrency; + const uniqueId = mainnetAddress ? `${mainnetAddress}_${Network.mainnet}` : `${address}_${network}`; + return uniqueId; +}; + +export function useUserNativeNetworkAsset(chainId: ChainId) { + const uniqueId = getNetworkNativeAssetUniqueId(chainId); + return useUserAsset(uniqueId); +}