diff --git a/.env.local.example b/.env.local.example
index 691db78d..d800dedd 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -34,3 +34,8 @@ ALCHEMY_API_KEY=
# used for getting block
ETHERSCAN_API_KEY=
+
+# ==================== Monarch API ====================
+# Monarch monitoring API for trending markets
+MONARCH_API_ENDPOINT=http://localhost:3000
+MONARCH_API_KEY=
diff --git a/app/api/monarch/liquidations/route.ts b/app/api/monarch/liquidations/route.ts
new file mode 100644
index 00000000..66f039b1
--- /dev/null
+++ b/app/api/monarch/liquidations/route.ts
@@ -0,0 +1,32 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { MONARCH_API_KEY, getMonarchUrl } from '../utils';
+
+export async function GET(req: NextRequest) {
+ if (!MONARCH_API_KEY) {
+ console.error('[Monarch Liquidations API] Missing MONARCH_API_KEY');
+ return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
+ }
+
+ const chainId = req.nextUrl.searchParams.get('chain_id');
+
+ try {
+ const url = getMonarchUrl('/v1/liquidations');
+ if (chainId) url.searchParams.set('chain_id', chainId);
+
+ const response = await fetch(url, {
+ headers: { 'X-API-Key': MONARCH_API_KEY },
+ next: { revalidate: 300 },
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('[Monarch Liquidations API] Error:', response.status, errorText);
+ return NextResponse.json({ error: 'Failed to fetch liquidations' }, { status: response.status });
+ }
+
+ return NextResponse.json(await response.json());
+ } catch (error) {
+ console.error('[Monarch Liquidations API] Failed to fetch:', error);
+ return NextResponse.json({ error: 'Failed to fetch liquidations' }, { status: 500 });
+ }
+}
diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts
new file mode 100644
index 00000000..2c022e3f
--- /dev/null
+++ b/app/api/monarch/metrics/route.ts
@@ -0,0 +1,35 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { MONARCH_API_KEY, getMonarchUrl } from '../utils';
+
+export async function GET(req: NextRequest) {
+ if (!MONARCH_API_KEY) {
+ console.error('[Monarch Metrics API] Missing MONARCH_API_KEY');
+ return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
+ }
+
+ const searchParams = req.nextUrl.searchParams;
+
+ try {
+ const url = getMonarchUrl('/v1/markets/metrics');
+ for (const key of ['chain_id', 'sort_by', 'sort_order', 'limit', 'offset']) {
+ const value = searchParams.get(key);
+ if (value) url.searchParams.set(key, value);
+ }
+
+ const response = await fetch(url, {
+ headers: { 'X-API-Key': MONARCH_API_KEY },
+ cache: 'no-store',
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('[Monarch Metrics API] Error:', response.status, errorText);
+ return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: response.status });
+ }
+
+ return NextResponse.json(await response.json());
+ } catch (error) {
+ console.error('[Monarch Metrics API] Failed to fetch:', error);
+ return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 });
+ }
+}
diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts
new file mode 100644
index 00000000..cea2f7d9
--- /dev/null
+++ b/app/api/monarch/utils.ts
@@ -0,0 +1,7 @@
+export const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT;
+export const MONARCH_API_KEY = process.env.MONARCH_API_KEY;
+
+export const getMonarchUrl = (path: string): URL => {
+ if (!MONARCH_API_ENDPOINT) throw new Error('MONARCH_API_ENDPOINT not configured');
+ return new URL(path, MONARCH_API_ENDPOINT.replace(/\/$/, ''));
+};
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index 4230d805..3cfdd8ba 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -14,11 +14,8 @@ import { useAppSettings } from '@/stores/useAppSettings';
import { useMarketPreferences } from '@/stores/useMarketPreferences';
export default function SettingsPage() {
- // App settings from Zustand store
const { usePermit2, setUsePermit2, showUnwhitelistedMarkets, setShowUnwhitelistedMarkets, isAprDisplay, setIsAprDisplay } =
useAppSettings();
-
- // Market preferences from Zustand store
const { includeUnknownTokens, setIncludeUnknownTokens, showUnknownOracle, setShowUnknownOracle } = useMarketPreferences();
const { vaults: userTrustedVaults } = useTrustedVaults();
@@ -50,7 +47,6 @@ export default function SettingsPage() {
Settings
- {/* Transaction Settings Section */}
Transaction Settings
@@ -78,7 +74,6 @@ export default function SettingsPage() {
- {/* Display Settings Section */}
Display Settings
@@ -106,12 +101,10 @@ export default function SettingsPage() {
- {/* Filter Settings Section */}
Filter Settings
- {/* Group related settings with a subtle separator */}
Show Unknown Tokens
@@ -171,7 +164,32 @@ export default function SettingsPage() {
- {/* Trusted Vaults Section */}
+
+
+
Trending Markets
+ Beta
+
+
+
+
+
+
Configure Trending Criteria
+
+ Define thresholds for market flow metrics to identify trending markets. Markets meeting all criteria will show a fire
+ indicator.
+
+
+
+
+
+
+
Trusted Vaults
@@ -193,7 +211,6 @@ export default function SettingsPage() {
- {/* Display trusted vault icons */}
{mounted ? (
@@ -230,7 +247,6 @@ export default function SettingsPage() {
- {/* Blacklisted Markets Section */}
Blacklisted Markets
@@ -253,7 +269,6 @@ export default function SettingsPage() {
- {/* Advanced Section */}
diff --git a/src/data-sources/morpho-api/liquidations.ts b/src/data-sources/morpho-api/liquidations.ts
index d59c13db..1fa0ef33 100644
--- a/src/data-sources/morpho-api/liquidations.ts
+++ b/src/data-sources/morpho-api/liquidations.ts
@@ -1,7 +1,14 @@
+/**
+ * @deprecated_after_monarch_api_stable
+ * This fetcher is kept as a fallback while Monarch Metrics API is being validated.
+ * Used by useLiquidationsQuery.ts which is also deprecated.
+ *
+ * Once the Monarch API is confirmed stable, this file can be removed.
+ * See useLiquidationsQuery.ts for the full list of related files.
+ */
import type { SupportedNetworks } from '@/utils/networks';
import { URLS } from '@/utils/urls';
-// Re-use the query structure from the original hook
const liquidationsQuery = `
query getLiquidations($first: Int, $skip: Int, $chainId: Int!) {
transactions(
@@ -76,7 +83,6 @@ export const fetchMorphoApiLiquidatedMarketKeys = async (network: SupportedNetwo
const result = (await response.json()) as QueryResult;
- // Check for GraphQL errors
if (result.errors) {
console.error('GraphQL errors:', result.errors);
throw new Error(`GraphQL error fetching liquidations for network ${network}`);
@@ -84,32 +90,27 @@ export const fetchMorphoApiLiquidatedMarketKeys = async (network: SupportedNetwo
if (!result.data?.transactions) {
console.warn(`No transactions data found for network ${network} at skip ${skip}`);
- break; // Exit loop if data structure is unexpected
+ break;
}
- const liquidations = result.data.transactions.items;
- const pageInfo = result.data.transactions.pageInfo;
+ const { items, pageInfo } = result.data.transactions;
- liquidations.forEach((tx) => {
+ for (const tx of items) {
if (tx.data?.market?.uniqueKey) {
liquidatedKeys.add(tx.data.market.uniqueKey);
}
- });
+ }
totalCount = pageInfo.countTotal;
skip += pageInfo.count;
- // Safety break if pageInfo.count is 0 to prevent infinite loop
- if (pageInfo.count === 0 && skip < totalCount) {
- console.warn('Received 0 items in a page, but not yet at total count. Breaking loop.');
- break;
- }
+ if (pageInfo.count === 0 && skip < totalCount) break;
} while (skip < totalCount);
} catch (error) {
console.error(`Error fetching liquidations via Morpho API for network ${network}:`, error);
- throw error; // Re-throw the error to be handled by the calling hook
+ throw error;
}
- console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Morpho API for ${network}.`);
+ console.log(`[Morpho API] Fetched ${liquidatedKeys.size} liquidated market keys for ${network}`);
return liquidatedKeys;
};
diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts
index 99c91bc8..7060d8bd 100644
--- a/src/data-sources/subgraph/liquidations.ts
+++ b/src/data-sources/subgraph/liquidations.ts
@@ -1,3 +1,11 @@
+/**
+ * @deprecated_after_monarch_api_stable
+ * This fetcher is kept as a fallback while Monarch Metrics API is being validated.
+ * Used by useLiquidationsQuery.ts which is also deprecated.
+ *
+ * Once the Monarch API is confirmed stable, this file can be removed.
+ * See useLiquidationsQuery.ts for the full list of related files.
+ */
import { subgraphMarketsWithLiquidationCheckQuery } from '@/graphql/morpho-subgraph-queries';
import type { SupportedNetworks } from '@/utils/networks';
import { getSubgraphUrl } from '@/utils/subgraph-urls';
@@ -25,9 +33,6 @@ export const fetchSubgraphLiquidatedMarketKeys = async (network: SupportedNetwor
}
const liquidatedKeys = new Set
();
-
- // Apply the same base filters as fetchSubgraphMarkets
- // paginate until the API returns < pageSize items
const pageSize = 1000;
let skip = 0;
while (true) {
@@ -60,12 +65,11 @@ export const fetchSubgraphLiquidatedMarketKeys = async (network: SupportedNetwor
break; // Exit loop if no markets are returned
}
- markets.forEach((market) => {
- // If the liquidates array has items, this market has had liquidations
- if (market.liquidates && market.liquidates.length > 0) {
+ for (const market of markets) {
+ if (market.liquidates?.length > 0) {
liquidatedKeys.add(market.id);
}
- });
+ }
if (markets.length < pageSize) {
break; // Exit loop if the number of returned markets is less than the page size
diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts
index acd0d678..3bbdb786 100644
--- a/src/data-sources/subgraph/market.ts
+++ b/src/data-sources/subgraph/market.ts
@@ -84,15 +84,12 @@ const transformSubgraphMarketToMarket = (
const marketId = subgraphMarket.id ?? '';
const lltv = subgraphMarket.lltv ?? '0';
const irmAddress = subgraphMarket.irm ?? '0x';
- const inputTokenPriceUSD = subgraphMarket.inputTokenPriceUSD ?? '0';
const oracleAddress = (subgraphMarket.oracle?.oracleAddress ?? '0x') as Address;
const totalSupplyShares = subgraphMarket.totalSupplyShares ?? '0';
const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0';
const fee = subgraphMarket.fee ?? '0';
- // Define the estimation helper *inside* the transform function
- // so it has access to majorPrices
const getEstimateValue = (token: ERC20Token | UnknownERC20Token): number | undefined => {
if (!('peg' in token) || token.peg === undefined) {
return undefined;
@@ -101,7 +98,6 @@ const transformSubgraphMarketToMarket = (
if (peg === TokenPeg.USD) {
return 1;
}
- // Access majorPrices from the outer function's scope
return majorPrices[peg];
};
@@ -118,7 +114,6 @@ const transformSubgraphMarketToMarket = (
const chainId = network;
- // @todo: might update due to input token being used here
const supplyAssets = subgraphMarket.totalSupply ?? subgraphMarket.inputTokenBalance ?? '0';
const borrowAssets = subgraphMarket.totalBorrow ?? subgraphMarket.variableBorrowedTokenBalance ?? '0';
const collateralAssets = subgraphMarket.totalCollateral ?? '0';
@@ -132,13 +127,10 @@ const transformSubgraphMarketToMarket = (
const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0);
const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0);
- const warnings: MarketWarning[] = []; // Initialize warnings
+ const warnings: MarketWarning[] = [];
- // get the prices
let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0');
let collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0');
-
- // @todo: might update due to input token being used here
const hasUSDPrice = loanAssetPrice > 0 && collateralAssetPrice > 0;
const knownLoadAsset = findToken(loanAsset.address, network);
@@ -173,56 +165,39 @@ const transformSubgraphMarketToMarket = (
const marketDetail = {
id: marketId,
uniqueKey: marketId,
- lltv: lltv,
+ lltv,
irmAddress: irmAddress as Address,
- collateralPrice: inputTokenPriceUSD,
- whitelisted: true, // All subgraph markets are considered whitelisted
- loanAsset: loanAsset,
- collateralAsset: collateralAsset,
+ whitelisted: true,
+ loanAsset,
+ collateralAsset,
state: {
- // assets
- borrowAssets: borrowAssets,
- supplyAssets: supplyAssets,
- liquidityAssets: liquidityAssets,
- collateralAssets: collateralAssets,
- // shares
+ borrowAssets,
+ supplyAssets,
+ liquidityAssets,
+ collateralAssets,
borrowShares: totalBorrowShares,
supplyShares: totalSupplyShares,
- // usd
- borrowAssetsUsd: borrowAssetsUsd,
- supplyAssetsUsd: supplyAssetsUsd,
- liquidityAssetsUsd: liquidityAssetsUsd,
- collateralAssetsUsd: collateralAssetsUsd,
-
- utilization: utilization,
- supplyApy: supplyApy,
- borrowApy: borrowApy,
- fee: safeParseFloat(fee) / 10_000, // Subgraph fee is likely basis points (needs verification)
- timestamp: timestamp,
-
- // AdaptiveCurveIRM APY if utilization was at target
- apyAtTarget: 0, // Not available from subgraph
-
- // AdaptiveCurveIRM rate per second if utilization was at target
- rateAtTarget: '0', // Not available from subgraph
+ borrowAssetsUsd,
+ supplyAssetsUsd,
+ liquidityAssetsUsd,
+ collateralAssetsUsd,
+ utilization,
+ supplyApy,
+ borrowApy,
+ fee: safeParseFloat(fee) / 10_000,
+ timestamp,
+ apyAtTarget: 0,
+ rateAtTarget: '0',
},
- oracleAddress: oracleAddress,
+ oracleAddress,
morphoBlue: {
id: subgraphMarket.protocol?.id ?? '0x',
address: subgraphMarket.protocol?.id ?? '0x',
- chain: {
- id: chainId,
- },
- },
- warnings: warnings, // Assign the potentially filtered warnings
- hasUSDPrice: hasUSDPrice,
-
- // todo: not able to parse bad debt now
- realizedBadDebt: {
- underlying: '0',
+ chain: { id: chainId },
},
-
- // todo: no way to parse supplying vaults now
+ warnings,
+ hasUSDPrice,
+ realizedBadDebt: { underlying: '0' },
supplyingVaults: [],
};
@@ -260,17 +235,14 @@ export const fetchSubgraphMarket = async (uniqueKey: string, network: SupportedN
}
};
-// Define type for GraphQL variables
type SubgraphMarketsVariables = {
first: number;
where?: {
inputToken_not_in?: string[];
- // Add other potential filter fields here if needed
};
- network?: string; // Keep network optional if sometimes omitted
+ network?: string;
};
-// Fetcher for multiple markets from Subgraph
export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise => {
const subgraphApiUrl = getSubgraphUrl(network);
@@ -279,7 +251,6 @@ export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise<
return [];
}
- // Construct variables for the query, adding blacklistTokens
const variables: SubgraphMarketsVariables = {
first: 1000, // Max limit
where: {
diff --git a/src/features/markets/components/constants.ts b/src/features/markets/components/constants.ts
index 9ed581bf..9b613c99 100644
--- a/src/features/markets/components/constants.ts
+++ b/src/features/markets/components/constants.ts
@@ -11,6 +11,7 @@ export enum SortColumn {
RateAtTarget = 10,
TrustedBy = 11,
UtilizationRate = 12,
+ Trend = 13,
}
// Gas cost to simplify tx flow: do not need to estimate gas for transactions
diff --git a/src/features/markets/components/market-indicators.tsx b/src/features/markets/components/market-indicators.tsx
index 013fbb7d..2945dc78 100644
--- a/src/features/markets/components/market-indicators.tsx
+++ b/src/features/markets/components/market-indicators.tsx
@@ -1,9 +1,11 @@
import { Tooltip } from '@/components/ui/tooltip';
import { FaShieldAlt, FaStar, FaUser } from 'react-icons/fa';
import { FiAlertCircle } from 'react-icons/fi';
+import { AiOutlineFire } from 'react-icons/ai';
import { TooltipContent } from '@/components/shared/tooltip-content';
-import { useLiquidationsQuery } from '@/hooks/queries/useLiquidationsQuery';
+import { useTrendingMarketKeys, getMetricsKey, useEverLiquidated } from '@/hooks/queries/useMarketMetricsQuery';
import { computeMarketWarnings } from '@/hooks/useMarketWarnings';
+import { useMarketPreferences } from '@/stores/useMarketPreferences';
import type { Market } from '@/utils/types';
import { RewardsIndicator } from '@/features/markets/components/rewards-indicator';
@@ -17,11 +19,10 @@ type MarketIndicatorsProps = {
};
export function MarketIndicators({ market, showRisk = false, isStared = false, hasUserPosition = false }: MarketIndicatorsProps) {
- // Check liquidation protection status using React Query
- const { data: liquidatedMarkets } = useLiquidationsQuery();
- const hasLiquidationProtection = liquidatedMarkets?.has(market.uniqueKey) ?? false;
-
- // Compute risk warnings if needed
+ const hasLiquidationProtection = useEverLiquidated(market.morphoBlue.chain.id, market.uniqueKey);
+ const { trendingConfig } = useMarketPreferences();
+ const trendingKeys = useTrendingMarketKeys();
+ const isTrending = trendingConfig.enabled && trendingKeys.has(getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey));
const warnings = showRisk ? computeMarketWarnings(market, true) : [];
const hasWarnings = warnings.length > 0;
const alertWarning = warnings.find((w) => w.level === 'alert');
@@ -29,7 +30,6 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h
return (
- {/* Personal Indicators */}
{isStared && (
)}
- {/* Universal Indicators */}
{hasLiquidationProtection && (
)}
- {/* {market.isMonarchWhitelisted && (
- }
- detail="This market is recognized by Monarch"
- />
- }
- >
-
-
-
-
- )} */}
-
- {/* Risk Warnings */}
+ {isTrending && (
+
+ }
+ detail="This market is trending based on flow metrics"
+ />
+ }
+ >
+
+
+ )}
+
{showRisk && hasWarnings && (
setExpandedRowId(item.uniqueKey === expandedRowId ? null : item.uniqueKey)}
+ onClick={() => {
+ const key = getMetricsKey(item.morphoBlue.chain.id, item.uniqueKey);
+ const metrics = metricsMap.get(key);
+ console.log('[Metrics]', key, metrics ?? 'NOT FOUND');
+ setExpandedRowId(item.uniqueKey === expandedRowId ? null : item.uniqueKey);
+ }}
className={`hover:cursor-pointer ${item.uniqueKey === expandedRowId ? 'table-body-focused ' : ''}`}
>
+ {trendingConfig.enabled && (
+
+
+
+ )}
{
+export const useLiquidationsQuery = (options: { enabled?: boolean } = {}) => {
+ const { enabled = true } = options;
+
return useQuery({
queryKey: ['liquidations'],
+ enabled,
queryFn: async () => {
const combinedLiquidatedKeys = new Set();
const fetchErrors: unknown[] = [];
@@ -36,7 +32,6 @@ export const useLiquidationsQuery = () => {
let networkLiquidatedKeys: Set;
let trySubgraph = false;
- // Try Morpho API first if supported
if (supportsMorphoApi(network)) {
try {
console.log(`Attempting to fetch liquidated markets via Morpho API for ${network}`);
@@ -51,7 +46,6 @@ export const useLiquidationsQuery = () => {
trySubgraph = true;
}
- // If Morpho API failed or not supported, try Subgraph
if (trySubgraph) {
try {
console.log(`Attempting to fetch liquidated markets via Subgraph for ${network}`);
@@ -62,8 +56,9 @@ export const useLiquidationsQuery = () => {
}
}
- // Add the keys to the combined set
- networkLiquidatedKeys.forEach((key) => combinedLiquidatedKeys.add(key));
+ for (const key of networkLiquidatedKeys) {
+ combinedLiquidatedKeys.add(key);
+ }
} catch (networkError) {
console.error(`Failed to fetch liquidated markets for network ${network}:`, networkError);
fetchErrors.push(networkError);
@@ -71,15 +66,14 @@ export const useLiquidationsQuery = () => {
}),
);
- // If any network fetch failed, log but still return what we got
if (fetchErrors.length > 0) {
console.warn(`Failed to fetch liquidations from ${fetchErrors.length} network(s)`, fetchErrors[0]);
}
return combinedLiquidatedKeys;
},
- staleTime: 10 * 60 * 1000, // Data is fresh for 10 minutes
- refetchInterval: 10 * 60 * 1000, // Auto-refetch every 10 minutes
- refetchOnWindowFocus: true, // Refetch when user returns to tab
+ staleTime: 10 * 60 * 1000,
+ refetchInterval: 10 * 60 * 1000,
+ refetchOnWindowFocus: true,
});
};
diff --git a/src/hooks/queries/useMarketMetricsQuery.ts b/src/hooks/queries/useMarketMetricsQuery.ts
new file mode 100644
index 00000000..93a7e20e
--- /dev/null
+++ b/src/hooks/queries/useMarketMetricsQuery.ts
@@ -0,0 +1,294 @@
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useMarketPreferences, type TrendingConfig, type FlowTimeWindow } from '@/stores/useMarketPreferences';
+import { useLiquidationsQuery } from '@/hooks/queries/useLiquidationsQuery';
+import { useMonarchLiquidatedKeys } from '@/hooks/queries/useMonarchLiquidationsQuery';
+
+// Re-export types for convenience
+export type { FlowTimeWindow } from '@/stores/useMarketPreferences';
+
+// Flow data for a specific time window
+export type MarketFlowData = {
+ // Native token units (BigInt as string) - use loanAsset.decimals to convert
+ supplyFlowAssets: string;
+ borrowFlowAssets: string;
+ // USD values
+ supplyFlowUsd: number;
+ borrowFlowUsd: number;
+ supplyFlowPct: number;
+ // Breakdown by source
+ individualSupplyFlowUsd: number;
+ vaultSupplyFlowUsd: number;
+};
+
+// Current state snapshot
+export type MarketCurrentState = {
+ supplyUsd: number;
+ borrowUsd: number;
+ supplyApy: number;
+ borrowApy: number;
+ utilization: number;
+ vaultSupplyUsd: number;
+ individualSupplyUsd: number;
+};
+
+// Enhanced market metrics from Monarch API
+export type MarketMetrics = {
+ marketUniqueKey: string;
+ chainId: number;
+ loanAsset: { address: string; symbol: string; decimals: number };
+ collateralAsset: { address: string; symbol: string; decimals: number } | null;
+ lltv: number;
+ // Key flags
+ everLiquidated: boolean;
+ marketScore: number | null;
+ // State and flows
+ currentState: MarketCurrentState;
+ flows: Record;
+ blockNumber: number;
+ updatedAt: string;
+};
+
+export type MarketMetricsResponse = {
+ total: number;
+ limit: number;
+ offset: number;
+ markets: MarketMetrics[];
+};
+
+// Composite key for market lookup
+export const getMetricsKey = (chainId: number, uniqueKey: string): string => `${chainId}-${uniqueKey.toLowerCase()}`;
+
+type MarketMetricsParams = {
+ chainId?: number | number[];
+ sortBy?: string;
+ sortOrder?: 'asc' | 'desc';
+ enabled?: boolean;
+};
+
+const PAGE_SIZE = 1000;
+
+const fetchMarketMetricsPage = async (params: MarketMetricsParams, limit: number, offset: number): Promise => {
+ const searchParams = new URLSearchParams();
+
+ if (params.chainId !== undefined) {
+ const chainIds = Array.isArray(params.chainId) ? params.chainId.join(',') : String(params.chainId);
+ searchParams.set('chain_id', chainIds);
+ }
+ if (params.sortBy) searchParams.set('sort_by', params.sortBy);
+ if (params.sortOrder) searchParams.set('sort_order', params.sortOrder);
+ searchParams.set('limit', String(limit));
+ searchParams.set('offset', String(offset));
+
+ const response = await fetch(`/api/monarch/metrics?${searchParams.toString()}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch market metrics');
+ }
+
+ return response.json();
+};
+
+/**
+ * Fetches all market metrics by paginating through the API.
+ * Uses PAGE_SIZE per request to minimize number of calls.
+ */
+const fetchAllMarketMetrics = async (params: MarketMetricsParams): Promise => {
+ // First request to get total count
+ const firstPage = await fetchMarketMetricsPage(params, PAGE_SIZE, 0);
+ const allMarkets = [...firstPage.markets];
+ const total = firstPage.total;
+
+ // If we got all markets in first request, return early
+ if (allMarkets.length >= total) {
+ return { ...firstPage, markets: allMarkets };
+ }
+
+ // Fetch remaining pages in parallel
+ const remainingPages = Math.ceil((total - PAGE_SIZE) / PAGE_SIZE);
+ const pagePromises: Promise[] = [];
+
+ for (let i = 1; i <= remainingPages; i++) {
+ pagePromises.push(fetchMarketMetricsPage(params, PAGE_SIZE, i * PAGE_SIZE));
+ }
+
+ const pages = await Promise.all(pagePromises);
+ for (const page of pages) {
+ allMarkets.push(...page.markets);
+ }
+
+ console.log(`[Metrics] Fetched ${allMarkets.length} markets in ${remainingPages + 1} requests`);
+
+ return {
+ total,
+ limit: total,
+ offset: 0,
+ markets: allMarkets,
+ };
+};
+
+/**
+ * Fetches enhanced market metrics from the Monarch monitoring API.
+ * Pre-fetched and cached for 15 minutes.
+ *
+ * Returns rich metadata including:
+ * - Flow data (1h, 24h, 7d, 30d) for supply/borrow
+ * - Individual vs vault supply breakdown
+ * - Liquidation history flag
+ * - Market scores (future)
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading } = useMarketMetricsQuery();
+ * ```
+ */
+export const useMarketMetricsQuery = (params: MarketMetricsParams = {}) => {
+ const { chainId, sortBy, sortOrder, enabled = true } = params;
+
+ return useQuery({
+ queryKey: ['market-metrics', { chainId, sortBy, sortOrder }],
+ queryFn: () => fetchAllMarketMetrics({ chainId, sortBy, sortOrder }),
+ staleTime: 5 * 60 * 1000, // 5 minutes - matches API update frequency
+ refetchInterval: 1 * 60 * 1000,
+ refetchOnWindowFocus: false, // Don't refetch on focus since data is slow-changing
+ enabled,
+ });
+};
+
+/**
+ * Returns a Map for O(1) lookup of market metrics by key.
+ * Key format: `${chainId}-${uniqueKey.toLowerCase()}`
+ *
+ * @example
+ * ```tsx
+ * const { metricsMap, isLoading } = useMarketMetricsMap();
+ * const metrics = metricsMap.get(getMetricsKey(chainId, uniqueKey));
+ * if (metrics?.everLiquidated) { ... }
+ * ```
+ */
+export const useMarketMetricsMap = (params: MarketMetricsParams = {}) => {
+ const { data, isLoading, ...rest } = useMarketMetricsQuery(params);
+
+ const metricsMap = useMemo(() => {
+ const map = new Map();
+ if (!data?.markets) return map;
+
+ for (const market of data.markets) {
+ const key = getMetricsKey(market.chainId, market.marketUniqueKey);
+ map.set(key, market);
+ }
+ console.log('[Metrics] Loaded', map.size, 'of', data.total, 'markets');
+ return map;
+ }, [data?.markets, data?.total]);
+
+ return { metricsMap, isLoading, data, ...rest };
+};
+
+/**
+ * Convert flow assets (BigInt string) to human-readable number.
+ * @param flowAssets - The flow assets as BigInt string
+ * @param decimals - Token decimals
+ */
+export const parseFlowAssets = (flowAssets: string, decimals: number): number => {
+ return Number(flowAssets) / 10 ** decimals;
+};
+
+/**
+ * Determines if a market is trending based on flow thresholds.
+ * All non-empty thresholds must be met (AND logic).
+ * Only positive flows (inflows) are considered.
+ */
+export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: TrendingConfig): boolean => {
+ if (!trendingConfig.enabled) return false;
+
+ for (const [window, config] of Object.entries(trendingConfig.windows)) {
+ const supplyPct = config?.minSupplyFlowPct ?? '';
+ const supplyUsd = config?.minSupplyFlowUsd ?? '';
+ const borrowPct = config?.minBorrowFlowPct ?? '';
+ const borrowUsd = config?.minBorrowFlowUsd ?? '';
+
+ const hasSupplyThreshold = supplyPct || supplyUsd;
+ const hasBorrowThreshold = borrowPct || borrowUsd;
+
+ if (!hasSupplyThreshold && !hasBorrowThreshold) continue;
+
+ const flow = metrics.flows[window as FlowTimeWindow];
+ if (!flow) return false;
+
+ if (supplyPct) {
+ const actualPct = flow.supplyFlowPct ?? 0;
+ if (actualPct < Number(supplyPct)) return false;
+ }
+ if (supplyUsd) {
+ const actualUsd = flow.supplyFlowUsd ?? 0;
+ if (actualUsd < Number(supplyUsd)) return false;
+ }
+
+ if (borrowPct) {
+ const borrowBase = metrics.currentState.borrowUsd;
+ const actualPct = borrowBase > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowBase) * 100 : 0;
+ if (actualPct < Number(borrowPct)) return false;
+ }
+ if (borrowUsd) {
+ const actualUsd = flow.borrowFlowUsd ?? 0;
+ if (actualUsd < Number(borrowUsd)) return false;
+ }
+ }
+
+ const hasAnyThreshold = Object.values(trendingConfig.windows).some((c) => {
+ const supplyPct = c?.minSupplyFlowPct ?? '';
+ const supplyUsd = c?.minSupplyFlowUsd ?? '';
+ const borrowPct = c?.minBorrowFlowPct ?? '';
+ const borrowUsd = c?.minBorrowFlowUsd ?? '';
+ return supplyPct || supplyUsd || borrowPct || borrowUsd;
+ });
+
+ return hasAnyThreshold;
+};
+
+/**
+ * Returns a Set of market keys that are currently trending.
+ * Uses metricsMap for O(1) lookup and filters based on trending config from preferences.
+ */
+export const useTrendingMarketKeys = () => {
+ const { metricsMap } = useMarketMetricsMap();
+ const { trendingConfig } = useMarketPreferences();
+
+ return useMemo(() => {
+ const keys = new Set();
+ if (!trendingConfig.enabled) return keys;
+
+ for (const [key, metrics] of metricsMap) {
+ if (isMarketTrending(metrics, trendingConfig)) {
+ keys.add(key);
+ }
+ }
+ return keys;
+ }, [metricsMap, trendingConfig]);
+};
+
+const LIQUIDATIONS_STALE_THRESHOLD_MS = (2 * 60 + 5) * 60 * 1000;
+
+/**
+ * Returns whether a market has ever been liquidated.
+ * Primary: Uses Monarch API /v1/liquidations endpoint
+ * Fallback: Uses old Morpho API/Subgraph if Monarch data is stale (>2 hours)
+ *
+ * @deprecated_fallback The fallback to useLiquidationsQuery can be removed
+ * once Monarch API stability is confirmed.
+ */
+export const useEverLiquidated = (chainId: number, uniqueKey: string): boolean => {
+ const { liquidatedKeys, lastUpdatedAt, isLoading } = useMonarchLiquidatedKeys();
+ const isStale = lastUpdatedAt * 1000 < Date.now() - LIQUIDATIONS_STALE_THRESHOLD_MS;
+ const needsFallback = !isLoading && (isStale || liquidatedKeys.size === 0);
+
+ const { data: fallbackKeys } = useLiquidationsQuery({ enabled: needsFallback });
+
+ return useMemo(() => {
+ const key = `${chainId}-${uniqueKey.toLowerCase()}`;
+ if (!needsFallback) {
+ return liquidatedKeys.has(key);
+ }
+ return fallbackKeys?.has(uniqueKey.toLowerCase()) ?? false;
+ }, [liquidatedKeys, needsFallback, chainId, uniqueKey, fallbackKeys]);
+};
diff --git a/src/hooks/queries/useMonarchLiquidationsQuery.ts b/src/hooks/queries/useMonarchLiquidationsQuery.ts
new file mode 100644
index 00000000..0bdd9850
--- /dev/null
+++ b/src/hooks/queries/useMonarchLiquidationsQuery.ts
@@ -0,0 +1,52 @@
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * Response from Monarch API /v1/liquidations endpoint.
+ */
+export type MonarchLiquidationsResponse = {
+ count: number;
+ lastUpdatedAt: number; // Unix timestamp (seconds)
+ markets: Array<{ marketUniqueKey: string; chainId: number }>;
+};
+
+/**
+ * Fetches liquidated market data from Monarch API.
+ * Returns all markets that have ever had a liquidation event.
+ */
+export const useMonarchLiquidationsQuery = () => {
+ return useQuery({
+ queryKey: ['monarch-liquidations'],
+ queryFn: async (): Promise => {
+ const response = await fetch('/api/monarch/liquidations');
+ if (!response.ok) throw new Error('Failed to fetch liquidations from Monarch API');
+ return response.json();
+ },
+ staleTime: 5 * 60 * 1000, // 5 min
+ refetchInterval: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+};
+
+/**
+ * Returns a Set of liquidated market keys for O(1) lookup.
+ * Key format: `${chainId}-${marketUniqueKey.toLowerCase()}`
+ *
+ * Also returns `lastUpdatedAt` to determine if data is stale.
+ */
+export const useMonarchLiquidatedKeys = () => {
+ const { data, ...rest } = useMonarchLiquidationsQuery();
+
+ const liquidatedKeys = useMemo(() => {
+ const keys = new Set();
+ if (!data?.markets) return keys;
+ for (const m of data.markets) {
+ keys.add(`${m.chainId}-${m.marketUniqueKey.toLowerCase()}`);
+ }
+ return keys;
+ }, [data?.markets]);
+
+ const lastUpdatedAt = data?.lastUpdatedAt ?? 0;
+
+ return { liquidatedKeys, lastUpdatedAt, ...rest };
+};
diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts
index 738a069d..5c3d2dd6 100644
--- a/src/hooks/useFilteredMarkets.ts
+++ b/src/hooks/useFilteredMarkets.ts
@@ -5,34 +5,12 @@ import { useMarketPreferences } from '@/stores/useMarketPreferences';
import { useAppSettings } from '@/stores/useAppSettings';
import { useTrustedVaults } from '@/stores/useTrustedVaults';
import { useTokensQuery } from '@/hooks/queries/useTokensQuery';
+import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery';
import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters';
import { SortColumn } from '@/features/markets/components/constants';
import { getVaultKey } from '@/constants/vaults/known_vaults';
import type { Market } from '@/utils/types';
-/**
- * Combines processed markets with all active filters and sorting preferences.
- *
- * Data Flow:
- * 1. Get processed markets (already blacklist filtered + oracle enriched)
- * 2. Apply whitelist setting (show all or whitelisted only)
- * 3. Apply user filters (network, assets, USD thresholds, search)
- * 4. Filter by trusted vaults if enabled
- * 5. Apply sorting (starred, property-based, etc.)
- *
- * Reactivity:
- * - Automatically recomputes when processed data changes (refetch, blacklist)
- * - Automatically recomputes when any filter/preference changes
- * - No manual synchronization needed!
- *
- * @returns Filtered and sorted markets ready for display
- *
- * @example
- * ```tsx
- * const filteredMarkets = useFilteredMarkets();
- * // Use in table - automatically updates when data or filters change
- * ```
- */
export const useFilteredMarkets = (): Market[] => {
const { allMarkets, whitelistedMarkets } = useProcessedMarkets();
const filters = useMarketsFilters();
@@ -40,14 +18,12 @@ export const useFilteredMarkets = (): Market[] => {
const { showUnwhitelistedMarkets } = useAppSettings();
const { vaults: trustedVaults } = useTrustedVaults();
const { findToken } = useTokensQuery();
+ const trendingKeys = useTrendingMarketKeys();
return useMemo(() => {
- // 1. Start with allMarkets or whitelistedMarkets based on setting
let markets = showUnwhitelistedMarkets ? allMarkets : whitelistedMarkets;
-
if (markets.length === 0) return [];
- // 2. Apply all filters (network, assets, USD thresholds, search, etc.)
markets = filterMarkets(markets, {
selectedNetwork: filters.selectedNetwork,
showUnknownTokens: preferences.includeUnknownTokens,
@@ -73,7 +49,6 @@ export const useFilteredMarkets = (): Market[] => {
searchQuery: filters.searchQuery,
});
- // 3. Filter by trusted vaults if enabled
if (preferences.trustedVaultsOnly) {
const trustedVaultKeys = new Set(trustedVaults.map((vault) => getVaultKey(vault.address, vault.chainId)));
markets = markets.filter((market) => {
@@ -86,13 +61,18 @@ export const useFilteredMarkets = (): Market[] => {
});
}
- // 4. Apply sorting
+ if (filters.trendingMode && trendingKeys.size > 0) {
+ markets = markets.filter((market) => {
+ const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey);
+ return trendingKeys.has(key);
+ });
+ }
+
if (preferences.sortColumn === SortColumn.Starred) {
return sortMarkets(markets, createStarredSort(preferences.starredMarkets), 1);
}
if (preferences.sortColumn === SortColumn.TrustedBy) {
- // Custom sort for trusted vaults
const trustedVaultKeys = new Set(trustedVaults.map((vault) => getVaultKey(vault.address, vault.chainId)));
return sortMarkets(
markets,
@@ -107,7 +87,6 @@ export const useFilteredMarkets = (): Market[] => {
);
}
- // Property-based sorting
const sortPropertyMap: Record = {
[SortColumn.Starred]: 'uniqueKey',
[SortColumn.LoanAsset]: 'loanAsset.name',
@@ -121,6 +100,7 @@ export const useFilteredMarkets = (): Market[] => {
[SortColumn.RateAtTarget]: 'state.apyAtTarget',
[SortColumn.TrustedBy]: '',
[SortColumn.UtilizationRate]: 'state.utilization',
+ [SortColumn.Trend]: '', // Trend is a filter mode, not a sort
};
const propertyPath = sortPropertyMap[preferences.sortColumn];
@@ -129,5 +109,5 @@ export const useFilteredMarkets = (): Market[] => {
}
return markets;
- }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken]);
+ }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken, trendingKeys]);
};
diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx
index 123e2bc0..c34d251c 100644
--- a/src/modals/registry.tsx
+++ b/src/modals/registry.tsx
@@ -38,6 +38,8 @@ const BlacklistedMarketsModal = lazy(() =>
const TrustedVaultsModal = lazy(() => import('@/modals/settings/trusted-vaults-modal'));
+const TrendingSettingsModal = lazy(() => import('@/modals/settings/trending-settings-modal'));
+
const MarketSettingsModal = lazy(() => import('@/features/markets/components/market-settings-modal'));
// Vault Operations
@@ -60,6 +62,7 @@ export const MODAL_REGISTRY: {
rebalanceMarketSelection: RebalanceMarketSelectionModal,
marketSettings: MarketSettingsModal,
trustedVaults: TrustedVaultsModal,
+ trendingSettings: TrendingSettingsModal,
blacklistedMarkets: BlacklistedMarketsModal,
vaultDeposit: VaultDepositModal,
vaultWithdraw: VaultWithdrawModal,
diff --git a/src/modals/settings/trending-settings-modal.tsx b/src/modals/settings/trending-settings-modal.tsx
new file mode 100644
index 00000000..54e4334a
--- /dev/null
+++ b/src/modals/settings/trending-settings-modal.tsx
@@ -0,0 +1,278 @@
+'use client';
+import { useMemo } from 'react';
+import { HiFire } from 'react-icons/hi2';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { IconSwitch } from '@/components/ui/icon-switch';
+import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal';
+import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity';
+import { useMarketPreferences, type FlowTimeWindow, type TrendingWindowConfig } from '@/stores/useMarketPreferences';
+import { useMarketMetricsMap, isMarketTrending, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery';
+import { useProcessedMarkets } from '@/hooks/useProcessedMarkets';
+import { formatReadable } from '@/utils/balance';
+import type { Market } from '@/utils/types';
+
+type TrendingSettingsModalProps = {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+};
+
+const TIME_WINDOWS: { value: FlowTimeWindow; label: string }[] = [
+ { value: '1h', label: '1h' },
+ { value: '24h', label: '24h' },
+ { value: '7d', label: '7d' },
+ { value: '30d', label: '30d' },
+];
+
+function generateFilterSummary(config: { enabled: boolean; windows: Record }): string {
+ if (!config.enabled) return 'Trending detection is disabled';
+
+ const parts: string[] = [];
+
+ for (const { value: window, label } of TIME_WINDOWS) {
+ const windowConfig = config.windows?.[window];
+ if (!windowConfig) continue;
+
+ const windowParts: string[] = [];
+
+ // Supply thresholds - defensive access for old stored data
+ const supplyPct = windowConfig.minSupplyFlowPct ?? '';
+ const supplyUsd = windowConfig.minSupplyFlowUsd ?? '';
+ const supplyParts: string[] = [];
+ if (supplyPct) supplyParts.push(`+${supplyPct}%`);
+ if (supplyUsd) supplyParts.push(`+$${formatReadable(Number(supplyUsd))}`);
+ if (supplyParts.length > 0) {
+ windowParts.push(`supply grew ${supplyParts.join(' and ')}`);
+ }
+
+ // Borrow thresholds - defensive access for old stored data
+ const borrowPct = windowConfig.minBorrowFlowPct ?? '';
+ const borrowUsd = windowConfig.minBorrowFlowUsd ?? '';
+ const borrowParts: string[] = [];
+ if (borrowPct) borrowParts.push(`+${borrowPct}%`);
+ if (borrowUsd) borrowParts.push(`+$${formatReadable(Number(borrowUsd))}`);
+ if (borrowParts.length > 0) {
+ windowParts.push(`borrow grew ${borrowParts.join(' and ')}`);
+ }
+
+ if (windowParts.length > 0) {
+ parts.push(`${windowParts.join(', ')} in ${label}`);
+ }
+ }
+
+ if (parts.length === 0) return 'No thresholds configured';
+ return `Markets where ${parts.join('; ')}`;
+}
+
+function CompactInput({
+ value,
+ onChange,
+ disabled,
+ prefix,
+ suffix,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ disabled: boolean;
+ prefix?: string;
+ suffix?: string;
+}) {
+ return (
+
+ {prefix && {prefix}}
+ {
+ const stripped = e.target.value.replace(/[^0-9.]/g, '');
+ const parts = stripped.split('.');
+ const result = parts.length <= 1 ? stripped : `${parts[0]}.${parts.slice(1).join('')}`;
+ onChange(result);
+ }}
+ placeholder="-"
+ disabled={disabled}
+ className="font-inter h-6 w-12 px-1 text-center text-xs"
+ />
+ {suffix && {suffix}}
+
+ );
+}
+
+export default function TrendingSettingsModal({ isOpen, onOpenChange }: TrendingSettingsModalProps) {
+ const { trendingConfig, setTrendingEnabled, setTrendingWindowConfig } = useMarketPreferences();
+ const { metricsMap } = useMarketMetricsMap();
+ const { allMarkets } = useProcessedMarkets();
+ const isEnabled = trendingConfig.enabled;
+
+ // Compute matching markets for preview
+ const matchingMarkets = useMemo(() => {
+ if (!isEnabled || metricsMap.size === 0) return [];
+
+ const matches: Array<{ market: Market; supplyFlowPct1h: number }> = [];
+
+ for (const [key, metrics] of metricsMap) {
+ if (isMarketTrending(metrics, trendingConfig)) {
+ const market = allMarkets.find((m) => getMetricsKey(m.morphoBlue.chain.id, m.uniqueKey) === key);
+ if (market) {
+ matches.push({
+ market,
+ supplyFlowPct1h: metrics.flows['1h']?.supplyFlowPct ?? 0,
+ });
+ }
+ }
+ }
+
+ return matches.sort((a, b) => (b.market.state?.supplyAssetsUsd ?? 0) - (a.market.state?.supplyAssetsUsd ?? 0));
+ }, [isEnabled, metricsMap, trendingConfig, allMarkets]);
+
+ const totalMatches = matchingMarkets.length;
+
+ const handleChange = (window: FlowTimeWindow, field: keyof TrendingWindowConfig, value: string) => {
+ setTrendingWindowConfig(window, { [field]: value });
+ };
+
+ const filterSummary = generateFilterSummary(trendingConfig);
+
+ return (
+
+ {(onClose) => (
+ <>
+
+ Trending Markets
+ Beta
+
+ }
+ mainIcon={}
+ onClose={onClose}
+ />
+
+ {/* Toggle + Summary */}
+
+
+ {/* Compact threshold table */}
+
+ {/* Header */}
+
+
+
Supply Flow
+
Borrow Flow
+
+
+ {/* Rows */}
+ {TIME_WINDOWS.map(({ value: window, label }) => {
+ const config = trendingConfig.windows[window];
+
+ return (
+
+
{label}
+
+ {/* Supply inputs */}
+
+ handleChange(window, 'minSupplyFlowPct', v)}
+ disabled={!isEnabled}
+ suffix="%"
+ />
+ handleChange(window, 'minSupplyFlowUsd', v.replace(/[^0-9]/g, ''))}
+ disabled={!isEnabled}
+ prefix="$"
+ />
+
+
+ {/* Borrow inputs */}
+
+ handleChange(window, 'minBorrowFlowPct', v)}
+ disabled={!isEnabled}
+ suffix="%"
+ />
+ handleChange(window, 'minBorrowFlowUsd', v.replace(/[^0-9]/g, ''))}
+ disabled={!isEnabled}
+ prefix="$"
+ />
+
+
+ );
+ })}
+
+
+ {/* Preview */}
+ {isEnabled && (
+
+
+ Preview
+
+ {totalMatches > 0 ? `${totalMatches} market${totalMatches !== 1 ? 's' : ''} match` : 'No matches'}
+
+
+
+ {matchingMarkets.length > 0 ? (
+
+ {matchingMarkets.slice(0, 2).map((m) => (
+
+
+ +{m.supplyFlowPct1h.toFixed(1)}%
+
+ ))}
+ {totalMatches > 2 &&
+{totalMatches - 2} more}
+
+ ) : (
+
No markets match current criteria
+ )}
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/stores/useMarketPreferences.ts b/src/stores/useMarketPreferences.ts
index b8e25ca4..76ef356e 100644
--- a/src/stores/useMarketPreferences.ts
+++ b/src/stores/useMarketPreferences.ts
@@ -4,6 +4,33 @@ import { SortColumn } from '@/features/markets/components/constants';
import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets';
import { DEFAULT_COLUMN_VISIBILITY, type ColumnVisibility } from '@/features/markets/components/column-visibility';
+// Trending feature types
+export type FlowTimeWindow = '1h' | '24h' | '7d' | '30d';
+
+export type TrendingWindowConfig = {
+ // Supply flow thresholds (both must be met if set - AND logic)
+ minSupplyFlowPct: string; // e.g. "6" = 6% of current supply
+ minSupplyFlowUsd: string; // Absolute USD threshold
+ // Borrow flow thresholds (both must be met if set - AND logic)
+ minBorrowFlowPct: string;
+ minBorrowFlowUsd: string;
+};
+
+export type TrendingConfig = {
+ enabled: boolean;
+ windows: Record;
+};
+
+const DEFAULT_TRENDING_CONFIG: TrendingConfig = {
+ enabled: false,
+ windows: {
+ '1h': { minSupplyFlowPct: '6', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' },
+ '24h': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' },
+ '7d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' },
+ '30d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' },
+ },
+};
+
type MarketPreferencesState = {
// Sorting
sortColumn: SortColumn;
@@ -31,6 +58,9 @@ type MarketPreferencesState = {
minSupplyEnabled: boolean;
minBorrowEnabled: boolean;
minLiquidityEnabled: boolean;
+
+ // Trending Config (Beta)
+ trendingConfig: TrendingConfig;
};
type MarketPreferencesActions = {
@@ -63,6 +93,10 @@ type MarketPreferencesActions = {
setMinBorrowEnabled: (enabled: boolean) => void;
setMinLiquidityEnabled: (enabled: boolean) => void;
+ // Trending Config (Beta)
+ setTrendingEnabled: (enabled: boolean) => void;
+ setTrendingWindowConfig: (window: FlowTimeWindow, config: Partial) => void;
+
// Bulk update for migration
setAll: (state: Partial) => void;
};
@@ -82,7 +116,6 @@ type MarketPreferencesStore = MarketPreferencesState & MarketPreferencesActions;
export const useMarketPreferences = create()(
persist(
(set, get) => ({
- // Default state
sortColumn: SortColumn.Supply,
sortDirection: -1,
entriesPerPage: 8,
@@ -98,8 +131,8 @@ export const useMarketPreferences = create()(
minSupplyEnabled: false,
minBorrowEnabled: false,
minLiquidityEnabled: false,
+ trendingConfig: DEFAULT_TRENDING_CONFIG,
- // Actions
setSortColumn: (column) => set({ sortColumn: column }),
setSortDirection: (direction) => set({ sortDirection: direction }),
setEntriesPerPage: (count) => set({ entriesPerPage: count }),
@@ -129,6 +162,20 @@ export const useMarketPreferences = create()(
setMinSupplyEnabled: (enabled) => set({ minSupplyEnabled: enabled }),
setMinBorrowEnabled: (enabled) => set({ minBorrowEnabled: enabled }),
setMinLiquidityEnabled: (enabled) => set({ minLiquidityEnabled: enabled }),
+ setTrendingEnabled: (enabled) =>
+ set((state) => ({
+ trendingConfig: { ...state.trendingConfig, enabled },
+ })),
+ setTrendingWindowConfig: (window, config) =>
+ set((state) => ({
+ trendingConfig: {
+ ...state.trendingConfig,
+ windows: {
+ ...state.trendingConfig.windows,
+ [window]: { ...state.trendingConfig.windows[window], ...config },
+ },
+ },
+ })),
setAll: (state) => set(state),
}),
{
diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts
index de85fcd0..e0797469 100644
--- a/src/stores/useMarketsFilters.ts
+++ b/src/stores/useMarketsFilters.ts
@@ -15,6 +15,7 @@ type MarketsFiltersState = {
selectedNetwork: SupportedNetworks | null;
selectedOracles: PriceFeedVendors[];
searchQuery: string;
+ trendingMode: boolean; // Filter toggle - thresholds are in useMarketPreferences
};
type MarketsFiltersActions = {
@@ -23,6 +24,7 @@ type MarketsFiltersActions = {
setSelectedNetwork: (network: SupportedNetworks | null) => void;
setSelectedOracles: (oracles: PriceFeedVendors[]) => void;
setSearchQuery: (query: string) => void;
+ toggleTrendingMode: () => void;
resetFilters: () => void;
};
@@ -34,6 +36,7 @@ const DEFAULT_STATE: MarketsFiltersState = {
selectedNetwork: null,
selectedOracles: [],
searchQuery: '',
+ trendingMode: false,
};
/**
@@ -46,34 +49,14 @@ const DEFAULT_STATE: MarketsFiltersState = {
* ```
*/
export const useMarketsFilters = create()((set) => ({
- // Default state
...DEFAULT_STATE,
- // Actions
- setSelectedCollaterals: (collaterals) =>
- set({
- selectedCollaterals: [...new Set(collaterals)], // Remove duplicates
- }),
-
- setSelectedLoanAssets: (assets) =>
- set({
- selectedLoanAssets: [...new Set(assets)], // Remove duplicates
- }),
-
- setSelectedNetwork: (network) =>
- set({
- selectedNetwork: network,
- }),
-
- setSelectedOracles: (oracles) =>
- set({
- selectedOracles: oracles,
- }),
-
- setSearchQuery: (query) =>
- set({
- searchQuery: query,
- }),
+ setSelectedCollaterals: (collaterals) => set({ selectedCollaterals: [...new Set(collaterals)] }),
+ setSelectedLoanAssets: (assets) => set({ selectedLoanAssets: [...new Set(assets)] }),
+ setSelectedNetwork: (network) => set({ selectedNetwork: network }),
+ setSelectedOracles: (oracles) => set({ selectedOracles: oracles }),
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ toggleTrendingMode: () => set((state) => ({ trendingMode: !state.trendingMode })),
resetFilters: () => set(DEFAULT_STATE),
}));
diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts
index 641932ed..66d80a57 100644
--- a/src/stores/useModalStore.ts
+++ b/src/stores/useModalStore.ts
@@ -51,6 +51,8 @@ export type ModalProps = {
trustedVaults: Record; // No props needed - uses useTrustedVaults() store
+ trendingSettings: Record; // No props needed - uses useMarketPreferences() store
+
blacklistedMarkets: Record; // No props needed - uses useProcessedMarkets() context
// Vault Operations
diff --git a/src/utils/markets.ts b/src/utils/markets.ts
index ca3d484c..2a48e891 100644
--- a/src/utils/markets.ts
+++ b/src/utils/markets.ts
@@ -21,6 +21,7 @@ export const parseNumericThreshold = (rawValue: string | undefined | null): numb
export const blacklistedMarkets = [
'0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9', // PAXG / USDC market with wrong oracle
'0x7e79c25831c97175922df132d09b02f93103a2306b1d71e57a7714ddd4c15d13', // Relend USDC / USDC: Should be considered unrecoverable
+ '0x1dca6989b0d2b0a546530b3a739e91402eee2e1536a2d3ded4f5ce589a9cd1c2', //
];
// Market specially whitelisted by Monarch, lowercase
diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts
index f8cbf763..4eb182b4 100644
--- a/src/utils/subgraph-types.ts
+++ b/src/utils/subgraph-types.ts
@@ -1,15 +1,13 @@
import type { Address } from 'viem';
-// Corresponds to tokenFragment
export type SubgraphToken = {
- id: Address; // address
+ id: Address;
name: string;
symbol: string;
decimals: number;
- lastPriceUSD: string | null; // BigDecimal represented as string
+ lastPriceUSD: string | null;
};
-// Corresponds to oracleFragment
export type SubgraphOracle = {
id: string;
oracleAddress: Address;
@@ -18,24 +16,21 @@ export type SubgraphOracle = {
isUSD: boolean;
};
-// Corresponds to InterestRate type within marketFragment
export type SubgraphInterestRate = {
id: string;
- rate: string; // BigDecimal represented as string (APY percentage)
+ rate: string;
side: 'LENDER' | 'BORROWER';
type: 'STABLE' | 'VARIABLE' | 'FIXED';
};
-// Corresponds to protocol details within marketFragment
export type SubgraphProtocolInfo = {
id: string;
- network: string; // e.g., "MAINNET", "BASE"
- protocol: string; // e.g., "Morpho Blue"
+ network: string;
+ protocol: string;
};
-// Corresponds to the main marketFragment (SubgraphMarketFields)
export type SubgraphMarket = {
- id: Address; // uniqueKey (market address)
+ id: Address;
name: string;
isActive: boolean;
canBorrowFrom: boolean;
@@ -48,18 +43,15 @@ export type SubgraphMarket = {
lltv: string;
irm: Address;
inputToken: SubgraphToken;
- inputTokenPriceUSD: string; // BigDecimal (collateralPrice)
- borrowedToken: SubgraphToken; // loanAsset
-
- // note: these 2 are weird
- variableBorrowedTokenBalance: string | null; // updated as total Borrowed
- inputTokenBalance: string; // updated as total Supply
+ borrowedToken: SubgraphToken;
+ variableBorrowedTokenBalance: string | null;
+ inputTokenBalance: string;
totalValueLockedUSD: string;
totalDepositBalanceUSD: string;
totalBorrowBalanceUSD: string;
totalSupplyShares: string;
- totalBorrowShares: string; // BigInt (borrowShares)
+ totalBorrowShares: string;
totalSupply: string;
totalBorrow: string;
@@ -74,7 +66,6 @@ export type SubgraphMarket = {
protocol: SubgraphProtocolInfo;
};
-// Type for the GraphQL response structure using marketsQuery
export type SubgraphMarketsQueryResponse = {
data: {
markets: SubgraphMarket[];
@@ -82,7 +73,6 @@ export type SubgraphMarketsQueryResponse = {
errors?: { message: string }[];
};
-// Type for a single market response (if we adapt query later)
export type SubgraphMarketQueryResponse = {
data: {
market: SubgraphMarket | null;
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 4b053b3a..1aeaede0 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -108,7 +108,6 @@ export type TokenInfo = {
decimals: number;
};
-// Common types
type AssetType = {
id: string;
address: string;
@@ -122,7 +121,6 @@ export type RewardAmount = {
claimed: string;
};
-// Market Program Type
export type MarketRewardType = {
// shared
type: 'market-reward';
@@ -143,7 +141,6 @@ export type MarketRewardType = {
};
};
-// Uniform Reward Type
export type UniformRewardType = {
// shared
type: 'uniform-reward';
@@ -179,7 +176,6 @@ export type VaultProgramType = {
id: string;
};
-// Combined RewardResponseType
export type RewardResponseType = MarketRewardType | UniformRewardType | VaultRewardType;
export type AggregatedRewardType = {
@@ -247,7 +243,6 @@ export type GroupedPosition = {
allWarnings: WarningWithDetail[];
};
-// Add these new types
export type OracleFeed = {
address: string;
chain: {
@@ -289,14 +284,12 @@ export type OraclesQueryResponse = {
errors?: { message: string }[];
};
-// Update the Market type
export type Market = {
id: string;
lltv: string;
uniqueKey: string;
irmAddress: string;
oracleAddress: string;
- collateralPrice: string;
whitelisted: boolean;
morphoBlue: {
id: string;
@@ -324,10 +317,7 @@ export type Market = {
fee: number;
timestamp: number;
- // AdaptiveCurveIRM APY if utilization was at target
apyAtTarget: number;
-
- // AdaptiveCurveIRM rate per second if utilization was at target
rateAtTarget: string;
};
realizedBadDebt: {
@@ -336,7 +326,6 @@ export type Market = {
supplyingVaults?: {
address: string;
}[];
- // whether we have USD price such has supplyUSD, borrowUSD, collateralUSD, etc. If not, use estimationP
hasUSDPrice: boolean;
warnings: MarketWarning[];
oracle?: {
@@ -355,7 +344,6 @@ export type TimeseriesOptions = {
interval: 'HOUR' | 'DAY' | 'WEEK' | 'MONTH';
};
-// Export MarketRates and MarketVolumes
export type MarketRates = {
supplyApy: TimeseriesDataPoint[];
borrowApy: TimeseriesDataPoint[];
@@ -402,7 +390,6 @@ export type AgentMetadata = {
image: string;
};
-// Define the comprehensive Market Activity Transaction type
export type MarketActivityTransaction = {
type: 'MarketSupply' | 'MarketWithdraw' | 'MarketBorrow' | 'MarketRepay';
hash: string;
@@ -411,13 +398,11 @@ export type MarketActivityTransaction = {
userAddress: string; // Unified field for user address
};
-// Paginated result type for market activity transactions
export type PaginatedMarketActivityTransactions = {
items: MarketActivityTransaction[];
totalCount: number;
};
-// Type for Liquidation Transactions (Simplified based on original hook)
export type MarketLiquidationTransaction = {
type: 'MarketLiquidation';
hash: string;
@@ -428,28 +413,22 @@ export type MarketLiquidationTransaction = {
badDebtAssets: string;
};
-// Type for Market Supplier (current position state, not historical transactions)
-// Only stores shares - assets can be calculated from shares using market state
export type MarketSupplier = {
userAddress: string;
supplyShares: string;
};
-// Paginated result type for market suppliers
export type PaginatedMarketSuppliers = {
items: MarketSupplier[];
totalCount: number;
};
-// Type for Market Borrower (current position state, not historical transactions)
-// Stores borrowAssets and collateral - shares can be calculated if needed
export type MarketBorrower = {
userAddress: string;
borrowAssets: string;
collateral: string;
};
-// Paginated result type for market borrowers
export type PaginatedMarketBorrowers = {
items: MarketBorrower[];
totalCount: number;