diff --git a/.env.local.example b/.env.local.example index ab796d75..e9fbbc1f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -43,6 +43,8 @@ NEXT_PUBLIC_UNICHAIN_RPC= NEXT_PUBLIC_ARBITRUM_RPC= NEXT_PUBLIC_HYPEREVM_RPC= NEXT_PUBLIC_MONAD_RPC= +# Set to "false" locally to skip RPC historical-rate fallback when Morpho API rolling rates fail. +NEXT_PUBLIC_ENABLE_MARKET_RATE_RPC_FALLBACK=true # ==================== End of RPC Settings ==================== diff --git a/src/features/market-detail/components/charts/chart-utils.tsx b/src/features/market-detail/components/charts/chart-utils.tsx index f97d7c67..f53e95a1 100644 --- a/src/features/market-detail/components/charts/chart-utils.tsx +++ b/src/features/market-detail/components/charts/chart-utils.tsx @@ -1,6 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { CHART_COLORS, type useChartColors } from '@/constants/chartColors'; import { TIMEFRAME_CONFIG, type ChartTimeframe } from '@/stores/useMarketDetailChartState'; +import type { TimeseriesOptions } from '@/utils/types'; // Derive labels from centralized config export const TIMEFRAME_LABELS: Record = Object.fromEntries( @@ -87,16 +88,27 @@ type ChartTooltipContentProps = { export function ChartTooltipContent({ active, payload, label, formatValue }: ChartTooltipContentProps) { if (!active || !payload) return null; + const pointMeta = payload[0]?.payload as { isStateRead?: boolean } | undefined; + return (
-

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

+
+

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

