diff --git a/AGENTS.md b/AGENTS.md index c47a4aae..e0e7ee30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,7 @@ When touching transaction and position flows, validation MUST include all releva 49. **Merged-history pagination query integrity**: when a UI paginates over a merged transaction/history dataset that is fetched as a full filtered set (for example because the backend cannot page a merged multi-table stream correctly), keep page changes local to the UI and do not include `skip`/`first` or current-page state in the upstream query key. Fetch once per filter scope, then slice locally, so pagination controls do not re-trigger the full upstream history cycle on every click. 50. **Market-registry fallback integrity**: shared multi-chain market registry reads must preserve per-chain isolation across providers. If a chain-scoped Morpho market-list request fails, fall back to a chain-scoped Monarch/Envio market-list query before subgraph, keep the fallback fully paginated, and apply the same canonical market guards (for example non-zero collateral token and non-zero IRM) so one broken chain such as HyperEVM does not blank the registry for healthy chains. 51. **Fallback token-decimal integrity**: market-registry fallback paths must not invent ERC20 decimals for unknown tokens. Resolve unknown token decimals through shared chain-scoped RPC multicalls keyed by canonical `chainId + address`, and fail closed for any market whose required token metadata cannot be resolved safely. +52. **Market-detail live-state source integrity**: `useMarketData` must treat Monarch/Envio as the primary source for live market state fields that drive the market page (`supplyApy`, `borrowApy`, `apyAtTarget`, `utilization`, balances/shares/liquidity where available), while preserving fallback-shell metadata fields that Monarch does not yet expose with parity (for example `whitelisted`, `supplyingVaults`, and rolling daily/weekly/monthly APYs). Do not let broken Morpho live state leak directly into market-detail headers/charts when Monarch state is available, and do not replace the whole market shell with partial Monarch metadata. ### REQUIRED: Regression Rule Capture diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index f11545da..e47d3716 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -166,10 +166,20 @@ NonStandardOracleOutput { ### Multi-Source Strategy ``` -Markets / positions: Morpho API (https://blue-api.morpho.org/graphql) +Market registry / market shells: + Morpho API (https://blue-api.morpho.org/graphql) ↓ (if unavailable or unsupported chain) + Monarch GraphQL (https://api.monarchlend.xyz/graphql) + ↓ Subgraph (The Graph / Goldsky) +Market detail live state + historical charts: + Monarch GraphQL (https://api.monarchlend.xyz/graphql) + ↓ (for shell metadata fallback and optional USD backfill) + Morpho API / Subgraph + ↓ (fresh balances / shares / liquidity override) + On-chain RPC snapshot + Autovault metadata: Monarch GraphQL (https://api.monarchlend.xyz/graphql) ↓ (if indexer lag / API failure) Narrow on-chain RPC fallback @@ -195,7 +205,8 @@ Market metrics: Monarch metrics API via `/api/monarch/metrics` |-----------|--------|---------|------------| | Markets list | Morpho API → Monarch API → Subgraph | 5 min stale | `useMarketsQuery` | | Market metrics (flows, trending) | Monarch API | 5 min stale | `useMarketMetricsQuery` | -| Market state (APY, utilization) | RPC snapshot + Morpho API/Subgraph | 30s stale | `useMarketData` | +| Market state (APY, utilization, balances) | Monarch market state + Morpho/Subgraph shell + RPC snapshot | 30s stale | `useMarketData` | +| Market historical chart series | Monarch GraphQL → Morpho API → Subgraph | 5 min stale | `useMarketHistoricalData` | | User positions | Monarch position discovery + on-chain snapshots + market registry from `useProcessedMarkets` | 5 min | `useUserPositions` | | User transaction history | Monarch GraphQL → Morpho API → Subgraph (`assetIds` queries still skip Monarch) | 60s | `useUserTransactionsQuery` | | Vaults list | Morpho API | 5 min | `useAllMorphoVaultsQuery` | @@ -220,11 +231,11 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he | Hook / Family | Responsibility | Infra Today | Full Monarch Support Still Needs | |---------------|----------------|-------------|----------------------------------| -| `useMarketsQuery` | Global market registry used across the app | Morpho API first per chain, then Monarch API, then subgraph | Monarch single-market detail parity | +| `useMarketsQuery` | Global market registry used across the app | Morpho API first per chain, then Monarch API, then subgraph | Rolling daily/weekly/monthly APYs plus whitelist/supplying-vault metadata parity if we ever want Monarch-first registry reads | | `useProcessedMarkets` | Blacklist/filtering layer on top of market registry, plus USD backfill | `useMarketsQuery` + `useTokenPrices` | Inherits `useMarketsQuery`; also needs a Monarch-native token price source if we want to remove Morpho price reads | -| `useMarketData` | Single-market detail shell with freshest live state | RPC snapshot + Morpho API, then subgraph | Monarch single-market metadata/detail path | -| `useMarketHistoricalData` | Historical market chart series | Morpho historical API, then subgraph | Monarch historical market snapshots/timeseries | -| `useTokenPrices` | Token USD price lookup and peg fallback used by markets/admin stats | Morpho price API + major price fallback | Monarch price endpoint or another canonical replacement | +| `useMarketData` | Single-market detail shell with freshest live state | Monarch live-state overlay on Morpho/Subgraph shell, then RPC snapshot override | Whitelist, supplying-vault, and rolling-APY metadata parity if we want to remove the shell fallback entirely | +| `useMarketHistoricalData` | Historical market chart series | Monarch historical snapshots first; Morpho API/Subgraph only for fallback | Already aligned for the current asset-only market charts | +| `useTokenPrices` | Token USD price lookup and peg fallback used by markets/admin stats | Morpho price API + major price fallback | Intentionally Morpho/major-price backed today | | `useUserPositions` | Discover all markets where a user has positions, then attach live balances | Monarch batched `Position` discovery + RPC snapshots/oracle reads + market metadata from `useProcessedMarkets`; Morpho/Subgraph fallback for discovery | Monarch market registry/detail if position objects should no longer depend on Morpho/Subgraph market metadata | | `useUserPosition` | Single-market user position | RPC snapshot first; if snapshot unavailable, Monarch position state when local market exists; then Morpho/Subgraph fallback | Same market-registry/detail gap as `useUserPositions` | | `useUserTransactionsQuery` / `fetchUserTransactions` | User history across one or many chains | Monarch user-event tables first; fallback Morpho API, then subgraph; `assetIds` filter still bypasses Monarch | Asset-address filtered history support to fully back reports and any asset-scoped history views | @@ -248,8 +259,8 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he |---------------|----------------|-------------|----------------------------------| | `useUserVaultsV2Query` | User vault list with optional balance, TVL, and yield enrichment | Monarch vault metadata + RPC balances/totalAssets + RPC 4626 yield snapshots | Already off Morpho for yield; no new Envio schema gap identified | | `useVaultV2Data` | Vault detail/settings metadata for a single vault | Monarch vault detail first, narrow RPC fallback if metadata unavailable | Already aligned with Monarch-first design | -| `useAllMorphoVaultsQuery` | Global whitelisted vault registry | Morpho API only | Monarch/public vault registry parity | -| `usePublicAllocatorVaults` | Public allocator config for supplying vaults in a market | Morpho API only | Monarch/public allocator config endpoint parity | +| `useAllMorphoVaultsQuery` | Global whitelisted vault registry | Morpho API only | Intentionally Morpho-only today | +| `usePublicAllocatorVaults` | Public allocator config for supplying vaults in a market | Morpho API only | Intentionally Morpho-only today | | `useAllocationsQuery` | Live vault `allocation(capId)` values | Pure RPC multicall | No Envio gap | | `usePublicAllocatorLiveData` | Live flow caps, vault supply, and liquidity for allocator UX | Pure RPC multicall | No Envio gap | | `useVaultHistoricalApy` / `use4626VaultAPR` | Historical 4626 yield and expected carry calculations | Pure RPC share-price snapshots + RPC Morpho market reads | No Envio gap | @@ -370,8 +381,8 @@ Fallback Strategy: **Monarch GraphQL** (`/src/data-sources/monarch-api/fetchers.ts`): - Endpoint: `NEXT_PUBLIC_MONARCH_API_NEW` -- Browser fetch with `NEXT_PUBLIC_MONARCH_API_KEY` -- Used as the primary read path for autovault V2 metadata, market-detail reads, and admin transaction reads +- Browser fetch against a public endpoint; `NEXT_PUBLIC_MONARCH_API_KEY` is optional and only sent when configured +- Used as the primary read path for autovault V2 metadata, market-detail live state/history/activity, and admin transaction reads **Subgraph** (`/src/data-sources/subgraph/fetchers.ts`): - Configurable URL per network @@ -388,10 +399,11 @@ Fallback Strategy: - useOracleMetadata() for oracle classification and feed details ↓ 3. Market fetch: - a. Try on-chain snapshot (viem multicall) - b. Try Morpho API (if supported) - c. Fallback to Subgraph - d. Merge snapshot with API state + a. Start Monarch single-market state fetch + b. Start Morpho API / Subgraph shell fallback + c. Start on-chain snapshot (viem multicall) + d. Merge Monarch live state into the shell when both exist + e. Override balances / shares / liquidity with the RPC snapshot ↓ 4. Oracle metadata resolves separately by `chainId + oracleAddress` - Standard/meta oracle UI reads scanner-native `OracleOutputData` / `MetaOracleOutputData` @@ -402,10 +414,10 @@ Fallback Strategy: ### Key Patterns -1. **Fallback Chain**: API → Subgraph → Empty +1. **Feature-Scoped Priority**: Monarch-first for market detail/history/activity, Morpho-first for the global market registry, Subgraph last 2. **Parallel Execution**: `Promise.all()` for multi-network 3. **Graceful Degradation**: Partial data > Error -4. **Two-Phase Market**: On-chain snapshot + API state +4. **Three-Phase Market Detail**: Monarch live state + fallback shell + RPC snapshot 5. **Hybrid Reads**: Scanner metadata for oracle structure + live RPC/API for market state --- @@ -416,7 +428,7 @@ Fallback Strategy: | Service | Endpoint | Purpose | |---------|----------|---------| | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions | -| Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, market detail/activity, admin transactions | +| Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, market live state, historical charts, market detail/activity, admin transactions | | Monarch Metrics | `/api/monarch/metrics` → external Monarch metrics API | Market metrics and admin stats | | The Graph | Per-chain subgraph URLs | Fallback data, suppliers, borrowers | | Merkl API | `https://api.merkl.xyz` | Reward campaigns | diff --git a/src/data-sources/monarch-api/fetchers.ts b/src/data-sources/monarch-api/fetchers.ts index b016182a..d9c802ff 100644 --- a/src/data-sources/monarch-api/fetchers.ts +++ b/src/data-sources/monarch-api/fetchers.ts @@ -16,16 +16,21 @@ export const monarchGraphqlFetcher = async >( variables: GraphQLVariables = {}, options: MonarchGraphqlFetcherOptions = {}, ): Promise => { - if (!MONARCH_GRAPHQL_API_ENDPOINT || !MONARCH_GRAPHQL_API_KEY) { + if (!MONARCH_GRAPHQL_API_ENDPOINT) { throw new Error('Monarch GraphQL client not configured'); } + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (MONARCH_GRAPHQL_API_KEY) { + headers.Authorization = `Bearer ${MONARCH_GRAPHQL_API_KEY}`; + } + const response = await fetch(MONARCH_GRAPHQL_API_ENDPOINT, { method: 'POST', - headers: { - Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`, - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify({ query, variables, diff --git a/src/data-sources/monarch-api/historical.ts b/src/data-sources/monarch-api/historical.ts new file mode 100644 index 00000000..bb2db18e --- /dev/null +++ b/src/data-sources/monarch-api/historical.ts @@ -0,0 +1,174 @@ +import { formatUnits } from 'viem'; +import { envioMarketDailySnapshotsQuery, envioMarketHourlySnapshotsQuery } from '@/graphql/envio-queries'; +import { convertAprToApy } from '@/utils/rateMath'; +import type { MarketRates, MarketVolumes, TimeseriesOptions } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { HistoricalDataSuccessResult } from '../morpho-api/historical'; +import { monarchGraphqlFetcher } from './fetchers'; + +type MonarchHistoricalSnapshotRow = { + timestamp: string; + totalSupplyAssets: string; + totalBorrowAssets: string; + supplyRateApr: string; + borrowRateApr: string; + rateAtTargetApr: string; + utilization: string; +}; + +type MonarchHistoricalSnapshotsResponse = { + data?: { + MarketHourlySnapshot?: MonarchHistoricalSnapshotRow[]; + MarketDailySnapshot?: MonarchHistoricalSnapshotRow[]; + }; +}; + +const WAD_DECIMALS = 18; +const MONARCH_HISTORICAL_PAGE_LIMIT = 1_000; +const MONARCH_HISTORICAL_TIMEOUT_MS = 10_000; + +const sortByTimestamp = (left: { x: number }, right: { x: number }): number => left.x - right.x; + +const parseIntegerValue = (value: string): bigint | null => { + try { + return BigInt(value); + } catch { + return null; + } +}; + +const parseWadDecimal = (value: string): number | null => { + try { + return Number(formatUnits(BigInt(value), WAD_DECIMALS)); + } catch { + return null; + } +}; + +const createEmptyVolumes = (): MarketVolumes => ({ + supplyAssetsUsd: [], + borrowAssetsUsd: [], + liquidityAssetsUsd: [], + supplyAssets: [], + borrowAssets: [], + liquidityAssets: [], +}); + +const transformSnapshotsToHistoricalResult = (snapshots: MonarchHistoricalSnapshotRow[]): HistoricalDataSuccessResult | null => { + if (snapshots.length === 0) { + return null; + } + + const rates: MarketRates = { + supplyApy: [], + borrowApy: [], + apyAtTarget: [], + utilization: [], + }; + const volumes = createEmptyVolumes(); + + for (const snapshot of snapshots) { + const timestamp = Number.parseInt(snapshot.timestamp, 10); + const supplyAssets = parseIntegerValue(snapshot.totalSupplyAssets); + const borrowAssets = parseIntegerValue(snapshot.totalBorrowAssets); + const supplyApr = parseWadDecimal(snapshot.supplyRateApr); + const borrowApr = parseWadDecimal(snapshot.borrowRateApr); + const targetApr = parseWadDecimal(snapshot.rateAtTargetApr); + const utilization = parseWadDecimal(snapshot.utilization); + + if ( + !Number.isFinite(timestamp) || + supplyAssets === null || + borrowAssets === null || + supplyApr === null || + borrowApr === null || + targetApr === null || + utilization === null + ) { + continue; + } + + rates.supplyApy.push({ + x: timestamp, + y: convertAprToApy(supplyApr), + }); + rates.borrowApy.push({ + x: timestamp, + y: convertAprToApy(borrowApr), + }); + rates.apyAtTarget.push({ + x: timestamp, + y: convertAprToApy(targetApr), + }); + rates.utilization.push({ + x: timestamp, + y: utilization, + }); + + volumes.supplyAssets.push({ + x: timestamp, + y: supplyAssets, + }); + volumes.borrowAssets.push({ + x: timestamp, + y: borrowAssets, + }); + volumes.liquidityAssets.push({ + x: timestamp, + y: supplyAssets - borrowAssets, + }); + } + + if (rates.supplyApy.length === 0) { + return null; + } + + Object.values(rates).forEach((series) => series.sort(sortByTimestamp)); + Object.values(volumes).forEach((series) => series.sort(sortByTimestamp)); + + return { rates, volumes }; +}; + +export const fetchMonarchMarketHistoricalData = async ( + marketId: string, + chainId: SupportedNetworks, + options: TimeseriesOptions, +): Promise => { + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => { + controller.abort(); + }, MONARCH_HISTORICAL_TIMEOUT_MS); + + const variables = { + chainId, + marketId: marketId.toLowerCase(), + startTimestamp: String(options.startTimestamp), + endTimestamp: String(options.endTimestamp), + limit: MONARCH_HISTORICAL_PAGE_LIMIT, + }; + + const query = + options.interval === 'HOUR' + ? envioMarketHourlySnapshotsQuery + : envioMarketDailySnapshotsQuery; + + try { + const response = await monarchGraphqlFetcher(query, variables, { + signal: controller.signal, + }); + const snapshots = + options.interval === 'HOUR' + ? (response.data?.MarketHourlySnapshot ?? []) + : (response.data?.MarketDailySnapshot ?? []); + + return transformSnapshotsToHistoricalResult(snapshots); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Monarch historical request timed out after ${MONARCH_HISTORICAL_TIMEOUT_MS}ms`); + } + + throw error; + } finally { + globalThis.clearTimeout(timeoutId); + } +}; diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index 4dfb25f6..d90a6c06 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -1,5 +1,5 @@ export { monarchGraphqlFetcher } from './fetchers'; -export { fetchMonarchMarkets } from './markets'; +export { fetchMonarchMarket, fetchMonarchMarkets } from './markets'; export { fetchMonarchUserPositionMarketsForNetworks, fetchMonarchUserPositionStateForMarket, diff --git a/src/data-sources/monarch-api/markets.ts b/src/data-sources/monarch-api/markets.ts index fa7c7af3..8c86c6ed 100644 --- a/src/data-sources/monarch-api/markets.ts +++ b/src/data-sources/monarch-api/markets.ts @@ -1,6 +1,6 @@ import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho-org/blue-sdk'; import { formatUnits, type Address, zeroAddress } from 'viem'; -import { buildEnvioMarketsPageQuery } from '@/graphql/envio-queries'; +import { buildEnvioMarketsPageQuery, envioMarketByIdQuery } from '@/graphql/envio-queries'; import { getMorphoAddress } from '@/utils/morpho'; import { isSupportedChain, type SupportedNetworks } from '@/utils/networks'; import { blacklistTokens, infoToKey } from '@/utils/tokens'; @@ -195,6 +195,16 @@ const fetchMonarchMarketsPage = async (query: string, variables: Record => { + if (rows.length === 0) { + return []; + } + + const tokenInfos = await resolveTokenInfos(getMarketTokenInputs(rows)); + + return rows.map((market) => mapMonarchMarketToMarket(market, tokenInfos)).filter((market): market is Market => market !== null); +}; + // If `network` is omitted, this fetches the merged multi-chain market registry in one query path. export const fetchMonarchMarkets = async (network?: SupportedNetworks): Promise => { const query = buildEnvioMarketsPageQuery({ @@ -224,7 +234,16 @@ export const fetchMonarchMarkets = async (network?: SupportedNetworks): Promise< offset += rows.length; } - const tokenInfos = await resolveTokenInfos(getMarketTokenInputs(allRows)); + return mapMonarchMarketRows(allRows); +}; + +export const fetchMonarchMarket = async (uniqueKey: string, network: SupportedNetworks): Promise => { + const rows = await fetchMonarchMarketsPage(envioMarketByIdQuery, { + chainId: network, + marketId: normalizeAddress(uniqueKey), + zeroAddress: MONARCH_MARKETS_ZERO_ADDRESS, + }); - return allRows.map((market) => mapMonarchMarketToMarket(market, tokenInfos)).filter((market): market is Market => market !== null); + const [market] = await mapMonarchMarketRows(rows); + return market ?? null; }; diff --git a/src/data-sources/morpho-api/historical.ts b/src/data-sources/morpho-api/historical.ts index f67af378..3ce9618d 100644 --- a/src/data-sources/morpho-api/historical.ts +++ b/src/data-sources/morpho-api/historical.ts @@ -1,6 +1,6 @@ import { marketHistoricalDataQuery } from '@/graphql/morpho-api-queries'; import type { SupportedNetworks } from '@/utils/networks'; -import type { Market, MarketRates, MarketVolumes, TimeseriesOptions, TimeseriesDataPoint } from '@/utils/types'; +import type { Market, MarketRates, MarketVolumes, TimeseriesOptions } from '@/utils/types'; import { morphoGraphqlFetcher } from './fetchers'; // Adjust the response structure type: historicalState contains rates/volumes directly @@ -23,6 +23,43 @@ export type HistoricalDataSuccessResult = { }; // --- End Types --- +const sortByTimestamp = (left: { x: number }, right: { x: number }): number => left.x - right.x; + +type RawTimeseriesPoint = { + x: number; + y: unknown; +}; + +const normalizeAssetPointValue = (value: unknown): bigint | null => { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === 'bigint') { + return value; + } + + if (typeof value === 'string') { + try { + return BigInt(value); + } catch { + return null; + } + } + + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return BigInt(value); + } + + return null; +}; + +const normalizeAssetSeries = (series: RawTimeseriesPoint[] | undefined) => + (series ?? []).map((point) => ({ + x: point.x, + y: normalizeAssetPointValue(point.y), + })); + // Fetcher for historical market data from Morpho API export const fetchMorphoMarketHistoricalData = async ( uniqueKey: string, @@ -63,15 +100,14 @@ export const fetchMorphoMarketHistoricalData = async ( supplyAssetsUsd: historicalState.supplyAssetsUsd ?? [], borrowAssetsUsd: historicalState.borrowAssetsUsd ?? [], liquidityAssetsUsd: historicalState.liquidityAssetsUsd ?? [], - supplyAssets: historicalState.supplyAssets ?? [], - borrowAssets: historicalState.borrowAssets ?? [], - liquidityAssets: historicalState.liquidityAssets ?? [], + supplyAssets: normalizeAssetSeries(historicalState.supplyAssets), + borrowAssets: normalizeAssetSeries(historicalState.borrowAssets), + liquidityAssets: normalizeAssetSeries(historicalState.liquidityAssets), }; // Sort each timeseries array by timestamp (x-axis) ascending - const sortByTimestamp = (a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x; - Object.values(rates).forEach((arr) => arr.sort(sortByTimestamp)); - Object.values(volumes).forEach((arr) => arr.sort(sortByTimestamp)); + Object.values(rates).forEach((series) => series.sort(sortByTimestamp)); + Object.values(volumes).forEach((series) => series.sort(sortByTimestamp)); return { rates, volumes }; } catch (error) { diff --git a/src/data-sources/subgraph/historical.ts b/src/data-sources/subgraph/historical.ts index a54ce04f..64f0e116 100644 --- a/src/data-sources/subgraph/historical.ts +++ b/src/data-sources/subgraph/historical.ts @@ -1,7 +1,7 @@ import { marketHourlySnapshotsQuery } from '@/graphql/morpho-subgraph-queries'; import type { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; -import type { TimeseriesOptions, TimeseriesDataPoint, MarketRates, MarketVolumes } from '@/utils/types'; +import type { AssetTimeseriesDataPoint, MarketRates, MarketVolumes, NumericTimeseriesDataPoint, TimeseriesOptions } from '@/utils/types'; import type { HistoricalDataSuccessResult } from '../morpho-api/historical'; import { subgraphGraphqlFetcher } from './fetchers'; @@ -41,19 +41,20 @@ type SubgraphMarketHourlySnapshotQueryResponse = { const transformSubgraphSnapshotsToHistoricalResult = ( snapshots: SubgraphMarketHourlySnapshot[], // Expect non-empty array here ): HistoricalDataSuccessResult => { + const sortByTimestamp = (left: { x: number }, right: { x: number }): number => left.x - right.x; const rates: MarketRates = { - supplyApy: [] as TimeseriesDataPoint[], - borrowApy: [] as TimeseriesDataPoint[], - apyAtTarget: [] as TimeseriesDataPoint[], - utilization: [] as TimeseriesDataPoint[], + supplyApy: [] as NumericTimeseriesDataPoint[], + borrowApy: [] as NumericTimeseriesDataPoint[], + apyAtTarget: [] as NumericTimeseriesDataPoint[], + utilization: [] as NumericTimeseriesDataPoint[], }; const volumes: MarketVolumes = { - supplyAssetsUsd: [] as TimeseriesDataPoint[], - borrowAssetsUsd: [] as TimeseriesDataPoint[], - liquidityAssetsUsd: [] as TimeseriesDataPoint[], - supplyAssets: [] as TimeseriesDataPoint[], - borrowAssets: [] as TimeseriesDataPoint[], - liquidityAssets: [] as TimeseriesDataPoint[], + supplyAssetsUsd: [] as NumericTimeseriesDataPoint[], + borrowAssetsUsd: [] as NumericTimeseriesDataPoint[], + liquidityAssetsUsd: [] as NumericTimeseriesDataPoint[], + supplyAssets: [] as AssetTimeseriesDataPoint[], + borrowAssets: [] as AssetTimeseriesDataPoint[], + liquidityAssets: [] as AssetTimeseriesDataPoint[], }; // No need to check for !snapshots here, handled by caller @@ -90,14 +91,14 @@ const transformSubgraphSnapshotsToHistoricalResult = ( volumes.borrowAssetsUsd.push({ x: timestamp, y: 0 }); volumes.liquidityAssetsUsd.push({ x: timestamp, y: 0 }); - volumes.supplyAssets.push({ x: timestamp, y: Number(supplyNative) }); - volumes.borrowAssets.push({ x: timestamp, y: Number(borrowNative) }); - volumes.liquidityAssets.push({ x: timestamp, y: Number(liquidityNative) }); + volumes.supplyAssets.push({ x: timestamp, y: supplyNative }); + volumes.borrowAssets.push({ x: timestamp, y: borrowNative }); + volumes.liquidityAssets.push({ x: timestamp, y: liquidityNative }); }); // Sort data by timestamp - Object.values(rates).forEach((arr: TimeseriesDataPoint[]) => arr.sort((a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x)); - Object.values(volumes).forEach((arr: TimeseriesDataPoint[]) => arr.sort((a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x)); + Object.values(rates).forEach((series) => series.sort(sortByTimestamp)); + Object.values(volumes).forEach((series) => series.sort(sortByTimestamp)); return { rates, volumes }; }; diff --git a/src/features/market-detail/components/charts/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index e567a33a..14c50512 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -51,14 +51,23 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { return supplyApy .map((point: TimeseriesDataPoint, index: number) => { - // Skip data points with null values - if (point.y === null || borrowApy[index]?.y === null || apyAtTarget[index]?.y === null) { + 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; } const supplyVal = isAprDisplay ? convertApyToApr(point.y) : point.y; - const borrowVal = isAprDisplay ? convertApyToApr(borrowApy[index]?.y ?? 0) : (borrowApy[index]?.y ?? 0); - const targetVal = isAprDisplay ? convertApyToApr(apyAtTarget[index]?.y ?? 0) : (apyAtTarget[index]?.y ?? 0); + const borrowVal = isAprDisplay ? convertApyToApr(borrowValue) : borrowValue; + const targetVal = isAprDisplay ? convertApyToApr(targetValue) : targetValue; return { x: point.x, @@ -76,9 +85,20 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { const getAverage = (data: TimeseriesDataPoint[] | undefined) => { if (!data || data.length === 0) return 0; - const validPoints = data.filter((point) => point.y !== null); - if (validPoints.length === 0) return 0; - return validPoints.reduce((sum, point) => sum + point.y, 0) / validPoints.length; + + let total = 0; + let count = 0; + + for (const point of data) { + if (point.y === null || !Number.isFinite(point.y)) { + continue; + } + + total += point.y; + count += 1; + } + + return count === 0 ? 0 : total / count; }; const currentSupplyRate = toDisplayRate(market.state.supplyApy); diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 6a5f76df..b6b86ee6 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -21,8 +21,7 @@ import { chartTooltipCursor, chartLegendStyle, } from './chart-utils'; -import type { Market } from '@/utils/types'; -import type { TimeseriesDataPoint } from '@/utils/types'; +import type { AssetTimeseriesDataPoint, Market } from '@/utils/types'; type VolumeChartProps = { marketId: string; @@ -45,9 +44,7 @@ function formatNetChangePercentage(value: number): string { function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { const selectedTimeframe = useMarketDetailChartState((s) => s.selectedTimeframe); const selectedTimeRange = useMarketDetailChartState((s) => s.selectedTimeRange); - const volumeView = useMarketDetailChartState((s) => s.volumeView); const setTimeframe = useMarketDetailChartState((s) => s.setTimeframe); - const setVolumeView = useMarketDetailChartState((s) => s.setVolumeView); const chartColors = useChartColors(); const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); @@ -58,52 +55,34 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { liquidity: true, }); - const formatYAxis = (value: number) => { - if (volumeView === 'USD') { - return `$${formatReadable(value)}`; - } - return formatReadable(value); - }; + const formatYAxis = (value: number) => formatReadable(value); - const convertValue = (raw: number | bigint | null): number => { - const value = raw ?? 0; - if (volumeView === 'USD') { - return Number(value); - } - return Number(formatUnits(BigInt(value), market.loanAsset.decimals)); + const convertValue = (raw: bigint | null): number => { + return Number(formatUnits(raw ?? 0n, market.loanAsset.decimals)); }; const chartData = useMemo(() => { if (!historicalData?.volumes) { - // Only show current state point in Asset mode (no USD values from market.state) - if (volumeView === 'Asset') { - return [ - { - x: moment().unix(), - supply: convertValue(BigInt(market.state.supplyAssets ?? 0)), - borrow: convertValue(BigInt(market.state.borrowAssets ?? 0)), - liquidity: convertValue(BigInt(market.state.liquidityAssets ?? 0)), - }, - ]; - } - return []; + return [ + { + x: moment().unix(), + supply: convertValue(BigInt(market.state.supplyAssets ?? 0)), + borrow: convertValue(BigInt(market.state.borrowAssets ?? 0)), + liquidity: convertValue(BigInt(market.state.liquidityAssets ?? 0)), + }, + ]; } - const supplyData = volumeView === 'USD' ? historicalData.volumes.supplyAssetsUsd : historicalData.volumes.supplyAssets; - const borrowData = volumeView === 'USD' ? historicalData.volumes.borrowAssetsUsd : historicalData.volumes.borrowAssets; - const liquidityData = volumeView === 'USD' ? historicalData.volumes.liquidityAssetsUsd : historicalData.volumes.liquidityAssets; + const supplyData = historicalData.volumes.supplyAssets; + const borrowData = historicalData.volumes.borrowAssets; + const liquidityData = historicalData.volumes.liquidityAssets; const historicalPoints = supplyData - .map((point: TimeseriesDataPoint, index: number) => { + .map((point: AssetTimeseriesDataPoint, index: number) => { if (point.y === null || borrowData[index]?.y === null || liquidityData[index]?.y === null) { return null; } - const supplyUsdValue = historicalData.volumes.supplyAssetsUsd[index]?.y; - if (supplyUsdValue !== null && supplyUsdValue >= 100_000_000_000) { - return null; - } - return { x: point.x, supply: convertValue(point.y), @@ -113,21 +92,16 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { }) .filter((point): point is NonNullable => point !== null); - // Only add "now" point in Asset mode (we don't have USD values from market.state) - if (volumeView === 'Asset') { - const nowPoint = { - x: moment().unix(), - supply: convertValue(BigInt(market.state.supplyAssets ?? 0)), - borrow: convertValue(BigInt(market.state.borrowAssets ?? 0)), - liquidity: convertValue(BigInt(market.state.liquidityAssets ?? 0)), - }; - return [...historicalPoints, nowPoint]; - } + const nowPoint = { + x: moment().unix(), + supply: convertValue(BigInt(market.state.supplyAssets ?? 0)), + borrow: convertValue(BigInt(market.state.borrowAssets ?? 0)), + liquidity: convertValue(BigInt(market.state.liquidityAssets ?? 0)), + }; - return historicalPoints; + return [...historicalPoints, nowPoint]; }, [ historicalData?.volumes, - volumeView, market.loanAsset.decimals, market.state.supplyAssets, market.state.borrowAssets, @@ -136,7 +110,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { const formatValue = (value: number) => { const formattedValue = formatReadable(value); - return volumeView === 'USD' ? `$${formattedValue}` : `${formattedValue} ${market.loanAsset.symbol}`; + return `${formattedValue} ${market.loanAsset.symbol}`; }; const STATE_KEY_MAP = { @@ -151,23 +125,19 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { const currentRaw = market.state[stateKey] ?? 0; const current = Number(formatUnits(BigInt(currentRaw), market.loanAsset.decimals)); - // Always use asset data for net change calculation (consistent units with current) const assetData = historicalData?.volumes[`${type}Assets`]; if (!assetData || assetData.length === 0) return { current, netChangePercentage: 0, average: 0 }; - const validAssetData = assetData.filter((point: TimeseriesDataPoint) => point.y !== null); + const validAssetData = assetData.filter((point: AssetTimeseriesDataPoint) => point.y !== null); if (validAssetData.length === 0) return { current, netChangePercentage: 0, average: 0 }; - // Net change percentage: compare asset-to-asset for consistent units const startAsset = Number(formatUnits(BigInt(validAssetData[0].y ?? 0), market.loanAsset.decimals)); const netChangePercentage = startAsset !== 0 ? ((current - startAsset) / startAsset) * 100 : 0; - // Average: use selected view data (USD or Asset) for display - const displayData = volumeView === 'USD' ? historicalData?.volumes[`${type}AssetsUsd`] : assetData; - const validDisplayData = displayData?.filter((point: TimeseriesDataPoint) => point.y !== null) ?? []; + const validDisplayData = assetData.filter((point: AssetTimeseriesDataPoint) => point.y !== null); const average = validDisplayData.length > 0 - ? validDisplayData.reduce((acc: number, point: TimeseriesDataPoint) => acc + convertValue(point.y), 0) / validDisplayData.length + ? validDisplayData.reduce((acc: number, point: AssetTimeseriesDataPoint) => acc + convertValue(point.y), 0) / validDisplayData.length : 0; return { current, netChangePercentage, average }; @@ -229,18 +199,6 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { {/* Controls */}
-