diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml
new file mode 100644
index 00000000..00fd9658
--- /dev/null
+++ b/.github/workflows/release-manual.yml
@@ -0,0 +1,85 @@
+name: Release Manual
+
+on:
+ workflow_dispatch:
+ inputs:
+ deploy_production:
+ description: Deploy production on Vercel
+ required: true
+ default: true
+ type: boolean
+
+permissions:
+ contents: read
+
+jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9.15.0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Type check
+ run: pnpm typecheck
+
+ release:
+ if: ${{ inputs.deploy_production }}
+ needs: preflight
+ runs-on: ubuntu-latest
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+ PRODUCTION_HEALTHCHECK_URL: ${{ secrets.PRODUCTION_HEALTHCHECK_URL }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Validate deploy secrets
+ run: |
+ missing=0
+ for key in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
+ if [ -z "${!key}" ]; then
+ echo "::error::Missing ${key} secret"
+ missing=1
+ fi
+ done
+ if [ "${missing}" -ne 0 ]; then
+ exit 1
+ fi
+
+ - name: Install Vercel CLI
+ run: npm install -g vercel@latest
+
+ - name: Pull project settings
+ run: vercel pull --yes --environment=production --token="${VERCEL_TOKEN}"
+
+ - name: Build deployment artifact
+ run: vercel build --prod --token="${VERCEL_TOKEN}"
+
+ - name: Deploy production artifact
+ run: vercel deploy --prebuilt --prod --yes --token="${VERCEL_TOKEN}"
+
+ - name: Smoke check
+ if: ${{ env.PRODUCTION_HEALTHCHECK_URL != '' }}
+ run: |
+ curl --fail --silent --show-error "${PRODUCTION_HEALTHCHECK_URL}"
diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/stats-v2-page-client.tsx
similarity index 89%
rename from app/admin/stats-v2/page.tsx
rename to app/admin/stats-v2/stats-v2-page-client.tsx
index 36c9d089..20c1ecf4 100644
--- a/app/admin/stats-v2/page.tsx
+++ b/app/admin/stats-v2/stats-v2-page-client.tsx
@@ -3,8 +3,8 @@
/**
* Stats V2 Dashboard (Experimental)
*
- * This page uses the shared Monarch API to provide cross-chain Monarch
- * transaction data across all chains with a single GraphQL endpoint.
+ * 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.
@@ -25,9 +25,11 @@ 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 { StatsAttributionOverview } from '@/features/admin-v2/components/stats-attribution-overview';
import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table';
import { StatsAssetTable } from '@/features/admin-v2/components/stats-asset-table';
import { StatsMarketTable } from '@/features/admin-v2/components/stats-market-table';
+import { useAttributionScoreboard } from '@/hooks/useAttributionScoreboard';
import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions';
import { useAdminAuth } from '@/stores/useAdminAuth';
@@ -47,6 +49,7 @@ function StatsV2Content() {
isLoading,
error,
} = useMonarchTransactions(timeframe);
+ const attribution = useAttributionScoreboard(timeframe);
const timeframeOptions = [
{ key: '1D', label: '1D', value: '1D' },
@@ -121,6 +124,13 @@ function StatsV2Content() {
chainStats={chainStats}
/>
+
+
{/* Charts Grid */}
{/* Aggregated Volume Chart */}
diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx
index d30a3336..0a904f61 100644
--- a/app/admin/stats/page.tsx
+++ b/app/admin/stats/page.tsx
@@ -1,369 +1,16 @@
-'use client';
+import { Suspense } from 'react';
+import StatsPageClient from './stats-page-client';
-import { useState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import Image from 'next/image';
-import ButtonGroup from '@/components/ui/button-group';
-import { Spinner } from '@/components/ui/spinner';
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuCheckboxItem,
-} from '@/components/ui/dropdown-menu';
-import { TokenIcon } from '@/components/shared/token-icon';
-import { useProcessedMarkets } from '@/hooks/useProcessedMarkets';
-import { fetchAllStatistics } from '@/services/statsService';
-import { SupportedNetworks, getNetworkImg, getNetworkName, getViemChain } from '@/utils/networks';
-import type { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils';
-import type { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens';
-import { findToken as findTokenStatic } from '@/utils/tokens';
-import { AssetMetricsTable } from '@/features/admin/components/asset-metrics-table';
-import { StatsOverviewCards } from '@/features/admin/components/stats-overview-cards';
-import { TransactionsTable } from '@/features/admin/components/transactions-table';
-
-const getAPIEndpoint = (network: SupportedNetworks) => {
- switch (network) {
- case SupportedNetworks.Base:
- return 'https://api.studio.thegraph.com/query/94369/monarch-metrics/version/latest';
- case SupportedNetworks.Mainnet:
- return 'https://api.studio.thegraph.com/query/110397/monarch-metrics-mainnet/version/latest';
- default:
- return undefined;
- }
-};
+const LoadingFallback = () => (
+
+
Loading statistics...
+
+);
export default function StatsPage() {
- const [timeframe, setTimeframe] = useState
('30D');
- const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base);
- const [isLoading, setIsLoading] = useState(true);
- const [selectedLoanAssets, setSelectedLoanAssets] = useState([]);
- const [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]);
- const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]);
- const [stats, setStats] = useState<{
- platformStats: PlatformStats;
- assetMetrics: AssetVolumeData[];
- transactions: Transaction[];
- }>({
- platformStats: {
- uniqueUsers: 0,
- uniqueUsersDelta: 0,
- totalTransactions: 0,
- totalTransactionsDelta: 0,
- supplyCount: 0,
- supplyCountDelta: 0,
- withdrawCount: 0,
- withdrawCountDelta: 0,
- activeMarkets: 0,
- },
- assetMetrics: [],
- transactions: [],
- });
-
- const { allMarkets } = useProcessedMarkets();
-
- useEffect(() => {
- const loadStats = async () => {
- setIsLoading(true);
- try {
- console.log(`Fetching statistics for timeframe: ${timeframe}, network: ${getNetworkName(selectedNetwork) ?? 'Unknown'}`);
- const startTime = performance.now();
-
- // Get API endpoint for the selected network
- const apiEndpoint = getAPIEndpoint(selectedNetwork);
- if (!apiEndpoint) {
- throw new Error(`Unsupported network: ${selectedNetwork}`);
- }
- console.log(`Using API endpoint: ${apiEndpoint}`);
-
- const allStats = await fetchAllStatistics(selectedNetwork, apiEndpoint, timeframe);
-
- const endTime = performance.now();
- console.log(`Statistics fetched in ${endTime - startTime}ms:`, allStats);
-
- console.log('Platform stats:', allStats.platformStats);
- console.log('Asset metrics count:', allStats.assetMetrics.length);
-
- setStats({
- platformStats: allStats.platformStats,
- assetMetrics: allStats.assetMetrics,
- transactions: allStats.transactions,
- });
- } catch (error) {
- console.error('Error loading stats:', error);
- } finally {
- setIsLoading(false);
- }
- };
-
- void loadStats();
- }, [timeframe, selectedNetwork]);
-
- // Extract unique loan assets from transactions
- useEffect(() => {
- if (stats.transactions.length === 0) {
- setUniqueLoanAssets([]);
- return;
- }
-
- const loanAssetsMap = new Map();
-
- stats.transactions.forEach((tx) => {
- // Extract from supplies
- tx.supplies?.forEach((supply) => {
- if (supply.market?.loan) {
- const address = supply.market.loan.toLowerCase();
- if (!loanAssetsMap.has(address)) {
- const token = findTokenStatic(address, selectedNetwork);
- if (token) {
- loanAssetsMap.set(address, {
- address,
- symbol: token.symbol,
- decimals: token.decimals,
- });
- }
- }
- }
- });
-
- // Extract from withdrawals
- tx.withdrawals?.forEach((withdrawal) => {
- if (withdrawal.market?.loan) {
- const address = withdrawal.market.loan.toLowerCase();
- if (!loanAssetsMap.has(address)) {
- const token = findTokenStatic(address, selectedNetwork);
- if (token) {
- loanAssetsMap.set(address, {
- address,
- symbol: token.symbol,
- decimals: token.decimals,
- });
- }
- }
- }
- });
- });
-
- // Convert to ERC20Token format
- const tokens: ERC20Token[] = Array.from(loanAssetsMap.values()).map((asset) => {
- const fullToken = findTokenStatic(asset.address, selectedNetwork);
- return {
- symbol: asset.symbol,
- img: fullToken?.img,
- decimals: asset.decimals,
- networks: [
- {
- chain: getViemChain(selectedNetwork),
- address: asset.address,
- },
- ],
- source: 'local' as TokenSource,
- };
- });
-
- setUniqueLoanAssets(tokens);
- }, [stats.transactions, selectedNetwork]);
-
- 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' },
- ];
-
- // Get network image for selected network with fallback
- const selectedNetworkImg = getNetworkImg(selectedNetwork);
- // Get network names
- const baseNetworkName = getNetworkName(SupportedNetworks.Base);
- const mainnetNetworkName = getNetworkName(SupportedNetworks.Mainnet);
-
return (
-
-
-
Platform Statistics
-
- {/* Network selector */}
-
-
-
-
-
- setSelectedNetwork(SupportedNetworks.Base)}
- startContent={
- getNetworkImg(SupportedNetworks.Base) && (
-
- )
- }
- className="py-2"
- >
- {baseNetworkName}
-
- setSelectedNetwork(SupportedNetworks.Mainnet)}
- startContent={
- getNetworkImg(SupportedNetworks.Mainnet) && (
-
- )
- }
- className="py-2"
- >
- {mainnetNetworkName}
-
-
-
-
- {/* Timeframe selector */}
-
setTimeframe(value as TimeFrame)}
- size="sm"
- variant="default"
- />
-
-
-
- {isLoading ? (
-
-
-
- ) : (
-
-
-
-
- {/* Transaction Filters */}
-
- {/* Loan Asset Filter */}
-
-
-
-
-
- {uniqueLoanAssets.map((asset) => {
- const assetKey = asset.networks.map((n) => `${n.address}-${n.chain.id}`).join('|');
- const firstNetwork = asset.networks[0];
-
- return (
- {
- if (checked) {
- setSelectedLoanAssets([...selectedLoanAssets, assetKey]);
- } else {
- setSelectedLoanAssets(selectedLoanAssets.filter((k) => k !== assetKey));
- }
- }}
- className="py-2"
- startContent={
-
- }
- >
- {asset.symbol}
-
- );
- })}
-
-
-
- {/* Side Filter */}
-
-
-
-
-
- {
- if (checked) {
- setSelectedSides([...selectedSides, 'Supply']);
- } else {
- setSelectedSides(selectedSides.filter((s) => s !== 'Supply'));
- }
- }}
- className="py-2"
- >
- Supply
-
- {
- if (checked) {
- setSelectedSides([...selectedSides, 'Withdraw']);
- } else {
- setSelectedSides(selectedSides.filter((s) => s !== 'Withdraw'));
- }
- }}
- className="py-2"
- >
- Withdraw
-
-
-
-
-
-
-
- )}
-
+ }>
+
+
);
}
diff --git a/app/admin/stats/stats-page-client.tsx b/app/admin/stats/stats-page-client.tsx
new file mode 100644
index 00000000..d30a3336
--- /dev/null
+++ b/app/admin/stats/stats-page-client.tsx
@@ -0,0 +1,369 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import Image from 'next/image';
+import ButtonGroup from '@/components/ui/button-group';
+import { Spinner } from '@/components/ui/spinner';
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+} from '@/components/ui/dropdown-menu';
+import { TokenIcon } from '@/components/shared/token-icon';
+import { useProcessedMarkets } from '@/hooks/useProcessedMarkets';
+import { fetchAllStatistics } from '@/services/statsService';
+import { SupportedNetworks, getNetworkImg, getNetworkName, getViemChain } from '@/utils/networks';
+import type { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils';
+import type { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens';
+import { findToken as findTokenStatic } from '@/utils/tokens';
+import { AssetMetricsTable } from '@/features/admin/components/asset-metrics-table';
+import { StatsOverviewCards } from '@/features/admin/components/stats-overview-cards';
+import { TransactionsTable } from '@/features/admin/components/transactions-table';
+
+const getAPIEndpoint = (network: SupportedNetworks) => {
+ switch (network) {
+ case SupportedNetworks.Base:
+ return 'https://api.studio.thegraph.com/query/94369/monarch-metrics/version/latest';
+ case SupportedNetworks.Mainnet:
+ return 'https://api.studio.thegraph.com/query/110397/monarch-metrics-mainnet/version/latest';
+ default:
+ return undefined;
+ }
+};
+
+export default function StatsPage() {
+ const [timeframe, setTimeframe] = useState('30D');
+ const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base);
+ const [isLoading, setIsLoading] = useState(true);
+ const [selectedLoanAssets, setSelectedLoanAssets] = useState([]);
+ const [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]);
+ const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]);
+ const [stats, setStats] = useState<{
+ platformStats: PlatformStats;
+ assetMetrics: AssetVolumeData[];
+ transactions: Transaction[];
+ }>({
+ platformStats: {
+ uniqueUsers: 0,
+ uniqueUsersDelta: 0,
+ totalTransactions: 0,
+ totalTransactionsDelta: 0,
+ supplyCount: 0,
+ supplyCountDelta: 0,
+ withdrawCount: 0,
+ withdrawCountDelta: 0,
+ activeMarkets: 0,
+ },
+ assetMetrics: [],
+ transactions: [],
+ });
+
+ const { allMarkets } = useProcessedMarkets();
+
+ useEffect(() => {
+ const loadStats = async () => {
+ setIsLoading(true);
+ try {
+ console.log(`Fetching statistics for timeframe: ${timeframe}, network: ${getNetworkName(selectedNetwork) ?? 'Unknown'}`);
+ const startTime = performance.now();
+
+ // Get API endpoint for the selected network
+ const apiEndpoint = getAPIEndpoint(selectedNetwork);
+ if (!apiEndpoint) {
+ throw new Error(`Unsupported network: ${selectedNetwork}`);
+ }
+ console.log(`Using API endpoint: ${apiEndpoint}`);
+
+ const allStats = await fetchAllStatistics(selectedNetwork, apiEndpoint, timeframe);
+
+ const endTime = performance.now();
+ console.log(`Statistics fetched in ${endTime - startTime}ms:`, allStats);
+
+ console.log('Platform stats:', allStats.platformStats);
+ console.log('Asset metrics count:', allStats.assetMetrics.length);
+
+ setStats({
+ platformStats: allStats.platformStats,
+ assetMetrics: allStats.assetMetrics,
+ transactions: allStats.transactions,
+ });
+ } catch (error) {
+ console.error('Error loading stats:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ void loadStats();
+ }, [timeframe, selectedNetwork]);
+
+ // Extract unique loan assets from transactions
+ useEffect(() => {
+ if (stats.transactions.length === 0) {
+ setUniqueLoanAssets([]);
+ return;
+ }
+
+ const loanAssetsMap = new Map();
+
+ stats.transactions.forEach((tx) => {
+ // Extract from supplies
+ tx.supplies?.forEach((supply) => {
+ if (supply.market?.loan) {
+ const address = supply.market.loan.toLowerCase();
+ if (!loanAssetsMap.has(address)) {
+ const token = findTokenStatic(address, selectedNetwork);
+ if (token) {
+ loanAssetsMap.set(address, {
+ address,
+ symbol: token.symbol,
+ decimals: token.decimals,
+ });
+ }
+ }
+ }
+ });
+
+ // Extract from withdrawals
+ tx.withdrawals?.forEach((withdrawal) => {
+ if (withdrawal.market?.loan) {
+ const address = withdrawal.market.loan.toLowerCase();
+ if (!loanAssetsMap.has(address)) {
+ const token = findTokenStatic(address, selectedNetwork);
+ if (token) {
+ loanAssetsMap.set(address, {
+ address,
+ symbol: token.symbol,
+ decimals: token.decimals,
+ });
+ }
+ }
+ }
+ });
+ });
+
+ // Convert to ERC20Token format
+ const tokens: ERC20Token[] = Array.from(loanAssetsMap.values()).map((asset) => {
+ const fullToken = findTokenStatic(asset.address, selectedNetwork);
+ return {
+ symbol: asset.symbol,
+ img: fullToken?.img,
+ decimals: asset.decimals,
+ networks: [
+ {
+ chain: getViemChain(selectedNetwork),
+ address: asset.address,
+ },
+ ],
+ source: 'local' as TokenSource,
+ };
+ });
+
+ setUniqueLoanAssets(tokens);
+ }, [stats.transactions, selectedNetwork]);
+
+ 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' },
+ ];
+
+ // Get network image for selected network with fallback
+ const selectedNetworkImg = getNetworkImg(selectedNetwork);
+ // Get network names
+ const baseNetworkName = getNetworkName(SupportedNetworks.Base);
+ const mainnetNetworkName = getNetworkName(SupportedNetworks.Mainnet);
+
+ return (
+
+
+
Platform Statistics
+
+ {/* Network selector */}
+
+
+
+
+
+ setSelectedNetwork(SupportedNetworks.Base)}
+ startContent={
+ getNetworkImg(SupportedNetworks.Base) && (
+
+ )
+ }
+ className="py-2"
+ >
+ {baseNetworkName}
+
+ setSelectedNetwork(SupportedNetworks.Mainnet)}
+ startContent={
+ getNetworkImg(SupportedNetworks.Mainnet) && (
+
+ )
+ }
+ className="py-2"
+ >
+ {mainnetNetworkName}
+
+
+
+
+ {/* Timeframe selector */}
+
setTimeframe(value as TimeFrame)}
+ size="sm"
+ variant="default"
+ />
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+ {/* Transaction Filters */}
+
+ {/* Loan Asset Filter */}
+
+
+
+
+
+ {uniqueLoanAssets.map((asset) => {
+ const assetKey = asset.networks.map((n) => `${n.address}-${n.chain.id}`).join('|');
+ const firstNetwork = asset.networks[0];
+
+ return (
+ {
+ if (checked) {
+ setSelectedLoanAssets([...selectedLoanAssets, assetKey]);
+ } else {
+ setSelectedLoanAssets(selectedLoanAssets.filter((k) => k !== assetKey));
+ }
+ }}
+ className="py-2"
+ startContent={
+
+ }
+ >
+ {asset.symbol}
+
+ );
+ })}
+
+
+
+ {/* Side Filter */}
+
+
+
+
+
+ {
+ if (checked) {
+ setSelectedSides([...selectedSides, 'Supply']);
+ } else {
+ setSelectedSides(selectedSides.filter((s) => s !== 'Supply'));
+ }
+ }}
+ className="py-2"
+ >
+ Supply
+
+ {
+ if (checked) {
+ setSelectedSides([...selectedSides, 'Withdraw']);
+ } else {
+ setSelectedSides(selectedSides.filter((s) => s !== 'Withdraw'));
+ }
+ }}
+ className="py-2"
+ >
+ Withdraw
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/api/monarch/attribution/scoreboard/route.ts b/app/api/monarch/attribution/scoreboard/route.ts
new file mode 100644
index 00000000..73fdbee1
--- /dev/null
+++ b/app/api/monarch/attribution/scoreboard/route.ts
@@ -0,0 +1,41 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { MONARCH_API_KEY, getMonarchUrl } from '../../utils';
+import { reportApiRouteError } from '@/utils/sentry-server';
+
+export async function GET(req: NextRequest) {
+ if (!MONARCH_API_KEY) {
+ console.error('[Monarch Attribution API] Missing MONARCH_API_KEY');
+ return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
+ }
+
+ const searchParams = req.nextUrl.searchParams;
+
+ try {
+ const url = getMonarchUrl('/v1/attribution/scoreboard');
+ for (const key of ['start_ts', 'end_ts', 'chain_id']) {
+ 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 Attribution API] Scoreboard error:', response.status, errorText);
+ return NextResponse.json({ error: 'Failed to fetch attribution scoreboard' }, { status: response.status });
+ }
+
+ return NextResponse.json(await response.json());
+ } catch (error) {
+ reportApiRouteError(error, {
+ route: '/api/monarch/attribution/scoreboard',
+ method: 'GET',
+ status: 500,
+ });
+ console.error('[Monarch Attribution API] Failed to fetch scoreboard:', error);
+ return NextResponse.json({ error: 'Failed to fetch attribution scoreboard' }, { status: 500 });
+ }
+}
diff --git a/app/api/monarch/attribution/touchpoint/route.ts b/app/api/monarch/attribution/touchpoint/route.ts
new file mode 100644
index 00000000..aaf2476b
--- /dev/null
+++ b/app/api/monarch/attribution/touchpoint/route.ts
@@ -0,0 +1,41 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { MONARCH_API_KEY, getMonarchUrl } from '../../utils';
+import { reportApiRouteError } from '@/utils/sentry-server';
+
+export async function POST(req: NextRequest) {
+ if (!MONARCH_API_KEY) {
+ console.error('[Monarch Attribution API] Missing MONARCH_API_KEY');
+ return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
+ }
+
+ try {
+ const body = await req.json();
+ const url = getMonarchUrl('/v1/attribution/touchpoint');
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': MONARCH_API_KEY,
+ },
+ body: JSON.stringify(body),
+ cache: 'no-store',
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('[Monarch Attribution API] Touchpoint error:', response.status, errorText);
+ return NextResponse.json({ error: 'Failed to save attribution touchpoint' }, { status: response.status });
+ }
+
+ return NextResponse.json(await response.json());
+ } catch (error) {
+ reportApiRouteError(error, {
+ route: '/api/monarch/attribution/touchpoint',
+ method: 'POST',
+ status: 500,
+ });
+ console.error('[Monarch Attribution API] Failed to save touchpoint:', error);
+ return NextResponse.json({ error: 'Failed to save attribution touchpoint' }, { status: 500 });
+ }
+}
diff --git a/public/imgs/curators/avantgarde.svg b/public/imgs/curators/avantgarde.svg
index dad9ad20..1587fe2c 100644
--- a/public/imgs/curators/avantgarde.svg
+++ b/public/imgs/curators/avantgarde.svg
@@ -1,9 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/clearstar.svg b/public/imgs/curators/clearstar.svg
index 2e212298..e7e08d50 100644
--- a/public/imgs/curators/clearstar.svg
+++ b/public/imgs/curators/clearstar.svg
@@ -1,9 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/felix.svg b/public/imgs/curators/felix.svg
index eeab1a9d..5d6e16cb 100644
--- a/public/imgs/curators/felix.svg
+++ b/public/imgs/curators/felix.svg
@@ -1,9 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/gauntlet.svg b/public/imgs/curators/gauntlet.svg
index f7f6b5c8..c6d3d5a4 100644
--- a/public/imgs/curators/gauntlet.svg
+++ b/public/imgs/curators/gauntlet.svg
@@ -1,6 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/spark.svg b/public/imgs/curators/spark.svg
index 50d1c32a..4fc5b417 100644
--- a/public/imgs/curators/spark.svg
+++ b/public/imgs/curators/spark.svg
@@ -1,9 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/steakhouse.svg b/public/imgs/curators/steakhouse.svg
index 744fb550..b28c8d9d 100644
--- a/public/imgs/curators/steakhouse.svg
+++ b/public/imgs/curators/steakhouse.svg
@@ -1,11 +1 @@
-
+
\ No newline at end of file
diff --git a/public/imgs/curators/unknown.svg b/public/imgs/curators/unknown.svg
index 4bdfc00e..e4960998 100644
--- a/public/imgs/curators/unknown.svg
+++ b/public/imgs/curators/unknown.svg
@@ -1,4 +1 @@
-
+
\ No newline at end of file
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 00000000..aba562ed
Binary files /dev/null and b/public/logo.png differ
diff --git a/public/posts/2026-03-06-leverage/erc4626-route.png b/public/posts/2026-03-06-leverage/erc4626-route.png
index 4cb2703b..a5fdc31f 100644
Binary files a/public/posts/2026-03-06-leverage/erc4626-route.png and b/public/posts/2026-03-06-leverage/erc4626-route.png differ
diff --git a/public/posts/2026-03-06-leverage/leverage-overview.png b/public/posts/2026-03-06-leverage/leverage-overview.png
index 5a697668..1200b959 100644
Binary files a/public/posts/2026-03-06-leverage/leverage-overview.png and b/public/posts/2026-03-06-leverage/leverage-overview.png differ
diff --git a/src/OnchainProviders.tsx b/src/OnchainProviders.tsx
index 1b5abbb0..6371844b 100644
--- a/src/OnchainProviders.tsx
+++ b/src/OnchainProviders.tsx
@@ -7,6 +7,7 @@ import * as Sentry from '@sentry/nextjs';
import { useConnection, WagmiProvider } from 'wagmi';
import { wagmiAdapter } from '@/config/appkit';
import { createWagmiConfig } from '@/store/createWagmiConfig';
+import { AttributionProvider } from './components/providers/AttributionProvider';
import { ConnectRedirectProvider } from './components/providers/ConnectRedirectProvider';
import { CustomRpcProvider, useCustomRpcContext } from './components/providers/CustomRpcProvider';
@@ -46,7 +47,9 @@ function WagmiConfigProvider({ children }: Props) {
reconnectOnMount
>
- {children}
+
+ {children}
+
);
}
diff --git a/src/components/layout/header/Navbar.tsx b/src/components/layout/header/Navbar.tsx
index cf2e9970..3540ad2b 100644
--- a/src/components/layout/header/Navbar.tsx
+++ b/src/components/layout/header/Navbar.tsx
@@ -15,7 +15,6 @@ import { useConnection } from 'wagmi';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useModal } from '@/hooks/useModal';
import { EXTERNAL_LINKS } from '@/utils/external';
-import logo from '../../imgs/logo.png';
import AccountConnect from './AccountConnect';
import { TransactionIndicator } from './TransactionIndicator';
@@ -58,8 +57,9 @@ export function NavbarTitle() {
return (
): Promise
{
+ await fetch('/api/monarch/attribution/touchpoint', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+}
+
+export function AttributionProvider({ children }: AttributionProviderProps) {
+ const pathname = usePathname();
+
+ const touchpoint = useAttributionStore((state) => state.touchpoint);
+ const lastSubmittedWallet = useAttributionStore((state) => state.lastSubmittedWallet);
+ const lastSubmittedAt = useAttributionStore((state) => state.lastSubmittedAt);
+ const captureFromUrl = useAttributionStore((state) => state.captureFromUrl);
+ const markSubmittedWallet = useAttributionStore((state) => state.markSubmittedWallet);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ captureFromUrl(params, pathname);
+ }, [captureFromUrl, pathname]);
+
+ useConnectionEffect({
+ onConnect: ({ address, chainId, isReconnected }) => {
+ const normalizedWallet = address.toLowerCase();
+ const submittedRecently =
+ lastSubmittedWallet === normalizedWallet &&
+ typeof lastSubmittedAt === 'number' &&
+ Date.now() - lastSubmittedAt < RESUBMIT_WINDOW_MS;
+
+ if (submittedRecently) {
+ return;
+ }
+
+ void submitTouchpoint({
+ walletAddress: normalizedWallet,
+ chainId,
+ refCode: touchpoint?.refCode ?? undefined,
+ utmSource: touchpoint?.utmSource ?? undefined,
+ utmMedium: touchpoint?.utmMedium ?? undefined,
+ utmCampaign: touchpoint?.utmCampaign ?? undefined,
+ utmContent: touchpoint?.utmContent ?? undefined,
+ landingPath: touchpoint?.landingPath ?? pathname,
+ metadata: {
+ isReconnected,
+ },
+ })
+ .then(() => {
+ markSubmittedWallet(normalizedWallet);
+ })
+ .catch(() => {
+ // Keep silent; failures are surfaced in backend telemetry and retried on next connect.
+ });
+ },
+ });
+
+ return <>{children}>;
+}
diff --git a/src/features/admin-v2/components/stats-attribution-overview.tsx b/src/features/admin-v2/components/stats-attribution-overview.tsx
new file mode 100644
index 00000000..d115574b
--- /dev/null
+++ b/src/features/admin-v2/components/stats-attribution-overview.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { Card, CardBody } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { formatReadable } from '@/utils/balance';
+import type { AttributionScoreboardRow, AttributionScoreboardSummary } from '@/hooks/useAttributionScoreboard';
+
+type StatsAttributionOverviewProps = {
+ summary: AttributionScoreboardSummary;
+ breakdown: AttributionScoreboardRow[];
+ revenueBps: number;
+ isLoading: boolean;
+};
+
+type MiniStatCardProps = {
+ title: string;
+ value: string;
+ subtitle?: string;
+};
+
+function MiniStatCard({ title, value, subtitle }: MiniStatCardProps) {
+ return (
+
+
+ {title}
+ {value}
+ {subtitle && {subtitle}
}
+
+
+ );
+}
+
+function formatPercent(value: number): string {
+ return `${(value * 100).toFixed(1)}%`;
+}
+
+function formatPaybackDays(value: number | null): string {
+ if (value === null || !Number.isFinite(value)) return 'N/A';
+ return `${value.toFixed(1)}d`;
+}
+
+export function StatsAttributionOverview({ summary, breakdown, revenueBps, isLoading }: StatsAttributionOverviewProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
Attribution Breakdown
+
Source/medium/campaign cohorts with activation and economics.
+
+
+ {isLoading ? (
+
Loading attribution data...
+ ) : breakdown.length === 0 ? (
+
No attribution cohorts in selected window
+ ) : (
+
+
+
+ Source
+ Medium
+ Campaign
+ Ref Code
+ Leads
+ Activated
+ Activation Rate
+ Volume USD
+ Revenue USD
+ Payback
+
+
+
+ {breakdown.map((row) => (
+
+ {row.source}
+ {row.medium}
+ {row.campaign}
+ {row.refCode}
+ {row.qualifiedLeads.toLocaleString()}
+ {row.activatedAccounts.toLocaleString()}
+ {formatPercent(row.activationRate)}
+ ${formatReadable(row.attributedVolumeUsd)}
+ ${formatReadable(row.attributedRevenueUsd)}
+ {formatPaybackDays(row.cacPaybackDays)}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/hooks/useAttributionScoreboard.ts b/src/hooks/useAttributionScoreboard.ts
new file mode 100644
index 00000000..27c38a88
--- /dev/null
+++ b/src/hooks/useAttributionScoreboard.ts
@@ -0,0 +1,91 @@
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import type { TimeFrame } from '@/hooks/useMonarchTransactions';
+
+const TIMEFRAME_TO_SECONDS: Record = {
+ '1D': 24 * 60 * 60,
+ '7D': 7 * 24 * 60 * 60,
+ '30D': 30 * 24 * 60 * 60,
+ '90D': 90 * 24 * 60 * 60,
+ ALL: 365 * 24 * 60 * 60,
+};
+
+export type AttributionScoreboardSummary = {
+ qualifiedLeads: number;
+ activatedAccounts: number;
+ activationRate: number;
+ attributedVolumeUsd: number;
+ attributedRevenueUsd: number;
+ distributionCostUsd: number;
+ cacPaybackDays: number | null;
+};
+
+export type AttributionScoreboardRow = AttributionScoreboardSummary & {
+ source: string;
+ medium: string;
+ campaign: string;
+ refCode: string;
+};
+
+export type AttributionScoreboardResponse = {
+ startTimestamp: number;
+ endTimestamp: number;
+ windowDays: number;
+ revenueBps: number;
+ summary: AttributionScoreboardSummary;
+ breakdown: AttributionScoreboardRow[];
+};
+
+function getTimeRange(timeframe: TimeFrame): { startTimestamp: number; endTimestamp: number } {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ startTimestamp: now - TIMEFRAME_TO_SECONDS[timeframe],
+ endTimestamp: now,
+ };
+}
+
+async function fetchAttributionScoreboard(timeframe: TimeFrame): Promise {
+ const { startTimestamp, endTimestamp } = getTimeRange(timeframe);
+ const searchParams = new URLSearchParams({
+ start_ts: String(startTimestamp),
+ end_ts: String(endTimestamp),
+ });
+
+ const response = await fetch(`/api/monarch/attribution/scoreboard?${searchParams.toString()}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch attribution scoreboard');
+ }
+
+ return response.json();
+}
+
+export function useAttributionScoreboard(timeframe: TimeFrame) {
+ const query = useQuery({
+ queryKey: ['attribution-scoreboard', timeframe],
+ queryFn: () => fetchAttributionScoreboard(timeframe),
+ staleTime: 2 * 60 * 1000,
+ refetchInterval: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ const summary = useMemo(() => {
+ return (
+ query.data?.summary ?? {
+ qualifiedLeads: 0,
+ activatedAccounts: 0,
+ activationRate: 0,
+ attributedVolumeUsd: 0,
+ attributedRevenueUsd: 0,
+ distributionCostUsd: 0,
+ cacPaybackDays: null,
+ }
+ );
+ }, [query.data?.summary]);
+
+ return {
+ ...query,
+ summary,
+ breakdown: query.data?.breakdown ?? [],
+ };
+}
diff --git a/src/imgs/chains/monad.svg b/src/imgs/chains/monad.svg
index 06a7dd89..21e1d4c2 100644
--- a/src/imgs/chains/monad.svg
+++ b/src/imgs/chains/monad.svg
@@ -1,4 +1 @@
-
-
+
\ No newline at end of file
diff --git a/src/imgs/chains/op.svg b/src/imgs/chains/op.svg
index 7e0b5b68..d9f923ab 100644
--- a/src/imgs/chains/op.svg
+++ b/src/imgs/chains/op.svg
@@ -1,5 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/intro/morpho-logo-darkmode.svg b/src/imgs/intro/morpho-logo-darkmode.svg
index 813a9de5..93ce8372 100644
--- a/src/imgs/intro/morpho-logo-darkmode.svg
+++ b/src/imgs/intro/morpho-logo-darkmode.svg
@@ -1,20 +1 @@
-
-
+
\ No newline at end of file
diff --git a/src/imgs/oracles/api3.svg b/src/imgs/oracles/api3.svg
index bfdfe99f..09c85e96 100644
--- a/src/imgs/oracles/api3.svg
+++ b/src/imgs/oracles/api3.svg
@@ -1,4 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/oracles/midas.png b/src/imgs/oracles/midas.png
index 128eb5c0..ab8fed42 100644
Binary files a/src/imgs/oracles/midas.png and b/src/imgs/oracles/midas.png differ
diff --git a/src/imgs/tokens/eurcv.svg b/src/imgs/tokens/eurcv.svg
index 10d40b71..5baea9e7 100644
--- a/src/imgs/tokens/eurcv.svg
+++ b/src/imgs/tokens/eurcv.svg
@@ -1,6 +1 @@
-
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/pmusd.svg b/src/imgs/tokens/pmusd.svg
index b786f791..e202ecfb 100644
--- a/src/imgs/tokens/pmusd.svg
+++ b/src/imgs/tokens/pmusd.svg
@@ -1,252 +1 @@
-
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/pt-reusd.svg b/src/imgs/tokens/pt-reusd.svg
index a57cef62..d3cebfb3 100644
--- a/src/imgs/tokens/pt-reusd.svg
+++ b/src/imgs/tokens/pt-reusd.svg
@@ -1,12 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/susdc.svg b/src/imgs/tokens/susdc.svg
index c91a76b8..491c1a4f 100644
--- a/src/imgs/tokens/susdc.svg
+++ b/src/imgs/tokens/susdc.svg
@@ -1,18 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/susdd.svg b/src/imgs/tokens/susdd.svg
index a822818c..b377c3a0 100644
--- a/src/imgs/tokens/susdd.svg
+++ b/src/imgs/tokens/susdd.svg
@@ -1,11 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/syrupUSDC.svg b/src/imgs/tokens/syrupUSDC.svg
index 9367c65f..37be0461 100644
--- a/src/imgs/tokens/syrupUSDC.svg
+++ b/src/imgs/tokens/syrupUSDC.svg
@@ -1,21 +1 @@
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/usdh.svg b/src/imgs/tokens/usdh.svg
index df757dec..e2efc915 100644
--- a/src/imgs/tokens/usdh.svg
+++ b/src/imgs/tokens/usdh.svg
@@ -1,33 +1 @@
-
-
+
\ No newline at end of file
diff --git a/src/imgs/tokens/usds.svg b/src/imgs/tokens/usds.svg
index 7c247303..5e689da4 100644
--- a/src/imgs/tokens/usds.svg
+++ b/src/imgs/tokens/usds.svg
@@ -1,15 +1 @@
-
+
\ No newline at end of file
diff --git a/src/stores/useAttributionStore.ts b/src/stores/useAttributionStore.ts
new file mode 100644
index 00000000..20a54506
--- /dev/null
+++ b/src/stores/useAttributionStore.ts
@@ -0,0 +1,90 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type AttributionTouchpoint = {
+ refCode: string | null;
+ utmSource: string | null;
+ utmMedium: string | null;
+ utmCampaign: string | null;
+ utmContent: string | null;
+ landingPath: string | null;
+ capturedAt: number;
+};
+
+type AttributionState = {
+ touchpoint: AttributionTouchpoint | null;
+ lastSubmittedWallet: string | null;
+ lastSubmittedAt: number | null;
+};
+
+type AttributionActions = {
+ captureFromUrl: (params: Pick, pathname: string) => void;
+ markSubmittedWallet: (walletAddress: string) => void;
+};
+
+type AttributionStore = AttributionState & AttributionActions;
+
+function toNullable(value: string | null | undefined): string | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+/**
+ * Persist first-touch attribution parameters so they survive route changes
+ * until a wallet connection and first action can be attributed.
+ */
+export const useAttributionStore = create()(
+ persist(
+ (set) => ({
+ touchpoint: null,
+ lastSubmittedWallet: null,
+ lastSubmittedAt: null,
+
+ captureFromUrl: (params, pathname) =>
+ set((state) => {
+ const candidate: AttributionTouchpoint = {
+ refCode: toNullable(params.get('ref_code')),
+ utmSource: toNullable(params.get('utm_source')),
+ utmMedium: toNullable(params.get('utm_medium')),
+ utmCampaign: toNullable(params.get('utm_campaign')),
+ utmContent: toNullable(params.get('utm_content')),
+ landingPath: toNullable(pathname),
+ capturedAt: Date.now(),
+ };
+
+ const hasAttributionValues = Boolean(
+ candidate.refCode ?? candidate.utmSource ?? candidate.utmMedium ?? candidate.utmCampaign ?? candidate.utmContent,
+ );
+
+ if (!hasAttributionValues && state.touchpoint) {
+ return state;
+ }
+
+ if (!state.touchpoint) {
+ return { touchpoint: candidate };
+ }
+
+ // Preserve first-touch semantics by only filling empty fields.
+ return {
+ touchpoint: {
+ refCode: state.touchpoint.refCode ?? candidate.refCode,
+ utmSource: state.touchpoint.utmSource ?? candidate.utmSource,
+ utmMedium: state.touchpoint.utmMedium ?? candidate.utmMedium,
+ utmCampaign: state.touchpoint.utmCampaign ?? candidate.utmCampaign,
+ utmContent: state.touchpoint.utmContent ?? candidate.utmContent,
+ landingPath: state.touchpoint.landingPath ?? candidate.landingPath,
+ capturedAt: state.touchpoint.capturedAt,
+ },
+ };
+ }),
+
+ markSubmittedWallet: (walletAddress) =>
+ set({
+ lastSubmittedWallet: walletAddress.toLowerCase(),
+ lastSubmittedAt: Date.now(),
+ }),
+ }),
+ { name: 'monarch_store_attribution' },
+ ),
+);