diff --git a/components/OperationsModal/BorrowDateSelector/index.tsx b/components/OperationsModal/BorrowDateSelector/index.tsx index 23593a004..6e95f25a2 100644 --- a/components/OperationsModal/BorrowDateSelector/index.tsx +++ b/components/OperationsModal/BorrowDateSelector/index.tsx @@ -1,15 +1,13 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Box, Typography } from '@mui/material'; - -import DropdownMenu from 'components/DropdownMenu'; -import parseTimestamp from 'utils/parseTimestamp'; - import { useTranslation } from 'react-i18next'; +import parseTimestamp from 'utils/parseTimestamp'; import { useOperationContext } from 'contexts/OperationContext'; +import ModalAlert from 'components/common/modal/ModalAlert'; +import DropdownMenu from 'components/DropdownMenu'; import getHourUTC2Local from 'utils/getHourUTC2Local'; import { track } from 'utils/mixpanel'; - -import ModalAlert from '../../common/modal/ModalAlert'; +import { DAY } from 'utils/utils'; type DateOptionProps = { label: string; @@ -32,6 +30,11 @@ export function DateOption({ label, option = false }: DateOptionProps) { function BorrowDateSelector() { const { t } = useTranslation(); const { date, dates, setDate } = useOperationContext(); + const daysToMaturity = useMemo(() => { + if (!date) return; + const now = BigInt(Math.round(Date.now() / 1000)); + return Number((date - now) / DAY); + }, [date]); const handleChange = useCallback( (maturity: bigint) => { @@ -46,8 +49,6 @@ function BorrowDateSelector() { [date, setDate], ); - const daysToMaturity = Math.floor((Number(date) - Date.now() / 1000) / 60 / 60 / 24); - return ( @@ -62,17 +63,23 @@ function BorrowDateSelector() { renderValue={date ? : null} renderOption={(o: bigint) => } /> - - {t('at {{hour}}', { hour: getHourUTC2Local() })} + + {t('at {{hour}}', { hour: getHourUTC2Local(undefined, 'h a [UTC] Z') })} - {daysToMaturity <= 7 && ( + {daysToMaturity && daysToMaturity <= 7 && ( )} diff --git a/components/OperationsModal/ModalGif/index.tsx b/components/OperationsModal/ModalGif/index.tsx index e1ed6a6f5..c98b69707 100644 --- a/components/OperationsModal/ModalGif/index.tsx +++ b/components/OperationsModal/ModalGif/index.tsx @@ -20,7 +20,7 @@ type Props = { function ModalGif({ tx, tryAgain }: Props) { const { t } = useTranslation(); const translateOperation = useTranslateOperation(); - const { operation, symbol, qty, date } = useOperationContext(); + const { operation, symbol, qty, date, installments } = useOperationContext(); const isLoading = useMemo(() => tx.status === 'processing' || tx.status === 'loading', [tx]); const isSuccess = useMemo(() => tx.status === 'success', [tx]); @@ -78,7 +78,14 @@ function ModalGif({ tx, tryAgain }: Props) { pastAction: translateOperation(operation, { variant: 'past' }), qty, symbol: formatSymbol(symbol), - }) + (reminder && date ? t(' until {{daysLeft}}', { daysLeft: parseTimestamp(date) }) : '')} + }) + + (reminder && date + ? installments > 1 + ? t(' In {{installments}} installments', { + installments, + }) + : t(' until {{daysLeft}}', { daysLeft: parseTimestamp(date) }) + : '')} {isError && t('Something went wrong')} diff --git a/components/Reminder/index.tsx b/components/Reminder/index.tsx index cc9087d56..4e94acb22 100644 --- a/components/Reminder/index.tsx +++ b/components/Reminder/index.tsx @@ -7,6 +7,8 @@ import parseTimestamp from 'utils/parseTimestamp'; import useTranslateOperation from 'hooks/useTranslateOperation'; import type { Operation } from 'types/Operation'; import { track } from 'utils/mixpanel'; +import { MATURITY_DAYS } from 'utils/utils'; +import { useOperationContext } from 'contexts/OperationContext'; type Props = { operation: Operation; @@ -18,6 +20,7 @@ const Reminder: FC = ({ operation, maturity }) => { const translateOperation = useTranslateOperation(); const { palette } = useTheme(); const buttonRef = useRef(null); + const { installments } = useOperationContext(); const isBorrow = useMemo(() => operation?.startsWith('borrow'), [operation]); @@ -33,6 +36,7 @@ const Reminder: FC = ({ operation, maturity }) => { options: ['Google', 'Apple', 'iCal', 'Microsoft365', 'MicrosoftTeams', 'Outlook.com', 'Yahoo'], timeZone: 'UTC', lightMode: palette.mode, + recurrence: `RRULE:FREQ=DAILY;INTERVAL=${MATURITY_DAYS.toString()};COUNT=${installments}`, }; track('Button Clicked', { location: 'Reminder', @@ -42,7 +46,7 @@ const Reminder: FC = ({ operation, maturity }) => { }); if (buttonRef.current) atcb_action(config, buttonRef.current); - }, [maturity, operation, palette.mode, t, translateOperation]); + }, [installments, maturity, operation, palette.mode, t, translateOperation]); return ( { bestFixedBorrow.rate < Number(formatEther(floatingBorrowRate)) ? { maturity: bestFixedBorrow.maturity, rate: bestFixedBorrow.rate } : { maturity: 0n, rate: Number(formatEther(floatingBorrowRate)) }; - const [first, second] = [...fixedPools].sort((a, b) => Number(a.maturity) - Number(b.maturity)); const now = BigInt(Math.round(Date.now() / 1000)); const upcomingMaturity = first.maturity - now < WEEK ? second.maturity : first.maturity; + tempRows.push({ symbol, totalDeposited: formatNumber(formatUnits((totalDeposited * usdPrice) / WEI_PER_ETHER, decimals)), diff --git a/components/operations/BorrowAtMaturity/Installments.tsx b/components/operations/BorrowAtMaturity/Installments.tsx new file mode 100644 index 000000000..2c708475c --- /dev/null +++ b/components/operations/BorrowAtMaturity/Installments.tsx @@ -0,0 +1,57 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOperationContext } from 'contexts/OperationContext'; +import { Box, Typography } from '@mui/material'; +import { MATURITY_DAYS } from 'utils/utils'; +import ModalAlert from 'components/common/modal/ModalAlert'; +import InstallmentsOptions from './InstallmentsOptions'; + +export default function 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: MATURITY_DAYS.toString(), + })}{' '} + + {t('Payment schedule')} + + + } + /> + )} + + ); +} diff --git a/components/operations/BorrowAtMaturity/InstallmentsBreakdown.tsx b/components/operations/BorrowAtMaturity/InstallmentsBreakdown.tsx new file mode 100644 index 000000000..2ecd2a931 --- /dev/null +++ b/components/operations/BorrowAtMaturity/InstallmentsBreakdown.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Box, Button, Skeleton, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useOperationContext } from 'contexts/OperationContext'; +import TableHeadCell from 'components/common/TableHeadCell'; +import parseTimestamp from 'utils/parseTimestamp'; +import useAccountData from 'hooks/useAccountData'; + +export default function InstallmentsBreakdown({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const { installmentsDetails, symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + if (!installmentsDetails || !marketAccount) return ; + + return ( + + + + + + + + + + + {installmentsDetails.installmentsRepayAmount.map((option, index) => ( + + + + {index + 1} + + + + {parseTimestamp(installmentsDetails.maturities[index])} + + + ))} + +
+
+ +
+ ); +} diff --git a/components/operations/BorrowAtMaturity/InstallmentsOptions.tsx b/components/operations/BorrowAtMaturity/InstallmentsOptions.tsx new file mode 100644 index 000000000..5f6b9e2a3 --- /dev/null +++ b/components/operations/BorrowAtMaturity/InstallmentsOptions.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useMemo } from 'react'; +import DropdownMenu from 'components/DropdownMenu'; +import formatNumber from 'utils/formatNumber'; +import { useOperationContext } from 'contexts/OperationContext'; +import useAccountData from 'hooks/useAccountData'; +import { formatUnits } from 'viem'; +import { Box, Skeleton, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +type OptionProps = { + installments: number; + repayAmount: bigint; + option?: boolean; +}; + +export function Option({ installments, repayAmount, option = false }: OptionProps) { + const { symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + + return ( + + {installments} x{' '} + + {repayAmount === 0n ? '' : '~'} + {marketAccount ? formatNumber(formatUnits(repayAmount, marketAccount.decimals)) : } + + + ); +} + +export default function InstallmentsOptions() { + const { t } = useTranslation(); + const { installments, onInstallmentsChange, installmentsOptions, symbol } = useOperationContext(); + const { marketAccount } = useAccountData(symbol); + + const handleChange = useCallback( + (option: { installments: number }) => { + onInstallmentsChange(option.installments); + }, + [onInstallmentsChange], + ); + + const option = useMemo(() => { + if (!installmentsOptions) return; + return installmentsOptions.find((o) => Number(o.installments) === installments); + }, [installments, installmentsOptions]); + + if (!installmentsOptions || !option) return ; + + return ( + + : null} + renderOption={(o: OptionProps) => + ); +} diff --git a/components/operations/BorrowAtMaturity/index.tsx b/components/operations/BorrowAtMaturity/index.tsx index 61c347cd5..3ba67eb3b 100644 --- a/components/operations/BorrowAtMaturity/index.tsx +++ b/components/operations/BorrowAtMaturity/index.tsx @@ -1,23 +1,9 @@ import React, { FC, PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; - import ModalTxCost from 'components/OperationsModal/ModalTxCost'; import ModalGif from 'components/OperationsModal/ModalGif'; - -import { WEEK, toPercentage } from 'utils/utils'; - +import { toPercentage } from 'utils/utils'; import { useOperationContext, usePreviewTx } from 'contexts/OperationContext'; -import { - Box, - Button, - Grid, - Skeleton, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Typography, -} from '@mui/material'; +import { Grid } from '@mui/material'; import { ModalBox, ModalBoxCell, ModalBoxRow } from 'components/common/modal/ModalBox'; import AssetInput from 'components/OperationsModal/AssetInput'; import BorrowDateSelector from 'components/OperationsModal/BorrowDateSelector'; @@ -34,216 +20,20 @@ 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')} - - - } - /> - )} - - ); -}; +import ModalSheet from 'components/common/modal/ModalSheet'; +import useBorrowInInstallments from 'hooks/useBorrowInInstallments'; +import InstallmentsBreakdown from './InstallmentsBreakdown'; +import Installments from './Installments'; const BorrowAtMaturity: FC = ({ children }) => { const { t } = useTranslation(); const translateOperation = useTranslateOperation(); - const { symbol, errorData, setErrorData, qty, gasCost, tx } = useOperationContext(); + const { symbol, errorData, setErrorData, qty, gasCost, tx, installments } = useOperationContext(); const { - isLoading, + isLoading: borrowAtMaturityLoading, onMax, handleInputChange, - handleSubmitAction, + handleSubmitAction: borrowAtMaturity, borrow, updateAPR, rawSlippage, @@ -254,6 +44,10 @@ const BorrowAtMaturity: FC = ({ children }) => { needsApproval, previewGasCost, } = useBorrowAtMaturity(); + + const { handleSubmitAction: borrowInInstallments, isLoading: borrowInInstallmentsLoading } = + useBorrowInInstallments(); + const { marketAccount } = useAccountData(symbol); const container = useRef(null); const breakdownSheetRef = useRef(null); @@ -276,11 +70,17 @@ const BorrowAtMaturity: FC = ({ children }) => { const handleBreakdownSheetClose = useCallback(() => { setBreakdownSheetOpen(false); }, []); + const loading = installments > 1 ? borrowInInstallmentsLoading : borrowAtMaturityLoading || previewIsLoading; if (tx) return ; return ( - + @@ -333,16 +133,16 @@ const BorrowAtMaturity: FC = ({ children }) => { 1 ? borrowInInstallments : borrowAtMaturity} + isLoading={loading} + disabled={!qty || parseFloat(qty) <= 0 || loading || previewIsLoading || errorData?.status} /> diff --git a/components/operations/DepositAtMaturity/index.tsx b/components/operations/DepositAtMaturity/index.tsx index 547da11a1..f541f2f8c 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 BorrowDateSelector from 'components/OperationsModal/DateSelector'; +import DateSelector 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 70eb0cc51..c6359d48e 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 BorrowDateSelector from 'components/OperationsModal/DateSelector'; +import DateSelector 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 0acdf4b06..33236c28c 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 BorrowDateSelector from 'components/OperationsModal/DateSelector'; +import DateSelector 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 1f4e8f3ea..d390683fb 100644 --- a/contexts/OperationContext.tsx +++ b/contexts/OperationContext.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useMemo, + SetStateAction, } from 'react'; import { Address, parseUnits } from 'viem'; @@ -23,7 +24,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'; +import useInstallmentsData from '../hooks/useInstallmentsData'; type LoadingButton = { withCircularProgress?: boolean; label?: string }; @@ -50,7 +51,7 @@ type ContextValues = { date?: bigint; dates: bigint[]; - setDate: (date?: bigint) => void; + setDate: React.Dispatch>; marketContract?: Market; assetContract?: ERC20; @@ -71,8 +72,8 @@ type ContextValues = { installments: number; onInstallmentsChange: (installments: number) => void; - installmentsOptions: ReturnType['installmentsOptions']; - installmentsDetails: ReturnType['installmentsDetails']; + installmentsOptions: ReturnType['installmentsOptions']; + installmentsDetails: ReturnType['installmentsDetails']; }; const OperationContext = createContext(null); @@ -120,12 +121,25 @@ export const OperationContextProvider: FC> = ({ args, c const assetContract = useERC20(marketAccount?.asset); const marketContract = useMarket(marketAccount?.market); const ETHRouterContract = useETHRouter(); - const { installmentsOptions, installmentsDetails } = useInstallments({ + const { installmentsOptions, installmentsDetails } = useInstallmentsData({ qty, date, symbol: marketSymbol, installments, }); + const handleInstallmentsChange = useCallback( + (installments_: number) => { + setInstallments(installments_); + if (!installmentsOptions) return; + setDate(installmentsOptions[installments_ - 1].startingDate); + }, + [installmentsOptions], + ); + + const handleDateChange = useCallback((date_: SetStateAction) => { + setDate(date_); + setInstallments(1); + }, []); const value: ContextValues = { operation, @@ -148,10 +162,7 @@ export const OperationContextProvider: FC> = ({ args, c date, dates, - setDate: (date_?: bigint) => { - setDate(date_); - setInstallments(1); - }, + setDate: handleDateChange, assetContract, marketContract, @@ -168,11 +179,7 @@ export const OperationContextProvider: FC> = ({ args, c receiver, setReceiver, installments, - onInstallmentsChange: (installments_: number) => { - setInstallments(installments_); - if (!installmentsOptions) return; - setDate(installmentsOptions[installments_ - 1].startingDate); - }, + onInstallmentsChange: handleInstallmentsChange, installmentsOptions, installmentsDetails, }; diff --git a/hooks/useBorrowInInstallments.ts b/hooks/useBorrowInInstallments.ts new file mode 100644 index 000000000..e0e579776 --- /dev/null +++ b/hooks/useBorrowInInstallments.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from 'react'; +import { usePublicClient } from 'wagmi'; +import { Address, Hex, pad, trim, zeroAddress } from 'viem'; +import { useOperationContext } from 'contexts/OperationContext'; +import handleOperationError from 'utils/handleOperationError'; +import { installmentsRouterABI, useInstallmentsRouterBorrow, usePrepareInstallmentsRouterBorrow } from 'types/abi'; +import { Transaction } from 'types/Transaction'; +import { WEI_PER_ETHER } from 'utils/const'; +import useWaitForTransaction from 'hooks/useWaitForTransaction'; +import useAccountData from 'hooks/useAccountData'; +import useSignPermit from 'hooks/useSignPermit'; +import useContract from 'hooks/useContract'; +import { useWeb3 } from 'hooks/useWeb3'; +import useMarket from 'hooks/useMarket'; + +type Permit = { + value: bigint; + deadline: bigint; + v: number; + r: Hex; + s: Hex; +}; + +function useMarketPermit(marketSymbol: string) { + const { walletAddress } = useWeb3(); + const publicClient = usePublicClient(); + const { marketAccount } = useAccountData(marketSymbol); + const market = useMarket(marketAccount?.market); + const signPermit = useSignPermit(); + + return useCallback( + async (params: { spender: Address; value: bigint; duration: number }) => { + if (!market || !walletAddress) return; + const implementation = await publicClient.getStorageAt({ + address: market.address, + slot: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc', + }); + if (!implementation) return; + + return signPermit({ + ...params, + verifyingContract: { + ...market, + address: pad(trim(implementation), { size: 20 }), + }, + }); + }, + [market, publicClient, signPermit, walletAddress], + ); +} + +export default function useBorrowInInstallments() { + const { installmentsDetails, marketContract, date, symbol, slippage, setTx, setErrorData } = useOperationContext(); + const { walletAddress, opts, chain } = useWeb3(); + const [permit, setPermit] = useState(); + const [signingPermit, setSigningPermit] = useState(false); + const installmentsRouter = useContract('InstallmentsRouter', installmentsRouterABI); + const marketPermit = useMarketPermit(symbol); + const maxRepay = installmentsDetails ? (installmentsDetails.maxRepay * slippage) / WEI_PER_ETHER : 0n; + + const { config: installmentsBorrowConfig } = usePrepareInstallmentsRouterBorrow( + marketContract && installmentsDetails && date && permit && installmentsRouter + ? ({ + ...opts, + chainId: chain.id, + enabled: true, + address: installmentsRouter.address, + account: walletAddress ?? zeroAddress, + args: [marketContract.address, date, installmentsDetails.installmentsPrincipal, maxRepay, permit], + } as const) + : undefined, + ); + + const { + writeAsync: installmentsBorrowWrite, + data: installmentsBorrowData, + isLoading: installmentsBorrowLoading, + } = useInstallmentsRouterBorrow(installmentsBorrowConfig); + + const { isLoading: waitingInstallmentsBorrow } = useWaitForTransaction({ + hash: installmentsBorrowData?.hash, + onSettled: (tx) => setTx(tx as Transaction), + }); + + const signInstallmentsPermit = useCallback(async () => { + if (!installmentsRouter) return; + setSigningPermit(true); + try { + setPermit( + await marketPermit({ + spender: installmentsRouter.address, + value: maxRepay, + duration: 3_600, // TODO check if correct + }), + ); + } catch (error) { + setErrorData({ + status: true, + message: handleOperationError(error), + }); + } finally { + setSigningPermit(false); + } + }, [installmentsRouter, marketPermit, maxRepay, setErrorData]); + + const borrow = useCallback(async () => { + if (!installmentsBorrowWrite) return; + setPermit(undefined); + try { + const { hash } = await installmentsBorrowWrite(); + if (hash) + setTx({ + status: 'processing', + hash, + }); + } catch (error) { + setErrorData({ + status: true, + message: handleOperationError(error), + }); + } + }, [installmentsBorrowWrite, setErrorData, setTx]); + + useEffect(() => { + if (permit) borrow(); + }, [borrow, installmentsBorrowWrite, permit]); + + return { + handleSubmitAction: signInstallmentsPermit, + isLoading: installmentsBorrowLoading || waitingInstallmentsBorrow || signingPermit, + }; +} diff --git a/hooks/useInstallments.ts b/hooks/useInstallmentsData.ts similarity index 93% rename from hooks/useInstallments.ts rename to hooks/useInstallmentsData.ts index 38bbf3ec5..b375eb2e9 100644 --- a/hooks/useInstallments.ts +++ b/hooks/useInstallmentsData.ts @@ -1,14 +1,13 @@ import { useCallback, useMemo } from 'react'; - -import useAccountData from './useAccountData'; -import useIRM from './useIRM'; -import { fixedUtilization, globalUtilization } from 'utils/interestRateCurve'; +import { parseUnits } from 'viem'; 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'; +import { fixedUtilization, globalUtilization } from 'utils/interestRateCurve'; +import useAccountData from 'hooks/useAccountData'; +import { INTERVAL } from 'utils/utils'; +import useIRM from 'hooks/useIRM'; -export default function useInstallments({ +export default function useInstallmentsData({ qty, date, symbol, @@ -26,13 +25,10 @@ export default function useInstallments({ (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), @@ -49,12 +45,6 @@ export default function useInstallments({ 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, @@ -71,17 +61,13 @@ export default function useInstallments({ 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, @@ -94,7 +80,6 @@ export default function useInstallments({ if (!marketAccount || !date) return undefined; const { decimals } = marketAccount; const amount = parseUnits(qty, decimals); - return getDetails(amount, BigInt(installments), date); }, [date, getDetails, installments, marketAccount, qty]); diff --git a/hooks/useSignPermit.ts b/hooks/useSignPermit.ts new file mode 100644 index 000000000..3359796b9 --- /dev/null +++ b/hooks/useSignPermit.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react'; +import { Address, Hex } from 'viem'; +import { useSignTypedData } from 'wagmi'; +import dayjs from 'dayjs'; +import useContractVersion from 'hooks/useContractVersion'; +import { splitSignature } from '@ethersproject/bytes'; +import { Market } from 'types/contracts'; +import { useWeb3 } from 'hooks/useWeb3'; + +export default function useSignPermit() { + const { signTypedDataAsync } = useSignTypedData(); + const { chain, walletAddress, opts } = useWeb3(); + const contractVersion = useContractVersion(); + + return useCallback( + async ({ + spender, + value, + duration = 3600, // TODO why 1 hour? + verifyingContract, + }: { + spender: Address; + value: bigint; + duration: number; + verifyingContract: Market; + }) => { + if (!verifyingContract || !walletAddress) return; + const nonce = await verifyingContract.read.nonces([walletAddress], opts); + const version = await contractVersion(verifyingContract.address); + const deadline = BigInt(dayjs().unix() + duration); + const signatureHex = await signTypedDataAsync({ + primaryType: 'Permit', + domain: { + name: '', + version, + chainId: chain.id, + verifyingContract: verifyingContract.address, + }, + types: { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + message: { + owner: walletAddress, + spender, + value, + nonce, + deadline, + }, + }); + const { v, r, s } = splitSignature(signatureHex); + + return { + value, + deadline, + v, + r: r as Hex, + s: s as Hex, + }; + }, + [chain.id, contractVersion, opts, signTypedDataAsync, walletAddress], + ); +} diff --git a/i18n/es/translation.json b/i18n/es/translation.json index 77c91c31a..0cad21b9e 100644 --- a/i18n/es/translation.json +++ b/i18n/es/translation.json @@ -26,17 +26,8 @@ "3M": "3M", "Historical Variable Rates": "Tasas Variables Históricas", "Show utilization": "Ver utilización", - "Compare with other yield curves": "Comparar con otras curvas de rendimiento", "Loading data...": "Cargando...", - "ZOOM IN": "AMPLIAR", - "ZOOM OUT": "REDUCIR", - "Utilization Rate (Variable Rate Pool)": "Tasa de Utilización (Pool de Tasa Variable)", "Utilization Rates (Fixed Rate Pools)": "Tasas de Utilización (Pools de Tasa Fija)", - "Utilization": "Utilización", - "DEPOSIT APR": "TNA DEPÓSITO", - "BORROW APR": "TNA PRÉSTAMO", - "Current Yield Curves": "Curvas de Rendimiento Actuales", - "Current Utilization": "Utilización Actual", "Late repayment will result in a penalty daily rate of {{penaltyRate}}": "El retraso en el pago resultará en una tasa de penalización diaria de {{penaltyRate}}", "Completed": "Completado", "{{daysLeft}} left": "{{daysLeft}} restantes", @@ -622,5 +613,20 @@ "Select a network -> check \"Optimism\"": "Selecciona una red -> tocá \"Optimism\"", "Transaction pending, in short you will receive the funds. Remember to have USDT set as payment currency. Do this by going to the \"Card\" tab -> \"Pay with\" and select Tether": "Transacción pendiente, en breve recibirás los fondos. Recordá tener USDT configurado como moneda de pago. Haz esto yendo a la pestaña \"Tarjeta\" -> \"Pagar con\" y selecciona Tether", "The Total Utilization is above 90%, and the remaining liquidity is established as a Liquidity Reserve that can't be borrowed and is only available for withdrawals.": "La Utilización Total está por encima del 90%, y la liquidez restante se establece como una Reserva de Liquidez que no se puede pedir prestada y solo está disponible para retiros.", - "The upgrade to the new Interest Rate Model (EXAIP-08) is scheduled for execution today at 8:30 PM UTC": "La actualización al nuevo Modelo de Tasa de Interés (EXAIP-08) está programada para ejecutarse hoy a las 8:30 PM UTC" + "The upgrade to the new Interest Rate Model (EXAIP-08) is scheduled for execution today at 8:30 PM UTC": "La actualización al nuevo Modelo de Tasa de Interés (EXAIP-08) está programada para ejecutarse hoy a las 8:30 PM UTC", + "Term Structure of Interest Rates": "Estructura de Plazos de Tasas de Interés", + "Rate": "Tasa", + "Min": "Mín", + "Variable APR, Utilization and Global Utilization": "TNA Variable, Utilización y Utilización Global", + "New!": "Nuevo!", + "Installments": "Cuotas", + "Each installment is due every {{interval}} days.": "Cada cuota vence cada {{interval}} días.", + "Payment schedule": "Calendario de pagos", + "First Payment Due Date": "Fecha de Vencimiento del Primer Pago", + "at {{hour}}": "a las {{hour}}", + "Dues in 1 day.": "Vence en 1 día.", + "Dues in {{daysToMaturity}} days.": "Vence en {{daysToMaturity}} días.", + "For optimal benefits, consider selecting a pool with a longer remaining duration.": "Para obtener beneficios óptimos, considera seleccionar un pool con una duración restante más larga.", + "Total": "Total", + " In {{installments}} installments": "En {{installments}} cuotas" } diff --git a/utils/getHourUTC2Local.ts b/utils/getHourUTC2Local.ts index 8eebec2af..af61069aa 100644 --- a/utils/getHourUTC2Local.ts +++ b/utils/getHourUTC2Local.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; -export default function getHourUTC2Local(utcDateTime = '1970-01-01T00:00:00Z'): string { +export default function getHourUTC2Local(utcDateTime = '1970-01-01T00:00:00Z', template = 'HH:mm'): string { const utcDate = dayjs(utcDateTime); - return utcDate.format('HH:mm'); + return utcDate.format(template); } diff --git a/utils/utils.tsx b/utils/utils.tsx index af73d1c39..d44bdc6d8 100644 --- a/utils/utils.tsx +++ b/utils/utils.tsx @@ -1,8 +1,10 @@ import { WAD } from './queryRates'; const YEAR_IN_SECONDS = 60n * 60n * 24n * 365n; -export const WEEK = 60n * 60n * 24n * 7n; -export const INTERVAL = 4n * WEEK; +export const DAY = 60n * 60n * 24n; +export const WEEK = DAY * 7n; +export const MATURITY_DAYS = 28n; +export const INTERVAL = MATURITY_DAYS * DAY; import { Hex } from 'viem'; diff --git a/wagmi.config.ts b/wagmi.config.ts index ea89d4107..1372433b3 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -20,10 +20,10 @@ import InterestRateModel from '@exactly/protocol/deployments/op-sepolia/Interest import ExtraFinanceLendingABI from './abi/extraFinanceLending.json' assert { type: 'json' }; import DelegateRegistryABI from './abi/DelegateRegistry.json' assert { type: 'json' }; import GasPriceOracle from './abi/GasPriceOracle.json' assert { type: 'json' }; - import EscrowedEXA from '@exactly/protocol/deployments/optimism/esEXA.json' assert { type: 'json' }; import SablierV2LockupLinear from '@exactly/protocol/deployments/optimism/SablierV2LockupLinear.json' assert { type: 'json' }; import SablierV2NFTDescriptor from '@exactly/protocol/deployments/optimism/SablierV2NFTDescriptor.json' assert { type: 'json' }; +import InstallmentsRouter from '@exactly/protocol/deployments/op-sepolia/InstallmentsRouter.json' assert { type: 'json' }; import { Abi } from 'viem'; @@ -52,6 +52,7 @@ export default defineConfig({ { name: 'DelegateRegistry', abi: DelegateRegistryABI as Abi }, { name: 'EscrowedEXA', abi: EscrowedEXA.abi as Abi }, { name: 'L1GasPriceOracle', abi: GasPriceOracle as Abi }, + { name: 'InstallmentsRouter', abi: InstallmentsRouter.abi as Abi }, ], plugins: [ react({