Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 27 additions & 14 deletions src/features/markets/components/table/market-table-body.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 <RateFormatted value={value} />;
}

if (isRateEnrichmentLoading) {
return (
<PulseLoader
size={4}
color="#f45f2d"
margin={3}
/>
);
}

return '—';
};

// Calculate colspan for expanded row based on visible columns
const visibleColumnsCount =
9 + // Base columns: Star, ID, Loan, Collateral, Oracle, LLTV, Risk, Indicators, Actions
Expand Down Expand Up @@ -250,7 +271,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">{item.state.dailySupplyApy != null ? <RateFormatted value={item.state.dailySupplyApy} /> : '—'}</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.dailySupplyApy)}</div>
</TableCell>
)}
{columnVisibility.dailyBorrowAPY && (
Expand All @@ -259,7 +280,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">{item.state.dailyBorrowApy != null ? <RateFormatted value={item.state.dailyBorrowApy} /> : '—'}</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.dailyBorrowApy)}</div>
</TableCell>
)}
{columnVisibility.weeklySupplyAPY && (
Expand All @@ -268,9 +289,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.weeklySupplyApy != null ? <RateFormatted value={item.state.weeklySupplyApy} /> : '—'}
</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.weeklySupplyApy)}</div>
</TableCell>
)}
{columnVisibility.weeklyBorrowAPY && (
Expand All @@ -279,9 +298,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.weeklyBorrowApy != null ? <RateFormatted value={item.state.weeklyBorrowApy} /> : '—'}
</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.weeklyBorrowApy)}</div>
</TableCell>
)}
{columnVisibility.monthlySupplyAPY && (
Expand All @@ -290,9 +307,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.monthlySupplyApy != null ? <RateFormatted value={item.state.monthlySupplyApy} /> : '—'}
</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.monthlySupplyApy)}</div>
</TableCell>
)}
{columnVisibility.monthlyBorrowAPY && (
Expand All @@ -301,9 +316,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI
className="z-50 text-center"
style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }}
>
<p className="text-sm">
{item.state.monthlyBorrowApy != null ? <RateFormatted value={item.state.monthlyBorrowApy} /> : '—'}
</p>
<div className="flex justify-center text-sm">{renderHistoricalRateCell(item.state.monthlyBorrowApy)}</div>
</TableCell>
)}
<TableCell style={{ minWidth: '90px' }}>
Expand Down
50 changes: 44 additions & 6 deletions src/hooks/queries/useMarketMetricsQuery.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()}`;

Expand All @@ -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<MarketMetricsResponse> => {
const searchParams = new URLSearchParams();
Expand Down Expand Up @@ -131,6 +152,15 @@ const fetchAllMarketMetrics = async (params: MarketMetricsParams): Promise<Marke
};
};

const fetchMarketLiquidationPresence = async (chainId: number, marketId: string): Promise<boolean> => {
const response = await monarchGraphqlFetcher<MarketLiquidationPresenceResponse>(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.
Expand All @@ -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()}`
Expand Down Expand Up @@ -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;
};
13 changes: 10 additions & 3 deletions src/hooks/useProcessedMarkets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Market[]>(() => {
if (!processedData.allMarkets.length || marketRateEnrichments.size === 0) {
Expand Down Expand Up @@ -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,
Expand Down