/ src / components / transactions / Bridge / BridgeModalContent.tsx
BridgeModalContent.tsx
  1  import { SwitchVerticalIcon } from '@heroicons/react/outline';
  2  import { Trans } from '@lingui/macro';
  3  import {
  4    Box,
  5    Button,
  6    IconButton,
  7    SelectChangeEvent,
  8    Skeleton,
  9    Stack,
 10    SvgIcon,
 11    Typography,
 12  } from '@mui/material';
 13  import { BigNumber } from 'bignumber.js';
 14  import { constants } from 'ethers';
 15  import { formatUnits } from 'ethers/lib/utils';
 16  import React, { useEffect, useState } from 'react';
 17  import { Link, ROUTES } from 'src/components/primitives/Link';
 18  import { Row } from 'src/components/primitives/Row';
 19  import { Warning } from 'src/components/primitives/Warning';
 20  import { TextWithTooltip } from 'src/components/TextWithTooltip';
 21  import {
 22    DetailsNumberLine,
 23    TxModalDetails,
 24  } from 'src/components/transactions/FlowCommons/TxModalDetails';
 25  import { NetworkSelect } from 'src/components/transactions/NetworkSelect';
 26  import { ConnectWalletButton } from 'src/components/WalletConnection/ConnectWalletButton';
 27  import { useBridgeTokens } from 'src/hooks/bridge/useBridgeWalletBalance';
 28  import { TokenInfoWithBalance, useTokensBalance } from 'src/hooks/generic/useTokensBalance';
 29  import { useModalContext } from 'src/hooks/useModal';
 30  import { useWeb3Context } from 'src/libs/hooks/useWeb3Context';
 31  import { useRootStore } from 'src/store/root';
 32  import { GHO_SYMBOL } from 'src/utils/ghoUtilities';
 33  import { getNetworkConfig, marketsData } from 'src/utils/marketsAndNetworksConfig';
 34  import { GENERAL } from 'src/utils/mixPanelEvents';
 35  
 36  import { AssetInput } from '../AssetInput';
 37  import { TxErrorView } from '../FlowCommons/Error';
 38  import { GasEstimationError } from '../FlowCommons/GasEstimationError';
 39  import { TxSuccessView } from '../FlowCommons/Success';
 40  import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning';
 41  import { BridgeActionProps, BridgeActions } from './BridgeActions';
 42  import { BridgeAmount } from './BridgeAmount';
 43  import {
 44    getConfigFor,
 45    laneConfig,
 46    supportedNetworksWithBridge,
 47    SupportedNetworkWithChainId,
 48  } from './BridgeConfig';
 49  import { BridgeDestinationInput } from './BridgeDestinationInput';
 50  import { BridgeFeeTokenSelector } from './BridgeFeeTokenSelector';
 51  import { useGetBridgeLimit, useGetRateLimit } from './useGetBridgeLimits';
 52  import { useGetBridgeMessage } from './useGetBridgeMessage';
 53  import { useTimeToDestination } from './useGetFinalityTime';
 54  
 55  const defaultNetwork = supportedNetworksWithBridge[0];
 56  const defaultNetworkMarket = marketsData[defaultNetwork.chainId];
 57  
 58  export const BridgeModalContent = () => {
 59    const { mainTxState: bridgeTxState, txError, close, gasLimit } = useModalContext();
 60    const user = useRootStore((state) => state.account);
 61    const [destinationAccount, setDestinationAccount] = useState(user);
 62    const [amount, setAmount] = useState('');
 63    const [maxSelected, setMaxSelected] = useState(false);
 64  
 65    const { readOnlyModeAddress, chainId: currentChainId } = useWeb3Context();
 66  
 67    const [sourceNetworkObj, setSourceNetworkObj] = useState(
 68      supportedNetworksWithBridge.find((net) => net.chainId === currentChainId) ?? defaultNetwork
 69    );
 70  
 71    const defaultDestinationNetwork = supportedNetworksWithBridge.find(
 72      (net) => net.chainId !== sourceNetworkObj.chainId
 73    ) as SupportedNetworkWithChainId;
 74  
 75    const [destinationNetworkObj, setDestinationNetworkObj] = useState(defaultDestinationNetwork);
 76  
 77    const { data: estimatedTimeToDestination, isFetching: loadingEstimatedTime } =
 78      useTimeToDestination(sourceNetworkObj.chainId);
 79  
 80    const getFilteredFeeTokens = (chainId: number) => {
 81      return laneConfig
 82        .filter((token) => token.sourceChainId === chainId)
 83        .flatMap((config) => config.feeTokens);
 84    };
 85  
 86    const filteredFeeTokensByChainId = getFilteredFeeTokens(sourceNetworkObj.chainId);
 87  
 88    const { data: feeTokenListWithBalance, isFetching: loadingTokenBalances } = useTokensBalance(
 89      filteredFeeTokensByChainId,
 90      sourceNetworkObj.chainId,
 91      user
 92    );
 93  
 94    const getGHOToken = (tokenList: TokenInfoWithBalance[]) => {
 95      return tokenList.find((token: TokenInfoWithBalance) => token.symbol === 'GHO') || tokenList[0];
 96    };
 97  
 98    const [selectedFeeToken, setSelectedFeeToken] = useState(
 99      getGHOToken(feeTokenListWithBalance || filteredFeeTokensByChainId)
100    );
101  
102    const handleTokenChange = (event: SelectChangeEvent) => {
103      const token = feeTokenListWithBalance?.find((token) => token.symbol === event.target.value);
104  
105      if (token) {
106        setSelectedFeeToken(token);
107      } else {
108        setSelectedFeeToken(filteredFeeTokensByChainId[0]);
109      }
110    };
111  
112    useEffect(() => {
113      if (feeTokenListWithBalance && feeTokenListWithBalance.length > 0 && !selectedFeeToken) {
114        setSelectedFeeToken(feeTokenListWithBalance[0]);
115      }
116    }, [feeTokenListWithBalance, sourceNetworkObj]);
117  
118    useEffect(() => {
119      // reset when source network changes
120      setAmount('');
121      setMaxSelected(false);
122    }, [sourceNetworkObj]);
123  
124    const { data: sourceTokenInfo, isFetching: fetchingBridgeTokenBalance } = useBridgeTokens(
125      Object.values(marketsData).find((elem) => elem.chainId === sourceNetworkObj.chainId) ||
126        defaultNetworkMarket,
127      getConfigFor(sourceNetworkObj.chainId).tokenOracle
128    );
129  
130    const isWrongNetwork = currentChainId !== sourceNetworkObj.chainId;
131  
132    const {
133      message,
134      bridgeFee,
135      bridgeFeeFormatted,
136      loading: loadingBridgeMessage,
137      latestAnswer: bridgeFeeUSD,
138      error: txErrorBridgeMessage,
139    } = useGetBridgeMessage({
140      sourceChainId: sourceNetworkObj.chainId,
141      destinationChainId: destinationNetworkObj?.chainId || 0,
142      amount,
143      sourceTokenAddress: sourceTokenInfo?.address || '',
144      destinationAccount,
145      feeToken: selectedFeeToken?.address || '',
146      feeTokenOracle: selectedFeeToken?.oracle || ('' as string),
147    });
148  
149    const { data: bridgeLimits, isInitialLoading: loadingBridgeLimit } = useGetBridgeLimit(
150      sourceNetworkObj.chainId
151    );
152  
153    const { data: rateLimit, isInitialLoading: loadingRateLimit } = useGetRateLimit({
154      destinationChainId: destinationNetworkObj?.chainId || 0,
155      sourceChainId: sourceNetworkObj.chainId,
156    });
157  
158    const loadingLimits = loadingBridgeLimit || loadingRateLimit;
159  
160    const handleSelectedNetworkChange =
161      (networkAction: string) => (network: SupportedNetworkWithChainId) => {
162        if (networkAction === 'sourceNetwork') {
163          setSourceNetworkObj(network);
164          // setSelectedChainId(network.chainId);
165        } else {
166          setDestinationNetworkObj(network);
167        }
168      };
169  
170    let maxAmountReducedDueToBridgeLimit = false;
171    let maxAmountReducedDueToRateLimit = false;
172    let maxAmountToBridge = sourceTokenInfo?.bridgeTokenBalance || '0';
173    const hasBridgeLimit = bridgeLimits?.bridgeLimit !== '-1';
174    const remainingBridgeLimit = BigNumber(bridgeLimits?.remainingAmount || '0');
175  
176    if (!loadingLimits && bridgeLimits && rateLimit) {
177      if (hasBridgeLimit && remainingBridgeLimit.lt(maxAmountToBridge)) {
178        maxAmountToBridge = bridgeLimits.remainingAmount;
179        maxAmountReducedDueToBridgeLimit = true;
180        maxAmountReducedDueToRateLimit = false;
181      } else if (BigNumber(rateLimit.tokens).lt(maxAmountToBridge)) {
182        maxAmountToBridge = rateLimit.tokens;
183        maxAmountReducedDueToRateLimit = true;
184        maxAmountReducedDueToBridgeLimit = false;
185      }
186    }
187  
188    const maxAmountToBridgeFormatted = formatUnits(maxAmountToBridge, 18);
189  
190    const handleInputChange = (value: string) => {
191      if (value === '-1') {
192        setAmount(maxAmountToBridgeFormatted);
193        setMaxSelected(true);
194      } else {
195        setAmount(value);
196        setMaxSelected(false);
197      }
198    };
199  
200    const handleSwapNetworks = () => {
201      const currentSourceNetworkObj = sourceNetworkObj;
202      setSourceNetworkObj(destinationNetworkObj);
203      setDestinationNetworkObj(currentSourceNetworkObj);
204  
205      const newFilteredFeeTokens = getFilteredFeeTokens(destinationNetworkObj.chainId);
206      setSelectedFeeToken(newFilteredFeeTokens[0]);
207    };
208  
209    // string formatting for tx display
210    const amountUsd = Number(amount) * sourceTokenInfo.tokenPriceUSD;
211    const parsedAmountFee = new BigNumber(amount || '0');
212    const parsedBridgeFee = new BigNumber(bridgeFeeFormatted || '0');
213    const amountAfterFee = BigNumber.max(0, parsedAmountFee.minus(parsedBridgeFee));
214    const amountAfterFeeFormatted = amountAfterFee.toString();
215    const feeTokenBalance =
216      feeTokenListWithBalance?.find((t) => t.address === selectedFeeToken.address)?.balance || '0';
217  
218    const feesExceedWalletBalance =
219      !loadingBridgeMessage &&
220      !loadingTokenBalances &&
221      amountUsd !== 0 &&
222      ((selectedFeeToken.address !== constants.AddressZero && amountAfterFee.lte(0)) ||
223        (selectedFeeToken.address === constants.AddressZero && parsedBridgeFee.gte(feeTokenBalance)));
224  
225    const bridgeActionsProps: BridgeActionProps = {
226      amountToBridge: amount,
227      isWrongNetwork,
228      symbol: GHO_SYMBOL,
229      blocked:
230        loadingBridgeMessage ||
231        loadingTokenBalances ||
232        !destinationAccount ||
233        loadingLimits ||
234        feesExceedWalletBalance,
235      decimals: 18,
236      message,
237      fees: bridgeFee,
238      sourceChainId: sourceNetworkObj.chainId,
239      destinationChainId: destinationNetworkObj.chainId,
240      tokenAddress: sourceTokenInfo?.address || constants.AddressZero,
241      isCustomFeeToken: selectedFeeToken.address !== constants.AddressZero,
242    };
243  
244    if (txError && txError.blocking) {
245      return <TxErrorView txError={txError} />;
246    }
247  
248    if (bridgeTxState.success) {
249      return (
250        <TxSuccessView
251          customAction={
252            <Box mt={5}>
253              <Button
254                component={Link}
255                href={ROUTES.bridge}
256                variant="outlined"
257                size="small"
258                onClick={close}
259              >
260                <Trans>View Bridge Transactions</Trans>
261              </Button>
262            </Box>
263          }
264          customText={
265            <Trans>
266              Asset has been successfully sent to CCIP contract. You can check the status of the
267              transactions below
268            </Trans>
269          }
270          action={<Trans>Bridged Via CCIP</Trans>}
271        />
272      );
273    }
274  
275    const estimatedTimeTooltip = (
276      <TextWithTooltip text={<Trans>Estimated time</Trans>}>
277        <Trans>
278          The source chain time to finality is the main factor that determines the time to
279          destination.{' '}
280          <Link
281            href="https://docs.chain.link/ccip/concepts#finality"
282            sx={{ textDecoration: 'underline' }}
283            variant="caption"
284            color="text.secondary"
285          >
286            Learn more
287          </Link>
288        </Trans>
289      </TextWithTooltip>
290    );
291  
292    const amountWithFee = (
293      <TextWithTooltip text={<Trans>Amount After Fee</Trans>}>
294        <Trans>
295          The total amount bridged minus CCIP fees. Paying in network token does not impact gho
296          amount.
297        </Trans>
298      </TextWithTooltip>
299    );
300  
301    return (
302      <>
303        <Box display="flex" justifyContent="space-between" alignItems="center">
304          <Typography variant="h2">
305            <Trans>Bridge GHO</Trans>
306          </Typography>
307          {user && (
308            <Box
309              sx={{
310                right: '0px',
311              }}
312            >
313              <Button
314                component={Link}
315                href={ROUTES.bridge}
316                sx={{ mr: 8 }}
317                variant="surface"
318                size="small"
319                onClick={close}
320              >
321                <Trans>Transactions</Trans>
322              </Button>
323            </Box>
324          )}
325        </Box>
326  
327        <ChangeNetworkWarning
328          networkName={getNetworkConfig(sourceNetworkObj.chainId).name}
329          chainId={sourceNetworkObj.chainId}
330          event={{
331            eventName: GENERAL.SWITCH_NETWORK,
332          }}
333          sx={{ my: 1, visibility: isWrongNetwork && !readOnlyModeAddress ? 'visible' : 'hidden' }}
334        />
335        {!user ? (
336          <Box sx={{ display: 'flex', flexDirection: 'column', mt: 4, alignItems: 'center' }}>
337            <Typography sx={{ mb: 6, textAlign: 'center' }} color="text.secondary">
338              <Trans>Please connect your wallet to be able to bridge your tokens.</Trans>
339            </Typography>
340            <ConnectWalletButton />
341          </Box>
342        ) : (
343          <>
344            <Stack
345              sx={{ mb: 3 }}
346              gap={3}
347              direction="column"
348              alignItems="center"
349              justifyContent="center"
350            >
351              <NetworkSelect
352                supportedBridgeMarkets={supportedNetworksWithBridge.filter(
353                  (net) => net.chainId !== destinationNetworkObj.chainId
354                )}
355                onNetworkChange={handleSelectedNetworkChange('sourceNetwork')}
356                defaultNetwork={sourceNetworkObj}
357              />
358              <IconButton
359                onClick={handleSwapNetworks}
360                sx={{
361                  border: '1px solid',
362                  borderColor: 'divider',
363                  position: 'absolute',
364                  backgroundColor: 'background.paper',
365                  mt: -1,
366                  '&:hover': { backgroundColor: 'background.surface' },
367                }}
368              >
369                <SvgIcon sx={{ color: 'primary.main', fontSize: '18px' }}>
370                  <SwitchVerticalIcon />
371                </SvgIcon>
372              </IconButton>
373              <NetworkSelect
374                supportedBridgeMarkets={supportedNetworksWithBridge.filter(
375                  (net) => net.chainId !== sourceNetworkObj.chainId
376                )}
377                onNetworkChange={handleSelectedNetworkChange('destinationNetwork')}
378                defaultNetwork={destinationNetworkObj}
379              />
380            </Stack>
381            <AssetInput
382              disableInput={!loadingBridgeMessage && sourceTokenInfo?.bridgeTokenBalance === '0'}
383              value={amount}
384              onChange={handleInputChange}
385              usdValue={amountUsd.toString()}
386              symbol={GHO_SYMBOL}
387              assets={[
388                {
389                  balance: sourceTokenInfo.bridgeTokenBalanceFormatted,
390                  address: sourceTokenInfo.address,
391                  symbol: GHO_SYMBOL,
392                  iconSymbol: GHO_SYMBOL,
393                },
394              ]}
395              maxValue={maxAmountToBridgeFormatted}
396              inputTitle={<Trans>Amount to Bridge</Trans>}
397              balanceText={<Trans>GHO balance</Trans>}
398              sx={{ width: '100%' }}
399              loading={fetchingBridgeTokenBalance || loadingLimits}
400              isMaxSelected={maxSelected}
401            />
402  
403            <Box sx={{ mt: 3 }}>
404              <BridgeDestinationInput
405                connectedAccount={user}
406                onInputValid={(account) => {
407                  setDestinationAccount(account);
408                }}
409                onInputError={() => setDestinationAccount('')}
410                sourceChainId={sourceNetworkObj.chainId}
411              />
412            </Box>
413            <TxModalDetails gasLimit={gasLimit} chainId={sourceNetworkObj.chainId}>
414              <BridgeAmount
415                amount={amount}
416                maxAmountToBridgeFormatted={maxAmountToBridgeFormatted}
417                maxAmountReducedDueToBridgeLimit={maxSelected && maxAmountReducedDueToBridgeLimit}
418                maxAmountReducedDueToRateLimit={maxSelected && maxAmountReducedDueToRateLimit}
419                refillRate={rateLimit?.rate || '0'}
420                maxRateLimitCapacity={rateLimit?.capacity || '0'}
421              />
422              <BridgeFeeTokenSelector
423                feeTokens={feeTokenListWithBalance || []}
424                selectedFeeToken={selectedFeeToken}
425                onFeeTokenChanged={handleTokenChange}
426                bridgeFeeFormatted={bridgeFeeFormatted}
427                bridgeFeeUSD={bridgeFeeUSD}
428                loading={loadingBridgeMessage || loadingTokenBalances}
429              />
430              {selectedFeeToken.address !== constants.AddressZero && (
431                <DetailsNumberLine
432                  description={amountWithFee}
433                  iconSymbol={GHO_SYMBOL}
434                  symbol={GHO_SYMBOL}
435                  value={amountAfterFeeFormatted}
436                  loading={loadingBridgeMessage || loadingTokenBalances}
437                />
438              )}
439              <Row caption={estimatedTimeTooltip} captionVariant="description" mb={4}>
440                <Box sx={{ display: 'flex', alignItems: 'center' }}>
441                  {loadingEstimatedTime ? (
442                    <Skeleton
443                      variant="rectangular"
444                      height={20}
445                      width={100}
446                      sx={{ borderRadius: '4px' }}
447                    />
448                  ) : (
449                    <Typography variant="secondary14">{estimatedTimeToDestination}</Typography>
450                  )}
451                </Box>
452              </Row>
453              {/* <Row caption={'Bridged Amount'} captionVariant="description" mb={4}>
454                <Box sx={{ display: 'flex', alignItems: 'center' }}>
455                  {loadingBridgeMessage ? (
456                    <Skeleton
457                      variant="rectangular"
458                      height={20}
459                      width={100}
460                      sx={{ borderRadius: '4px' }}
461                    />
462                  ) : (
463                    <Typography variant="secondary14">{estimatedTimeToDestination}</Typography>
464                  )}
465                </Box> */}
466              <Row /> {/* Spacer */}
467              {feesExceedWalletBalance && (
468                <Warning severity="warning" sx={{ my: 0 }}>
469                  <Typography variant="caption">
470                    <Trans>Fees exceed wallet balance</Trans>
471                  </Typography>
472                </Warning>
473              )}
474            </TxModalDetails>
475            {txError && <GasEstimationError txError={txError} />}
476  
477            {txErrorBridgeMessage && (
478              <Warning severity="error" sx={{ mt: 4 }} icon={false}>
479                <Typography variant="caption">
480                  <Trans>Something went wrong fetching bridge message, please try again later.</Trans>
481                </Typography>
482              </Warning>
483            )}
484  
485            <BridgeActions {...bridgeActionsProps} />
486          </>
487        )}
488      </>
489    );
490  };