diff --git a/src/__swaps__/screens/Swap/components/GasButton.tsx b/src/__swaps__/screens/Swap/components/GasButton.tsx index 032083ac5d9..ae9fa1620ae 100644 --- a/src/__swaps__/screens/Swap/components/GasButton.tsx +++ b/src/__swaps__/screens/Swap/components/GasButton.tsx @@ -1,243 +1,142 @@ -import React, { useMemo, useState, useCallback, useEffect, ReactNode } from 'react'; +import { ChainId } from '@/__swaps__/types/chains'; +import { weiToGwei } from '@/__swaps__/utils/ethereum'; +import { useMeteorologySuggestions } from '@/__swaps__/utils/meteorology'; +import { add } from '@/__swaps__/utils/numbers'; import { ButtonPressAnimation } from '@/components/animations'; -import { AnimatedText, Box, Inline, Stack, Text, TextIcon, useColorMode, useForegroundColor } from '@/design-system'; -import { useGasStore } from '@/state/gas/gasStore'; -import { Centered } from '@/components/layout'; -import { IS_ANDROID } from '@/env'; -import { getNetworkObj } from '@/networks'; -import { useRoute } from '@react-navigation/native'; -import { useAccountSettings } from '@/hooks'; import { ContextMenu } from '@/components/context-menu'; +import { Centered } from '@/components/layout'; import ContextMenuButton from '@/components/native-context-menu/contextMenu'; -import { ethereumUtils, gasUtils } from '@/utils'; +import { Box, Inline, Stack, Text, TextIcon, useColorMode, useForegroundColor } from '@/design-system'; +import { IS_ANDROID } from '@/env'; +import * as i18n from '@/languages'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; import styled from '@/styled-thing'; -import { useMeteorology } from '@/__swaps__/utils/meteorology'; -import { parseGasFeeParamsBySpeed } from '@/__swaps__/utils/gasUtils'; -import Animated, { runOnUI, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'; -import { ParsedAddressAsset } from '@/entities'; -import { GasFeeLegacyParamsBySpeed, GasFeeParamsBySpeed, GasSpeed } from '@/__swaps__/types/gas'; -import { ParsedAsset } from '@/__swaps__/types/assets'; +import { gasUtils } from '@/utils'; +import React, { ReactNode, useCallback, useMemo } from 'react'; +import { runOnUI } from 'react-native-reanimated'; import { ETH_COLOR, ETH_COLOR_DARK, THICK_BORDER_WIDTH } from '../constants'; +import { formatNumber } from '../hooks/formatNumber'; +import { GasSettings, useCustomGasSettings } from '../hooks/useCustomGas'; +import { useSwapEstimatedGasFee } from '../hooks/useEstimatedGasFee'; +import { GasSpeed, setSelectedGasSpeed, useSelectedGas, useSelectedGasSpeed } from '../hooks/useSelectedGas'; import { useSwapContext } from '../providers/swap-provider'; +import { StyleSheet } from 'react-native'; -const { CUSTOM, GAS_ICONS, GAS_EMOJIS, getGasLabel, getGasFallback } = gasUtils; -const mockedGasLimit = '21000'; +const { GAS_ICONS } = gasUtils; -export const GasButton = ({ accentColor, isReviewing = false }: { accentColor?: string; isReviewing?: boolean }) => { - const { SwapNavigation } = useSwapContext(); +function EstimatedGasFee() { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const gasSettings = useSelectedGas(chainId); + const estimatedGasFee = useSwapEstimatedGasFee(gasSettings); - const { isDarkMode } = useColorMode(); - const { params } = useRoute(); - const { currentNetwork } = (params as any) || {}; - const chainId = getNetworkObj(currentNetwork).id; - const { selectedGas } = useGasStore(); - const { data, isLoading } = useMeteorology({ chainId }); - const [nativeAsset, setNativeAsset] = useState(); - const { nativeCurrency } = useAccountSettings(); + return ( + + + 􀵟 + + + {estimatedGasFee} + + + ); +} - const separatatorSecondary = useForegroundColor('separatorSecondary'); +function SelectedGas() { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const selectedGasSpeed = useSelectedGasSpeed(chainId); - useEffect(() => { - const getNativeAsset = async () => { - const theNativeAsset = await ethereumUtils.getNativeAssetForNetwork(currentNetwork); - setNativeAsset(theNativeAsset); - }; - getNativeAsset(); - }, [currentNetwork, setNativeAsset]); + return ( + + + + 􀙭 + + + {i18n.t(i18n.l.gas.speeds[selectedGasSpeed])} + + + + 􀆏 + + + ); +} - const gasFeeBySpeed: GasFeeParamsBySpeed | GasFeeLegacyParamsBySpeed | any = useMemo(() => { - if (!isLoading) { - return parseGasFeeParamsBySpeed({ - chainId, - data: data!, - gasLimit: mockedGasLimit, - nativeAsset: nativeAsset as unknown as ParsedAsset, - currency: nativeCurrency, - }); - } - return {}; - }, [chainId, data, isLoading, nativeAsset, nativeCurrency]); - const gasFallback = getGasFallback(nativeCurrency); +const GasSpeedPagerCentered = styled(Centered).attrs(() => ({ + marginHorizontal: 8, +}))({}); - const animatedGas = useDerivedValue(() => { - return gasFeeBySpeed[selectedGas?.option]?.gasFee?.display ?? gasFallback; - }, [gasFeeBySpeed, selectedGas]); +function getEstimatedFeeRangeInGwei(gasSettings: GasSettings | undefined, currentBaseFee?: string | undefined) { + if (!gasSettings) return undefined; - const buttonWrapperStyles = useAnimatedStyle(() => { - return { - display: 'flex', - flexDirection: 'row', - backgroundColor: 'transparent', - borderWidth: 2, - borderColor: isDarkMode ? ETH_COLOR_DARK : ETH_COLOR, - borderRadius: 15, - paddingHorizontal: 10, - paddingVertical: 6, - gap: 5, - alignItems: 'center', - justifyContent: 'center', - }; - }); + if (!gasSettings.isEIP1559) return `${formatNumber(weiToGwei(gasSettings.gasPrice))} Gwei`; - if (isReviewing) { - return ( - - - - - - 􀙭 - - - {getGasLabel(selectedGas?.option || GasSpeed.FAST)} - - - - 􀆏 - - - + const { maxBaseFee, maxPriorityFee } = gasSettings; + return `${formatNumber(weiToGwei(add(maxBaseFee, maxPriorityFee)))} Gwei`; - runOnUI(SwapNavigation.handleShowGas)({ backToReview: true })}> - - - 􀌆 - - - - - ); - } + // return `${formatNumber(weiToGwei(add(baseFee, maxPriorityFee)))} - ${formatNumber( + // weiToGwei(add(maxBaseFee, maxPriorityFee)) + // )} Gwei`; +} - return ( - - - - - - 􀙭 - - - {getGasLabel(selectedGas?.option || GasSpeed.FAST)} - - - - 􀆏 - - - - - 􀵟 - - - - - - ); -}; -const GasSpeedPagerCentered = styled(Centered).attrs(() => ({ - marginHorizontal: 8, -}))({}); +function keys(obj: Record | undefined) { + if (!obj) return []; + return Object.keys(obj) as T[]; +} + +const GasMenu = ({ children }: { children: ReactNode }) => { + const { SwapNavigation } = useSwapContext(); + + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const metereologySuggestions = useMeteorologySuggestions({ chainId }); + const customGasSettings = useCustomGasSettings(chainId); -const GasMenu = ({ - children, - gasFeeBySpeed, -}: { - flashbotTransaction: boolean; - children: ReactNode; - gasFeeBySpeed: GasFeeParamsBySpeed | GasFeeLegacyParamsBySpeed; -}) => { - const { SwapNavigation, SwapGas } = useSwapContext(); - const { gasFeeParamsBySpeed, setSelectedGas } = useGasStore(); - // this needs to be moved up or out shouldnt need asset just the color + const menuOptions = useMemo(() => [...keys(metereologySuggestions.data), 'custom'] as const, [metereologySuggestions.data]); const handlePressSpeedOption = useCallback( (selectedGasSpeed: GasSpeed) => { - // TODO: Handle updating SwapGas references - // SwapGas.selectGasOption(selectedGasSpeed); - if (selectedGasSpeed === CUSTOM) { + if (selectedGasSpeed === 'custom') { runOnUI(SwapNavigation.handleShowGas)({}); return; } - setSelectedGas({ selectedGas: gasFeeBySpeed[selectedGasSpeed] }); + setSelectedGasSpeed(chainId, selectedGasSpeed); }, - [setSelectedGas, gasFeeBySpeed, SwapNavigation.handleShowGas] + [SwapNavigation.handleShowGas, chainId] ); + const handlePressMenuItem = useCallback( - ({ nativeEvent: { actionKey } }: any) => { - handlePressSpeedOption(actionKey as GasSpeed); - }, + ({ nativeEvent: { actionKey } }: any) => handlePressSpeedOption(actionKey), [handlePressSpeedOption] ); const handlePressActionSheet = useCallback( - (buttonIndex: number) => { - switch (buttonIndex) { - case 0: - setSelectedGas({ selectedGas: gasFeeParamsBySpeed[GasSpeed.NORMAL] }); - break; - case 1: - setSelectedGas({ selectedGas: gasFeeParamsBySpeed[GasSpeed.FAST] }); - break; - case 2: - setSelectedGas({ selectedGas: gasFeeParamsBySpeed[GasSpeed.URGENT] }); - break; - case 3: - setSelectedGas({ selectedGas: gasFeeParamsBySpeed[GasSpeed.CUSTOM] }); - runOnUI(SwapNavigation.handleShowGas)({}); - } - }, - [SwapNavigation.handleShowGas, gasFeeParamsBySpeed, setSelectedGas] + (buttonIndex: number) => handlePressSpeedOption(menuOptions[buttonIndex]), + [handlePressSpeedOption, menuOptions] ); const menuConfig = useMemo(() => { - const menuOptions = Object.keys(gasFeeBySpeed) - .reverse() - .map(gasOption => { - if (IS_ANDROID) return gasOption as GasSpeed; - const { display } = gasFeeBySpeed[gasOption as GasSpeed] ?? {}; + const menuItems = menuOptions.map(gasOption => { + if (IS_ANDROID) return gasOption; + + // const currentBaseFee = getCachedCurrentBaseFee(chainId); + const gasSettings = gasOption === 'custom' ? customGasSettings : metereologySuggestions.data?.[gasOption]; + const subtitle = getEstimatedFeeRangeInGwei(gasSettings); + + return { + actionKey: gasOption, + actionTitle: i18n.t(i18n.l.gas.speeds[gasOption]), + discoverabilityTitle: subtitle, + icon: { iconType: 'ASSET', iconValue: GAS_ICONS[gasOption] }, + }; + }); + return { menuItems, menuTitle: '' }; + }, [customGasSettings, menuOptions, metereologySuggestions.data]); + + if (metereologySuggestions.isLoading) return children; - return { - actionKey: gasOption, - actionTitle: android ? `${GAS_EMOJIS[gasOption as GasSpeed]} ` : getGasLabel(gasOption || ''), - discoverabilityTitle: display, - icon: { - iconType: 'ASSET', - iconValue: GAS_ICONS[gasOption as GasSpeed], - }, - }; - }); - return { - menuItems: menuOptions, - menuTitle: '', - }; - }, [gasFeeBySpeed]); - const renderGasSpeedPager = useMemo(() => { - if (IS_ANDROID) { - return ( + return ( + + {IS_ANDROID ? ( {children} - ); - } + ) : ( + + {children} + + )} + + ); +}; + +export function ReviewGasButton() { + const { isDarkMode } = useColorMode(); + const { SwapNavigation } = useSwapContext(); + + const separatatorSecondary = useForegroundColor('separatorSecondary'); - return ( - - {children} - - ); - }, [children, handlePressActionSheet, handlePressMenuItem, menuConfig]); - return {renderGasSpeedPager}; + return ( + + + + + + + + runOnUI(SwapNavigation.handleShowGas)({ backToReview: true })}> + + + 􀌆 + + + + + ); +} + +export const GasButton = () => { + return ( + + + + + + + ); }; + +const sx = StyleSheet.create({ + reviewGasButtonPillStyles: { + display: 'flex', + flexDirection: 'row', + backgroundColor: 'transparent', + borderWidth: 2, + borderRadius: 15, + paddingHorizontal: 10, + paddingVertical: 6, + gap: 5, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/__swaps__/screens/Swap/components/GasPanel.tsx b/src/__swaps__/screens/Swap/components/GasPanel.tsx index 72f25169c3c..597804ec7b4 100644 --- a/src/__swaps__/screens/Swap/components/GasPanel.tsx +++ b/src/__swaps__/screens/Swap/components/GasPanel.tsx @@ -1,24 +1,33 @@ -import React, { useCallback, useMemo } from 'react'; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import * as i18n from '@/languages'; +import React, { PropsWithChildren } from 'react'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; -import { AnimatedText, Box, Inline, Separator, Stack, Text, globalColors, useColorMode } from '@/design-system'; -import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { fadeConfig } from '@/__swaps__/screens/Swap/constants'; +import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { ChainId } from '@/__swaps__/types/chains'; +import { gweiToWei, weiToGwei } from '@/__swaps__/utils/ethereum'; +import { + getSelectedSpeedSuggestion, + useBaseFee, + useGasTrend, + useIsChainEIP1559, + useMeteorologySuggestions, +} from '@/__swaps__/utils/meteorology'; +import { add, subtract } from '@/__swaps__/utils/numbers'; import { ButtonPressAnimation } from '@/components/animations'; -import { useGas } from '@/hooks'; -import { getTrendKey } from '@/helpers/gas'; -import { gasUtils } from '@/utils'; +import { Box, Inline, Separator, Stack, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import { IS_ANDROID } from '@/env'; +import { lessThan } from '@/helpers/utilities'; import { useNavigation } from '@/navigation'; -import { Keyboard } from 'react-native'; import Routes from '@/navigation/routesNames'; -import { useTheme } from '@/theme'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; import { upperFirst } from 'lodash'; -import { IS_ANDROID } from '@/env'; -import { TextColor } from '@/design-system/color/palettes'; -import { CustomColor } from '@/design-system/color/useForegroundColor'; - -const { CUSTOM } = gasUtils; +import { formatNumber } from '../hooks/formatNumber'; +import { GasSettings, getCustomGasSettings, setCustomGasSettings, useCustomGasStore } from '../hooks/useCustomGas'; +import { useSwapEstimatedGasFee } from '../hooks/useEstimatedGasFee'; +import { setSelectedGasSpeed, useSelectedGasSpeed } from '../hooks/useSelectedGas'; +import { opacity } from '@/__swaps__/utils/swaps'; const MINER_TIP_TYPE = 'minerTip'; const MAX_BASE_FEE_TYPE = 'maxBaseFee'; @@ -30,18 +39,289 @@ type AlertInfo = { message: string; } | null; -export function GasPanel() { - const { selectedGasFee, currentBlockParams } = useGas(); +function PressableLabel({ onPress, children }: PropsWithChildren<{ onPress: VoidFunction }>) { + return ( + + + + {`${children} `} + + 􀅴 + + + + + + ); +} + +function NumericInputButton({ children, onPress }: PropsWithChildren<{ onPress: VoidFunction }>) { + const { isDarkMode } = useColorMode(); - const currentBaseFee = useSharedValue(''); - const maxBaseFee = useSharedValue(''); - const priorityFee = useSharedValue(''); - const maxTransactionFee = useSharedValue(''); + const fillSecondary = useForegroundColor('fillSecondary'); + const labelTertiary = useForegroundColor('labelTertiary'); + return ( + + + + {children} + + + + ); +} + +const INPUT_STEP = gweiToWei('0.1'); +function GasSettingInput({ + onChange, + min = '0', + value = min || '0', +}: { + onChange: (v: string) => void; + value: string | undefined; + min?: string; +}) { const { isDarkMode } = useColorMode(); - const { configProgress } = useSwapContext(); + + return ( + + + { + const newValue = subtract(value, INPUT_STEP); + onChange(lessThan(newValue, min) ? min : newValue); + }} + > + 􀅽 + + + + {formatNumber(weiToGwei(value))} + + + onChange(add(value, INPUT_STEP))}>􀅼 + + + + Gwei + + + ); +} + +const selectWeiToGwei = (s: string | undefined) => s && weiToGwei(s); + +function CurrentBaseFee() { + const { isDarkMode } = useColorMode(); + const { navigate } = useNavigation(); + + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const { data: baseFee } = useBaseFee({ chainId, select: selectWeiToGwei }); + const { data: gasTrend } = useGasTrend({ chainId }); + + const trendType = 'currentBaseFee' + upperFirst(gasTrend); + + // loading state? + + return ( + + + navigate(Routes.EXPLAIN_SHEET, { + currentBaseFee: baseFee, + currentGasTrend: gasTrend, + type: trendType, + }) + } + > + {i18n.t(i18n.l.gas.current_base_fee)} + + + {formatNumber(baseFee || '0')} + + + ); +} + +type GasPanelState = { gasPrice?: string; maxBaseFee?: string; maxPriorityFee?: string }; +const useGasPanelStore = createRainbowStore(() => undefined); + +function useGasPanelState< + Key extends 'maxBaseFee' | 'maxPriorityFee' | 'gasPrice' | undefined = undefined, + Selected = Key extends string ? string : GasPanelState, +>(key?: Key, select: (s: GasPanelState | undefined) => Selected = s => (key ? s?.[key] : s) as Selected) { + const state = useGasPanelStore(select); + + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const selectedSpeed = useSelectedGasSpeed(chainId); + + const currentGasSettings = useCustomGasStore(s => select(s?.[chainId])); + + const { data: suggestion } = useMeteorologySuggestions({ + chainId, + select: d => select(selectedSpeed === 'custom' ? undefined : d[selectedSpeed]), + enabled: !state && selectedSpeed !== 'custom', + }); + + return state ?? currentGasSettings ?? suggestion; +} + +const setGasPanelState = (update: Partial) => { + const chainId = useSwapsStore.getState().inputAsset?.chainId || ChainId.mainnet; + + const currentGasSettings = getCustomGasSettings(chainId); + if (currentGasSettings) useGasPanelStore.setState({ ...currentGasSettings, ...update }); + + const suggestion = getSelectedSpeedSuggestion(chainId); + useGasPanelStore.setState({ ...suggestion, ...update }); +}; + +function EditMaxBaseFee() { + const maxBaseFee = useGasPanelState('maxBaseFee'); + const { navigate } = useNavigation(); + + return ( + + {/* TODO: Add error and warning values here */} + navigate(Routes.EXPLAIN_SHEET, { type: MAX_BASE_FEE_TYPE })}> + {i18n.t(i18n.l.gas.max_base_fee)} + + setGasPanelState({ maxBaseFee })} /> + + ); +} + +const MIN_FLASHBOTS_PRIORITY_FEE = gweiToWei('6'); +function EditPriorityFee() { + const maxPriorityFee = useGasPanelState('maxPriorityFee'); const { navigate } = useNavigation(); - const { colors } = useTheme(); + + const isFlashbotsEnabled = useSwapsStore(s => s.flashbots); + // TODO: THIS FLASHBOTS INPUT LOGIC IS FLAWED REVIEW LATER + const min = isFlashbotsEnabled ? MIN_FLASHBOTS_PRIORITY_FEE : '0'; + + return ( + + {/* TODO: Add error and warning values here */} + navigate(Routes.EXPLAIN_SHEET, { type: MINER_TIP_TYPE })}> + {i18n.t(i18n.l.gas.miner_tip)} + + setGasPanelState({ maxPriorityFee })} min={min} /> + + ); +} + +function EditGasPrice() { + const gasPrice = useGasPanelState('gasPrice'); + const { navigate } = useNavigation(); + + return ( + + {/* TODO: Add error and warning values here */} + navigate(Routes.EXPLAIN_SHEET, { type: MAX_BASE_FEE_TYPE })}> + {i18n.t(i18n.l.gas.max_base_fee)} + + setGasPanelState({ gasPrice })} /> + + ); +} + +const stateToGasSettings = (s: GasPanelState | undefined): GasSettings | undefined => { + if (!s) return; + if (s.gasPrice) return { isEIP1559: false, gasPrice: s.gasPrice || '0' }; + return { isEIP1559: true, maxBaseFee: s.maxBaseFee || '0', maxPriorityFee: s.maxPriorityFee || '0' }; +}; +function MaxTransactionFee() { + const { isDarkMode } = useColorMode(); + + const gasPanelState = useGasPanelState(); + const gasSettings = stateToGasSettings(gasPanelState); + const maxTransactionFee = useSwapEstimatedGasFee(gasSettings); + + return ( + + + + + {i18n.t(i18n.l.gas.max_transaction_fee)} + + + 􀅴 + + + + + + + {maxTransactionFee} + + + + ); +} + +function EditableGasSettings() { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const isEIP1559 = useIsChainEIP1559(chainId); + if (!isEIP1559) return ; + return ( + <> + + + + ); +} + +function saveCustomGasSettings() { + // input is debounced if the time between editing and closing the panel is less than the debounce time (500ms) it's gonna be outdated + + const unsaved = useGasPanelStore.getState(); + + const { inputAsset } = useSwapsStore.getState(); + const chainId = inputAsset?.chainId || ChainId.mainnet; + if (!unsaved) { + if (getCustomGasSettings(chainId)) setSelectedGasSpeed(chainId, 'custom'); + return; + } + + setCustomGasSettings(chainId, unsaved); + setSelectedGasSpeed(chainId, 'custom'); + useGasPanelStore.setState(undefined); +} + +export function onCloseGasPanel() { + saveCustomGasSettings(); +} + +export function GasPanel() { + const { configProgress } = useSwapContext(); const styles = useAnimatedStyle(() => { return { @@ -52,229 +332,22 @@ export function GasPanel() { }; }); - const currentGasTrend = useMemo(() => getTrendKey(currentBlockParams?.trend), [currentBlockParams?.trend]); - const trendType = 'currentBaseFee' + upperFirst(currentGasTrend); - const selectedOptionIsCustom = useMemo(() => selectedGasFee?.option === CUSTOM, [selectedGasFee?.option]); - - // TODO: L2 check for the currentBaseFee - const openGasHelper = useCallback( - (type: string) => { - Keyboard.dismiss(); - navigate(Routes.EXPLAIN_SHEET, { - currentBaseFee: currentBlockParams?.baseFeePerGas?.display, - currentGasTrend, - type, - }); - }, - [navigate, currentBlockParams, currentGasTrend] - ); - - const renderRowLabel = (label: string, type: string, error?: AlertInfo, warning?: AlertInfo) => { - let color: TextColor | CustomColor = 'labelTertiary'; - let text; - if ((!error && !warning) || !selectedOptionIsCustom) { - color = 'labelTertiary'; - text = '􀅵'; - } else if (error) { - color = { - custom: colors.red, - }; - text = '􀇿'; - } else { - color = { - custom: colors.yellowFavorite, - }; - text = '􀇿'; - } - - return ( - openGasHelper(type)} - backgroundColor="accent" - style={{ maxWidth: 175 }} - > - - - {`${label} `} - - {text} - - - - - - ); - }; - return ( - + {i18n.t(i18n.l.gas.gas_settings)} - - - {renderRowLabel(i18n.t(i18n.l.gas.current_base_fee), trendType)} - - - - - - {/* TODO: Add error and warning values here */} - {renderRowLabel(i18n.t(i18n.l.gas.max_base_fee), MAX_BASE_FEE_TYPE, null, null)} - - - - {/* TODO: Handle decrement by 3 */} - {}}> - - {/* TODO: 56% opacity */} - - 􀅽 - - - - - - - {/* TODO: Handle increment by 3 */} - {}}> - - {/* TODO: 56% opacity */} - - 􀅼 - - - - - - Gwei - - - - - - {/* TODO: Add error and warning values here */} - {renderRowLabel(i18n.t(i18n.l.gas.miner_tip), MINER_TIP_TYPE, null, null)} - - - - {/* TODO: Handle decrement by 1 */} - {}}> - - {/* TODO: 56% opacity */} - - 􀅽 - - - - - - - {/* TODO: Handle increment by 1 */} - {}}> - - {/* TODO: 56% opacity */} - - 􀅼 - - - - - - Gwei - - - + + + + - - - - - {i18n.t(i18n.l.gas.max_transaction_fee)} - - - 􀅴 - - - - - - - - - + + ); diff --git a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx index 15ca364a16a..392c632d1f0 100644 --- a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx +++ b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx @@ -1,4 +1,9 @@ +import * as i18n from '@/languages'; import React, { useCallback } from 'react'; + +import { ReviewGasButton } from '@/__swaps__/screens/Swap/components/GasButton'; +import { ChainId } from '@/__swaps__/types/chains'; +import { AnimatedText, Box, Inline, Separator, Stack, Text, globalColors, useColorMode } from '@/design-system'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -9,22 +14,22 @@ import Animated, { useSharedValue, withTiming, } from 'react-native-reanimated'; +import { fadeConfig } from '../constants'; +import { NavigationSteps, useSwapContext } from '../providers/swap-provider'; +import { AnimatedSwitch } from './AnimatedSwitch'; -import { AnimatedText, Box, Inline, Separator, Stack, Text, globalColors, useColorMode } from '@/design-system'; -import * as i18n from '@/languages'; -import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; import { useAccountSettings } from '@/hooks'; +import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; -import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import { fadeConfig } from '@/__swaps__/screens/Swap/constants'; -import { ChainId } from '@/__swaps__/types/chains'; -import { chainNameForChainIdWithMainnetSubstitutionWorklet } from '@/__swaps__/utils/chains'; -import { AnimatedSwitch } from '@/__swaps__/screens/Swap/components/AnimatedSwitch'; -import { GasButton } from '@/__swaps__/screens/Swap/components/GasButton'; -import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; import { AnimatedChainImage } from '@/__swaps__/screens/Swap/components/AnimatedChainImage'; -import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, handleSignificantDecimals, multiply } from '@/__swaps__/utils/numbers'; +import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; import { useNativeAssetForChain } from '@/__swaps__/screens/Swap/hooks/useNativeAssetForChain'; +import { chainNameForChainIdWithMainnetSubstitutionWorklet } from '@/__swaps__/utils/chains'; +import { useEstimatedTime } from '@/__swaps__/utils/meteorology'; +import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, handleSignificantDecimals, multiply } from '@/__swaps__/utils/numbers'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; +import { useSwapEstimatedGasFee } from '../hooks/useEstimatedGasFee'; +import { useSelectedGas, useSelectedGasSpeed } from '../hooks/useSelectedGas'; const unknown = i18n.t(i18n.l.swap.unknown); @@ -81,6 +86,30 @@ const RainbowFee = () => { ); }; +function EstimatedGasFee() { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const gasSettings = useSelectedGas(chainId); + const estimatedGasFee = useSwapEstimatedGasFee(gasSettings); + + return ( + + {estimatedGasFee} + + ); +} + +function EstimatedArrivalTime() { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const speed = useSelectedGasSpeed(chainId); + const { data: estimatedTime } = useEstimatedTime({ chainId, speed }); + if (!estimatedTime) return null; + return ( + + {estimatedTime} + + ); +} + export function ReviewPanel() { const { isDarkMode } = useColorMode(); const { configProgress, SwapSettings, SwapInputController, internalSelectedInputAsset, internalSelectedOutputAsset } = useSwapContext(); @@ -109,10 +138,6 @@ export function ReviewPanel() { SwapSettings.onUpdateSlippage('plus'); }; - // TODO: Comes from gas store - const estimatedGasFee = useSharedValue('$2.25'); - const estimatedArrivalTime = useSharedValue('~4 sec'); - const styles = useAnimatedStyle(() => { return { display: configProgress.value !== NavigationSteps.SHOW_REVIEW ? 'none' : 'flex', @@ -296,8 +321,8 @@ export function ReviewPanel() { - - + + @@ -312,7 +337,7 @@ export function ReviewPanel() { - + diff --git a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx index 09bf493fcaf..2170757a807 100644 --- a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx +++ b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx @@ -1,21 +1,22 @@ import React from 'react'; +import Animated, { runOnUI, useAnimatedStyle, withSpring } from 'react-native-reanimated'; +import { StyleSheet } from 'react-native'; import { getSoftMenuBarHeight } from 'react-native-extra-dimensions-android'; import { PanGestureHandler } from 'react-native-gesture-handler'; import { Box, Column, Columns, Separator, globalColors, useColorMode } from '@/design-system'; import { safeAreaInsetValues } from '@/utils'; -import { SwapActionButton } from './SwapActionButton'; -import { GasButton } from './GasButton'; import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH, springConfig } from '@/__swaps__/screens/Swap/constants'; import { IS_ANDROID } from '@/env'; import { useSwapContext, NavigationSteps } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import Animated, { runOnUI, useAnimatedStyle, withSpring } from 'react-native-reanimated'; -import { StyleSheet } from 'react-native'; + import { opacity } from '@/__swaps__/utils/swaps'; -import { ReviewPanel } from './ReviewPanel'; -import { GasPanel } from './GasPanel'; import { useBottomPanelGestureHandler } from '../hooks/useBottomPanelGestureHandler'; +import { GasButton } from './GasButton'; +import { GasPanel } from './GasPanel'; +import { ReviewPanel } from './ReviewPanel'; +import { SwapActionButton } from './SwapActionButton'; export function SwapBottomPanel() { const { isDarkMode } = useColorMode(); diff --git a/src/__swaps__/screens/Swap/hooks/formatNumber.ts b/src/__swaps__/screens/Swap/hooks/formatNumber.ts new file mode 100644 index 00000000000..bbaf4f72d6d --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/formatNumber.ts @@ -0,0 +1,31 @@ +import store from '@/redux/store'; +import { supportedNativeCurrencies } from '@/references'; + +const decimalSeparator = '.'; +export const formatNumber = (value: string, options?: { decimals?: number }) => { + if (!+value) return `0${decimalSeparator}00`; + if (+value < 0.0001) return `<0${decimalSeparator}0001`; + + const [whole, fraction = ''] = value.split(decimalSeparator); + const decimals = options?.decimals; + const paddedFraction = `${fraction.padEnd(decimals || 4, '0')}`; + + if (decimals) { + if (decimals === 0) return whole; + return `${whole}${decimalSeparator}${paddedFraction.slice(0, decimals)}`; + } + + if (+whole > 0) return `${whole}${decimalSeparator}${paddedFraction.slice(0, 2)}`; + return `0${decimalSeparator}${paddedFraction.slice(0, 4)}`; +}; + +const getUserPreferredCurrency = () => { + const currency = store.getState().settings.nativeCurrency; + return supportedNativeCurrencies[currency]; +}; + +export const formatCurrency = (value: string, currency = getUserPreferredCurrency()) => { + const formatted = formatNumber(value); + if (currency.alignment === 'left') return `${currency.symbol}${formatted}`; + return `${formatted} ${currency.symbol}`; +}; diff --git a/src/__swaps__/screens/Swap/hooks/useCustomGas.ts b/src/__swaps__/screens/Swap/hooks/useCustomGas.ts index 69eeb3f1232..5bc76c842d8 100644 --- a/src/__swaps__/screens/Swap/hooks/useCustomGas.ts +++ b/src/__swaps__/screens/Swap/hooks/useCustomGas.ts @@ -1,141 +1,37 @@ -import { useGas } from '@/hooks'; -import { useCallback } from 'react'; -import { runOnJS, useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated'; -import { gasUtils } from '@/utils'; -import { greaterThan } from '@/__swaps__/utils/numbers'; -import { gweiToWei, parseGasFeeParam } from '@/parsers'; -import { GasSpeed } from '@/__swaps__/types/gas'; - -export enum CUSTOM_GAS_FIELDS { - MAX_BASE_FEE = 'maxBaseFee', - PRIORITY_FEE = 'priorityFee', -} - -const { CUSTOM } = gasUtils; - -/** - * TODO: Work left to do for custom gas - * 1. Need to add in currentBaseFee for current network - * 2. Show current gas trend on custom gas panel UI somewhere - * 3. Allow user to type in both fields (make both animated textinput component) - * - will have to figure out how to handle prompting keyboard and dismissing - * 4. Handle long press on both fields - * 5. Handle showing warnings (overpaying, might fail, etc.) - */ - -export function useCustomGas() { - const { selectedGasFee, currentBlockParams, updateToCustomGasFee, updateGasFeeOption, gasFeeParamsBySpeed } = useGas(); - - const currentBaseFee = useSharedValue(currentBlockParams?.baseFeePerGas?.gwei || 'Unknown'); - const maxBaseFee = useSharedValue(currentBlockParams?.baseFeePerGas?.amount || '1'); - const priorityFee = useSharedValue('1'); - - const maxTransactionFee = useDerivedValue(() => { - const gasPrice = gasFeeParamsBySpeed?.[GasSpeed.CUSTOM]?.maxBaseFee.gwei || ''; - if (gasPrice.trim() === '') return 'Unknown'; - return gasPrice; +import { ChainId } from '@/__swaps__/types/chains'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +export type EIP1159GasSettings = { + isEIP1559: true; + maxBaseFee: string; + maxPriorityFee: string; +}; + +export type LegacyGasSettings = { + isEIP1559: false; + gasPrice: string; +}; + +export type GasSettings = EIP1159GasSettings | LegacyGasSettings; + +export type CustomGasStoreState = { [c in ChainId]?: GasSettings }; +export const useCustomGasStore = createRainbowStore(() => ({})); + +export const useCustomGasSettings = (chainId: ChainId) => useCustomGasStore(s => s[chainId]); +export const getCustomGasSettings = (chainId: ChainId) => useCustomGasStore.getState()[chainId]; + +export const setCustomGasSettings = (chainId: ChainId, update: Partial) => { + useCustomGasStore.setState(s => { + const state = s[chainId] || { + isEIP1559: !('gasPrice' in update && !!update.gasPrice), + maxBaseFee: '0', + maxPriorityFee: '0', + gasPrice: '0', + }; + return { [chainId]: { ...state, ...update } as GasSettings }; }); +}; - useAnimatedReaction( - () => currentBlockParams?.baseFeePerGas?.gwei, - current => { - currentBaseFee.value = current; - } - ); - - const updateCustomFieldValue = useCallback( - (field: CUSTOM_GAS_FIELDS, value: string) => { - switch (field) { - case CUSTOM_GAS_FIELDS.MAX_BASE_FEE: { - const maxBaseFee = parseGasFeeParam(gweiToWei(value || 0)); - const newGasParams = { - ...selectedGasFee.gasFeeParams, - maxBaseFee, - }; - updateToCustomGasFee(newGasParams); - break; - } - - case CUSTOM_GAS_FIELDS.PRIORITY_FEE: { - const priorityFee = parseGasFeeParam(gweiToWei(value || 0)); - const newGasParams = { - ...selectedGasFee.gasFeeParams, - maxPriorityFeePerGas: priorityFee, - }; - updateToCustomGasFee(newGasParams); - break; - } - } - }, - [selectedGasFee.gasFeeParams, updateToCustomGasFee] - ); - - const onUpdateField = useCallback( - (field: CUSTOM_GAS_FIELDS, operation: 'increment' | 'decrement', step = 1) => { - 'worklet'; - - switch (field) { - case CUSTOM_GAS_FIELDS.MAX_BASE_FEE: { - const text = maxBaseFee.value; - - if (operation === 'decrement' && greaterThan(1, Number(text) - step)) { - maxBaseFee.value = String(1); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.MAX_BASE_FEE, String(1)); - return; - } - - const maxBaseFeeNumber = Number(text); - maxBaseFee.value = operation === 'increment' ? String(maxBaseFeeNumber + step) : String(maxBaseFeeNumber - step); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.MAX_BASE_FEE, maxBaseFee.value); - break; - } - - case CUSTOM_GAS_FIELDS.PRIORITY_FEE: { - const text = priorityFee.value; - - if (operation === 'decrement' && greaterThan(1, Number(text) - step)) { - priorityFee.value = String(1); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.PRIORITY_FEE, String(1)); - return; - } - - const priorityFeeNumber = Number(text); - priorityFee.value = operation === 'increment' ? String(priorityFeeNumber + step) : String(priorityFeeNumber - step); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.PRIORITY_FEE, priorityFee.value); - break; - } - } - }, - [maxBaseFee, priorityFee, updateCustomFieldValue] - ); - - const updateCustomGas = ({ priorityFee, baseFee }: { priorityFee: string; baseFee: string }) => { - updateGasFeeOption(CUSTOM); - const maxPriorityFeePerGas = parseGasFeeParam(gweiToWei(priorityFee || 0)); - const maxBaseFee = parseGasFeeParam(gweiToWei(baseFee || 0)); - - updateToCustomGasFee({ - ...selectedGasFee.gasFeeParams, - maxPriorityFeePerGas, - maxBaseFee, - }); - }; - - const onSaveCustomGas = () => { - 'worklet'; - - runOnJS(updateCustomGas)({ - priorityFee: priorityFee.value, - baseFee: maxBaseFee.value, - }); - }; - - return { - currentBaseFee, - maxBaseFee, - priorityFee, - maxTransactionFee, - onUpdateField, - onSaveCustomGas, - }; -} +export const setCustomMaxBaseFee = (chainId: ChainId, maxBaseFee = '0') => setCustomGasSettings(chainId, { maxBaseFee }); +export const setCustomMaxPriorityFee = (chainId: ChainId, maxPriorityFee = '0') => setCustomGasSettings(chainId, { maxPriorityFee }); +export const setCustomGasPrice = (chainId: ChainId, gasPrice = '0') => setCustomGasSettings(chainId, { gasPrice }); diff --git a/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts new file mode 100644 index 00000000000..6e01456917b --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts @@ -0,0 +1,47 @@ +import { ChainId } from '@/__swaps__/types/chains'; +import { weiToGwei } from '@/__swaps__/utils/ethereum'; +import { add, multiply } from '@/__swaps__/utils/numbers'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; +import ethereumUtils, { useNativeAssetForNetwork } from '@/utils/ethereumUtils'; +import { formatUnits } from 'viem'; +import { formatCurrency, formatNumber } from './formatNumber'; +import { GasSettings } from './useCustomGas'; +import { useSwapEstimatedGasLimit } from './useSwapEstimatedGasLimit'; + +export function useEstimatedGasFee({ + chainId, + gasLimit, + gasSettings, +}: { + chainId: ChainId; + gasLimit: string | undefined; + gasSettings: GasSettings | undefined; +}) { + const network = ethereumUtils.getNetworkFromChainId(chainId); + const nativeNetworkAsset = useNativeAssetForNetwork(network); + + if (!gasLimit || !gasSettings || !nativeNetworkAsset) return 'Loading...'; // TODO: loading state + + const amount = gasSettings.isEIP1559 ? add(gasSettings.maxBaseFee, gasSettings.maxPriorityFee) : gasSettings.gasPrice; + + const totalWei = multiply(gasLimit, amount); + const nativePrice = nativeNetworkAsset.price.value?.toString(); + + if (!nativePrice) return `${formatNumber(weiToGwei(totalWei))} Gwei`; + + const gasAmount = formatUnits(BigInt(totalWei), nativeNetworkAsset.decimals).toString(); + const feeInUserCurrency = multiply(nativePrice, gasAmount); + + return formatCurrency(feeInUserCurrency); +} + +export function useSwapEstimatedGasFee(gasSettings: GasSettings | undefined) { + const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + + const assetToSell = useSwapsStore(s => s.inputAsset); + const quote = useSwapsStore(s => s.quote); + + const { data: gasLimit } = useSwapEstimatedGasLimit({ chainId, quote, assetToSell }, { enabled: !!quote }); + + return useEstimatedGasFee({ chainId, gasLimit, gasSettings }); +} diff --git a/src/__swaps__/screens/Swap/hooks/useSelectedGas.ts b/src/__swaps__/screens/Swap/hooks/useSelectedGas.ts new file mode 100644 index 00000000000..667d0b46c74 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useSelectedGas.ts @@ -0,0 +1,44 @@ +import { ChainId } from '@/__swaps__/types/chains'; +import { getCachedGasSuggestions, useMeteorologySuggestions } from '@/__swaps__/utils/meteorology'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; +import { useMemo } from 'react'; +import { getCustomGasSettings, useCustomGasSettings } from './useCustomGas'; + +export type GasSpeed = 'custom' | 'urgent' | 'fast' | 'normal'; +const useSelectedGasSpeedStore = createRainbowStore<{ [c in ChainId]?: GasSpeed }>(() => ({}), { + version: 0, + storageKey: 'preferred gas speed', +}); +export const useSelectedGasSpeed = (chainId: ChainId) => + useSelectedGasSpeedStore(s => { + const speed = s[chainId] || 'fast'; + if (speed === 'custom' && getCustomGasSettings(chainId) === undefined) return 'fast'; + return speed; + }); +export const setSelectedGasSpeed = (chainId: ChainId, speed: GasSpeed) => useSelectedGasSpeedStore.setState({ [chainId]: speed }); +export const getSelectedGasSpeed = (chainId: ChainId) => useSelectedGasSpeedStore.getState()[chainId] || 'fast'; + +export function useSelectedGas(chainId: ChainId) { + const selectedGasSpeed = useSelectedGasSpeed(chainId); + + const userCustomGasSettings = useCustomGasSettings(chainId); + const { data: metereologySuggestions } = useMeteorologySuggestions({ + chainId, + enabled: selectedGasSpeed !== 'custom', + }); + + return useMemo(() => { + if (selectedGasSpeed === 'custom') return userCustomGasSettings; + return metereologySuggestions?.[selectedGasSpeed]; + }, [selectedGasSpeed, userCustomGasSettings, metereologySuggestions]); +} + +export function getGasSettings(speed: GasSpeed, chainId: ChainId) { + if (speed === 'custom') return getCustomGasSettings(chainId); + return getCachedGasSuggestions(chainId)?.[speed]; +} + +export function getSelectedGas(chainId: ChainId) { + const selectedGasSpeed = useSelectedGasSpeedStore.getState()[chainId] || 'fast'; + return getGasSettings(selectedGasSpeed, chainId); +} diff --git a/src/__swaps__/screens/Swap/hooks/useSwapEstimatedGasLimit.ts b/src/__swaps__/screens/Swap/hooks/useSwapEstimatedGasLimit.ts new file mode 100644 index 00000000000..905a2ae1c58 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useSwapEstimatedGasLimit.ts @@ -0,0 +1,98 @@ +import { CrosschainQuote, Quote, QuoteError, SwapType } from '@rainbow-me/swaps'; +import { useQuery } from '@tanstack/react-query'; + +import { ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { ChainId } from '@/__swaps__/types/chains'; +import { estimateUnlockAndCrosschainSwap } from '@/raps/unlockAndCrosschainSwap'; +import { estimateUnlockAndSwap } from '@/raps/unlockAndSwap'; +import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; +import { gasUnits } from '@/references/gasUnits'; + +// /////////////////////////////////////////////// +// Query Types + +export type EstimateSwapGasLimitResponse = { + gasLimit: string; +}; + +export type EstimateSwapGasLimitArgs = { + chainId: ChainId; + quote?: Quote | CrosschainQuote | QuoteError | null; + assetToSell?: ParsedSearchAsset | null; +}; + +// /////////////////////////////////////////////// +// Query Key + +const estimateSwapGasLimitQueryKey = ({ chainId, quote, assetToSell }: EstimateSwapGasLimitArgs) => + createQueryKey('estimateSwapGasLimit', { chainId, quote, assetToSell }, { persisterVersion: 1 }); + +type EstimateSwapGasLimitQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +async function estimateSwapGasLimitQueryFunction({ + queryKey: [{ chainId, quote, assetToSell }], +}: QueryFunctionArgs) { + if (!quote || 'error' in quote || !assetToSell) { + return gasUnits.basic_swap[chainId]; + } + + const gasLimit = await (quote.swapType === SwapType.crossChain + ? estimateUnlockAndCrosschainSwap({ + chainId, + quote: quote as CrosschainQuote, + sellAmount: quote.sellAmount.toString(), + assetToSell, + }) + : estimateUnlockAndSwap({ + chainId, + quote, + sellAmount: quote.sellAmount.toString(), + assetToSell, + })); + + if (!gasLimit) { + return gasUnits.basic_swap[chainId]; + } + return gasLimit; +} + +type EstimateSwapGasLimitResult = QueryFunctionResult; + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchSwapEstimatedGasLimit( + { chainId, quote, assetToSell }: EstimateSwapGasLimitArgs, + config: QueryConfigWithSelect = {} +) { + return await queryClient.fetchQuery( + estimateSwapGasLimitQueryKey({ + chainId, + quote, + assetToSell, + }), + estimateSwapGasLimitQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function useSwapEstimatedGasLimit( + { chainId, quote, assetToSell }: EstimateSwapGasLimitArgs, + config: QueryConfigWithSelect = {} +) { + return useQuery( + estimateSwapGasLimitQueryKey({ + chainId, + quote, + assetToSell, + }), + estimateSwapGasLimitQueryFunction, + { keepPreviousData: true, staleTime: 12000, cacheTime: Infinity, ...config } + ); +} diff --git a/src/__swaps__/screens/Swap/hooks/useSwapGas.ts b/src/__swaps__/screens/Swap/hooks/useSwapGas.ts deleted file mode 100644 index 74438afbf8e..00000000000 --- a/src/__swaps__/screens/Swap/hooks/useSwapGas.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { SharedValue, runOnJS, useSharedValue } from 'react-native-reanimated'; -import { useCallback } from 'react'; - -import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; -import { gasStore } from '@/state/gas/gasStore'; -import { GasFeeParams } from '@/entities'; -import { GasFeeLegacyParams } from '@/__swaps__/types/gas'; -import { CUSTOM_GAS_FIELDS } from './useCustomGas'; -import { greaterThan } from '@/helpers/utilities'; - -export const useSwapGas = ({ - inputAsset, - outputAsset, -}: { - inputAsset: SharedValue; - outputAsset: SharedValue; -}) => { - const selectedGas = useSharedValue(null); - - // TODO: Keep these in sync with zustand gas store - const currentBaseFee = useSharedValue(''); - const maxBaseFee = useSharedValue(''); - const priorityFee = useSharedValue(''); - const maxTransactionFee = useSharedValue(''); - - const selectGasOption = useCallback( - (option: GasFeeParams | GasFeeLegacyParams) => { - 'worklet'; - - selectedGas.value = option; - runOnJS(gasStore.setState)({ - selectedGas: option as GasFeeLegacyParams, - }); - }, - [selectedGas] - ); - - const updateCustomFieldValue = useCallback((field: CUSTOM_GAS_FIELDS, value: string) => { - switch (field) { - case CUSTOM_GAS_FIELDS.MAX_BASE_FEE: { - // TODO: Update zustand store here? - break; - } - - case CUSTOM_GAS_FIELDS.PRIORITY_FEE: { - // TODO: Update zustand store here? - break; - } - } - }, []); - - const onUpdateField = useCallback( - (field: CUSTOM_GAS_FIELDS, operation: 'increment' | 'decrement', step = 1) => { - 'worklet'; - - switch (field) { - case CUSTOM_GAS_FIELDS.MAX_BASE_FEE: { - const text = maxBaseFee.value; - - if (operation === 'decrement' && greaterThan(1, Number(text) - step)) { - maxBaseFee.value = String(1); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.MAX_BASE_FEE, String(1)); - return; - } - - const maxBaseFeeNumber = Number(text); - maxBaseFee.value = operation === 'increment' ? String(maxBaseFeeNumber + step) : String(maxBaseFeeNumber - step); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.MAX_BASE_FEE, maxBaseFee.value); - break; - } - - case CUSTOM_GAS_FIELDS.PRIORITY_FEE: { - const text = priorityFee.value; - - if (operation === 'decrement' && greaterThan(1, Number(text) - step)) { - priorityFee.value = String(1); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.PRIORITY_FEE, String(1)); - return; - } - - const priorityFeeNumber = Number(text); - priorityFee.value = operation === 'increment' ? String(priorityFeeNumber + step) : String(priorityFeeNumber - step); - runOnJS(updateCustomFieldValue)(CUSTOM_GAS_FIELDS.PRIORITY_FEE, priorityFee.value); - break; - } - } - }, - [maxBaseFee, priorityFee, updateCustomFieldValue] - ); - - return { selectedGas, currentBaseFee, maxBaseFee, priorityFee, maxTransactionFee, onUpdateField, selectGasOption }; -}; diff --git a/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts b/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts index 13ae083d95b..567f71737ad 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; -import { SharedValue, useSharedValue } from 'react-native-reanimated'; +import { SharedValue, runOnJS, useSharedValue } from 'react-native-reanimated'; +import { onCloseGasPanel } from '../components/GasPanel'; import { useSwapInputsController } from './useSwapInputsController'; export const enum NavigationSteps { @@ -42,6 +43,7 @@ export function useSwapNavigation({ const handleShowGas = useCallback( ({ backToReview = false }: { backToReview?: boolean }) => { 'worklet'; + if (backToReview) { navigateBackToReview.value = true; } @@ -57,6 +59,9 @@ export function useSwapNavigation({ const handleDismissGas = useCallback(() => { 'worklet'; + + runOnJS(onCloseGasPanel)(); + if (configProgress.value === NavigationSteps.SHOW_GAS) { configProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; } diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 3b37c628832..b5f0209c7c3 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -1,6 +1,20 @@ // @refresh -import React, { createContext, useContext, ReactNode, useEffect } from 'react'; -import { StyleProp, TextStyle, TextInput } from 'react-native'; +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 { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { NavigationSteps, useSwapNavigation } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; +import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles'; +import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; +import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { ChainId } from '@/__swaps__/types/chains'; +import { SwapAssetType, inputKeys } from '@/__swaps__/types/swap'; +import { isSameAsset } from '@/__swaps__/utils/assets'; +import { parseAssetAndExtend } from '@/__swaps__/utils/swaps'; +import { logger } from '@/logger'; +import { swapsStore } from '@/state/swaps/swapsStore'; +import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; +import React, { ReactNode, createContext, useContext, useEffect } from 'react'; +import { StyleProp, TextInput, TextStyle } from 'react-native'; import { AnimatedRef, SharedValue, @@ -10,22 +24,7 @@ import { useDerivedValue, useSharedValue, } from 'react-native-reanimated'; -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 { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; -import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; -import { useSwapGas } from '@/__swaps__/screens/Swap/hooks/useSwapGas'; -import { useSwapSettings } from '@/__swaps__/screens/Swap/hooks/useSwapSettings'; -import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; -import { swapsStore } from '@/state/swaps/swapsStore'; -import { isSameAsset } from '@/__swaps__/utils/assets'; -import { parseAssetAndExtend } from '@/__swaps__/utils/swaps'; -import { ChainId } from '@/__swaps__/types/chains'; -import { logger } from '@/logger'; +import { useSwapSettings } from '../hooks/useSwapSettings'; interface SwapContextType { isFetching: SharedValue; @@ -58,7 +57,6 @@ interface SwapContextType { SwapTextStyles: ReturnType; SwapNavigation: ReturnType; SwapWarning: ReturnType; - SwapGas: ReturnType; confirmButtonIcon: Readonly>; confirmButtonLabel: Readonly>; @@ -98,11 +96,6 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { inputAsset: internalSelectedInputAsset, }); - const SwapGas = useSwapGas({ - inputAsset: internalSelectedInputAsset, - outputAsset: internalSelectedOutputAsset, - }); - const SwapInputController = useSwapInputsController({ focusedInput, lastTypedInput, @@ -360,7 +353,6 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { SwapTextStyles, SwapNavigation, SwapWarning, - SwapGas, confirmButtonIcon, confirmButtonLabel, diff --git a/src/__swaps__/types/gas.ts b/src/__swaps__/types/gas.ts index b9d9d9fb964..dbbe7584285 100644 --- a/src/__swaps__/types/gas.ts +++ b/src/__swaps__/types/gas.ts @@ -48,18 +48,11 @@ export type GasFeeParamsBySpeed = { }; export interface BlocksToConfirmationByPriorityFee { - 1: string; - 2: string; - 3: string; - 4: string; + [priorityFee: string]: string; } export interface BlocksToConfirmationByBaseFee { - 4: string; - 8: string; - 40: string; - 120: string; - 240: string; + [baseFee: string]: string; } export interface BlocksToConfirmation { diff --git a/src/__swaps__/utils/meteorology.ts b/src/__swaps__/utils/meteorology.ts index 2530f3670e9..3606135cd51 100644 --- a/src/__swaps__/utils/meteorology.ts +++ b/src/__swaps__/utils/meteorology.ts @@ -1,9 +1,12 @@ import { useQuery } from '@tanstack/react-query'; -import { QueryConfig, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; import { ChainId } from '@/__swaps__/types/chains'; import { rainbowMeteorologyGetData } from '@/handlers/gasFees'; +import { abs, lessThan, subtract } from '@/helpers/utilities'; +import { QueryConfig, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; import { getNetworkFromChainId } from '@/utils/ethereumUtils'; +import { GasSpeed, getGasSettings, getSelectedGasSpeed } from '../screens/Swap/hooks/useSelectedGas'; +import { getMinimalTimeUnitStringForMs } from './time'; // Query Types @@ -12,23 +15,13 @@ export type MeteorologyResponse = { baseFeeSuggestion: string; baseFeeTrend: number; blocksToConfirmationByBaseFee: { - '4': string; - '8': string; - '40': string; - '120': string; - '240': string; + [baseFee: string]: string; }; blocksToConfirmationByPriorityFee: { - '1': string; - '2': string; - '3': string; - '4': string; + [priorityFee: string]: string; }; confirmationTimeByPriorityFee: { - '15': string; - '30': string; - '45': string; - '60': string; + [priorityFee: string]: string; }; currentBaseFee: string; maxPriorityFeeSuggestions: { @@ -79,7 +72,7 @@ async function meteorologyQueryFunction({ queryKey: [{ chainId }] }: QueryFuncti return meteorologyData; } -type MeteorologyResult = QueryFunctionResult; +export type MeteorologyResult = QueryFunctionResult; // /////////////////////////////////////////////// // Query Fetcher @@ -94,6 +87,155 @@ export async function fetchMeteorology( // /////////////////////////////////////////////// // Query Hook -export function useMeteorology({ chainId }: MeteorologyArgs, config: QueryConfig = {}) { - return useQuery(meteorologyQueryKey({ chainId }), meteorologyQueryFunction, config); +export function useMeteorology( + { chainId }: MeteorologyArgs, + { select, enabled }: { select?: (data: MeteorologyResult) => Selected; enabled?: boolean } = { select: data => data as Selected } +) { + return useQuery(meteorologyQueryKey({ chainId }), meteorologyQueryFunction, { + select, + enabled, + refetchInterval: 12_000, // 12 seconds + staleTime: 12_000, // 12 seconds + cacheTime: Infinity, + notifyOnChangeProps: ['data'], + }); +} + +function selectGasSuggestions({ data }: MeteorologyResult) { + if ('legacy' in data) { + const { fastGasPrice, proposeGasPrice, safeGasPrice } = data.legacy; + return { + urgent: { + isEIP1559: false, + gasPrice: fastGasPrice, + }, + fast: { + isEIP1559: false, + gasPrice: proposeGasPrice, + }, + normal: { + isEIP1559: false, + gasPrice: safeGasPrice, + }, + } as const; + } + + const { baseFeeSuggestion, maxPriorityFeeSuggestions } = data; + return { + urgent: { + isEIP1559: true, + maxBaseFee: baseFeeSuggestion, + maxPriorityFee: maxPriorityFeeSuggestions.urgent, + }, + fast: { + isEIP1559: true, + maxBaseFee: baseFeeSuggestion, + maxPriorityFee: maxPriorityFeeSuggestions.fast, + }, + normal: { + isEIP1559: true, + maxBaseFee: baseFeeSuggestion, + maxPriorityFee: maxPriorityFeeSuggestions.normal, + }, + } as const; +} + +export const getMeteorologyCachedData = (chainId: ChainId) => { + return queryClient.getQueryData(meteorologyQueryKey({ chainId })); +}; + +function selectBaseFee({ data }: MeteorologyResult) { + if ('legacy' in data) return undefined; + return data.currentBaseFee; +} + +export function useBaseFee({ + chainId, + enabled, + select = s => s as Selected, +}: { + chainId: ChainId; + enabled?: boolean; + select?: (c: string | undefined) => Selected; +}) { + return useMeteorology({ chainId }, { select: d => select(selectBaseFee(d)), enabled }); +} + +function selectGasTrend({ data }: MeteorologyResult) { + if ('legacy' in data) return 'notrend'; + + const trend = data.baseFeeTrend; + if (trend === -1) return 'falling'; + if (trend === 1) return 'rising'; + if (trend === 2) return 'surging'; + if (trend === 0) return 'stable'; + return 'notrend'; } + +export function useGasTrend({ chainId }: { chainId: ChainId }) { + return useMeteorology({ chainId }, { select: selectGasTrend }); +} + +const diff = (a: string, b: string) => abs(subtract(a, b)); +function findClosestValue(target: string, array: string[]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return array.find((value, index) => { + const nextValue = array[index + 1]; + if (!nextValue) return true; + return lessThan(diff(value, target), diff(nextValue, target)); + })!; +} + +export function useEstimatedTime({ chainId, speed }: { chainId: ChainId; speed: GasSpeed }) { + return useMeteorology( + { chainId }, + { + select: ({ data }) => { + if ('legacy' in data) return undefined; + const gasSettings = getGasSettings(speed, chainId); + if (!gasSettings?.isEIP1559) return undefined; + const value = findClosestValue(gasSettings.maxPriorityFee, Object.values(data.confirmationTimeByPriorityFee)); + const [time] = Object.entries(data.confirmationTimeByPriorityFee).find(([, v]) => v === value) || []; + if (!time) return undefined; + return `${+time >= 3600 ? '>' : '~'} ${getMinimalTimeUnitStringForMs(+time * 1000)}`; + }, + } + ); +} + +export const getCachedCurrentBaseFee = (chainId: ChainId) => { + const data = getMeteorologyCachedData(chainId); + if (!data) return undefined; + return selectBaseFee(data); +}; + +export function useMeteorologySuggestions>({ + chainId, + enabled, + select = s => s as Selected, +}: { + chainId: ChainId; + enabled?: boolean; + select?: (d: ReturnType) => Selected; +}) { + return useMeteorology({ chainId }, { select: d => select(selectGasSuggestions(d)), enabled }); +} + +export const useIsChainEIP1559 = (chainId: ChainId) => { + const { data } = useMeteorology({ chainId }, { select: ({ data }) => !('legacy' in data) }); + if (data === undefined) return true; + return data; +}; + +export const getCachedGasSuggestions = (chainId: ChainId) => { + const data = getMeteorologyCachedData(chainId); + if (!data) return undefined; + return selectGasSuggestions(data); +}; + +export const getSelectedSpeedSuggestion = (chainId: ChainId) => { + const suggestions = getCachedGasSuggestions(chainId); + const speed = getSelectedGasSpeed(chainId); + if (speed === 'custom') return; + return suggestions?.[speed]; +}; diff --git a/src/raps/unlockAndCrosschainSwap.ts b/src/raps/unlockAndCrosschainSwap.ts index 4e74263c6d3..6346f7238a0 100644 --- a/src/raps/unlockAndCrosschainSwap.ts +++ b/src/raps/unlockAndCrosschainSwap.ts @@ -1,19 +1,22 @@ import { ALLOWS_PERMIT, ChainId, ETH_ADDRESS as ETH_ADDRESS_AGGREGATOR, PermitSupportedTokenList, WRAPPED_ASSET } from '@rainbow-me/swaps'; import { Address } from 'viem'; -import { ETH_ADDRESS } from '../references'; import { isNativeAsset } from '@/handlers/assets'; import { add } from '@/helpers/utilities'; import { ethereumUtils, isLowerCaseMatch } from '@/utils'; +import { ETH_ADDRESS } from '../references'; import { assetNeedsUnlocking, estimateApprove } from './actions'; import { estimateCrosschainSwapGasLimit } from './actions/crosschainSwap'; import { createNewAction, createNewRap } from './common'; import { RapAction, RapSwapActionParameters, RapUnlockActionParameters } from './references'; -export const estimateUnlockAndCrosschainSwap = async (swapParameters: RapSwapActionParameters<'crosschainSwap'>) => { - const { sellAmount, quote, chainId, assetToSell } = swapParameters; - +export const estimateUnlockAndCrosschainSwap = async ({ + sellAmount, + quote, + chainId, + assetToSell, +}: Pick, 'sellAmount' | 'quote' | 'chainId' | 'assetToSell'>) => { const { from: accountAddress, sellTokenAddress, diff --git a/src/raps/unlockAndSwap.ts b/src/raps/unlockAndSwap.ts index 08b33a4aad2..970029d0b4a 100644 --- a/src/raps/unlockAndSwap.ts +++ b/src/raps/unlockAndSwap.ts @@ -7,21 +7,24 @@ import { } from '@rainbow-me/swaps'; import { Address } from 'viem'; -import { ETH_ADDRESS } from '../references'; import { ChainId } from '@/__swaps__/types/chains'; import { isNativeAsset } from '@/handlers/assets'; import { add } from '@/helpers/utilities'; import { ethereumUtils, isLowerCaseMatch } from '@/utils'; +import { ETH_ADDRESS } from '../references'; +import { isWrapNative } from '@/handlers/swap'; import { assetNeedsUnlocking, estimateApprove, estimateSwapGasLimit } from './actions'; import { estimateUnlockAndSwapFromMetadata } from './actions/swap'; import { createNewAction, createNewRap } from './common'; import { RapAction, RapSwapActionParameters, RapUnlockActionParameters } from './references'; -import { isWrapNative } from '@/handlers/swap'; - -export const estimateUnlockAndSwap = async (swapParameters: RapSwapActionParameters<'swap'>) => { - const { sellAmount, quote, chainId, assetToSell } = swapParameters; +export const estimateUnlockAndSwap = async ({ + sellAmount, + quote, + chainId, + assetToSell, +}: Pick, 'sellAmount' | 'quote' | 'chainId' | 'assetToSell'>) => { const { from: accountAddress, sellTokenAddress, diff --git a/src/references/supportedCurrencies.ts b/src/references/supportedCurrencies.ts index ea2c72b79a4..0047a068e30 100644 --- a/src/references/supportedCurrencies.ts +++ b/src/references/supportedCurrencies.ts @@ -196,7 +196,7 @@ export const supportedCurrencies = { symbol: 'R', glyph: 'R', }, -}; +} as const; export type SupportedCurrency = typeof supportedCurrencies; export type SupportedCurrencyKey = keyof SupportedCurrency; diff --git a/src/state/swaps/swapsStore.ts b/src/state/swaps/swapsStore.ts index 42dae86082e..a10b28b9dce 100644 --- a/src/state/swaps/swapsStore.ts +++ b/src/state/swaps/swapsStore.ts @@ -1,9 +1,9 @@ import { ParsedSearchAsset } from '@/__swaps__/types/assets'; -import { CrosschainQuote, Quote, QuoteError, Source } from '@rainbow-me/swaps'; -import { getDefaultSlippage } from '@/__swaps__/utils/swaps'; import { ChainId } from '@/__swaps__/types/chains'; +import { getDefaultSlippage } from '@/__swaps__/utils/swaps'; import { DEFAULT_CONFIG } from '@/model/remoteConfig'; import { createRainbowStore } from '@/state/internal/createRainbowStore'; +import { CrosschainQuote, Quote, QuoteError, Source } from '@rainbow-me/swaps'; export interface SwapsState { // assets @@ -52,3 +52,5 @@ export const swapsStore = createRainbowStore( }, } ); + +export const useSwapsStore = swapsStore;