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,