From 80d5030e4310b9e5d8d3a413216a372824efe1f0 Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Mon, 16 Jun 2025 11:24:01 -0700 Subject: [PATCH 1/2] feat: limit orders poc --- .../transactions/Switch/BaseSwitchModal.tsx | 95 -- .../Switch/BaseSwitchModalContent.tsx | 607 +------------ .../transactions/Switch/ExpirySelector.tsx | 75 ++ .../transactions/Switch/LimitSwitch.tsx | 837 ++++++++++++++++++ .../transactions/Switch/MarketSwitch.tsx | 654 ++++++++++++++ .../transactions/Switch/PriceInput.tsx | 184 ++++ .../transactions/Switch/SwitchActions.tsx | 24 +- .../transactions/Switch/SwitchAssetInput.tsx | 30 +- .../transactions/Switch/SwitchModal.tsx | 104 ++- .../Switch/SwitchModalTxDetails.tsx | 150 +++- .../Switch/SwitchTypeSelector.tsx | 58 ++ .../Switch/cowprotocol.helpers.ts | 15 +- .../transactions/Switch/switch.types.ts | 4 +- src/hooks/switch/cowprotocol.rates.ts | 3 +- .../switch/useMultiProviderSwitchRates.ts | 2 + src/locales/el/messages.js | 2 +- src/locales/en/messages.js | 2 +- src/locales/en/messages.po | 26 +- src/locales/es/messages.js | 2 +- src/locales/fr/messages.js | 2 +- src/utils/events.ts | 4 + 21 files changed, 2153 insertions(+), 727 deletions(-) delete mode 100644 src/components/transactions/Switch/BaseSwitchModal.tsx create mode 100644 src/components/transactions/Switch/ExpirySelector.tsx create mode 100644 src/components/transactions/Switch/LimitSwitch.tsx create mode 100644 src/components/transactions/Switch/MarketSwitch.tsx create mode 100644 src/components/transactions/Switch/PriceInput.tsx create mode 100644 src/components/transactions/Switch/SwitchTypeSelector.tsx diff --git a/src/components/transactions/Switch/BaseSwitchModal.tsx b/src/components/transactions/Switch/BaseSwitchModal.tsx deleted file mode 100644 index ffeb42ff43..0000000000 --- a/src/components/transactions/Switch/BaseSwitchModal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Trans } from '@lingui/macro'; -import { Box, Typography } from '@mui/material'; -import React, { useEffect, useMemo, useState } from 'react'; -import { BasicModal } from 'src/components/primitives/BasicModal'; -import { supportedNetworksWithEnabledMarket } from 'src/components/transactions/Switch/common'; -import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton'; -import { useModalContext } from 'src/hooks/useModal'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { useRootStore } from 'src/store/root'; -import { CustomMarket, marketsData } from 'src/utils/marketsAndNetworksConfig'; - -import { - BaseSwitchModalContent, - getFilteredTokensForSwitch, - SwitchModalCustomizableProps, -} from './BaseSwitchModalContent'; - -const defaultNetwork = marketsData[CustomMarket.proto_mainnet_v3]; - -export const BaseSwitchModal = ({ - modalType, - switchDetails: swapDetails, - inputBalanceTitle: balanceTitle, - forcedDefaultInputToken, - forcedDefaultOutputToken, -}: SwitchModalCustomizableProps) => { - const { - type, - close, - args: { chainId }, - } = useModalContext(); - - const currentChainId = useRootStore((store) => store.currentChainId); - const { chainId: connectedChainId } = useWeb3Context(); - const user = useRootStore((store) => store.account); - - const [selectedChainId, setSelectedChainId] = useState(() => { - if (supportedNetworksWithEnabledMarket.find((elem) => elem.chainId === currentChainId)) - return currentChainId; - return defaultNetwork.chainId; - }); - - useEffect(() => { - // Passing chainId as prop will set default network for switch modal - if (chainId && supportedNetworksWithEnabledMarket.find((elem) => elem.chainId === chainId)) { - setSelectedChainId(chainId); - } else if ( - connectedChainId && - supportedNetworksWithEnabledMarket.find((elem) => elem.chainId === connectedChainId) - ) { - const supportedFork = supportedNetworksWithEnabledMarket.find( - (elem) => elem.underlyingChainId === connectedChainId - ); - setSelectedChainId(supportedFork ? supportedFork.chainId : connectedChainId); - } else if (supportedNetworksWithEnabledMarket.find((elem) => elem.chainId === currentChainId)) { - setSelectedChainId(currentChainId); - } else { - setSelectedChainId(defaultNetwork.chainId); - } - }, [currentChainId, chainId, connectedChainId]); - - const initialFromTokens = useMemo( - () => getFilteredTokensForSwitch(selectedChainId), - [selectedChainId] - ); - const initialToTokens = useMemo( - () => getFilteredTokensForSwitch(selectedChainId), - [selectedChainId] - ); - - return ( - - {!user ? ( - - - Please connect your wallet to swap tokens. - - close()} /> - - ) : ( - - )} - - ); -}; diff --git a/src/components/transactions/Switch/BaseSwitchModalContent.tsx b/src/components/transactions/Switch/BaseSwitchModalContent.tsx index 49cdc6acad..e536ad1a03 100644 --- a/src/components/transactions/Switch/BaseSwitchModalContent.tsx +++ b/src/components/transactions/Switch/BaseSwitchModalContent.tsx @@ -1,45 +1,16 @@ -import { normalize, normalizeBN } from '@aave/math-utils'; -import { OrderStatus, SupportedChainId, WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/cow-sdk'; -import { SwitchVerticalIcon } from '@heroicons/react/outline'; -import { Trans } from '@lingui/macro'; -import { Box, CircularProgress, IconButton, SvgIcon, Typography } from '@mui/material'; -import { useQueryClient } from '@tanstack/react-query'; -import { debounce } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; -import { Link } from 'src/components/primitives/Link'; -import { Warning } from 'src/components/primitives/Warning'; -import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton'; -import { isSmartContractWallet } from 'src/helpers/provider'; -import { TokenInfoWithBalance, useTokensBalance } from 'src/hooks/generic/useTokensBalance'; -import { useMultiProviderSwitchRates } from 'src/hooks/switch/useMultiProviderSwitchRates'; +import React, { useState } from 'react'; +import { TokenInfoWithBalance } from 'src/hooks/generic/useTokensBalance'; import { useSwitchProvider } from 'src/hooks/switch/useSwitchProvider'; -import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; -import { ModalType, useModalContext } from 'src/hooks/useModal'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; -import { useRootStore } from 'src/store/root'; -import { findByChainId } from 'src/ui-config/marketsConfig'; -import { queryKeysFactory } from 'src/ui-config/queries'; -import { TOKEN_LIST, TokenInfo } from 'src/ui-config/TokenList'; -import { wagmiConfig } from 'src/ui-config/wagmiConfig'; -import { GENERAL } from 'src/utils/events'; +import { ModalType } from 'src/hooks/useModal'; +import { TOKEN_LIST } from 'src/ui-config/TokenList'; import { CustomMarket, getNetworkConfig, marketsData } from 'src/utils/marketsAndNetworksConfig'; -import { parseUnits } from 'viem'; import { TxModalTitle } from '../FlowCommons/TxModalTitle'; -import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; -import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; import { supportedNetworksWithEnabledMarket, SupportedNetworkWithChainId } from './common'; -import { getOrders, isNativeToken } from './cowprotocol.helpers'; -import { NetworkSelector } from './NetworkSelector'; -import { isCowProtocolRates, SwitchProvider, SwitchRatesType } from './switch.types'; -import { SwitchActions } from './SwitchActions'; -import { SwitchAssetInput } from './SwitchAssetInput'; -import { SwitchErrors } from './SwitchErrors'; -import { SwitchRates } from './SwitchRates'; -import { SwitchSlippageSelector } from './SwitchSlippageSelector'; -import { SwitchTxSuccessView } from './SwitchTxSuccessView'; -import { validateSlippage, ValidationSeverity } from './validation.helpers'; +import { LimitSwitch } from './LimitSwitch'; +import { MarketSwitch } from './MarketSwitch'; +import { SwitchProvider, SwitchRatesType } from './switch.types'; +import { SwitchType, SwitchTypeSelector } from './SwitchTypeSelector'; export type SwitchDetailsParams = Parameters< NonNullable @@ -96,17 +67,14 @@ export interface SwitchModalCustomizableProps { } export const BaseSwitchModalContent = ({ - showSwitchInputAndOutputAssetsButton = true, showTitle = true, forcedDefaultInputToken, forcedChainId, forcedDefaultOutputToken, - supportedNetworks, - switchDetails, + modalType, inputBalanceTitle, - outputBalanceTitle, initialFromTokens, - showChangeNetworkWarning = true, + initialToTokens, }: { showTitle?: boolean; forcedChainId: number; @@ -118,12 +86,6 @@ export const BaseSwitchModalContent = ({ supportedNetworks: SupportedNetworkWithChainId[]; showChangeNetworkWarning?: boolean; } & SwitchModalCustomizableProps) => { - // State - const [inputAmount, setInputAmount] = useState(''); - const [debounceInputAmount, setDebounceInputAmount] = useState(''); - const { mainTxState: switchTxState, gasLimit, txError, setTxError, close } = useModalContext(); - const user = useRootStore((store) => store.account); - const { readOnlyModeAddress, chainId: connectedChainId } = useWeb3Context(); const defaultNetwork = marketsData[CustomMarket.proto_mainnet_v3]; const [selectedChainId, setSelectedChainId] = useState(() => { if (supportedNetworksWithEnabledMarket.find((elem) => elem.chainId === forcedChainId)) @@ -131,535 +93,42 @@ export const BaseSwitchModalContent = ({ return defaultNetwork.chainId; }); const switchProvider = useSwitchProvider({ chainId: selectedChainId }); - const [slippage, setSlippage] = useState(switchProvider == 'cowprotocol' ? '2' : '0.10'); - const [showGasStation, setShowGasStation] = useState(switchProvider == 'paraswap'); - const selectedNetworkConfig = getNetworkConfig(selectedChainId); - const isWrongNetwork = useIsWrongNetwork(selectedChainId); + const [switchType, setSwitchType] = useState(SwitchType.SWAP); - const [filteredTokens, setFilteredTokens] = useState(initialFromTokens); - const { data: baseTokenList, refetch: refetchBaseTokenList } = useTokensBalance( - filteredTokens, - selectedChainId, - user - ); - - const [userIsSmartContractWallet, setUserIsSmartContractWallet] = useState(false); - useEffect(() => { - try { - if (user && connectedChainId) { - getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { - isSmartContractWallet(user, provider).then((isSmartContractWallet) => { - setUserIsSmartContractWallet(isSmartContractWallet); - }); - }); - } - } catch (error) { - console.error(error); - } - }, [user, connectedChainId]); - - const debouncedInputChange = useMemo(() => { - return debounce((value: string) => { - setDebounceInputAmount(value); - }, 300); - }, [setDebounceInputAmount]); - - const handleInputChange = (value: string) => { - setTxError(undefined); - if (value === '-1') { - // Max Selected - setInputAmount(selectedInputToken.balance); - debouncedInputChange(selectedInputToken.balance); - } else { - setInputAmount(value); - debouncedInputChange(value); - } - }; - - const handleSelectedInputToken = (token: TokenInfoWithBalance) => { - if (!baseTokenList?.find((t) => t.address === token.address)) { - addNewToken(token).then(() => { - setSelectedInputToken(token); - setTxError(undefined); - }); - } else { - setSelectedInputToken(token); - setTxError(undefined); - } - }; - - const handleSelectedOutputToken = (token: TokenInfoWithBalance) => { - if (!baseTokenList?.find((t) => t.address === token.address)) { - addNewToken(token).then(() => { - setSelectedOutputToken(token); - setTxError(undefined); - }); - } else { - setSelectedOutputToken(token); - setTxError(undefined); - } - }; - - const onSwitchReserves = () => { - const fromToken = selectedInputToken; - const toToken = selectedOutputToken; - const toInput = switchRates - ? normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() - : '0'; - setSelectedInputToken(toToken); - setSelectedOutputToken(fromToken); - setInputAmount(toInput); - setDebounceInputAmount(toInput); - setTxError(undefined); - }; - - const handleSelectedNetworkChange = (value: number) => { - setTxError(undefined); - setSelectedChainId(value); - const newFilteredTokens = getFilteredTokensForSwitch(value); - setFilteredTokens(newFilteredTokens); - refetchBaseTokenList(); - }; - - const queryClient = useQueryClient(); - const addNewToken = async (token: TokenInfoWithBalance) => { - queryClient.setQueryData( - queryKeysFactory.tokensBalance(baseTokenList ?? [], selectedChainId, user), - (oldData) => { - if (oldData) - return [...oldData, token].sort((a, b) => Number(b.balance) - Number(a.balance)); - return [token]; - } - ); - const customTokens = localStorage.getItem('customTokens'); - const newTokenInfo = { - address: token.address, - symbol: token.symbol, - decimals: token.decimals, - chainId: token.chainId, - name: token.name, - logoURI: token.logoURI, - extensions: { - isUserCustom: true, - }, - }; - if (customTokens) { - const parsedCustomTokens: TokenInfo[] = JSON.parse(customTokens); - parsedCustomTokens.push(newTokenInfo); - localStorage.setItem('customTokens', JSON.stringify(parsedCustomTokens)); - } else { - localStorage.setItem('customTokens', JSON.stringify([newTokenInfo])); - } - }; - - const { defaultInputToken, defaultOutputToken } = useMemo(() => { - let auxInputToken = forcedDefaultInputToken; - let auxOutputToken = forcedDefaultOutputToken; - - const fromList = baseTokenList || filteredTokens; - const toList = baseTokenList || filteredTokens; - - if (!auxInputToken) { - auxInputToken = fromList.find( - (token) => (token.balance !== '0' || token.extensions?.isNative) && token.symbol !== 'GHO' - ); - } - - if (!auxOutputToken) { - auxOutputToken = toList.find((token) => token.symbol == 'GHO'); - } - - return { - defaultInputToken: auxInputToken ?? fromList[0], - defaultOutputToken: auxOutputToken ?? toList[1], - }; - }, [baseTokenList, filteredTokens]); - - const [selectedInputToken, setSelectedInputToken] = useState( - forcedDefaultInputToken ?? defaultInputToken - ); - const [selectedOutputToken, setSelectedOutputToken] = useState( - forcedDefaultOutputToken ?? defaultOutputToken - ); - - useEffect(() => { - setSelectedInputToken(defaultInputToken); - }, [defaultInputToken]); - - useEffect(() => { - setSelectedOutputToken(defaultOutputToken); - }, [defaultOutputToken]); - - const slippageValidation = validateSlippage( - slippage, - selectedChainId, - isNativeToken(selectedInputToken?.address), - switchProvider - ); - const safeSlippage = - slippageValidation && slippageValidation.severity === ValidationSeverity.ERROR - ? 0 - : Number(slippage) / 100; - - // Data - const { - data: switchRates, - error: ratesError, - isFetching: ratesLoading, - } = useMultiProviderSwitchRates({ - chainId: selectedChainId, - amount: - debounceInputAmount === '' - ? '0' - : normalizeBN(debounceInputAmount, -1 * selectedInputToken.decimals).toFixed(0), - srcToken: selectedInputToken.address, - srcDecimals: selectedInputToken.decimals, - destToken: selectedOutputToken.address, - destDecimals: selectedOutputToken.decimals, - inputSymbol: selectedInputToken.symbol, - outputSymbol: selectedOutputToken.symbol, - user, - options: { - partner: 'aave-widget', - }, - isTxSuccess: switchTxState.success, - }); - - // Define default slippage for CoW - useEffect(() => { - if (switchProvider == 'cowprotocol' && isCowProtocolRates(switchRates)) { - setSlippage(switchRates.suggestedSlippage.toString()); - } - }, [switchRates, switchProvider]); - - const [showSlippageWarning, setShowSlippageWarning] = useState(false); - useEffect(() => { - // Debounce to avoid race condition - const timeout = setTimeout(() => { - setShowSlippageWarning( - isCowProtocolRates(switchRates) && Number(slippage) < switchRates?.suggestedSlippage - ); - }, 500); - return () => clearTimeout(timeout); - }, [slippage, switchRates]); - - const [cowOpenOrdersTotalAmountFormatted, setCowOpenOrdersTotalAmountFormatted] = useState< - string | undefined - >(undefined); - useEffect(() => { - if ( - switchProvider == 'cowprotocol' && - user && - selectedChainId && - selectedInputToken && - selectedOutputToken - ) { - setCowOpenOrdersTotalAmountFormatted(undefined); - - getOrders(selectedChainId, user).then((orders) => { - const cowOpenOrdersTotalAmount = orders - .filter( - (order) => - order.sellToken.toLowerCase() == selectedInputToken.address.toLowerCase() && - order.status == OrderStatus.OPEN - ) - .map((order) => order.sellAmount) - .reduce((acc, curr) => acc + Number(curr), 0); - if (cowOpenOrdersTotalAmount > 0) { - setCowOpenOrdersTotalAmountFormatted( - normalize(cowOpenOrdersTotalAmount, selectedInputToken.decimals).toString() - ); - } else { - setCowOpenOrdersTotalAmountFormatted(undefined); - } - }); - } else { - setCowOpenOrdersTotalAmountFormatted(undefined); - } - }, [selectedInputToken, selectedOutputToken, switchProvider, selectedChainId, user]); - - // Views - if (!baseTokenList) { - return ( - - - - ); - } - - // Success View - if (switchRates && switchTxState.success) { - return ( - - ); - } - - // Eth-Flow requires to leave some assets for gas - const nativeDecimals = 18; - const gasRequiredForEthFlow = parseUnits('0.01', nativeDecimals); // TODO: Ask for better value coming from the SDK - const requiredAssetsLeftForGas = isNativeToken(selectedInputToken.address) - ? gasRequiredForEthFlow - : undefined; - const maxAmount = requiredAssetsLeftForGas - ? parseUnits(selectedInputToken.balance, nativeDecimals) - requiredAssetsLeftForGas - : undefined; - const maxAmountFormatted = maxAmount - ? normalize(maxAmount.toString(), nativeDecimals).toString() - : undefined; - - const swapDetailsComponent = - switchDetails && switchRates - ? switchDetails({ - switchProvider, - user, - switchRates, - gasLimit, - selectedChainId, - selectedOutputToken, - selectedInputToken, - safeSlippage, - maxSlippage: Number(slippage), - ratesLoading, - ratesError, - showGasStation, - }) - : null; - - // Component return ( <> - {showTitle && ( - - )} - {showChangeNetworkWarning && isWrongNetwork.isWrongNetwork && !readOnlyModeAddress && ( - - )} + {showTitle && } - {cowOpenOrdersTotalAmountFormatted && ( - - - You have open orders for {cowOpenOrdersTotalAmountFormatted} {selectedInputToken.symbol} - .
Track them in your{' '} - - transaction history - -
-
+ {switchProvider === 'cowprotocol' && ( + )} - - - - - {!selectedInputToken || !selectedOutputToken ? ( - ) : ( - <> - - - token.address !== selectedOutputToken.address && - Number(token.balance) !== 0 && - // Avoid wrapping - !( - isNativeToken(selectedOutputToken.address) && - token.address === - WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address - ) && - !( - selectedOutputToken.address === - WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && - isNativeToken(token.address) - ) - )} - value={inputAmount} - onChange={handleInputChange} - usdValue={switchRates?.srcUSD || '0'} - onSelect={handleSelectedInputToken} - selectedAsset={selectedInputToken} - forcedMaxValue={maxAmountFormatted} - /> - {showSwitchInputAndOutputAssetsButton && ( - - - - - - )} - - token.address !== selectedInputToken.address && - // Avoid wrapping - !( - isNativeToken(selectedInputToken.address) && - token.address === - WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address - ) && - !( - selectedInputToken.address === - WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && - isNativeToken(token.address) - ) - )} - value={ - switchRates - ? normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() - : '0' - } - usdValue={switchRates?.destUSD || '0'} - loading={ - debounceInputAmount !== '0' && - debounceInputAmount !== '' && - ratesLoading && - !ratesError - } - onSelect={handleSelectedOutputToken} - disableInput={true} - selectedAsset={selectedOutputToken} - showBalance={false} - /> - - {switchRates && ( - <> - - - )} - - {user ? ( - <> - {(selectedInputToken.extensions?.isUserCustom || - selectedOutputToken.extensions?.isUserCustom) && ( - - - You have selected a custom imported token. - - - )} - - {swapDetailsComponent} - - {showSlippageWarning && ( - - - Slippage is lower than recommended. The swap may be delayed or fail. - - - )} - - - {txError && } - - Number(selectedInputToken.balance) || - !user || - slippageValidation?.severity === ValidationSeverity.ERROR - } - chainId={selectedChainId} - switchRates={switchRates} - /> - - ) : ( - - - Please connect your wallet to swap tokens. - - { - close(); - }} - /> - - )} - + )} ); diff --git a/src/components/transactions/Switch/ExpirySelector.tsx b/src/components/transactions/Switch/ExpirySelector.tsx new file mode 100644 index 0000000000..7cb9074ea7 --- /dev/null +++ b/src/components/transactions/Switch/ExpirySelector.tsx @@ -0,0 +1,75 @@ +import { ChevronDownIcon } from '@heroicons/react/outline'; +import { Trans } from '@lingui/macro'; +import { + Box, + FormControl, + MenuItem, + Select, + SelectChangeEvent, + SvgIcon, + Typography, +} from '@mui/material'; + +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 3600; +const ONE_DAY_IN_SECONDS = 86400; +const ONE_MONTH_IN_SECONDS = 2592000; + +export const Expiry: { [key: string]: number } = { + 'Five minutes': ONE_MINUTE_IN_SECONDS * 5, + 'Half hour': ONE_HOUR_IN_SECONDS / 2, + 'One hour': ONE_HOUR_IN_SECONDS, + 'One day': ONE_DAY_IN_SECONDS, + 'One week': 7 * ONE_DAY_IN_SECONDS, + 'One month': ONE_MONTH_IN_SECONDS, + 'Three months': 3 * ONE_MONTH_IN_SECONDS, + 'One year': 12 * ONE_MONTH_IN_SECONDS, +}; + +interface ExpirySelectorProps { + selectedExpiry: number; + setSelectedExpiry: (value: number) => void; +} + +export const ExpirySelector = ({ selectedExpiry, setSelectedExpiry }: ExpirySelectorProps) => { + const handleChange = (event: SelectChangeEvent) => { + setSelectedExpiry(Number(event.target.value)); + }; + return ( + + + + ); +}; diff --git a/src/components/transactions/Switch/LimitSwitch.tsx b/src/components/transactions/Switch/LimitSwitch.tsx new file mode 100644 index 0000000000..2da97d4601 --- /dev/null +++ b/src/components/transactions/Switch/LimitSwitch.tsx @@ -0,0 +1,837 @@ +import { normalize, normalizeBN, valueToBigNumber } from '@aave/math-utils'; +import { + OrderKind, + OrderStatus, + SupportedChainId, + WRAPPED_NATIVE_CURRENCIES, +} from '@cowprotocol/cow-sdk'; +import { SwitchVerticalIcon } from '@heroicons/react/outline'; +import { Trans } from '@lingui/macro'; +import { Box, CircularProgress, IconButton, SvgIcon, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { debounce } from 'lodash'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'src/components/primitives/Link'; +import { Warning } from 'src/components/primitives/Warning'; +import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton'; +import { isSmartContractWallet } from 'src/helpers/provider'; +import { TokenInfoWithBalance, useTokensBalance } from 'src/hooks/generic/useTokensBalance'; +import { useMultiProviderSwitchRates } from 'src/hooks/switch/useMultiProviderSwitchRates'; +import { useSwitchProvider } from 'src/hooks/switch/useSwitchProvider'; +import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; +import { ModalType, useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; +import { useRootStore } from 'src/store/root'; +import { findByChainId } from 'src/ui-config/marketsConfig'; +import { queryKeysFactory } from 'src/ui-config/queries'; +import { TOKEN_LIST, TokenInfo } from 'src/ui-config/TokenList'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { GENERAL } from 'src/utils/events'; +import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; +import { parseUnits } from 'viem'; + +import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; +import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; +import { SupportedNetworkWithChainId } from './common'; +import { getOrders, isNativeToken } from './cowprotocol.helpers'; +import { Expiry, ExpirySelector } from './ExpirySelector'; +import { NetworkSelector } from './NetworkSelector'; +import { SwitchPriceInput } from './PriceInput'; +import { isCowProtocolRates, SwitchProvider, SwitchRatesType } from './switch.types'; +import { SwitchActions } from './SwitchActions'; +import { InputRole, SwitchAssetInput } from './SwitchAssetInput'; +import { SwitchErrors } from './SwitchErrors'; +import { SwitchModalTxDetails } from './SwitchModalTxDetails'; +import { SwitchTxSuccessView } from './SwitchTxSuccessView'; +import { SwitchType } from './SwitchTypeSelector'; + +export type SwitchDetailsParams = Parameters< + NonNullable +>[0]; + +export const getFilteredTokensForSwitch = (chainId: number): TokenInfoWithBalance[] => { + let customTokenList = TOKEN_LIST.tokens; + const savedCustomTokens = localStorage.getItem('customTokens'); + if (savedCustomTokens) { + customTokenList = customTokenList.concat(JSON.parse(savedCustomTokens)); + } + + const transformedTokens = customTokenList.map((token) => { + return { ...token, balance: '0' }; + }); + const realChainId = getNetworkConfig(chainId).underlyingChainId ?? chainId; + return transformedTokens.filter((token) => token.chainId === realChainId); +}; +export interface SwitchModalCustomizableProps { + modalType: ModalType; + switchDetails?: ({ + user, + switchRates, + gasLimit, + selectedChainId, + selectedOutputToken, + selectedInputToken, + switchProvider, + ratesLoading, + ratesError, + showGasStation, + switchType, + }: { + user: string; + switchRates: SwitchRatesType; + gasLimit: string; + selectedChainId: number; + selectedOutputToken: TokenInfoWithBalance; + selectedInputToken: TokenInfoWithBalance; + switchProvider?: SwitchProvider; + ratesLoading: boolean; + ratesError: Error | null; + showGasStation?: boolean; + switchType: SwitchType; + }) => React.ReactNode; + inputBalanceTitle?: string; + outputBalanceTitle?: string; + tokensFrom?: TokenInfoWithBalance[]; + tokensTo?: TokenInfoWithBalance[]; + forcedDefaultInputToken?: TokenInfoWithBalance; + forcedDefaultOutputToken?: TokenInfoWithBalance; +} + +export const LimitSwitch = ({ + showSwitchInputAndOutputAssetsButton = true, + forcedDefaultInputToken, + forcedDefaultOutputToken, + supportedNetworks, + inputBalanceTitle, + outputBalanceTitle, + initialFromTokens, + showChangeNetworkWarning = true, + selectedChainId, + setSelectedChainId, +}: { + showTitle?: boolean; + forcedChainId: number; + showSwitchInputAndOutputAssetsButton?: boolean; + forcedDefaultInputToken?: TokenInfoWithBalance; + initialFromTokens: TokenInfoWithBalance[]; + initialToTokens: TokenInfoWithBalance[]; + forcedDefaultOutputToken?: TokenInfoWithBalance; + supportedNetworks: SupportedNetworkWithChainId[]; + showChangeNetworkWarning?: boolean; + selectedChainId: number; + setSelectedChainId: (chainId: number) => void; +} & SwitchModalCustomizableProps) => { + // State + const [inputAmount, setInputAmount] = useState(''); + const [outputAmount, setOutputAmount] = useState(''); + const [debounceInputAmount, setDebounceInputAmount] = useState(''); + const [debounceOutputAmount, setDebounceOutputAmount] = useState(''); + const [amountForRatesProvider, setAmountForRatesProvider] = useState('0'); + const [rate, setRate] = useState< + | { + rate: number; + rateUsd: number; + originAsset: TokenInfoWithBalance; + targetAsset: TokenInfoWithBalance; + } + | undefined + >(undefined); + const { mainTxState: switchTxState, gasLimit, txError, setTxError, close } = useModalContext(); + const user = useRootStore((store) => store.account); + const { readOnlyModeAddress, chainId: connectedChainId } = useWeb3Context(); + const [selectedExpiry, setSelectedExpiry] = useState(Expiry['One day']); + const switchProvider = useSwitchProvider({ chainId: selectedChainId }); + const [showGasStation, setShowGasStation] = useState(switchProvider == 'paraswap'); + const selectedNetworkConfig = getNetworkConfig(selectedChainId); + const isWrongNetwork = useIsWrongNetwork(selectedChainId); + + const [filteredTokens, setFilteredTokens] = useState(initialFromTokens); + const { data: baseTokenList, refetch: refetchBaseTokenList } = useTokensBalance( + filteredTokens, + selectedChainId, + user + ); + const [limitOrderKind, setLimitOrderKind] = useState(OrderKind.SELL); + + const [userIsSmartContractWallet, setUserIsSmartContractWallet] = useState(false); + useEffect(() => { + try { + if (user && connectedChainId) { + getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { + isSmartContractWallet(user, provider).then((isSmartContractWallet) => { + setUserIsSmartContractWallet(isSmartContractWallet); + }); + }); + } + } catch (error) { + console.error(error); + } + }, [user, connectedChainId]); + + const debouncedInputChange = useMemo(() => { + return debounce((value: string) => { + setDebounceInputAmount(value); + }, 300); + }, [setDebounceInputAmount]); + + const debouncedOutputChange = useMemo(() => { + return debounce((value: string) => { + setDebounceOutputAmount(value); + }, 300); + }, [setDebounceOutputAmount]); + + const handleInputChange = (value: string) => { + setTxError(undefined); + + setLimitOrderKind(OrderKind.SELL); + + const amountToSet = value === '-1' ? selectedInputToken.balance : value; + + setInputAmount(amountToSet); + debouncedInputChange(amountToSet); + + if (amountToSet === '') { + setOutputAmount(''); + setAmountForRatesProvider('0'); + setRate(undefined); + } else if (outputAmount === '') { + setAmountForRatesProvider( + normalizeBN(amountToSet, -1 * selectedInputToken.decimals).toFixed(0) + ); + } else if (rate) { + // Re-calculate output based on rate + let rateToUse; + if (rate?.originAsset == selectedInputToken) { + rateToUse = rate.rate; + } else { + rateToUse = 1 / rate.rate; + } + + const newOutputAmount = valueToBigNumber(amountToSet).multipliedBy(rateToUse).toString(); + setOutputAmount(newOutputAmount); + debouncedOutputChange(newOutputAmount); + } + }; + + const handleOutputChange = (value: string) => { + setTxError(undefined); + + setLimitOrderKind(OrderKind.BUY); + + const amountToSet = value === '-1' ? selectedOutputToken.balance : value; + + setOutputAmount(amountToSet); + debouncedOutputChange(amountToSet); + + if (amountToSet === '') { + setInputAmount(''); + setAmountForRatesProvider('0'); + setRate(undefined); + } else if (inputAmount === '') { + setAmountForRatesProvider( + normalizeBN(amountToSet, -1 * selectedOutputToken.decimals).toFixed(0) + ); + } else if (rate) { + // Re-calculate input based on rate + let rateToUse; + if (rate?.originAsset == selectedOutputToken) { + rateToUse = rate.rate; + } else { + rateToUse = 1 / rate.rate; + } + + const newInputAmount = valueToBigNumber(amountToSet).multipliedBy(rateToUse).toString(); + setInputAmount(newInputAmount); + debouncedInputChange(newInputAmount); + } + }; + + const handleSelectedInputToken = (token: TokenInfoWithBalance) => { + if (!baseTokenList?.find((t) => t.address === token.address)) { + addNewToken(token).then(() => { + setSelectedInputToken(token); + setTxError(undefined); + }); + } else { + setSelectedInputToken(token); + setTxError(undefined); + } + }; + + const handleSelectedOutputToken = (token: TokenInfoWithBalance) => { + if (!baseTokenList?.find((t) => t.address === token.address)) { + addNewToken(token).then(() => { + setSelectedOutputToken(token); + setTxError(undefined); + }); + } else { + setSelectedOutputToken(token); + setTxError(undefined); + } + }; + + const onSwitchReserves = () => { + const fromToken = selectedInputToken; + const toToken = selectedOutputToken; + const fromInput = switchRates + ? normalizeBN(switchRates.srcAmount, switchRates.srcDecimals).toString() + : '0'; + const toInput = switchRates + ? normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() + : '0'; + setSelectedInputToken(toToken); + setSelectedOutputToken(fromToken); + setInputAmount(toInput); + setOutputAmount(fromInput); + setDebounceInputAmount(toInput); + setDebounceOutputAmount(fromInput); + setTxError(undefined); + }; + + const handleSelectedNetworkChange = (value: number) => { + setTxError(undefined); + setSelectedChainId(value); + const newFilteredTokens = getFilteredTokensForSwitch(value); + setFilteredTokens(newFilteredTokens); + refetchBaseTokenList(); + }; + + const queryClient = useQueryClient(); + const addNewToken = async (token: TokenInfoWithBalance) => { + queryClient.setQueryData( + queryKeysFactory.tokensBalance(baseTokenList ?? [], selectedChainId, user), + (oldData) => { + if (oldData) + return [...oldData, token].sort((a, b) => Number(b.balance) - Number(a.balance)); + return [token]; + } + ); + const customTokens = localStorage.getItem('customTokens'); + const newTokenInfo = { + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + chainId: token.chainId, + name: token.name, + logoURI: token.logoURI, + extensions: { + isUserCustom: true, + }, + }; + if (customTokens) { + const parsedCustomTokens: TokenInfo[] = JSON.parse(customTokens); + parsedCustomTokens.push(newTokenInfo); + localStorage.setItem('customTokens', JSON.stringify(parsedCustomTokens)); + } else { + localStorage.setItem('customTokens', JSON.stringify([newTokenInfo])); + } + }; + + const { defaultInputToken, defaultOutputToken } = useMemo(() => { + let auxInputToken = forcedDefaultInputToken; + let auxOutputToken = forcedDefaultOutputToken; + + const fromList = baseTokenList || filteredTokens; + const toList = baseTokenList || filteredTokens; + + if (!auxInputToken) { + auxInputToken = fromList.find( + (token) => (token.balance !== '0' || token.extensions?.isNative) && token.symbol !== 'GHO' + ); + } + + if (!auxOutputToken) { + auxOutputToken = toList.find((token) => token.symbol == 'GHO'); + } + + return { + defaultInputToken: auxInputToken ?? fromList[0], + defaultOutputToken: auxOutputToken ?? toList[1], + }; + }, [baseTokenList, filteredTokens]); + + const [selectedInputToken, setSelectedInputToken] = useState( + forcedDefaultInputToken ?? defaultInputToken + ); + const [selectedOutputToken, setSelectedOutputToken] = useState( + forcedDefaultOutputToken ?? defaultOutputToken + ); + + useEffect(() => { + setSelectedInputToken(defaultInputToken); + }, [defaultInputToken]); + + useEffect(() => { + setSelectedOutputToken(defaultOutputToken); + }, [defaultOutputToken]); + + // Data + const { + data: switchRates, + error: ratesError, + isFetching: ratesLoading, + } = useMultiProviderSwitchRates({ + chainId: selectedChainId, + amount: amountForRatesProvider, + srcToken: selectedInputToken.address, + srcDecimals: selectedInputToken.decimals, + destToken: selectedOutputToken.address, + destDecimals: selectedOutputToken.decimals, + inputSymbol: selectedInputToken.symbol, + outputSymbol: selectedOutputToken.symbol, + user, + orderKind: limitOrderKind, + options: { + partner: 'aave-widget', + }, + isTxSuccess: switchTxState.success, + }); + + useEffect(() => { + if (switchProvider == 'cowprotocol' && isCowProtocolRates(switchRates)) { + if (limitOrderKind == OrderKind.SELL) { + // Define rate + let rateAmount = 0, + rateUsd = 0, + rateFrom = rate?.originAsset; + + if (!rateFrom) { + rateFrom = selectedInputToken; + } + + if (rateFrom == selectedOutputToken) { + rateAmount = Number( + normalizeBN(switchRates.srcAmount, switchRates.srcDecimals).div( + normalizeBN(switchRates.destAmount, switchRates.destDecimals) + ) + ); + rateUsd = rateAmount * Number(switchRates.srcTokenPriceUsd); + } else if (rateFrom == selectedInputToken) { + rateAmount = Number( + normalizeBN(switchRates.destAmount, switchRates.destDecimals).div( + normalizeBN(switchRates.srcAmount, switchRates.srcDecimals) + ) + ); + rateUsd = rateAmount * Number(switchRates.destTokenPriceUsd); + } + setRate({ + rate: rateAmount, + rateUsd, + originAsset: selectedInputToken, + targetAsset: selectedOutputToken, + }); + + if (inputAmount === '') { + setInputAmount(normalizeBN(switchRates.srcAmount, switchRates.srcDecimals).toString()); + debouncedInputChange( + normalizeBN(switchRates.srcAmount, switchRates.srcDecimals).toString() + ); + } + if (outputAmount === '') { + setOutputAmount(normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString()); + debouncedOutputChange( + normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() + ); + } + } + } + }, [switchRates, switchProvider]); + + const switchRate = () => { + if (!switchRates || switchRates.provider !== 'cowprotocol') { + return; + } + + if (rate?.originAsset == selectedInputToken) { + const rateAmount = Number( + normalizeBN(switchRates.srcAmount, switchRates.srcDecimals).div( + normalizeBN(switchRates.destAmount, switchRates.destDecimals) + ) + ); + const rateUsd = rateAmount * Number(switchRates.srcTokenPriceUsd); + setRate({ + originAsset: selectedOutputToken, + targetAsset: selectedInputToken, + rate: rateAmount, + rateUsd, + }); + } else if (rate?.originAsset == selectedOutputToken) { + const rateAmount = Number( + normalizeBN(switchRates.destAmount, switchRates.destDecimals).div( + normalizeBN(switchRates.srcAmount, switchRates.srcDecimals) + ) + ); + const rateUsd = rateAmount * Number(switchRates.destTokenPriceUsd); + setRate({ + originAsset: selectedInputToken, + targetAsset: selectedOutputToken, + rate: rateAmount, + rateUsd, + }); + } + }; + + const onChangeRate = (newRate: number, targetToken: TokenInfoWithBalance) => { + // Calculate new amount + if (!switchRates) { + return; + } + // calculate 1 input in usd + const previousUsd = rate?.rateUsd || 0; + + const rateChange = newRate / (rate?.rate || 1); + const newUsd = previousUsd * rateChange; + + if (targetToken == selectedInputToken) { + const newAmount = + newRate * Number(normalizeBN(switchRates.destAmount, switchRates.destDecimals)); + setInputAmount(newAmount.toString()); + } else if (targetToken == selectedOutputToken) { + const newAmount = + newRate * Number(normalizeBN(switchRates.srcAmount, switchRates.srcDecimals)); + setInputAmount(newAmount.toString()); + } + + setRate({ + originAsset: selectedInputToken, + targetAsset: selectedOutputToken, + rate: newRate, + rateUsd: newUsd, + }); + }; + + const [cowOpenOrdersTotalAmountFormatted, setCowOpenOrdersTotalAmountFormatted] = useState< + string | undefined + >(undefined); + useEffect(() => { + if ( + switchProvider == 'cowprotocol' && + user && + selectedChainId && + selectedInputToken && + selectedOutputToken + ) { + setCowOpenOrdersTotalAmountFormatted(undefined); + + getOrders(selectedChainId, user).then((orders) => { + const cowOpenOrdersTotalAmount = orders + .filter( + (order) => + order.sellToken.toLowerCase() == selectedInputToken.address.toLowerCase() && + order.status == OrderStatus.OPEN + ) + .map((order) => order.sellAmount) + .reduce((acc, curr) => acc + Number(curr), 0); + if (cowOpenOrdersTotalAmount > 0) { + setCowOpenOrdersTotalAmountFormatted( + normalize(cowOpenOrdersTotalAmount, selectedInputToken.decimals).toString() + ); + } else { + setCowOpenOrdersTotalAmountFormatted(undefined); + } + }); + } else { + setCowOpenOrdersTotalAmountFormatted(undefined); + } + }, [selectedInputToken, selectedOutputToken, switchProvider, selectedChainId, user]); + + // Views + if (!baseTokenList) { + return ( + + + + ); + } + + // Success View + if (switchRates && switchTxState.success) { + return ( + + ); + } + + // Eth-Flow requires to leave some assets for gas + const nativeDecimals = 18; + const gasRequiredForEthFlow = parseUnits('0.01', nativeDecimals); // TODO: Ask for better value coming from the SDK + const requiredAssetsLeftForGas = isNativeToken(selectedInputToken.address) + ? gasRequiredForEthFlow + : undefined; + const maxAmount = requiredAssetsLeftForGas + ? parseUnits(selectedInputToken.balance, nativeDecimals) - requiredAssetsLeftForGas + : undefined; + const maxAmountFormatted = maxAmount + ? normalize(maxAmount.toString(), nativeDecimals).toString() + : undefined; + + // Component + return ( + <> + {showChangeNetworkWarning && isWrongNetwork.isWrongNetwork && !readOnlyModeAddress && ( + + )} + + {cowOpenOrdersTotalAmountFormatted && ( + + + You have open orders for {cowOpenOrdersTotalAmountFormatted} {selectedInputToken.symbol} + .
Track them in your{' '} + + transaction history + +
+
+ )} + + + + + {switchProvider === 'cowprotocol' && ( + + )} + + {!selectedInputToken || !selectedOutputToken ? ( + + ) : ( + <> + + + token.address !== selectedOutputToken.address && + Number(token.balance) !== 0 && + // Avoid wrapping + !( + isNativeToken(selectedOutputToken.address) && + token.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address + ) && + !( + selectedOutputToken.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && + isNativeToken(token.address) + ) + )} + value={inputAmount} + onChange={handleInputChange} + usdValue={switchRates?.srcUSD || '0'} + onSelect={handleSelectedInputToken} + selectedAsset={selectedInputToken} + forcedMaxValue={maxAmountFormatted} + orderKind={limitOrderKind} + loading={ + debounceOutputAmount !== '0' && + debounceOutputAmount !== '' && + ratesLoading && + limitOrderKind !== OrderKind.SELL && + !ratesError + } + role={InputRole.INPUT} + /> + {showSwitchInputAndOutputAssetsButton && ( + + + + + + )} + + + token.address !== selectedInputToken.address && + // Avoid wrapping + !( + isNativeToken(selectedInputToken.address) && + token.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address + ) && + !( + selectedInputToken.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && + isNativeToken(token.address) + ) + )} + value={outputAmount} + usdValue={switchRates?.destUSD || '0'} + loading={ + debounceInputAmount !== '0' && + debounceInputAmount !== '' && + ratesLoading && + limitOrderKind !== OrderKind.BUY && + !ratesError + } + onSelect={handleSelectedOutputToken} + selectedAsset={selectedOutputToken} + showBalance={false} + onChange={handleOutputChange} + orderKind={limitOrderKind} + role={InputRole.OUTPUT} + showMaxButton={false} + /> + {switchProvider === 'cowprotocol' && ( + + )} + + + {user ? ( + <> + {(selectedInputToken.extensions?.isUserCustom || + selectedOutputToken.extensions?.isUserCustom) && ( + + + You have selected a custom imported token. + + + )} + + {switchRates && ( + + )} + + + {txError && } + + Number(selectedInputToken.balance) || + !user + } + chainId={selectedChainId} + switchRates={ + switchRates + ? { + ...switchRates, + srcAmount: normalizeBN( + debounceInputAmount || '0', + -1 * selectedInputToken.decimals + ).toFixed(0), + destAmount: normalizeBN( + debounceOutputAmount || '0', + -1 * selectedOutputToken.decimals + ).toFixed(0), + } + : undefined + } + /> + + ) : ( + + + Please connect your wallet to swap tokens. + + { + close(); + }} + /> + + )} + + )} + + ); +}; diff --git a/src/components/transactions/Switch/MarketSwitch.tsx b/src/components/transactions/Switch/MarketSwitch.tsx new file mode 100644 index 0000000000..6e444601a9 --- /dev/null +++ b/src/components/transactions/Switch/MarketSwitch.tsx @@ -0,0 +1,654 @@ +import { normalize, normalizeBN } from '@aave/math-utils'; +import { OrderStatus, SupportedChainId, WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/cow-sdk'; +import { SwitchVerticalIcon } from '@heroicons/react/outline'; +import { Trans } from '@lingui/macro'; +import { Box, CircularProgress, IconButton, SvgIcon, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { debounce } from 'lodash'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'src/components/primitives/Link'; +import { Warning } from 'src/components/primitives/Warning'; +import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton'; +import { isSmartContractWallet } from 'src/helpers/provider'; +import { TokenInfoWithBalance, useTokensBalance } from 'src/hooks/generic/useTokensBalance'; +import { useMultiProviderSwitchRates } from 'src/hooks/switch/useMultiProviderSwitchRates'; +import { useSwitchProvider } from 'src/hooks/switch/useSwitchProvider'; +import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; +import { ModalType, useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; +import { useRootStore } from 'src/store/root'; +import { findByChainId } from 'src/ui-config/marketsConfig'; +import { queryKeysFactory } from 'src/ui-config/queries'; +import { TOKEN_LIST, TokenInfo } from 'src/ui-config/TokenList'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { GENERAL } from 'src/utils/events'; +import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; +import { parseUnits } from 'viem'; + +import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; +import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; +import { SupportedNetworkWithChainId } from './common'; +import { getOrders, isNativeToken } from './cowprotocol.helpers'; +import { Expiry } from './ExpirySelector'; +import { NetworkSelector } from './NetworkSelector'; +import { isCowProtocolRates, SwitchProvider, SwitchRatesType } from './switch.types'; +import { SwitchActions } from './SwitchActions'; +import { InputRole, SwitchAssetInput } from './SwitchAssetInput'; +import { SwitchErrors } from './SwitchErrors'; +import { SwitchModalTxDetails } from './SwitchModalTxDetails'; +import { SwitchRates } from './SwitchRates'; +import { SwitchSlippageSelector } from './SwitchSlippageSelector'; +import { SwitchTxSuccessView } from './SwitchTxSuccessView'; +import { validateSlippage, ValidationSeverity } from './validation.helpers'; + +export type SwitchDetailsParams = Parameters< + NonNullable +>[0]; + +export const getFilteredTokensForSwitch = (chainId: number): TokenInfoWithBalance[] => { + let customTokenList = TOKEN_LIST.tokens; + const savedCustomTokens = localStorage.getItem('customTokens'); + if (savedCustomTokens) { + customTokenList = customTokenList.concat(JSON.parse(savedCustomTokens)); + } + + const transformedTokens = customTokenList.map((token) => { + return { ...token, balance: '0' }; + }); + const realChainId = getNetworkConfig(chainId).underlyingChainId ?? chainId; + return transformedTokens.filter((token) => token.chainId === realChainId); +}; +export interface SwitchModalCustomizableProps { + modalType: ModalType; + switchDetails?: ({ + user, + switchRates, + gasLimit, + selectedChainId, + selectedOutputToken, + selectedInputToken, + safeSlippage, + maxSlippage, + switchProvider, + ratesLoading, + ratesError, + showGasStation, + }: { + user: string; + switchRates: SwitchRatesType; + gasLimit: string; + selectedChainId: number; + selectedOutputToken: TokenInfoWithBalance; + selectedInputToken: TokenInfoWithBalance; + safeSlippage: number; + maxSlippage: number; + switchProvider?: SwitchProvider; + ratesLoading: boolean; + ratesError: Error | null; + showGasStation?: boolean; + }) => React.ReactNode; + inputBalanceTitle?: string; + outputBalanceTitle?: string; + tokensFrom?: TokenInfoWithBalance[]; + tokensTo?: TokenInfoWithBalance[]; + forcedDefaultInputToken?: TokenInfoWithBalance; + forcedDefaultOutputToken?: TokenInfoWithBalance; +} + +export const MarketSwitch = ({ + showSwitchInputAndOutputAssetsButton = true, + forcedDefaultInputToken, + forcedDefaultOutputToken, + supportedNetworks, + inputBalanceTitle, + outputBalanceTitle, + initialFromTokens, + showChangeNetworkWarning = true, + selectedChainId, + setSelectedChainId, +}: { + showTitle?: boolean; + forcedChainId: number; + showSwitchInputAndOutputAssetsButton?: boolean; + forcedDefaultInputToken?: TokenInfoWithBalance; + initialFromTokens: TokenInfoWithBalance[]; + initialToTokens: TokenInfoWithBalance[]; + forcedDefaultOutputToken?: TokenInfoWithBalance; + supportedNetworks: SupportedNetworkWithChainId[]; + showChangeNetworkWarning?: boolean; + selectedChainId: number; + setSelectedChainId: (chainId: number) => void; +} & SwitchModalCustomizableProps) => { + // State + const [inputAmount, setInputAmount] = useState(''); + const [debounceInputAmount, setDebounceInputAmount] = useState(''); + const { mainTxState: switchTxState, gasLimit, txError, setTxError, close } = useModalContext(); + const user = useRootStore((store) => store.account); + const { readOnlyModeAddress, chainId: connectedChainId } = useWeb3Context(); + const switchProvider = useSwitchProvider({ chainId: selectedChainId }); + const [slippage, setSlippage] = useState(switchProvider == 'cowprotocol' ? '2' : '0.10'); + const [showGasStation, setShowGasStation] = useState(switchProvider == 'paraswap'); + const selectedNetworkConfig = getNetworkConfig(selectedChainId); + const isWrongNetwork = useIsWrongNetwork(selectedChainId); + + const [filteredTokens, setFilteredTokens] = useState(initialFromTokens); + const { data: baseTokenList, refetch: refetchBaseTokenList } = useTokensBalance( + filteredTokens, + selectedChainId, + user + ); + + const [userIsSmartContractWallet, setUserIsSmartContractWallet] = useState(false); + useEffect(() => { + try { + if (user && connectedChainId) { + getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { + isSmartContractWallet(user, provider).then((isSmartContractWallet) => { + setUserIsSmartContractWallet(isSmartContractWallet); + }); + }); + } + } catch (error) { + console.error(error); + } + }, [user, connectedChainId]); + + const debouncedInputChange = useMemo(() => { + return debounce((value: string) => { + setDebounceInputAmount(value); + }, 300); + }, [setDebounceInputAmount]); + + const handleInputChange = (value: string) => { + setTxError(undefined); + if (value === '-1') { + // Max Selected + setInputAmount(selectedInputToken.balance); + debouncedInputChange(selectedInputToken.balance); + } else { + setInputAmount(value); + debouncedInputChange(value); + } + }; + + const handleSelectedInputToken = (token: TokenInfoWithBalance) => { + if (!baseTokenList?.find((t) => t.address === token.address)) { + addNewToken(token).then(() => { + setSelectedInputToken(token); + setTxError(undefined); + }); + } else { + setSelectedInputToken(token); + setTxError(undefined); + } + }; + + const handleSelectedOutputToken = (token: TokenInfoWithBalance) => { + if (!baseTokenList?.find((t) => t.address === token.address)) { + addNewToken(token).then(() => { + setSelectedOutputToken(token); + setTxError(undefined); + }); + } else { + setSelectedOutputToken(token); + setTxError(undefined); + } + }; + + const onSwitchReserves = () => { + const fromToken = selectedInputToken; + const toToken = selectedOutputToken; + const toInput = switchRates + ? normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() + : '0'; + setSelectedInputToken(toToken); + setSelectedOutputToken(fromToken); + setInputAmount(toInput); + setDebounceInputAmount(toInput); + setTxError(undefined); + }; + + const handleSelectedNetworkChange = (value: number) => { + setTxError(undefined); + setSelectedChainId(value); + const newFilteredTokens = getFilteredTokensForSwitch(value); + setFilteredTokens(newFilteredTokens); + refetchBaseTokenList(); + }; + + const queryClient = useQueryClient(); + const addNewToken = async (token: TokenInfoWithBalance) => { + queryClient.setQueryData( + queryKeysFactory.tokensBalance(baseTokenList ?? [], selectedChainId, user), + (oldData) => { + if (oldData) + return [...oldData, token].sort((a, b) => Number(b.balance) - Number(a.balance)); + return [token]; + } + ); + const customTokens = localStorage.getItem('customTokens'); + const newTokenInfo = { + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + chainId: token.chainId, + name: token.name, + logoURI: token.logoURI, + extensions: { + isUserCustom: true, + }, + }; + if (customTokens) { + const parsedCustomTokens: TokenInfo[] = JSON.parse(customTokens); + parsedCustomTokens.push(newTokenInfo); + localStorage.setItem('customTokens', JSON.stringify(parsedCustomTokens)); + } else { + localStorage.setItem('customTokens', JSON.stringify([newTokenInfo])); + } + }; + + const { defaultInputToken, defaultOutputToken } = useMemo(() => { + let auxInputToken = forcedDefaultInputToken; + let auxOutputToken = forcedDefaultOutputToken; + + const fromList = baseTokenList || filteredTokens; + const toList = baseTokenList || filteredTokens; + + if (!auxInputToken) { + auxInputToken = fromList.find( + (token) => (token.balance !== '0' || token.extensions?.isNative) && token.symbol !== 'GHO' + ); + } + + if (!auxOutputToken) { + auxOutputToken = toList.find((token) => token.symbol == 'GHO'); + } + + return { + defaultInputToken: auxInputToken ?? fromList[0], + defaultOutputToken: auxOutputToken ?? toList[1], + }; + }, [baseTokenList, filteredTokens]); + + const [selectedInputToken, setSelectedInputToken] = useState( + forcedDefaultInputToken ?? defaultInputToken + ); + const [selectedOutputToken, setSelectedOutputToken] = useState( + forcedDefaultOutputToken ?? defaultOutputToken + ); + + useEffect(() => { + setSelectedInputToken(defaultInputToken); + }, [defaultInputToken]); + + useEffect(() => { + setSelectedOutputToken(defaultOutputToken); + }, [defaultOutputToken]); + + const slippageValidation = validateSlippage( + slippage, + selectedChainId, + isNativeToken(selectedInputToken?.address), + switchProvider + ); + const safeSlippage = + slippageValidation && slippageValidation.severity === ValidationSeverity.ERROR + ? 0 + : Number(slippage) / 100; + + // Data + const { + data: switchRates, + error: ratesError, + isFetching: ratesLoading, + } = useMultiProviderSwitchRates({ + chainId: selectedChainId, + amount: + debounceInputAmount === '' + ? '0' + : normalizeBN(debounceInputAmount, -1 * selectedInputToken.decimals).toFixed(0), + srcToken: selectedInputToken.address, + srcDecimals: selectedInputToken.decimals, + destToken: selectedOutputToken.address, + destDecimals: selectedOutputToken.decimals, + inputSymbol: selectedInputToken.symbol, + outputSymbol: selectedOutputToken.symbol, + user, + options: { + partner: 'aave-widget', + }, + isTxSuccess: switchTxState.success, + }); + + // Define default slippage for CoW + useEffect(() => { + if (switchProvider == 'cowprotocol' && isCowProtocolRates(switchRates)) { + setSlippage(switchRates.suggestedSlippage.toString()); + } + }, [switchRates, switchProvider]); + + const [showSlippageWarning, setShowSlippageWarning] = useState(false); + useEffect(() => { + // Debounce to avoid race condition + const timeout = setTimeout(() => { + setShowSlippageWarning( + isCowProtocolRates(switchRates) && Number(slippage) < switchRates?.suggestedSlippage + ); + }, 500); + return () => clearTimeout(timeout); + }, [slippage, switchRates]); + + const [cowOpenOrdersTotalAmountFormatted, setCowOpenOrdersTotalAmountFormatted] = useState< + string | undefined + >(undefined); + useEffect(() => { + if ( + switchProvider == 'cowprotocol' && + user && + selectedChainId && + selectedInputToken && + selectedOutputToken + ) { + setCowOpenOrdersTotalAmountFormatted(undefined); + + getOrders(selectedChainId, user).then((orders) => { + const cowOpenOrdersTotalAmount = orders + .filter( + (order) => + order.sellToken.toLowerCase() == selectedInputToken.address.toLowerCase() && + order.status == OrderStatus.OPEN + ) + .map((order) => order.sellAmount) + .reduce((acc, curr) => acc + Number(curr), 0); + if (cowOpenOrdersTotalAmount > 0) { + setCowOpenOrdersTotalAmountFormatted( + normalize(cowOpenOrdersTotalAmount, selectedInputToken.decimals).toString() + ); + } else { + setCowOpenOrdersTotalAmountFormatted(undefined); + } + }); + } else { + setCowOpenOrdersTotalAmountFormatted(undefined); + } + }, [selectedInputToken, selectedOutputToken, switchProvider, selectedChainId, user]); + + // Views + if (!baseTokenList) { + return ( + + + + ); + } + + // Success View + if (switchRates && switchTxState.success) { + return ( + + ); + } + + // Eth-Flow requires to leave some assets for gas + const nativeDecimals = 18; + const gasRequiredForEthFlow = parseUnits('0.01', nativeDecimals); // TODO: Ask for better value coming from the SDK + const requiredAssetsLeftForGas = isNativeToken(selectedInputToken.address) + ? gasRequiredForEthFlow + : undefined; + const maxAmount = requiredAssetsLeftForGas + ? parseUnits(selectedInputToken.balance, nativeDecimals) - requiredAssetsLeftForGas + : undefined; + const maxAmountFormatted = maxAmount + ? normalize(maxAmount.toString(), nativeDecimals).toString() + : undefined; + + // Component + return ( + <> + {showChangeNetworkWarning && isWrongNetwork.isWrongNetwork && !readOnlyModeAddress && ( + + )} + + {cowOpenOrdersTotalAmountFormatted && ( + + + You have open orders for {cowOpenOrdersTotalAmountFormatted} {selectedInputToken.symbol} + .
Track them in your{' '} + + transaction history + +
+
+ )} + + + + + + {!selectedInputToken || !selectedOutputToken ? ( + + ) : ( + <> + + + token.address !== selectedOutputToken.address && + Number(token.balance) !== 0 && + // Avoid wrapping + !( + isNativeToken(selectedOutputToken.address) && + token.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address + ) && + !( + selectedOutputToken.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && + isNativeToken(token.address) + ) + )} + value={inputAmount} + onChange={handleInputChange} + usdValue={switchRates?.srcUSD || '0'} + onSelect={handleSelectedInputToken} + selectedAsset={selectedInputToken} + forcedMaxValue={maxAmountFormatted} + /> + {showSwitchInputAndOutputAssetsButton && ( + + + + + + )} + + token.address !== selectedInputToken.address && + // Avoid wrapping + !( + isNativeToken(selectedInputToken.address) && + token.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address + ) && + !( + selectedInputToken.address === + WRAPPED_NATIVE_CURRENCIES[selectedChainId as SupportedChainId]?.address && + isNativeToken(token.address) + ) + )} + value={ + switchRates + ? normalizeBN(switchRates.destAmount, switchRates.destDecimals).toString() + : '0' + } + usdValue={switchRates?.destUSD || '0'} + loading={ + debounceInputAmount !== '0' && + debounceInputAmount !== '' && + ratesLoading && + !ratesError + } + onSelect={handleSelectedOutputToken} + disableInput={true} + selectedAsset={selectedOutputToken} + showBalance={false} + /> + + {switchRates && ( + <> + + + )} + + {user ? ( + <> + {(selectedInputToken.extensions?.isUserCustom || + selectedOutputToken.extensions?.isUserCustom) && ( + + + You have selected a custom imported token. + + + )} + + {switchRates && ( + + )} + + {showSlippageWarning && ( + + + Slippage is lower than recommended. The swap may be delayed or fail. + + + )} + + + {txError && } + + Number(selectedInputToken.balance) || + !user || + slippageValidation?.severity === ValidationSeverity.ERROR + } + chainId={selectedChainId} + switchRates={switchRates} + expiry={Expiry.ONE_HOUR} + /> + + ) : ( + + + Please connect your wallet to swap tokens. + + { + close(); + }} + /> + + )} + + )} + + ); +}; diff --git a/src/components/transactions/Switch/PriceInput.tsx b/src/components/transactions/Switch/PriceInput.tsx new file mode 100644 index 0000000000..6135631f25 --- /dev/null +++ b/src/components/transactions/Switch/PriceInput.tsx @@ -0,0 +1,184 @@ +import { ExclamationIcon } from '@heroicons/react/outline'; +import { Box, Button, CircularProgress, InputBase, SvgIcon, Typography } from '@mui/material'; +import React, { useRef } from 'react'; +import NumberFormat, { NumberFormatProps } from 'react-number-format'; +import { TokenInfoWithBalance } from 'src/hooks/generic/useTokensBalance'; + +import { FormattedNumber } from '../../primitives/FormattedNumber'; +import { ExternalTokenIcon } from '../../primitives/TokenIcon'; + +interface CustomProps { + onChange: (event: { target: { name: string; value: string } }) => void; + name: string; + value: string; +} + +export const NumberFormatCustom = React.forwardRef( + function NumberFormatCustom(props, ref) { + const { onChange, ...other } = props; + + return ( + { + if (values.value !== props.value) + onChange({ + target: { + name: props.name, + value: values.value || '', + }, + }); + }} + thousandSeparator + isNumericString + allowNegative={false} + /> + ); + } +); + +export interface AssetInputProps { + loading?: boolean; + rate: string; + rateUsd: string; + originAsset: TokenInfoWithBalance; + targetAsset: TokenInfoWithBalance; + switchRate: () => void; + disabled?: boolean; + onChangeRate: (newRate: number, targetAsset: TokenInfoWithBalance) => void; +} + +export const SwitchPriceInput = ({ + loading = false, + rate, + rateUsd, + originAsset, + targetAsset, + switchRate, + onChangeRate, + disabled = false, +}: AssetInputProps) => { + const inputRef = useRef(null); + + return ( + ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: '6px', + overflow: 'hidden', + px: 3, + py: 2, + width: '100%', + })} + > + + When 1 {originAsset.symbol} is worth: + + + {loading ? ( + + + + ) : ( + { + onChangeRate(Number(e.target.value), targetAsset); + }} + /> + )} + + + + + + {loading ? ( + + ) : ( + + )} + + + + + + + + ); +}; diff --git a/src/components/transactions/Switch/SwitchActions.tsx b/src/components/transactions/Switch/SwitchActions.tsx index 4cda9b237e..65353dff8a 100644 --- a/src/components/transactions/Switch/SwitchActions.tsx +++ b/src/components/transactions/Switch/SwitchActions.tsx @@ -40,17 +40,20 @@ import { sendOrder, uploadAppData, } from './cowprotocol.helpers'; +import { Expiry } from './ExpirySelector'; import { isCowProtocolRates, isParaswapRates, SwitchRatesType } from './switch.types'; interface SwitchProps { inputAmount: string; inputToken: string; outputToken: string; - slippage: string; + slippage?: string; + expiry: number; blocked: boolean; loading?: boolean; isWrongNetwork: boolean; chainId: number; + orderKind: 'market' | 'limit'; switchRates?: SwitchRatesType; inputName: string; outputName: string; @@ -74,7 +77,9 @@ export const SwitchActions = ({ outputToken, inputSymbol, outputSymbol, - slippage: slippageInPercent, + slippage: slippageInPercent = '0', + orderKind, + expiry, blocked, loading, isWrongNetwork, @@ -216,6 +221,7 @@ export const SwitchActions = ({ }); } } else if (isCowProtocolRates(switchRates)) { + const validTo = Math.floor(Date.now() / 1000) + (expiry ?? Expiry['Half hour']); try { const provider = await getEthersProvider(wagmiConfig, { chainId }); const destAmountWithSlippage = valueToBigNumber(switchRates.destAmount) @@ -225,7 +231,6 @@ export const SwitchActions = ({ // If srcToken is native, we need to use the eth-flow instead of the orderbook if (isNativeToken(inputToken)) { - const validTo = Math.floor(Date.now() / 1000) + 60 * 30; // 30 minutes const ethFlowTx = await populateEthFlowTx( switchRates.srcAmount, destAmountWithSlippage.toString(), @@ -262,7 +267,8 @@ export const SwitchActions = ({ user, chainId, inputSymbol, - outputSymbol + outputSymbol, + validTo ); const calculatedOrderId = await calculateUniqueOrderId(chainId, unsignerOrder); @@ -310,10 +316,13 @@ export const SwitchActions = ({ tokenSrcDecimals: switchRates.srcDecimals, tokenDestDecimals: switchRates.destDecimals, afterNetworkCostsBuyAmount: - switchRates.amountAndCosts.afterNetworkCosts.buyAmount.toString(), + orderKind === 'market' + ? switchRates.amountAndCosts.afterNetworkCosts.buyAmount.toString() + : switchRates.destAmount, // TODO: check with cow team slippageBps, inputSymbol, outputSymbol, + validTo, quote: switchRates.order, }); @@ -348,12 +357,15 @@ export const SwitchActions = ({ quote: switchRates.order, amount: switchRates.srcAmount, afterNetworkCostsBuyAmount: - switchRates.amountAndCosts.afterNetworkCosts.buyAmount.toString(), + orderKind === 'market' + ? switchRates.amountAndCosts.afterNetworkCosts.buyAmount.toString() + : switchRates.destAmount, // TODO: check with cow team slippageBps, chainId, user, provider, inputSymbol, + validTo, outputSymbol, }); setMainTxState({ diff --git a/src/components/transactions/Switch/SwitchAssetInput.tsx b/src/components/transactions/Switch/SwitchAssetInput.tsx index adfab729d7..750d9294f4 100644 --- a/src/components/transactions/Switch/SwitchAssetInput.tsx +++ b/src/components/transactions/Switch/SwitchAssetInput.tsx @@ -1,3 +1,4 @@ +import { OrderKind } from '@cowprotocol/cow-sdk'; import { isAddress } from '@ethersproject/address'; import { formatUnits } from '@ethersproject/units'; import { ExclamationIcon } from '@heroicons/react/outline'; @@ -34,6 +35,22 @@ interface CustomProps { value: string; } +export enum InputRole { + INPUT, + OUTPUT, +} + +const InputTypeStringByRole: Record> = { + [InputRole.INPUT]: { + [OrderKind.SELL]: 'Sell exactly', + [OrderKind.BUY]: 'Sell at most', + }, + [InputRole.OUTPUT]: { + [OrderKind.SELL]: 'Receive at least', + [OrderKind.BUY]: 'Receive exactly', + }, +}; + export const NumberFormatCustom = React.forwardRef( function NumberFormatCustom(props, ref) { const { onChange, ...other } = props; @@ -75,6 +92,9 @@ export interface AssetInputProps { selectedAsset: TokenInfoWithBalance; balanceTitle?: string; showBalance?: boolean; + showMaxButton?: boolean; + role: InputRole; + orderKind?: OrderKind; } export const SwitchAssetInput = ({ @@ -93,6 +113,9 @@ export const SwitchAssetInput = ({ selectedAsset, balanceTitle, showBalance = true, + showMaxButton = true, + orderKind, + role, }: AssetInputProps) => { const theme = useTheme(); const handleSelect = (asset: TokenInfoWithBalance) => { @@ -173,6 +196,11 @@ export const SwitchAssetInput = ({ width: '100%', })} > + {orderKind && ( + + {InputTypeStringByRole[role][orderKind]} + + )} {loading ? ( @@ -458,7 +486,7 @@ export const SwitchAssetInput = ({ sx={{ ml: 1 }} /> - {!disableInput && ( + {showMaxButton && !disableInput && (