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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Comment thread
antoncoding marked this conversation as resolved.
32 changes: 32 additions & 0 deletions app/api/monarch/liquidations/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Comment on lines +12 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n app/api/monarch/liquidations/route.ts

Repository: antoncoding/monarch

Length of output: 1745


🌐 Web query:

Next.js 15 Route Handlers fetch timeout AbortController best practices

💡 Result:

Summary — best practices for timeouts in Next.js 15 Route Handlers (fetch + AbortController)

  1. Use the incoming Request signal to detect client cancellation. Listen to req.signal and wire it to your work so you stop early when the client aborts. [1][2]

  2. Create a separate AbortController for your own timeout and race it with req.signal. Example pattern:

    • const timeout = new AbortController();
    • const timeoutId = setTimeout(() => timeout.abort(), ms);
    • // combine signals by passing one (or using Promise.race to abort fetch when either fires)
    • await fetch(url, { signal: timeout.signal, ... });
    • clearTimeout(timeoutId) in finally.
      This avoids leaking long-running fetches. [2][3]
  3. Forward cancellation to downstream requests and streaming APIs. Pass your controller.signal into fetch/streaming clients (and into SDKs that accept AbortSignal). Many SDKs and streams will throw on abort — handle errors explicitly (check for error.name === 'AbortError'). [1][4]

  4. Don’t block the HTTP response for nonessential background work. For tasks that can run after response, use Next/Vercel background helpers (waitUntil / after / .) or export server maxDuration/configs rather than awaiting them in the main response path. Use context.waitUntil or the Next.js/Vercel background API so the response returns immediately while work continues (subject to function timeouts). [1][5]

  5. Configure function timeouts when needed. Increase maxDuration/export or set platform timeout (vercel.json or named export) for long-running handlers, but keep client-visible fetch timeouts short and explicit. [5][3]

  6. Handle platform-specific quirks:

    • On Vercel/Node you can check req.signal.aborted per the platform change that added request cancellation. Enable request-cancellation behavior per platform docs if required. [2]
    • Fetch timeouts can produce platform-specific errors (e.g., UND_ERR_CONNECT_TIMEOUT); monitor and catch those to retry or surface user-friendly errors. [6]
  7. Practical tips

    • Use small, per-request timeout values (e.g., 5–30s for upstream HTTP calls) and longer server maxDuration only when necessary.
    • For streaming responses, be conservative with AbortController — some libraries/SDKs may close streams immediately on attach; test with your SDK. [4]
    • Always clear timers and remove listeners in finally blocks to avoid memory leaks.

Relevant references

  • Vercel changelog: request cancellation / use req.signal. [1]
  • Vercel / Next.js docs on background tasks, waitUntil and functions API. [2]
  • Guide on avoiding long-running actions in route handlers (recommend using background tasks). [5]
  • Issues/discussions re: AbortController + streaming SDKs (behavior & error handling). [4]
  • GitHub issue showing platform fetch timeout behavior (UND_ERR_CONNECT_TIMEOUT). [6]

If you want, I can paste a minimal example route.ts showing: combining req.signal + timeout controller, passing signal to fetch, cleanup, and using waitUntil for background work.


Add timeout + guard JSON parsing to prevent hanging and crashes on invalid upstream responses.

No timeout allows indefinite resource exhaustion if the upstream stalls; response.json() throws unhandled on non-JSON payloads. Both are real risks.

