diff --git a/components/OperationsModal/BorrowDateSelector/index.tsx b/components/OperationsModal/BorrowDateSelector/index.tsx new file mode 100644 index 000000000..23593a004 --- /dev/null +++ b/components/OperationsModal/BorrowDateSelector/index.tsx @@ -0,0 +1,83 @@ +import React, { useCallback } from 'react'; +import { Box, Typography } from '@mui/material'; + +import DropdownMenu from 'components/DropdownMenu'; +import parseTimestamp from 'utils/parseTimestamp'; + +import { useTranslation } from 'react-i18next'; +import { useOperationContext } from 'contexts/OperationContext'; +import getHourUTC2Local from 'utils/getHourUTC2Local'; +import { track } from 'utils/mixpanel'; + +import ModalAlert from '../../common/modal/ModalAlert'; + +type DateOptionProps = { + label: string; + option?: boolean; +}; + +export function DateOption({ label, option = false }: DateOptionProps) { + return ( + + {label} + + ); +} + +function BorrowDateSelector() { + const { t } = useTranslation(); + const { date, dates, setDate } = useOperationContext(); + + const handleChange = useCallback( + (maturity: bigint) => { + setDate(maturity); + track('Option Selected', { + location: 'Operations Modal', + name: 'maturity', + value: String(maturity), + prevValue: String(date), + }); + }, + [date, setDate], + ); + + const daysToMaturity = Math.floor((Number(date) - Date.now() / 1000) / 60 / 60 / 24); + + return ( + + + + {t('First Payment Due Date')} + + + : null} + renderOption={(o: bigint) => } + /> + + {t('at {{hour}}', { hour: getHourUTC2Local() })} + + + + {daysToMaturity <= 7 && ( + + )} + + ); +} + +export default React.memo(BorrowDateSelector); diff --git a/components/common/modal/ModalAlert/index.tsx b/components/common/modal/ModalAlert/index.tsx index e39d55278..d068c02cc 100644 --- a/components/common/modal/ModalAlert/index.tsx +++ b/components/common/modal/ModalAlert/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { Box, SxProps, Typography } from '@mui/material'; import InfoIcon from '@mui/icons-material/InfoRounded'; import WarningIcon from '@mui/icons-material/ErrorRounded'; @@ -11,7 +11,7 @@ type Variant = 'info' | 'warning' | 'error' | 'success'; type Props = { variant?: Variant; - message: string; + message: ReactNode; mb?: number; }; diff --git a/components/operations/BorrowAtMaturity/index.tsx b/components/operations/BorrowAtMaturity/index.tsx index 8ad98b71b..61c347cd5 100644 --- a/components/operations/BorrowAtMaturity/index.tsx +++ b/components/operations/BorrowAtMaturity/index.tsx @@ -1,20 +1,30 @@ -import React, { FC, PropsWithChildren, useEffect } from 'react'; +import React, { FC, PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; import ModalTxCost from 'components/OperationsModal/ModalTxCost'; import ModalGif from 'components/OperationsModal/ModalGif'; -import { toPercentage } from 'utils/utils'; +import { WEEK, toPercentage } from 'utils/utils'; import { useOperationContext, usePreviewTx } from 'contexts/OperationContext'; -import { Grid } from '@mui/material'; +import { + Box, + Button, + Grid, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; import { ModalBox, ModalBoxCell, ModalBoxRow } from 'components/common/modal/ModalBox'; import AssetInput from 'components/OperationsModal/AssetInput'; -import DateSelector from 'components/OperationsModal/DateSelector'; +import BorrowDateSelector from 'components/OperationsModal/BorrowDateSelector'; import ModalInfoAPR from 'components/OperationsModal/Info/ModalInfoAPR'; import ModalInfoHealthFactor from 'components/OperationsModal/Info/ModalInfoHealthFactor'; import ModalInfoFixedUtilizationRate from 'components/OperationsModal/Info/ModalInfoFixedUtilizationRate'; import ModalAdvancedSettings from 'components/common/modal/ModalAdvancedSettings'; -import ModalInfoBorrowLimit from 'components/OperationsModal/Info/ModalInfoBorrowLimit'; import ModalInfoEditableSlippage from 'components/OperationsModal/Info/ModalInfoEditableSlippage'; import ModalAlert from 'components/common/modal/ModalAlert'; import ModalSubmit from 'components/OperationsModal/ModalSubmit'; @@ -24,6 +34,206 @@ import ModalRewards from 'components/OperationsModal/ModalRewards'; import ModalPenaltyRate from 'components/OperationsModal/ModalPenaltyRate'; import { useTranslation } from 'react-i18next'; import useTranslateOperation from 'hooks/useTranslateOperation'; +import DropdownMenu from '../../DropdownMenu'; +import ModalSheet from '../../common/modal/ModalSheet'; +import TableHeadCell from '../../common/TableHeadCell'; +import { formatUnits } from 'viem'; +import parseTimestamp from '../../../utils/parseTimestamp'; +import usePreviewFixedOperation from '../../../hooks/usePreviewFixedOperation'; +import formatNumber from '../../../utils/formatNumber'; +import Image from 'next/image'; +import formatSymbol from '../../../utils/formatSymbol'; +import { WEI_PER_ETHER } from '../../../utils/const'; + +type OptionProps = { + installments: number; + repayAmount: bigint; + option?: boolean; +}; + +const InstallmentsBreakdown = ({ onClose }: { onClose: () => void }) => { + const { t } = useTranslation(); + const { installmentsDetails, symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + const { options: fixedOptions } = usePreviewFixedOperation('borrow'); + + if (!installmentsDetails || !marketAccount) return ; + + return ( + + + + + + + + + + + + + {installmentsDetails.installmentsRepayAmount.map((option, index) => { + const maturity = installmentsDetails.maturities[index]; + const apr = fixedOptions.find((o) => o.maturity === maturity)?.borrowAPR; + return ( + + {index + 1} + + {parseTimestamp(maturity)} + + + + {formatNumber(formatUnits(option, marketAccount.decimals))}{' '} + {symbol && ( + + )} + + + + {apr ? toPercentage(apr) : } + + + ); + })} + +
+
+ +
+ ); +}; + +export function Option({ installments, repayAmount, option = false }: OptionProps) { + const { t } = useTranslation(); + const { symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + + return ( + + {installments} x{' '} + + {repayAmount === 0n ? '' : '~'} + {marketAccount ? formatNumber(formatUnits(repayAmount, marketAccount.decimals)) : } + {installments > 1 ? t('/mo') : ''} + + + ); +} + +const MaturitiesDropdown = () => { + const { t } = useTranslation(); + const { installments, onInstallmentsChange, installmentsOptions, symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + + const handleChange = useCallback( + (option: { installments: number }) => { + onInstallmentsChange(option.installments); + }, + [onInstallmentsChange], + ); + if (!installmentsOptions) return ; + + const option = installmentsOptions.find((o) => Number(o.installments) === installments); + + const usd = + option && marketAccount + ? (option?.repayAmount * marketAccount.usdPrice) / (WEI_PER_ETHER * 10n ** BigInt(marketAccount.decimals)) + : 0n; + + return ( + + : null} + renderOption={(o: OptionProps) => + ); +}; + +const Installments = ({ setBreakdownSheetOpen }: { setBreakdownSheetOpen: (open: boolean) => void }) => { + const { t } = useTranslation(); + + const { installments } = useOperationContext(); + + const viewBreakdown = useCallback(() => { + setBreakdownSheetOpen(true); + }, [setBreakdownSheetOpen]); + + return ( + + + + palette.green, + borderRadius: '4px', + px: 0.5, + textTransform: 'uppercase', + }} + > + {t('New!')} + + + {t('Installments')} + + + + + + {installments >= 2 && ( + + {t('Each installment is due every {{interval}} days.', { + interval: ((4n * WEEK) / (60n * 60n * 24n)).toString(), + })}{' '} + + {t('View breakdown')} + + + } + /> + )} + + ); +}; const BorrowAtMaturity: FC = ({ children }) => { const { t } = useTranslation(); @@ -45,6 +255,9 @@ const BorrowAtMaturity: FC = ({ children }) => { previewGasCost, } = useBorrowAtMaturity(); const { marketAccount } = useAccountData(symbol); + const container = useRef(null); + const breakdownSheetRef = useRef(null); + const [breakdownSheetOpen, setBreakdownSheetOpen] = useState(false); useEffect(() => void updateAPR(), [updateAPR]); useEffect(() => { @@ -60,6 +273,9 @@ const BorrowAtMaturity: FC = ({ children }) => { }, [hasCollateral, setErrorData, t]); const { isLoading: previewIsLoading } = usePreviewTx({ qty, needsApproval, previewGasCost }); + const handleBreakdownSheetClose = useCallback(() => { + setBreakdownSheetOpen(false); + }, []); if (tx) return ; @@ -80,19 +296,17 @@ const BorrowAtMaturity: FC = ({ children }) => { /> - - - - - - + + + + - + @@ -124,6 +338,15 @@ const BorrowAtMaturity: FC = ({ children }) => { disabled={!qty || parseFloat(qty) <= 0 || isLoading || previewIsLoading || errorData?.status} /> + + + ); }; diff --git a/components/operations/DepositAtMaturity/index.tsx b/components/operations/DepositAtMaturity/index.tsx index f541f2f8c..547da11a1 100644 --- a/components/operations/DepositAtMaturity/index.tsx +++ b/components/operations/DepositAtMaturity/index.tsx @@ -15,7 +15,7 @@ import ModalAdvancedSettings from 'components/common/modal/ModalAdvancedSettings import ModalInfoTotalDeposits from 'components/OperationsModal/Info/ModalInfoTotalDeposits'; import ModalAlert from 'components/common/modal/ModalAlert'; import ModalSubmit from 'components/OperationsModal/ModalSubmit'; -import DateSelector from 'components/OperationsModal/DateSelector'; +import BorrowDateSelector from 'components/OperationsModal/DateSelector'; import ModalInfoAPR from 'components/OperationsModal/Info/ModalInfoAPR'; import ModalInfoFixedUtilizationRate from 'components/OperationsModal/Info/ModalInfoFixedUtilizationRate'; import ModalInfo from 'components/common/modal/ModalInfo'; @@ -73,7 +73,7 @@ const DepositAtMaturity: FC = () => { - + diff --git a/components/operations/RepayAtMaturity/index.tsx b/components/operations/RepayAtMaturity/index.tsx index c6359d48e..70eb0cc51 100644 --- a/components/operations/RepayAtMaturity/index.tsx +++ b/components/operations/RepayAtMaturity/index.tsx @@ -14,7 +14,7 @@ import useAccountData from 'hooks/useAccountData'; import { Grid } from '@mui/material'; import { ModalBox, ModalBoxCell, ModalBoxRow } from 'components/common/modal/ModalBox'; import AssetInput from 'components/OperationsModal/AssetInput'; -import DateSelector from 'components/OperationsModal/DateSelector'; +import BorrowDateSelector from 'components/OperationsModal/DateSelector'; import ModalInfoMaturityStatus from 'components/OperationsModal/Info/ModalInfoMaturityStatus'; import ModalInfoAmount from 'components/OperationsModal/Info/ModalInfoAmount'; import ModalInfoHealthFactor from 'components/OperationsModal/Info/ModalInfoHealthFactor'; @@ -338,7 +338,7 @@ const RepayAtMaturity: FC = () => { - + {date !== undefined && } diff --git a/components/operations/WithdrawAtMaturity/index.tsx b/components/operations/WithdrawAtMaturity/index.tsx index 33236c28c..0acdf4b06 100644 --- a/components/operations/WithdrawAtMaturity/index.tsx +++ b/components/operations/WithdrawAtMaturity/index.tsx @@ -12,7 +12,7 @@ import useAccountData from 'hooks/useAccountData'; import { Grid } from '@mui/material'; import { ModalBox, ModalBoxCell, ModalBoxRow } from 'components/common/modal/ModalBox'; import AssetInput from 'components/OperationsModal/AssetInput'; -import DateSelector from 'components/OperationsModal/DateSelector'; +import BorrowDateSelector from 'components/OperationsModal/DateSelector'; import ModalInfoHealthFactor from 'components/OperationsModal/Info/ModalInfoHealthFactor'; import ModalInfoFixedUtilizationRate from 'components/OperationsModal/Info/ModalInfoFixedUtilizationRate'; import ModalAdvancedSettings from 'components/common/modal/ModalAdvancedSettings'; @@ -274,7 +274,7 @@ const WithdrawAtMaturity: FC = () => { - + {date !== undefined && } diff --git a/contexts/OperationContext.tsx b/contexts/OperationContext.tsx index 3218a4320..1f4e8f3ea 100644 --- a/contexts/OperationContext.tsx +++ b/contexts/OperationContext.tsx @@ -23,6 +23,7 @@ import { Transaction } from 'types/Transaction'; import numbers from 'config/numbers.json'; import type { Operation } from 'types/Operation'; import { Args } from './ModalContext'; +import useInstallments from '../hooks/useInstallments'; type LoadingButton = { withCircularProgress?: boolean; label?: string }; @@ -49,7 +50,7 @@ type ContextValues = { date?: bigint; dates: bigint[]; - setDate: React.Dispatch>; + setDate: (date?: bigint) => void; marketContract?: Market; assetContract?: ERC20; @@ -66,6 +67,12 @@ type ContextValues = { receiver?: Address; setReceiver: React.Dispatch>; + + installments: number; + onInstallmentsChange: (installments: number) => void; + + installmentsOptions: ReturnType['installmentsOptions']; + installmentsDetails: ReturnType['installmentsDetails']; }; const OperationContext = createContext(null); @@ -102,6 +109,7 @@ export const OperationContextProvider: FC> = ({ args, c const [requiresApproval, setRequiresApproval] = useState(false); const [rawSlippage, setRawSlippage] = useState(DEFAULT_SLIPPAGE); const [receiver, setReceiver] = useState
(); + const [installments, setInstallments] = useState(1); const slippage = useMemo(() => { return ['deposit', 'depositAtMaturity', 'withdraw', 'withdrawAtMaturity'].includes(operation) @@ -112,6 +120,12 @@ export const OperationContextProvider: FC> = ({ args, c const assetContract = useERC20(marketAccount?.asset); const marketContract = useMarket(marketAccount?.market); const ETHRouterContract = useETHRouter(); + const { installmentsOptions, installmentsDetails } = useInstallments({ + qty, + date, + symbol: marketSymbol, + installments, + }); const value: ContextValues = { operation, @@ -134,7 +148,10 @@ export const OperationContextProvider: FC> = ({ args, c date, dates, - setDate, + setDate: (date_?: bigint) => { + setDate(date_); + setInstallments(1); + }, assetContract, marketContract, @@ -150,6 +167,14 @@ export const OperationContextProvider: FC> = ({ args, c setErrorButton, receiver, setReceiver, + installments, + onInstallmentsChange: (installments_: number) => { + setInstallments(installments_); + if (!installmentsOptions) return; + setDate(installmentsOptions[installments_ - 1].startingDate); + }, + installmentsOptions, + installmentsDetails, }; return {children}; diff --git a/hooks/useInstallments.ts b/hooks/useInstallments.ts new file mode 100644 index 000000000..38bbf3ec5 --- /dev/null +++ b/hooks/useInstallments.ts @@ -0,0 +1,102 @@ +import { useCallback, useMemo } from 'react'; + +import useAccountData from './useAccountData'; +import useIRM from './useIRM'; +import { fixedUtilization, globalUtilization } from 'utils/interestRateCurve'; +import split from '@exactly/lib/esm/installments/split'; +import fromAmounts from '@exactly/lib/esm/installments/fromAmounts'; +import { parseUnits } from 'viem'; +import { INTERVAL } from '../utils/utils'; + +export default function useInstallments({ + qty, + date, + symbol, + installments, +}: { + qty: string; + date?: bigint; + symbol: string; + installments: number; +}) { + const { marketAccount } = useAccountData(symbol); + const irmParameters = useIRM(symbol); + + const getDetails = useCallback( + (amount: bigint, installments_: bigint, firstMaturity: bigint) => { + if (amount === 0n || irmParameters === undefined || marketAccount === undefined) return; + const { floatingUtilization, floatingAssets, floatingDebt, floatingBackupBorrowed, fixedPools } = marketAccount; + + const fixedPoolsUtilizations = fixedPools + .filter(({ maturity }) => maturity >= firstMaturity && maturity < firstMaturity + installments_ * INTERVAL) + .map(({ supplied, borrowed }) => fixedUtilization(supplied, borrowed, floatingAssets)); + + const timestamp = Math.round(Date.now() / 1000); + + const parameters = [ + floatingAssets, + Number(firstMaturity), + Number(installments_), + fixedPoolsUtilizations, + floatingUtilization, + globalUtilization(floatingAssets, floatingDebt, floatingBackupBorrowed), + irmParameters, + timestamp, + ] as const; + const installmentsPrincipal = split(amount, ...parameters); + const installmentsRepayAmount = fromAmounts(installmentsPrincipal, ...parameters); + const totalPrincipal = installmentsPrincipal.reduce((acc, val) => acc + val, 0n); + const maxRepay = installmentsRepayAmount.reduce((acc, val) => acc + val, 0n); + const averageRepay = maxRepay / installments_; + const maturities = installmentsPrincipal.map((_, index) => firstMaturity + BigInt(index) * INTERVAL); + + console.log({ + installments_, + installmentsRepayAmount, + }); + + return { + installmentsPrincipal, + maturities, + installmentsRepayAmount, + totalPrincipal, + maxRepay, + averageRepay, + }; + }, + [irmParameters, marketAccount], + ); + + const installmentsOptions = useMemo(() => { + if (!marketAccount || !date) return undefined; + const { decimals, maxFuturePools, fixedPools } = marketAccount; + const amount = parseUnits(qty, decimals); + + const lastPool = fixedPools.map(({ maturity }) => maturity).sort((a, b) => (a > b ? -1 : 1))[0]; + + return new Array(maxFuturePools).fill(0).map((_, index) => { + const installmentsCount = BigInt(index + 1); + + const endDate = date + (installmentsCount - 1n) * INTERVAL; + const last = endDate > lastPool ? lastPool : endDate; + const startingDate = last - (installmentsCount - 1n) * INTERVAL; + const details = getDetails(amount, installmentsCount, startingDate); + + return { + installments: Number(installmentsCount), + repayAmount: amount === 0n || details === undefined ? 0n : details.averageRepay, + startingDate, + }; + }); + }, [date, marketAccount, qty, getDetails]); + + const installmentsDetails = useMemo(() => { + if (!marketAccount || !date) return undefined; + const { decimals } = marketAccount; + const amount = parseUnits(qty, decimals); + + return getDetails(amount, BigInt(installments), date); + }, [date, getDetails, installments, marketAccount, qty]); + + return { installmentsOptions, installmentsDetails }; +}