chore: sync submodule state (parent ref update)

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-02 12:14:09 -08:00
parent 50ab378da9
commit 5efe36b1e0
1100 changed files with 155024 additions and 8674 deletions

View File

@@ -0,0 +1,56 @@
/**
* Central audit client for token-aggregation admin actions
* Sends audit entries to dbis_core Admin Central API when DBIS_CENTRAL_URL and ADMIN_CENTRAL_API_KEY are set.
*/
const DBIS_CENTRAL_URL = process.env.DBIS_CENTRAL_URL?.replace(/\/$/, '');
const ADMIN_CENTRAL_API_KEY = process.env.ADMIN_CENTRAL_API_KEY;
const SERVICE_NAME = 'token_aggregation';
function isConfigured(): boolean {
return Boolean(DBIS_CENTRAL_URL && ADMIN_CENTRAL_API_KEY);
}
export interface CentralAuditPayload {
employeeId: string;
action: string;
permission: string;
resourceType: string;
resourceId?: string | null;
outcome?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
export async function appendCentralAudit(payload: CentralAuditPayload): Promise<void> {
if (!isConfigured()) return;
try {
const res = await fetch(`${DBIS_CENTRAL_URL}/api/admin/central/audit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Central-Key': ADMIN_CENTRAL_API_KEY!,
},
body: JSON.stringify({
employeeId: payload.employeeId,
action: payload.action,
permission: payload.permission ?? 'admin:action',
resourceType: payload.resourceType,
resourceId: payload.resourceId ?? undefined,
project: 'smom-dbis-138',
service: SERVICE_NAME,
outcome: payload.outcome ?? 'success',
metadata: payload.metadata,
ipAddress: payload.ipAddress,
userAgent: payload.userAgent,
}),
});
if (!res.ok) {
console.warn(`[central-audit] POST failed: ${res.status} ${await res.text()}`);
}
} catch (err) {
console.warn('[central-audit] append failed:', err instanceof Error ? err.message : err);
}
}

View File

@@ -0,0 +1,3 @@
export function cacheMiddleware(_ttl?: number) {
return (req: unknown, res: unknown, next: () => void) => next();
}

View File

@@ -0,0 +1,55 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'token-aggregation-secret';
export interface AuthUser {
id: number;
username: string;
email?: string;
role: string;
}
export interface AuthRequest extends Request {
userId?: number;
username?: string;
role?: string;
user?: AuthUser;
}
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Access token required' });
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number; username: string; role: string };
req.userId = payload.userId;
req.username = payload.username;
req.role = payload.role;
req.user = { id: payload.userId, username: payload.username, role: payload.role };
next();
} catch {
res.status(403).json({ error: 'Invalid or expired token' });
}
}
export function requireRole(...allowed: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.role || !allowed.includes(req.role)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}
next();
};
}
export function generateToken(userId: number, username: string, role: string): string {
return jwt.sign(
{ userId, username, role },
JWT_SECRET,
{ expiresIn: '24h' }
);
}

View File

@@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from 'express';
interface CacheEntry {
data: any;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
const DEFAULT_TTL = 60 * 1000; // 1 minute
export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
return (req: Request, res: Response, next: NextFunction) => {
const key = `${req.method}:${req.originalUrl}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return res.json(cached.data);
}
// Store original json method
const originalJson = res.json.bind(res);
// Override json method to cache response
res.json = function (body: any) {
cache.set(key, {
data: body,
expiresAt: Date.now() + ttl,
});
return originalJson(body);
};
next();
};
}
export function clearCache(): void {
cache.clear();
}
export function clearCacheForPattern(pattern: string): void {
for (const key of cache.keys()) {
if (key.includes(pattern)) {
cache.delete(key);
}
}
}

View File

@@ -0,0 +1,20 @@
import rateLimit from 'express-rate-limit';
const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10);
const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10);
export const apiRateLimiter = rateLimit({
windowMs,
max: maxRequests,
message: 'Too many requests from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
export const strictRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
message: 'Too many requests, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});

View File

@@ -0,0 +1,388 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { AdminRepository } from '../../database/repositories/admin-repo';
import { authenticateToken, requireRole, AuthRequest, generateToken } from '../middleware/auth';
import { cacheMiddleware } from '../middleware/cache';
import { appendCentralAudit } from '../central-audit';
const router: Router = Router();
const adminRepo = new AdminRepository();
// Authentication routes (public)
router.post('/auth/login', async (req: Request, res: Response) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const user = await adminRepo.getAdminUserByUsername(username);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValid = await adminRepo.verifyPassword(user, password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Update last login
await adminRepo.pool.query(
`UPDATE admin_users SET last_login = NOW() WHERE id = $1`,
[user.id]
);
// Generate token
const token = generateToken(user.id!, user.username, user.role);
res.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// All admin routes require authentication
router.use(authenticateToken);
// API Keys Management
router.get('/api-keys', requireRole('admin', 'super_admin', 'operator'), async (req: Request, res: Response) => {
try {
const provider = req.query.provider as string | undefined;
const keys = await adminRepo.getApiKeys(provider);
res.json({ apiKeys: keys });
} catch (error) {
console.error('Error fetching API keys:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/api-keys', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const { provider, keyName, apiKey, rateLimitPerMinute, rateLimitPerDay, expiresAt } = req.body;
if (!provider || !keyName || !apiKey) {
return res.status(400).json({ error: 'Provider, keyName, and apiKey are required' });
}
// Simple encryption (in production, use proper encryption)
const encrypted = Buffer.from(apiKey).toString('base64');
const newKey = await adminRepo.createApiKey({
provider,
keyName,
apiKeyEncrypted: encrypted,
isActive: true,
rateLimitPerMinute,
rateLimitPerDay,
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
createdBy: req.user?.username,
});
// Audit log
await adminRepo.createAuditLog(
req.user?.id || null,
'create',
'api_key',
newKey.id || null,
null,
{ provider, keyName },
req.ip,
req.get('user-agent')
);
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'api_key', resourceId: String(newKey.id ?? ''), metadata: { provider, keyName }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
res.status(201).json({ apiKey: { ...newKey, apiKeyEncrypted: undefined } });
} catch (error) {
console.error('Error creating API key:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.put('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const updates: any = {};
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
if (req.body.rateLimitPerMinute !== undefined) updates.rateLimitPerMinute = req.body.rateLimitPerMinute;
if (req.body.expiresAt !== undefined) updates.expiresAt = new Date(req.body.expiresAt);
const oldKey = await adminRepo.getApiKey(id);
await adminRepo.updateApiKey(id, updates);
// Audit log
await adminRepo.createAuditLog(
req.user?.id || null,
'update',
'api_key',
id,
oldKey,
updates,
req.ip,
req.get('user-agent')
);
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'update', permission: 'admin:action', resourceType: 'api_key', resourceId: String(id), metadata: updates, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
res.json({ success: true });
} catch (error) {
console.error('Error updating API key:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.delete('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const oldKey = await adminRepo.getApiKey(id);
await adminRepo.deleteApiKey(id);
// Audit log
await adminRepo.createAuditLog(
req.user?.id || null,
'delete',
'api_key',
id,
oldKey,
null,
req.ip,
req.get('user-agent')
);
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'delete', permission: 'admin:action', resourceType: 'api_key', resourceId: String(id), ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
res.json({ success: true });
} catch (error) {
console.error('Error deleting API key:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// API Endpoints Management
router.get('/endpoints', requireRole('admin', 'super_admin', 'operator', 'viewer'), async (req: Request, res: Response) => {
try {
const chainId = req.query.chainId ? parseInt(req.query.chainId as string, 10) : undefined;
const endpointType = req.query.endpointType as string | undefined;
const endpoints = await adminRepo.getEndpoints(chainId, endpointType);
res.json({ endpoints });
} catch (error) {
console.error('Error fetching endpoints:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/endpoints', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const {
chainId,
endpointType,
endpointName,
endpointUrl,
isPrimary,
requiresAuth,
authType,
authConfig,
rateLimitPerMinute,
timeoutMs,
} = req.body;
if (!chainId || !endpointType || !endpointName || !endpointUrl) {
return res.status(400).json({ error: 'Missing required fields' });
}
const endpoint = await adminRepo.createEndpoint({
chainId,
endpointType,
endpointName,
endpointUrl,
isPrimary: isPrimary || false,
isActive: true,
requiresAuth: requiresAuth || false,
authType,
authConfig,
rateLimitPerMinute,
timeoutMs: timeoutMs || 10000,
healthCheckEnabled: true,
createdBy: req.user?.username,
});
// Audit log
await adminRepo.createAuditLog(
req.user?.id || null,
'create',
'endpoint',
endpoint.id || null,
null,
{ chainId, endpointType, endpointName, endpointUrl },
req.ip,
req.get('user-agent')
);
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'endpoint', resourceId: String(endpoint.id ?? ''), metadata: { chainId, endpointType, endpointName, endpointUrl }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
res.status(201).json({ endpoint });
} catch (error) {
console.error('Error creating endpoint:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.put('/endpoints/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const updates: any = {};
if (req.body.endpointUrl !== undefined) updates.endpointUrl = req.body.endpointUrl;
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
if (req.body.isPrimary !== undefined) updates.isPrimary = req.body.isPrimary;
await adminRepo.updateEndpoint(id, updates);
res.json({ success: true });
} catch (error) {
console.error('Error updating endpoint:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// DEX Factory Management
router.get('/dex-factories', requireRole('admin', 'super_admin', 'operator', 'viewer'), async (req: Request, res: Response) => {
try {
const chainId = req.query.chainId ? parseInt(req.query.chainId as string, 10) : undefined;
const factories = await adminRepo.getDexFactories(chainId);
res.json({ factories });
} catch (error) {
console.error('Error fetching DEX factories:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/dex-factories', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const {
chainId,
dexType,
factoryAddress,
routerAddress,
poolManagerAddress,
startBlock,
description,
} = req.body;
if (!chainId || !dexType || !factoryAddress) {
return res.status(400).json({ error: 'Missing required fields' });
}
const factory = await adminRepo.createDexFactory({
chainId,
dexType,
factoryAddress,
routerAddress,
poolManagerAddress,
startBlock: startBlock || 0,
isActive: true,
description,
createdBy: req.user?.username,
});
// Audit log
await adminRepo.createAuditLog(
req.user?.id || null,
'create',
'dex_factory',
factory.id || null,
null,
{ chainId, dexType, factoryAddress },
req.ip,
req.get('user-agent')
);
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'dex_factory', resourceId: String(factory.id ?? ''), metadata: { chainId, dexType, factoryAddress }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
res.status(201).json({ factory });
} catch (error) {
console.error('Error creating DEX factory:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Service Status
router.get('/status', requireRole('admin', 'super_admin', 'operator', 'viewer'), cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
try {
const [apiKeys, endpoints, factories] = await Promise.all([
adminRepo.getApiKeys(),
adminRepo.getEndpoints(),
adminRepo.getDexFactories(),
]);
res.json({
status: 'operational',
stats: {
apiKeys: {
total: apiKeys.length,
active: apiKeys.filter((k) => k.isActive).length,
},
endpoints: {
total: endpoints.length,
active: endpoints.filter((e) => e.isActive).length,
},
factories: {
total: factories.length,
active: factories.filter((f) => f.isActive).length,
},
},
});
} catch (error) {
console.error('Error fetching status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Audit Log
router.get('/audit-log', requireRole('admin', 'super_admin'), async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string, 10) || 100;
const offset = parseInt(req.query.offset as string, 10) || 0;
const result = await adminRepo.pool.query(
`SELECT al.*, au.username
FROM admin_audit_log al
LEFT JOIN admin_users au ON al.user_id = au.id
ORDER BY al.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
res.json({
logs: result.rows.map((row) => ({
id: row.id,
username: row.username,
action: row.action,
resourceType: row.resource_type,
resourceId: row.resource_id,
oldValues: row.old_values,
newValues: row.new_values,
ipAddress: row.ip_address,
userAgent: row.user_agent,
createdAt: row.created_at,
})),
pagination: {
limit,
offset,
count: result.rows.length,
},
});
} catch (error) {
console.error('Error fetching audit log:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,64 @@
import { Router, Request, Response } from 'express';
import { getNetworks, getConfigByChain, API_VERSION } from '../../config/networks';
import { cacheMiddleware } from '../middleware/cache';
import { fetchRemoteJson } from '../utils/fetch-remote-json';
const router: Router = Router();
/**
* GET /api/v1/networks
* Full EIP-3085 chain params for wallet_addEthereumChain (Chain 138, 1, 651940).
* If NETWORKS_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; otherwise uses built-in networks.
*/
router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
try {
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
if (networksJsonUrl) {
try {
const data = (await fetchRemoteJson(networksJsonUrl)) as { version?: string; networks?: unknown[] };
return res.json({
version: data.version ?? API_VERSION,
networks: data.networks ?? [],
});
} catch (err) {
console.error('NETWORKS_JSON_URL fetch failed, using built-in networks:', err);
}
}
const networks = getNetworks();
res.json({
version: API_VERSION,
networks,
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/v1/config
* Oracles (and optional config) per chain. Query: chainId (optional).
* If chainId provided, returns config for that chain only.
*/
router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
try {
const chainIdParam = req.query.chainId as string | undefined;
const networks = getNetworks();
if (chainIdParam) {
const chainId = parseInt(chainIdParam, 10);
const config = getConfigByChain(chainId);
if (!config) {
return res.status(404).json({ error: 'Chain not found', chainId });
}
return res.json({ version: API_VERSION, chainId, ...config });
}
const chains = networks.map((n) => ({
chainId: n.chainIdDecimal,
oracles: n.oracles,
}));
res.json({ version: API_VERSION, chains });
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,110 @@
import { Router, Request, Response } from 'express';
import { PoolRepository } from '../../database/repositories/pool-repo';
import { cacheMiddleware } from '../middleware/cache';
const router: Router = Router();
const poolRepo = new PoolRepository();
/**
* Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997)
*/
function quoteAmountOut(
amountIn: bigint,
reserveIn: bigint,
reserveOut: bigint
): bigint {
if (reserveIn === BigInt(0)) {
return BigInt(0);
}
const amountInWithFee = amountIn * BigInt(997);
const numerator = reserveOut * amountInWithFee;
const denominator = reserveIn * BigInt(1000) + amountInWithFee;
return numerator / denominator;
}
/**
* GET /api/v1/quote
* Returns an estimated amountOut for a token swap (constant-product from first available pool).
* Query: chainId, tokenIn, tokenOut, amountIn (raw amount in token's smallest unit).
*/
router.get(
'/quote',
cacheMiddleware(60 * 1000),
async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const tokenIn = (req.query.tokenIn as string)?.toLowerCase();
const tokenOut = (req.query.tokenOut as string)?.toLowerCase();
const amountInRaw = req.query.amountIn as string;
if (!chainId || !tokenIn || !tokenOut || amountInRaw === undefined) {
return res.status(400).json({
error: 'Missing required query: chainId, tokenIn, tokenOut, amountIn',
amountOut: null,
});
}
let amountIn: bigint;
try {
amountIn = BigInt(amountInRaw);
} catch {
return res.status(400).json({
error: 'Invalid amountIn (must be integer string)',
amountOut: null,
});
}
if (tokenIn === tokenOut) {
return res.json({ amountOut: amountInRaw, poolAddress: null });
}
const pools = await poolRepo.getPoolsByToken(chainId, tokenIn);
const pairPools = pools.filter(
(p) =>
p.token0Address.toLowerCase() === tokenOut ||
p.token1Address.toLowerCase() === tokenOut
);
if (pairPools.length === 0) {
return res.json({
amountOut: null,
error: 'No pool found for this token pair',
poolAddress: null,
});
}
let bestAmountOut = BigInt(0);
let bestPool = pairPools[0];
for (const pool of pairPools) {
const reserveIn =
pool.token0Address.toLowerCase() === tokenIn
? BigInt(pool.reserve0)
: BigInt(pool.reserve1);
const reserveOut =
pool.token0Address.toLowerCase() === tokenOut
? BigInt(pool.reserve0)
: BigInt(pool.reserve1);
const out = quoteAmountOut(amountIn, reserveIn, reserveOut);
if (out > bestAmountOut) {
bestAmountOut = out;
bestPool = pool;
}
}
res.json({
amountOut: bestAmountOut.toString(),
poolAddress: bestPool.poolAddress,
dexType: bestPool.dexType,
});
} catch (error) {
console.error('Quote error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Internal server error',
amountOut: null,
});
}
}
);
export default router;

View File

@@ -0,0 +1,88 @@
/**
* Integration tests for report API (CMC, CoinGecko)
* Uses native fetch + http server (no deprecated supertest)
*/
import { createServer } from 'http';
import express from 'express';
import reportRoutes from './report';
jest.mock('../../database/repositories/token-repo', () => ({
TokenRepository: jest.fn().mockImplementation(() => ({
getToken: jest.fn().mockResolvedValue(null),
})),
}));
jest.mock('../../database/repositories/market-data-repo', () => ({
MarketDataRepository: jest.fn().mockImplementation(() => ({
getMarketData: jest.fn().mockResolvedValue(null),
})),
}));
jest.mock('../../database/repositories/pool-repo', () => ({
PoolRepository: jest.fn().mockImplementation(() => ({
getPoolsByToken: jest.fn().mockResolvedValue([]),
getPoolsByChain: jest.fn().mockResolvedValue([]),
})),
}));
jest.mock('../middleware/cache');
function createApp() {
const app = express();
app.use('/api/v1/report', reportRoutes);
return app;
}
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
const server = createServer(app);
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
const port = (server.address() as { port: number }).port;
return { server, baseUrl: `http://127.0.0.1:${port}` };
}
describe('Report API', () => {
let server: ReturnType<typeof createServer>;
let baseUrl: string;
beforeAll(async () => {
const app = createApp();
const started = await startServer(app);
server = started.server;
baseUrl = started.baseUrl;
});
afterAll((done) => {
server.close(done);
});
describe('GET /api/v1/report/cmc', () => {
it('returns 200 with cmc format', async () => {
const res = await fetch(`${baseUrl}/api/v1/report/cmc?chainId=138`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toHaveProperty('generatedAt');
expect(body).toHaveProperty('chainId', 138);
expect(body).toHaveProperty('format', 'coinmarketcap-dex');
expect(body).toHaveProperty('tokens');
expect(Array.isArray(body.tokens)).toBe(true);
});
it('accepts chainId 651940', async () => {
const res = await fetch(`${baseUrl}/api/v1/report/cmc?chainId=651940`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.chainId).toBe(651940);
});
});
describe('GET /api/v1/report/coingecko', () => {
it('returns 200 with coingecko format', async () => {
const res = await fetch(`${baseUrl}/api/v1/report/coingecko?chainId=138`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toHaveProperty('generatedAt');
expect(body).toHaveProperty('chainId', 138);
expect(body).toHaveProperty('format', 'coingecko-submission');
expect(body).toHaveProperty('tokens');
expect(Array.isArray(body.tokens)).toBe(true);
});
});
});

View File

@@ -0,0 +1,407 @@
/**
* CMC and CoinGecko reporting API: all tokens, liquidity, volume, and reportable data.
* Use for listing submissions and external aggregator sync.
*/
import { Router, Request, Response } from 'express';
import { TokenRepository } from '../../database/repositories/token-repo';
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
import { PoolRepository } from '../../database/repositories/pool-repo';
import {
CANONICAL_TOKENS,
getCanonicalTokensByChain,
getLogoUriForSpec,
} from '../../config/canonical-tokens';
import { getSupportedChainIds } from '../../config/chains';
import { cacheMiddleware } from '../middleware/cache';
import { fetchRemoteJson } from '../utils/fetch-remote-json';
import { buildCrossChainReport } from '../../indexer/cross-chain-indexer';
const router: Router = Router();
const tokenRepo = new TokenRepository();
const marketDataRepo = new MarketDataRepository();
const poolRepo = new PoolRepository();
/** Build token entries with DB market/pool data for a chain */
async function buildTokenReport(chainId: number) {
const canonical = getCanonicalTokensByChain(chainId);
const out: Array<{
chainId: number;
address: string;
symbol: string;
name: string;
type: string;
decimals: number;
currencyCode?: string;
market?: {
priceUsd?: number;
volume24h: number;
volume7d: number;
volume30d: number;
marketCapUsd?: number;
liquidityUsd: number;
lastUpdated: string;
};
pools: Array<{
poolAddress: string;
dex: string;
token0: string;
token1: string;
tvl: number;
volume24h: number;
}>;
fromDb: boolean;
}> = [];
for (const spec of canonical) {
const address = spec.addresses[chainId];
if (!address || String(address).trim() === '') continue;
const [dbToken, marketData, pools] = await Promise.all([
tokenRepo.getToken(chainId, address),
marketDataRepo.getMarketData(chainId, address),
poolRepo.getPoolsByToken(chainId, address),
]);
out.push({
chainId,
address: address.toLowerCase(),
symbol: spec.symbol,
name: dbToken?.name ?? spec.name,
type: spec.type,
decimals: spec.decimals,
currencyCode: spec.currencyCode,
market: marketData
? {
priceUsd: marketData.priceUsd,
volume24h: marketData.volume24h,
volume7d: marketData.volume7d,
volume30d: marketData.volume30d,
marketCapUsd: marketData.marketCapUsd,
liquidityUsd: marketData.liquidityUsd,
lastUpdated: marketData.lastUpdated?.toISOString() ?? '',
}
: undefined,
pools: pools.map((p) => ({
poolAddress: p.poolAddress,
dex: p.dexType,
token0: p.token0Address,
token1: p.token1Address,
tvl: p.totalLiquidityUsd,
volume24h: p.volume24h,
})),
fromDb: !!dbToken,
});
}
return out;
}
/** GET /report/cross-chain — cross-chain pools, bridge volume, atomic swaps (Chain 138, ALL Mainnet) */
router.get(
'/cross-chain',
cacheMiddleware(2 * 60 * 1000),
async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10) || 138;
const report = await buildCrossChainReport(chainId);
res.json({
...report,
format: 'cross-chain-report',
documentation: 'Use for CMC/CoinGecko submission alongside single-chain reports. Includes CCIP, Alltra, Trustless bridge events and volume by lane.',
});
} catch (error) {
console.error('Error building report/cross-chain:', error);
res.status(500).json({
error: 'Internal server error',
crossChainPools: [],
volumeByLane: [],
atomicSwapVolume24h: 0,
bridgeVolume24hTotal: 0,
events: [],
});
}
}
);
/** GET /report/all — all tokens, pools, liquidity, volume (unified) + cross-chain */
router.get(
'/all',
cacheMiddleware(2 * 60 * 1000),
async (req: Request, res: Response) => {
try {
const chainIdParam = req.query.chainId as string | undefined;
const chainIds = chainIdParam
? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n))
: getSupportedChainIds();
const tokensByChain: Record<number, Awaited<ReturnType<typeof buildTokenReport>>> = {};
const poolsByChain: Record<number, Awaited<ReturnType<typeof poolRepo.getPoolsByChain>>> = {};
for (const chainId of chainIds) {
tokensByChain[chainId] = await buildTokenReport(chainId);
poolsByChain[chainId] = await poolRepo.getPoolsByChain(chainId);
}
const crossChainReport = await buildCrossChainReport(138).catch(() => null);
const totalLiquidityByChain: Record<number, number> = {};
const totalVolume24hByChain: Record<number, number> = {};
for (const chainId of chainIds) {
const pools = poolsByChain[chainId] || [];
totalLiquidityByChain[chainId] = pools.reduce((s, p) => s + (p.totalLiquidityUsd || 0), 0);
totalVolume24hByChain[chainId] = pools.reduce((s, p) => s + (p.volume24h || 0), 0);
}
res.json({
generatedAt: new Date().toISOString(),
chains: chainIds,
tokens: tokensByChain,
pools: poolsByChain,
summary: {
totalLiquidityUsdByChain: totalLiquidityByChain,
totalVolume24hUsdByChain: totalVolume24hByChain,
tokenCountByChain: Object.fromEntries(
chainIds.map((c) => [c, (tokensByChain[c] || []).length])
),
poolCountByChain: Object.fromEntries(
chainIds.map((c) => [c, (poolsByChain[c] || []).length])
),
crossChainBridgeVolume24h: crossChainReport?.bridgeVolume24hTotal,
crossChainAtomicSwapVolume24h: crossChainReport?.atomicSwapVolume24h,
},
crossChain: crossChainReport
? {
crossChainPools: crossChainReport.crossChainPools,
volumeByLane: crossChainReport.volumeByLane,
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
}
: undefined,
});
} catch (error) {
console.error('Error building report/all:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
/** GET /report/coingecko — format suitable for CoinGecko submission / API */
router.get(
'/coingecko',
cacheMiddleware(2 * 60 * 1000),
async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10) || 138;
const tokens = await buildTokenReport(chainId);
const coingeckoFormat = tokens.map((t) => ({
chain_id: chainId,
contract_address: t.address,
id: `${t.symbol.toLowerCase()}-${chainId}`,
symbol: t.symbol,
name: t.name,
asset_platform_id: chainId === 138 ? 'defi-oracle-meta' : chainId === 651940 ? 'all-mainnet' : `chain-${chainId}`,
decimals: t.decimals,
description: t.currencyCode ? `ISO-4217 ${t.currencyCode} compliant token` : undefined,
market_data: t.market
? {
current_price: { usd: t.market.priceUsd },
total_volume: t.market.volume24h,
market_cap: t.market.marketCapUsd,
liquidity_usd: t.market.liquidityUsd,
last_updated: t.market.lastUpdated,
}
: undefined,
liquidity_pools: t.pools.map((p) => ({
pool_address: p.poolAddress,
dex_id: p.dex,
tvl_usd: p.tvl,
volume_24h_usd: p.volume24h,
})),
}));
const crossChainReport = await buildCrossChainReport(chainId).catch(() => null);
res.json({
generatedAt: new Date().toISOString(),
chainId,
format: 'coingecko-submission',
tokens: coingeckoFormat,
crossChain: crossChainReport
? {
crossChainPools: crossChainReport.crossChainPools,
volumeByLane: crossChainReport.volumeByLane,
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
}
: undefined,
documentation: 'https://www.coingecko.com/en/api/documentation',
});
} catch (error) {
console.error('Error building report/coingecko:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
/** GET /report/cmc — format suitable for CoinMarketCap submission / API */
router.get(
'/cmc',
cacheMiddleware(2 * 60 * 1000),
async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10) || 138;
const tokens = await buildTokenReport(chainId);
const cmcFormat = tokens.map((t) => ({
chain_id: chainId,
contract_address: t.address,
symbol: t.symbol,
name: t.name,
decimals: t.decimals,
volume_24h: t.market?.volume24h,
market_cap: t.market?.marketCapUsd,
liquidity_usd: t.market?.liquidityUsd ?? t.pools.reduce((s, p) => s + p.tvl, 0),
pairs: t.pools.map((p) => ({
pair_address: p.poolAddress,
dex_id: p.dex,
base: t.address,
quote: p.token0 === t.address ? p.token1 : p.token0,
liquidity_usd: p.tvl,
volume_24h_usd: p.volume24h,
})),
}));
const crossChainReport = await buildCrossChainReport(chainId).catch(() => null);
res.json({
generatedAt: new Date().toISOString(),
chainId,
format: 'coinmarketcap-dex',
tokens: cmcFormat,
crossChain: crossChainReport
? {
crossChainPools: crossChainReport.crossChainPools,
volumeByLane: crossChainReport.volumeByLane,
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
}
: undefined,
documentation: 'https://coinmarketcap.com/api/documentation',
});
} catch (error) {
console.error('Error building report/cmc:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
/** GET /report/token-list — flat list of all canonical tokens (Uniswap token list format with logoURI).
* If TOKEN_LIST_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; optional ?chainId= filters tokens.
*/
router.get(
'/token-list',
cacheMiddleware(5 * 60 * 1000),
async (req: Request, res: Response) => {
try {
const tokenListUrl = process.env.TOKEN_LIST_JSON_URL?.trim();
if (tokenListUrl) {
try {
const data = (await fetchRemoteJson(tokenListUrl)) as {
name?: string;
version?: string;
timestamp?: string;
logoURI?: string;
tokens?: Array<{ chainId?: number; address?: string; symbol?: string; name?: string; decimals?: number; [key: string]: unknown }>;
};
const chainIdParam = req.query.chainId as string | undefined;
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
let tokens = Array.isArray(data.tokens) ? data.tokens : [];
if (!isNaN(chainIdFilter as number)) {
tokens = tokens.filter((t) => t.chainId === chainIdFilter);
}
return res.json({
name: data.name ?? 'Token List',
version: data.version ?? '1.0.0',
timestamp: data.timestamp ?? new Date().toISOString(),
logoURI: data.logoURI,
tokens,
});
} catch (err) {
console.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err);
}
}
const chainIdParam = req.query.chainId as string | undefined;
const chainIds = chainIdParam
? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n))
: getSupportedChainIds();
const list: Array<{
chainId: number;
address: string;
symbol: string;
name: string;
decimals: number;
type: string;
logoURI: string;
}> = [];
for (const chainId of chainIds) {
const specs = getCanonicalTokensByChain(chainId);
for (const spec of specs) {
const address = spec.addresses[chainId];
if (address) {
list.push({
chainId,
address: address.toLowerCase(),
symbol: spec.symbol,
name: spec.name,
decimals: spec.decimals,
type: spec.type,
logoURI: getLogoUriForSpec(spec),
});
}
}
}
res.json({
name: 'GRU Canonical Token List',
version: '1.0.0',
timestamp: new Date().toISOString(),
logoURI: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png',
tokens: list,
});
} catch (error) {
console.error('Error building report/token-list:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
/** GET /report/canonical — raw canonical spec list (no DB merge) */
router.get(
'/canonical',
cacheMiddleware(10 * 60 * 1000),
async (req: Request, res: Response) => {
try {
res.json({
generatedAt: new Date().toISOString(),
tokens: CANONICAL_TOKENS.map((t) => ({
symbol: t.symbol,
name: t.name,
type: t.type,
decimals: t.decimals,
currencyCode: t.currencyCode,
addresses: t.addresses,
})),
});
} catch (error) {
console.error('Error building report/canonical:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,134 @@
/**
* Token mapping API: exposes config/token-mapping-loader (multichain) when run from monorepo.
* GET /api/v1/token-mapping?fromChain=138&toChain=651940
* GET /api/v1/token-mapping/pairs
*/
import { Router, Request, Response } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { cacheMiddleware } from '../middleware/cache';
const router: Router = Router();
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Resolve path to repo root (proxmox) from token-aggregation src/api/routes -> 5 levels up */
const PROXMOX_ROOT = path.resolve(__dirname, '../../../../../');
const LOADER_PATH = path.join(PROXMOX_ROOT, 'config', 'token-mapping-loader.cjs');
function loadMultichainLoader(): {
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
} | null {
try {
const loader = require(LOADER_PATH);
if (loader?.getTokenMappingForPair && loader?.getAllMultichainPairs && loader?.getMappedAddress) {
return loader;
}
} catch {
// config not available when run outside monorepo
}
return null;
}
/**
* GET /api/v1/token-mapping?fromChain=138&toChain=651940
* Returns token mapping for a chain pair (from config/token-mapping-multichain.json).
*/
router.get(
'/',
cacheMiddleware(5 * 60 * 1000),
(req: Request, res: Response) => {
const fromChain = parseInt(String(req.query.fromChain ?? req.query.from), 10);
const toChain = parseInt(String(req.query.toChain ?? req.query.to), 10);
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain)) {
return res.status(400).json({
error: 'Missing or invalid fromChain and toChain query params',
example: '/api/v1/token-mapping?fromChain=138&toChain=651940',
});
}
const loader = loadMultichainLoader();
if (!loader) {
return res.status(503).json({
error: 'Token mapping config not available (run from monorepo with config/token-mapping-multichain.json)',
});
}
const result = loader.getTokenMappingForPair(fromChain, toChain);
if (!result) {
return res.status(404).json({
error: 'No token mapping for this chain pair',
fromChain,
toChain,
});
}
return res.json({
fromChainId: fromChain,
toChainId: toChain,
tokens: result.tokens,
addressMapFromTo: result.addressMapFromTo,
addressMapToFrom: result.addressMapToFrom,
});
}
);
/**
* GET /api/v1/token-mapping/pairs
* Returns all defined chain pairs.
*/
router.get(
'/pairs',
cacheMiddleware(5 * 60 * 1000),
(req: Request, res: Response) => {
const loader = loadMultichainLoader();
if (!loader) {
return res.status(503).json({
error: 'Token mapping config not available (run from monorepo)',
});
}
const pairs = loader.getAllMultichainPairs();
return res.json({ pairs });
}
);
/**
* GET /api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=0x...
* Returns mapped token address on target chain.
*/
router.get(
'/resolve',
cacheMiddleware(5 * 60 * 1000),
(req: Request, res: Response) => {
const fromChain = parseInt(String(req.query.fromChain ?? req.query.from), 10);
const toChain = parseInt(String(req.query.toChain ?? req.query.to), 10);
const address = String(req.query.address ?? req.query.token ?? '').trim();
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain) || !address) {
return res.status(400).json({
error: 'Missing or invalid fromChain, toChain, or address',
example: '/api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
});
}
const loader = loadMultichainLoader();
if (!loader) {
return res.status(503).json({
error: 'Token mapping config not available (run from monorepo)',
});
}
const mapped = loader.getMappedAddress(fromChain, toChain, address);
return res.json({
fromChainId: fromChain,
toChainId: toChain,
addressOnSource: address,
addressOnTarget: mapped ?? null,
});
}
);
export default router;

View File

@@ -0,0 +1,291 @@
import { Router, Request, Response } from 'express';
import { TokenRepository } from '../../database/repositories/token-repo';
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
import { PoolRepository } from '../../database/repositories/pool-repo';
import { OHLCVGenerator } from '../../indexer/ohlcv-generator';
import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter';
import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter';
import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter';
import { cacheMiddleware } from '../middleware/cache';
const router: Router = Router();
const tokenRepo = new TokenRepository();
const marketDataRepo = new MarketDataRepository();
const poolRepo = new PoolRepository();
const ohlcvGenerator = new OHLCVGenerator();
const coingeckoAdapter = new CoinGeckoAdapter();
const cmcAdapter = new CoinMarketCapAdapter();
const dexscreenerAdapter = new DexScreenerAdapter();
router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
try {
res.json({
chains: [
{
chainId: 138,
name: 'DeFi Oracle Meta Mainnet',
explorerUrl: 'https://explorer.d-bis.org',
},
{
chainId: 651940,
name: 'ALL Mainnet',
explorerUrl: 'https://alltra.global',
},
],
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const limit = parseInt(req.query.limit as string, 10) || 50;
const offset = parseInt(req.query.offset as string, 10) || 0;
const includeDodoPool = (req.query.includeDodoPool as string) === '1' || (req.query.includeDodoPool as string) === 'true';
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const tokens = await tokenRepo.getTokens(chainId, limit, offset);
const tokensWithMarketData = await Promise.all(
tokens.map(async (token) => {
const marketData = await marketDataRepo.getMarketData(chainId, token.address);
const out: Record<string, unknown> = {
...token,
market: marketData || undefined,
};
if (includeDodoPool) {
const pools = await poolRepo.getPoolsByToken(chainId, token.address);
const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo');
out.hasDodoPool = !!dodoPool;
out.pmmPool = dodoPool?.poolAddress || undefined;
}
return out;
})
);
res.json({
tokens: tokensWithMarketData,
pagination: {
limit,
offset,
count: tokensWithMarketData.length,
},
});
} catch (error) {
console.error('Error fetching tokens:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = req.params.address;
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const token = await tokenRepo.getToken(chainId, address);
if (!token) {
return res.status(404).json({ error: 'Token not found' });
}
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
marketDataRepo.getMarketData(chainId, address),
poolRepo.getPoolsByToken(chainId, address),
coingeckoAdapter.getTokenByContract(chainId, address),
cmcAdapter.getTokenByContract(chainId, address),
dexscreenerAdapter.getTokenByContract(chainId, address),
]);
res.json({
token: {
...token,
onChain: {
totalSupply: token.totalSupply,
},
market: marketData || undefined,
external: {
coingecko: coingeckoData || undefined,
cmc: cmcData || undefined,
dexscreener: dexscreenerData || undefined,
},
pools: pools.map((pool) => ({
address: pool.poolAddress,
dex: pool.dexType,
token0: pool.token0Address,
token1: pool.token1Address,
reserves: {
token0: pool.reserve0,
token1: pool.reserve1,
},
tvl: pool.totalLiquidityUsd,
volume24h: pool.volume24h,
})),
hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'),
pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined,
},
});
} catch (error) {
console.error('Error fetching token:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = req.params.address;
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const pools = await poolRepo.getPoolsByToken(chainId, address);
res.json({
pools: pools.map((pool) => ({
address: pool.poolAddress,
dex: pool.dexType,
token0: pool.token0Address,
token1: pool.token1Address,
reserves: {
token0: pool.reserve0,
token1: pool.reserve1,
},
tvl: pool.totalLiquidityUsd,
volume24h: pool.volume24h,
feeTier: pool.feeTier,
})),
});
} catch (error) {
console.error('Error fetching pools:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens/:address/ohlcv', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = req.params.address;
const interval = (req.query.interval as string) || '1h';
const from = req.query.from ? new Date(req.query.from as string) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const to = req.query.to ? new Date(req.query.to as string) : new Date();
const poolAddress = req.query.poolAddress as string | undefined;
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
if (!['5m', '15m', '1h', '4h', '24h'].includes(interval)) {
return res.status(400).json({ error: 'Invalid interval. Must be one of: 5m, 15m, 1h, 4h, 24h' });
}
const ohlcv = await ohlcvGenerator.getOHLCV(
chainId,
address,
interval as any,
from,
to,
poolAddress
);
res.json({
chainId,
tokenAddress: address,
interval,
data: ohlcv,
});
} catch (error) {
console.error('Error fetching OHLCV:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens/:address/signals', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = req.params.address;
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const trending = await coingeckoAdapter.getTrending();
res.json({
chainId,
tokenAddress: address,
signals: {
trendingRank: trending.findIndex((t) => t.symbol.toLowerCase() === address.toLowerCase()),
},
});
} catch (error) {
console.error('Error fetching signals:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/search', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const query = req.query.q as string;
if (!chainId || !query) {
return res.status(400).json({ error: 'chainId and q (query) are required' });
}
const tokens = await tokenRepo.searchTokens(chainId, query, 20);
res.json({
query,
chainId,
results: tokens,
});
} catch (error) {
console.error('Error searching tokens:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const poolAddress = req.params.poolAddress;
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const pool = await poolRepo.getPool(chainId, poolAddress);
if (!pool) {
return res.status(404).json({ error: 'Pool not found' });
}
res.json({
pool: {
address: pool.poolAddress,
dex: pool.dexType,
token0: pool.token0Address,
token1: pool.token1Address,
reserves: {
token0: pool.reserve0,
token1: pool.reserve1,
},
tvl: pool.totalLiquidityUsd,
volume24h: pool.volume24h,
feeTier: pool.feeTier,
},
});
} catch (error) {
console.error('Error fetching pool:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,162 @@
import express, { Express, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import compression from 'compression';
import { apiRateLimiter, strictRateLimiter } from './middleware/rate-limit';
import tokenRoutes from './routes/tokens';
import reportRoutes from './routes/report';
import adminRoutes from './routes/admin';
import configRoutes from './routes/config';
import bridgeRoutes from './routes/bridge';
import quoteRoutes from './routes/quote';
import tokenMappingRoutes from './routes/token-mapping';
import { MultiChainIndexer } from '../indexer/chain-indexer';
import { getDatabasePool } from '../database/client';
import winston from 'winston';
// Setup logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
],
});
export class ApiServer {
private app: Express;
private port: number;
private indexer: MultiChainIndexer;
constructor() {
this.app = express();
this.port = parseInt(process.env.PORT || '3000', 10);
this.indexer = new MultiChainIndexer();
this.setupMiddleware();
this.setupRoutes();
this.setupErrorHandling();
}
private setupMiddleware(): void {
// CORS
this.app.use(cors());
// Compression
this.app.use(compression());
// Body parsing
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// Rate limiting
this.app.use('/api/v1', apiRateLimiter);
// Request logging
this.app.use((req: Request, res: Response, next: NextFunction) => {
logger.info(`${req.method} ${req.path}`, {
ip: req.ip,
userAgent: req.get('user-agent'),
});
next();
});
}
private setupRoutes(): void {
// Health check
this.app.get('/health', async (req: Request, res: Response) => {
try {
// Check database connection
const pool = getDatabasePool();
await pool.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
database: 'connected',
indexer: 'running',
},
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// API routes
this.app.use('/api/v1', tokenRoutes);
this.app.use('/api/v1', configRoutes);
this.app.use('/api/v1/report', reportRoutes);
this.app.use('/api/v1/bridge', bridgeRoutes);
this.app.use('/api/v1/token-mapping', tokenMappingRoutes);
this.app.use('/api/v1', quoteRoutes);
// Admin routes (stricter rate limit)
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
// Root
this.app.get('/', (req: Request, res: Response) => {
res.json({
name: 'Token Aggregation Service',
version: '1.0.0',
endpoints: {
health: '/health',
api: '/api/v1',
},
});
});
}
private setupErrorHandling(): void {
// 404 handler
this.app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler
this.app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
}
async start(): Promise<void> {
try {
// Initialize indexer
await this.indexer.initialize();
// Start indexing
await this.indexer.startAll();
// Start server
this.app.listen(this.port, () => {
logger.info(`Token Aggregation Service listening on port ${this.port}`);
logger.info(`Health check: http://localhost:${this.port}/health`);
logger.info(`API: http://localhost:${this.port}/api/v1`);
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
}
async stop(): Promise<void> {
this.indexer.stopAll();
logger.info('Server stopped');
}
}

View File

@@ -0,0 +1,41 @@
/**
* Fetch JSON from a URL with in-memory caching.
* Used for TOKEN_LIST_JSON_URL, BRIDGE_LIST_JSON_URL, NETWORKS_JSON_URL (e.g. GitHub raw URLs).
*/
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface CacheEntry {
data: unknown;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
export async function fetchRemoteJson(
url: string,
ttlMs: number = CACHE_TTL_MS
): Promise<unknown> {
const trimmed = url?.trim();
if (!trimmed) {
throw new Error('URL is required');
}
const entry = cache.get(trimmed);
if (entry && entry.expiresAt > Date.now()) {
return entry.data;
}
const res = await fetch(trimmed, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
cache.set(trimmed, { data, expiresAt: Date.now() + ttlMs });
return data;
}
export function clearRemoteJsonCache(): void {
cache.clear();
}