diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index cd084511..ec7f30da 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -74,6 +74,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Market-table data enrichments that affect visible columns or sorting must report degraded readiness to the shared market-data notice surface instead of silently replacing values with empty placeholders. - Large optional metadata or enrichment queries used only for secondary badges, warnings, filters, or tooltips must be gated or deferred so core table rendering does not wait on them during cold start. - Vault-scoped pages with configured cap or market IDs must use targeted market reads for first render; do not wait on the global market registry when the vault metadata already identifies the relevant markets. +- Vault adapter selection must be cap-aware when a vault has multiple active adapters; do not let list order alone choose the adapter used for positions, activity, withdrawals, or settings. - Expensive queries must not start with placeholder dependency data that immediately invalidates the same query. Gate on prerequisite readiness, or use a stable query key that does not refetch equivalent work. - Expensive enrichment queries derived from filtered, sorted, or paginated rows must wait for the inputs that can change those rows, such as USD price enrichment, before they start. - Periodic refreshes for RPC or API data must use React Query polling with background refetch disabled, or explicitly pause when `document.visibilityState` is hidden. Do not use raw `setInterval` for mounted data refresh unless hidden-tab behavior is handled. @@ -96,6 +97,8 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Avoid repeated large UI blocks; extract or reuse only when it reduces real duplication. - Validate loading, empty, disabled, error, and success states for changed flows; period-derived metrics must not show stale values while recalculating. +- Related analytics sections that share period-dependent data should expose one period control at the shared container level, not competing child-level dropdowns. +- Yield, rate, and share-price charts must use period-aware minimum y-axis bands when comparing growth; do not let data-only auto-fit scaling make materially different APRs look visually identical. - Cold-start loading for optional metadata or enrichment must not trigger warning/error banners; warn only after a source is partial, stale, or unavailable. - Product routes must not add render-blocking external font CSS or multi-megabyte custom font assets to the app shell; prefer system font stacks or prove a small subset budget. - Font performance changes must preserve the intended design-token font families. Optimize with self-hosted subsets or scoped loading, not by silently mapping custom font utilities to system stacks. @@ -108,6 +111,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Relationship metadata must not link to the current page's own account again; only link to counterpart accounts, external protocol pages, or expandable details. - V2 vault position pages must not render native vault-address market or vault tables when the meaningful market exposure is held by a linked adapter. - Vault and adapter relationship UI should prefer short chips, address badges, and structural grouping over explanatory paragraphs. +- Vault adapter labels must come from confirmed adapter type metadata or fall back to a generic "vault adapter" label; do not infer protocol-specific adapter types from a missing `adapterType`. - Vault identity and vault action links should resolve to Monarch's canonical `/vault/:chainId/:address` route by default; external Morpho vault links belong only in explicit "View on Morpho" actions. - Use available entity icons in compact metadata chips before adding extra explanatory text. - Dense product headers should use compact chips, short address links, icon buttons, and tooltips for secondary navigation; avoid long text buttons unless they are the primary action. diff --git a/src/features/position-detail/components/position-period-selector.tsx b/src/components/common/period-selector.tsx similarity index 63% rename from src/features/position-detail/components/position-period-selector.tsx rename to src/components/common/period-selector.tsx index 5da5e74a..39a352fd 100644 --- a/src/features/position-detail/components/position-period-selector.tsx +++ b/src/components/common/period-selector.tsx @@ -1,24 +1,37 @@ 'use client'; -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; -type PositionPeriodSelectorProps = { - period: EarningsPeriod; - onPeriodChange: (period: EarningsPeriod) => void; - className?: string; - contentClassName?: string; +export type PeriodSelectorOption = { + value: EarningsPeriod; + label: string; }; -const PERIOD_OPTIONS: { value: EarningsPeriod; label: string }[] = [ +export const EARNINGS_PERIOD_OPTIONS: readonly 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' }, { value: 'all', label: 'All time' }, ]; -export function PositionPeriodSelector({ period, onPeriodChange, className, contentClassName }: PositionPeriodSelectorProps) { +type PeriodSelectorProps = { + period: EarningsPeriod; + onPeriodChange: (period: EarningsPeriod) => void; + options?: readonly PeriodSelectorOption[]; + className?: string; + contentClassName?: string; +}; + +export function PeriodSelector({ + period, + onPeriodChange, + options = EARNINGS_PERIOD_OPTIONS, + className, + contentClassName, +}: PeriodSelectorProps) { return ( 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)}

+
+
+
+ +
+ {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 4d2f96a6..48ad04f5 100644 --- a/src/features/vault/vault-view.tsx +++ b/src/features/vault/vault-view.tsx @@ -8,13 +8,18 @@ import { useConnection } from 'wagmi'; import { Breadcrumbs } from '@/components/shared/breadcrumbs'; import { Button } from '@/components/ui/button'; import Header from '@/components/layout/header/Header'; -import { TransactionHistoryPreview } from '@/features/history/components/transaction-history-preview'; import { VaultInitializationModal } from '@/features/autovault/components/vault-detail/modals/vault-initialization-modal'; import { VaultSettingsModal } from '@/features/autovault/components/vault-detail/modals/vault-settings'; import { VaultHeader } from '@/features/autovault/components/vault-detail/vault-header'; +import { + VaultAnalyticsPeriodControl, + vaultAnalyticsPeriodToTimeframe, + 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 { useModal } from '@/hooks/useModal'; -import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; +import { type VaultMarketAdapter, useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import useUserPositionsSummaryData from '@/hooks/useUserPositionsSummaryData'; import { useVaultAllocations } from '@/hooks/useVaultAllocations'; @@ -23,7 +28,7 @@ import { useVaultQueryRefresh } from '@/hooks/useVaultQueryRefresh'; import { useVaultV2 } from '@/hooks/useVaultV2'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; -import { usePositionDetailPreferences } from '@/stores/usePositionDetailPreferences'; +import { useMarketDetailChartState } from '@/stores/useMarketDetailChartState'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { useVaultSettingsModalStore } from '@/stores/vault-settings-modal-store'; import { formatBalance } from '@/utils/balance'; @@ -32,18 +37,24 @@ import { getVaultURL, supportsMorphoAppLinks } from '@/utils/external'; import { parseCapIdParams } from '@/utils/morpho'; import { groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { ALL_SUPPORTED_NETWORKS, getNetworkConfig, SupportedNetworks } from '@/utils/networks'; +import { formatVaultAdapterType } from '@/utils/vaults'; type VaultAdapterPositionDetailProps = { adapterAddress?: Address; + adapterType?: string; assetAddress?: Address; chainId: SupportedNetworks; isResolvingAdapter: boolean; period: EarningsPeriod; - setPeriod: (period: EarningsPeriod) => void; + showAdapterLabel?: boolean; totalAssets?: bigint; vaultAddress: Address; }; +type VaultAdaptersPositionDetailProps = Omit & { + adapters: VaultMarketAdapter[]; +}; + function VaultStatusPanel({ message }: { message: string }) { return
{message}
; } @@ -84,11 +95,12 @@ function VaultPositionLoadingState() { function VaultAdapterPositionDetail({ adapterAddress, + adapterType, assetAddress, chainId, isResolvingAdapter, period, - setPeriod, + showAdapterLabel = false, totalAssets, vaultAddress, }: VaultAdapterPositionDetailProps) { @@ -140,7 +152,7 @@ function VaultAdapterPositionDetail({ }, [currentPosition, transactions]); if (!adapterAddress && !isResolvingAdapter) { - return ; + return ; } const isLoading = isResolvingAdapter || marketHintsLoading || isPositionsLoading; @@ -154,24 +166,56 @@ function VaultAdapterPositionDetail({ {isLoading && !currentPosition && } {currentPosition && adapterAddress && ( - +
+ {showAdapterLabel && ( +
+ {formatVaultAdapterType(adapterType)} + {getSlicedAddress(adapterAddress)} +
+ )} + +
)} ); } +function VaultAdaptersPositionDetail({ adapters, ...props }: VaultAdaptersPositionDetailProps) { + if (adapters.length === 0) { + return ( + + ); + } + + return ( +
+ {adapters.map((adapter) => ( + 1} + /> + ))} +
+ ); +} + export default function VaultContent() { const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; @@ -182,13 +226,21 @@ export default function VaultContent() { const [hasMounted, setHasMounted] = useState(false); const { open: openModal } = useModal(); const { findToken } = useTokensQuery(); - const period = usePositionDetailPreferences((state) => state.period); - const setPeriod = usePositionDetailPreferences((state) => state.setPeriod); + const selectedAnalyticsTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); + const setAnalyticsTimeframe = useMarketDetailChartState((state) => state.setTimeframe); + const analyticsPeriod = vaultAnalyticsTimeframeToEarningsPeriod[selectedAnalyticsTimeframe]; useEffect(() => { setHasMounted(true); }, []); + const handleAnalyticsPeriodChange = useCallback( + (period: typeof analyticsPeriod) => { + setAnalyticsTimeframe(vaultAnalyticsPeriodToTimeframe[period]); + }, + [setAnalyticsTimeframe], + ); + const connectedAddress = hasMounted ? address : undefined; const chainId = useMemo(() => { @@ -238,11 +290,8 @@ export default function VaultContent() { const tokenDecimals = vaultData?.tokenDecimals; const tokenSymbol = vaultData?.tokenSymbol; const assetAddress = vaultData?.assetAddress as Address | undefined; - const adapterAddress = adapterQuery.primaryAdapter; - const adapterPortfolioHref = useMemo(() => { - if (!adapterAddress || !assetAddress) return undefined; - return `/position/${chainId}/${assetAddress}/${adapterAddress}`; - }, [adapterAddress, assetAddress, chainId]); + const positionAdapters = adapterQuery.configuredAdapters.length > 0 ? adapterQuery.configuredAdapters : adapterQuery.adapters; + const adapterAddress = positionAdapters[0]?.adapter ?? adapterQuery.primaryAdapter; const morphoVaultHref = useMemo(() => { if (!supportsMorphoAppLinks(chainId)) return undefined; return getVaultURL(vaultAddressValue, chainId); @@ -253,16 +302,6 @@ export default function VaultContent() { const hasNoAllocators = Boolean(vaultData?.capsData) && (vaultData?.allocators ?? []).length === 0; const capsUninitialized = vaultData?.capsData?.needSetupCaps === true; - const capsInitialized = vaultData?.capsData?.needSetupCaps === false; - const { marketAllocations: activityMarketAllocations, loading: activityMarketsLoading } = useVaultAllocations({ - vaultAddress: vaultAddressValue, - chainId, - enabled: Boolean(adapterAddress && isVaultInitialized && capsInitialized), - }); - const activityMarkets = useMemo( - () => activityMarketAllocations.map((allocation) => allocation.market), - [activityMarketAllocations], - ); const isRefetching = vaultDataQuery.isRefetching || vaultContract.isRefetching || adapterQuery.isRefetching || isRefetchingVaultQueries; @@ -398,6 +437,7 @@ export default function VaultContent() { curator={vaultData?.curator} adapter={adapterAddress} adapters={adapterQuery.adapters} + capsAdapters={positionAdapters} onDeposit={handleDeposit} onWithdraw={handleWithdraw} onRefresh={handleRefreshVault} @@ -463,27 +503,30 @@ export default function VaultContent() { )} - +
+ - {adapterAddress && isVaultInitialized && capsInitialized && ( - - )} + + +
address?.toLowerCase() ?? ''; + export function useMorphoMarketAdapters({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { const query = useVaultV2Data({ vaultAddress, chainId }); + const capSummaryByAdapter = useMemo(() => { + const summaries = new Map(); + const ensureSummary = (adapterAddress: string): AdapterCapSummary => { + const key = getAddressKey(adapterAddress); + const summary = summaries.get(key) ?? { hasPositiveAdapterCap: false, marketCapCount: 0 }; + summaries.set(key, summary); + return summary; + }; + + for (const cap of query.data?.capsData?.adapterCaps ?? []) { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type !== 'adapter' || !parsed.adapterAddress) { + continue; + } + + const summary = ensureSummary(parsed.adapterAddress); + summary.hasPositiveAdapterCap ||= hasPositiveVaultCap(cap); + } + + for (const cap of query.data?.capsData?.marketCaps ?? []) { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type !== 'market' || !parsed.adapterAddress || !hasPositiveVaultCap(cap)) { + continue; + } + + const summary = ensureSummary(parsed.adapterAddress); + summary.marketCapCount += 1; + } + + return summaries; + }, [query.data?.capsData?.adapterCaps, query.data?.capsData?.marketCaps]); + const adapters = useMemo(() => { const adapterDetails = query.data?.adapterDetails ?? []; - const adapterDetailsByAddress = new Map(adapterDetails.map((adapterDetail) => [adapterDetail.address, adapterDetail])); + const adapterDetailsByAddress = new Map(adapterDetails.map((adapterDetail) => [getAddressKey(adapterDetail.address), adapterDetail])); const adapterAddresses = [...(query.data?.adapters ?? []), ...adapterDetails.map((adapterDetail) => adapterDetail.address)]; const seenAddresses = new Set(); - return adapterAddresses.flatMap((adapterAddress) => { - if (seenAddresses.has(adapterAddress)) { - return []; - } + return adapterAddresses + .flatMap((adapterAddress) => { + const adapterKey = getAddressKey(adapterAddress); + if (seenAddresses.has(adapterKey)) { + return []; + } + + seenAddresses.add(adapterKey); + const adapterDetail = adapterDetailsByAddress.get(adapterKey); + const capSummary = capSummaryByAdapter.get(adapterKey); + + return [ + { + adapter: adapterKey as Address, + adapterType: adapterDetail?.adapterType, + factoryAddress: adapterDetail?.factoryAddress as Address | undefined, + hasPositiveAdapterCap: capSummary?.hasPositiveAdapterCap ?? false, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterKey}`, + marketCapCount: capSummary?.marketCapCount ?? 0, + parentVault: (vaultAddress ?? zeroAddress) as Address, + }, + ]; + }) + .sort((left, right) => { + if (left.marketCapCount !== right.marketCapCount) { + return right.marketCapCount - left.marketCapCount; + } + + if (left.hasPositiveAdapterCap !== right.hasPositiveAdapterCap) { + return left.hasPositiveAdapterCap ? -1 : 1; + } + + return 0; + }); + }, [capSummaryByAdapter, chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); - seenAddresses.add(adapterAddress); - const adapterDetail = adapterDetailsByAddress.get(adapterAddress); - - return [ - { - adapter: adapterAddress as Address, - adapterType: adapterDetail?.adapterType, - factoryAddress: adapterDetail?.factoryAddress as Address | undefined, - id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, - parentVault: (vaultAddress ?? zeroAddress) as Address, - }, - ]; - }); - }, [chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); + const configuredAdapters = useMemo( + () => adapters.filter((adapter) => adapter.marketCapCount > 0 || adapter.hasPositiveAdapterCap), + [adapters], + ); const { primaryAdapter, primaryAdapterType, primaryFactoryAddress } = useMemo(() => { - const primary = adapters[0]; + const primary = configuredAdapters[0] ?? adapters[0]; return { primaryAdapter: primary?.adapter, primaryAdapterType: primary?.adapterType, primaryFactoryAddress: primary?.factoryAddress, }; - }, [adapters]); + }, [adapters, configuredAdapters]); return { primaryAdapter, primaryAdapterType, primaryFactoryAddress, adapters, + configuredAdapters, isFetching: query.isFetching, isLoading: query.isLoading, error: query.error, refetch: query.refetch, isRefetching: query.isRefetching, hasAdapters: adapters.length > 0, + hasMultipleAdapters: adapters.length > 1, }; } diff --git a/src/hooks/usePositionsWithEarnings.ts b/src/hooks/usePositionsWithEarnings.ts index ada8da28..4547786d 100644 --- a/src/hooks/usePositionsWithEarnings.ts +++ b/src/hooks/usePositionsWithEarnings.ts @@ -15,6 +15,8 @@ export const getPeriodTimestamp = (period: EarningsPeriod): number => { return now - 7 * 86_400; case 'month': return now - 30 * 86_400; + case 'threemonth': + return now - 90 * 86_400; case 'sixmonth': return now - 180 * 86_400; case 'all': diff --git a/src/hooks/useVaultSharePriceHistory.ts b/src/hooks/useVaultSharePriceHistory.ts new file mode 100644 index 00000000..5608d2ad --- /dev/null +++ b/src/hooks/useVaultSharePriceHistory.ts @@ -0,0 +1,251 @@ +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'; +}; + +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, + }); +} diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index c6dabd1e..955cd059 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -8,6 +8,7 @@ import { getSlicedAddress } from '@/utils/address'; import { parseCapIdParams } from '@/utils/morpho'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; +import { hasPositiveVaultCap } from '@/utils/vaultAllocation'; type UseVaultV2DataArgs = { vaultAddress?: Address; @@ -16,6 +17,7 @@ type UseVaultV2DataArgs = { export type CapData = { adapterCap: VaultV2Cap | null; + adapterCaps: VaultV2Cap[]; collateralCaps: VaultV2Cap[]; marketCaps: VaultV2Cap[]; needSetupCaps: boolean; @@ -128,14 +130,14 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { } const caps = monarchVault?.caps ?? []; - let adapterCap: VaultV2Cap | null = null; + const adapterCaps: VaultV2Cap[] = []; const collateralCaps: VaultV2Cap[] = []; const marketCaps: VaultV2Cap[] = []; for (const cap of caps) { const parsed = parseCapIdParams(cap.idParams); if (parsed.type === 'adapter') { - adapterCap = cap; + adapterCaps.push(cap); continue; } if (parsed.type === 'collateral') { @@ -146,6 +148,10 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { marketCaps.push(cap); } } + const adapterCap = adapterCaps.find(hasPositiveVaultCap) ?? adapterCaps[0] ?? null; + const hasPositiveAdapterCap = adapterCaps.some(hasPositiveVaultCap); + const hasPositiveCollateralCap = collateralCaps.some(hasPositiveVaultCap); + const hasPositiveMarketCap = marketCaps.some(hasPositiveVaultCap); const assetAddress = monarchVault?.asset || rpcFallback?.assetAddress || ''; const token = assetAddress ? findToken(assetAddress, chainId) : undefined; @@ -155,9 +161,10 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { const capsData = monarchVault ? { adapterCap, + adapterCaps, collateralCaps, marketCaps, - needSetupCaps: !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0, + needSetupCaps: !hasPositiveAdapterCap || !hasPositiveCollateralCap || !hasPositiveMarketCap, } : undefined; diff --git a/src/stores/usePositionsFilters.ts b/src/stores/usePositionsFilters.ts index 8a566283..7adc9969 100644 --- a/src/stores/usePositionsFilters.ts +++ b/src/stores/usePositionsFilters.ts @@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware'; /** * Earnings calculation periods for the positions summary page. */ -export type EarningsPeriod = 'day' | 'week' | 'month' | 'sixmonth' | 'all'; +export type EarningsPeriod = 'day' | 'week' | 'month' | 'threemonth' | 'sixmonth' | 'all'; type PositionsFiltersState = { /** Currently selected earnings period */ diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts index a2d9148d..2e1a6f44 100644 --- a/src/utils/vaultAllocation.ts +++ b/src/utils/vaultAllocation.ts @@ -39,6 +39,14 @@ export function formatVaultAbsoluteCap(cap: string, tokenDecimals: number, token } } +export function hasPositiveVaultCap(cap: { relativeCap: string; absoluteCap: string }): boolean { + try { + return BigInt(cap.relativeCap) > 0n || BigInt(cap.absoluteCap) > 0n; + } catch (_error) { + return false; + } +} + const groupVaultsByNetwork = (vaults: { address: Address; networkId: SupportedNetworks }[]): Record => { return vaults.reduce( (acc, vault) => { diff --git a/src/utils/vaults.ts b/src/utils/vaults.ts index 7688c298..653191fb 100644 --- a/src/utils/vaults.ts +++ b/src/utils/vaults.ts @@ -115,9 +115,10 @@ export function isMarketTrustedByVault(market: Market, trustedVaultMap: Map