From 091215fa58393e9edad43f5b0d2467152f499d73 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 13 Apr 2026 16:07:49 +0800 Subject: [PATCH 1/3] refactor: remove vercel api usage --- .env.local.example | 8 +-- app/api/monarch/metrics/route.ts | 48 ---------------- app/api/monarch/utils.ts | 38 ------------- app/api/token-metadata/route.ts | 35 ------------ docs/TECHNICAL_OVERVIEW.md | 6 +- next.config.js | 3 +- .../components/filters/asset-filter.tsx | 1 + src/hooks/queries/useMarketMetricsQuery.ts | 24 ++++++-- src/server/token-metadata-cache.ts | 55 ------------------- src/utils/tokenMetadata.ts | 11 +++- src/utils/urls.ts | 2 + 11 files changed, 42 insertions(+), 189 deletions(-) delete mode 100644 app/api/monarch/metrics/route.ts delete mode 100644 app/api/monarch/utils.ts delete mode 100644 app/api/token-metadata/route.ts delete mode 100644 src/server/token-metadata-cache.ts diff --git a/.env.local.example b/.env.local.example index e9fbbc1f..2745d316 100644 --- a/.env.local.example +++ b/.env.local.example @@ -57,10 +57,10 @@ 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= +# ==================== External Data API ==================== +# Required in production now that Vercel data-route fallbacks are removed. +# Single required base URL for both metrics and token metadata. +NEXT_PUBLIC_DATA_API_BASE_URL= # ==================== Oracle Metadata ==================== # Base URL for oracle metadata Gist (without trailing slash) diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts deleted file mode 100644 index 2c17e787..00000000 --- a/app/api/monarch/metrics/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { - MONARCH_METRICS_API_KEY, - MONARCH_METRICS_TIMEOUT_MS, - fetchMonarchUpstream, - getMonarchMetricsUrl, - getMonarchRouteFailure, -} from '../utils'; -import { reportApiRouteError } from '@/utils/sentry-server'; - -export async function GET(req: NextRequest) { - if (!MONARCH_METRICS_API_KEY) { - console.error('[Monarch Metrics API] Missing MONARCH_METRICS_API_KEY'); - return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); - } - - const searchParams = req.nextUrl.searchParams; - - try { - const url = getMonarchMetricsUrl('/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 fetchMonarchUpstream(url, MONARCH_METRICS_TIMEOUT_MS, { - headers: { 'X-API-Key': MONARCH_METRICS_API_KEY }, - }); - - 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) { - const failure = getMonarchRouteFailure(error, 'Failed to fetch market metrics', 'Monarch metrics request timed out'); - - reportApiRouteError(error, { - route: '/api/monarch/metrics', - method: 'GET', - status: failure.status, - }); - console.error('[Monarch Metrics API] Failed to fetch:', error); - return NextResponse.json({ error: failure.message }, { status: failure.status }); - } -} diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts deleted file mode 100644 index 3d29654b..00000000 --- a/app/api/monarch/utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -export const MONARCH_METRICS_API_ENDPOINT = process.env.MONARCH_METRICS_API_ENDPOINT; -export const MONARCH_METRICS_API_KEY = process.env.MONARCH_METRICS_API_KEY; -export const MONARCH_METRICS_TIMEOUT_MS = 5_000; - -const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === 'AbortError'; - -export const getMonarchRouteFailure = ( - error: unknown, - fallbackMessage: string, - timeoutMessage: string, -): { - message: string; - status: number; -} => { - return isAbortError(error) ? { message: timeoutMessage, status: 504 } : { message: fallbackMessage, status: 500 }; -}; - -export const fetchMonarchUpstream = async (input: URL | string, timeoutMs: number, init: RequestInit): Promise => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, timeoutMs); - - try { - return await fetch(input, { - ...init, - cache: 'no-store', - signal: controller.signal, - }); - } finally { - clearTimeout(timeoutId); - } -}; - -export const getMonarchMetricsUrl = (path: string): URL => { - if (!MONARCH_METRICS_API_ENDPOINT) throw new Error('MONARCH_METRICS_API_ENDPOINT not configured'); - return new URL(path, MONARCH_METRICS_API_ENDPOINT.replace(/\/$/, '')); -}; diff --git a/app/api/token-metadata/route.ts b/app/api/token-metadata/route.ts deleted file mode 100644 index d0664a72..00000000 --- a/app/api/token-metadata/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { fetchCachedUnknownTokenInfos } from '@/server/token-metadata-cache'; -import { reportApiRouteError } from '@/utils/sentry-server'; -import type { TokenAddressInput } from '@/utils/tokenMetadata'; - -const isTokenAddressInput = (value: unknown): value is TokenAddressInput => { - if (!value || typeof value !== 'object') { - return false; - } - - const candidate = value as Partial; - return typeof candidate.address === 'string' && typeof candidate.chainId === 'number'; -}; - -export async function POST(request: NextRequest) { - try { - const body = (await request.json()) as { tokens?: unknown }; - const tokens = Array.isArray(body.tokens) ? body.tokens.filter(isTokenAddressInput) : []; - - if (tokens.length === 0) { - return NextResponse.json({ tokens: {} }); - } - - const resolvedTokens = await fetchCachedUnknownTokenInfos(tokens); - return NextResponse.json({ tokens: resolvedTokens }); - } catch (error) { - reportApiRouteError(error, { - route: '/api/token-metadata', - method: 'POST', - status: 500, - }); - - return NextResponse.json({ error: 'Failed to fetch cached token metadata' }, { status: 500 }); - } -} diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 9bbc8be2..4a5b2d7e 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -190,7 +190,7 @@ Market detail participants/activity + admin stats transactions: ↓ (for market-detail fallback only) Morpho API / Subgraph -Market metrics: Monarch metrics API via `/api/monarch/metrics` +Market metrics: external data API via `/v1/markets/metrics` ``` **Morpho API Supported Chains:** Mainnet, Base, Unichain, Polygon, Arbitrum, HyperEVM, Monad @@ -275,7 +275,7 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he | `useUserBalancesQuery` | ERC20 wallet balances across chains | Pure RPC multicall via wagmi | No Envio gap | | `useTokensQuery` | Token metadata lookup for app UI | Local token registry + Pendle assets API | Not part of Monarch migration | | `useOracleMetadata` / `useAllOracleMetadata` | Oracle classification and feed metadata | Scanner gist JSON | Not part of Monarch migration | -| `useMarketMetricsQuery` | Enhanced market metrics, flows, trending, scores | Monarch metrics API via `/api/monarch/metrics` | Already Monarch-backed | +| `useMarketMetricsQuery` | Enhanced market metrics, flows, trending, scores | External data API via `/v1/markets/metrics` | Already Monarch-backed | | `useUserRewardsQuery` | Claimable rewards and distributions | Morpho rewards REST + Merkl API | Outside Monarch/Envio scope today | | `useMerklCampaignsQuery` / `useMerklHoldIncentivesQuery` | Campaign and HOLD incentive enrichment | Merkl API + hardcoded opportunity mapping | Outside Monarch/Envio scope today | @@ -432,7 +432,7 @@ Fallback Strategy: |---------|----------|---------| | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions | | Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, market live state, historical charts, market detail/activity, admin transactions | -| Monarch Metrics | `/api/monarch/metrics` → external Monarch metrics API | Market metrics and admin stats | +| Monarch Metrics | External data API `/v1/markets/metrics` | Market metrics and admin stats | | The Graph | Per-chain subgraph URLs | Fallback data, suppliers, borrowers | | Merkl API | `https://api.merkl.xyz` | Reward campaigns | | Velora API | `https://api.paraswap.io` | Swap quotes and executable tx payloads | diff --git a/next.config.js b/next.config.js index 96a79489..89ba21e0 100644 --- a/next.config.js +++ b/next.config.js @@ -10,6 +10,7 @@ const nextConfig = { ignoreDuringBuilds: true, }, images: { + minimumCacheTTL: 2_678_400, remotePatterns: [ { protocol: 'https', @@ -25,7 +26,7 @@ const nextConfig = { }, { protocol: 'https', - hostname: '**', + hostname: 'storage.googleapis.com', }, ], }, diff --git a/src/features/markets/components/filters/asset-filter.tsx b/src/features/markets/components/filters/asset-filter.tsx index 41a3244e..ec819782 100644 --- a/src/features/markets/components/filters/asset-filter.tsx +++ b/src/features/markets/components/filters/asset-filter.tsx @@ -79,6 +79,7 @@ export default function AssetFilter({ alt={token.symbol} width={size} height={size} + unoptimized /> ) : (
{ + if (!DATA_API_BASE_URL) { + throw new Error('NEXT_PUBLIC_DATA_API_BASE_URL is required.'); + } + + const baseUrl = `${DATA_API_BASE_URL}/v1/markets/metrics`; + if (!searchParams || searchParams.size === 0) { + return baseUrl; + } + + return `${baseUrl}?${searchParams.toString()}`; +}; + const MARKET_LIQUIDATION_PRESENCE_QUERY = ` query MarketLiquidationPresence($chainId: Int!, $marketId: String!) { Morpho_Liquidate( @@ -105,7 +121,7 @@ const fetchMarketMetricsPage = async (params: MarketMetricsParams, limit: number searchParams.set('limit', String(limit)); searchParams.set('offset', String(offset)); - const response = await fetch(`/api/monarch/metrics?${searchParams.toString()}`); + const response = await fetch(getMarketMetricsClientUrl(searchParams)); if (!response.ok) { throw new Error('Failed to fetch market metrics'); @@ -163,7 +179,7 @@ const fetchMarketLiquidationPresence = async (chainId: number, marketId: string) /** * Fetches enhanced market metrics from the Monarch monitoring API. - * Pre-fetched and cached for 5 minutes. + * Pre-fetched and cached for 15 minutes. * * Returns rich metadata including: * - Flow data (1h, 24h, 7d, 30d) for supply/borrow @@ -178,8 +194,8 @@ export const useMarketMetricsQuery = (params: MarketMetricsParams = {}) => { return useQuery({ queryKey: ['market-metrics', { chainId, sortBy, sortOrder }], queryFn: () => fetchAllMarketMetrics({ chainId, sortBy, sortOrder }), - staleTime: 5 * 60 * 1000, // 5 minutes - matches API update frequency - refetchInterval: 5 * 60 * 1000, // Match staleTime - no point refetching more often + staleTime: MARKET_METRICS_REFRESH_MS, + refetchInterval: MARKET_METRICS_REFRESH_MS, refetchOnWindowFocus: false, // Don't refetch on focus since data is slow-changing enabled, }); diff --git a/src/server/token-metadata-cache.ts b/src/server/token-metadata-cache.ts deleted file mode 100644 index 70e826e0..00000000 --- a/src/server/token-metadata-cache.ts +++ /dev/null @@ -1,55 +0,0 @@ -import 'server-only'; - -import { createHash } from 'node:crypto'; -import { unstable_cache } from 'next/cache'; -import { - resolveUnknownTokenInfosOnchain, - serializeResolvedTokenInfos, - type SerializedResolvedTokenInfos, - type TokenAddressInput, -} from '@/utils/tokenMetadata'; -import { infoToKey } from '@/utils/tokens'; - -const TOKEN_METADATA_REVALIDATE_SECONDS = 24 * 60 * 60; - -const normalizeTokenInputs = (tokens: TokenAddressInput[]): TokenAddressInput[] => { - const deduped = new Map(); - - for (const token of tokens) { - const normalized = { - address: token.address.toLowerCase(), - chainId: token.chainId, - }; - const key = infoToKey(normalized.address, normalized.chainId); - - if (!deduped.has(key)) { - deduped.set(key, normalized); - } - } - - return Array.from(deduped.values()).sort((left, right) => left.chainId - right.chainId || left.address.localeCompare(right.address)); -}; - -const buildTokenSignature = (tokens: TokenAddressInput[]): string => createHash('sha256').update(JSON.stringify(tokens)).digest('hex'); - -export const fetchCachedUnknownTokenInfos = async (tokens: TokenAddressInput[]): Promise => { - if (tokens.length === 0) { - return {}; - } - - const normalizedTokens = normalizeTokenInputs(tokens); - const signature = buildTokenSignature(normalizedTokens); - - const getCachedTokenInfos = unstable_cache( - async () => { - const resolvedTokenInfos = await resolveUnknownTokenInfosOnchain(normalizedTokens); - return serializeResolvedTokenInfos(resolvedTokenInfos); - }, - ['token-metadata', signature], - { - revalidate: TOKEN_METADATA_REVALIDATE_SECONDS, - }, - ); - - return getCachedTokenInfos(); -}; diff --git a/src/utils/tokenMetadata.ts b/src/utils/tokenMetadata.ts index 2a487c3f..4b408642 100644 --- a/src/utils/tokenMetadata.ts +++ b/src/utils/tokenMetadata.ts @@ -4,6 +4,7 @@ import type { SupportedNetworks } from './networks'; import { getClient } from './rpc'; import { findToken, infoToKey } from './tokens'; import type { TokenInfo } from './types'; +import { DATA_API_BASE_URL } from './urls'; export type TokenAddressInput = { address: string; @@ -25,6 +26,14 @@ export type SerializedResolvedTokenInfos = Record; const TOKEN_METADATA_BATCH_SIZE = 200; const erc20SymbolBytes32Abi = parseAbi(['function symbol() view returns (bytes32)']); +const getTokenMetadataClientUrl = (): string => { + if (!DATA_API_BASE_URL) { + throw new Error('NEXT_PUBLIC_DATA_API_BASE_URL is required.'); + } + + return `${DATA_API_BASE_URL}/v1/tokens/metadata`; +}; + const normalizeAddress = (value: string): string => value.toLowerCase(); const formatUnknownTokenLabel = (address: string): string => { @@ -252,7 +261,7 @@ export const resolveUnknownTokenInfosOnchain = async ( }; const fetchResolvedUnknownTokenInfosFromServer = async (tokens: TokenAddressInput[]): Promise> => { - const response = await fetch('/api/token-metadata', { + const response = await fetch(getTokenMetadataClientUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/utils/urls.ts b/src/utils/urls.ts index b839af62..3836339c 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -7,6 +7,8 @@ export const URLS = { MORPHO_REWARDS_API: 'https://rewards.morpho.org/v1', } as const; +export const DATA_API_BASE_URL = process.env.NEXT_PUBLIC_DATA_API_BASE_URL?.replace(/\/+$/, '') ?? ''; + export const MONARCH_AGENT_URLS: Partial> = { [SupportedNetworks.Base]: 'https://api.studio.thegraph.com/query/110397/monarch-agent-base/version/latest', [SupportedNetworks.Polygon]: 'https://api.studio.thegraph.com/query/110397/monarch-agent-polygon/version/latest', From ad4e73d440c5a365568f69ab87b816e252285f10 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 13 Apr 2026 20:39:04 +0800 Subject: [PATCH 2/3] chore: metadata working --- src/utils/tokenMetadata.ts | 88 ++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/src/utils/tokenMetadata.ts b/src/utils/tokenMetadata.ts index 4b408642..a45c7679 100644 --- a/src/utils/tokenMetadata.ts +++ b/src/utils/tokenMetadata.ts @@ -107,7 +107,20 @@ export const serializeResolvedTokenInfos = (resolvedTokenInfos: Map => - new Map(Object.entries(value)); + new Map( + Object.entries(value).map(([key, resolvedTokenInfo]) => { + if (key.includes(':')) { + const [chainIdRaw, address] = key.split(':'); + const chainId = Number(chainIdRaw); + + if (Number.isInteger(chainId) && address) { + return [infoToKey(address, chainId), resolvedTokenInfo] satisfies [string, ResolvedTokenInfo]; + } + } + + return [key, resolvedTokenInfo] satisfies [string, ResolvedTokenInfo]; + }), + ); export const fetchOnchainTokenMetadataMap = async ( tokens: TokenAddressInput[], @@ -261,47 +274,58 @@ export const resolveUnknownTokenInfosOnchain = async ( }; const fetchResolvedUnknownTokenInfosFromServer = async (tokens: TokenAddressInput[]): Promise> => { - const response = await fetch(getTokenMetadataClientUrl(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ tokens }), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch token metadata: ${response.status}`); + const uniqueTokens = dedupeTokenInputs(tokens); + const batches: TokenAddressInput[][] = []; + + for (let start = 0; start < uniqueTokens.length; start += TOKEN_METADATA_BATCH_SIZE) { + batches.push(uniqueTokens.slice(start, start + TOKEN_METADATA_BATCH_SIZE)); } - const data = (await response.json()) as { tokens?: SerializedResolvedTokenInfos }; - return deserializeResolvedTokenInfos(data.tokens ?? {}); + const responses = await Promise.allSettled( + batches.map(async (tokenBatch) => { + const response = await fetch(getTokenMetadataClientUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tokens: tokenBatch }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch token metadata: ${response.status}`); + } + + const data = (await response.json()) as { tokens?: SerializedResolvedTokenInfos }; + return deserializeResolvedTokenInfos(data.tokens ?? {}); + }), + ); + + const resolvedTokenInfos = new Map(); + + for (const response of responses) { + if (response.status !== 'fulfilled') { + continue; + } + + for (const [key, value] of response.value.entries()) { + resolvedTokenInfos.set(key, value); + } + } + + return resolvedTokenInfos; }; export const resolveTokenInfos = async ( tokens: TokenAddressInput[], - customRpcUrls: CustomRpcUrls = {}, + _customRpcUrls: CustomRpcUrls = {}, ): Promise> => { const uniqueTokens = dedupeTokenInputs(tokens); const resolvedTokenInfos = new Map(); const unresolvedTokens = uniqueTokens.filter((token) => !findToken(token.address, token.chainId)); - const serverResolvedTokens = unresolvedTokens.filter((token) => !customRpcUrls[token.chainId]); - const clientResolvedTokens = unresolvedTokens.filter((token) => Boolean(customRpcUrls[token.chainId])); - const [serverResolvedTokenInfos, clientResolvedTokenInfos] = await Promise.all([ - serverResolvedTokens.length === 0 - ? Promise.resolve(new Map()) - : typeof window === 'undefined' - ? resolveUnknownTokenInfosOnchain(serverResolvedTokens, customRpcUrls) - : fetchResolvedUnknownTokenInfosFromServer(serverResolvedTokens).catch(() => - resolveUnknownTokenInfosOnchain(serverResolvedTokens, customRpcUrls), - ), - clientResolvedTokens.length === 0 - ? Promise.resolve(new Map()) - : resolveUnknownTokenInfosOnchain(clientResolvedTokens, customRpcUrls), - ]); - const unresolvedTokenInfos = new Map([ - ...serverResolvedTokenInfos.entries(), - ...clientResolvedTokenInfos.entries(), - ]); + const unresolvedTokenInfos = + unresolvedTokens.length === 0 + ? new Map() + : await fetchResolvedUnknownTokenInfosFromServer(unresolvedTokens).catch(() => new Map()); for (const token of uniqueTokens) { const key = infoToKey(token.address, token.chainId); From a8dce3325bb0b09fd8bd409668529b94f753462a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 14 Apr 2026 00:45:51 +0800 Subject: [PATCH 3/3] chore: diff --- next.config.js | 5 ----- src/features/markets/components/filters/asset-filter.tsx | 1 - 2 files changed, 6 deletions(-) diff --git a/next.config.js b/next.config.js index 89ba21e0..bf68f83c 100644 --- a/next.config.js +++ b/next.config.js @@ -10,7 +10,6 @@ const nextConfig = { ignoreDuringBuilds: true, }, images: { - minimumCacheTTL: 2_678_400, remotePatterns: [ { protocol: 'https', @@ -24,10 +23,6 @@ const nextConfig = { protocol: 'https', hostname: 'api.dicebear.com', }, - { - protocol: 'https', - hostname: 'storage.googleapis.com', - }, ], }, // temp fix for reown package issue: https://github.com/MetaMask/metamask-sdk/issues/1376 diff --git a/src/features/markets/components/filters/asset-filter.tsx b/src/features/markets/components/filters/asset-filter.tsx index ec819782..41a3244e 100644 --- a/src/features/markets/components/filters/asset-filter.tsx +++ b/src/features/markets/components/filters/asset-filter.tsx @@ -79,7 +79,6 @@ export default function AssetFilter({ alt={token.symbol} width={size} height={size} - unoptimized /> ) : (