diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index 30a2abf58..c6314d2da 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -1,3 +1,5 @@ +REACT_APP_RATES_HISTORY_API_URL=https://bob-mm-cache.test.sovryn.app/data/rates-history + REACT_APP_GRAPH_RSK=https://subgraph.test.sovryn.app/subgraphs/name/DistributedCollective/sovryn-subgraph REACT_APP_GRAPH_BOB=https://bob-ambient-subgraph.test.sovryn.app/subgraphs/name/DistributedCollective/bob-ambient-subgraph @@ -23,4 +25,6 @@ REACT_APP_ENABLE_SERVICE_WORKER=false REACT_APP_SIMULATE_TX=false REACT_APP_ESTIMATOR_URI=https://simulator.sovryn.app -REACT_APP_DATADOG_CLIENT_TOKEN= \ No newline at end of file +REACT_APP_DATADOG_CLIENT_TOKEN= + +REACT_APP_GRAPH_BOB_AAVE=https://bob-mm.test.sovryn.app/subgraphs/name/DistributedCollective/sov-protocol-subgraphs diff --git a/apps/frontend/README.md b/apps/frontend/README.md index a3358b480..2b0cc4038 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -15,3 +15,4 @@ yarn --cwd apps/frontend serve To use custom environment variables locally, create a file named `.env.local` and add required values (see `.env.example` for sample variables and values). For environment variables used in deployments see `netlify.toml`. + diff --git a/apps/frontend/codegen.fetch.yml b/apps/frontend/codegen.fetch.yml index 4794780f2..37de3f1af 100644 --- a/apps/frontend/codegen.fetch.yml +++ b/apps/frontend/codegen.fetch.yml @@ -19,6 +19,11 @@ generates: - ${REACT_APP_GRAPH_BOB} plugins: - schema-ast + ./src/utils/graphql/bobAave/schema.graphql: + schema: + - ${REACT_APP_GRAPH_BOB_AAVE} + plugins: + - schema-ast hooks: afterAllFileWrite: - prettier ./src/utils/graphql/**/schema.graphql --write diff --git a/apps/frontend/codegen.yml b/apps/frontend/codegen.yml index b94e4edd0..203ea5f22 100644 --- a/apps/frontend/codegen.yml +++ b/apps/frontend/codegen.yml @@ -29,6 +29,21 @@ generates: Bytes: string BigInt: string BigDecimal: string + ./src/utils/graphql/bobAave/generated.tsx: + schema: + - './src/utils/graphql/bobAave/schema.graphql' + documents: + - './src/utils/graphql/bobAave/operations/*.graphql' + plugins: + - typescript + - typescript-operations + - typescript-react-apollo + config: + withHooks: true + scalars: + Bytes: string + BigInt: string + BigDecimal: string ./src/utils/graphql/zero/generated.tsx: schema: - './src/utils/graphql/zero/schema.graphql' diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3d148dddc..7faf3f664 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -4,6 +4,8 @@ "homepage": ".", "private": true, "dependencies": { + "@aave/contract-helpers": "1.29.1", + "@aave/math-utils": "1.29.1", "@apollo/client": "3.7.1", "@apollo/react-hooks": "4.0.0", "@loadable/component": "5.15.2", @@ -28,6 +30,7 @@ "@uniswap/permit2-sdk": "1.2.0", "bitcoin-address-validation": "2.2.1", "chart.js": "4.1.1", + "chartjs-adapter-date-fns": "3.0.0", "classnames": "2.3.2", "date-fns": "2.30.0", "dayjs": "1.11.7", @@ -54,6 +57,7 @@ "react-slider": "2.0.6", "react-timer-hook": "3.0.7", "reactjs-localstorage": "1.0.1", + "reflect-metadata": "0.2.2", "remark-gfm": "3.0.1", "rxjs": "7.5.6", "sanitize-html": "2.11.0", diff --git a/apps/frontend/src/app/1_atoms/Icons/Icons.tsx b/apps/frontend/src/app/1_atoms/Icons/Icons.tsx new file mode 100644 index 000000000..fae2b2880 --- /dev/null +++ b/apps/frontend/src/app/1_atoms/Icons/Icons.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { IconType } from '@sovryn/ui'; + +export const WalletIcon: IconType = ( + + + + +); + +export const EModeIcon: IconType = ( + + + +); diff --git a/apps/frontend/src/app/2_molecules/AavePoolRowTitle/AavePoolRowTitle.tsx b/apps/frontend/src/app/2_molecules/AavePoolRowTitle/AavePoolRowTitle.tsx new file mode 100644 index 000000000..23ce59e97 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/AavePoolRowTitle/AavePoolRowTitle.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; + +import { BOB_CHAIN_ID } from '../../../config/chains'; + +import { + AmountRenderer, + AmountRendererProps, +} from '../AmountRenderer/AmountRenderer'; +import { AssetRenderer } from '../AssetRenderer/AssetRenderer'; + +type AaveRowTitleProps = AmountRendererProps & { + isOpen?: boolean; + asset: string; + label?: string; +}; + +export const AaveRowTitle: FC = ({ + asset, + label, + isOpen = false, + ...props +}) => ( +
+ + {!isOpen && ( +
+ + {label && ( + + {label} + + )} +
+ )} +
+); diff --git a/apps/frontend/src/app/2_molecules/AmountRenderer/AmountRenderer.tsx b/apps/frontend/src/app/2_molecules/AmountRenderer/AmountRenderer.tsx index 6350188fd..b8d3cb01b 100644 --- a/apps/frontend/src/app/2_molecules/AmountRenderer/AmountRenderer.tsx +++ b/apps/frontend/src/app/2_molecules/AmountRenderer/AmountRenderer.tsx @@ -29,7 +29,7 @@ import { const { decimal, thousand } = getLocaleSeparators(); -type AmountRendererProps = { +export type AmountRendererProps = { value: Decimalish; precision?: number; className?: string; @@ -42,6 +42,7 @@ type AmountRendererProps = { trigger?: TooltipTrigger; decimals?: number; asIf?: boolean; + infiniteFrom?: Decimalish; }; export const AmountRenderer: FC = ({ @@ -57,6 +58,7 @@ export const AmountRenderer: FC = ({ trigger = TooltipTrigger.click, decimals = 18, asIf, + infiniteFrom, }) => { const adjustedValue = useMemo( () => @@ -129,6 +131,11 @@ export const AmountRenderer: FC = ({ [adjustedValue, calculatedPrecision, precision], ); + const isInfinite = useMemo( + () => !!(infiniteFrom && Decimal.from(value).gt(infiniteFrom)), + [infiniteFrom, value], + ); + return ( = ({ } - className={classNames({ - 'cursor-pointer': shouldShowTooltip, - })} - disabled={!shouldShowTooltip} + className={classNames({ 'cursor-pointer': shouldShowTooltip }, className)} + disabled={!shouldShowTooltip || isInfinite} trigger={trigger} dataAttribute={dataAttribute} > - - {isAnimated ? ( - - ) : ( - <> - {shouldShowRoundingPrefix ? '~ ' : ''} - {prefix} - {localeFormattedValue} {suffix} - - )} - + {isAnimated ? ( + + ) : isInfinite ? ( + + ) : ( + + {shouldShowRoundingPrefix ? '~ ' : ''} + {prefix} + {localeFormattedValue} {suffix} + + )} ); }; diff --git a/apps/frontend/src/app/2_molecules/AmountTransition/AmountTransition.tsx b/apps/frontend/src/app/2_molecules/AmountTransition/AmountTransition.tsx new file mode 100644 index 000000000..643c56722 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/AmountTransition/AmountTransition.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; + +import classNames from 'classnames'; + +import { Icon, IconNames } from '@sovryn/ui'; + +import { + AmountRenderer, + AmountRendererProps, +} from '../AmountRenderer/AmountRenderer'; + +type AmountTransitionProps = { + className?: string; + to: AmountRendererProps; + from: AmountRendererProps; +}; + +export const AmountTransition: FC = ({ + from, + to, + className, +}) => ( +
+ + + +
+); diff --git a/apps/frontend/src/app/2_molecules/AssetAmountInput/AssetAmountInput.tsx b/apps/frontend/src/app/2_molecules/AssetAmountInput/AssetAmountInput.tsx new file mode 100644 index 000000000..de60bb63b --- /dev/null +++ b/apps/frontend/src/app/2_molecules/AssetAmountInput/AssetAmountInput.tsx @@ -0,0 +1,115 @@ +import React, { FC, useCallback } from 'react'; + +import { AmountInput, Paragraph, Select, SelectOption } from '@sovryn/ui'; +import { Decimal, Decimalish } from '@sovryn/utils'; + +import { AmountRenderer } from '../AmountRenderer/AmountRenderer'; +import { AssetRenderer } from '../AssetRenderer/AssetRenderer'; +import { MaxButton } from '../MaxButton/MaxButton'; + +type AssetAmountInputProps = { + label?: string; + chainId?: string | undefined; + maxAmount?: Decimalish; + invalid?: boolean; + amountLabel?: string; + amountValue?: string | number; + onAmountChange?: (value: string) => void; + onMaxClicked?: (value: boolean) => void; + assetValue: string; + assetUsdValue?: Decimalish; + assetOptions: SelectOption[]; + onAssetChange: (asset: string) => void; +}; + +export const AssetAmountInput: FC = ({ + label, + chainId, + maxAmount, + amountLabel, + amountValue, + onAmountChange, + onMaxClicked, + invalid, + assetValue, + assetUsdValue, + assetOptions, + onAssetChange, +}) => { + const assetOptionRenderer = useCallback( + ({ value }) => ( + + ), + [chainId], + ); + + const handleMaxClick = useCallback(() => { + if (maxAmount) { + onMaxClicked?.(true); + onAmountChange?.(Decimal.from(maxAmount).toString()); + } + }, [maxAmount, onAmountChange, onMaxClicked]); + + const handleChangeAmount = useCallback( + (value: string) => { + onAmountChange?.(value); + onMaxClicked?.(false); + }, + [onAmountChange, onMaxClicked], + ); + + return ( +
+ {label && ( + + {label} + + )} + +
+ {maxAmount !== undefined && ( + + + + )} + +
+
+ + +
+ + + ); +}; diff --git a/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficencyModeCard.utils.ts b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficencyModeCard.utils.ts new file mode 100644 index 000000000..9ee152a6b --- /dev/null +++ b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficencyModeCard.utils.ts @@ -0,0 +1,59 @@ +import { ReservesDataHumanized } from '@aave/contract-helpers'; +import { formatReserves, formatUserSummary } from '@aave/math-utils'; + +import { Decimal } from '@sovryn/utils'; + +import { UserReservesData } from '../../../../../../../types/aave'; +import { AaveCalculations } from '../../../../../../../utils/aave/AaveCalculations'; + +export const normalizeEModeSummary = ( + eModeCategoryId: number, + reservesData?: ReservesDataHumanized, + userReservesData?: UserReservesData, + timestamp?: number, +): { + ltv: Decimal; + collateralRatio: Decimal; + liquidationRisk: boolean; +} => { + if (!reservesData || !userReservesData || !timestamp) { + return { + ltv: Decimal.from(0), + collateralRatio: Decimal.from(0), + liquidationRisk: false, + }; + } + + const { + marketReferenceCurrencyDecimals, + marketReferenceCurrencyPriceInUsd: marketReferencePriceInUsd, + } = reservesData.baseCurrencyData; + const summary = formatUserSummary({ + userEmodeCategoryId: eModeCategoryId, + currentTimestamp: timestamp, + marketReferencePriceInUsd, + marketReferenceCurrencyDecimals, + userReserves: userReservesData.userReserves, + formattedReserves: formatReserves({ + currentTimestamp: timestamp, + marketReferencePriceInUsd, + marketReferenceCurrencyDecimals, + reserves: reservesData.reservesData, + }), + }); + + // if health factor is below 1 we're at risk. Negative means doesn't apply + const healthFactor = Decimal.from(summary.healthFactor); + const liquidationRisk = healthFactor.lte(1) && healthFactor.gt(0); + + const collateralRatio = AaveCalculations.computeCollateralRatio( + Decimal.from(summary.totalCollateralUSD), + Decimal.from(summary.totalBorrowsUSD), + ); + + return { + ltv: Decimal.from(summary.currentLoanToValue).mul(100), + collateralRatio, + liquidationRisk, + }; +}; diff --git a/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficiencyModeCard.tsx b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficiencyModeCard.tsx new file mode 100644 index 000000000..954b4bfcb --- /dev/null +++ b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/EfficiencyModeCard.tsx @@ -0,0 +1,175 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; + +import classNames from 'classnames'; +import { t } from 'i18next'; + +import { + Button, + ButtonStyle, + Dialog, + DialogBody, + DialogHeader, + Icon, + IconNames, + Link, + Paragraph, + Tooltip, + TooltipTrigger, +} from '@sovryn/ui'; + +import { EModeIcon } from '../../../../../../1_atoms/Icons/Icons'; +import { useAaveEModeCategories } from '../../../../../../../hooks/aave/useAaveEModeCategories'; +import { translations } from '../../../../../../../locales/i18n'; +import { DisableEModeForm } from './components/DisableEModeForm/DisableEModeForm'; +import { EnableEModeForm } from './components/EnableEModeForm/EnableEModeForm'; +import { SwitchEModeForm } from './components/SwitchEModeForm/SwitchEModeForm'; + +type EfficiencyModeCardProps = { + className?: string; + eModeCategoryId: Number; +}; + +export const EfficiencyModeCard: FC = ({ + className, + eModeCategoryId, +}) => { + const eModeCategories = useAaveEModeCategories(); + const [enableEModeOpen, setEnableEModeOpen] = useState(false); + const [disableEModeOpen, setDisableEModeOpen] = useState(false); + const [switchEModeOpen, setSwitchEModeOpen] = useState(false); + + const onEnableEModeClose = useCallback(() => setEnableEModeOpen(false), []); + + const onDisableEModeClose = useCallback(() => setDisableEModeOpen(false), []); + + const onSwitchEModeClose = useCallback(() => setSwitchEModeOpen(false), []); + + const currentCategory = useMemo( + () => eModeCategories.find(c => c.id === eModeCategoryId), + [eModeCategories, eModeCategoryId], + ); + + return ( + <> + +
+ + {t(translations.aavePage.eModeCard.title)} + + + {eModeCategoryId !== 0 && ( +
+ + {t(translations.aavePage.eMode.assetCategory)} + + +
+ + {currentCategory?.label} + +
+ + + {t(translations.common.enabled)} + +
+
+
+ )} + + + {t(translations.aavePage.eModeCard.description)}{' '} + + +
+ {eModeCategoryId !== 0 ? ( +
+
+ ) : ( +
+ } + > +
+ + + {eModeCategoryId !== 0 + ? t(translations.aavePage.eModeCard.enabled) + : t(translations.aavePage.eModeCard.disabled)} + + +
+ + + + + + + + + + {currentCategory && ( + + + + + + + )} + + {currentCategory && ( + + + + + + + )} + + ); +}; diff --git a/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/components/DisableEModeForm/DisableEModeForm.tsx b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/components/DisableEModeForm/DisableEModeForm.tsx new file mode 100644 index 000000000..8c671296e --- /dev/null +++ b/apps/frontend/src/app/5_pages/AavePage/components/BorrowPositionsList/components/EfficiencyModeCard/components/DisableEModeForm/DisableEModeForm.tsx @@ -0,0 +1,139 @@ +import React, { FC, useCallback, useMemo } from 'react'; + +import { t } from 'i18next'; + +import { + Button, + ErrorBadge, + ErrorLevel, + Icon, + IconNames, + SimpleTable, + SimpleTableRow, +} from '@sovryn/ui'; +import { Decimal } from '@sovryn/utils'; + +import { AmountRenderer } from '../../../../../../../../2_molecules/AmountRenderer/AmountRenderer'; +import { + EMODE_DISABLED_ID, + MINIMUM_COLLATERAL_RATIO_LENDING_POOLS_AAVE, +} from '../../../../../../../../../constants/aave'; +import { useAaveSetUserEMode } from '../../../../../../../../../hooks/aave/useAaveSetUserEMode'; +import { useAaveUserReservesData } from '../../../../../../../../../hooks/aave/useAaveUserReservesData'; +import { translations } from '../../../../../../../../../locales/i18n'; +import { EModeCategory } from '../../../../../../../../../types/aave'; +import { CollateralRatioHealthBar } from '../../../../../CollateralRatioHealthBar/CollateralRatioHealthBar'; +import { normalizeEModeSummary } from '../../EfficencyModeCard.utils'; + +type DisableEModeFormProps = { + current: EModeCategory; + onComplete: () => void; +}; + +export const DisableEModeForm: FC = ({ + current, + onComplete, +}) => { + const { handleDisableUserEMode } = useAaveSetUserEMode(); + const { userReservesData, reservesData, timestamp } = + useAaveUserReservesData(); + + const onConfirm = useCallback( + () => handleDisableUserEMode({ onComplete }), + [handleDisableUserEMode, onComplete], + ); + + const summaryAfterDisabled = useMemo( + () => + normalizeEModeSummary( + EMODE_DISABLED_ID, + reservesData, + userReservesData, + timestamp, + ), + [userReservesData, reservesData, timestamp], + ); + + const isConfirmEnabled = useMemo( + () => !summaryAfterDisabled.liquidationRisk, + [summaryAfterDisabled.liquidationRisk], + ); + + return ( +
+ {summaryAfterDisabled.liquidationRisk && ( + + )} + + + + + + {current?.label} + + + {t(translations.aavePage.eMode.none)} + +
+ } + /> + + {current?.assets.join(', ')} + + + {t(translations.aavePage.eMode.allAssets)} + +
+ } + /> + + + + +
+ } + /> + + +