From 0e99e3f9a309b08e52c4cd89ae5965c7eb32bfd8 Mon Sep 17 00:00:00 2001 From: starksama <257340800+starksama@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:16:37 +0800 Subject: [PATCH 1/3] fix(api): handle Morpho API returning NOT_FOUND error with valid data The Morpho API sometimes returns a NOT_FOUND error alongside valid data. Previously we would discard all data when seeing this error, causing supplier/borrower charts to show 'No suppliers found' even when data exists. Now we check if valid data exists before returning null on NOT_FOUND errors. --- src/data-sources/morpho-api/fetchers.ts | 8 +- .../charts/supplier-holdings-chart.tsx | 299 ++++++++++++++++++ src/hooks/useSupplierPositionHistory.ts | 265 ++++++++++++++++ 3 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 src/features/market-detail/components/charts/supplier-holdings-chart.tsx create mode 100644 src/hooks/useSupplierPositionHistory.ts diff --git a/src/data-sources/morpho-api/fetchers.ts b/src/data-sources/morpho-api/fetchers.ts index 9d551f62..deb93375 100644 --- a/src/data-sources/morpho-api/fetchers.ts +++ b/src/data-sources/morpho-api/fetchers.ts @@ -24,12 +24,18 @@ export const morphoGraphqlFetcher = async >( const notFoundError = (result as any).errors.find((err: { status?: string }) => err.status?.includes('NOT_FOUND')); if (notFoundError) { + // Morpho API sometimes returns NOT_FOUND error alongside valid data + // Only return null if there's truly no data + if ('data' in result && result.data !== null) { + console.log('Morpho API returned NOT_FOUND error but has valid data, using data'); + return result; + } console.log('Morpho API return Not Found error:', notFoundError); return null; } // Log the full error for debugging - console.error('Morpho API GraphQL Error:', result.errors); + console.error('Morpho API GraphQL Error:', (result as any).errors); throw new Error('Unknown GraphQL error from Morpho API'); } diff --git a/src/features/market-detail/components/charts/supplier-holdings-chart.tsx b/src/features/market-detail/components/charts/supplier-holdings-chart.tsx new file mode 100644 index 00000000..f5b25b37 --- /dev/null +++ b/src/features/market-detail/components/charts/supplier-holdings-chart.tsx @@ -0,0 +1,299 @@ +import { useState, useMemo, useCallback } from 'react'; +import type { Address } from 'viem'; +import { Card } from '@/components/ui/card'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { useChartColors } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { formatReadable } from '@/utils/balance'; +import { formatChartTime } from '@/utils/chart'; +import { getSlicedAddress } from '@/utils/address'; +import { useSupplierPositionHistory, type SupplierHoldingsTimeframe } from '@/hooks/useSupplierPositionHistory'; +import { chartTooltipCursor, chartLegendStyle } from './chart-utils'; +import type { Market } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; + +type SupplierHoldingsChartProps = { + marketId: string; + chainId: SupportedNetworks; + market: Market; +}; + +const TIMEFRAME_LABELS: Record = { + '7d': '7D', + '30d': '30D', +}; + +function SupplierHoldingsChart({ marketId, chainId, market }: SupplierHoldingsChartProps) { + const [selectedTimeframe, setSelectedTimeframe] = useState('7d'); + const [visibleLines, setVisibleLines] = useState>({}); + const chartColors = useChartColors(); + const { getVaultByAddress } = useVaultRegistry(); + + const { data, suppliers, isLoading } = useSupplierPositionHistory(marketId, chainId, market, selectedTimeframe); + + // Initialize visible lines when suppliers change + useMemo(() => { + if (suppliers.length > 0 && Object.keys(visibleLines).length === 0) { + const initial: Record = {}; + for (const supplier of suppliers) { + initial[supplier.address.toLowerCase()] = true; + } + setVisibleLines(initial); + } + }, [suppliers, visibleLines]); + + // Calculate duration for time formatting + const durationSeconds = useMemo(() => { + if (selectedTimeframe === '7d') return 7 * 24 * 60 * 60; + return 30 * 24 * 60 * 60; + }, [selectedTimeframe]); + + // Get display name for an address (vault name or shortened 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 formatValue = (value: number) => { + const formattedValue = formatReadable(value); + return `${formattedValue} ${market.loanAsset.symbol}`; + }; + + const formatYAxis = (value: number) => { + return formatReadable(value); + }; + + // Handle legend click to toggle line visibility + const handleLegendClick = useCallback((legendData: { dataKey?: string | number | ((entry: unknown) => unknown) }) => { + const key = typeof legendData.dataKey === 'string' ? legendData.dataKey : ''; + if (!key) return; + setVisibleLines((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }, []); + + // Custom legend formatter + const legendFormatter = useCallback( + (value: string, entry: { dataKey?: string | number | ((entry: unknown) => unknown) }) => { + const addr = typeof entry.dataKey === 'string' ? entry.dataKey : ''; + const isVisible = addr ? visibleLines[addr] !== false : true; + const displayName = addr ? getDisplayName(addr) : value; + return ( + + {displayName} + + ); + }, + [visibleLines, getDisplayName], + ); + + // Custom tooltip + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: number }) => { + if (!active || !payload || payload.length === 0) return null; + + // Sort payload by value descending + const sortedPayload = [...payload].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); + + return ( +
+

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

+
+ {sortedPayload.map((entry) => { + if (entry.value === undefined || entry.value === null) return null; + const displayName = getDisplayName(entry.dataKey); + return ( +
+
+ + {displayName} +
+
+ {formatReadable(entry.value)} + +
+
+ ); + })} +
+
+ ); + }; + + // Calculate stats + const stats = useMemo(() => { + if (suppliers.length === 0) return null; + + const totalCurrent = suppliers.reduce((sum, s) => sum + s.currentPosition, 0); + const topSupplier = suppliers[0]; + + return { + supplierCount: suppliers.length, + totalTracked: totalCurrent, + topSupplierValue: topSupplier?.currentPosition ?? 0, + topSupplierName: topSupplier ? getDisplayName(topSupplier.address) : '', + }; + }, [suppliers, getDisplayName]); + + if (isLoading) { + return ( + + + + ); + } + + if (data.length === 0 || suppliers.length === 0) { + return ( + +

