diff --git a/AGENTS.md b/AGENTS.md index 9d9d54c2..f0c908c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,8 @@ When touching transaction and position flows, validation MUST include all releva 54. **Market USD-price provenance integrity**: when market-list USD values are recomputed through shared token-price hooks, keep price provenance separate from the numeric USD value. Only mark a market as having a real USD price, or remove estimated-price UI, when the loan-token price came from a direct chain-scoped price source rather than a peg or hardcoded fallback. If a direct price becomes available after first paint, replace the previously estimated loan-asset USD values at the shared market-processing chokepoint instead of leaving stale estimated values and flags in place. 55. **Token metadata integrity**: chain-scoped token metadata used by market registries must treat `decimals` and `symbol` as one metadata unit. When a token is not in the local registry, resolve both fields through shared batched on-chain reads rather than mixing RPC decimals with placeholder symbols. Manual entries in `src/utils/tokens.ts` must be validated against on-chain `decimals()` and `symbol()` per chain through the shared verifier command, and any intentional display-symbol differences must be captured as explicit overrides instead of silent drift. 56. **Market shell parity integrity**: single-market market-shell fetchers must apply the same canonical registry guards as list fetchers, so blocked or malformed markets cannot re-enter through detail-path reads. In per-chain provider fallback chains, treat empty non-authoritative results the same as provider failure and continue to the next provider instead of short-circuiting fallback for that chain. +57. **Liquidation-history source integrity**: market-list liquidation badges must resolve from authoritative indexed liquidation events keyed by canonical `chainId + market.uniqueKey`, not only from auxiliary metrics/monitoring feeds. A metrics outage, timeout, or stale response must not erase historical liquidation indicators for otherwise healthy market rows. +58. **Historical-rate loading signal integrity**: market-list 24h/7d/30d rate columns must distinguish “RPC/archive enrichment still loading” from “historical rate unavailable”. While shared historical-rate enrichment is unresolved, show a loading state; reserve `—` only for settled null/unsupported values after the enrichment boundary completes. ### REQUIRED: Regression Rule Capture diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 5dadcfd2..d755a097 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { GoStarFill, GoStar } from 'react-icons/go'; +import { PulseLoader } from 'react-spinners'; import { TableBody, TableRow, TableCell } from '@/components/ui/table'; import { RateFormatted } from '@/components/shared/rate-formatted'; import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; @@ -9,6 +10,7 @@ import { MarketRiskIndicators } from '@/features/markets/components/market-risk- import OracleVendorBadge from '@/features/markets/components/oracle-vendor-badge'; import { TrustedByCell } from '@/features/autovault/components/trusted-vault-badges'; import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; @@ -28,10 +30,29 @@ type MarketTableBodyProps = { export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowId, trustedVaultMap }: MarketTableBodyProps) { const { columnVisibility, starredMarkets, starMarket, unstarMarket } = useMarketPreferences(); const { success: toastSuccess } = useStyledToast(); + const { isRateEnrichmentLoading } = useProcessedMarkets(); const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' }); const { label: borrowRateLabel } = useRateLabel({ prefix: 'Borrow' }); + const renderHistoricalRateCell = (value: number | null) => { + if (value != null) { + return ; + } + + if (isRateEnrichmentLoading) { + return ( + + ); + } + + return '—'; + }; + // Calculate colspan for expanded row based on visible columns const visibleColumnsCount = 9 + // Base columns: Star, ID, Loan, Collateral, Oracle, LLTV, Risk, Indicators, Actions @@ -250,7 +271,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

{item.state.dailySupplyApy != null ? : '—'}