+
+ {pointMeta?.isStateRead ? ( + + State Read + + ) : null} +
+
{payload.map((entry: any) => (
({ + type: 'number' as const, + scale: 'linear' as const, + domain: [timeRange.startTimestamp, timeRange.endTimestamp] as [number, number], +}); diff --git a/src/features/market-detail/components/charts/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index 5af9ce97..6f2a3c61 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -30,6 +30,7 @@ import { createLegendClickHandler, chartTooltipCursor, chartLegendStyle, + getTimeSeriesXAxisProps, } from './chart-utils'; import type { Market } from '@/utils/types'; import type { TimeseriesDataPoint } from '@/utils/types'; @@ -52,7 +53,12 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { const rateChainId = chainId as SupportedNetworks; const customRpcUrl = customRpcUrls[rateChainId]; - const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); + const { + data: historicalData, + stateReadPoints, + isLoading, + isFetching, + } = useMarketHistoricalData(marketId, chainId, selectedTimeRange, market, selectedTimeframe); const { data: realizedRates, isLoading: isRealizedRatesLoading } = useQuery({ queryKey: ['market-realized-window-rates', chainId, market.uniqueKey, realizedWindowSeconds, customRpcUrl ?? null], queryFn: async () => { @@ -86,38 +92,62 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { }); const chartData = useMemo(() => { - if (!historicalData?.rates) return []; - const { supplyApy, borrowApy, apyAtTarget } = historicalData.rates; + const stateReadByTimestamp = new Map(stateReadPoints.map((point) => [point.targetTimestamp, point])); + const historicalPoints = historicalData?.rates + ? historicalData.rates.supplyApy + .map((point: TimeseriesDataPoint, index: number) => { + const borrowValue = historicalData.rates.borrowApy[index]?.y; + const targetValue = historicalData.rates.apyAtTarget[index]?.y; - return supplyApy - .map((point: TimeseriesDataPoint, index: number) => { - const borrowValue = borrowApy[index]?.y; - const targetValue = apyAtTarget[index]?.y; + if ( + point.y === null || + borrowValue == null || + targetValue == null || + !Number.isFinite(point.y) || + !Number.isFinite(borrowValue) || + !Number.isFinite(targetValue) + ) { + return null; + } - if ( - point.y === null || - borrowValue == null || - targetValue == null || - !Number.isFinite(point.y) || - !Number.isFinite(borrowValue) || - !Number.isFinite(targetValue) - ) { - return null; - } + const supplyVal = isAprDisplay ? convertApyToApr(point.y) : point.y; + const borrowVal = isAprDisplay ? convertApyToApr(borrowValue) : borrowValue; + const targetVal = isAprDisplay ? convertApyToApr(targetValue) : targetValue; + + return { + x: point.x, + supplyApy: supplyVal, + borrowApy: borrowVal, + apyAtTarget: targetVal, + blockNumber: stateReadByTimestamp.get(point.x)?.blockNumber, + isStateRead: stateReadByTimestamp.has(point.x), + }; + }) + .filter((point): point is NonNullable => point !== null) + : []; - const supplyVal = isAprDisplay ? convertApyToApr(point.y) : point.y; - const borrowVal = isAprDisplay ? convertApyToApr(borrowValue) : borrowValue; - const targetVal = isAprDisplay ? convertApyToApr(targetValue) : targetValue; + const nowPoint = { + x: selectedTimeRange.endTimestamp, + supplyApy: isAprDisplay ? convertApyToApr(market.state.supplyApy) : market.state.supplyApy, + borrowApy: isAprDisplay ? convertApyToApr(market.state.borrowApy) : market.state.borrowApy, + apyAtTarget: isAprDisplay ? convertApyToApr(market.state.apyAtTarget) : market.state.apyAtTarget, + }; - return { - x: point.x, - supplyApy: supplyVal, - borrowApy: borrowVal, - apyAtTarget: targetVal, - }; - }) - .filter((point): point is NonNullable => point !== null); - }, [historicalData, isAprDisplay]); + const lastHistoricalPoint = historicalPoints.at(-1); + if (!lastHistoricalPoint || lastHistoricalPoint.x < nowPoint.x) { + return [...historicalPoints, nowPoint]; + } + + return historicalPoints; + }, [ + historicalData, + isAprDisplay, + market.state.supplyApy, + market.state.borrowApy, + market.state.apyAtTarget, + selectedTimeRange.endTimestamp, + stateReadPoints, + ]); const formatPercentage = (value: number) => `${(value * 100).toFixed(2)}%`; @@ -167,6 +197,12 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { {/* Controls */}
+ {isFetching && !isLoading ? ( +
+ + Updating +
+ ) : null} setTimeframe(value as '1d' | '7d' | '30d' | '3m' | '6m')} @@ -245,6 +261,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { /> { + if (!historicalData) { + return []; + } + + return historicalData.rates.supplyApy + .map((supplyPoint, index) => { + const borrowPoint = historicalData.rates.borrowApy[index]; + const targetPoint = historicalData.rates.apyAtTarget[index]; + const utilizationPoint = historicalData.rates.utilization[index]; + const supplyAssetsPoint = historicalData.volumes.supplyAssets[index]; + const borrowAssetsPoint = historicalData.volumes.borrowAssets[index]; + const liquidityAssetsPoint = historicalData.volumes.liquidityAssets[index]; + + if ( + supplyPoint?.y == null || + borrowPoint?.y == null || + targetPoint?.y == null || + utilizationPoint?.y == null || + supplyAssetsPoint?.y == null || + borrowAssetsPoint?.y == null || + liquidityAssetsPoint?.y == null + ) { + return null; + } + + return { + x: supplyPoint.x, + supplyApy: supplyPoint.y, + borrowApy: borrowPoint.y, + apyAtTarget: targetPoint.y, + utilization: utilizationPoint.y, + supplyAssets: supplyAssetsPoint.y, + borrowAssets: borrowAssetsPoint.y, + liquidityAssets: liquidityAssetsPoint.y, + }; + }) + .filter((point): point is HistoricalSamplePoint => point !== null); +}; + +const buildHistoricalDataFromSamplePoints = (points: HistoricalSamplePoint[]): HistoricalDataSuccessResult => ({ + rates: { + supplyApy: points.map((point) => ({ x: point.x, y: point.supplyApy })), + borrowApy: points.map((point) => ({ x: point.x, y: point.borrowApy })), + apyAtTarget: points.map((point) => ({ x: point.x, y: point.apyAtTarget })), + utilization: points.map((point) => ({ x: point.x, y: point.utilization })), + }, + volumes: { + supplyAssetsUsd: [], + borrowAssetsUsd: [], + liquidityAssetsUsd: [], + supplyAssets: points.map((point) => ({ x: point.x, y: point.supplyAssets })), + borrowAssets: points.map((point) => ({ x: point.x, y: point.borrowAssets })), + liquidityAssets: points.map((point) => ({ x: point.x, y: point.liquidityAssets })), + }, +}); + +const findNearestHistoricalPoint = ( + points: HistoricalSamplePoint[], + targetTimestamp: number, + toleranceSeconds: number, + usedIndexes: Set, +): HistoricalSamplePoint | null => { + let bestPoint: HistoricalSamplePoint | null = null; + let bestPointIndex = -1; + let bestDiff = Number.POSITIVE_INFINITY; + + points.forEach((point, index) => { + if (usedIndexes.has(index)) { + return; + } + + const diff = Math.abs(point.x - targetTimestamp); + if (diff > toleranceSeconds || diff >= bestDiff) { + return; + } + + bestPoint = point; + bestPointIndex = index; + bestDiff = diff; + }); + + if (bestPointIndex >= 0) { + usedIndexes.add(bestPointIndex); + } + + return bestPoint; +}; export const useMarketHistoricalData = ( uniqueKey: string | undefined, network: SupportedNetworks | undefined, options: TimeseriesOptions | undefined, + market?: Market, + timeframe?: ChartTimeframe, ) => { - const queryKey = ['marketHistoricalData', uniqueKey, network, options?.startTimestamp, options?.endTimestamp, options?.interval]; + const { customRpcUrls } = useCustomRpcContext(); + const customRpcUrl = network ? customRpcUrls[network] : undefined; + const queryKey = [ + 'marketHistoricalData', + uniqueKey, + network, + options?.startTimestamp, + options?.endTimestamp, + options?.interval, + customRpcUrl ?? null, + timeframe ?? null, + ]; - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: queryKey, - queryFn: async (): Promise => { + queryFn: async (): Promise => { if (!uniqueKey || !network || !options) { console.log('Historical data prerequisites not met.', { uniqueKey, network, options, }); - return null; + return { + historicalData: null, + stateReadPoints: [], + }; } let historicalData: HistoricalDataSuccessResult | null = null; @@ -34,12 +158,8 @@ export const useMarketHistoricalData = ( console.error('Failed to fetch historical data via Monarch API:', monarchError); } - if (historicalData) { - return historicalData; - } - // Try Morpho API next if supported - if (supportsMorphoApi(network)) { + if (!historicalData && supportsMorphoApi(network)) { try { console.log(`Attempting to fetch historical data via Morpho API for ${uniqueKey}`); historicalData = await fetchMorphoMarketHistoricalData(uniqueKey, network, options); @@ -60,17 +180,77 @@ export const useMarketHistoricalData = ( } } - return historicalData; + let stateReadPoints: HistoricalMarketBoundaryState[] = []; + if (market && timeframe) { + const targetTimestamps = calculateTimePoints(timeframe, options.endTimestamp).slice(0, -1); + const toleranceSeconds = Math.max(1, Math.floor(TIMEFRAME_CONFIG[timeframe].intervalSeconds / 2)); + const historicalPoints = buildHistoricalSamplePoints(historicalData); + const usedPointIndexes = new Set(); + const pointsByTarget = new Map(); + + targetTimestamps.forEach((targetTimestamp) => { + const nearestPoint = findNearestHistoricalPoint(historicalPoints, targetTimestamp, toleranceSeconds, usedPointIndexes); + if (!nearestPoint) { + return; + } + + pointsByTarget.set(targetTimestamp, { + ...nearestPoint, + x: targetTimestamp, + }); + }); + + const missingTargetTimestamps = targetTimestamps.filter((targetTimestamp) => !pointsByTarget.has(targetTimestamp)); + try { + stateReadPoints = await fetchHistoricalMarketBoundaryStates( + market, + missingTargetTimestamps, + customRpcUrl ? { [network]: customRpcUrl } : {}, + ); + } catch (boundaryError) { + console.error('Failed to fetch historical boundary states via RPC:', boundaryError); + stateReadPoints = []; + } + + stateReadPoints.forEach((point) => { + pointsByTarget.set(point.targetTimestamp, { + x: point.targetTimestamp, + supplyApy: point.supplyApy, + borrowApy: point.borrowApy, + apyAtTarget: point.apyAtTarget, + utilization: point.utilization, + supplyAssets: point.supplyAssets, + borrowAssets: point.borrowAssets, + liquidityAssets: point.liquidityAssets, + }); + }); + + const filledPoints = targetTimestamps + .map((targetTimestamp) => pointsByTarget.get(targetTimestamp) ?? null) + .filter((point): point is HistoricalSamplePoint => point !== null); + + historicalData = filledPoints.length > 0 ? buildHistoricalDataFromSamplePoints(filledPoints) : null; + } + + return { + historicalData, + stateReadPoints, + }; }, enabled: !!uniqueKey && !!network && !!options, staleTime: 1000 * 60 * 5, - placeholderData: null, + placeholderData: { + historicalData: null, + stateReadPoints: [], + }, retry: 1, }); return { - data: data, + data: data?.historicalData ?? null, + stateReadPoints: data?.stateReadPoints ?? [], isLoading: isLoading, + isFetching: isFetching, error: error, refetch: refetch, }; diff --git a/src/utils/market-rate-enrichment.ts b/src/utils/market-rate-enrichment.ts index 89bafb84..9168fa1b 100644 --- a/src/utils/market-rate-enrichment.ts +++ b/src/utils/market-rate-enrichment.ts @@ -1,7 +1,7 @@ import { supportsMorphoApi } from '@/config/dataSources'; import { fetchMorphoMarket } from '@/data-sources/morpho-api/market'; import { fetchMorphoMarketRateEnrichments, getMorphoMarketRateFieldsKey } from '@/data-sources/morpho-api/market-rate-fields'; -import { MarketUtils, SharesMath } from '@morpho-org/blue-sdk'; +import { AdaptiveCurveIrmLib, MarketUtils, MathLib, SharesMath } from '@morpho-org/blue-sdk'; import type { Address } from 'viem'; import { morphoIrmAbi } from '@/abis/morpho-irm'; import type { CustomRpcUrls } from '@/stores/useCustomRpc'; @@ -15,6 +15,7 @@ import type { Market } from '@/utils/types'; const SECONDS_PER_DAY = 24 * 60 * 60; const BORROW_RATE_BATCH_SIZE = 100; const BORROW_RATE_PARALLEL_BATCHES = 2; +const MARKET_RATE_RPC_FALLBACK_ENABLED = process.env.NEXT_PUBLIC_ENABLE_MARKET_RATE_RPC_FALLBACK?.trim().toLowerCase() !== 'false'; const LOOKBACK_WINDOWS = [ { @@ -76,6 +77,19 @@ export type MarketRateEnrichment = Pick< export type MarketRateEnrichmentMap = Map; export const ROLLING_RATE_WINDOW_SECONDS = LOOKBACK_WINDOWS.map((window) => window.seconds); +export type HistoricalMarketBoundaryState = { + timestamp: number; + targetTimestamp: number; + blockNumber: number; + supplyApy: number; + borrowApy: number; + apyAtTarget: number; + utilization: number; + supplyAssets: bigint; + borrowAssets: bigint; + liquidityAssets: bigint; +}; + const buildEmptyEnrichment = (): MarketRateEnrichment => ({ apyAtTarget: 0, rateAtTarget: '0', @@ -92,6 +106,31 @@ const buildEmptyWindowRates = (windowSeconds: number[]): MarketWindowRates => const toBigInt = (value: string | number | bigint): bigint => BigInt(value); +const deriveRateAtTargetFromBorrowRate = (borrowRate: bigint, utilization: bigint): bigint => { + if (borrowRate <= 0n) { + return 0n; + } + + const targetUtilization = AdaptiveCurveIrmLib.TARGET_UTILIZATION; + const errNormFactor = utilization > targetUtilization ? MathLib.WAD - targetUtilization : targetUtilization; + if (errNormFactor <= 0n) { + return 0n; + } + + const err = MathLib.wDivDown(utilization - targetUtilization, errNormFactor); + const coeff = + err < 0n + ? MathLib.WAD - MathLib.wDivDown(MathLib.WAD, AdaptiveCurveIrmLib.CURVE_STEEPNESS) + : AdaptiveCurveIrmLib.CURVE_STEEPNESS - MathLib.WAD; + const factor = MathLib.wMulDown(coeff, err) + MathLib.WAD; + + if (factor <= 0n) { + return 0n; + } + + return MathLib.wDivDown(borrowRate, factor); +}; + export const getWindowRatesFromEnrichment = (enrichment: MarketRateEnrichment | undefined, windowSeconds: number): WindowRealizedRates => { if (!enrichment) { return { supplyApy: null, borrowApy: null }; @@ -483,6 +522,84 @@ export async function fetchRealizedMarketWindowRates( return marketWindowRates; } +export async function fetchHistoricalMarketBoundaryState( + market: RateEnrichmentMarketInput, + targetTimestamp: number, + customRpcUrls: CustomRpcUrls = {}, +): Promise { + const [boundaryState] = await fetchHistoricalMarketBoundaryStates(market, [targetTimestamp], customRpcUrls); + return boundaryState ?? null; +} + +export async function fetchHistoricalMarketBoundaryStates( + market: RateEnrichmentMarketInput, + targetTimestamps: number[], + customRpcUrls: CustomRpcUrls = {}, +): Promise { + const uniqueTargetTimestamps = Array.from( + new Set(targetTimestamps.filter((timestamp) => Number.isFinite(timestamp) && timestamp > 0)), + ).sort((left, right) => left - right); + + if (uniqueTargetTimestamps.length === 0) { + return []; + } + + const chainId = market.morphoBlue.chain.id as SupportedNetworks; + const client = getClient(chainId, customRpcUrls[chainId]); + + try { + const latestBlockNumber = Number(await client.getBlockNumber()); + const latestBlock = await client.getBlock({ blockNumber: BigInt(latestBlockNumber) }); + const latestTimestamp = Number(latestBlock.timestamp); + const boundaryBlocks = await fetchBlocksWithTimestamps(client, chainId, uniqueTargetTimestamps, latestBlockNumber, latestTimestamp); + const boundaryStates: HistoricalMarketBoundaryState[] = []; + + for (const boundaryBlock of boundaryBlocks) { + const snapshots = await fetchMarketsSnapshots([market.uniqueKey], chainId, client, boundaryBlock.blockNumber); + const snapshot = snapshots.get(market.uniqueKey.toLowerCase()); + if (!snapshot || isUninitializedSnapshot(snapshot)) { + continue; + } + + const borrowRates = await fetchBoundaryBorrowRates([market], chainId, snapshots, client, boundaryBlock.blockNumber); + const borrowRate = borrowRates.get(market.uniqueKey.toLowerCase()); + if (borrowRate == null) { + continue; + } + + const accruedState = accrueSnapshotToTimestamp(snapshot, borrowRate, boundaryBlock.timestamp); + if (!accruedState) { + continue; + } + + const utilizationWad = MarketUtils.getUtilization({ + totalSupplyAssets: accruedState.totalSupplyAssets, + totalBorrowAssets: accruedState.totalBorrowAssets, + }); + const supplyRate = MathLib.wMulUp(MathLib.wMulDown(borrowRate, utilizationWad), MathLib.WAD - toBigInt(snapshot.fee)); + const rateAtTarget = deriveRateAtTargetFromBorrowRate(borrowRate, utilizationWad); + + boundaryStates.push({ + timestamp: boundaryBlock.timestamp, + targetTimestamp: boundaryBlock.targetTimestamp, + blockNumber: boundaryBlock.blockNumber, + supplyApy: MarketUtils.rateToApy(supplyRate), + borrowApy: MarketUtils.rateToApy(borrowRate), + apyAtTarget: MarketUtils.rateToApy(rateAtTarget), + utilization: Number(utilizationWad) / 1e18, + supplyAssets: accruedState.totalSupplyAssets, + borrowAssets: accruedState.totalBorrowAssets, + liquidityAssets: accruedState.totalSupplyAssets - accruedState.totalBorrowAssets, + }); + } + + return boundaryStates; + } catch (error) { + console.warn(`[market-rate-enrichment] Failed to fetch boundary states for market ${market.uniqueKey} on chain ${chainId}:`, error); + return []; + } +} + export async function fetchMarketRateEnrichment( markets: RateEnrichmentMarketInput[], customRpcUrls: CustomRpcUrls = {}, @@ -510,7 +627,7 @@ export async function fetchMarketRateEnrichment( enrichments.set(key, morphoEnrichment); }); - if (morphoRateFetchFailed) { + if (morphoRateFetchFailed && MARKET_RATE_RPC_FALLBACK_ENABLED) { const windowRates = await fetchRealizedMarketWindowRates( chainMarkets, LOOKBACK_WINDOWS.map((window) => window.seconds),