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" - /> - } - > -
- 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 */} +
+
+

{filterSummary}

+
+ +
+ + {/* 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;