chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
56
services/token-aggregation/src/api/central-audit.ts
Normal file
56
services/token-aggregation/src/api/central-audit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cacheMiddleware(_ttl?: number) {
|
||||
return (req: unknown, res: unknown, next: () => void) => next();
|
||||
}
|
||||
55
services/token-aggregation/src/api/middleware/auth.ts
Normal file
55
services/token-aggregation/src/api/middleware/auth.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
46
services/token-aggregation/src/api/middleware/cache.ts
Normal file
46
services/token-aggregation/src/api/middleware/cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
services/token-aggregation/src/api/middleware/rate-limit.ts
Normal file
20
services/token-aggregation/src/api/middleware/rate-limit.ts
Normal 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,
|
||||
});
|
||||
388
services/token-aggregation/src/api/routes/admin.ts
Normal file
388
services/token-aggregation/src/api/routes/admin.ts
Normal 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;
|
||||
64
services/token-aggregation/src/api/routes/config.ts
Normal file
64
services/token-aggregation/src/api/routes/config.ts
Normal 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;
|
||||
110
services/token-aggregation/src/api/routes/quote.ts
Normal file
110
services/token-aggregation/src/api/routes/quote.ts
Normal 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;
|
||||
88
services/token-aggregation/src/api/routes/report.test.ts
Normal file
88
services/token-aggregation/src/api/routes/report.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
services/token-aggregation/src/api/routes/report.ts
Normal file
407
services/token-aggregation/src/api/routes/report.ts
Normal 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;
|
||||
134
services/token-aggregation/src/api/routes/token-mapping.ts
Normal file
134
services/token-aggregation/src/api/routes/token-mapping.ts
Normal 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;
|
||||
291
services/token-aggregation/src/api/routes/tokens.ts
Normal file
291
services/token-aggregation/src/api/routes/tokens.ts
Normal 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;
|
||||
162
services/token-aggregation/src/api/server.ts
Normal file
162
services/token-aggregation/src/api/server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user