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 };