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
158 changes: 158 additions & 0 deletions app/admin/stats-v2/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client';

/**
* Stats V2 Dashboard (Experimental)
*
* This page uses a new cross-chain indexer API that provides Monarch transaction
* data across all chains with a single API call.
*
* NOTE: This API is experimental and may be reverted due to cost concerns.
* The old stats page at /admin/stats should be kept as a fallback.
*
* Features:
* - Cross-chain volume aggregation
* - Volume breakdown by chain
* - Supply and withdraw transaction tables
* - ETH/BTC price estimation for USD values
*/

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import ButtonGroup from '@/components/ui/button-group';
import { Spinner } from '@/components/ui/spinner';
import Header from '@/components/layout/header/Header';
import { PasswordGate } from '@/features/admin-v2/components/password-gate';
import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards';
import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart';
import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart';
import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table';
import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions';
import { useAdminAuth } from '@/stores/useAdminAuth';

function StatsV2Content() {
const [timeframe, setTimeframe] = useState<TimeFrame>('30D');
const { logout } = useAdminAuth();

const {
transactions,
supplies,
withdraws,
chainStats,
dailyVolumes,
totalSupplyVolumeUsd,
totalWithdrawVolumeUsd,
totalVolumeUsd,
isLoading,
error,
} = useMonarchTransactions(timeframe);

const timeframeOptions = [
{ key: '1D', label: '1D', value: '1D' },
{ key: '7D', label: '7D', value: '7D' },
{ key: '30D', label: '30D', value: '30D' },
{ key: '90D', label: '90D', value: '90D' },
{ key: 'ALL', label: 'ALL', value: 'ALL' },
];

if (error) {
return (
<div className="flex w-full flex-col font-inter">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-6 text-center">
<h2 className="font-zen text-lg text-red-500">Error Loading Data</h2>
<p className="mt-2 text-sm text-secondary">{error.message}</p>
<Button
onClick={() => window.location.reload()}
className="mt-4"
>
Retry
</Button>
</div>
</div>
</div>
);
}

return (
<div className="flex w-full flex-col font-inter">
<Header />
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="font-zen text-2xl font-bold">Monarch Stats</h1>
</div>
</div>
<div className="flex items-center gap-4 font-zen">
<ButtonGroup
options={timeframeOptions}
value={timeframe}
onChange={(value) => setTimeframe(value as TimeFrame)}
size="sm"
variant="default"
/>
<Button
variant="default"
onClick={() => void logout()}
size="sm"
>
Logout
</Button>
</div>
</div>

{isLoading ? (
<div className="flex h-64 w-full items-center justify-center">
<Spinner size={40} />
</div>
) : (
<div className="space-y-6">
{/* Overview Cards */}
<StatsOverviewCards
totalSupplyVolumeUsd={totalSupplyVolumeUsd}
totalWithdrawVolumeUsd={totalWithdrawVolumeUsd}
totalVolumeUsd={totalVolumeUsd}
supplyCount={supplies.length}
withdrawCount={withdraws.length}
chainStats={chainStats}
/>

{/* Charts Grid */}
<div className="grid gap-6 lg:grid-cols-1">
{/* Aggregated Volume Chart */}
<StatsVolumeChart
dailyVolumes={dailyVolumes}
totalSupplyVolumeUsd={totalSupplyVolumeUsd}
totalWithdrawVolumeUsd={totalWithdrawVolumeUsd}
isLoading={isLoading}
/>

{/* Chain Breakdown Chart */}
<ChainVolumeChart
dailyVolumes={dailyVolumes}
chainStats={chainStats}
isLoading={isLoading}
/>
</div>

{/* Transactions Table */}
<StatsTransactionsTable
transactions={transactions}
isLoading={isLoading}
/>
</div>
)}
</div>
</div>
);
}