Proposed diff
   try {
-    const response = await fetch(url, {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 10_000);
+
+    const response = await fetch(url, {
       headers: { 'X-API-Key': MONARCH_API_KEY },
       next: { revalidate: 300 }, // Cache for 5 minutes
+      signal: controller.signal,
     });
+    clearTimeout(timeoutId);
@@
-    return NextResponse.json(await response.json());
+    let data: unknown;
+    try {
+      data = await response.json();
+    } catch {
+      console.error('[Monarch Liquidations API] Upstream returned non-JSON payload');
+      return NextResponse.json({ error: 'Failed to fetch liquidations' }, { status: 502 });
+    }
+    return NextResponse.json(data);
   } catch (error) {

For Next.js 15, this AbortController + setTimeout pattern is the standard way to set per-request timeouts. Works cleanly with next.revalidate caching. Consider also wiring req.signal to catch client-side cancellations early.

🤖 Prompt for AI Agents
In @app/api/monarch/liquidations/route.ts around lines 20 - 36, Add a
per-request timeout using an AbortController around the fetch call (create
controller, merge controller.signal with incoming req.signal if available, start
setTimeout to abort after e.g. 10s and clear it on completion) to prevent
indefinite hanging; after receiving response, guard JSON parsing by checking
Content-Type for application/json and wrapping response.json() in a try/catch
(or fallback to response.text()) so non-JSON upstream payloads don't throw, and
ensure you still return appropriate NextResponse.json(...) with error
details/status on parse or fetch errors.

}
35 changes: 35 additions & 0 deletions app/api/monarch/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
antoncoding marked this conversation as resolved.

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 });
}
}
7 changes: 7 additions & 0 deletions app/api/monarch/utils.ts
Original file line number Diff line number Diff line change
@@ -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(/\/$/, ''));
};
Comment thread
antoncoding marked this conversation as resolved.
37 changes: 26 additions & 11 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -50,7 +47,6 @@ export default function SettingsPage() {
<h1 className="py-8 font-zen">Settings</h1>

<div className="flex flex-col gap-6">
{/* Transaction Settings Section */}
<div className="flex flex-col gap-4">
<h2 className="text font-monospace text-secondary">Transaction Settings</h2>

Expand Down Expand Up @@ -78,7 +74,6 @@ export default function SettingsPage() {
</div>
</div>

{/* Display Settings Section */}
<div className="flex flex-col gap-4 pt-4">
<h2 className="text font-monospace text-secondary">Display Settings</h2>

Expand Down Expand Up @@ -106,12 +101,10 @@ export default function SettingsPage() {
</div>
</div>

{/* Filter Settings Section */}
<div className="flex flex-col gap-4 pt-4">
<h2 className="text font-monospace text-secondary">Filter Settings</h2>

<div className="bg-surface flex flex-col gap-6 rounded p-6">
{/* Group related settings with a subtle separator */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-medium text-primary">Show Unknown Tokens</h3>
Expand Down Expand Up @@ -171,7 +164,32 @@ export default function SettingsPage() {
</div>
</div>

{/* Trusted Vaults Section */}
<div className="flex flex-col gap-4 pt-4">
<div className="flex items-center gap-2">
<h2 className="text font-monospace text-secondary">Trending Markets</h2>
<span className="rounded-sm bg-orange-500/20 px-2 py-0.5 text-xs text-orange-500">Beta</span>
</div>

<div className="bg-surface rounded p-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-medium text-primary">Configure Trending Criteria</h3>
<p className="text-sm text-secondary">
Define thresholds for market flow metrics to identify trending markets. Markets meeting all criteria will show a fire
indicator.
</p>
</div>
<Button
size="sm"
variant="default"
onClick={() => openModal('trendingSettings', {})}
>
Configure
</Button>
</div>
</div>
</div>

<div className="flex flex-col gap-4 pt-4">
<h2 className="text font-monospace text-secondary">Trusted Vaults</h2>

Expand All @@ -193,7 +211,6 @@ export default function SettingsPage() {
</Button>
</div>

{/* Display trusted vault icons */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{mounted ? (
Expand Down Expand Up @@ -230,7 +247,6 @@ export default function SettingsPage() {
</div>
</div>

{/* Blacklisted Markets Section */}
<div className="flex flex-col gap-4 pt-4">
<h2 className="text font-monospace text-secondary">Blacklisted Markets</h2>

Expand All @@ -253,7 +269,6 @@ export default function SettingsPage() {
</div>
</div>

{/* Advanced Section */}
<div className="flex flex-col gap-4 pt-4">
<AdvancedRpcSettings />
</div>
Expand Down
29 changes: 15 additions & 14 deletions src/data-sources/morpho-api/liquidations.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -76,40 +83,34 @@ 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}`);
}

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;
};
18 changes: 11 additions & 7 deletions src/data-sources/subgraph/liquidations.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -25,9 +33,6 @@ export const fetchSubgraphLiquidatedMarketKeys = async (network: SupportedNetwor
}

const liquidatedKeys = new Set<string>();

// Apply the same base filters as fetchSubgraphMarkets
// paginate until the API returns < pageSize items
const pageSize = 1000;
let skip = 0;
while (true) {
Expand Down Expand Up @@ -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
Expand Down
Loading