diff --git a/apps/main/next.config.js b/apps/main/next.config.js index 9019efac4..25a489596 100644 --- a/apps/main/next.config.js +++ b/apps/main/next.config.js @@ -65,7 +65,7 @@ const nextConfiguration = { permanent: false }, { source: '/llamalend/:network', - destination: '/llamalend/ethereum/markets/', + destination: '/llamalend/:network/markets/', permanent: false }, { source: '/crvusd/:network', diff --git a/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx b/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx index ecc22314a..43f14f6ea 100644 --- a/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx +++ b/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx @@ -16,6 +16,7 @@ import CellLoanState from '@/lend/components/SharedCellData/CellLoanState' import CellLoss from '@/lend/components/SharedCellData/CellLoss' import CellUserMain from '@/lend/components/SharedCellData/CellUserMain' import { TITLE } from '@/lend/constants' +import { useBorrowPositionDetails } from '@/lend/hooks/useBorrowPositionDetails' import { useUserLoanStatus } from '@/lend/hooks/useUserLoanDetails' import networks from '@/lend/networks' import useStore from '@/lend/store/useStore' @@ -25,15 +26,24 @@ import Box from '@ui/Box' import ListInfoItem, { ListInfoItems, ListInfoItemsWrapper } from '@ui/ListInfo' import { breakpoints } from '@ui/utils' import { useUserProfileStore } from '@ui-kit/features/user-profile' +import { useBetaFlag } from '@ui-kit/hooks/useLocalStorage' +import { BorrowPositionDetails } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' const DetailsUserLoan = (pageProps: PageContentProps) => { const { rChainId, rOwmId, api, market, titleMapper, userActiveKey } = pageProps const loanExistsResp = useStore((state) => state.user.loansExistsMapper[userActiveKey]) const chartExpanded = useStore((state) => state.ohlcCharts.chartExpanded) + const borrowPositionDetailsProps = useBorrowPositionDetails({ + chainId: rChainId, + market, + marketId: rOwmId, + userActiveKey, + }) const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) const isSoftLiquidation = useUserLoanStatus(userActiveKey) === 'soft_liquidation' + const [isBeta] = useBetaFlag() const { signerAddress } = api ?? {} @@ -83,32 +93,36 @@ const DetailsUserLoan = (pageProps: PageContentProps) => { ) : foundLoan ? (
- {isSoftLiquidation && ( + {!isBeta && isSoftLiquidation && ( )} - - - + {isBeta && } - {/* stats */} - - {contents.map((groupedContents, idx) => ( - - {groupedContents - .filter(({ show }) => _showContent(show)) - .map(({ titleKey, content }, idx) => ( - - {content} - - ))} - - ))} - - - + {!isBeta && ( + + + + + {/* stats */} + + {contents.map((groupedContents, idx) => ( + + {groupedContents + .filter(({ show }) => _showContent(show)) + .map(({ titleKey, content }, idx) => ( + + {content} + + ))} + + ))} + + + + )} {/* CHARTS */} diff --git a/apps/main/src/lend/components/PageVault/Page.tsx b/apps/main/src/lend/components/PageVault/Page.tsx index 9f7932ff6..89128af89 100644 --- a/apps/main/src/lend/components/PageVault/Page.tsx +++ b/apps/main/src/lend/components/PageVault/Page.tsx @@ -8,11 +8,15 @@ import { _getSelectedTab } from '@/lend/components/PageLoanManage/utils' import Vault from '@/lend/components/PageVault/index' import PageTitleBorrowSupplyLinks from '@/lend/components/SharedPageStyles/PageTitleBorrowSupplyLinks' import { useOneWayMarket } from '@/lend/entities/chain' +import { useBorrowPositionDetails } from '@/lend/hooks/useBorrowPositionDetails' +import { useLendPositionDetails } from '@/lend/hooks/useLendPositionDetails' +import { useMarketDetails } from '@/lend/hooks/useMarketDetails' import useTitleMapper from '@/lend/hooks/useTitleMapper' import { helpers } from '@/lend/lib/apiLending' import useStore from '@/lend/store/useStore' import { Api, type MarketUrlParams, OneWayMarketTemplate, PageContentProps } from '@/lend/types/lend.types' import { parseMarketParams } from '@/lend/utils/utilsRouter' +import Stack from '@mui/material/Stack' import { AppPageFormContainer, AppPageFormsWrapper, @@ -26,8 +30,14 @@ import Tabs, { Tab } from '@ui/Tab' import { ConnectWalletPrompt, isLoading, useConnection, useWallet } from '@ui-kit/features/connect-wallet' import { useLayoutStore } from '@ui-kit/features/layout' import { useUserProfileStore } from '@ui-kit/features/user-profile' +import { useBetaFlag } from '@ui-kit/hooks/useLocalStorage' import { t } from '@ui-kit/lib/i18n' import { REFRESH_INTERVAL } from '@ui-kit/lib/model' +import { MarketDetails } from '@ui-kit/shared/ui/MarketDetails' +import { PositionDetails } from '@ui-kit/shared/ui/PositionDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces const Page = (params: MarketUrlParams) => { const { rMarket, rChainId, rFormType } = parseMarketParams(params) @@ -48,8 +58,28 @@ const Page = (params: MarketUrlParams) => { const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) const rOwmId = market?.id ?? '' + const userActiveKey = helpers.getUserActiveKey(api, market!) const { signerAddress } = api ?? {} const [isLoaded, setLoaded] = useState(false) + const [isBeta] = useBetaFlag() + + const borrowPositionDetails = useBorrowPositionDetails({ + chainId: rChainId, + market: market ?? undefined, + marketId: market?.id ?? '', + userActiveKey: userActiveKey, + }) + const lendPositionDetails = useLendPositionDetails({ + chainId: rChainId, + market: market, + marketId: rOwmId, + userActiveKey: userActiveKey, + }) + const marketDetails = useMarketDetails({ + chainId: rChainId, + llamma: market, + llammaId: rOwmId, + }) // set tabs const DETAIL_INFO_TYPES: { key: DetailInfoTypes; label: string }[] = [{ label: t`Lend Details`, key: 'market' }] @@ -100,51 +130,84 @@ const Page = (params: MarketUrlParams) => { isLoaded, api, market, - userActiveKey: helpers.getUserActiveKey(api, market!), + userActiveKey: userActiveKey, titleMapper, } return ( <> {provider ? ( - - - {(!isMdUp || !isAdvancedMode) && } - {rChainId && rOwmId && } - - - {isAdvancedMode && rChainId && rOwmId && ( - - {isMdUp && } - - - - - - {DETAIL_INFO_TYPES.map(({ key, label }) => ( - setMarketsStateKey('marketDetailsView', key)} - > - {label} - - ))} - - - - - {selectedTab === 'market' && provider && } - {selectedTab === 'user' && provider && } - - - )} - + !isBeta ? ( + + + {(!isMdUp || !isAdvancedMode) && } + {rChainId && rOwmId && } + + + {isAdvancedMode && rChainId && rOwmId && ( + + {isMdUp && } + + + + {isBeta && ( + + )} + + + {DETAIL_INFO_TYPES.map(({ key, label }) => ( + setMarketsStateKey('marketDetailsView', key)} + > + {label} + + ))} + + + + + {selectedTab === 'market' && provider && } + {selectedTab === 'user' && provider && } + + + )} + + ) : ( + // New design layout, only in beta for now + + {rChainId && rOwmId && } + + + + + + + ) ) : ( ['prices-api', 'supported-chains'] as const, + queryFn: getSupportedChainsFromApi, + staleTime: '1d', + validationSuite: EmptyValidationSuite, +}) + +export const { fetchQuery: fetchSupportedLendingChains, setQueryData: setSupportedLendingChains } = queryFactory({ + queryKey: () => ['prices-api', 'supported-lending-chains'] as const, + queryFn: getChains, + staleTime: '1d', + validationSuite: EmptyValidationSuite, +}) diff --git a/apps/main/src/lend/entities/market-collateral-value.ts b/apps/main/src/lend/entities/market-collateral-value.ts new file mode 100644 index 000000000..80d78e569 --- /dev/null +++ b/apps/main/src/lend/entities/market-collateral-value.ts @@ -0,0 +1,63 @@ +import { USE_API } from '@/lend/shared/config' +import { ChainId } from '@/lend/types/lend.types' +import { requireLib } from '@ui-kit/features/connect-wallet' +import { FieldsOf } from '@ui-kit/lib' +import { queryFactory } from '@ui-kit/lib/model/query' +import type { ChainQuery } from '@ui-kit/lib/model/query' +import { llamaApiValidationSuite } from '@ui-kit/lib/model/query/curve-api-validation' + +type MarketQuery = ChainQuery & { marketId: string } +type MarketParams = FieldsOf + +type MarketCollateralValue = { + totalUsdValue: number + collateral: { + amount: number + usdRate: number + usdValue: number + } + borrowed: { + amount: number + usdRate: number + usdValue: number + } +} + +/** + * The purpose of this query is to allow fetching market collateral values on chain + * in order to display the most current data when a wallet is connected. + * */ +const _getMarketCollateralValue = async ({ marketId }: MarketQuery): Promise => { + const api = requireLib('llamaApi') + const market = api.getLendMarket(marketId) + const { collateral_token, borrowed_token } = market + const [ammBalance, collateralUsdRate, borrowedUsdRate] = await Promise.all([ + market.stats.ammBalances(false, USE_API), + api.getUsdRate(collateral_token.address), + api.getUsdRate(borrowed_token.address), + ]) + const borrowedUsd = +ammBalance.borrowed * +borrowedUsdRate + const collateralUsd = +ammBalance.collateral * +collateralUsdRate + const total = +borrowedUsd + +collateralUsd + return { + totalUsdValue: total, + collateral: { + amount: +ammBalance.collateral, + usdRate: collateralUsdRate, + usdValue: collateralUsd, + }, + borrowed: { + amount: +ammBalance.borrowed, + usdRate: borrowedUsdRate, + usdValue: borrowedUsd, + }, + } +} + +export const { useQuery: useMarketCollateralValue } = queryFactory({ + queryKey: (params: MarketParams) => + ['marketCollateralValue', { chainId: params.chainId }, { marketId: params.marketId }] as const, + queryFn: _getMarketCollateralValue, + refetchInterval: '1m', + validationSuite: llamaApiValidationSuite, +}) diff --git a/apps/main/src/lend/entities/market-price-per-share.ts b/apps/main/src/lend/entities/market-price-per-share.ts new file mode 100644 index 000000000..8df760575 --- /dev/null +++ b/apps/main/src/lend/entities/market-price-per-share.ts @@ -0,0 +1,26 @@ +import { ChainId } from '@/lend/types/lend.types' +import { requireLib } from '@ui-kit/features/connect-wallet' +import { FieldsOf } from '@ui-kit/lib' +import type { ChainQuery } from '@ui-kit/lib/model/query' +import { queryFactory } from '@ui-kit/lib/model/query' +import { llamaApiValidationSuite } from '@ui-kit/lib/model/query/curve-api-validation' + +type MarketQuery = ChainQuery & { marketId: string } +type MarketParams = FieldsOf + +const _fetchMarketPricePerShare = async ({ marketId }: MarketQuery) => { + const api = requireLib('llamaApi') + const market = api.getLendMarket(marketId) + return await market.vault.previewRedeem(1) +} + +/** + * Fetches the price per share of a market on chain + */ +export const { useQuery: useMarketPricePerShare, invalidate: invalidateMarketPricePerShare } = queryFactory({ + queryKey: (params: MarketParams) => + ['marketPricePerShare', { chainId: params.chainId }, { marketId: params.marketId }] as const, + queryFn: _fetchMarketPricePerShare, + refetchInterval: '1m', + validationSuite: llamaApiValidationSuite, +}) diff --git a/apps/main/src/lend/hooks/useBorrowPositionDetails.ts b/apps/main/src/lend/hooks/useBorrowPositionDetails.ts new file mode 100644 index 000000000..95a23179a --- /dev/null +++ b/apps/main/src/lend/hooks/useBorrowPositionDetails.ts @@ -0,0 +1,129 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate' +import networks from '@/lend/networks' +import useStore from '@/lend/store/useStore' +import { ChainId, OneWayMarketTemplate } from '@/lend/types/lend.types' +import type { Address, Chain } from '@curvefi/prices-api' +import { useLendingSnapshots } from '@ui-kit/entities/lending-snapshots' +import { useTokenUsdRate } from '@ui-kit/lib/model/entities/token-usd-rate' +import { BorrowPositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' + +type UseBorrowPositionDetailsProps = { + chainId: ChainId + market: OneWayMarketTemplate | null | undefined + marketId: string + userActiveKey: string +} + +export const useBorrowPositionDetails = ({ + chainId, + market, + marketId, + userActiveKey, +}: UseBorrowPositionDetailsProps): BorrowPositionDetailsProps => { + const userLoanDetailsResp = useStore((state) => state.user.loansDetailsMapper[userActiveKey]) + const isFetchingAll = useStore((state) => state.markets.isFetchingAll) + const marketRate = useStore((state) => state.markets.ratesMapper[chainId]?.[marketId]) + const prices = useStore((state) => state.markets.pricesMapper[chainId]?.[marketId]) + + const { data: onChainRatesData, isLoading: isOnchainRatesLoading } = useMarketOnChainRates({ + chainId: chainId, + marketId, + }) + const { data: collateralUsdRate, isLoading: collateralUsdRateLoading } = useTokenUsdRate({ + chainId: chainId, + tokenAddress: market?.addresses?.collateral_token, + }) + const { data: borrowedUsdRate, isLoading: borrowedUsdRateLoading } = useTokenUsdRate({ + chainId: chainId, + tokenAddress: market?.addresses?.borrowed_token, + }) + + const { details: userLoanDetails } = userLoanDetailsResp ?? {} + + const { data: lendSnapshots, isLoading: isLendSnapshotsLoading } = useLendingSnapshots({ + blockchainId: networks[chainId].id as Chain, + contractAddress: market?.addresses?.controller as Address, + }) + + const thirtyDayAvgRate = useMemo(() => { + if (!lendSnapshots) return null + + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + const recentSnapshots = lendSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > thirtyDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ borrowApy }) => borrowApy) * 100 + }, [lendSnapshots]) + + const borrowApy = onChainRatesData?.rates?.borrowApy ?? marketRate?.rates?.borrowApy + + const collateralTotalValue = useMemo(() => { + if (!collateralUsdRate || !userLoanDetails?.state?.collateral) return null + return ( + Number(userLoanDetails?.state?.collateral) * Number(collateralUsdRate) + Number(userLoanDetails?.state?.borrowed) + ) + }, [userLoanDetails?.state?.collateral, userLoanDetails?.state?.borrowed, collateralUsdRate]) + + return { + isSoftLiquidation: userLoanDetails?.status?.colorKey === 'soft_liquidation', + health: { + value: Number(userLoanDetails?.healthFull), + loading: isFetchingAll ?? true, + }, + borrowAPR: { + value: borrowApy != null ? Number(borrowApy) : null, + thirtyDayAvgRate: thirtyDayAvgRate, + loading: isOnchainRatesLoading || isLendSnapshotsLoading || !market?.addresses.controller, + }, + liquidationRange: { + value: userLoanDetails?.prices ? userLoanDetails.prices.map(Number) : null, + rangeToLiquidation: + prices?.prices?.oraclePrice && userLoanDetails?.prices + ? (Number(userLoanDetails?.prices?.[1]) / Number(prices.prices.oraclePrice)) * 100 + : null, + loading: isFetchingAll ?? true, + }, + bandRange: { + value: userLoanDetails?.bands ? userLoanDetails.bands : null, + loading: isFetchingAll ?? true, + }, + collateralValue: { + totalValue: collateralTotalValue, + collateral: { + value: userLoanDetails?.state?.collateral ? Number(userLoanDetails.state.collateral) : null, + usdRate: collateralUsdRate ?? null, + symbol: market?.collateral_token?.symbol, + }, + borrow: { + value: userLoanDetails?.state?.borrowed ? Number(userLoanDetails.state.borrowed) : null, + usdRate: borrowedUsdRate ?? null, + symbol: market?.borrowed_token?.symbol, + }, + loading: isFetchingAll || collateralUsdRateLoading || borrowedUsdRateLoading, + }, + ltv: { + value: collateralTotalValue ? (Number(userLoanDetails?.state?.debt) / collateralTotalValue) * 100 : null, + loading: isFetchingAll ?? true, + }, + pnl: { + currentProfit: userLoanDetails?.pnl?.currentProfit ? Number(userLoanDetails.pnl.currentProfit) : null, + currentPositionValue: userLoanDetails?.pnl?.currentPosition ? Number(userLoanDetails.pnl.currentPosition) : null, + depositedValue: userLoanDetails?.pnl?.deposited ? Number(userLoanDetails.pnl.deposited) : null, + percentageChange: userLoanDetails?.pnl?.percentage ? Number(userLoanDetails.pnl.percentage) : null, + loading: isFetchingAll ?? true, + }, + leverage: { + value: userLoanDetails?.leverage ? Number(userLoanDetails.leverage) : null, + loading: isFetchingAll ?? true, + }, + totalDebt: { + value: userLoanDetails?.state?.debt ? Number(userLoanDetails.state.debt) : null, + loading: isFetchingAll ?? true, + }, + } +} diff --git a/apps/main/src/lend/hooks/useLendPositionDetails.ts b/apps/main/src/lend/hooks/useLendPositionDetails.ts new file mode 100644 index 000000000..c18e6894e --- /dev/null +++ b/apps/main/src/lend/hooks/useLendPositionDetails.ts @@ -0,0 +1,87 @@ +import { meanBy } from 'lodash' +import { useMemo } from 'react' +import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate' +import { useMarketPricePerShare } from '@/lend/entities/market-price-per-share' +import networks from '@/lend/networks' +import useStore from '@/lend/store/useStore' +import { ChainId, OneWayMarketTemplate } from '@/lend/types/lend.types' +import type { Address, Chain } from '@curvefi/prices-api' +import { useLendingSnapshots } from '@ui-kit/entities/lending-snapshots' +import { useTokenUsdRate } from '@ui-kit/lib/model/entities/token-usd-rate' +import { LendPositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails/LendPositionDetails' + +type UseLendPositionDetailsProps = { + chainId: ChainId + market: OneWayMarketTemplate | null | undefined + marketId: string + userActiveKey: string +} + +export const useLendPositionDetails = ({ + chainId, + market, + marketId, + userActiveKey, +}: UseLendPositionDetailsProps): LendPositionDetailsProps => { + const userBalancesResp = useStore((state) => state.user.marketsBalancesMapper[userActiveKey]) // TODO: add loading state + const { data: onChainRatesData, isLoading: isOnchainRatesLoading } = useMarketOnChainRates({ + chainId: chainId, + marketId, + }) + const { data: marketPricePerShare, isLoading: isMarketPricePerShareLoading } = useMarketPricePerShare({ + chainId: chainId, + marketId, + }) + const { data: lentAssetUsdRate, isLoading: lentAssetUsdRateLoading } = useTokenUsdRate({ + chainId: chainId, + tokenAddress: market?.addresses?.borrowed_token, + }) + const { data: lendSnapshots, isLoading: isLendSnapshotsLoading } = useLendingSnapshots({ + blockchainId: networks[chainId].id as Chain, + contractAddress: market?.addresses?.controller as Address, + }) + + const thirtyDayAvgLendApr = useMemo(() => { + if (!lendSnapshots) return null + + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + const recentSnapshots = lendSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > thirtyDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ lendApr }) => lendApr) * 100 + }, [lendSnapshots]) + + return { + lendingAPY: { + value: onChainRatesData?.rates?.lendApy ? Number(onChainRatesData.rates.lendApy) : null, + thirtyDayAvgRate: thirtyDayAvgLendApr, + loading: isOnchainRatesLoading || isLendSnapshotsLoading, + }, + shares: { + value: userBalancesResp?.vaultShares + ? Number(userBalancesResp.vaultShares) + Number(userBalancesResp.gauge) + : null, + staked: userBalancesResp?.gauge ? Number(userBalancesResp.gauge) : null, + loading: false, + }, + lentAsset: { + symbol: market?.collateral_token.symbol, + address: market?.collateral_token.address, + usdRate: lentAssetUsdRate, + depositedAmount: + marketPricePerShare && userBalancesResp?.vaultShares && userBalancesResp?.gauge + ? +marketPricePerShare * (Number(userBalancesResp.vaultShares) + Number(userBalancesResp.gauge)) + : null, + depositedUsdValue: + lentAssetUsdRate && marketPricePerShare && userBalancesResp?.vaultShares && userBalancesResp?.gauge + ? +marketPricePerShare * + (Number(userBalancesResp.vaultShares) + Number(userBalancesResp.gauge)) * + lentAssetUsdRate + : null, + loading: isMarketPricePerShareLoading || lentAssetUsdRateLoading, + }, + } +} diff --git a/apps/main/src/lend/hooks/useMarketDetails.tsx b/apps/main/src/lend/hooks/useMarketDetails.tsx new file mode 100644 index 000000000..ad281d3b4 --- /dev/null +++ b/apps/main/src/lend/hooks/useMarketDetails.tsx @@ -0,0 +1,83 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { useMarketCollateralValue } from '@/lend/entities/market-collateral-value' +import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate' +import { networks } from '@/lend/networks' +import useStore from '@/lend/store/useStore' +import type { ChainId, OneWayMarketTemplate } from '@/lend/types/lend.types' +import type { Chain, Address } from '@curvefi/prices-api' +import { useLendingSnapshots } from '@ui-kit/entities/lending-snapshots' +import { MarketDetailsProps } from '@ui-kit/shared/ui/MarketDetails' + +type UseMarketDetailsProps = { + chainId: ChainId + llamma: OneWayMarketTemplate | null | undefined + llammaId: string +} + +export const useMarketDetails = ({ chainId, llamma, llammaId }: UseMarketDetailsProps): MarketDetailsProps => { + const capAndAvailable = useStore((state) => state.markets.statsCapAndAvailableMapper[chainId]?.[llammaId]) + const maxLeverage = useStore((state) => state.markets.maxLeverageMapper[chainId]?.[llammaId]) + const { data: collateralValue, isLoading: isCollateralValueLoading } = useMarketCollateralValue({ + marketId: llammaId, + }) + const { data: onChainRates, isLoading: isOnChainRatesLoading } = useMarketOnChainRates({ marketId: llammaId }) + const { data: lendingSnapshots, isLoading: isSnapshotsLoading } = useLendingSnapshots({ + blockchainId: networks[chainId as keyof typeof networks]?.id as Chain, + contractAddress: llamma?.addresses.controller as Address, + }) + + const thirtyDayAvgRates = useMemo(() => { + if (!lendingSnapshots) return null + + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + const recentSnapshots = lendingSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > thirtyDaysAgo) + + if (recentSnapshots.length === 0) return null + + return { + borrowApyAvg: meanBy(recentSnapshots, ({ borrowApy }) => borrowApy) * 100, + lendApyAvg: meanBy(recentSnapshots, ({ lendApy }) => lendApy) * 100, + } + }, [lendingSnapshots]) + + return { + collateral: { + symbol: llamma?.collateral_token.symbol ?? null, + tokenAddress: llamma?.collateral_token.address, + total: collateralValue?.collateral.amount ?? null, + totalUsdValue: collateralValue?.totalUsdValue ?? null, + usdRate: collateralValue?.collateral.usdRate ?? null, + loading: isCollateralValueLoading, + }, + borrowToken: { + symbol: llamma?.borrowed_token.symbol ?? null, + tokenAddress: llamma?.borrowed_token.address, + total: collateralValue?.borrowed.amount ?? null, + totalUsdValue: collateralValue?.borrowed.usdValue ?? null, + usdRate: collateralValue?.borrowed.usdRate ?? null, + loading: isCollateralValueLoading, + }, + borrowAPR: { + value: onChainRates?.rates.borrowApr ? Number(onChainRates.rates.borrowApr) : null, + thirtyDayAvgRate: thirtyDayAvgRates?.borrowApyAvg ?? null, + loading: isSnapshotsLoading || (isOnChainRatesLoading ?? true), + }, + lendingAPY: { + value: onChainRates?.rates.lendApy ? Number(onChainRates.rates.lendApy) : null, + thirtyDayAvgRate: thirtyDayAvgRates?.lendApyAvg ?? null, + loading: isSnapshotsLoading || (isOnChainRatesLoading ?? true), + }, + availableLiquidity: { + value: capAndAvailable?.available ? Number(capAndAvailable.available) : null, + max: capAndAvailable?.cap ? Number(capAndAvailable.cap) : null, + loading: false, // to do: set up loading state + }, + maxLeverage: { + value: maxLeverage ? Number(maxLeverage) : null, + loading: false, // to do: set up loading state + }, + } +} diff --git a/apps/main/src/lend/store/createMarketsSlice.ts b/apps/main/src/lend/store/createMarketsSlice.ts index 5126afe9b..e322b7e11 100644 --- a/apps/main/src/lend/store/createMarketsSlice.ts +++ b/apps/main/src/lend/store/createMarketsSlice.ts @@ -25,6 +25,7 @@ import { getLib } from '@ui-kit/features/connect-wallet' type StateKey = keyof typeof DEFAULT_STATE type SliceState = { + isFetchingAll: boolean statsParametersMapper: { [chainId: string]: MarketsStatsParametersMapper } statsBandsMapper: { [chainId: string]: MarketsStatsBandsMapper } statsTotalsMapper: { [chainId: string]: MarketsStatsTotalsMapper } @@ -62,6 +63,7 @@ export type MarketsSlice = { } const DEFAULT_STATE: SliceState = { + isFetchingAll: true, statsParametersMapper: {}, statsBandsMapper: {}, statsTotalsMapper: {}, @@ -86,6 +88,8 @@ const createMarketsSlice = (set: SetState, get: GetState): Markets const { chainId } = api + sliceState.setStateByKey('isFetchingAll', true) + const fnMapper = { statsParametersMapper: apiLending.market.fetchStatsParameters, statsBandsMapper: apiLending.market.fetchStatsBands, @@ -116,6 +120,7 @@ const createMarketsSlice = (set: SetState, get: GetState): Markets cMapper[owmId] = resp[owmId] }) sliceState.setStateByActiveKey(k, chainId.toString(), cMapper) + sliceState.setStateByKey('isFetchingAll', false) }, fetchAll: async (api, OneWayMarketTemplate, shouldRefetch) => { diff --git a/apps/main/src/llamalend/PageLlamaMarkets/hooks/useSnapshots.ts b/apps/main/src/llamalend/PageLlamaMarkets/hooks/useSnapshots.ts index e63d5f611..5aaa3de6f 100644 --- a/apps/main/src/llamalend/PageLlamaMarkets/hooks/useSnapshots.ts +++ b/apps/main/src/llamalend/PageLlamaMarkets/hooks/useSnapshots.ts @@ -1,8 +1,8 @@ import lodash from 'lodash' import { useMemo } from 'react' -import { CrvUsdSnapshot, useCrvUsdSnapshots } from '@/llamalend/entities/crvusd-snapshots' -import { LendingSnapshot, useLendingSnapshots } from '@/llamalend/entities/lending-snapshots' import { LlamaMarket, LlamaMarketType } from '@/llamalend/entities/llama-markets' +import { CrvUsdSnapshot, useCrvUsdSnapshots } from '@ui-kit/entities/crvusd-snapshots' +import { LendingSnapshot, useLendingSnapshots } from '@ui-kit/entities/lending-snapshots' export type RateType = 'borrow' | 'lend' diff --git a/apps/main/src/loan/components/LoanInfoLlamma/index.tsx b/apps/main/src/loan/components/LoanInfoLlamma/index.tsx index 1653ef38f..52b0b0d07 100644 --- a/apps/main/src/loan/components/LoanInfoLlamma/index.tsx +++ b/apps/main/src/loan/components/LoanInfoLlamma/index.tsx @@ -9,6 +9,7 @@ import type { PageLoanManageProps } from '@/loan/components/PageLoanManage/types import useStore from '@/loan/store/useStore' import { breakpoints } from '@ui/utils/responsive' import { useUserProfileStore } from '@ui-kit/features/user-profile' +import { useBetaFlag } from '@ui-kit/hooks/useLocalStorage' import { t } from '@ui-kit/lib/i18n' interface Props extends Pick { @@ -18,14 +19,16 @@ interface Props extends Pick { const { rChainId, llamma, llammaId } = props const chartExpanded = useStore((state) => state.ohlcCharts.chartExpanded) - const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) + const [isBeta] = useBetaFlag() return ( -
- -
+ {!isBeta && ( +
+ +
+ )} {!chartExpanded && (
diff --git a/apps/main/src/loan/components/LoanInfoUser/index.tsx b/apps/main/src/loan/components/LoanInfoUser/index.tsx index 52cc19dbf..4f1611fb1 100644 --- a/apps/main/src/loan/components/LoanInfoUser/index.tsx +++ b/apps/main/src/loan/components/LoanInfoUser/index.tsx @@ -9,13 +9,16 @@ import ChartUserLiquidationRange from '@/loan/components/LoanInfoUser/components import UserInfos from '@/loan/components/LoanInfoUser/components/UserInfos' import type { PageLoanManageProps } from '@/loan/components/PageLoanManage/types' import { DEFAULT_HEALTH_MODE } from '@/loan/components/PageLoanManage/utils' +import { useLoanPositionDetails } from '@/loan/hooks/useLoanPositionDetails' import { useUserLoanDetails, useUserLoanStatus } from '@/loan/hooks/useUserLoanDetails' import useStore from '@/loan/store/useStore' import { ChainId } from '@/loan/types/loan.types' import Box from '@ui/Box' import { breakpoints } from '@ui/utils/responsive' import { useUserProfileStore } from '@ui-kit/features/user-profile' +import { useBetaFlag } from '@ui-kit/hooks/useLocalStorage' import { t } from '@ui-kit/lib/i18n' +import { BorrowPositionDetails } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' interface Props extends Pick { rChainId: ChainId @@ -28,10 +31,17 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) const isSoftLiquidation = useUserLoanStatus(llammaId) === 'soft_liquidation' + const [isBeta] = useBetaFlag() const { oraclePriceBand } = loanDetails ?? {} const [healthMode, setHealthMode] = useState(DEFAULT_HEALTH_MODE) + const positionDetailsProps = useLoanPositionDetails({ + chainId: rChainId, + llamma, + llammaId, + health: healthMode.percent, + }) useEffect(() => { if (!lodash.isUndefined(oraclePriceBand) && healthFull && healthNotFull && userBands) { @@ -54,15 +64,18 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { return ( - - - + {isBeta && } + {!isBeta && ( + + + + )} {!chartExpanded && (
diff --git a/apps/main/src/loan/components/PageLoanCreate/Page.tsx b/apps/main/src/loan/components/PageLoanCreate/Page.tsx index 6ff76a94d..4a201789a 100644 --- a/apps/main/src/loan/components/PageLoanCreate/Page.tsx +++ b/apps/main/src/loan/components/PageLoanCreate/Page.tsx @@ -5,6 +5,7 @@ import ChartOhlcWrapper from '@/loan/components/ChartOhlcWrapper' import LoanInfoLlamma from '@/loan/components/LoanInfoLlamma' import LoanCreate from '@/loan/components/PageLoanCreate/index' import { hasLeverage } from '@/loan/components/PageLoanCreate/utils' +import { useMarketDetails } from '@/loan/hooks/useMarketDetails' import useTitleMapper from '@/loan/hooks/useTitleMapper' import useStore from '@/loan/store/useStore' import { type CollateralUrlParams, type LlamaApi, Llamma } from '@/loan/types/loan.types' @@ -33,9 +34,11 @@ import { ConnectWalletPrompt, isLoading, useConnection, useWallet } from '@ui-ki import { useLayoutStore } from '@ui-kit/features/layout' import { useUserProfileStore } from '@ui-kit/features/user-profile' import { useNavigate } from '@ui-kit/hooks/router' +import { useBetaFlag } from '@ui-kit/hooks/useLocalStorage' import usePageVisibleInterval from '@ui-kit/hooks/usePageVisibleInterval' import { t } from '@ui-kit/lib/i18n' import { REFRESH_INTERVAL } from '@ui-kit/lib/model' +import { MarketDetails } from '@ui-kit/shared/ui/MarketDetails' const Page = (params: CollateralUrlParams) => { const { rFormType, rCollateralId } = parseCollateralParams(params) @@ -68,6 +71,13 @@ const Page = (params: CollateralUrlParams) => { const isValidRouterParams = !!rChainId && !!rCollateralId const isLeverage = rFormType === 'leverage' + const [isBeta] = useBetaFlag() + const marketDetailsProps = useMarketDetails({ + chainId: rChainId, + llamma, + llammaId, + }) + const fetchInitial = useCallback( (curve: LlamaApi, isLeverage: boolean, llamma: Llamma) => { // reset createLoan estGas, detailInfo state @@ -206,7 +216,8 @@ const Page = (params: CollateralUrlParams) => { {isMdUp && !chartExpanded && } - LLAMMA Details + {isBeta && } + {!isBeta && LLAMMA Details} {isValidRouterParams && rChainId && ( )} diff --git a/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx b/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx index c221ef616..fcce1cd1f 100644 --- a/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx +++ b/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx @@ -2,6 +2,7 @@ import { useChainId } from 'wagmi' import { useAppStatsTotalCrvusdSupply } from '@/loan/entities/appstats-total-crvusd-supply' import type { ChainId } from '@/loan/types/loan.types' import { CardHeader, Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' import { Metric } from '@ui-kit/shared/ui/Metric' import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' @@ -18,17 +19,17 @@ export const PegKeeperStatistics = () => { return ( - + diff --git a/apps/main/src/loan/hooks/useLoanPositionDetails.ts b/apps/main/src/loan/hooks/useLoanPositionDetails.ts new file mode 100644 index 000000000..6bf5066b9 --- /dev/null +++ b/apps/main/src/loan/hooks/useLoanPositionDetails.ts @@ -0,0 +1,108 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { CRVUSD_ADDRESS } from '@/loan/constants' +import networks from '@/loan/networks' +import useStore from '@/loan/store/useStore' +import { ChainId, Llamma } from '@/loan/types/loan.types' +import { Address } from '@curvefi/prices-api' +import { useCrvUsdSnapshots } from '@ui-kit/entities/crvusd-snapshots' +import { useTokenUsdRate } from '@ui-kit/lib/model/entities/token-usd-rate' +import { BorrowPositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' + +type UseLoanPositionDetailsProps = { + chainId: ChainId + llamma: Llamma | null | undefined + llammaId: string + health: string | undefined +} + +export const useLoanPositionDetails = ({ + chainId, + llamma, + llammaId, + health, +}: UseLoanPositionDetailsProps): BorrowPositionDetailsProps => { + const userLoanDetails = useStore((state) => state.loans.userDetailsMapper[llammaId]) + const loanDetails = useStore((state) => state.loans.detailsMapper[llammaId ?? '']) + + const { data: collateralUsdRate, isLoading: collateralUsdRateLoading } = useTokenUsdRate({ + chainId: chainId, + tokenAddress: llamma?.collateral, + }) + const { data: borrowedUsdRate, isLoading: borrowedUsdRateLoading } = useTokenUsdRate({ + chainId: chainId, + tokenAddress: CRVUSD_ADDRESS, + }) + const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useCrvUsdSnapshots({ + blockchainId: networks[chainId as keyof typeof networks].id, + contractAddress: llamma?.controller as Address, + }) + + const thirtyDayAvgRate = useMemo(() => { + if (!crvUsdSnapshots) return null + + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > thirtyDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ rate }) => rate) * 100 + }, [crvUsdSnapshots]) + + const collateralTotalValue = useMemo(() => { + if (!collateralUsdRate || !userLoanDetails?.userState?.collateral) return null + return ( + Number(userLoanDetails?.userState?.collateral) * Number(collateralUsdRate) + + Number(userLoanDetails?.userState?.stablecoin) + ) + }, [userLoanDetails?.userState?.collateral, userLoanDetails?.userState?.stablecoin, collateralUsdRate]) + + return { + isSoftLiquidation: userLoanDetails?.userStatus?.colorKey === 'soft_liquidation', + health: { + value: Number(health), + loading: userLoanDetails?.loading ?? true, + }, + borrowAPR: { + value: loanDetails?.parameters?.rate ? Number(loanDetails?.parameters?.rate) : null, + thirtyDayAvgRate: thirtyDayAvgRate, + loading: isSnapshotsLoading || (loanDetails?.loading ?? true), + }, + liquidationRange: { + value: userLoanDetails?.userPrices ? userLoanDetails.userPrices.map(Number) : null, + rangeToLiquidation: + loanDetails?.priceInfo?.oraclePrice && userLoanDetails?.userPrices + ? (Number(userLoanDetails?.userPrices?.[1]) / Number(loanDetails.priceInfo.oraclePrice)) * 100 + : null, + loading: userLoanDetails?.loading ?? true, + }, + bandRange: { + value: userLoanDetails?.userBands ? userLoanDetails.userBands : null, + loading: userLoanDetails?.loading ?? true, + }, + collateralValue: { + totalValue: collateralTotalValue, + collateral: { + value: userLoanDetails?.userState?.collateral ? Number(userLoanDetails.userState.collateral) : null, + usdRate: collateralUsdRate ? Number(collateralUsdRate) : null, + symbol: llamma?.collateralSymbol, + }, + borrow: { + value: userLoanDetails?.userState?.stablecoin ? Number(userLoanDetails.userState.stablecoin) : null, + usdRate: borrowedUsdRate ? Number(borrowedUsdRate) : null, + symbol: 'crvUSD', + }, + loading: (userLoanDetails?.loading ?? true) || collateralUsdRateLoading || borrowedUsdRateLoading, + }, + ltv: { + value: collateralTotalValue ? (Number(userLoanDetails?.userState?.debt) / collateralTotalValue) * 100 : null, + loading: userLoanDetails?.loading ?? true, + }, + totalDebt: { + value: userLoanDetails?.userState?.debt ? Number(userLoanDetails.userState.debt) : null, + loading: userLoanDetails?.loading ?? true, + }, + } +} diff --git a/apps/main/src/loan/hooks/useMarketDetails.ts b/apps/main/src/loan/hooks/useMarketDetails.ts new file mode 100644 index 000000000..46bb6e8d9 --- /dev/null +++ b/apps/main/src/loan/hooks/useMarketDetails.ts @@ -0,0 +1,68 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { CRVUSD_ADDRESS } from '@/loan/constants' +import networks from '@/loan/networks' +import useStore from '@/loan/store/useStore' +import { ChainId, Llamma } from '@/loan/types/loan.types' +import { Address } from '@curvefi/prices-api' +import { useCrvUsdSnapshots } from '@ui-kit/entities/crvusd-snapshots' +import { MarketDetailsProps } from '@ui-kit/shared/ui/MarketDetails' + +type UseMarketDetailsProps = { + chainId: ChainId + llamma: Llamma | null | undefined + llammaId: string +} + +export const useMarketDetails = ({ chainId, llamma, llammaId }: UseMarketDetailsProps): MarketDetailsProps => { + const loanDetails = useStore((state) => state.loans.detailsMapper[llammaId ?? '']) + const usdRatesLoading = useStore((state) => state.usdRates.loading) + const collateralUsdRate = useStore((state) => state.usdRates.tokens[llamma?.collateral ?? '']) + const borrowedUsdRate = useStore((state) => state.usdRates.tokens[CRVUSD_ADDRESS]) + const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useCrvUsdSnapshots({ + blockchainId: networks[chainId as keyof typeof networks]?.id, + contractAddress: llamma?.controller as Address, + }) + + const thirtyDayAvgBorrowAPR = useMemo(() => { + if (!crvUsdSnapshots) return null + + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > thirtyDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ rate }) => rate) * 100 + }, [crvUsdSnapshots]) + + return { + collateral: { + symbol: llamma?.collateralSymbol ?? null, + tokenAddress: llamma?.collateral, + total: loanDetails?.totalCollateral ? Number(loanDetails.totalCollateral) : null, + totalUsdValue: loanDetails?.totalCollateral + ? Number(loanDetails.totalCollateral) * Number(collateralUsdRate) + : null, + usdRate: collateralUsdRate ? Number(collateralUsdRate) : null, + loading: usdRatesLoading || (loanDetails?.loading ?? true), + }, + borrowToken: { + symbol: 'crvUSD', + tokenAddress: CRVUSD_ADDRESS, + usdRate: borrowedUsdRate ? Number(borrowedUsdRate) : null, + loading: usdRatesLoading || (loanDetails?.loading ?? true), + }, + borrowAPR: { + value: loanDetails?.parameters?.rate ? Number(loanDetails?.parameters?.rate) : null, + thirtyDayAvgRate: thirtyDayAvgBorrowAPR, + loading: isSnapshotsLoading || (loanDetails?.loading ?? true), + }, + availableLiquidity: { + value: loanDetails?.capAndAvailable?.available ? Number(loanDetails.capAndAvailable.available) : null, + max: loanDetails?.capAndAvailable?.cap ? Number(loanDetails.capAndAvailable.cap) : null, + loading: loanDetails?.loading ?? true, + }, + } +} diff --git a/apps/main/src/loan/lib/apiCrvusd.ts b/apps/main/src/loan/lib/apiCrvusd.ts index fef141587..27f36da87 100644 --- a/apps/main/src/loan/lib/apiCrvusd.ts +++ b/apps/main/src/loan/lib/apiCrvusd.ts @@ -204,6 +204,7 @@ const detailInfo = { ) const fetchedUserDetails: UserLoanDetails = { + loading: false, ...fetchedPartialUserLoanInfo, userBandsBalances: parsedBandsBalances, userBandsRange, diff --git a/apps/main/src/loan/store/createLoansSlice.ts b/apps/main/src/loan/store/createLoansSlice.ts index e71a293bc..8658386d3 100644 --- a/apps/main/src/loan/store/createLoansSlice.ts +++ b/apps/main/src/loan/store/createLoansSlice.ts @@ -90,13 +90,19 @@ const createLoansSlice = (set: SetState, get: GetState) => ({ }, fetchLoanDetails: async (curve: LlamaApi, llamma: Llamma) => { const chainId = curve.chainId as ChainId + + get()[sliceKey].setStateByActiveKey('detailsMapper', llamma.id, { + ...get()[sliceKey].detailsMapper[llamma.id], + loading: true, + }) + const [{ collateralId, ...loanDetails }, priceInfo, loanExists] = await Promise.all([ networks[chainId].api.detailInfo.loanInfo(llamma), networks[chainId].api.detailInfo.priceInfo(llamma), networks[chainId].api.loanCreate.exists(llamma, curve.signerAddress), ]) - const fetchedLoanDetails: LoanDetails = { ...loanDetails, priceInfo } + const fetchedLoanDetails: LoanDetails = { ...loanDetails, priceInfo, loading: false } get()[sliceKey].setStateByActiveKey('detailsMapper', collateralId, fetchedLoanDetails) get()[sliceKey].setStateByActiveKey('existsMapper', collateralId, loanExists) @@ -123,10 +129,19 @@ const createLoansSlice = (set: SetState, get: GetState) => ({ fetchUserLoanDetails: async (curve: LlamaApi, llamma: Llamma) => { const chainId = curve.chainId as ChainId const userLoanDetailsFn = networks[chainId].api.detailInfo.userLoanInfo + + get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, { + ...get()[sliceKey].userDetailsMapper[llamma.id], + loading: true, + }) + const resp = await userLoanDetailsFn(llamma, curve.signerAddress) - get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, resp) - return resp + get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, { + ...resp, + loading: false, + }) + return { ...resp, loading: false } }, fetchUserLoanPartialDetails: async (curve: LlamaApi, llamma: Llamma) => { const chainId = curve.chainId as ChainId diff --git a/apps/main/src/loan/types/loan.types.ts b/apps/main/src/loan/types/loan.types.ts index a24a49a13..10fba87bd 100644 --- a/apps/main/src/loan/types/loan.types.ts +++ b/apps/main/src/loan/types/loan.types.ts @@ -82,6 +82,7 @@ export type LoanParameter = { } export type BandBalance = Record export type LoanDetails = { + loading: boolean oraclePriceBand: number | null parameters: LoanParameter balances: [string, string] @@ -108,6 +109,7 @@ export type BandsBalancesData = { collateralStablecoinUsd: number } export type UserLoanDetails = { + loading: boolean healthFull: string healthNotFull: string userBands: number[] diff --git a/apps/main/src/llamalend/entities/crvusd-snapshots.ts b/packages/curve-ui-kit/src/entities/crvusd-snapshots.ts similarity index 100% rename from apps/main/src/llamalend/entities/crvusd-snapshots.ts rename to packages/curve-ui-kit/src/entities/crvusd-snapshots.ts diff --git a/apps/main/src/llamalend/entities/lending-snapshots.ts b/packages/curve-ui-kit/src/entities/lending-snapshots.ts similarity index 100% rename from apps/main/src/llamalend/entities/lending-snapshots.ts rename to packages/curve-ui-kit/src/entities/lending-snapshots.ts diff --git a/packages/curve-ui-kit/src/shared/ui/MarketDetails/SymbolCell.tsx b/packages/curve-ui-kit/src/shared/ui/MarketDetails/SymbolCell.tsx new file mode 100644 index 000000000..f004f48aa --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/MarketDetails/SymbolCell.tsx @@ -0,0 +1,28 @@ +import { Stack, Typography } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { MetricSize } from '@ui-kit/shared/ui/Metric' +import { TokenIcon } from '@ui-kit/shared/ui/TokenIcon' +import { WithSkeleton } from '@ui-kit/shared/ui/WithSkeleton' + +type SymbolCellProps = { + label: string + symbol: string | undefined | null + tokenAddress: string | undefined | null + loading: boolean + size?: keyof Pick +} + +/** Mimics the style of Metric but is used for cells that only have a symbol and a token icon. */ +export const SymbolCell = ({ label, symbol, tokenAddress, loading, size = 'medium' }: SymbolCellProps) => ( + + + {label} + + + + {symbol == null ? t`N/A` : symbol} + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/MarketDetails/index.tsx b/packages/curve-ui-kit/src/shared/ui/MarketDetails/index.tsx new file mode 100644 index 000000000..93e4d093f --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/MarketDetails/index.tsx @@ -0,0 +1,175 @@ +import { CardHeader, Box } from '@mui/material' +import { formatNumber, FORMAT_OPTIONS } from '@ui/utils/utilsFormat' +import { t } from '@ui-kit/lib/i18n' +import { SymbolCell } from '@ui-kit/shared/ui/MarketDetails/SymbolCell' +import { Metric } from '@ui-kit/shared/ui/Metric' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' +import { abbreviateNumber, scaleSuffix } from '@ui-kit/utils/number' + +const { Spacing } = SizesAndSpaces + +type Collateral = { + total: number | undefined | null + totalUsdValue: number | undefined | null + symbol: string | undefined | null + tokenAddress: string | undefined | null + usdRate: number | undefined | null + loading: boolean +} +type BorrowToken = { + total?: number | undefined | null + totalUsdValue?: number | undefined | null + symbol: string | undefined | null + tokenAddress: string | undefined | null + usdRate: number | undefined | null + loading: boolean +} +type BorrowAPR = { + value: number | undefined | null + thirtyDayAvgRate: number | undefined | null + loading: boolean +} +type LendingAPY = { + value: number | undefined | null + thirtyDayAvgRate: number | undefined | null + loading: boolean +} +type AvailableLiquidity = { + value: number | undefined | null + max: number | undefined | null + loading: boolean +} +type MaxLeverage = { + value: number | undefined | null + loading: boolean +} + +export type MarketDetailsProps = { + /** + * Use true if Market Details appear on the top of the page, the upper row will have larger cells + */ + marketPage: boolean + collateral: Collateral + borrowToken: BorrowToken + borrowAPR: BorrowAPR + lendingAPY?: LendingAPY + availableLiquidity: AvailableLiquidity + maxLeverage?: MaxLeverage +} + +const formatLiquidity = (value: number) => + `${formatNumber(abbreviateNumber(value), { ...FORMAT_OPTIONS.USD })}${scaleSuffix(value).toUpperCase()}` + +export const MarketDetails = ({ + marketPage = false, + collateral, + borrowToken, + borrowAPR, + lendingAPY, + availableLiquidity, + maxLeverage, +}: MarketDetailsProps) => { + const utilization = + availableLiquidity?.value && availableLiquidity.max + ? (availableLiquidity.value / availableLiquidity.max) * 100 + : undefined + const utilizationBreakdown = + availableLiquidity?.value && availableLiquidity.max + ? `${formatLiquidity(availableLiquidity.value)}/${formatLiquidity(availableLiquidity.max)}` + : undefined + + return ( + t.design.Layer[1].Fill }}> + + + + {lendingAPY && ( + + )} + + + {/* Insert empty box to maintain grid layout when there is no lending APY metric */} + {!lendingAPY && } + + + + {maxLeverage && ( + + )} + + + ) +} diff --git a/packages/curve-ui-kit/src/shared/ui/Metric.tsx b/packages/curve-ui-kit/src/shared/ui/Metric.tsx index d5fb3b810..7f9759f33 100644 --- a/packages/curve-ui-kit/src/shared/ui/Metric.tsx +++ b/packages/curve-ui-kit/src/shared/ui/Metric.tsx @@ -20,14 +20,14 @@ const { Spacing, IconSize } = SizesAndSpaces export const ALIGNMENTS = ['start', 'center', 'end'] as const type Alignment = (typeof ALIGNMENTS)[number] -const MetricSize = { +export const MetricSize = { small: 'highlightM', medium: 'highlightL', large: 'highlightXl', extraLarge: 'highlightXxl', } as const satisfies Record -const MetricUnitSize = { +export const MetricUnitSize = { small: 'highlightXs', medium: 'highlightS', large: 'highlightM', diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx new file mode 100644 index 000000000..ed052eb9d --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx @@ -0,0 +1,146 @@ +import { CardHeader, Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { Metric } from '@ui-kit/shared/ui/Metric' +import type { + Pnl, + BorrowAPR, + Leverage, + CollateralValue, + Ltv, + TotalDebt, + LiquidationRange, + BandRange, +} from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' +import { CollateralMetricTooltip } from '@ui-kit/shared/ui/PositionDetails/tooltips/CollateralMetricTooltip' +import { LiquidityThresholdTooltip } from '@ui-kit/shared/ui/PositionDetails/tooltips/LiquidityThresholdMetricTooltip' +import { PnlMetricTooltip } from '@ui-kit/shared/ui/PositionDetails/tooltips/PnlMetricTooltip' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +const dollarUnitOptions = { + abbreviate: false, + unit: { + symbol: '$', + position: 'prefix' as const, + abbreviate: false, + }, +} + +type BorrowInformationProps = { + borrowAPR: BorrowAPR | undefined | null + pnl: Pnl | undefined | null + collateralValue: CollateralValue | undefined | null + ltv: Ltv | undefined | null + leverage: Leverage | undefined | null + liquidationRange: LiquidationRange | undefined | null + bandRange: BandRange | undefined | null + totalDebt: TotalDebt | undefined | null +} + +export const BorrowInformation = ({ + borrowAPR, + pnl, + collateralValue, + ltv, + leverage, + liquidationRange, + bandRange, + totalDebt, +}: BorrowInformationProps) => ( + + + + + {pnl && ( // PNL is only available on lend for now + , + placement: 'top', + arrow: false, + }} + /> + )} + , + placement: 'top', + arrow: false, + }} + /> + + {leverage && + leverage?.value && + leverage?.value > 1 && ( // Leverage is only available on lend for now + + )} + , + placement: 'top', + arrow: false, + }} + notional={ + liquidationRange?.rangeToLiquidation + ? { + value: liquidationRange.rangeToLiquidation, + unit: { symbol: '% Buffer to liquidation', position: 'suffix' }, + } + : undefined + } + /> + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowPositionDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowPositionDetails.tsx new file mode 100644 index 000000000..9efb68cd1 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowPositionDetails.tsx @@ -0,0 +1,103 @@ +import { Box, Typography } from '@mui/material' +import { Alert } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { BorrowInformation } from '@ui-kit/shared/ui/PositionDetails/BorrowInformation' +import { HealthDetails } from '@ui-kit/shared/ui/PositionDetails/HealthDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +export type Pnl = { + currentProfit: number | undefined | null + currentPositionValue: number | undefined | null + depositedValue: number | undefined | null + percentageChange: number | undefined | null + loading: boolean +} +export type Health = { value: number | undefined | null; loading: boolean } +export type BorrowAPR = { + value: number | undefined | null + thirtyDayAvgRate: number | undefined | null + loading: boolean +} +export type LiquidationRange = { + value: number[] | undefined | null + rangeToLiquidation: number | undefined | null + loading: boolean +} +export type BandRange = { value: number[] | undefined | null; loading: boolean } +export type Leverage = { value: number | undefined | null; loading: boolean } +export type CollateralValue = { + totalValue: number | undefined | null + collateral: { + value: number | undefined | null + usdRate: number | undefined | null + symbol: string | undefined + } + borrow: { + value: number | undefined | null + usdRate: number | undefined | null + symbol: string | undefined + } + loading: boolean +} +export type Ltv = { value: number | undefined | null; loading: boolean } +export type TotalDebt = { value: number | undefined | null; loading: boolean } + +export type BorrowPositionDetailsProps = { + isSoftLiquidation: boolean + health: Health + borrowAPR: BorrowAPR + pnl?: Pnl // doesn't exist yet for crvusd + liquidationRange: LiquidationRange + bandRange: BandRange + leverage?: Leverage // doesn't exist yet for crvusd + collateralValue: CollateralValue + ltv: Ltv + totalDebt: TotalDebt +} + +export const BorrowPositionDetails = ({ + isSoftLiquidation, + health, + borrowAPR, + pnl, + liquidationRange, + bandRange, + leverage, + collateralValue, + ltv, + totalDebt, +}: BorrowPositionDetailsProps) => ( + + {isSoftLiquidation && ( + + + + {t`Soft-Liquidation active`} + + {t`Price has entered the liquidation zone and your collateral is at risk. Manage your position to avoid full liquidation.`} + + + + + )} + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx new file mode 100644 index 000000000..bfa6e8294 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx @@ -0,0 +1,98 @@ +import { Box, Typography, type Theme } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { Reds, Blues } from '@ui-kit/themes/design/0_primitives' + +type HealthBarProps = { + health: number | undefined | null +} + +const BAR_HEIGHT = '1.4375rem' // 23px +const LINE_WIDTH = '0.25rem' // 4px +const LABEL_GAP = '0.125rem' // 2px + +type LineColor = 'red' | 'orange' | 'green' | 'dark-green' + +const getLineColor = (color: LineColor) => (t: Theme) => + ({ + red: t.design.Color.Tertiary[600], + orange: t.design.Color.Tertiary[400], + ['dark-green']: t.design.Color.Secondary[600], + green: t.design.Color.Secondary[500], + })[color] + +const Line = ({ first, position, color }: { first?: boolean; position: string; color: LineColor }) => ( + +) + +const Label = ({ + first, + last, + position, + text, +}: { + first?: boolean + last?: boolean + position: string + text: string +}) => ( + + + {text} + + +) + +export const HealthBar = ({ health }: HealthBarProps) => { + // Clamps health percentage between 0 and 100 + const healthPercentage = Math.max(0, Math.min(health ?? 0, 100)) + const trackColor = health != null && health < 5 ? Reds[500] : Blues[500] + + return ( + + + + t.design.Color.Neutral[300], + transition: 'background-color 0.3s ease-in-out', + }} + > + + + + + + + + ) +} diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx new file mode 100644 index 000000000..5d3715280 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx @@ -0,0 +1,54 @@ +import { Box, Typography } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { Metric } from '@ui-kit/shared/ui/Metric' +import type { Health } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' +import { HealthBar } from '@ui-kit/shared/ui/PositionDetails/HealthBar' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +const getHealthValueColor = (value: number) => { + if (value < 5) return 'error' + if (value < 15) return 'warning' + return 'textPrimary' +} + +export const HealthDetails = ({ health }: { health: Health }) => ( + + t.design.Layer.Highlight.Fill, + }} + > + + + + + + + {t`Health determines a position liquidation. It is not directly correlated to the price of the collateral. `} + + t.typography.fontWeightBold }}> + {t`Liquidations occur when health reaches 0.`} + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/LendPositionDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/LendPositionDetails.tsx new file mode 100644 index 000000000..6568e8d1b --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/LendPositionDetails.tsx @@ -0,0 +1,89 @@ +import { CardHeader, Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { SymbolCell } from '@ui-kit/shared/ui/MarketDetails/SymbolCell' +import { Metric } from '@ui-kit/shared/ui/Metric' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +type LendingAPY = { + value: number | undefined | null + thirtyDayAvgRate: number | undefined | null + loading: boolean +} +type Shares = { + value: number | undefined | null + staked: number | undefined | null + loading: boolean +} + +// TODO: figure out where to find boost data and add +type Boost = { + value: number | undefined | null + loading: boolean +} +type LentAsset = { + symbol: string | undefined | null + address: string | undefined | null + usdRate: number | undefined | null + depositedAmount: number | undefined | null + depositedUsdValue: number | undefined | null + loading: boolean +} + +export type LendPositionDetailsProps = { + lendingAPY: LendingAPY + shares: Shares + lentAsset: LentAsset +} + +export const LendPositionDetails = ({ lendingAPY, shares, lentAsset }: LendPositionDetailsProps) => ( + + + + + + + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx new file mode 100644 index 000000000..2160acd5b --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import { Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { TabsSwitcher } from '@ui-kit/shared/ui/TabsSwitcher' +import { BorrowPositionDetails, type BorrowPositionDetailsProps } from './BorrowPositionDetails' +import { LendPositionDetails, type LendPositionDetailsProps } from './LendPositionDetails' + +type PositionDetailsProps = { + borrowPositionDetails: BorrowPositionDetailsProps + lendPositionDetails?: LendPositionDetailsProps // lend is optional since it's not available for crvusd +} + +const TABS = [ + { value: 'borrow', label: t`Borrow`, href: '' }, + { value: 'lend', label: t`Lend`, href: '' }, +] + +export const PositionDetails = ({ borrowPositionDetails, lendPositionDetails }: PositionDetailsProps) => { + const [tab, setTab] = useState<(typeof TABS)[number]['value']>(TABS[0].value) + return ( + + {lendPositionDetails && ( + + )} + t.design.Layer[1].Fill }}> + {tab === 'borrow' && } + {tab === 'lend' && lendPositionDetails && } + + + ) +} diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/CollateralMetricTooltip.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/CollateralMetricTooltip.tsx new file mode 100644 index 000000000..f4e54ab56 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/CollateralMetricTooltip.tsx @@ -0,0 +1,82 @@ +import { Stack, Typography } from '@mui/material' +import { FORMAT_OPTIONS, formatNumber } from '@ui/utils/utilsFormat' +import { t } from '@ui-kit/lib/i18n' +import { CollateralValue } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +type CollateralMetricTooltipProps = { + collateralValue: CollateralValue | undefined | null +} + +const formatMetricValue = (value?: number | null) => { + if (value === 0) return '0' + if (value) return formatNumber(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + return '-' +} + +const formatPercentage = ( + value: number | undefined | null, + totalValue: number | undefined | null, + usdRate: number | undefined | null, +) => { + if (value === 0) return '0.00%' + if (value && totalValue && usdRate) { + return formatNumber(((value * usdRate) / totalValue) * 100, { + ...FORMAT_OPTIONS.PERCENT, + }) + } + return null +} + +export const CollateralMetricTooltip = ({ collateralValue }: CollateralMetricTooltipProps) => { + const collateralValueFormatted = formatMetricValue(collateralValue?.collateral?.value) + const collateralPercentage = formatPercentage( + collateralValue?.collateral?.value, + collateralValue?.totalValue, + collateralValue?.collateral?.usdRate, + ) + + const crvUSDValueFormatted = formatMetricValue(collateralValue?.borrow?.value) + const crvUSDPercentage = formatPercentage( + collateralValue?.borrow?.value, + collateralValue?.totalValue, + collateralValue?.borrow?.usdRate, + ) + + const totalValueFormatted = collateralValue?.totalValue + ? formatNumber(collateralValue.totalValue, { ...FORMAT_OPTIONS.USD }) + : '-' + + return ( + + {t`Collateral value is taken by multiplying tokens in collateral by the oracle price. In soft liquidation, it may include crvUSD due to liquidation protection.`} + + t.design.Layer[2].Fill, padding: Spacing.sm }}> + {t`Breakdown`} + + + {t`Deposit token`} + + {`${collateralValueFormatted} ${collateralValue?.collateral?.symbol ?? '-'}`} + {collateralPercentage && {`(${collateralPercentage})`}} + + + + + {collateralValue?.borrow?.symbol ?? '-'} + + {`${crvUSDValueFormatted} ${collateralValue?.borrow?.symbol ?? '-'}`} + {`(${crvUSDPercentage})`} + + + + + + {t`Total collateral value`} + {totalValueFormatted} + + + ) +} diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/LiquidityThresholdMetricTooltip.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/LiquidityThresholdMetricTooltip.tsx new file mode 100644 index 000000000..664f7de00 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/LiquidityThresholdMetricTooltip.tsx @@ -0,0 +1,43 @@ +import { Stack, Typography } from '@mui/material' +import { FORMAT_OPTIONS, formatNumber } from '@ui/utils/utilsFormat' +import { t } from '@ui-kit/lib/i18n' +import { BandRange, LiquidationRange } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +type LiquidityThresholdTooltipProps = { + liquidationRange: LiquidationRange | undefined | null + bandRange: BandRange | undefined | null +} + +export const LiquidityThresholdTooltip = ({ liquidationRange, bandRange }: LiquidityThresholdTooltipProps) => ( + + {t`The price at which your position enters liquidation protection and your collateral starts to be eroded.`} + + t.design.Layer[2].Fill, padding: Spacing.sm }}> + {t`Breakdown`} + + + {t`Liquidation threshold`} + + {liquidationRange?.value?.[1] ? formatNumber(liquidationRange.value[1], { ...FORMAT_OPTIONS.USD }) : '-'} + + + + + {t`Liquidation lower bound`} + + {liquidationRange?.value?.[0] ? formatNumber(liquidationRange.value[0], { ...FORMAT_OPTIONS.USD }) : '-'} + + + + + {t`Band range`} + + {bandRange?.value ? `${bandRange.value[0]} - ${bandRange.value[1]}` : '-'} + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/PnlMetricTooltip.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/PnlMetricTooltip.tsx new file mode 100644 index 000000000..5d7138e67 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/tooltips/PnlMetricTooltip.tsx @@ -0,0 +1,47 @@ +import { Stack, Typography } from '@mui/material' +import { FORMAT_OPTIONS, formatNumber } from '@ui/utils/utilsFormat' +import { t } from '@ui-kit/lib/i18n' +import { Pnl } from '@ui-kit/shared/ui/PositionDetails/BorrowPositionDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +type PnlMetricTooltipProps = { + pnl: Pnl | undefined | null +} + +export const PnlMetricTooltip = ({ pnl }: PnlMetricTooltipProps) => ( + + {t`Profit and Loss (PnL) is calculated based on the value of the collateral at deposits minus the borrow costs and eventual losses if the position was in soft-liquidation.`} + + t.design.Layer[2].Fill, padding: Spacing.sm }}> + {t`Breakdown`} + + + {t`Collateral value`} + + {pnl?.currentPositionValue ? formatNumber(pnl.currentPositionValue, { ...FORMAT_OPTIONS.USD }) : '-'} + + + + + {t`Value at deposit`} + + {pnl?.depositedValue ? formatNumber(pnl.depositedValue, { ...FORMAT_OPTIONS.USD }) : '-'} + + + + + {t`Profit/Loss`} + + + {pnl?.currentProfit ? formatNumber(pnl.currentProfit, { ...FORMAT_OPTIONS.USD }) : '-'} + + {pnl?.currentPositionValue && ( + {`(${formatNumber(pnl.currentPositionValue, { ...FORMAT_OPTIONS.PERCENT })})`} + )} + + + + +)