diff --git a/src/features/market-detail/components/charts/chart-utils.tsx b/src/features/market-detail/components/charts/chart-utils.tsx index 59b8184b..f97d7c67 100644 --- a/src/features/market-detail/components/charts/chart-utils.tsx +++ b/src/features/market-detail/components/charts/chart-utils.tsx @@ -1,13 +1,11 @@ import type { Dispatch, SetStateAction } from 'react'; import { CHART_COLORS, type useChartColors } from '@/constants/chartColors'; +import { TIMEFRAME_CONFIG, type ChartTimeframe } from '@/stores/useMarketDetailChartState'; -export const TIMEFRAME_LABELS: Record = { - '1d': '1D', - '7d': '7D', - '30d': '30D', - '3m': '3M', - '6m': '6M', -}; +// Derive labels from centralized config +export const TIMEFRAME_LABELS: Record = Object.fromEntries( + Object.entries(TIMEFRAME_CONFIG).map(([key, config]) => [key, config.label]), +) as Record; type GradientConfig = { id: string; diff --git a/src/features/market-detail/components/charts/supplier-positions-chart.tsx b/src/features/market-detail/components/charts/supplier-positions-chart.tsx new file mode 100644 index 00000000..c7aa4c1d --- /dev/null +++ b/src/features/market-detail/components/charts/supplier-positions-chart.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import type { Address } from 'viem'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; +import { useHistoricalSupplierPositions } from '@/hooks/useHistoricalSupplierPositions'; +import { useMarketDetailChartState, calculateTimePoints } from '@/stores/useMarketDetailChartState'; +import { formatSimple, formatReadable } from '@/utils/balance'; +import { formatChartTime } from '@/utils/chart'; +import { getSlicedAddress } from '@/utils/address'; +import { chartTooltipCursor } from './chart-utils'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +type SupplierPositionsChartProps = { + marketId: string; + chainId: SupportedNetworks; + market: Market; +}; + +const TOP_SUPPLIERS_TO_SHOW = 5; + +export function SupplierPositionsChart({ marketId, chainId, market }: SupplierPositionsChartProps) { + const selectedTimeframe = useMarketDetailChartState((s) => s.selectedTimeframe); + const selectedTimeRange = useMarketDetailChartState((s) => s.selectedTimeRange); + const chartColors = useChartColors(); + const { getVaultByAddress } = useVaultRegistry(); + + const { data: suppliers, isLoading: suppliersLoading } = useAllMarketSuppliers(market.uniqueKey, chainId); + + const totalSupplyShares = BigInt(market.state.supplyShares); + const totalSupplyAssets = BigInt(market.state.supplyAssets); + + const { + data: historicalData, + suppliers: topSuppliers, + isLoading: historyLoading, + } = useHistoricalSupplierPositions( + marketId, + chainId, + selectedTimeframe, + suppliers, + totalSupplyShares, + totalSupplyAssets, + market.loanAsset.decimals, + ); + + // Track which lines have been explicitly toggled by user + const [visibleLines, setVisibleLines] = useState>({}); + + // Compute visibility: show top 5 by default, respect user toggles + const effectiveVisibility = useMemo(() => { + const visibility: Record = {}; + for (const [index, supplier] of topSuppliers.entries()) { + visibility[supplier.address] = visibleLines[supplier.address] ?? index < TOP_SUPPLIERS_TO_SHOW; + } + return visibility; + }, [topSuppliers, visibleLines]); + + // Get display name for a supplier address + const getDisplayName = useCallback( + (address: string): string => { + const vault = getVaultByAddress(address as Address, chainId); + if (vault?.name) return vault.name; + return getSlicedAddress(address as `0x${string}`); + }, + [getVaultByAddress, chainId], + ); + + const handleLegendClick = useCallback( + (dataKey: string) => { + setVisibleLines((prev) => ({ + ...prev, + [dataKey]: !(prev[dataKey] ?? topSuppliers.findIndex((s) => s.address === dataKey) < TOP_SUPPLIERS_TO_SHOW), + })); + }, + [topSuppliers], + ); + + const formatValue = (value: number) => `${formatSimple(value)} ${market.loanAsset.symbol}`; + + const isLoading = suppliersLoading || historyLoading; + + // Calculate data coverage - how much of the requested timeframe has data + const dataCoverage = useMemo(() => { + if (!historicalData || historicalData.length === 0) return null; + + const expectedPoints = calculateTimePoints(selectedTimeframe).length; + const actualPoints = historicalData.length; + const percentage = Math.round((actualPoints / expectedPoints) * 100); + + // Get the earliest data point date + const earliestTimestamp = historicalData[0]?.timestamp; + const earliestDate = earliestTimestamp ? new Date(earliestTimestamp * 1000) : null; + + return { + percentage, + expectedPoints, + actualPoints, + earliestDate, + isPartial: percentage < 90, + }; + }, [historicalData, selectedTimeframe]); + + // Custom tooltip with block number + const CustomTooltip = ({ + active, + payload, + }: { + active?: boolean; + payload?: { dataKey: string; value: number; color: string; payload: { timestamp: number; blockNumber: number } }[]; + }) => { + if (!active || !payload || payload.length === 0) return null; + + const dataPoint = payload[0]?.payload; + const timestamp = dataPoint?.timestamp ?? 0; + const blockNumber = dataPoint?.blockNumber; + + return ( +
+
+

