diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index f50d44a8..18776025 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -214,6 +214,7 @@ Market metrics: external data API via `/v1/markets/metrics` | Vaults list | Morpho API | 5 min | `useAllMorphoVaultsQuery` | | User autovault metadata | Monarch GraphQL + on-chain enrichment | 60s | `useUserVaultsV2Query` | | Vault detail/settings metadata | Monarch GraphQL + narrow RPC fallback | 30s | `useVaultV2Data` | +| Vault detail native-yield/deposits/share-price history | Morpho Vault V2 historical state → archive RPC fallback for deposits and share price | 5 min stale | `useVaultHistoryQuery` | | Vault V2 rewards | Merkl API opportunities via `/api/merkl` | 5 min | `useVaultV2RewardsQuery` | | Market detail participants/activity | Monarch GraphQL + Morpho API fallback | 2-5 min stale | `useMarketSuppliers` / `useMarketBorrowers` / `useMarketSupplies` / `useMarketBorrows` | | Vault allocations | On-chain multicall | 30s | `useAllocationsQuery` | @@ -268,6 +269,7 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he | `usePublicAllocatorVaults` | Public allocator config for supplying vaults in a market | Morpho API only | Intentionally Morpho-only today | | `useAllocationsQuery` | Live vault `allocation(capId)` values | Pure RPC multicall | No Envio gap | | `usePublicAllocatorLiveData` | Live flow caps, vault supply, and liquidity for allocator UX | Pure RPC multicall | No Envio gap | +| `useVaultHistoryQuery` | Vault detail selected-period realized yield, current 6-hour native yield, total-deposit, and share-price charts | Morpho Vault V2 historical `avgApy` + `totalAssets` + `sharePrice`; realized/implied yield derived from share-price growth; archive RPC data source for `totalAssets` + `previewRedeem` fallback | Historical vault state snapshots or an equivalent accrued-assets series; cumulative Monarch deposit/withdraw totals do not include accrued yield | | `useVaultHistoricalApy` / `useErc4626VaultAPR` | Historical 4626 yield and expected carry calculations | Pure RPC share-price snapshots + RPC Morpho market reads | No Envio gap | #### RPC Helpers And External Reads @@ -306,7 +308,8 @@ Split: allMarkets vs whitelistedMarkets 2. Enrich user-specific balances / totalAssets via multicall where needed 3. Use narrow RPC fallback only when Monarch vault metadata is unavailable 4. Fetch live allocations from on-chain `allocation(capId)` reads -5. After vault writes, use shared bounded retry refreshes so Monarch indexing can catch up +5. Fetch detail-page native yield, deposits, and share price from Morpho Vault V2 history; fall back to archive RPC reads for deposits and share price +6. After vault writes, use shared bounded retry refreshes so Monarch indexing can catch up ``` --- diff --git a/src/components/layout/header/HeaderMenuItems.tsx b/src/components/layout/header/HeaderMenuItems.tsx index 7c2566d1..15c1b168 100644 --- a/src/components/layout/header/HeaderMenuItems.tsx +++ b/src/components/layout/header/HeaderMenuItems.tsx @@ -6,19 +6,20 @@ import { useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { FaRegMoon } from 'react-icons/fa'; import { LuSunMedium } from 'react-icons/lu'; -import { RiBookLine, RiBriefcaseLine, RiDiscordFill, RiLineChartLine, RiSafeLine, RiSwapLine } from 'react-icons/ri'; +import { RiBarChart2Line, RiBookLine, RiBriefcaseLine, RiDiscordFill, RiLineChartLine, RiSafeLine, RiSwapLine } from 'react-icons/ri'; import { useConnection } from 'wagmi'; import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import { useModal } from '@/hooks/useModal'; import { EXTERNAL_LINKS } from '@/utils/external'; type HeaderMenuItemsProps = { + includeAutovault?: boolean; iconSide?: 'start' | 'end'; itemClassName?: string; onSelect?: () => void; }; -export function HeaderMenuItems({ iconSide = 'end', itemClassName, onSelect }: HeaderMenuItemsProps) { +export function HeaderMenuItems({ includeAutovault = true, iconSide = 'end', itemClassName, onSelect }: HeaderMenuItemsProps) { const router = useRouter(); const { open: openModal } = useModal(); const { address } = useConnection(); @@ -84,12 +85,21 @@ export function HeaderMenuItems({ iconSide = 'end', itemClassName, onSelect }: H > Positions + {includeAutovault && ( + )} + className={itemClassName} + onClick={() => handleNavigation('/autovault')} + > + Autovaults + + )} )} + {...iconProps()} className={itemClassName} - onClick={() => handleNavigation('/autovault')} + onClick={() => handleNavigation('/analysis')} > - Autovaults + Analytics diff --git a/src/components/layout/header/Navbar.tsx b/src/components/layout/header/Navbar.tsx index 15a0e89c..006d1c05 100644 --- a/src/components/layout/header/Navbar.tsx +++ b/src/components/layout/header/Navbar.tsx @@ -100,10 +100,10 @@ export function Navbar() { - Analytics + Autovaults @@ -129,7 +129,7 @@ export function Navbar() { align="end" className="min-w-[180px]" > - + diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index 949057f6..fda9713b 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -6,7 +6,7 @@ export { type MonarchUserPositionState, } from './positions'; export { fetchMonarchUserTransactions } from './user-transactions'; -export { fetchCompletedPositionDailyFlows } from './position-daily-flows'; +export { fetchCompletedPositionDailyFlows, fetchPositionDailyFlows } from './position-daily-flows'; export { fetchMonarchMarketBorrowers, fetchMonarchMarketBorrows, diff --git a/src/data-sources/monarch-api/position-daily-flows.ts b/src/data-sources/monarch-api/position-daily-flows.ts index 70870515..21fc4f1f 100644 --- a/src/data-sources/monarch-api/position-daily-flows.ts +++ b/src/data-sources/monarch-api/position-daily-flows.ts @@ -25,23 +25,30 @@ const MAX_PAGES = 50; const getDayStart = (timestamp: number): number => Math.floor(timestamp / SECONDS_PER_DAY) * SECONDS_PER_DAY; -export async function fetchCompletedPositionDailyFlows({ +export async function fetchPositionDailyFlows({ userAddress, chainId, marketIds, startTimestamp, endTimestamp, + includeCurrentBucket = false, }: { userAddress: string; chainId: number; marketIds: string[]; startTimestamp: number; endTimestamp: number; + includeCurrentBucket?: boolean; }): Promise { const startBucket = getDayStart(startTimestamp); - // The current UTC bucket is mutable. Excluding it keeps every paged row - // stable; the chart uses the live on-chain position for its final point. - const endBucket = Math.min(getDayStart(endTimestamp), getDayStart(Math.floor(Date.now() / 1000))); + const currentBucket = getDayStart(Math.floor(Date.now() / 1000)); + const shouldIncludeCurrentBucket = includeCurrentBucket && endTimestamp >= currentBucket; + // All-time history excludes the mutable current bucket so pagination stays + // stable. Bounded charts include it to avoid a stale penultimate point; the + // live on-chain position remains the final source of truth. + const lastBucket = shouldIncludeCurrentBucket ? currentBucket + SECONDS_PER_DAY : currentBucket; + const requestedEndBucket = getDayStart(endTimestamp) + (shouldIncludeCurrentBucket ? SECONDS_PER_DAY : 0); + const endBucket = Math.min(requestedEndBucket, lastBucket); if (marketIds.length === 0 || endBucket <= startBucket) { return []; } @@ -87,3 +94,6 @@ export async function fetchCompletedPositionDailyFlows({ throw new Error(`Position daily flow history exceeded the safe pagination limit (${MAX_PAGES * PAGE_SIZE} rows)`); } + +export const fetchCompletedPositionDailyFlows = (options: Omit[0], 'includeCurrentBucket'>) => + fetchPositionDailyFlows(options); diff --git a/src/data-sources/monarch-api/user-transactions.ts b/src/data-sources/monarch-api/user-transactions.ts index 826bcb27..d8e328be 100644 --- a/src/data-sources/monarch-api/user-transactions.ts +++ b/src/data-sources/monarch-api/user-transactions.ts @@ -6,7 +6,7 @@ import { dedupeUserTransactions, sortUserTransactions } from '@/utils/user-trans import { monarchGraphqlFetcher } from './fetchers'; const MAX_PAGES = 50; -const MONARCH_USER_TRANSACTIONS_BATCH_SIZE = 500; +const MONARCH_USER_TRANSACTIONS_BATCH_SIZE = 1_000; const MONARCH_USER_TRANSACTIONS_TIMEOUT_MS = 15_000; type MonarchUserActivityRow = { diff --git a/src/data-sources/morpho-api/vault-history.ts b/src/data-sources/morpho-api/vault-history.ts new file mode 100644 index 00000000..4897a129 --- /dev/null +++ b/src/data-sources/morpho-api/vault-history.ts @@ -0,0 +1,89 @@ +import { supportsMorphoApi } from '@/config/dataSources'; +import { vaultV2HistoryQuery } from '@/graphql/vault-queries'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { TimeseriesOptions } from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; + +export type MorphoVaultHistoryPoint = { + timestamp: number; + value: number; +}; + +export type MorphoVaultHistory = { + nativeApy: MorphoVaultHistoryPoint[]; + sharePrice: MorphoVaultHistoryPoint[]; + totalAssets: MorphoVaultHistoryPoint[]; +}; + +type RawHistoryPoint = { + x: unknown; + y: unknown; +}; + +type VaultV2HistoryResponse = { + data?: { + vaultV2ByAddress?: { + historicalState?: { + avgApy?: RawHistoryPoint[] | null; + sharePrice?: RawHistoryPoint[] | null; + totalAssets?: RawHistoryPoint[] | null; + } | null; + } | null; + }; +}; + +function normalizePoints(points: RawHistoryPoint[] | null | undefined): MorphoVaultHistoryPoint[] { + return (points ?? []) + .map((point) => { + if (point.x === null || point.x === undefined || point.y === null || point.y === undefined) { + return null; + } + + const timestamp = Number(point.x); + const value = Number(point.y); + + if (!Number.isFinite(timestamp) || !Number.isFinite(value)) { + return null; + } + + return { timestamp, value }; + }) + .filter((point): point is MorphoVaultHistoryPoint => point !== null) + .sort((left, right) => left.timestamp - right.timestamp); +} + +export async function fetchMorphoVaultV2History({ + vaultAddress, + chainId, + options, +}: { + vaultAddress: string; + chainId: SupportedNetworks; + options: TimeseriesOptions; +}): Promise { + if (!supportsMorphoApi(chainId)) { + return null; + } + + try { + const response = await morphoGraphqlFetcher( + vaultV2HistoryQuery, + { address: vaultAddress, chainId, options }, + { timeoutMs: 8000 }, + ); + const history = response?.data?.vaultV2ByAddress?.historicalState; + + if (!history) { + return null; + } + + return { + nativeApy: normalizePoints(history.avgApy), + sharePrice: normalizePoints(history.sharePrice), + totalAssets: normalizePoints(history.totalAssets), + }; + } catch (error) { + console.warn('[vaultHistory] Morpho API vault history unavailable:', error); + return null; + } +} diff --git a/src/data-sources/morpho-api/vault-share-price-history.ts b/src/data-sources/morpho-api/vault-share-price-history.ts deleted file mode 100644 index 5fa4950a..00000000 --- a/src/data-sources/morpho-api/vault-share-price-history.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { supportsMorphoApi } from '@/config/dataSources'; -import { vaultV2SharePriceHistoryQuery } from '@/graphql/vault-queries'; -import type { SupportedNetworks } from '@/utils/networks'; -import type { TimeseriesOptions } from '@/utils/types'; -import { morphoGraphqlFetcher } from './fetchers'; - -export type MorphoVaultSharePricePoint = { - timestamp: number; - sharePrice: number; -}; - -type RawSharePricePoint = { - x: unknown; - y: unknown; -}; - -type VaultV2SharePriceHistoryResponse = { - data?: { - vaultV2ByAddress?: { - historicalState?: { - sharePrice?: RawSharePricePoint[] | null; - } | null; - } | null; - }; - errors?: { message: string }[]; -}; - -const sortByTimestamp = (left: MorphoVaultSharePricePoint, right: MorphoVaultSharePricePoint): number => left.timestamp - right.timestamp; - -function normalizeSharePrice(value: unknown): number | null { - const numericValue = typeof value === 'string' ? Number(value) : value; - - if (typeof numericValue !== 'number' || !Number.isFinite(numericValue)) { - return null; - } - - return numericValue; -} - -export async function fetchMorphoVaultV2SharePriceHistory({ - vaultAddress, - chainId, - options, -}: { - vaultAddress: string; - chainId: SupportedNetworks; - options: TimeseriesOptions; -}): Promise { - if (!supportsMorphoApi(chainId)) { - return null; - } - - try { - const response = await morphoGraphqlFetcher( - vaultV2SharePriceHistoryQuery, - { - address: vaultAddress, - chainId, - options, - }, - { timeoutMs: 8000 }, - ); - - const points = response?.data?.vaultV2ByAddress?.historicalState?.sharePrice; - if (!points || points.length === 0) { - return null; - } - - const normalizedPoints = points - .map((point) => { - const timestamp = Number(point.x); - const sharePrice = normalizeSharePrice(point.y); - if (!Number.isFinite(timestamp) || sharePrice === null) { - return null; - } - - return { - timestamp, - sharePrice, - }; - }) - .filter((point): point is MorphoVaultSharePricePoint => point !== null) - .sort(sortByTimestamp); - - return normalizedPoints.length > 0 ? normalizedPoints : null; - } catch (error) { - console.warn('[vaultSharePriceHistory] Morpho API share price history unavailable:', error); - return null; - } -} diff --git a/src/data-sources/rpc/vault-history.ts b/src/data-sources/rpc/vault-history.ts new file mode 100644 index 00000000..e44d6ad9 --- /dev/null +++ b/src/data-sources/rpc/vault-history.ts @@ -0,0 +1,122 @@ +import { formatUnits, type Address, type PublicClient } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { fetchBlocksWithTimestamps, type BlockWithTimestamp } from '@/utils/blockEstimation'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +const PARALLEL_BATCH_SIZE = 6; + +export type RpcVaultHistoryPoint = { + blockNumber: number; + timestamp: number; + value: number; +}; + +export type RpcVaultHistory = { + sharePrice: RpcVaultHistoryPoint[]; + totalAssets: RpcVaultHistoryPoint[]; +}; + +type FetchRpcVaultHistoryArgs = { + assetDecimals: number; + chainId: SupportedNetworks; + endTimestamp: number; + intervalSeconds: number; + startTimestamp: number; + vaultAddress: Address; + customRpcUrl?: string; +}; + +function calculateTimePoints(startTimestamp: number, endTimestamp: number, intervalSeconds: number): number[] { + const points: number[] = []; + + for (let timestamp = startTimestamp; timestamp < endTimestamp; timestamp += intervalSeconds) { + points.push(timestamp); + } + + points.push(endTimestamp); + return points; +} + +async function fetchVaultHistoryPoint( + client: PublicClient, + vaultAddress: Address, + oneShareUnit: bigint, + assetDecimals: number, + block: BlockWithTimestamp, +): Promise<{ sharePrice?: RpcVaultHistoryPoint; totalAssets?: RpcVaultHistoryPoint }> { + try { + const [totalAssetsResult, sharePriceResult] = await client.multicall({ + blockNumber: BigInt(block.blockNumber), + allowFailure: true, + contracts: [ + { + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'totalAssets', + }, + { + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'previewRedeem', + args: [oneShareUnit], + }, + ], + }); + const point = { blockNumber: block.blockNumber, timestamp: block.targetTimestamp }; + const totalAssets = totalAssetsResult.status === 'success' ? Number(formatUnits(totalAssetsResult.result, assetDecimals)) : Number.NaN; + const sharePrice = sharePriceResult.status === 'success' ? Number(formatUnits(sharePriceResult.result, assetDecimals)) : Number.NaN; + + return { + totalAssets: Number.isFinite(totalAssets) ? { ...point, value: totalAssets } : undefined, + sharePrice: Number.isFinite(sharePrice) ? { ...point, value: sharePrice } : undefined, + }; + } catch { + return {}; + } +} + +export async function fetchRpcVaultHistory({ + assetDecimals, + chainId, + customRpcUrl, + endTimestamp, + intervalSeconds, + startTimestamp, + vaultAddress, +}: FetchRpcVaultHistoryArgs): Promise { + const client = getClient(chainId, customRpcUrl); + const [currentBlock, shareDecimals] = await Promise.all([ + client.getBlockNumber(), + client.readContract({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'decimals', + }), + ]); + const currentBlockData = await client.getBlock({ blockNumber: currentBlock }); + const currentTimestamp = Number(currentBlockData.timestamp); + const boundedEndTimestamp = Math.min(endTimestamp, currentTimestamp); + const targetTimestamps = calculateTimePoints(Math.min(startTimestamp, boundedEndTimestamp), boundedEndTimestamp, intervalSeconds); + const blocks = await fetchBlocksWithTimestamps(client, chainId, targetTimestamps, Number(currentBlock), currentTimestamp); + const oneShareUnit = 10n ** BigInt(shareDecimals); + const sharePrice: RpcVaultHistoryPoint[] = []; + const totalAssets: RpcVaultHistoryPoint[] = []; + + for (let index = 0; index < blocks.length; index += PARALLEL_BATCH_SIZE) { + const batch = blocks.slice(index, index + PARALLEL_BATCH_SIZE); + const results = await Promise.all( + batch.map((block) => fetchVaultHistoryPoint(client, vaultAddress, oneShareUnit, assetDecimals, block)), + ); + + for (const result of results) { + if (result.totalAssets) totalAssets.push(result.totalAssets); + if (result.sharePrice) sharePrice.push(result.sharePrice); + } + } + + return { + sharePrice: sharePrice.sort((left, right) => left.timestamp - right.timestamp), + totalAssets: totalAssets.sort((left, right) => left.timestamp - right.timestamp), + }; +} diff --git a/src/features/autovault/vault-view.tsx b/src/features/autovault/vault-view.tsx index 8f1066c8..bb3b0ad8 100644 --- a/src/features/autovault/vault-view.tsx +++ b/src/features/autovault/vault-view.tsx @@ -18,9 +18,15 @@ import { parseCapIdParams } from '@/utils/morpho'; import { VaultInitializationModal } from '@/features/autovault/components/vault-detail/modals/vault-initialization-modal'; import { VaultMarketAllocations } from '@/features/autovault/components/vault-detail/vault-market-allocations'; import { VaultSettingsModal } from '@/features/autovault/components/vault-detail/modals/vault-settings'; -import { VaultSharePriceChart } from '@/features/vault/components/vault-share-price-chart'; +import { + VaultAnalyticsPeriodControl, + vaultAnalyticsPeriodToTimeframe, + vaultAnalyticsTimeframeToEarningsPeriod, +} from '@/features/vault/components/vault-analytics-period-control'; +import { VaultHistoryCharts } from '@/features/vault/components/vault-history-charts'; import { useVaultSettingsModalStore } from '@/stores/vault-settings-modal-store'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; +import { useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; import { VaultHeader } from '@/features/autovault/components/vault-detail/vault-header'; import { useModal } from '@/hooks/useModal'; import { formatBalance } from '@/utils/balance'; @@ -37,6 +43,16 @@ export default function VaultContent() { const [hasMounted, setHasMounted] = useState(false); const { open: openModal } = useModal(); const { findToken } = useTokensQuery(); + const selectedAnalyticsTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); + const setAnalyticsTimeframe = useMarketDetailChartState((state) => state.setTimeframe); + const analyticsPeriod = vaultAnalyticsTimeframeToEarningsPeriod[selectedAnalyticsTimeframe]; + + const handleAnalyticsPeriodChange = useCallback( + (period: typeof analyticsPeriod) => { + setAnalyticsTimeframe(vaultAnalyticsPeriodToTimeframe[period]); + }, + [setAnalyticsTimeframe], + ); useEffect(() => { setHasMounted(true); @@ -291,12 +307,25 @@ export default function VaultContent() { )} - + + + + + {/* Market Allocations */} {isLoadingChart ? ( - - Loading position history... - + ) : chartError ? ( + + Loading: {title} + + {[32, 38, 44, 40, 52, 58, 54, 66, 62, 72, 76, 70].map((barHeight, index) => ( + + ))} + + + + + + + ); +} + // Pie chart data type type PieDataPoint = { key: string; // unique market key @@ -72,12 +106,14 @@ function ChartContent({ loanAssetSymbol, height, actions, + title, }: { dataPoints: PositionHistoryDataPoint[]; markets: MarketInfo[]; loanAssetSymbol: string; height: number; actions?: ReactNode; + title: string; }) { const chartColors = useChartColors(); @@ -163,7 +199,7 @@ function ChartContent({ return ( @@ -437,6 +473,7 @@ export function UserPositionsChart(props: UserPositionsChartProps) { loanAssetSymbol={chartParams.loanAssetSymbol} height={height} actions={props.actions} + title={props.title ?? 'Position History'} /> ); } diff --git a/src/features/vault/components/vault-adapter-position-overview.tsx b/src/features/vault/components/vault-adapter-position-overview.tsx index 9aa6c151..db51f553 100644 --- a/src/features/vault/components/vault-adapter-position-overview.tsx +++ b/src/features/vault/components/vault-adapter-position-overview.tsx @@ -5,7 +5,7 @@ import { ArrowRightIcon } from '@radix-ui/react-icons'; import type { Address } from 'viem'; import { Button } from '@/components/ui/button'; import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; -import { UserPositionsChart } from '@/features/positions/components/user-positions-chart'; +import { UserPositionsChart, UserPositionsChartSkeleton } from '@/features/positions/components/user-positions-chart'; import { usePositionChartTransactions } from '@/hooks/usePositionChartTransactions'; import { VaultMarketAllocationsTable } from '@/features/vault/components/vault-market-allocations-table'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; @@ -28,6 +28,7 @@ type VaultAdapterPositionOverviewProps = { chainId: SupportedNetworks; adapterAddress: Address; isEarningsLoading: boolean; + isSnapshotsLoading: boolean; actualBlockData: Record; period: EarningsPeriod; snapshotsByChain: Record>; @@ -95,6 +96,7 @@ export function VaultAdapterPositionOverview({ chainId, adapterAddress, isEarningsLoading, + isSnapshotsLoading, actualBlockData, period, snapshotsByChain, @@ -104,27 +106,28 @@ export function VaultAdapterPositionOverview({ }: VaultAdapterPositionOverviewProps) { const periodLabel = PERIOD_LABELS[period]; const detailHref = `/position/${chainId}/${groupedPosition.loanAssetAddress}/${adapterAddress}`; + const chartStartTimestamp = actualBlockData[chainId]?.timestamp; const { - transactions, - isLoading: isLoadingTransactions, - error: transactionError, + transactions: chartTransactions, + isLoading: isLoadingChartTransactions, + error: chartTransactionError, } = usePositionChartTransactions({ account: adapterAddress, groupedPosition, - startTimestamp: actualBlockData[chainId]?.timestamp, - useDailyBuckets: period === 'all', + startTimestamp: chartStartTimestamp, + useDailyBuckets: true, + includeCurrentDailyBucket: period !== 'all', }); + const isChartLoading = chartStartTimestamp === undefined || isSnapshotsLoading || isLoadingChartTransactions; return ( - {isLoadingTransactions ? ( - - Loading position history... - - ) : transactionError ? ( + {isChartLoading ? ( + + ) : chartTransactionError ? ( )} void; }; -const PERIOD_OPTIONS: PeriodSelectorOption[] = [ - { value: 'day', label: '24h' }, - { value: 'week', label: '7 days' }, - { value: 'month', label: '30 days' }, - { value: 'threemonth', label: '3 months' }, - { value: 'sixmonth', label: '6 months' }, +const PERIOD_OPTIONS: { value: VaultAnalyticsPeriod; label: string }[] = [ + { value: 'day', label: '1D' }, + { value: 'week', label: '7D' }, + { value: 'month', label: '30D' }, + { value: 'threemonth', label: '3M' }, + { value: 'sixmonth', label: '6M' }, ]; export const vaultAnalyticsTimeframeToEarningsPeriod: Record = { @@ -37,20 +37,29 @@ export const vaultAnalyticsPeriodToTimeframe: Record - - Period - { - if (period !== 'all') { - onChange(period); - } - }} - options={PERIOD_OPTIONS} - className="h-8 w-[110px] text-xs" - contentClassName="z-[3600]" - /> + + + {PERIOD_OPTIONS.map((option) => { + const isSelected = option.value === value; + + return ( + onChange(option.value)} + > + {option.label} + + ); + })} ); diff --git a/src/features/vault/components/vault-history-charts.tsx b/src/features/vault/components/vault-history-charts.tsx new file mode 100644 index 00000000..8d595360 --- /dev/null +++ b/src/features/vault/components/vault-history-charts.tsx @@ -0,0 +1,395 @@ +'use client'; + +import { useMemo, type ReactNode } from 'react'; +import type { Address } from 'viem'; +import { CartesianGrid, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { TableContainerWithDescription } from '@/components/common/table-container-with-header'; +import { Spinner } from '@/components/ui/spinner'; +import { supportsMorphoApi } from '@/config/dataSources'; +import { useChartColors } from '@/constants/chartColors'; +import { ChartTooltipContent, chartTooltipCursor, getTimeSeriesXAxisProps } from '@/features/market-detail/components/charts/chart-utils'; +import { useVaultHistoryQuery, type VaultHistoryPoint } from '@/hooks/queries/useVaultHistoryQuery'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { type ChartTimeframe, TIMEFRAME_CONFIG, useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; +import { formatReadableTokenAmount } from '@/utils/balance'; +import { formatChartTime } from '@/utils/chart'; +import type { SupportedNetworks } from '@/utils/networks'; +import { computeAnnualizedApyFromValueGrowth, formatRateAsPercentage, toDisplayRateFromApy } from '@/utils/rateMath'; +import type { TimeseriesOptions } from '@/utils/types'; + +type VaultHistoryChartsProps = { + vaultAddress: Address; + chainId: SupportedNetworks; + assetDecimals?: number; + assetSymbol?: string; + mode?: 'primary' | 'share-price'; +}; + +const SHARE_PRICE_MINIMUM_GROWTH: Record = { + '1d': 0.00035, + '7d': 0.0025, + '30d': 0.01, + '3m': 0.03, + '6m': 0.06, +}; + +type MetricChartProps = { + data: VaultHistoryPoint[]; + formatAxisValue: (value: number) => string; + formatValue: (value: number) => string; + isLoading: boolean; + metricLabel: string; + name: string; + title: string; + updating: boolean; + average?: number; + emptyMessage: string; + summary?: ReactNode; + yDomain?: [number | 'auto', number | 'auto']; +}; + +type MetricSummaryItem = { + label: string; + value: string; +}; + +function formatCompactAmount(value: number, symbol?: string): string { + if (!Number.isFinite(value)) return '--'; + + const formatted = formatReadableTokenAmount(value, { precision: 2 }); + + return symbol ? `${formatted} ${symbol}` : formatted; +} + +function formatPercent(value: number): string { + if (!Number.isFinite(value)) return '--'; + + return formatRateAsPercentage(value, 2); +} + +function formatSharePrice(value: number, symbol?: string): string { + if (!Number.isFinite(value)) return '--'; + + const formatted = value.toLocaleString('en-US', { + maximumFractionDigits: Math.abs(value) >= 1 ? 6 : 8, + minimumFractionDigits: Math.abs(value) >= 1 ? 4 : 0, + }); + + return symbol ? `${formatted} ${symbol}` : formatted; +} + +function formatChangePercent(value: number | null): string { + if (value === null || !Number.isFinite(value)) return '--'; + + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`; +} + +function MetricSummary({ items }: { items: MetricSummaryItem[] }) { + return ( + + + {items.map((item) => ( + + {item.label} + {item.value} + + ))} + + + ); +} + +function ChartStatus({ children }: { children: ReactNode }) { + return {children}; +} + +function MetricChartSkeleton() { + return ( + + Loading chart history + + + + {[30, 42, 38, 54, 62, 58, 72, 76, 68, 82, 88, 84].map((height, index) => ( + + ))} + + + ); +} + +function getChartRange(data: VaultHistoryPoint[]): TimeseriesOptions { + const startTimestamp = data[0]?.timestamp ?? 0; + const endTimestamp = data.at(-1)?.timestamp ?? startTimestamp + 1; + + return { startTimestamp, endTimestamp, interval: 'DAY' }; +} + +function getRateDomain(data: VaultHistoryPoint[]): [number, number] { + if (data.length === 0) return [0, 0.01]; + + const values = data.map((point) => point.value); + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const padding = Math.max((maximum - minimum) * 0.15, 0.0025); + + return [Math.min(0, minimum - padding), Math.max(0.01, maximum + padding)]; +} + +function getSharePriceDomain(data: VaultHistoryPoint[], timeframe: ChartTimeframe): [number, number] { + if (data.length === 0) return [0, 1]; + + const values = data.map((point) => point.value); + const baseline = data[0]?.value ?? 0; + const minimumGrowth = Math.max(Math.abs(baseline) * (SHARE_PRICE_MINIMUM_GROWTH[timeframe] ?? 0.01), Number.EPSILON); + const minimum = Math.max(0, Math.min(...values, baseline - minimumGrowth)); + const maximum = Math.max(...values, baseline + minimumGrowth); + + return maximum > minimum ? [minimum, maximum] : [minimum, minimum + Number.EPSILON]; +} + +function MetricChart({ + average, + data, + emptyMessage, + formatAxisValue, + formatValue, + isLoading, + metricLabel, + name, + summary, + title, + updating, + yDomain = [0, 'auto'], +}: MetricChartProps) { + const chartColors = useChartColors(); + const currentPoint = data.at(-1); + const chartRange = useMemo(() => getChartRange(data), [data]); + const metricSummary = summary ?? ( + + ); + const actions = updating ? ( + + + Updating + + ) : undefined; + + return ( + + {isLoading ? ( + + ) : data.length < 2 ? ( + {emptyMessage} + ) : ( + <> + {metricSummary} + + + + + + formatChartTime(time, chartRange.endTimestamp - chartRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + formatAxisValue(Number(value))} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={64} + domain={yDomain} + /> + {average === undefined ? null : ( + + )} + ( + + )} + /> + + + + + + > + )} + + ); +} + +export function VaultHistoryCharts({ vaultAddress, chainId, assetDecimals, assetSymbol, mode = 'primary' }: VaultHistoryChartsProps) { + const selectedTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); + const selectedTimeRange = useMarketDetailChartState((state) => state.selectedTimeRange); + const { isAprDisplay } = useAppSettings(); + const { data, isFetching, isLoading } = useVaultHistoryQuery({ + assetDecimals, + vaultAddress, + chainId, + timeframe: selectedTimeframe, + timeRange: selectedTimeRange, + }); + const totalAssets = data?.totalAssets ?? []; + const nativeApy = data?.nativeApy ?? []; + const sharePrice = data?.sharePrice ?? []; + const nativeRate = useMemo( + () => nativeApy.map((point) => ({ ...point, value: toDisplayRateFromApy(point.value, isAprDisplay) })), + [isAprDisplay, nativeApy], + ); + const nativeRateAverage = useMemo(() => { + if (nativeRate.length === 0) return undefined; + return nativeRate.reduce((sum, point) => sum + point.value, 0) / nativeRate.length; + }, [nativeRate]); + const nativeRateDomain = useMemo(() => getRateDomain(nativeRate), [nativeRate]); + const sharePriceDomain = useMemo(() => getSharePriceDomain(sharePrice, selectedTimeframe), [selectedTimeframe, sharePrice]); + const impliedApy = useMemo(() => { + const firstPoint = sharePrice[0]; + const lastPoint = sharePrice.at(-1); + + if (!firstPoint || !lastPoint) return null; + + return computeAnnualizedApyFromValueGrowth({ + currentValue: lastPoint.value, + pastValue: firstPoint.value, + periodSeconds: lastPoint.timestamp - firstPoint.timestamp, + }); + }, [sharePrice]); + const sharePriceChange = useMemo(() => { + const firstPoint = sharePrice[0]; + const lastPoint = sharePrice.at(-1); + + if (!firstPoint || !lastPoint || firstPoint.value <= 0) return null; + + return ((lastPoint.value - firstPoint.value) / firstPoint.value) * 100; + }, [sharePrice]); + const isMorphoSupported = supportsMorphoApi(chainId); + const isInitialLoading = assetDecimals === undefined || (isLoading && !data); + const isUpdating = isFetching && !isInitialLoading; + const rateLabel = isAprDisplay ? 'APR' : 'APY'; + const periodLabel = TIMEFRAME_CONFIG[selectedTimeframe].label; + const impliedRate = impliedApy === null ? Number.NaN : toDisplayRateFromApy(impliedApy, isAprDisplay); + const currentRate = nativeRate.at(-1)?.value ?? Number.NaN; + + if (mode === 'share-price') { + return ( + + } + formatAxisValue={(value) => formatSharePrice(value)} + formatValue={(value) => formatSharePrice(value, assetSymbol)} + emptyMessage="Historical share price is unavailable for this vault." + /> + ); + } + + return ( + + {isMorphoSupported ? ( + + } + formatAxisValue={formatPercent} + formatValue={formatPercent} + emptyMessage="Native yield history is unavailable for this vault." + /> + ) : null} + + formatCompactAmount(value)} + formatValue={(value) => formatCompactAmount(value, assetSymbol)} + emptyMessage="Historical deposits are unavailable for this vault." + /> + + ); +} diff --git a/src/features/vault/components/vault-share-price-chart.tsx b/src/features/vault/components/vault-share-price-chart.tsx deleted file mode 100644 index a198dac7..00000000 --- a/src/features/vault/components/vault-share-price-chart.tsx +++ /dev/null @@ -1,313 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import type { Address } from 'viem'; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { TableContainerWithDescription } from '@/components/common/table-container-with-header'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Spinner } from '@/components/ui/spinner'; -import { useChartColors } from '@/constants/chartColors'; -import { useRateLabel } from '@/hooks/useRateLabel'; -import { useVaultSharePriceHistory } from '@/hooks/useVaultSharePriceHistory'; -import { useAppSettings } from '@/stores/useAppSettings'; -import { type ChartTimeframe, useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; -import { formatChartTime } from '@/utils/chart'; -import type { SupportedNetworks } from '@/utils/networks'; -import { computeAnnualizedApyFromValueGrowth, formatRateAsPercentage, toDisplayRateFromApy } from '@/utils/rateMath'; -import { - ChartGradients, - ChartTooltipContent, - TIMEFRAME_LABELS, - chartTooltipCursor, - createSharePriceChartGradients, - getTimeSeriesXAxisProps, -} from '@/features/market-detail/components/charts/chart-utils'; - -type VaultSharePriceChartProps = { - vaultAddress: Address; - chainId: SupportedNetworks; - assetDecimals?: number; - assetSymbol?: string; - showPeriodControl?: boolean; -}; - -type VaultSharePriceChartPoint = { - x: number; - sharePrice: number; -}; - -const SHARE_PRICE_DOMAIN_GROWTH_BY_TIMEFRAME: Record = { - '1d': 0.00035, - '7d': 0.0025, - '30d': 0.01, - '3m': 0.03, - '6m': 0.06, -}; - -function getSharePriceDomain(chartData: VaultSharePriceChartPoint[], timeframe: ChartTimeframe): [number, number] { - if (chartData.length === 0) { - return [0, 1]; - } - - const values = chartData.map((point) => point.sharePrice); - const baseline = chartData[0].sharePrice; - const dataMin = Math.min(...values); - const dataMax = Math.max(...values); - const minimumGrowth = Math.max(Math.abs(baseline) * SHARE_PRICE_DOMAIN_GROWTH_BY_TIMEFRAME[timeframe], Number.EPSILON); - // Center normal period moves around the starting share price so low-growth vaults do not sit on the chart floor. - const lower = Math.min(dataMin, baseline - minimumGrowth); - const upper = Math.max(dataMax, baseline + minimumGrowth); - - const safeLower = Math.max(0, lower); - return upper > safeLower ? [safeLower, upper] : [safeLower, safeLower + Number.EPSILON]; -} - -function formatSharePrice(value: number, assetSymbol?: string): string { - if (!Number.isFinite(value)) { - return assetSymbol ? `-- ${assetSymbol}` : '--'; - } - - const maximumFractionDigits = Math.abs(value) >= 1 ? 6 : 8; - const formatted = value.toLocaleString('en-US', { - maximumFractionDigits, - minimumFractionDigits: Math.abs(value) >= 1 ? 4 : 0, - }); - - return assetSymbol ? `${formatted} ${assetSymbol}` : formatted; -} - -function formatChangePercent(value: number | null): string { - if (value === null || !Number.isFinite(value)) { - return '--'; - } - - return `${value >= 0 ? '+' : ''}${value.toFixed(4)}%`; -} - -function formatAnnualizedRate(annualizedApy: number | null, isAprDisplay: boolean): string { - if (annualizedApy === null || !Number.isFinite(annualizedApy)) { - return '--'; - } - - const displayRate = toDisplayRateFromApy(annualizedApy, isAprDisplay); - return formatRateAsPercentage(displayRate, 4); -} - -function changeTextColor(value: number | null): string { - if (value === null) { - return 'text-secondary'; - } - - return value >= 0 ? 'text-emerald-500' : 'text-rose-500'; -} - -export function VaultSharePriceChart({ - vaultAddress, - chainId, - assetDecimals, - assetSymbol, - showPeriodControl = true, -}: VaultSharePriceChartProps) { - const selectedTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); - const selectedTimeRange = useMarketDetailChartState((state) => state.selectedTimeRange); - const setTimeframe = useMarketDetailChartState((state) => state.setTimeframe); - const { isAprDisplay } = useAppSettings(); - const { short: rateLabel } = useRateLabel(); - const chartColors = useChartColors(); - - const { data, isError, isFetching, isLoading } = useVaultSharePriceHistory({ - assetDecimals, - vaultAddress, - chainId, - timeframe: selectedTimeframe, - timeRange: selectedTimeRange, - }); - - const chartData = useMemo(() => { - return (data?.points ?? []) - .map((point) => { - if (!Number.isFinite(point.sharePrice)) { - return null; - } - - return { - x: point.targetTimestamp, - sharePrice: point.sharePrice, - }; - }) - .filter((point): point is VaultSharePriceChartPoint => point !== null); - }, [data?.points]); - - const chartTimeRange = useMemo(() => { - const firstPoint = chartData[0]; - const lastPoint = chartData.at(-1); - - return { - ...selectedTimeRange, - startTimestamp: firstPoint?.x ?? selectedTimeRange.startTimestamp, - endTimestamp: lastPoint?.x ?? selectedTimeRange.endTimestamp, - }; - }, [chartData, selectedTimeRange]); - - const firstPoint = chartData[0]; - const lastPoint = chartData.at(-1); - const changePercent = - firstPoint && lastPoint && firstPoint.sharePrice > 0 - ? ((lastPoint.sharePrice - firstPoint.sharePrice) / firstPoint.sharePrice) * 100 - : null; - const periodSeconds = firstPoint && lastPoint ? lastPoint.x - firstPoint.x : 0; - const annualizedApy = - firstPoint && lastPoint - ? computeAnnualizedApyFromValueGrowth({ - currentValue: lastPoint.sharePrice, - pastValue: firstPoint.sharePrice, - periodSeconds, - }) - : null; - const yAxisDomain = useMemo(() => getSharePriceDomain(chartData, selectedTimeframe), [chartData, selectedTimeframe]); - const isInitialLoading = isLoading; - const isUnavailable = data?.isUnsupportedNetwork || isError || (!isInitialLoading && chartData.length < 2); - const chartActions = - showPeriodControl || (isFetching && !isInitialLoading) ? ( - - {isFetching && !isInitialLoading ? ( - - - Updating - - ) : null} - {showPeriodControl && ( - setTimeframe(value as ChartTimeframe)} - > - - {TIMEFRAME_LABELS[selectedTimeframe]} - - - 1D - 7D - 30D - 3M - 6M - - - )} - - ) : undefined; - - return ( - - - - - Current - {lastPoint ? formatSharePrice(lastPoint.sharePrice, assetSymbol) : '--'} - - - {TIMEFRAME_LABELS[selectedTimeframe]} Change - {formatChangePercent(changePercent)} - - - Annualized {rateLabel} - {formatAnnualizedRate(annualizedApy, isAprDisplay)} - - - - - - {isInitialLoading ? ( - - - - ) : isUnavailable ? ( - - Historical share price is unavailable for this vault. - - ) : ( - - - - - formatChartTime(time, chartTimeRange.endTimestamp - chartTimeRange.startTimestamp)} - tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} - /> - formatSharePrice(Number(value))} - tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} - width={74} - domain={yAxisDomain} - /> - ( - formatSharePrice(value, assetSymbol)} - /> - )} - /> - - - - )} - - - - - - Start - {firstPoint ? formatSharePrice(firstPoint.sharePrice, assetSymbol) : '--'} - - - End - {lastPoint ? formatSharePrice(lastPoint.sharePrice, assetSymbol) : '--'} - - - Change - {formatChangePercent(changePercent)} - - - - - ); -} diff --git a/src/features/vault/vault-view.tsx b/src/features/vault/vault-view.tsx index 2eb31660..53bcf46b 100644 --- a/src/features/vault/vault-view.tsx +++ b/src/features/vault/vault-view.tsx @@ -17,7 +17,7 @@ import { vaultAnalyticsTimeframeToEarningsPeriod, } from '@/features/vault/components/vault-analytics-period-control'; import { VaultAdapterPositionOverview } from '@/features/vault/components/vault-adapter-position-overview'; -import { VaultSharePriceChart } from '@/features/vault/components/vault-share-price-chart'; +import { VaultHistoryCharts } from '@/features/vault/components/vault-history-charts'; import { useModal } from '@/hooks/useModal'; import { type VaultMarketAdapter, useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; @@ -124,16 +124,12 @@ function VaultAdapterPositionDetail({ [marketAllocations], ); - const { positions, isPositionsLoading, isEarningsLoading, actualBlockData, snapshotsByChain } = useUserPositionsSummaryData( - adapterAddress, - period, - [chainId], - { + const { positions, isPositionsLoading, isEarningsLoading, actualBlockData, snapshotsByChain, loadingStates } = + useUserPositionsSummaryData(adapterAddress, period, [chainId], { enabled: hasAdapterPositionTarget && marketHints.length > 0, marketHints, showEmpty: true, - }, - ); + }); const groupedPositions = useMemo(() => { const grouped = groupPositionsByLoanAsset(positions ?? [], actualBlockData); @@ -174,6 +170,7 @@ function VaultAdapterPositionDetail({ chainId={chainId} adapterAddress={adapterAddress} isEarningsLoading={isEarningsLoading} + isSnapshotsLoading={loadingStates.snapshots} actualBlockData={actualBlockData} period={period} snapshotsByChain={snapshotsByChain} @@ -544,12 +541,11 @@ export default function VaultContent() { onChange={handleAnalyticsPeriodChange} /> - + + = { + '1d': 'HOUR', + '7d': 'HOUR', + '30d': 'DAY', + '3m': 'DAY', + '6m': 'DAY', +}; + +export type VaultHistoryPoint = { + blockNumber?: number; + timestamp: number; + value: number; +}; + +export type VaultHistory = { + nativeApy: VaultHistoryPoint[]; + sharePrice: VaultHistoryPoint[]; + sharePriceSource: 'morpho-api' | 'none' | 'rpc'; + totalAssets: VaultHistoryPoint[]; + totalAssetsSource: 'morpho-api' | 'none' | 'rpc'; +}; + +export function useVaultHistoryQuery({ + assetDecimals, + vaultAddress, + chainId, + timeframe, + timeRange, +}: { + assetDecimals?: number; + vaultAddress?: Address; + chainId?: SupportedNetworks; + timeframe: ChartTimeframe; + timeRange: TimeseriesOptions; +}) { + const { customRpcUrls } = useCustomRpcContext(); + const customRpcUrl = chainId ? customRpcUrls[chainId] : undefined; + + return useQuery({ + queryKey: [ + 'vault-history', + vaultAddress?.toLowerCase() ?? null, + chainId ?? null, + timeframe, + timeRange.startTimestamp, + timeRange.endTimestamp, + assetDecimals ?? null, + customRpcUrl ?? null, + ], + queryFn: async () => { + if (!vaultAddress || !chainId || assetDecimals === undefined) { + return null; + } + + // Monarch indexes cumulative flows, but historical total assets must also include accrued yield. + // Morpho's snapshots provide that accrued value; RPC is the exact fallback when they are unavailable. + const morphoHistory = await fetchMorphoVaultV2History({ + vaultAddress, + chainId, + options: { ...timeRange, interval: API_INTERVAL_BY_TIMEFRAME[timeframe] }, + }); + const nativeApy = (morphoHistory?.nativeApy ?? []).map((point) => ({ + timestamp: point.timestamp, + value: point.value, + })); + const sharePrice = (morphoHistory?.sharePrice ?? []).map((point) => ({ + timestamp: point.timestamp, + value: point.value, + })); + const apiTotalAssets = (morphoHistory?.totalAssets ?? []).map((point) => ({ + timestamp: point.timestamp, + value: point.value / 10 ** assetDecimals, + })); + const hasApiSharePrice = sharePrice.length >= 2; + const hasApiTotalAssets = apiTotalAssets.length >= 2; + + if (hasApiSharePrice && hasApiTotalAssets) { + return { + nativeApy, + sharePrice, + sharePriceSource: 'morpho-api', + totalAssets: apiTotalAssets, + totalAssetsSource: 'morpho-api', + }; + } + + if (!supportsHistoricalStateRead(chainId)) { + return { + nativeApy, + sharePrice, + sharePriceSource: hasApiSharePrice ? 'morpho-api' : 'none', + totalAssets: apiTotalAssets, + totalAssetsSource: hasApiTotalAssets ? 'morpho-api' : 'none', + }; + } + + const rpcHistory = await fetchRpcVaultHistory({ + assetDecimals, + chainId, + customRpcUrl, + endTimestamp: timeRange.endTimestamp, + intervalSeconds: TIMEFRAME_CONFIG[timeframe].intervalSeconds, + startTimestamp: timeRange.startTimestamp, + vaultAddress, + }); + + return { + nativeApy, + sharePrice: hasApiSharePrice ? sharePrice : rpcHistory.sharePrice, + sharePriceSource: hasApiSharePrice ? 'morpho-api' : rpcHistory.sharePrice.length >= 2 ? 'rpc' : 'none', + totalAssets: hasApiTotalAssets ? apiTotalAssets : rpcHistory.totalAssets, + totalAssetsSource: hasApiTotalAssets ? 'morpho-api' : rpcHistory.totalAssets.length >= 2 ? 'rpc' : 'none', + }; + }, + enabled: Boolean(vaultAddress && chainId && timeframe && timeRange && assetDecimals !== undefined), + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/usePositionChartTransactions.ts b/src/hooks/usePositionChartTransactions.ts index bba86c05..fd126329 100644 --- a/src/hooks/usePositionChartTransactions.ts +++ b/src/hooks/usePositionChartTransactions.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { fetchCompletedPositionDailyFlows } from '@/data-sources/monarch-api'; +import { fetchPositionDailyFlows } from '@/data-sources/monarch-api'; import { useUserTransactionsQuery } from '@/hooks/queries/useUserTransactionsQuery'; import type { SupportedNetworks } from '@/utils/networks'; import { type GroupedPosition, type UserTransaction, UserTxTypes } from '@/utils/types'; @@ -10,16 +10,20 @@ const SECONDS_PER_DAY = 86_400; export function usePositionChartTransactions({ account, + enabled = true, groupedPosition, startTimestamp, endTimestamp, useDailyBuckets = false, + includeCurrentDailyBucket = false, }: { account: string; + enabled?: boolean; groupedPosition: GroupedPosition; startTimestamp: number | undefined; endTimestamp?: number; useDailyBuckets?: boolean; + includeCurrentDailyBucket?: boolean; }) { const chainId = groupedPosition.chainId as SupportedNetworks; const effectiveEndTimestamp = endTimestamp ?? Math.floor(Date.now() / (SECONDS_PER_DAY * 1000)) * SECONDS_PER_DAY; @@ -33,24 +37,33 @@ export function usePositionChartTransactions({ timestampLte: endTimestamp, }, paginate: true, - enabled: Boolean(startTimestamp) && !useDailyBuckets, + enabled: enabled && Boolean(startTimestamp) && !useDailyBuckets, }); const dailyFlowQuery = useQuery({ - queryKey: ['position-daily-flows', account.toLowerCase(), chainId, [...marketUniqueKeys].sort(), startTimestamp, effectiveEndTimestamp], + queryKey: [ + 'position-daily-flows', + account.toLowerCase(), + chainId, + [...marketUniqueKeys].sort(), + startTimestamp, + effectiveEndTimestamp, + includeCurrentDailyBucket, + ], queryFn: () => { if (startTimestamp === undefined) { return []; } - return fetchCompletedPositionDailyFlows({ + return fetchPositionDailyFlows({ userAddress: account, chainId, marketIds: marketUniqueKeys, startTimestamp, endTimestamp: effectiveEndTimestamp, + includeCurrentBucket: includeCurrentDailyBucket, }); }, - enabled: Boolean(startTimestamp) && useDailyBuckets, + enabled: enabled && Boolean(startTimestamp) && useDailyBuckets, staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000, refetchOnWindowFocus: false, diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 2b4b05e0..f0fe98b6 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -305,6 +305,7 @@ const useUserPositionsSummaryData = ( endSnapshotsByChain: endSnapshots ?? {}, earningsRangesByChain, snapshotsByChain: startSnapshotsByChain, + transactions: mergedTransactions, }; }; diff --git a/src/hooks/useVaultSharePriceHistory.ts b/src/hooks/useVaultSharePriceHistory.ts deleted file mode 100644 index 66536f54..00000000 --- a/src/hooks/useVaultSharePriceHistory.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { formatUnits, type Address, type PublicClient } from 'viem'; -import { erc4626Abi } from '@/abis/erc4626'; -import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -import { fetchMorphoVaultV2SharePriceHistory, type MorphoVaultSharePricePoint } from '@/data-sources/morpho-api/vault-share-price-history'; -import { TIMEFRAME_CONFIG, type ChartTimeframe } from '@/stores/useMarketDetailChartState'; -import { fetchBlocksWithTimestamps, type BlockWithTimestamp } from '@/utils/blockEstimation'; -import { supportsHistoricalStateRead, type SupportedNetworks } from '@/utils/networks'; -import { getClient } from '@/utils/rpc'; -import type { TimeseriesOptions } from '@/utils/types'; - -const PARALLEL_BATCH_SIZE = 6; -const HOUR_IN_SECONDS = 60 * 60; -const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS; - -const SHARE_PRICE_API_INTERVAL_BY_TIMEFRAME: Record = { - '1d': 'HOUR', - '7d': 'DAY', - '30d': 'DAY', - '3m': 'DAY', - '6m': 'DAY', -}; - -const SHARE_PRICE_POINT_INTERVAL_SECONDS: Record = { - '1d': 2 * HOUR_IN_SECONDS, - '7d': DAY_IN_SECONDS, - '30d': DAY_IN_SECONDS, - '3m': 3 * DAY_IN_SECONDS, - '6m': 6 * DAY_IN_SECONDS, -}; - -export type VaultSharePricePoint = { - blockNumber?: number; - sharePrice: number; - source: 'morpho-api' | 'rpc'; - timestamp: number; - targetTimestamp: number; -}; - -type VaultSharePriceHistory = { - points: VaultSharePricePoint[]; - isUnsupportedNetwork: boolean; - source: 'morpho-api' | 'none' | 'rpc'; -}; - -export function selectNearestVaultSharePricePoint(points: VaultSharePricePoint[], targetTimestamp: number) { - let nearestPoint: VaultSharePricePoint | undefined; - let nearestDistance = Number.POSITIVE_INFINITY; - - for (const point of points) { - const distance = Math.abs(point.timestamp - targetTimestamp); - if (distance >= nearestDistance) continue; - - nearestPoint = point; - nearestDistance = distance; - } - - return nearestPoint; -} - -function getMorphoSharePriceOptions(timeframe: ChartTimeframe, timeRange: TimeseriesOptions): TimeseriesOptions { - return { - ...timeRange, - interval: SHARE_PRICE_API_INTERVAL_BY_TIMEFRAME[timeframe], - }; -} - -function calculateSharePriceTimePoints(timeframe: ChartTimeframe, endTimestamp: number): number[] { - const config = TIMEFRAME_CONFIG[timeframe]; - const startTimestamp = endTimestamp - config.durationSeconds; - const intervalSeconds = SHARE_PRICE_POINT_INTERVAL_SECONDS[timeframe]; - const points: number[] = []; - - for (let timestamp = startTimestamp; timestamp < endTimestamp; timestamp += intervalSeconds) { - points.push(timestamp); - } - - points.push(endTimestamp); - return points; -} - -function selectNearestMorphoPoints(points: MorphoVaultSharePricePoint[], targetTimestamps: number[]): VaultSharePricePoint[] { - const selected: VaultSharePricePoint[] = []; - - for (const targetTimestamp of targetTimestamps) { - let nearestIndex = -1; - let nearestDistance = Number.POSITIVE_INFINITY; - - for (const [index, point] of points.entries()) { - const distance = Math.abs(point.timestamp - targetTimestamp); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestIndex = index; - } - } - - if (nearestIndex === -1) continue; - - const point = points[nearestIndex]; - if (!point) continue; - - selected.push({ - sharePrice: point.sharePrice, - source: 'morpho-api', - timestamp: point.timestamp, - targetTimestamp, - }); - } - - return selected.sort((left, right) => left.targetTimestamp - right.targetTimestamp); -} - -async function fetchSharePricePoint( - client: PublicClient, - vaultAddress: Address, - oneShareUnit: bigint, - assetDecimals: number, - block: BlockWithTimestamp, -): Promise { - try { - const rawSharePrice = await client.readContract({ - address: vaultAddress, - abi: erc4626Abi, - functionName: 'previewRedeem', - args: [oneShareUnit], - blockNumber: BigInt(block.blockNumber), - }); - const sharePrice = Number(formatUnits(rawSharePrice, assetDecimals)); - - if (!Number.isFinite(sharePrice)) { - return null; - } - - return { - blockNumber: block.blockNumber, - sharePrice, - source: 'rpc', - timestamp: block.timestamp, - targetTimestamp: block.targetTimestamp, - }; - } catch { - return null; - } -} - -export function useVaultSharePriceHistory({ - assetDecimals, - vaultAddress, - chainId, - timeframe, - timeRange, -}: { - assetDecimals?: number; - vaultAddress?: Address; - chainId?: SupportedNetworks; - timeframe: ChartTimeframe; - timeRange: TimeseriesOptions; -}) { - const { customRpcUrls } = useCustomRpcContext(); - const customRpcUrl = chainId ? customRpcUrls[chainId] : undefined; - - return useQuery({ - queryKey: [ - 'vault-share-price-history', - vaultAddress?.toLowerCase() ?? null, - chainId ?? null, - timeframe, - timeRange.startTimestamp, - timeRange.endTimestamp, - timeRange.interval, - assetDecimals ?? null, - customRpcUrl ?? null, - ], - queryFn: async () => { - if (!vaultAddress || !chainId) { - return null; - } - - const morphoTimeRange = getMorphoSharePriceOptions(timeframe, timeRange); - const targetTimestamps = calculateSharePriceTimePoints(timeframe, timeRange.endTimestamp); - const morphoPoints = await fetchMorphoVaultV2SharePriceHistory({ - vaultAddress, - chainId, - options: morphoTimeRange, - }); - - if (morphoPoints && morphoPoints.length >= 2) { - const selectedMorphoPoints = selectNearestMorphoPoints(morphoPoints, targetTimestamps); - const fallbackMorphoPoints = morphoPoints.map((point) => ({ - sharePrice: point.sharePrice, - source: 'morpho-api' as const, - timestamp: point.timestamp, - targetTimestamp: point.timestamp, - })); - - return { - points: selectedMorphoPoints.length >= 2 ? selectedMorphoPoints : fallbackMorphoPoints, - isUnsupportedNetwork: false, - source: 'morpho-api', - }; - } - - if (!supportsHistoricalStateRead(chainId)) { - return { - points: [], - isUnsupportedNetwork: true, - source: 'none', - }; - } - - if (assetDecimals === undefined) { - return null; - } - - const client = getClient(chainId, customRpcUrl); - const [currentBlock, shareDecimals] = await Promise.all([ - client.getBlockNumber(), - client.readContract({ - address: vaultAddress, - abi: erc4626Abi, - functionName: 'decimals', - args: [], - }), - ]); - const currentBlockData = await client.getBlock({ blockNumber: currentBlock }); - const currentTimestamp = Number(currentBlockData.timestamp); - const fallbackTargetTimestamps = calculateSharePriceTimePoints(timeframe, Math.min(timeRange.endTimestamp, currentTimestamp)); - const blocksWithTimestamps = await fetchBlocksWithTimestamps( - client, - chainId, - fallbackTargetTimestamps, - Number(currentBlock), - currentTimestamp, - ); - const oneShareUnit = 10n ** BigInt(shareDecimals); - const points: VaultSharePricePoint[] = []; - - for (let i = 0; i < blocksWithTimestamps.length; i += PARALLEL_BATCH_SIZE) { - const batch = blocksWithTimestamps.slice(i, i + PARALLEL_BATCH_SIZE); - const batchResults = await Promise.all( - batch.map((block) => fetchSharePricePoint(client, vaultAddress, oneShareUnit, assetDecimals, block)), - ); - points.push(...batchResults.filter((point): point is VaultSharePricePoint => point !== null)); - } - - points.sort((a, b) => a.targetTimestamp - b.targetTimestamp); - - return { - points, - isUnsupportedNetwork: false, - source: 'rpc', - }; - }, - enabled: Boolean(vaultAddress && chainId && timeframe && timeRange), - placeholderData: (previousData) => previousData ?? null, - staleTime: 5 * 60 * 1000, - gcTime: 30 * 60 * 1000, - refetchOnWindowFocus: false, - }); -}
Current
{lastPoint ? formatSharePrice(lastPoint.sharePrice, assetSymbol) : '--'}
{TIMEFRAME_LABELS[selectedTimeframe]} Change
{formatChangePercent(changePercent)}
Annualized {rateLabel}
{formatAnnualizedRate(annualizedApy, isAprDisplay)}