export default function StatsV2Page() {
return (
<PasswordGate>
<StatsV2Content />
</PasswordGate>
);
}
81 changes: 81 additions & 0 deletions app/api/admin/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { type NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

/**
* Admin authentication endpoint
*
* POST: Login - validates password, sets httpOnly cookie
* DELETE: Logout - clears the cookie
*
* Environment variable (server-side only):
* - ADMIN_V2_PASSWORD_HASH: Expected password hash
*
* To generate a hash, run in Node:
* node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))"
*/

const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH;
const COOKIE_NAME = 'monarch_admin_session';

function hashPassword(password: string): string {
let hash = 0;
for (const char of password) {
const charCode = char.charCodeAt(0);
hash = (hash << 5) - hash + charCode;
hash &= hash;
}
return hash.toString(16);
}
Comment thread
antoncoding marked this conversation as resolved.

export async function POST(request: NextRequest) {
if (!EXPECTED_HASH) {
return NextResponse.json({ error: 'Authentication not configured' }, { status: 500 });
}

try {
const { password } = await request.json();

if (!password || typeof password !== 'string') {
return NextResponse.json({ error: 'Password required' }, { status: 400 });
}

const hash = hashPassword(password);

if (hash !== EXPECTED_HASH) {
await new Promise((resolve) => setTimeout(resolve, 200));
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}

// Set httpOnly cookie - client can't read this via JS
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, hash, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/admin',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
Comment on lines +49 to +57
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 | 🟡 Minor

Cookie stores the password hash directly.

If an attacker obtains ADMIN_V2_PASSWORD_HASH (e.g., from logs, env leak), they can forge valid session cookies. Consider using a separate session token or signing the cookie value with an additional secret.

🤖 Prompt for AI Agents
In `@app/api/admin/auth/route.ts` around lines 49 - 57, The cookie is currently
storing the raw password hash via cookieStore.set(COOKIE_NAME, hash, ...) which
lets anyone with ADMIN_V2_PASSWORD_HASH forge sessions; instead stop storing the
hash directly: generate a server-side session token (secure random ID or signed
value) and store an association to the authenticated user/server-side expiry (in
memory, DB, or using a signed JWT/HMAC) and set cookieStore.set(COOKIE_NAME,
sessionToken, ...) where sessionToken is either a random UUID mapped to a server
session record or a cryptographically signed value using a separate secret
(e.g., SESSION_SECRET); update authentication checks to validate the token
against the session store or verify the signature instead of comparing cookie to
ADMIN_V2_PASSWORD_HASH.


return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ success: true });
}

export async function GET() {
// Check if user is authenticated
const cookieStore = await cookies();
const session = cookieStore.get(COOKIE_NAME);

if (!session?.value || session.value !== EXPECTED_HASH) {
return NextResponse.json({ authenticated: false });
}

return NextResponse.json({ authenticated: true });
}
53 changes: 53 additions & 0 deletions app/api/admin/monarch-indexer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

/**
* Proxy API route for Monarch Indexer
*
* Validates auth via httpOnly cookie, then proxies to the actual indexer.
* The real endpoint URL is never exposed to the client.
*
* Environment variables (server-side only):
* - MONARCH_INDEXER_ENDPOINT: The actual GraphQL endpoint URL
* - ADMIN_V2_PASSWORD_HASH: Expected password hash (for cookie validation)
*/

const INDEXER_ENDPOINT = process.env.MONARCH_INDEXER_ENDPOINT;
const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH;
const COOKIE_NAME = 'monarch_admin_session';

