/ src / components / transactions / Switch / SwitchModalContent.tsx
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  };