+ {new Date(timestamp * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+ {blockNumber &&

Block #{blockNumber.toLocaleString()}

} +
+
+ {payload + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value) + .map((entry) => ( +
+
+ + {getDisplayName(entry.dataKey)} +
+ {formatValue(entry.value)} +
+ ))} +
+
+ ); + }; + + // Custom legend with toggleable items + const renderLegend = () => ( +
+ {topSuppliers.map((supplier, index) => { + const isVisible = effectiveVisibility[supplier.address]; + const color = chartColors.pie[index % chartColors.pie.length]; + + return ( + + ); + })} +
+ ); + + if (isLoading) { + return ( + + + + ); + } + + if (!historicalData || historicalData.length === 0 || topSuppliers.length === 0) { + return ( + +

No historical supplier data available

+

+ {topSuppliers.length === 0 ? 'No suppliers found for this market' : 'Try a different timeframe'} +

+
+ ); + } + + return ( + + {/* Header */} +
+
+

Supplier Position Changes

+

+ {dataCoverage?.isPartial && dataCoverage.earliestDate + ? `Data available from ${dataCoverage.earliestDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` + : 'Track how top supplier positions change over time'} +

+
+
+ {topSuppliers.length} suppliers tracked + {dataCoverage?.isPartial && ( +

+ {dataCoverage.actualPoints}/{dataCoverage.expectedPoints} data points +

+ )} +
+
+ + {/* Chart */} +
+ + + + formatChartTime(time, selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + } + /> + {topSuppliers.map((supplier, index) => ( + + ))} + + + + {/* Custom Legend */} + {renderLegend()} +
+
+ ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index aae5a4e8..6592db06 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -30,6 +30,7 @@ import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMark import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; +import { SupplierPositionsChart } from './components/charts/supplier-positions-chart'; import { SuppliersPieChart } from './components/charts/suppliers-pie-chart'; import { BorrowersPieChart } from './components/charts/borrowers-pie-chart'; import { DebtAtRiskChart } from './components/charts/debt-at-risk-chart'; @@ -374,6 +375,14 @@ function MarketContent() { market={market} /> + +
+ +
diff --git a/src/hooks/useHistoricalSupplierPositions.ts b/src/hooks/useHistoricalSupplierPositions.ts new file mode 100644 index 00000000..a2a0bda9 --- /dev/null +++ b/src/hooks/useHistoricalSupplierPositions.ts @@ -0,0 +1,200 @@ +import { useQuery } from '@tanstack/react-query'; +import { formatUnits, type Address, type PublicClient } from 'viem'; +import morphoABI from '@/abis/morpho'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import { getMorphoAddress } from '@/utils/morpho'; +import { fetchBlocksWithTimestamps, type BlockWithTimestamp } from '@/utils/blockEstimation'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; +import { calculateTimePoints, type ChartTimeframe } from '@/stores/useMarketDetailChartState'; +import type { MarketSupplier } from '@/utils/types'; + +type SupplierPositionDataPoint = { + timestamp: number; // actual block timestamp + targetTimestamp: number; // original target timestamp + blockNumber: number; + [key: string]: number; // supplier addresses as keys, supply assets as values +}; + +type SupplierInfo = { + address: string; + currentSupply: bigint; +}; + +type UseHistoricalSupplierPositionsResult = { + data: SupplierPositionDataPoint[] | null; + suppliers: SupplierInfo[]; + isLoading: boolean; + error: Error | null; +}; + +type MarketDataRaw = readonly [bigint, bigint, bigint, bigint, bigint, bigint]; +type PositionDataRaw = readonly [bigint, bigint, bigint]; + +// Limit suppliers to fetch historical data for +const TOP_SUPPLIERS_LIMIT = 15; +// Number of blocks to process in parallel per batch +const PARALLEL_BATCH_SIZE = 5; + +/** + * Fetch position data for a single block. + * Returns null only if the market doesn't exist at that block (before creation). + */ +async function fetchBlockData( + client: PublicClient, + morphoAddress: Address, + marketId: string, + suppliers: SupplierInfo[], + block: BlockWithTimestamp, + loanAssetDecimals: number, +): Promise { + try { + // Build multicall contracts: market state + positions for all suppliers + const contracts = [ + { + address: morphoAddress, + abi: morphoABI, + functionName: 'market' as const, + args: [marketId as `0x${string}`], + }, + ...suppliers.map((supplier) => ({ + address: morphoAddress, + abi: morphoABI, + functionName: 'position' as const, + args: [marketId as `0x${string}`, supplier.address as Address], + })), + ]; + + const results = await client.multicall({ + contracts, + allowFailure: true, + blockNumber: BigInt(block.blockNumber), + }); + + // Parse market state + const marketResult = results[0]; + if (marketResult.status !== 'success' || !marketResult.result) { + // Market doesn't exist at this block (before creation) + return null; + } + + const marketData = marketResult.result as MarketDataRaw; + const blockTotalSupplyAssets = marketData[0]; + const blockTotalSupplyShares = marketData[1]; + + // If market has no supply, it might be before first deposit - still return data point with zeros + const dataPoint: SupplierPositionDataPoint = { + timestamp: block.timestamp, + targetTimestamp: block.targetTimestamp, + blockNumber: block.blockNumber, + }; + + for (const [index, supplier] of suppliers.entries()) { + const positionResult = results[index + 1]; + if (positionResult.status === 'success' && positionResult.result) { + const positionData = positionResult.result as PositionDataRaw; + const supplyShares = positionData[0]; + + // Convert shares to assets + const supplyAssets = blockTotalSupplyShares > 0n ? (supplyShares * blockTotalSupplyAssets) / blockTotalSupplyShares : 0n; + + dataPoint[supplier.address] = Number(formatUnits(supplyAssets, loanAssetDecimals)); + } else { + // Position doesn't exist or call failed - supplier hadn't deposited yet + dataPoint[supplier.address] = 0; + } + } + + return dataPoint; + } catch { + // Block fetch failed - may be before chain genesis or RPC issue + return null; + } +} + +/** + * Hook to fetch historical position snapshots for top suppliers via multicall. + */ +export function useHistoricalSupplierPositions( + marketId: string | undefined, + chainId: SupportedNetworks | undefined, + timeframe: ChartTimeframe, + suppliers: MarketSupplier[] | null, + totalSupplyShares: bigint, + totalSupplyAssets: bigint, + loanAssetDecimals: number, +): UseHistoricalSupplierPositionsResult { + const { customRpcUrls } = useCustomRpcContext(); + + // Get top suppliers by current supply shares + const topSuppliers: SupplierInfo[] = (suppliers ?? []).slice(0, TOP_SUPPLIERS_LIMIT).map((s) => { + const shares = BigInt(s.supplyShares); + const assets = totalSupplyShares > 0n ? (shares * totalSupplyAssets) / totalSupplyShares : 0n; + return { + address: s.userAddress, + currentSupply: assets, + }; + }); + + // Create a stable key from supplier addresses for cache invalidation + const supplierAddressesHash = topSuppliers.map((s) => s.address).join(','); + + const { data, isLoading, error } = useQuery({ + queryKey: ['historicalSupplierPositions', marketId, chainId, timeframe, supplierAddressesHash], + queryFn: async () => { + if (!marketId || !chainId || topSuppliers.length === 0) { + return null; + } + + // Create client with custom RPC if configured (respects user's settings) + const client = getClient(chainId, customRpcUrls[chainId]); + + const morphoAddress = getMorphoAddress(chainId) as Address; + const currentBlock = await client.getBlockNumber(); + const currentTimestamp = Math.floor(Date.now() / 1000); + + // Use centralized timeframe config for time points + const targetTimestamps = calculateTimePoints(timeframe, currentTimestamp); + + // Fetch all blocks with real timestamps in parallel + const blocksWithTimestamps = await fetchBlocksWithTimestamps( + client, + chainId, + targetTimestamps, + Number(currentBlock), + currentTimestamp, + ); + + // Process blocks in parallel batches for position data + const dataPoints: SupplierPositionDataPoint[] = []; + + 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) => fetchBlockData(client, morphoAddress, marketId, topSuppliers, block, loanAssetDecimals)), + ); + + // Filter out null results and add to dataPoints + const validResults = batchResults.filter((result): result is SupplierPositionDataPoint => result !== null); + dataPoints.push(...validResults); + } + + // Sort by timestamp + dataPoints.sort((a, b) => a.timestamp - b.timestamp); + + return dataPoints; + }, + enabled: !!marketId && !!chainId && topSuppliers.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30, // 30 minutes + refetchOnWindowFocus: false, + }); + + return { + data: data ?? null, + suppliers: topSuppliers, + isLoading, + error: error as Error | null, + }; +} diff --git a/src/stores/useMarketDetailChartState.ts b/src/stores/useMarketDetailChartState.ts index 84c62d14..22573448 100644 --- a/src/stores/useMarketDetailChartState.ts +++ b/src/stores/useMarketDetailChartState.ts @@ -1,46 +1,87 @@ import { create } from 'zustand'; import type { TimeseriesOptions } from '@/utils/types'; -const DAY_IN_SECONDS = 24 * 60 * 60; +const HOUR_IN_SECONDS = 60 * 60; +const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS; const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS; const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS; +export type ChartTimeframe = '1d' | '7d' | '30d' | '3m' | '6m'; + +/** + * Timeframe configuration for on-chain historical data fetching. + * Defines the interval between data points for each timeframe. + */ +export const TIMEFRAME_CONFIG: Record< + ChartTimeframe, + { + durationSeconds: number; + intervalSeconds: number; + label: string; + } +> = { + '1d': { + durationSeconds: DAY_IN_SECONDS, + intervalSeconds: HOUR_IN_SECONDS, // every hour = 24 points + label: '1D', + }, + '7d': { + durationSeconds: WEEK_IN_SECONDS, + intervalSeconds: 4 * HOUR_IN_SECONDS, // every 4 hours = 42 points + label: '7D', + }, + '30d': { + durationSeconds: 30 * DAY_IN_SECONDS, + intervalSeconds: DAY_IN_SECONDS, // every day = 30 points + label: '30D', + }, + '3m': { + durationSeconds: 3 * MONTH_IN_SECONDS, + intervalSeconds: 3 * DAY_IN_SECONDS, // every 3 days = 30 points + label: '3M', + }, + '6m': { + durationSeconds: 6 * MONTH_IN_SECONDS, + intervalSeconds: 6 * DAY_IN_SECONDS, // every 6 days = 30 points + label: '6M', + }, +}; + +/** + * Calculate time points for a given timeframe. + * Returns array of target timestamps from oldest to newest. + */ +export function calculateTimePoints(timeframe: ChartTimeframe, endTimestamp?: number): number[] { + const config = TIMEFRAME_CONFIG[timeframe]; + const end = endTimestamp ?? Math.floor(Date.now() / 1000); + const start = end - config.durationSeconds; + const points: number[] = []; + + for (let t = start; t <= end; t += config.intervalSeconds) { + points.push(t); + } + + return points; +} + // Helper to calculate time range based on timeframe string -const calculateTimeRange = (timeframe: '1d' | '7d' | '30d' | '3m' | '6m'): TimeseriesOptions => { +const calculateTimeRange = (timeframe: ChartTimeframe): TimeseriesOptions => { const endTimestamp = Math.floor(Date.now() / 1000); - let startTimestamp; - let interval: TimeseriesOptions['interval'] = 'HOUR'; - switch (timeframe) { - case '1d': - startTimestamp = endTimestamp - DAY_IN_SECONDS; - break; - case '30d': - startTimestamp = endTimestamp - 30 * DAY_IN_SECONDS; - interval = 'DAY'; - break; - case '3m': - startTimestamp = endTimestamp - 3 * MONTH_IN_SECONDS; - interval = 'DAY'; - break; - case '6m': - startTimestamp = endTimestamp - 6 * MONTH_IN_SECONDS; - interval = 'DAY'; - break; - default: - startTimestamp = endTimestamp - WEEK_IN_SECONDS; - break; - } + const config = TIMEFRAME_CONFIG[timeframe]; + const startTimestamp = endTimestamp - config.durationSeconds; + const interval: TimeseriesOptions['interval'] = config.intervalSeconds >= DAY_IN_SECONDS ? 'DAY' : 'HOUR'; + return { startTimestamp, endTimestamp, interval }; }; type ChartState = { - selectedTimeframe: '1d' | '7d' | '30d' | '3m' | '6m'; + selectedTimeframe: ChartTimeframe; selectedTimeRange: TimeseriesOptions; volumeView: 'USD' | 'Asset'; }; type ChartActions = { - setTimeframe: (timeframe: '1d' | '7d' | '30d' | '3m' | '6m') => void; + setTimeframe: (timeframe: ChartTimeframe) => void; setVolumeView: (view: 'USD' | 'Asset') => void; }; diff --git a/src/utils/blockEstimation.ts b/src/utils/blockEstimation.ts index 3d7c2c5c..059eba1d 100644 --- a/src/utils/blockEstimation.ts +++ b/src/utils/blockEstimation.ts @@ -1,5 +1,12 @@ +import type { PublicClient } from 'viem'; import { type SupportedNetworks, getBlocktime } from './networks'; +export type BlockWithTimestamp = { + blockNumber: number; + timestamp: number; // actual block timestamp in seconds + targetTimestamp: number; // original target timestamp used for estimation +}; + /** * Estimates the block number at a given timestamp using average block times. * This provides a quick approximation that's then refined by fetching the actual block timestamp. @@ -33,3 +40,48 @@ export const estimateBlockAtTimestamp = ( // Ensure we don't return negative block numbers return Math.max(0, currentBlock - blockDiff); }; + +/** + * Fetches real block timestamps for multiple estimated blocks in parallel. + * Uses batched RPC calls for efficiency. + * + * @param client - The viem PublicClient + * @param chainId - The chain ID + * @param targetTimestamps - Array of target timestamps to find blocks for + * @param currentBlock - Current block number + * @param currentTimestamp - Current timestamp in seconds + * @returns Array of BlockWithTimestamp containing real block timestamps + */ +export async function fetchBlocksWithTimestamps( + client: PublicClient, + chainId: SupportedNetworks, + targetTimestamps: number[], + currentBlock: number, + currentTimestamp: number, +): Promise { + // First, estimate all block numbers + const estimatedBlocks = targetTimestamps.map((ts) => + estimateBlockAtTimestamp(chainId, ts, currentBlock, currentTimestamp), + ); + + // Fetch all block timestamps in parallel + const blockPromises = estimatedBlocks.map(async (blockNum, index) => { + try { + const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + return { + blockNumber: blockNum, + timestamp: Number(block.timestamp), + targetTimestamp: targetTimestamps[index], + }; + } catch { + // If block fetch fails (shouldn't happen for valid blocks), use estimated timestamp + return { + blockNumber: blockNum, + timestamp: targetTimestamps[index], + targetTimestamp: targetTimestamps[index], + }; + } + }); + + return Promise.all(blockPromises); +}