SwitchModalContent.tsx
1 import { normalize, normalizeBN } from '@aave/math-utils'; 2 import { SwitchVerticalIcon } from '@heroicons/react/outline'; 3 import { Trans } from '@lingui/macro'; 4 import { Box, CircularProgress, IconButton, SvgIcon, Typography } from '@mui/material'; 5 import { debounce } from 'lodash'; 6 import React, { useMemo, useState } from 'react'; 7 import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; 8 import { Row } from 'src/components/primitives/Row'; 9 import { Warning } from 'src/components/primitives/Warning'; 10 import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton'; 11 import { TokenInfoWithBalance } from 'src/hooks/generic/useTokensBalance'; 12 import { useParaswapSellRates } from 'src/hooks/paraswap/useParaswapRates'; 13 import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; 14 import { useModalContext } from 'src/hooks/useModal'; 15 import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; 16 import { useRootStore } from 'src/store/root'; 17 import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; 18 import { GENERAL } from 'src/utils/mixPanelEvents'; 19 20 import { TxModalDetails } from '../FlowCommons/TxModalDetails'; 21 import { TxModalTitle } from '../FlowCommons/TxModalTitle'; 22 import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; 23 import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; 24 import { SupportedNetworkWithChainId } from './common'; 25 import { NetworkSelector } from './NetworkSelector'; 26 import { SwitchActions } from './SwitchActions'; 27 import { SwitchAssetInput } from './SwitchAssetInput'; 28 import { SwitchErrors } from './SwitchErrors'; 29 import { SwitchRates } from './SwitchRates'; 30 import { SwitchSlippageSelector } from './SwitchSlippageSelector'; 31 import { SwitchTxSuccessView } from './SwitchTxSuccessView'; 32 33 interface SwitchModalContentProps { 34 selectedChainId: number; 35 setSelectedChainId: (value: number) => void; 36 supportedNetworks: SupportedNetworkWithChainId[]; 37 tokens: TokenInfoWithBalance[]; 38 defaultInputToken: TokenInfoWithBalance; 39 defaultOutputToken: TokenInfoWithBalance; 40 addNewToken: (token: TokenInfoWithBalance) => Promise<void>; 41 } 42 43 enum ValidationSeverity { 44 ERROR = 'error', 45 WARNING = 'warning', 46 } 47 48 export interface ValidationData { 49 message: string; 50 severity: ValidationSeverity; 51 } 52 53 const validateSlippage = (slippage: string): ValidationData | undefined => { 54 try { 55 const numberSlippage = Number(slippage); 56 if (Number.isNaN(numberSlippage)) 57 return { 58 message: 'Invalid slippage', 59 severity: ValidationSeverity.ERROR, 60 }; 61 if (numberSlippage > 30) 62 return { 63 message: 'Slippage must be lower 30%', 64 severity: ValidationSeverity.ERROR, 65 }; 66 if (numberSlippage < 0) 67 return { 68 message: 'Slippage must be positive', 69 severity: ValidationSeverity.ERROR, 70 }; 71 if (numberSlippage > 10) 72 return { 73 message: 'High slippage', 74 severity: ValidationSeverity.WARNING, 75 }; 76 if (numberSlippage < 0.1) 77 return { 78 message: 'Slippage lower than 0.1% may result in failed transactions', 79 severity: ValidationSeverity.WARNING, 80 }; 81 return undefined; 82 } catch { 83 return { message: 'Invalid slippage', severity: ValidationSeverity.ERROR }; 84 } 85 }; 86 87 export const SwitchModalContent = ({ 88 supportedNetworks, 89 selectedChainId, 90 setSelectedChainId, 91 defaultInputToken, 92 defaultOutputToken, 93 tokens, 94 addNewToken, 95 }: SwitchModalContentProps) => { 96 const [slippage, setSlippage] = useState('0.10'); 97 const [inputAmount, setInputAmount] = useState(''); 98 const [debounceInputAmount, setDebounceInputAmount] = useState(''); 99 const { mainTxState: switchTxState, gasLimit, txError, setTxError } = useModalContext(); 100 const user = useRootStore((store) => store.account); 101 102 const selectedNetworkConfig = getNetworkConfig(selectedChainId); 103 104 const [selectedInputToken, setSelectedInputToken] = useState(defaultInputToken); 105 const [selectedOutputToken, setSelectedOutputToken] = useState(defaultOutputToken); 106 107 const { readOnlyModeAddress } = useWeb3Context(); 108 109 const isWrongNetwork = useIsWrongNetwork(selectedChainId); 110 111 const slippageValidation = validateSlippage(slippage); 112 113 const safeSlippage = 114 slippageValidation && slippageValidation.severity === ValidationSeverity.ERROR 115 ? 0 116 : Number(slippage) / 100; 117 118 const handleInputChange = (value: string) => { 119 setTxError(undefined); 120 if (value === '-1') { 121 setInputAmount(selectedInputToken.balance); 122 debouncedInputChange(selectedInputToken.balance); 123 } else { 124 setInputAmount(value); 125 debouncedInputChange(value); 126 } 127 }; 128 129 const debouncedInputChange = useMemo(() => { 130 return debounce((value: string) => { 131 setDebounceInputAmount(value); 132 }, 300); 133 }, [setDebounceInputAmount]); 134 135 const { 136 data: sellRates, 137 error: ratesError, 138 isFetching: ratesLoading, 139 } = useParaswapSellRates({ 140 chainId: selectedNetworkConfig.underlyingChainId ?? selectedChainId, 141 amount: 142 debounceInputAmount === '' 143 ? '0' 144 : normalizeBN(debounceInputAmount, -1 * selectedInputToken.decimals).toFixed(0), 145 srcToken: selectedInputToken.address, 146 srcDecimals: selectedInputToken.decimals, 147 destToken: selectedOutputToken.address, 148 destDecimals: selectedOutputToken.decimals, 149 user, 150 options: { 151 partner: 'aave-widget', 152 }, 153 }); 154 155 if (sellRates && switchTxState.success) { 156 return ( 157 <SwitchTxSuccessView 158 txHash={switchTxState.txHash} 159 amount={debounceInputAmount} 160 symbol={selectedInputToken.symbol} 161 iconSymbol={selectedInputToken.symbol} 162 iconUri={selectedInputToken.logoURI} 163 outSymbol={selectedOutputToken.symbol} 164 outIconSymbol={selectedOutputToken.symbol} 165 outIconUri={selectedOutputToken.logoURI} 166 outAmount={( 167 Number(normalize(sellRates.destAmount, sellRates.destDecimals)) * 168 (1 - safeSlippage) 169 ).toString()} 170 /> 171 ); 172 } 173 174 const onSwitchReserves = () => { 175 const fromToken = selectedInputToken; 176 const toToken = selectedOutputToken; 177 const toInput = sellRates 178 ? normalizeBN(sellRates.destAmount, sellRates.destDecimals).toString() 179 : '0'; 180 setSelectedInputToken(toToken); 181 setSelectedOutputToken(fromToken); 182 setInputAmount(toInput); 183 setDebounceInputAmount(toInput); 184 setTxError(undefined); 185 }; 186 187 const handleSelectedInputToken = (token: TokenInfoWithBalance) => { 188 if (!tokens.find((t) => t.address === token.address)) { 189 addNewToken(token).then(() => { 190 setSelectedInputToken(token); 191 setTxError(undefined); 192 }); 193 } else { 194 setSelectedInputToken(token); 195 setTxError(undefined); 196 } 197 }; 198 199 const handleSelectedOutputToken = (token: TokenInfoWithBalance) => { 200 if (!tokens.find((t) => t.address === token.address)) { 201 addNewToken(token).then(() => { 202 setSelectedOutputToken(token); 203 setTxError(undefined); 204 }); 205 } else { 206 setSelectedOutputToken(token); 207 setTxError(undefined); 208 } 209 }; 210 211 const handleSelectedNetworkChange = (value: number) => { 212 setTxError(undefined); 213 setSelectedChainId(value); 214 }; 215 216 return ( 217 <> 218 <TxModalTitle title="Switch tokens" /> 219 {isWrongNetwork.isWrongNetwork && !readOnlyModeAddress && ( 220 <ChangeNetworkWarning 221 networkName={selectedNetworkConfig.name} 222 chainId={selectedChainId} 223 event={{ 224 eventName: GENERAL.SWITCH_NETWORK, 225 }} 226 /> 227 )} 228 <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> 229 <NetworkSelector 230 networks={supportedNetworks} 231 selectedNetwork={selectedChainId} 232 setSelectedNetwork={handleSelectedNetworkChange} 233 /> 234 <SwitchSlippageSelector 235 slippageValidation={slippageValidation} 236 slippage={slippage} 237 setSlippage={setSlippage} 238 /> 239 </Box> 240 {!selectedInputToken || !selectedOutputToken ? ( 241 <CircularProgress /> 242 ) : ( 243 <> 244 <Box 245 sx={{ 246 display: 'flex', 247 gap: '15px', 248 flexDirection: 'column', 249 alignItems: 'center', 250 justifyContent: 'center', 251 position: 'relative', 252 }} 253 > 254 <SwitchAssetInput 255 chainId={selectedChainId} 256 assets={tokens.filter((token) => token.address !== selectedOutputToken.address)} 257 value={inputAmount} 258 onChange={handleInputChange} 259 usdValue={sellRates?.srcUSD || '0'} 260 onSelect={handleSelectedInputToken} 261 selectedAsset={selectedInputToken} 262 /> 263 <IconButton 264 onClick={onSwitchReserves} 265 sx={{ 266 border: '1px solid', 267 borderColor: 'divider', 268 position: 'absolute', 269 backgroundColor: 'background.paper', 270 '&:hover': { backgroundColor: 'background.surface' }, 271 }} 272 > 273 <SvgIcon 274 sx={{ 275 color: 'primary.main', 276 fontSize: '18px', 277 }} 278 > 279 <SwitchVerticalIcon /> 280 </SvgIcon> 281 </IconButton> 282 <SwitchAssetInput 283 chainId={selectedChainId} 284 assets={tokens.filter((token) => token.address !== selectedInputToken.address)} 285 value={ 286 sellRates 287 ? normalizeBN(sellRates.destAmount, sellRates.destDecimals).toString() 288 : '0' 289 } 290 usdValue={sellRates?.destUSD || '0'} 291 loading={ 292 debounceInputAmount !== '0' && 293 debounceInputAmount !== '' && 294 ratesLoading && 295 !ratesError 296 } 297 onSelect={handleSelectedOutputToken} 298 disableInput={true} 299 selectedAsset={selectedOutputToken} 300 /> 301 </Box> 302 {sellRates && ( 303 <> 304 <SwitchRates 305 rates={sellRates} 306 srcSymbol={selectedInputToken.symbol} 307 destSymbol={selectedOutputToken.symbol} 308 /> 309 </> 310 )} 311 {sellRates && user && ( 312 <TxModalDetails gasLimit={gasLimit} chainId={selectedChainId}> 313 <Row 314 caption={<Trans>{`Minimum ${selectedOutputToken.symbol} received`}</Trans>} 315 captionVariant="caption" 316 > 317 <FormattedNumber 318 compact={false} 319 roundDown={true} 320 variant="caption" 321 value={ 322 Number(normalize(sellRates.destAmount, sellRates.destDecimals)) * 323 (1 - safeSlippage) 324 } 325 /> 326 </Row> 327 <Row 328 sx={{ mt: 1 }} 329 caption={<Trans>Minimum USD value received</Trans>} 330 captionVariant="caption" 331 > 332 <FormattedNumber 333 symbol="usd" 334 symbolsVariant="caption" 335 variant="caption" 336 value={Number(sellRates.destUSD) * (1 - safeSlippage)} 337 /> 338 </Row> 339 </TxModalDetails> 340 )} 341 {user ? ( 342 <> 343 {(selectedInputToken.extensions?.isUserCustom || 344 selectedOutputToken.extensions?.isUserCustom) && ( 345 <Warning severity="warning" icon={false} sx={{ mt: 2, mb: 2 }}> 346 <Typography variant="caption"> 347 You have selected a custom imported token. 348 </Typography> 349 </Warning> 350 )} 351 <SwitchErrors 352 ratesError={ratesError} 353 balance={selectedInputToken.balance} 354 inputAmount={debounceInputAmount} 355 /> 356 {txError && <ParaswapErrorDisplay txError={txError} />} 357 <SwitchActions 358 isWrongNetwork={isWrongNetwork.isWrongNetwork} 359 inputAmount={debounceInputAmount} 360 inputToken={selectedInputToken.address} 361 outputToken={selectedOutputToken.address} 362 inputName={selectedInputToken.name} 363 outputName={selectedOutputToken.name} 364 slippage={safeSlippage.toString()} 365 blocked={ 366 !sellRates || 367 Number(debounceInputAmount) > Number(selectedInputToken.balance) || 368 !user || 369 slippageValidation?.severity === ValidationSeverity.ERROR 370 } 371 chainId={selectedChainId} 372 route={sellRates} 373 /> 374 </> 375 ) : ( 376 <Box sx={{ display: 'flex', flexDirection: 'column', mt: 4, alignItems: 'center' }}> 377 <Typography sx={{ mb: 6, textAlign: 'center' }} color="text.secondary"> 378 <Trans>Please connect your wallet to be able to switch your tokens.</Trans> 379 </Typography> 380 <ConnectWalletButton /> 381 </Box> 382 )} 383 </> 384 )} 385 </> 386 ); 387 };