No supplier position history available

+
+ ); + } + + return ( + + {/* Header: Stats + Controls */} +
+ {/* Stats */} +
+
+

Top Suppliers

+ {stats?.supplierCount ?? 0} +
+
+

Total Tracked

+ {formatValue(stats?.totalTracked ?? 0)} +
+
+

Largest Supplier

+
+ {stats?.topSupplierName} + {formatValue(stats?.topSupplierValue ?? 0)} +
+
+
+ + {/* Controls */} +
+ +
+
+ + {/* Chart Body */} +
+ + + + formatChartTime(time, durationSeconds)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + } + /> + + {suppliers.map((supplier, index) => { + const addr = supplier.address.toLowerCase(); + const isVisible = visibleLines[addr] !== false; + return ( + + ); + })} + + +
+ + {/* Footer: Legend info */} +
+
+

Click legend items to show/hide individual supplier lines

+
+
+
+ ); +} + +export default SupplierHoldingsChart; diff --git a/src/hooks/useSupplierPositionHistory.ts b/src/hooks/useSupplierPositionHistory.ts new file mode 100644 index 00000000..4170f1d3 --- /dev/null +++ b/src/hooks/useSupplierPositionHistory.ts @@ -0,0 +1,265 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import moment from 'moment'; +import { formatUnits } from 'viem'; +import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMorphoMarketSupplies } from '@/data-sources/morpho-api/market-supplies'; +import { fetchSubgraphMarketSupplies } from '@/data-sources/subgraph/market-supplies'; +import { useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market, MarketActivityTransaction } from '@/utils/types'; + +export type SupplierHoldingsTimeframe = '7d' | '30d'; + +export type SupplierPositionDataPoint = { + timestamp: number; + [address: string]: number; // Dynamic keys for each supplier's position value +}; + +export type SupplierInfo = { + address: string; + displayName: string; + currentPosition: number; +}; + +type UseSupplierPositionHistoryResult = { + data: SupplierPositionDataPoint[]; + suppliers: SupplierInfo[]; + isLoading: boolean; + error: Error | null; +}; + +const TOP_SUPPLIERS_COUNT = 10; + +/** + * Hook to fetch and reconstruct supplier position history over time. + * Creates data suitable for a multi-line chart showing each supplier's holdings. + */ +export const useSupplierPositionHistory = ( + marketId: string | undefined, + chainId: SupportedNetworks | undefined, + market: Market | undefined, + timeframe: SupplierHoldingsTimeframe, +): UseSupplierPositionHistoryResult => { + // Get top suppliers for this market + const { data: allSuppliers, isLoading: suppliersLoading } = useAllMarketSuppliers(marketId, chainId); + + // Get top N suppliers by shares + const topSuppliers = useMemo(() => { + if (!allSuppliers) return []; + return allSuppliers.slice(0, TOP_SUPPLIERS_COUNT); + }, [allSuppliers]); + + // Calculate time range + const timeRange = useMemo(() => { + const now = moment(); + const startTimestamp = timeframe === '7d' ? now.clone().subtract(7, 'days').unix() : now.clone().subtract(30, 'days').unix(); + return { startTimestamp, endTimestamp: now.unix() }; + }, [timeframe]); + + // Fetch all supply/withdraw transactions for this market in the timeframe + const { + data: transactions, + isLoading: txLoading, + error, + } = useQuery({ + queryKey: ['supplierPositionHistory', marketId, chainId, timeframe], + queryFn: async () => { + if (!marketId || !chainId || !market) return null; + + // Fetch all transactions for the timeframe (up to 500) + const allTransactions: MarketActivityTransaction[] = []; + let skip = 0; + const pageSize = 100; + let hasMore = true; + + while (hasMore) { + let result; + + // Try Morpho API first if supported + if (supportsMorphoApi(chainId)) { + try { + result = await fetchMorphoMarketSupplies(marketId, '0', pageSize, skip); + } catch { + console.error('Morpho API failed, falling back to subgraph'); + } + } + + // Fallback to Subgraph + if (!result) { + result = await fetchSubgraphMarketSupplies(marketId, market.loanAsset.id, chainId, '0', pageSize, skip); + } + + if (!result || result.items.length === 0) { + hasMore = false; + break; + } + + // Filter to only include transactions within our timeframe + const filteredItems = result.items.filter((tx) => tx.timestamp >= timeRange.startTimestamp); + allTransactions.push(...filteredItems); + + // If the oldest transaction in this batch is before our start time, we have all we need + const oldestTimestamp = Math.min(...result.items.map((tx) => tx.timestamp)); + if (oldestTimestamp < timeRange.startTimestamp || result.items.length < pageSize) { + hasMore = false; + } else { + skip += pageSize; + } + + // Safety limit + if (skip >= 500) { + hasMore = false; + } + } + + return allTransactions; + }, + enabled: !!marketId && !!chainId && !!market && topSuppliers.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + + // Process transactions to reconstruct position history for each top supplier + const processedData = useMemo(() => { + if (!transactions || !market || topSuppliers.length === 0) { + return { data: [], suppliers: [] }; + } + + const decimals = market.loanAsset.decimals; + const totalSupplyAssets = BigInt(market.state.supplyAssets); + const totalSupplyShares = BigInt(market.state.supplyShares); + + // Create a set of top supplier addresses for quick lookup (lowercase) + const topSupplierAddresses = new Set(topSuppliers.map((s) => s.userAddress.toLowerCase())); + + // Filter transactions to only those from top suppliers + const relevantTxs = transactions + .filter((tx) => topSupplierAddresses.has(tx.userAddress.toLowerCase())) + .sort((a, b) => a.timestamp - b.timestamp); // Sort chronologically (oldest first) + + if (relevantTxs.length === 0) { + return { data: [], suppliers: [] }; + } + + // For each supplier, calculate their current position value from shares + const supplierCurrentPositions = new Map(); + for (const supplier of topSuppliers) { + const shares = BigInt(supplier.supplyShares); + const assets = totalSupplyShares > 0n ? (shares * totalSupplyAssets) / totalSupplyShares : 0n; + const value = Number(formatUnits(assets, decimals)); + supplierCurrentPositions.set(supplier.userAddress.toLowerCase(), value); + } + + // Group transactions by supplier + const txsBySupplier = new Map(); + for (const tx of relevantTxs) { + const addr = tx.userAddress.toLowerCase(); + if (!txsBySupplier.has(addr)) { + txsBySupplier.set(addr, []); + } + txsBySupplier.get(addr)!.push(tx); + } + + // Reconstruct position history for each supplier + // We work backwards from current position to find historical values + const supplierHistories = new Map>(); + + for (const [addr, txs] of txsBySupplier) { + const currentPosition = supplierCurrentPositions.get(addr) ?? 0; + const history = new Map(); + + // Sort transactions in reverse chronological order + const sortedTxs = [...txs].sort((a, b) => b.timestamp - a.timestamp); + + // Start from current position and work backwards + let position = currentPosition; + history.set(timeRange.endTimestamp, position); + + for (const tx of sortedTxs) { + const amount = Number(formatUnits(BigInt(tx.amount), decimals)); + // Working backwards: if they supplied, we subtract to get previous position + // if they withdrew, we add to get previous position + if (tx.type === 'MarketSupply') { + position -= amount; + } else if (tx.type === 'MarketWithdraw') { + position += amount; + } + // Clamp to 0 (shouldn't go negative but just in case) + position = Math.max(0, position); + history.set(tx.timestamp, position); + } + + // Add start point + history.set(timeRange.startTimestamp, position); + + supplierHistories.set(addr, history); + } + + // Create unified timeline with all unique timestamps + const allTimestamps = new Set(); + allTimestamps.add(timeRange.startTimestamp); + allTimestamps.add(timeRange.endTimestamp); + + for (const history of supplierHistories.values()) { + for (const ts of history.keys()) { + allTimestamps.add(ts); + } + } + + const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); + + // Build data points with forward-fill for each supplier + const dataPoints: SupplierPositionDataPoint[] = []; + + for (const timestamp of sortedTimestamps) { + const point: SupplierPositionDataPoint = { timestamp }; + + for (const [addr, history] of supplierHistories) { + // Find the most recent position at or before this timestamp + let value = 0; + const timestamps = Array.from(history.keys()).sort((a, b) => a - b); + for (const ts of timestamps) { + if (ts <= timestamp) { + value = history.get(ts)!; + } else { + break; + } + } + point[addr] = value; + } + + dataPoints.push(point); + } + + // Also include suppliers with current positions but no transactions in timeframe + // They had their position before the timeframe started + for (const supplier of topSuppliers) { + const addr = supplier.userAddress.toLowerCase(); + if (!supplierHistories.has(addr)) { + // This supplier has no transactions in timeframe, so they had constant position + const currentPosition = supplierCurrentPositions.get(addr) ?? 0; + for (const point of dataPoints) { + point[addr] = currentPosition; + } + } + } + + // Build supplier info + const suppliers: SupplierInfo[] = topSuppliers.map((s) => ({ + address: s.userAddress, + displayName: `${s.userAddress.slice(0, 6)}...${s.userAddress.slice(-4)}`, + currentPosition: supplierCurrentPositions.get(s.userAddress.toLowerCase()) ?? 0, + })); + + return { data: dataPoints, suppliers }; + }, [transactions, market, topSuppliers, timeRange]); + + return { + data: processedData.data, + suppliers: processedData.suppliers, + isLoading: suppliersLoading || txLoading, + error: error as Error | null, + }; +}; + +export default useSupplierPositionHistory; From f84b7924eefce991e0f46b35c8f7dd06c563b483 Mon Sep 17 00:00:00 2001 From: starksama <257340800+starksama@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:24:13 +0800 Subject: [PATCH 2/3] fix(api): filter out positions with null state from Morpho API response Some positions returned by Morpho API have state: null, causing crashes when mapping. Now we filter these out before mapping to our types. --- src/data-sources/morpho-api/market-borrowers.ts | 16 +++++++++------- src/data-sources/morpho-api/market-suppliers.ts | 14 ++++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/data-sources/morpho-api/market-borrowers.ts b/src/data-sources/morpho-api/market-borrowers.ts index 5af4ba5f..bea725aa 100644 --- a/src/data-sources/morpho-api/market-borrowers.ts +++ b/src/data-sources/morpho-api/market-borrowers.ts @@ -10,7 +10,7 @@ type MorphoAPIBorrowersResponse = { state: { borrowAssets: string; collateral: string; - }; + } | null; // API can return null state for some positions user: { address: string; }; @@ -64,12 +64,14 @@ export const fetchMorphoMarketBorrowers = async ( const items = result.data?.marketPositions?.items ?? []; const totalCount = result.data?.marketPositions?.pageInfo?.countTotal ?? 0; - // Map to unified type - const mappedItems = items.map((item) => ({ - userAddress: item.user.address, - borrowAssets: item.state.borrowAssets, - collateral: item.state.collateral, - })); + // Map to unified type, filtering out items with null state + const mappedItems = items + .filter((item) => item.state !== null) + .map((item) => ({ + userAddress: item.user.address, + borrowAssets: item.state.borrowAssets, + collateral: item.state.collateral, + })); return { items: mappedItems, diff --git a/src/data-sources/morpho-api/market-suppliers.ts b/src/data-sources/morpho-api/market-suppliers.ts index d210baec..f8265170 100644 --- a/src/data-sources/morpho-api/market-suppliers.ts +++ b/src/data-sources/morpho-api/market-suppliers.ts @@ -9,7 +9,7 @@ type MorphoAPISuppliersResponse = { items?: { state: { supplyShares: string; - }; + } | null; // API can return null state for some positions user: { address: string; }; @@ -63,11 +63,13 @@ export const fetchMorphoMarketSuppliers = async ( const items = result.data?.marketPositions?.items ?? []; const totalCount = result.data?.marketPositions?.pageInfo?.countTotal ?? 0; - // Map to unified type - const mappedItems = items.map((item) => ({ - userAddress: item.user.address, - supplyShares: item.state.supplyShares, - })); + // Map to unified type, filtering out items with null state + const mappedItems = items + .filter((item) => item.state !== null) + .map((item) => ({ + userAddress: item.user.address, + supplyShares: item.state.supplyShares, + })); return { items: mappedItems, From 968e6043eab3f418e5f4c0b7208093e03c1a4540 Mon Sep 17 00:00:00 2001 From: starksama <257340800+starksama@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:29:58 +0800 Subject: [PATCH 3/3] fix(types): add type guard for null state filter --- src/data-sources/morpho-api/market-borrowers.ts | 2 +- src/data-sources/morpho-api/market-suppliers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data-sources/morpho-api/market-borrowers.ts b/src/data-sources/morpho-api/market-borrowers.ts index bea725aa..f9cd90e0 100644 --- a/src/data-sources/morpho-api/market-borrowers.ts +++ b/src/data-sources/morpho-api/market-borrowers.ts @@ -66,7 +66,7 @@ export const fetchMorphoMarketBorrowers = async ( // Map to unified type, filtering out items with null state const mappedItems = items - .filter((item) => item.state !== null) + .filter((item): item is typeof item & { state: NonNullable } => item.state !== null) .map((item) => ({ userAddress: item.user.address, borrowAssets: item.state.borrowAssets, diff --git a/src/data-sources/morpho-api/market-suppliers.ts b/src/data-sources/morpho-api/market-suppliers.ts index f8265170..5856c5a1 100644 --- a/src/data-sources/morpho-api/market-suppliers.ts +++ b/src/data-sources/morpho-api/market-suppliers.ts @@ -65,7 +65,7 @@ export const fetchMorphoMarketSuppliers = async ( // Map to unified type, filtering out items with null state const mappedItems = items - .filter((item) => item.state !== null) + .filter((item): item is typeof item & { state: NonNullable } => item.state !== null) .map((item) => ({ userAddress: item.user.address, supplyShares: item.state.supplyShares,