diff --git a/apps/main/src/llamalend/entities/lending-vaults.ts b/apps/main/src/llamalend/entities/lending-vaults.ts index 05475034a3..9f25476651 100644 --- a/apps/main/src/llamalend/entities/lending-vaults.ts +++ b/apps/main/src/llamalend/entities/lending-vaults.ts @@ -46,7 +46,11 @@ const { validationSuite: userAddressValidationSuite, }) -const { useQuery: useUserLendingVaultStatsQuery, invalidate: invalidateUserLendingVaultStats } = queryFactory({ +const { + useQuery: useUserLendingVaultStatsQuery, + getQueryOptions: getUserLendingVaultStatsQueryOptions, + invalidate: invalidateUserLendingVaultStats, +} = queryFactory({ queryKey: ({ userAddress, contractAddress, blockchainId }: UserContractParams) => ['user-lending-vault', 'stats', { blockchainId }, { contractAddress }, { userAddress }, 'v1'] as const, queryFn: async ({ userAddress, contractAddress, blockchainId }: UserContractQuery): Promise => @@ -54,7 +58,11 @@ const { useQuery: useUserLendingVaultStatsQuery, invalidate: invalidateUserLendi validationSuite: userContractValidationSuite, }) -const { useQuery: useUserLendingVaultEarningsQuery, invalidate: invalidateUserLendingVaultEarnings } = queryFactory({ +const { + useQuery: useUserLendingVaultEarningsQuery, + getQueryOptions: getUserLendingVaultEarningsQueryOptions, + invalidate: invalidateUserLendingVaultEarnings, +} = queryFactory({ queryKey: ({ userAddress, contractAddress, blockchainId }: UserContractParams) => ['user-lending-vault', 'earnings', { blockchainId }, { contractAddress }, { userAddress }, 'v1'] as const, queryFn: ({ userAddress, contractAddress, blockchainId }: UserContractQuery) => @@ -111,5 +119,7 @@ export function invalidateAllUserLendingSupplies(userAddress: Address | undefine export const getUserLendingSuppliesOptions = getUserLendingSuppliesQueryOptions export const useUserLendingVaultEarnings = useUserLendingVaultEarningsQuery +export const getUserLendingVaultEarningsOptions = getUserLendingVaultEarningsQueryOptions export const getUserLendingVaultsOptions = getUserLendingVaultsQueryOptions export const useUserLendingVaultStats = useUserLendingVaultStatsQuery +export const getUserLendingVaultStatsOptions = getUserLendingVaultStatsQueryOptions diff --git a/apps/main/src/llamalend/entities/mint-markets.ts b/apps/main/src/llamalend/entities/mint-markets.ts index ad2ce23fb3..ab5ad9fe19 100644 --- a/apps/main/src/llamalend/entities/mint-markets.ts +++ b/apps/main/src/llamalend/entities/mint-markets.ts @@ -40,7 +40,11 @@ const { export const getUserMintMarketsOptions = getUserMintMarketsQueryOptions -const { useQuery: useUserMintMarketStatsQuery, invalidate: invalidateUserMintMarketStats } = queryFactory({ +const { + useQuery: useUserMintMarketStatsQuery, + getQueryOptions: getUserMintMarketStatsQueryOptions, + invalidate: invalidateUserMintMarketStats, +} = queryFactory({ queryKey: ({ userAddress, blockchainId, contractAddress }: UserContractParams) => ['user-mint-markets', 'stats', { blockchainId }, { contractAddress }, { userAddress }, 'v1'] as const, queryFn: ({ userAddress, blockchainId, contractAddress }: UserContractQuery) => @@ -62,3 +66,4 @@ export const invalidateAllUserMintMarkets = (userAddress: Address | undefined) = } export const useUserMintMarketStats = useUserMintMarketStatsQuery +export const getUserMintMarketStatsOptions = getUserMintMarketStatsQueryOptions diff --git a/apps/main/src/llamalend/entities/usd-prices.ts b/apps/main/src/llamalend/entities/usd-prices.ts index a48c3a33a8..32752eef8c 100644 --- a/apps/main/src/llamalend/entities/usd-prices.ts +++ b/apps/main/src/llamalend/entities/usd-prices.ts @@ -1,7 +1,10 @@ import { ContractParams, queryFactory, rootKeys } from '@ui-kit/lib/model' import { contractValidationSuite } from '@ui-kit/lib/model/query/contract-validation' -export const { useQuery: useTokenUsdPrice } = queryFactory({ +export const { + useQuery: useTokenUsdPrice, + getQueryOptions: getTokenUsdPriceOptions, +} = queryFactory({ queryKey: (params: ContractParams) => [...rootKeys.contract(params), 'usd-price'] as const, queryFn: async ({ blockchainId, contractAddress }: ContractParams) => { const response = await fetch(`https://prices.curve.finance/v1/usd_price/${blockchainId}/${contractAddress}`) diff --git a/apps/main/src/llamalend/features/market-list/UserPositionStatistics.tsx b/apps/main/src/llamalend/features/market-list/UserPositionStatistics.tsx deleted file mode 100644 index 4e5b9078ea..0000000000 --- a/apps/main/src/llamalend/features/market-list/UserPositionStatistics.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import Grid from '@mui/material/Grid' -import Skeleton from '@mui/material/Skeleton' -import Stack from '@mui/material/Stack' -import Typography from '@mui/material/Typography' -import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' -import { formatNumber } from '@ui-kit/utils' - -const { Spacing } = SizesAndSpaces - -const dummyData = [ - { - label: 'Total Collateral Value', - value: 1420000, - startAdornment: '$', - }, - { - label: 'Total Borrowed', - value: 645120, - startAdornment: '$', - }, - { - label: 'Total Supplied', - value: 128170, - startAdornment: '$', - }, - { - label: 'Claimable Rewards ', - value: 28170, - startAdornment: '$', - }, -] - -export const UserPositionStatistics = () => ( - t.design.Layer[1].Fill }}> - {dummyData.map((item) => ( - - ))} - -) - -const UserPositionStatisticItem = ({ - label, - value, - startAdornment, - isLoading, -}: { - label: string - value: string - startAdornment?: string - isLoading?: boolean -}) => ( - - - - {label} - - {isLoading ? ( - - ) : ( - - {startAdornment && ( - - {startAdornment} - - )} - {value} - - )} - - -) diff --git a/apps/main/src/llamalend/features/market-list/UserPositionSummary.tsx b/apps/main/src/llamalend/features/market-list/UserPositionSummary.tsx new file mode 100644 index 0000000000..f1f97c9879 --- /dev/null +++ b/apps/main/src/llamalend/features/market-list/UserPositionSummary.tsx @@ -0,0 +1,88 @@ +import Grid from '@mui/material/Grid' +import Skeleton from '@mui/material/Skeleton' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { type LlamaMarket } from '@/llamalend/entities/llama-markets' +import { useUserPositionsSummary } from './hooks/useUserPositionsSummary' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' +import { formatNumber } from '@ui-kit/utils' + +const { Spacing } = SizesAndSpaces + +type UserPositionStatisticsProps = { + markets: LlamaMarket[] | undefined + loading: boolean +} + +export const UserPositionSummary = ({ markets, loading }: UserPositionStatisticsProps) => { + const { totals, isInitialLoading } = useUserPositionsSummary(markets, { enabled: !loading }) + const showSkeletons = loading || isInitialLoading + const items = [ + { + label: 'Total Collateral Value', + value: totals.collateralValue, + startAdornment: '$', + }, + { + label: 'Total Borrowed', + value: totals.borrowedValue, + startAdornment: '$', + }, + { + label: 'Total Supplied', + value: totals.suppliedValue, + startAdornment: '$', + }, + { + label: 'Claimable Rewards', + value: totals.rewardsValue, + startAdornment: '$', + }, + ] as const + + return ( + t.design.Layer[1].Fill }}> + {items.map(({ label, value, startAdornment }) => ( + + ))} + + ) +} + +const UserPositionStatisticItem = ({ + label, + value, + startAdornment, + isLoading, +}: { + label: string + value: string + startAdornment?: string + isLoading?: boolean +}) => ( + + + + {label} + + {isLoading ? ( + + ) : ( + + {startAdornment && ( + + {startAdornment} + + )} + {value} + + )} + + +) diff --git a/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx b/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx index cb6d51350c..5448884a10 100644 --- a/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx +++ b/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { fromEntries } from '@curvefi/prices-api/objects.util' import Button from '@mui/material/Button' import Stack from '@mui/material/Stack' @@ -12,7 +12,7 @@ import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' import { MarketRateType } from '@ui-kit/types/market' import { LlamaMonitorBotButton } from './LlamaMonitorBotButton' import { UserPositionsTable, type UserPositionsTableProps } from './UserPositionsTable' -import { UserPositionStatistics } from './UserPositionStatistics' +import { UserPositionSummary } from './UserPositionSummary' const { Spacing, Height } = SizesAndSpaces @@ -57,6 +57,11 @@ export const UserPositionsTabs = (props: Omit(defaultTab.value) + // Update tab when defaultTab changes (e.g., when user positions data loads) + useEffect(() => { + setTab(defaultTab.value) + }, [defaultTab.value]) + return ( ) : ( <> - {!isMobile && } + {!isMobile && } `${blockchainId}-${contractAddress?.toLowerCase() ?? ''}` + +const getBorrowTokenAmount = (stats: BorrowStats | undefined) => { + if (!stats) return 0 + return 'borrowed' in stats ? stats.borrowed : stats.stablecoin +} + +export const useUserPositionsSummary = ( + markets: LlamaMarket[] | undefined, + { enabled = true }: UseUserPositionsSummaryOptions = {}, +) => { + const { address: userAddress } = useAccount() + const hasUser = Boolean(userAddress) + const safeMarkets = markets ?? [] + + const borrowMarkets = useMemo(() => safeMarkets.filter((market) => market.userHasPositions?.Borrow), [safeMarkets]) + const supplyMarkets = useMemo(() => safeMarkets.filter((market) => market.userHasPositions?.Supply), [safeMarkets]) + + const shouldFetch = enabled && hasUser + const tokenEntries = useMemo(() => { + const seen = new Set() + const entries: [string, ContractParams][] = [] + const addToken = (params: ContractParams | undefined) => { + if (!params?.contractAddress) return + const key = getTokenKey(params.blockchainId, params.contractAddress) + if (seen.has(key)) return + seen.add(key) + entries.push([key, params]) + } + borrowMarkets.forEach((market) => { + addToken({ blockchainId: market.chain, contractAddress: market.assets.collateral.address }) + addToken({ blockchainId: market.chain, contractAddress: market.assets.borrowed.address }) + }) + supplyMarkets.forEach((market) => { + addToken({ blockchainId: market.chain, contractAddress: market.assets.borrowed.address }) + }) + return entries + }, [borrowMarkets, supplyMarkets]) + + const statsResults = useQueries({ + queries: borrowMarkets.map((market) => { + const params = { userAddress, contractAddress: market.controllerAddress, blockchainId: market.chain } + const statsOptions = + market.type === LlamaMarketType.Lend + ? getUserLendingVaultStatsOptions(params, shouldFetch) + : getUserMintMarketStatsOptions(params, shouldFetch) + return statsOptions + }), + }) + + const earningsResults = useQueries({ + queries: supplyMarkets.map((market) => + getUserLendingVaultEarningsOptions( + { userAddress, contractAddress: market.address, blockchainId: market.chain }, + shouldFetch, + ), + ), + }) + + const priceResults = useQueries({ + queries: tokenEntries.map(([, params]) => getTokenUsdPriceOptions(params, shouldFetch && tokenEntries.length > 0)), + }) + + const priceMap = useMemo(() => { + const map = new Map() + priceResults.forEach((result, index) => { + const key = tokenEntries[index]?.[0] + if (key && result.data != null) { + map.set(key, result.data) + } + }) + return map + }, [priceResults, tokenEntries]) + + const totals = useMemo(() => { + if (!shouldFetch) return ZERO_TOTALS + return borrowMarkets.reduce( + (acc, market, index) => { + const stats = statsResults[index]?.data as BorrowStats | undefined + if (stats) { + const collateralPrice = priceMap.get(getTokenKey(market.chain, market.assets.collateral.address)) + const borrowPrice = priceMap.get(getTokenKey(market.chain, market.assets.borrowed.address)) + const collateralAmount = stats.collateral ?? 0 + const borrowTokenAmount = getBorrowTokenAmount(stats) + if (collateralPrice != null) { + acc.collateralValue += collateralAmount * collateralPrice + } + if (borrowPrice != null) { + acc.collateralValue += borrowTokenAmount * borrowPrice + acc.borrowedValue += (stats.debt ?? 0) * borrowPrice + } + } + return acc + }, + { + collateralValue: 0, + borrowedValue: 0, + suppliedValue: 0, + rewardsValue: 0, + }, + ) + }, [borrowMarkets, priceMap, shouldFetch, statsResults]) + + const totalsWithSupply = useMemo(() => { + if (!shouldFetch) return ZERO_TOTALS + return supplyMarkets.reduce( + (acc, market, index) => { + const earnings = earningsResults[index]?.data + if (earnings) { + const borrowPrice = priceMap.get(getTokenKey(market.chain, market.assets.borrowed.address)) + if (borrowPrice != null) { + acc.suppliedValue += (earnings.totalCurrentAssets ?? 0) * borrowPrice + acc.rewardsValue += (earnings.earnings ?? 0) * borrowPrice + } + } + return acc + }, + { ...totals }, + ) + }, [earningsResults, priceMap, shouldFetch, supplyMarkets, totals]) + + const statsFetching = statsResults.some((result) => result.isFetching) + const earningsFetching = earningsResults.some((result) => result.isFetching) + const priceFetching = priceResults.some((result) => result.isFetching) + const hasStatsData = statsResults.some((result) => result.data != null) + const hasEarningsData = earningsResults.some((result) => result.data != null) + const hasAnyData = hasStatsData || hasEarningsData + + const isInitialLoading = shouldFetch && !hasAnyData && (statsFetching || earningsFetching || priceFetching) + + return { + totals: shouldFetch ? totalsWithSupply : ZERO_TOTALS, + isInitialLoading, + isFetching: shouldFetch && (statsFetching || earningsFetching || priceFetching), + } +}