From c91313f18759cea096dc2ca123fed9ab1ee35dd2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 24 Mar 2026 17:05:37 +0800 Subject: [PATCH 1/5] feat:graph --- src/data-sources/monarch-api/fetchers.ts | 15 +- src/data-sources/monarch-api/historical.ts | 171 ++++++++++++++++++ .../components/charts/volume-chart.tsx | 2 +- src/graphql/envio-queries.ts | 38 ++++ src/hooks/useMarketHistoricalData.ts | 71 +++++++- 5 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 src/data-sources/monarch-api/historical.ts 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..d0a3f8ab --- /dev/null +++ b/src/data-sources/monarch-api/historical.ts @@ -0,0 +1,171 @@ +import { formatUnits } from 'viem'; +import { envioMarketDailySnapshotsQuery, envioMarketHourlySnapshotsQuery } from '@/graphql/envio-queries'; +import { convertAprToApy } from '@/utils/rateMath'; +import type { MarketRates, MarketVolumes, TimeseriesDataPoint, 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: TimeseriesDataPoint, right: TimeseriesDataPoint): number => left.x - right.x; + +const parseIntegerValue = (value: string): number | null => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 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, + 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/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 6a5f76df..3099681c 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -50,7 +50,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { const setVolumeView = useMarketDetailChartState((s) => s.setVolumeView); const chartColors = useChartColors(); - const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); + const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange, volumeView === 'USD'); const [visibleLines, setVisibleLines] = useState({ supply: true, diff --git a/src/graphql/envio-queries.ts b/src/graphql/envio-queries.ts index 5752aa96..a5373bdf 100644 --- a/src/graphql/envio-queries.ts +++ b/src/graphql/envio-queries.ts @@ -74,6 +74,44 @@ export const envioUserPositionForMarketQuery = ` } `; +const buildEnvioMarketSnapshotsQuery = ({ + operationName, + rootField, +}: { + operationName: 'EnvioMarketHourlySnapshots' | 'EnvioMarketDailySnapshots'; + rootField: 'MarketHourlySnapshot' | 'MarketDailySnapshot'; +}) => ` + query ${operationName}($chainId: Int!, $marketId: String!, $startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!) { + ${rootField}( + where: { + chainId: { _eq: $chainId } + marketId: { _eq: $marketId } + timestamp: { _gte: $startTimestamp, _lte: $endTimestamp } + } + order_by: [{ timestamp: asc }] + limit: $limit + ) { + timestamp + totalSupplyAssets + totalBorrowAssets + supplyRateApr + borrowRateApr + rateAtTargetApr + utilization + } + } +`; + +export const envioMarketHourlySnapshotsQuery = buildEnvioMarketSnapshotsQuery({ + operationName: 'EnvioMarketHourlySnapshots', + rootField: 'MarketHourlySnapshot', +}); + +export const envioMarketDailySnapshotsQuery = buildEnvioMarketSnapshotsQuery({ + operationName: 'EnvioMarketDailySnapshots', + rootField: 'MarketDailySnapshot', +}); + export const buildEnvioMarketsPageQuery = ({ useChainIdFilter }: { useChainIdFilter: boolean }): string => { const variableDeclarations = ['$limit: Int!', '$offset: Int!', '$zeroAddress: String!']; const whereClauses = ['collateralToken: { _neq: $zeroAddress }', 'irm: { _neq: $zeroAddress }']; diff --git a/src/hooks/useMarketHistoricalData.ts b/src/hooks/useMarketHistoricalData.ts index 4929aa48..b2d1d722 100644 --- a/src/hooks/useMarketHistoricalData.ts +++ b/src/hooks/useMarketHistoricalData.ts @@ -1,16 +1,63 @@ import { useQuery } from '@tanstack/react-query'; import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMonarchMarketHistoricalData } from '@/data-sources/monarch-api/historical'; import { fetchMorphoMarketHistoricalData, type HistoricalDataSuccessResult } from '@/data-sources/morpho-api/historical'; import { fetchSubgraphMarketHistoricalData } from '@/data-sources/subgraph/historical'; import type { SupportedNetworks } from '@/utils/networks'; import type { TimeseriesOptions } from '@/utils/types'; +const hasUsdSeries = (historicalData: HistoricalDataSuccessResult): boolean => + historicalData.volumes.supplyAssetsUsd.length > 0 && + historicalData.volumes.borrowAssetsUsd.length > 0 && + historicalData.volumes.liquidityAssetsUsd.length > 0; + +const mergeUsdFallback = ( + primaryData: HistoricalDataSuccessResult, + fallbackData: HistoricalDataSuccessResult, +): HistoricalDataSuccessResult => ({ + rates: primaryData.rates, + volumes: { + ...primaryData.volumes, + supplyAssetsUsd: primaryData.volumes.supplyAssetsUsd.length > 0 ? primaryData.volumes.supplyAssetsUsd : fallbackData.volumes.supplyAssetsUsd, + borrowAssetsUsd: primaryData.volumes.borrowAssetsUsd.length > 0 ? primaryData.volumes.borrowAssetsUsd : fallbackData.volumes.borrowAssetsUsd, + liquidityAssetsUsd: + primaryData.volumes.liquidityAssetsUsd.length > 0 ? primaryData.volumes.liquidityAssetsUsd : fallbackData.volumes.liquidityAssetsUsd, + }, +}); + +const fetchUsdBackfill = async ( + uniqueKey: string, + network: SupportedNetworks, + options: TimeseriesOptions, +): Promise => { + if (supportsMorphoApi(network)) { + try { + console.log(`Attempting to backfill USD historical data via Morpho API for ${uniqueKey}`); + const morphoHistoricalData = await fetchMorphoMarketHistoricalData(uniqueKey, network, options); + if (morphoHistoricalData) { + return morphoHistoricalData; + } + } catch (morphoUsdError) { + console.error('Failed to backfill USD historical data via Morpho API:', morphoUsdError); + } + } + + try { + console.log(`Attempting to backfill USD historical data via Subgraph for ${uniqueKey}`); + return await fetchSubgraphMarketHistoricalData(uniqueKey, network, options); + } catch (subgraphUsdError) { + console.error('Failed to backfill USD historical data via Subgraph:', subgraphUsdError); + return null; + } +}; + export const useMarketHistoricalData = ( uniqueKey: string | undefined, network: SupportedNetworks | undefined, options: TimeseriesOptions | undefined, + includeUsd = false, ) => { - const queryKey = ['marketHistoricalData', uniqueKey, network, options?.startTimestamp, options?.endTimestamp, options?.interval]; + const queryKey = ['marketHistoricalData', uniqueKey, network, options?.startTimestamp, options?.endTimestamp, options?.interval, includeUsd]; const { data, isLoading, error, refetch } = useQuery({ queryKey: queryKey, @@ -26,7 +73,27 @@ export const useMarketHistoricalData = ( let historicalData: HistoricalDataSuccessResult | null = null; - // Try Morpho API first if supported + try { + console.log(`Attempting to fetch historical data via Monarch API for ${uniqueKey}`); + historicalData = await fetchMonarchMarketHistoricalData(uniqueKey, network, options); + } catch (monarchError) { + console.error('Failed to fetch historical data via Monarch API:', monarchError); + } + + if (historicalData) { + if (!includeUsd || hasUsdSeries(historicalData)) { + return historicalData; + } + + const usdBackfill = await fetchUsdBackfill(uniqueKey, network, options); + if (usdBackfill) { + return mergeUsdFallback(historicalData, usdBackfill); + } + + return historicalData; + } + + // Try Morpho API next if supported if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch historical data via Morpho API for ${uniqueKey}`); From dc0477b8826da3bca565b827e2183cf91cfc53c5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 24 Mar 2026 19:56:03 +0800 Subject: [PATCH 2/5] feat: use monarch api for primary market rate --- AGENTS.md | 1 + docs/TECHNICAL_OVERVIEW.md | 46 +++++--- src/data-sources/monarch-api/index.ts | 2 +- src/data-sources/monarch-api/markets.ts | 25 +++- src/graphql/envio-queries.ts | 50 +++++--- src/hooks/useMarketData.ts | 150 ++++++++++++++++-------- 6 files changed, 187 insertions(+), 87 deletions(-) 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..13824d01 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 (USD backfill only when needed) | 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 and optional USD backfill | USD snapshot parity if we want to remove Morpho/Subgraph backfill entirely | +| `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/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/graphql/envio-queries.ts b/src/graphql/envio-queries.ts index a5373bdf..5a1935b7 100644 --- a/src/graphql/envio-queries.ts +++ b/src/graphql/envio-queries.ts @@ -74,6 +74,24 @@ export const envioUserPositionForMarketQuery = ` } `; +const envioMarketSelection = ` + chainId + marketId + loanToken + collateralToken + oracle + irm + lltv + totalSupplyAssets + totalBorrowAssets + totalSupplyShares + totalBorrowShares + collateralAssets + lastUpdate + fee + rateAtTarget +`; + const buildEnvioMarketSnapshotsQuery = ({ operationName, rootField, @@ -129,26 +147,28 @@ export const buildEnvioMarketsPageQuery = ({ useChainIdFilter }: { useChainIdFil offset: $offset order_by: [{ chainId: asc }, { marketId: asc }] ) { - chainId - marketId - loanToken - collateralToken - oracle - irm - lltv - totalSupplyAssets - totalBorrowAssets - totalSupplyShares - totalBorrowShares - collateralAssets - lastUpdate - fee - rateAtTarget + ${envioMarketSelection} } } `; }; +export const envioMarketByIdQuery = ` + query EnvioMarketById($chainId: Int!, $marketId: String!, $zeroAddress: String!) { + Market( + where: { + chainId: { _eq: $chainId } + marketId: { _eq: $marketId } + collateralToken: { _neq: $zeroAddress } + irm: { _neq: $zeroAddress } + } + limit: 1 + ) { + ${envioMarketSelection} + } + } +`; + export const buildEnvioUserTransactionsPageQuery = ({ useHashFilter, useMarketFilter, diff --git a/src/hooks/useMarketData.ts b/src/hooks/useMarketData.ts index 7c74812a..c3bd4f38 100644 --- a/src/hooks/useMarketData.ts +++ b/src/hooks/useMarketData.ts @@ -1,12 +1,95 @@ import { useQuery } from '@tanstack/react-query'; +import type { PublicClient } from 'viem'; import { usePublicClient } from 'wagmi'; import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMonarchMarket } from '@/data-sources/monarch-api'; import { fetchMorphoMarket } from '@/data-sources/morpho-api/market'; import { fetchSubgraphMarket } from '@/data-sources/subgraph/market'; import type { SupportedNetworks } from '@/utils/networks'; -import { fetchMarketSnapshot } from '@/utils/positions'; +import { fetchMarketSnapshot, type MarketSnapshot } from '@/utils/positions'; import type { Market } from '@/utils/types'; +const mergeMonarchStateIntoMarket = (marketShell: Market, monarchMarket: Market): Market => ({ + ...marketShell, + state: { + ...marketShell.state, + borrowAssets: monarchMarket.state.borrowAssets, + supplyAssets: monarchMarket.state.supplyAssets, + borrowShares: monarchMarket.state.borrowShares, + supplyShares: monarchMarket.state.supplyShares, + liquidityAssets: monarchMarket.state.liquidityAssets, + collateralAssets: monarchMarket.state.collateralAssets, + utilization: monarchMarket.state.utilization, + supplyApy: monarchMarket.state.supplyApy, + borrowApy: monarchMarket.state.borrowApy, + fee: monarchMarket.state.fee, + timestamp: monarchMarket.state.timestamp, + apyAtTarget: monarchMarket.state.apyAtTarget, + rateAtTarget: monarchMarket.state.rateAtTarget, + }, +}); + +const mergeSnapshotIntoMarket = (market: Market, snapshot: MarketSnapshot): Market => ({ + ...market, + state: { + ...market.state, + supplyAssets: snapshot.totalSupplyAssets, + supplyShares: snapshot.totalSupplyShares, + borrowAssets: snapshot.totalBorrowAssets, + borrowShares: snapshot.totalBorrowShares, + liquidityAssets: snapshot.liquidityAssets, + }, +}); + +const fetchRpcMarketSnapshot = async ( + uniqueKey: string, + network: SupportedNetworks, + publicClient: PublicClient, +): Promise => { + console.log(`Attempting fetchMarketSnapshot for market ${uniqueKey}`); + + try { + const snapshot = await fetchMarketSnapshot(uniqueKey, network, publicClient); + console.log(`Market state (from RPC) result for ${uniqueKey}:`, snapshot ? 'Exists' : 'Null'); + return snapshot; + } catch (snapshotError) { + console.error(`Error fetching market snapshot for ${uniqueKey}:`, snapshotError); + return null; + } +}; + +const fetchFallbackMarketShell = async (uniqueKey: string, network: SupportedNetworks): Promise => { + if (supportsMorphoApi(network)) { + try { + console.log(`Attempting to fetch market shell via Morpho API for ${uniqueKey}`); + const morphoMarket = await fetchMorphoMarket(uniqueKey, network); + if (morphoMarket) { + return morphoMarket; + } + } catch (morphoError) { + console.error('Failed to fetch market shell via Morpho API:', morphoError); + } + } + + try { + console.log(`Attempting to fetch market shell via Subgraph for ${uniqueKey}`); + return await fetchSubgraphMarket(uniqueKey, network); + } catch (subgraphError) { + console.error('Failed to fetch market shell via Subgraph:', subgraphError); + return null; + } +}; + +const fetchMonarchMarketState = async (uniqueKey: string, network: SupportedNetworks): Promise => { + try { + console.log(`Attempting to fetch market state via Monarch API for ${uniqueKey}`); + return await fetchMonarchMarket(uniqueKey, network); + } catch (monarchError) { + console.error('Failed to fetch market state via Monarch API:', monarchError); + return null; + } +}; + export const useMarketData = (uniqueKey: string | undefined, network: SupportedNetworks | undefined) => { const queryKey = ['marketData', uniqueKey, network]; const publicClient = usePublicClient({ chainId: network }); @@ -25,63 +108,28 @@ export const useMarketData = (uniqueKey: string | undefined, network: SupportedN return null; } - // 1. Try fetching the on-chain market snapshot first - console.log(`Attempting fetchMarketSnapshot for market ${uniqueKey}`); - let snapshot = null; - try { - snapshot = await fetchMarketSnapshot(uniqueKey, network, publicClient); - console.log(`Market state (from RPC) result for ${uniqueKey}:`, snapshot ? 'Exists' : 'Null'); - } catch (snapshotError) { - console.error(`Error fetching market snapshot for ${uniqueKey}:`, snapshotError); - // Snapshot fetch failed, will proceed to fallback fetch - } + const [snapshot, monarchMarket, fallbackMarketShell] = await Promise.all([ + fetchRpcMarketSnapshot(uniqueKey, network, publicClient), + fetchMonarchMarketState(uniqueKey, network), + fetchFallbackMarketShell(uniqueKey, network), + ]); - let finalMarket: Market | null = null; - - // 2. Try Morpho API first if supported, then fallback to Subgraph - try { - if (supportsMorphoApi(network)) { - console.log(`Attempting to fetch market data via Morpho API for ${uniqueKey}`); - finalMarket = await fetchMorphoMarket(uniqueKey, network); - } - } catch (morphoError) { - console.error('Failed to fetch market data via Morpho API:', morphoError); - // Continue to Subgraph fallback - } + let finalMarket = fallbackMarketShell; - // 3. If Morpho API failed or not supported, try Subgraph - if (!finalMarket) { - try { - console.log(`Attempting to fetch market data via Subgraph for ${uniqueKey}`); - finalMarket = await fetchSubgraphMarket(uniqueKey, network); - } catch (subgraphError) { - console.error('Failed to fetch market data via Subgraph:', subgraphError); - finalMarket = null; - } + // Preserve shell-only metadata such as whitelist and supplying vaults until Monarch exposes parity. + if (fallbackMarketShell && monarchMarket) { + finalMarket = mergeMonarchStateIntoMarket(fallbackMarketShell, monarchMarket); + } else if (monarchMarket) { + finalMarket = monarchMarket; } - // 4. If we have both snapshot and market data, override the state fields with snapshot if (snapshot && finalMarket) { - console.log(`Found market snapshot for ${uniqueKey}, overriding state with on-chain data.`); - finalMarket = { - ...finalMarket, - state: { - ...finalMarket.state, - // Override with on-chain snapshot data - supplyAssets: snapshot.totalSupplyAssets, - supplyShares: snapshot.totalSupplyShares, - borrowAssets: snapshot.totalBorrowAssets, - borrowShares: snapshot.totalBorrowShares, - liquidityAssets: snapshot.liquidityAssets, - }, - }; + console.log(`Found market snapshot for ${uniqueKey}, overriding live balances with on-chain data.`); + finalMarket = mergeSnapshotIntoMarket(finalMarket, snapshot); } else if (!finalMarket) { - // Both data sources failed - console.error(`Failed to fetch market data for ${uniqueKey} via both Morpho API and Subgraph.`); - finalMarket = null; + console.error(`Failed to fetch market data for ${uniqueKey} via Monarch, Morpho API, and Subgraph.`); } else if (!snapshot) { - // Snapshot failed but data source succeeded - just use data source - console.warn(`Market snapshot failed for ${uniqueKey}, using data source only.`); + console.warn(`Market snapshot failed for ${uniqueKey}, using indexed market state only.`); } console.log(`Final market data for ${uniqueKey}:`, finalMarket ? 'Found' : 'Not Found'); From 740fd5ded7c3da27c21eded6921bb87e0c4207e0 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 24 Mar 2026 20:08:16 +0800 Subject: [PATCH 3/5] feat: cleanup usd graph --- .../components/charts/volume-chart.tsx | 87 +++++-------------- src/stores/useMarketDetailChartState.ts | 7 -- 2 files changed, 23 insertions(+), 71 deletions(-) diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 3099681c..0f438781 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -45,12 +45,10 @@ 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, volumeView === 'USD'); + const { data: historicalData, isLoading } = useMarketHistoricalData(marketId, chainId, selectedTimeRange); const [visibleLines, setVisibleLines] = useState({ supply: true, @@ -58,40 +56,27 @@ 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)); + return Number(formatUnits(BigInt(raw ?? 0), 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) => { @@ -99,11 +84,6 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { 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 +93,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 +111,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,20 +126,16 @@ 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); 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: TimeseriesDataPoint) => point.y !== null); const average = validDisplayData.length > 0 ? validDisplayData.reduce((acc: number, point: TimeseriesDataPoint) => acc + convertValue(point.y), 0) / validDisplayData.length @@ -229,18 +200,6 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { {/* Controls */}
-