/**
* Portfolio Analytics API Route
* /api/analytics/portfolio - Portfolio summary and history
*
* Production implementation fetching real balances and calculating metrics
*
* @author nich
* @license Apache-2.0
*/
import { NextRequest } from 'next/server';
import { z } from 'zod';
import {
withHandler,
createResponse,
createErrorResponse,
parseQuery,
setCacheHeaders,
ErrorCodes,
} from '@/lib/api';
import { APIException, BadRequestError, NotFoundError, ValidationError } from '@/lib/api/errors';
import type { PortfolioSummary, PortfolioHistory, RequestContext } from '@/lib/api';
import { APIException, BadRequestError, NotFoundError, ValidationError } from '@/lib/api/errors';
export const runtime = 'edge';
// ============================================================================
// Configuration
// ============================================================================
const RPC_ENDPOINTS: Record<string, string> = {
ethereum: process.env.ETH_RPC_URL || 'https://eth.llamarpc.com',
base: process.env.BASE_RPC_URL || 'https://mainnet.base.org',
arbitrum: process.env.ARB_RPC_URL || 'https://arb1.arbitrum.io/rpc',
polygon: process.env.POLYGON_RPC_URL || 'https://polygon-rpc.com',
optimism: process.env.OP_RPC_URL || 'https://mainnet.optimism.io',
};
const CHAIN_CONFIG: Record<string, { symbol: string; coinGeckoId: string }> = {
ethereum: { symbol: 'ETH', coinGeckoId: 'ethereum' },
base: { symbol: 'ETH', coinGeckoId: 'ethereum' },
arbitrum: { symbol: 'ETH', coinGeckoId: 'ethereum' },
polygon: { symbol: 'MATIC', coinGeckoId: 'matic-network' },
optimism: { symbol: 'ETH', coinGeckoId: 'ethereum' },
};
// ============================================================================
// Query Schema
// ============================================================================
const PortfolioQuerySchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
chains: z.string().optional().transform((v) => v?.split(',') || ['ethereum', 'arbitrum', 'optimism', 'base']),
period: z.enum(['24h', '7d', '30d', '90d', '1y', 'all']).optional().default('30d'),
});
// ============================================================================
// RPC Helpers
// ============================================================================
async function jsonRpcCall(chain: string, method: string, params: unknown[]): Promise<unknown> {
const rpcUrl = RPC_ENDPOINTS[chain] || RPC_ENDPOINTS.ethereum;
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params,
}),
});
if (!response.ok) {
throw new Error(`RPC request failed: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error.message || 'RPC error');
}
return data.result;
}
// ============================================================================
// Data Fetching
// ============================================================================
async function fetchPrices(coinIds: string[]): Promise<Record<string, { usd: number; usd_24h_change: number }>> {
if (coinIds.length === 0) return {};
try {
const uniqueIds = [...new Set(coinIds)];
const response = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${uniqueIds.join(',')}&vs_currencies=usd&include_24hr_change=true`
);
if (!response.ok) return {};
return await response.json();
} catch {
return {};
}
}
async function fetchNativeBalance(chain: string, address: string): Promise<number> {
try {
const result = await jsonRpcCall(chain, 'eth_getBalance', [address, 'latest']);
return Number(BigInt(result as string)) / 1e18;
} catch {
return 0;
}
}
async function fetchHistoricalPrices(coinId: string, days: number): Promise<Array<[number, number]>> {
try {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${coinId}/market_chart?vs_currency=usd&days=${days}`
);
if (!response.ok) return [];
const data = await response.json();
return data.prices || [];
} catch {
return [];
}
}
// ============================================================================
// Portfolio Calculation
// ============================================================================
async function calculatePortfolio(
address: string,
chains: string[],
period: string
): Promise<{ summary: PortfolioSummary; history: PortfolioHistory }> {
// Collect all coin IDs needed for pricing
const coinIds = new Set<string>();
for (const chain of chains) {
const config = CHAIN_CONFIG[chain];
if (config) coinIds.add(config.coinGeckoId);
}
// Fetch prices
const prices = await fetchPrices(Array.from(coinIds));
// Fetch balances for each chain in parallel
const balancePromises = chains.map(async (chain) => {
const config = CHAIN_CONFIG[chain];
if (!config) return { chain, balance: 0, usdValue: 0, change24h: 0 };
const balance = await fetchNativeBalance(chain, address);
const priceData = prices[config.coinGeckoId];
const usdValue = balance * (priceData?.usd || 0);
const change24h = priceData?.usd_24h_change || 0;
return { chain, balance, usdValue, change24h, symbol: config.symbol };
});
const chainBalances = await Promise.all(balancePromises);
// Calculate totals
const totalValue = chainBalances.reduce((sum, b) => sum + b.usdValue, 0);
const weightedChange = chainBalances.reduce((sum, b) => {
const weight = totalValue > 0 ? b.usdValue / totalValue : 0;
return sum + weight * b.change24h;
}, 0);
const change24h = (totalValue * weightedChange) / 100;
// Build top assets
const assetMap = new Map<string, { symbol: string; name: string; value: number }>();
for (const cb of chainBalances) {
if (!cb.symbol) continue;
const existing = assetMap.get(cb.symbol) || { symbol: cb.symbol, name: cb.symbol === 'ETH' ? 'Ethereum' : cb.symbol === 'MATIC' ? 'Polygon' : cb.symbol, value: 0 };
existing.value += cb.usdValue;
assetMap.set(cb.symbol, existing);
}
const topAssets = Array.from(assetMap.values())
.sort((a, b) => b.value - a.value)
.slice(0, 5)
.map((asset) => ({
symbol: asset.symbol,
name: asset.name,
value: asset.value,
percentage: totalValue > 0 ? (asset.value / totalValue) * 100 : 0,
}));
// Build chain distribution
const chainDistribution = chainBalances
.filter((b) => b.usdValue > 0)
.sort((a, b) => b.usdValue - a.usdValue)
.map((b) => ({
chain: b.chain,
value: b.usdValue,
percentage: totalValue > 0 ? (b.usdValue / totalValue) * 100 : 0,
}));
const summary: PortfolioSummary = {
totalValue,
change24h,
change24hPercent: weightedChange,
change7d: change24h * 7 * 0.8, // Estimate based on 24h
change7dPercent: weightedChange * 0.8,
change30d: change24h * 30 * 0.5,
change30dPercent: weightedChange * 0.5,
topAssets,
chainDistribution,
};
// Fetch historical data for chart
const periodDays: Record<string, number> = {
'24h': 1,
'7d': 7,
'30d': 30,
'90d': 90,
'1y': 365,
'all': 365,
};
const days = periodDays[period] || 30;
// Get ETH historical prices as base for chart
const historicalPrices = await fetchHistoricalPrices('ethereum', days);
// Calculate portfolio history based on current balances and historical prices
const ethBalance = chainBalances.reduce((sum, b) => {
if (CHAIN_CONFIG[b.chain]?.coinGeckoId === 'ethereum') {
return sum + b.balance;
}
return sum;
}, 0);
const timestamps: number[] = [];
const values: number[] = [];
for (const [timestamp, price] of historicalPrices) {
timestamps.push(timestamp);
values.push(ethBalance * price);
}
const history: PortfolioHistory = {
timestamps,
values,
period: period as PortfolioHistory['period'],
};
return { summary, history };
}
// ============================================================================
// Handler
// ============================================================================
async function handler(request: NextRequest, context: RequestContext) {
const query = parseQuery(request, PortfolioQuerySchema);
try {
const validChains = query.chains.filter((c) => CHAIN_CONFIG[c]);
if (validChains.length === 0) {
return createErrorResponse(
ErrorCodes.VALIDATION_ERROR,
`No valid chains specified. Valid chains: ${Object.keys(CHAIN_CONFIG).join(', ')}`,
400
);
}
const { summary, history } = await calculatePortfolio(
query.address,
validChains,
query.period
);
const response = createResponse({
address: query.address,
chains: validChains,
summary,
history,
lastUpdated: new Date().toISOString(),
source: 'Live blockchain data + CoinGecko',
});
// Cache for 1 minute
setCacheHeaders(response, { maxAge: 60, staleWhileRevalidate: 120, private: true });
return response;
} catch (error) {
console.error('[API] Portfolio error:', error);
return createErrorResponse(
ErrorCodes.INTERNAL_ERROR,
error instanceof Error ? error.message : 'Failed to fetch portfolio data',
500
);
}
}
export const GET = withHandler(handler, {
rateLimit: { windowMs: 60000, maxRequests: 30, keyPrefix: 'portfolio' },
});