+
{renderHistoricalRateCell(item.state.dailySupplyApy)}
)} {columnVisibility.dailyBorrowAPY && ( @@ -259,7 +280,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

{item.state.dailyBorrowApy != null ? : '—'}

+
{renderHistoricalRateCell(item.state.dailyBorrowApy)}
)} {columnVisibility.weeklySupplyAPY && ( @@ -268,9 +289,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.weeklySupplyApy != null ? : '—'} -

+
{renderHistoricalRateCell(item.state.weeklySupplyApy)}
)} {columnVisibility.weeklyBorrowAPY && ( @@ -279,9 +298,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.weeklyBorrowApy != null ? : '—'} -

+
{renderHistoricalRateCell(item.state.weeklyBorrowApy)}
)} {columnVisibility.monthlySupplyAPY && ( @@ -290,9 +307,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.monthlySupplyApy != null ? : '—'} -

+
{renderHistoricalRateCell(item.state.monthlySupplyApy)}
)} {columnVisibility.monthlyBorrowAPY && ( @@ -301,9 +316,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.monthlyBorrowApy != null ? : '—'} -

+
{renderHistoricalRateCell(item.state.monthlyBorrowApy)}
)} diff --git a/src/hooks/queries/useMarketMetricsQuery.ts b/src/hooks/queries/useMarketMetricsQuery.ts index d9aa8248..287f7b6b 100644 --- a/src/hooks/queries/useMarketMetricsQuery.ts +++ b/src/hooks/queries/useMarketMetricsQuery.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { monarchGraphqlFetcher } from '@/data-sources/monarch-api/fetchers'; import { useMarketPreferences, type CustomTagConfig, type FlowTimeWindow } from '@/stores/useMarketPreferences'; // Re-export types for convenience @@ -60,6 +61,12 @@ export type MarketMetricsResponse = { markets: MarketMetrics[]; }; +type MarketLiquidationPresenceResponse = { + data?: { + Morpho_Liquidate?: Array<{ id: string }>; + }; +}; + // Composite key for market lookup export const getMetricsKey = (chainId: number, uniqueKey: string): string => `${chainId}-${uniqueKey.toLowerCase()}`; @@ -71,6 +78,20 @@ type MarketMetricsParams = { }; const PAGE_SIZE = 1000; +const MARKET_LIQUIDATION_PRESENCE_QUERY = ` + query MarketLiquidationPresence($chainId: Int!, $marketId: String!) { + Morpho_Liquidate( + where: { + chainId: { _eq: $chainId } + market_id: { _eq: $marketId } + } + limit: 1 + order_by: [{ timestamp: desc }, { id: desc }] + ) { + id + } + } +`; const fetchMarketMetricsPage = async (params: MarketMetricsParams, limit: number, offset: number): Promise => { const searchParams = new URLSearchParams(); @@ -131,6 +152,15 @@ const fetchAllMarketMetrics = async (params: MarketMetricsParams): Promise => { + const response = await monarchGraphqlFetcher(MARKET_LIQUIDATION_PRESENCE_QUERY, { + chainId, + marketId: marketId.toLowerCase(), + }); + + return (response.data?.Morpho_Liquidate?.length ?? 0) > 0; +}; + /** * Fetches enhanced market metrics from the Monarch monitoring API. * Pre-fetched and cached for 5 minutes. @@ -155,6 +185,16 @@ export const useMarketMetricsQuery = (params: MarketMetricsParams = {}) => { }); }; +export const useMarketLiquidationPresence = (chainId: number, uniqueKey: string, enabled = true) => { + return useQuery({ + queryKey: ['market-liquidation-presence', chainId, uniqueKey.toLowerCase()], + queryFn: () => fetchMarketLiquidationPresence(chainId, uniqueKey), + staleTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + enabled, + }); +}; + /** * Returns a Map for O(1) lookup of market metrics by key. * Key format: `${chainId}-${uniqueKey.toLowerCase()}` @@ -286,11 +326,9 @@ export const useTrendingMarketKeys = useOfficialTrendingMarketKeys; * Returns whether a market has ever been liquidated. */ export const useEverLiquidated = (chainId: number, uniqueKey: string): boolean => { - const { metricsMap } = useMarketMetricsMap(); + const { metricsMap, isLoading: isMetricsLoading } = useMarketMetricsMap(); + const metrics = metricsMap.get(getMetricsKey(chainId, uniqueKey)); + const { data: hasLiquidationPresence = false } = useMarketLiquidationPresence(chainId, uniqueKey, !metrics?.everLiquidated && !isMetricsLoading); - return useMemo(() => { - const key = `${chainId}-${uniqueKey.toLowerCase()}`; - const metrics = metricsMap.get(key); - return metrics?.everLiquidated ?? false; - }, [metricsMap, chainId, uniqueKey]); + return Boolean(metrics?.everLiquidated) || hasLiquidationPresence; }; diff --git a/src/hooks/useProcessedMarkets.ts b/src/hooks/useProcessedMarkets.ts index 5c0319c6..52df6052 100644 --- a/src/hooks/useProcessedMarkets.ts +++ b/src/hooks/useProcessedMarkets.ts @@ -108,9 +108,15 @@ export const useProcessedMarkets = () => { }; }, [rawMarketsFromQuery, allBlacklistedMarketKeys]); - const { data: marketRateEnrichments = EMPTY_RATE_ENRICHMENTS, isRefetching: isRateEnrichmentRefetching } = useMarketRateEnrichmentQuery( - processedData.allMarkets, - ); + const { + data: marketRateEnrichments = EMPTY_RATE_ENRICHMENTS, + isLoading: isRateEnrichmentQueryLoading, + isFetching: isRateEnrichmentFetching, + isRefetching: isRateEnrichmentRefetching, + } = useMarketRateEnrichmentQuery(processedData.allMarkets); + + const isRateEnrichmentLoading = + processedData.allMarkets.length > 0 && marketRateEnrichments.size === 0 && (isRateEnrichmentQueryLoading || isRateEnrichmentFetching); const allMarketsWithRates = useMemo(() => { if (!processedData.allMarkets.length || marketRateEnrichments.size === 0) { @@ -251,6 +257,7 @@ export const useProcessedMarkets = () => { allMarkets: allMarketsWithUsd, whitelistedMarkets: whitelistedMarketsWithUsd, markets, // Computed from setting (backward compatible with old context) + isRateEnrichmentLoading, loading: isLoading, isRefetching: isRefetching || isRateEnrichmentRefetching, error,