export async function POST(request: NextRequest) {
// Validate auth cookie
const cookieStore = await cookies();
const session = cookieStore.get(COOKIE_NAME);

if (!EXPECTED_HASH || !session?.value || session.value !== EXPECTED_HASH) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
Comment thread
antoncoding marked this conversation as resolved.

if (!INDEXER_ENDPOINT) {
return NextResponse.json({ error: 'Indexer endpoint not configured' }, { status: 500 });
}

try {
const body = await request.json();

Comment thread
antoncoding marked this conversation as resolved.
const response = await fetch(INDEXER_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
Comment on lines +35 to +41
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

Add a timeout to the upstream fetch.

Unbounded external calls can hang a request thread and pile up.

Proposed fix
-    const response = await fetch(INDEXER_ENDPOINT, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-      },
-      body: JSON.stringify(body),
-    });
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 10_000);
+    let response: Response;
+    try {
+      response = await fetch(INDEXER_ENDPOINT, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(body),
+        signal: controller.signal,
+      });
+    } finally {
+      clearTimeout(timeout);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(INDEXER_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let response: Response;
try {
response = await fetch(INDEXER_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
🤖 Prompt for AI Agents
In `@app/api/admin/monarch-indexer/route.ts` around lines 35 - 41, The fetch call
to INDEXER_ENDPOINT is unbounded and needs a timeout: create an AbortController,
pass controller.signal into the existing fetch options (alongside
method/headers/body), set a timeout (e.g. TIMEOUT_MS constant) that calls
controller.abort() after the delay, clear that timer immediately after the fetch
completes, and handle aborted requests (detect AbortError) so the route returns
an appropriate error response; update the code around the response = await
fetch(INDEXER_ENDPOINT, {...}) and reference INDEXER_ENDPOINT, response, and
body when adding the controller and timeout logic.


if (!response.ok) {
return NextResponse.json({ error: `Indexer request failed: ${response.status}` }, { status: response.status });
}

const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Monarch indexer proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
5 changes: 5 additions & 0 deletions src/constants/chartColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type ChartColorConfig = {
type ChartPaletteConfig = {
supply: ChartColorConfig;
borrow: ChartColorConfig;
withdraw: ChartColorConfig;
apyAtTarget: ChartColorConfig;
risk: ChartColorConfig;
pie: readonly string[];
Expand All @@ -33,6 +34,7 @@ export const CHART_PALETTES: Record<ChartPaletteName, ChartPaletteConfig> = {
classic: {
supply: createColorConfig('#4E79A7'), // Blue
borrow: createColorConfig('#59A14F'), // Green
withdraw: createColorConfig('#F28E2B'), // Orange
apyAtTarget: createColorConfig('#EDC948'), // Yellow
risk: createColorConfig('#E15759'), // Red
pie: [
Expand All @@ -53,6 +55,7 @@ export const CHART_PALETTES: Record<ChartPaletteName, ChartPaletteConfig> = {
earth: {
supply: createColorConfig('#B26333'), // Burnt sienna
borrow: createColorConfig('#89392D'), // Rust/terracotta
withdraw: createColorConfig('#CD853F'), // Peru
apyAtTarget: createColorConfig('#A48A7A'), // Taupe
risk: createColorConfig('#411E1D'), // Dark maroon
pie: [
Expand All @@ -73,6 +76,7 @@ export const CHART_PALETTES: Record<ChartPaletteName, ChartPaletteConfig> = {
forest: {
supply: createColorConfig('#223A30'), // Dark forest green
borrow: createColorConfig('#8DA99D'), // Sage green
withdraw: createColorConfig('#6B8E23'), // Olive drab
apyAtTarget: createColorConfig('#727472'), // Medium gray
risk: createColorConfig('#7A7C7B'), // Gray
pie: [
Expand All @@ -93,6 +97,7 @@ export const CHART_PALETTES: Record<ChartPaletteName, ChartPaletteConfig> = {
simple: {
supply: createColorConfig('#2563EB'), // Blue-600
borrow: createColorConfig('#16A34A'), // Green-600
withdraw: createColorConfig('#F97316'), // Orange-500
apyAtTarget: createColorConfig('#EAB308'), // Yellow-500
risk: createColorConfig('#DC2626'), // Red-600
pie: [
Expand Down
44 changes: 44 additions & 0 deletions src/data-sources/monarch-indexer/fetchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Monarch Indexer GraphQL Fetcher
*
* Calls our internal API route which proxies to the actual indexer.
* Auth is handled via httpOnly cookie (set during login).
*
* NOTE: This API is experimental and may be reverted due to cost concerns.
* The old stats page at /admin/stats should be kept as a fallback.
*/

type GraphQLVariables = Record<string, unknown>;

/**
* Fetches data from the monarch indexer via our API proxy.
* Requires valid session cookie (set via /api/admin/auth).
*/
export async function monarchIndexerFetcher<T>(query: string, variables?: GraphQLVariables): Promise<T> {
const response = await fetch('/api/admin/monarch-indexer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin', // Include cookies
body: JSON.stringify({
query,
variables,
}),
});

if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized - please log in again');
}
throw new Error(`Monarch Indexer request failed: ${response.status} ${response.statusText}`);
}

const result = await response.json();

if (result.errors) {
throw new Error(`Monarch Indexer GraphQL error: ${JSON.stringify(result.errors)}`);
}

return result as T;
}
17 changes: 17 additions & 0 deletions src/data-sources/monarch-indexer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Monarch Indexer Data Source
*
* A new cross-chain indexer API for fetching Monarch transactions
* across all chains with a single API call.
*
* NOTE: This API is experimental and may be reverted due to cost concerns.
* The old stats page at /admin/stats should be kept as a fallback.
*/

export { monarchIndexerFetcher } from './fetchers';
export {
fetchMonarchTransactions,
type MonarchSupplyTransaction,
type MonarchWithdrawTransaction,
type TimeRange,
} from './transactions';
Loading