DebtSwitchModalContent.tsx
1 import { valueToBigNumber } from '@aave/math-utils'; 2 import { MaxUint256 } from '@ethersproject/constants'; 3 import { ArrowDownIcon } from '@heroicons/react/outline'; 4 import { ArrowNarrowRightIcon } from '@heroicons/react/solid'; 5 import { Trans } from '@lingui/macro'; 6 import { Box, ListItemText, ListSubheader, Stack, SvgIcon, Typography } from '@mui/material'; 7 import { BigNumber } from 'bignumber.js'; 8 import React, { useRef, useState } from 'react'; 9 import { GhoIncentivesCard } from 'src/components/incentives/GhoIncentivesCard'; 10 import { PriceImpactTooltip } from 'src/components/infoTooltips/PriceImpactTooltip'; 11 import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; 12 import { ROUTES } from 'src/components/primitives/Link'; 13 import { TokenIcon } from 'src/components/primitives/TokenIcon'; 14 import { Warning } from 'src/components/primitives/Warning'; 15 import { Asset, AssetInput } from 'src/components/transactions/AssetInput'; 16 import { TxModalDetails } from 'src/components/transactions/FlowCommons/TxModalDetails'; 17 import { maxInputAmountWithSlippage } from 'src/hooks/paraswap/common'; 18 import { useDebtSwitch } from 'src/hooks/paraswap/useDebtSwitch'; 19 import { useGhoPoolReserve } from 'src/hooks/pool/useGhoPoolReserve'; 20 import { useUserGhoPoolReserve } from 'src/hooks/pool/useUserGhoPoolReserve'; 21 import { useModalContext } from 'src/hooks/useModal'; 22 import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; 23 import { ListSlippageButton } from 'src/modules/dashboard/lists/SlippageList'; 24 import { useRootStore } from 'src/store/root'; 25 import { CustomMarket } from 'src/ui-config/marketsConfig'; 26 import { assetCanBeBorrowedByUser } from 'src/utils/getMaxAmountAvailableToBorrow'; 27 import { 28 displayGhoForMintableMarket, 29 ghoUserQualifiesForDiscount, 30 weightedAverageAPY, 31 } from 'src/utils/ghoUtilities'; 32 33 import { 34 ComputedUserReserveData, 35 ExtendedFormattedUser, 36 useAppDataContext, 37 } from '../../../hooks/app-data-provider/useAppDataProvider'; 38 import { ModalWrapperProps } from '../FlowCommons/ModalWrapper'; 39 import { TxSuccessView } from '../FlowCommons/Success'; 40 import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; 41 import { DebtSwitchActions } from './DebtSwitchActions'; 42 import { DebtSwitchModalDetails } from './DebtSwitchModalDetails'; 43 44 export type SupplyProps = { 45 underlyingAsset: string; 46 }; 47 48 export interface GhoRange { 49 qualifiesForDiscount: boolean; 50 userBorrowApyAfterMaxSwitch: number; 51 ghoApyRange?: [number, number]; 52 userDiscountTokenBalance: number; 53 inputAmount: number; 54 targetAmount: number; 55 userCurrentBorrowApy: number; 56 ghoVariableBorrowApy: number; 57 userGhoAvailableToBorrowAtDiscount: number; 58 ghoBorrowAPYWithMaxDiscount: number; 59 userCurrentBorrowBalance: number; 60 } 61 62 interface SwitchTargetAsset extends Asset { 63 variableApy: string; 64 } 65 66 enum ErrorType { 67 INSUFFICIENT_LIQUIDITY, 68 } 69 70 export const DebtSwitchModalContent = ({ 71 poolReserve, 72 userReserve, 73 isWrongNetwork, 74 user, 75 }: ModalWrapperProps & { user: ExtendedFormattedUser }) => { 76 const { reserves, ghoReserveData, ghoUserData, ghoUserLoadingData } = useAppDataContext(); 77 const currentChainId = useRootStore((store) => store.currentChainId); 78 const currentNetworkConfig = useRootStore((store) => store.currentNetworkConfig); 79 const { currentAccount } = useWeb3Context(); 80 const { gasLimit, mainTxState, txError, setTxError } = useModalContext(); 81 82 const currentMarket = useRootStore((store) => store.currentMarket); 83 const currentMarketData = useRootStore((store) => store.currentMarketData); 84 const { data: _ghoUserData } = useUserGhoPoolReserve(currentMarketData); 85 const { data: _ghoReserveData } = useGhoPoolReserve(currentMarketData); 86 87 let switchTargets = reserves 88 .filter( 89 (r) => 90 r.underlyingAsset !== poolReserve.underlyingAsset && 91 r.availableLiquidity !== '0' && 92 assetCanBeBorrowedByUser(r, user) 93 ) 94 .map<SwitchTargetAsset>((reserve) => ({ 95 address: reserve.underlyingAsset, 96 symbol: reserve.symbol, 97 iconSymbol: reserve.iconSymbol, 98 variableApy: reserve.variableBorrowAPY, 99 priceInUsd: reserve.priceInUSD, 100 decimals: reserve.decimals, 101 })); 102 103 switchTargets = [ 104 ...switchTargets.filter((r) => r.symbol === 'GHO'), 105 ...switchTargets.filter((r) => r.symbol !== 'GHO'), 106 ]; 107 108 // states 109 const [_amount, setAmount] = useState(''); 110 const amountRef = useRef<string>(''); 111 const [targetReserve, setTargetReserve] = useState<Asset>(switchTargets[0]); 112 const [maxSlippage, setMaxSlippage] = useState('0.1'); 113 114 const switchTarget = user.userReservesData.find( 115 (r) => r.underlyingAsset === targetReserve.address 116 ) as ComputedUserReserveData; 117 118 const maxAmountToSwitch = userReserve.variableBorrows; 119 120 const isMaxSelected = _amount === '-1'; 121 const amount = isMaxSelected ? maxAmountToSwitch : _amount; 122 123 const { 124 inputAmount, 125 outputAmount, 126 outputAmountUSD, 127 error, 128 loading: routeLoading, 129 buildTxFn, 130 } = useDebtSwitch({ 131 chainId: currentNetworkConfig.underlyingChainId || currentChainId, 132 userAddress: currentAccount, 133 swapOut: { ...poolReserve, amount: amountRef.current }, 134 swapIn: { ...switchTarget.reserve, amount: '0' }, 135 max: isMaxSelected, 136 skip: mainTxState.loading || false, 137 maxSlippage: Number(maxSlippage), 138 }); 139 140 const loadingSkeleton = routeLoading && outputAmountUSD === '0'; 141 142 const handleChange = (value: string) => { 143 const maxSelected = value === '-1'; 144 amountRef.current = maxSelected ? maxAmountToSwitch : value; 145 setAmount(value); 146 setTxError(undefined); 147 }; 148 149 // TODO consider pulling out a util helper here or maybe moving this logic into the store 150 let availableBorrowCap = valueToBigNumber(MaxUint256.toString()); 151 let availableLiquidity: string | number = '0'; 152 if (displayGhoForMintableMarket({ symbol: switchTarget.reserve.symbol, currentMarket })) { 153 availableLiquidity = ghoReserveData.aaveFacilitatorRemainingCapacity.toString(); 154 } else { 155 availableBorrowCap = 156 switchTarget.reserve.borrowCap === '0' 157 ? valueToBigNumber(MaxUint256.toString()) 158 : valueToBigNumber(Number(switchTarget.reserve.borrowCap)).minus( 159 valueToBigNumber(switchTarget.reserve.totalDebt) 160 ); 161 availableLiquidity = switchTarget.reserve.formattedAvailableLiquidity; 162 } 163 164 const availableLiquidityOfTargetReserve = BigNumber.max( 165 BigNumber.min(availableLiquidity, availableBorrowCap), 166 0 167 ); 168 169 const poolReserveAmountUSD = Number(amount) * Number(poolReserve.priceInUSD); 170 const targetReserveAmountUSD = Number(inputAmount) * Number(targetReserve.priceInUsd); 171 172 const priceImpactDifference: number = targetReserveAmountUSD - poolReserveAmountUSD; 173 const insufficientCollateral = 174 Number(user.availableBorrowsUSD) === 0 || 175 priceImpactDifference > Number(user.availableBorrowsUSD); 176 177 let blockingError: ErrorType | undefined = undefined; 178 if (BigNumber(inputAmount).gt(availableLiquidityOfTargetReserve)) { 179 blockingError = ErrorType.INSUFFICIENT_LIQUIDITY; 180 } 181 182 const BlockingError: React.FC = () => { 183 switch (blockingError) { 184 case ErrorType.INSUFFICIENT_LIQUIDITY: 185 return ( 186 <Trans> 187 There is not enough liquidity for the target asset to perform the switch. Try lowering 188 the amount. 189 </Trans> 190 ); 191 default: 192 return null; 193 } 194 }; 195 196 const maxAmountToReceiveWithSlippage = maxInputAmountWithSlippage( 197 inputAmount, 198 maxSlippage, 199 targetReserve.decimals || 18 200 ); 201 202 if (mainTxState.success) 203 return ( 204 <TxSuccessView 205 customAction={ 206 <Stack gap={3}> 207 <Typography variant="description" color="text.primary"> 208 <Trans>You've successfully switched borrow position.</Trans> 209 </Typography> 210 <Stack direction="row" alignItems="center" justifyContent="center" gap={1}> 211 <TokenIcon symbol={poolReserve.iconSymbol} sx={{ mx: 1 }} /> 212 <FormattedNumber value={amountRef.current} compact variant="subheader1" /> 213 {poolReserve.symbol} 214 <SvgIcon color="primary" sx={{ fontSize: '14px', mx: 1 }}> 215 <ArrowNarrowRightIcon /> 216 </SvgIcon> 217 <TokenIcon symbol={switchTarget.reserve.iconSymbol} sx={{ mx: 1 }} /> 218 <FormattedNumber 219 value={maxAmountToReceiveWithSlippage} 220 compact 221 variant="subheader1" 222 /> 223 {switchTarget.reserve.symbol} 224 </Stack> 225 </Stack> 226 } 227 /> 228 ); 229 230 let qualifiesForDiscount = false; 231 let ghoTargetData: GhoRange | undefined; 232 if (reserves.some((reserve) => reserve.symbol === 'GHO')) { 233 const ghoBalanceAfterMaxSwitchTo = 234 Number(maxAmountToSwitch) * Number(poolReserve.priceInUSD) + ghoUserData.userGhoBorrowBalance; 235 const userCurrentBorrowApy = weightedAverageAPY( 236 ghoReserveData.ghoVariableBorrowAPY, 237 ghoUserData.userGhoBorrowBalance, 238 ghoUserData.userGhoAvailableToBorrowAtDiscount, 239 ghoReserveData.ghoBorrowAPYWithMaxDiscount 240 ); 241 const userBorrowApyAfterMaxSwitchTo = weightedAverageAPY( 242 ghoReserveData.ghoVariableBorrowAPY, 243 ghoBalanceAfterMaxSwitchTo, 244 ghoUserData.userGhoAvailableToBorrowAtDiscount, 245 ghoReserveData.ghoBorrowAPYWithMaxDiscount 246 ); 247 const ghoApyRange: [number, number] | undefined = !ghoUserLoadingData 248 ? [userCurrentBorrowApy, userBorrowApyAfterMaxSwitchTo] 249 : undefined; 250 qualifiesForDiscount = 251 _ghoUserData && _ghoReserveData 252 ? ghoUserQualifiesForDiscount(_ghoReserveData, _ghoUserData, maxAmountToSwitch) 253 : false; 254 ghoTargetData = { 255 qualifiesForDiscount, 256 ghoApyRange, 257 userBorrowApyAfterMaxSwitch: userBorrowApyAfterMaxSwitchTo, 258 userDiscountTokenBalance: ghoUserData.userDiscountTokenBalance, 259 inputAmount: Number(amount), 260 targetAmount: Number(inputAmount), 261 userCurrentBorrowApy, 262 ghoVariableBorrowApy: ghoReserveData.ghoVariableBorrowAPY, 263 userGhoAvailableToBorrowAtDiscount: ghoUserData.userGhoAvailableToBorrowAtDiscount, 264 ghoBorrowAPYWithMaxDiscount: ghoReserveData.ghoBorrowAPYWithMaxDiscount, 265 userCurrentBorrowBalance: ghoUserData.userGhoBorrowBalance, 266 }; 267 } 268 269 return ( 270 <> 271 <AssetInput 272 value={amount} 273 onChange={handleChange} 274 usdValue={poolReserveAmountUSD.toString()} 275 symbol={poolReserve.symbol} 276 assets={[ 277 { 278 balance: maxAmountToSwitch, 279 address: poolReserve.underlyingAsset, 280 symbol: poolReserve.symbol, 281 iconSymbol: poolReserve.iconSymbol, 282 }, 283 ]} 284 maxValue={maxAmountToSwitch} 285 inputTitle={<Trans>Borrowed asset amount</Trans>} 286 balanceText={ 287 <React.Fragment> 288 <Trans>Borrow balance</Trans> 289 </React.Fragment> 290 } 291 isMaxSelected={isMaxSelected} 292 /> 293 <Box sx={{ padding: '18px', pt: '14px', display: 'flex', justifyContent: 'space-between' }}> 294 <SvgIcon sx={{ fontSize: '18px !important' }}> 295 <ArrowDownIcon /> 296 </SvgIcon> 297 298 {/** For debt switch, targetAmountUSD (input) > poolReserveAmountUSD (output) means that more is being borrowed to cover the current borrow balance as exactOut, so this should be treated as positive impact */} 299 <PriceImpactTooltip 300 loading={loadingSkeleton} 301 outputAmountUSD={targetReserveAmountUSD.toString()} 302 inputAmountUSD={poolReserveAmountUSD.toString()} 303 /> 304 </Box> 305 <AssetInput<SwitchTargetAsset> 306 value={inputAmount} 307 onSelect={setTargetReserve} 308 usdValue={targetReserveAmountUSD.toString()} 309 symbol={targetReserve.symbol} 310 assets={switchTargets} 311 inputTitle={<Trans>Switch to</Trans>} 312 balanceText={<Trans>Supply balance</Trans>} 313 disableInput 314 loading={loadingSkeleton} 315 selectOptionHeader={<SelectOptionListHeader />} 316 selectOption={(asset) => 317 displayGhoForMintableMarket({ symbol: asset.symbol, currentMarket }) ? ( 318 <GhoSwitchTargetSelectOption 319 asset={asset} 320 ghoApyRange={ghoTargetData?.ghoApyRange} 321 userBorrowApyAfterMaxSwitch={ghoTargetData?.userBorrowApyAfterMaxSwitch} 322 userDiscountTokenBalance={ghoUserData.userDiscountTokenBalance} 323 currentMarket={currentMarket} 324 qualifiesForDiscount={qualifiesForDiscount} 325 /> 326 ) : ( 327 <SwitchTargetSelectOption asset={asset} /> 328 ) 329 } 330 /> 331 {error && !loadingSkeleton && ( 332 <Typography variant="helperText" color="error.main"> 333 {error} 334 </Typography> 335 )} 336 {!error && blockingError !== undefined && ( 337 <Typography variant="helperText" color="error.main"> 338 <BlockingError /> 339 </Typography> 340 )} 341 342 <TxModalDetails 343 gasLimit={gasLimit} 344 slippageSelector={ 345 <ListSlippageButton 346 selectedSlippage={maxSlippage} 347 setSlippage={(newMaxSlippage) => { 348 setTxError(undefined); 349 setMaxSlippage(newMaxSlippage); 350 }} 351 slippageTooltipHeader={ 352 <Stack direction="row" gap={2} alignItems="center" justifyContent="space-between"> 353 <Trans>Maximum amount received</Trans> 354 <Stack alignItems="end"> 355 <Stack direction="row"> 356 <TokenIcon 357 symbol={switchTarget.reserve.iconSymbol} 358 sx={{ mr: 1, fontSize: '14px' }} 359 /> 360 <FormattedNumber value={maxAmountToReceiveWithSlippage} variant="secondary12" /> 361 </Stack> 362 </Stack> 363 </Stack> 364 } 365 /> 366 } 367 > 368 <DebtSwitchModalDetails 369 switchSource={userReserve} 370 switchTarget={switchTarget} 371 toAmount={inputAmount} 372 fromAmount={amount === '' ? '0' : amount} 373 loading={loadingSkeleton} 374 sourceBalance={maxAmountToSwitch} 375 sourceBorrowAPY={poolReserve.variableBorrowAPY} 376 targetBorrowAPY={switchTarget.reserve.variableBorrowAPY} 377 ghoData={ghoTargetData} 378 currentMarket={currentMarket} 379 /> 380 </TxModalDetails> 381 382 {txError && <ParaswapErrorDisplay txError={txError} />} 383 384 {insufficientCollateral && ( 385 <Warning severity="error" sx={{ mt: 4 }}> 386 <Typography variant="caption"> 387 <Trans> 388 Insufficient collateral to cover new borrow position. Wallet must have borrowing power 389 remaining to perform debt switch. 390 </Trans> 391 </Typography> 392 </Warning> 393 )} 394 395 <DebtSwitchActions 396 isMaxSelected={isMaxSelected} 397 poolReserve={poolReserve} 398 amountToSwap={outputAmount} 399 amountToReceive={maxAmountToReceiveWithSlippage} 400 isWrongNetwork={isWrongNetwork} 401 targetReserve={switchTarget.reserve} 402 symbol={poolReserve.symbol} 403 blocked={blockingError !== undefined || error !== '' || insufficientCollateral} 404 loading={routeLoading} 405 buildTxFn={buildTxFn} 406 /> 407 </> 408 ); 409 }; 410 411 const SelectOptionListHeader = () => { 412 return ( 413 <ListSubheader sx={(theme) => ({ borderBottom: `1px solid ${theme.palette.divider}`, mt: -1 })}> 414 <Stack direction="row" sx={{ py: 4 }} gap={14}> 415 <Typography variant="subheader2"> 416 <Trans>Select an asset</Trans> 417 </Typography> 418 <Typography variant="subheader2"> 419 <Trans>Borrow APY</Trans> 420 </Typography> 421 </Stack> 422 </ListSubheader> 423 ); 424 }; 425 426 const SwitchTargetSelectOption = ({ asset }: { asset: SwitchTargetAsset }) => { 427 return ( 428 <> 429 <TokenIcon 430 aToken={asset.aToken} 431 symbol={asset.iconSymbol || asset.symbol} 432 sx={{ fontSize: '22px', mr: 1 }} 433 /> 434 <ListItemText sx={{ mr: 6 }}>{asset.symbol}</ListItemText> 435 <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'end' }}> 436 <FormattedNumber 437 value={asset.variableApy} 438 percent 439 variant="main14" 440 color="text.secondary" 441 /> 442 <Typography variant="helperText" color="text.secondary"> 443 <Trans>Variable rate</Trans> 444 </Typography> 445 </Box> 446 </> 447 ); 448 }; 449 450 interface GhoSwitchTargetAsset { 451 ghoApyRange?: [number, number]; 452 asset: SwitchTargetAsset; 453 userBorrowApyAfterMaxSwitch?: number; 454 userDiscountTokenBalance: number; 455 currentMarket: CustomMarket; 456 qualifiesForDiscount: boolean; 457 } 458 459 const GhoSwitchTargetSelectOption = ({ 460 ghoApyRange, 461 asset, 462 userBorrowApyAfterMaxSwitch, 463 userDiscountTokenBalance, 464 currentMarket, 465 qualifiesForDiscount, 466 }: GhoSwitchTargetAsset) => { 467 return ( 468 <> 469 <TokenIcon 470 aToken={asset.aToken} 471 symbol={asset.iconSymbol || asset.symbol} 472 sx={{ fontSize: '22px', mr: 1 }} 473 /> 474 <ListItemText sx={{ mr: 6 }}>{asset.symbol}</ListItemText> 475 <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'end' }}> 476 <GhoIncentivesCard 477 useApyRange={qualifiesForDiscount} 478 rangeValues={ghoApyRange} 479 variant="main14" 480 color="text.secondary" 481 value={userBorrowApyAfterMaxSwitch ?? -1} 482 data-cy={`apyType`} 483 stkAaveBalance={userDiscountTokenBalance} 484 ghoRoute={ROUTES.reserveOverview(asset?.address ?? '', currentMarket) + '/#discount'} 485 forceShowTooltip 486 withTokenIcon={qualifiesForDiscount} 487 userQualifiesForDiscount={qualifiesForDiscount} 488 /> 489 <Typography variant="helperText" color="text.secondary"> 490 <Trans>Fixed rate</Trans> 491 </Typography> 492 </Box> 493 </> 494 ); 495 };