/ src / components / transactions / DebtSwitch / DebtSwitchModalContent.tsx
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&apos;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  };