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) => }
+ />
+ {option ? (
+
+ ~${formatNumber(usd || '0', 'USD')}
+ {t('/mo')}
+
+ ) : null}
+
+ );
+};
+
+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 };
+}