Add MetaMask Snap metadata, wallet image helpers, and n-hop planning surfaces so Chain 138 routing and wallet-add flows have consistent API coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import express, { Express, Request, Response, NextFunction, type RequestHandler } from 'express';
|
|
import { Server } from 'http';
|
|
import path from 'path';
|
|
import { readFileSync, existsSync } from 'fs';
|
|
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, { sendMetamaskConfig } from './routes/config';
|
|
import bridgeRoutes from './routes/bridge';
|
|
import quoteRoutes from './routes/quote';
|
|
import routeTreeRoutes from './routes/routes';
|
|
import tokenMappingRoutes from './routes/token-mapping';
|
|
import polygonMapperReportRoutes from './routes/polygon-mapper';
|
|
import officialProtocolsReportRoutes from './routes/official-protocols';
|
|
import explorerRegistryOpenApiRoutes from './routes/explorer-registry-openapi';
|
|
import heatmapRoutes from './routes/heatmap';
|
|
import arbitrageRoutes from './routes/arbitrage';
|
|
import aggregatorRouteMatrixRoutes from './routes/aggregator-routes';
|
|
import partnerPayloadRoutes from './routes/partner-payloads';
|
|
import plannerV2Routes from './routes/planner-v2';
|
|
import omnlRoutes from './routes/omnl';
|
|
import omnlIpsasRoutes from './routes/omnl-ipsas';
|
|
import omnlPayoutJournalRoutes from './routes/omnl-payout-journal-routes';
|
|
import omnlComplianceRoutes from './routes/omnl-compliance-routes';
|
|
import omnlTerminalRoutes from './routes/omnl-terminal-routes';
|
|
import checkpointRoutes from './routes/checkpoint';
|
|
import reserveCapacityRoutes from './routes/reserve-capacity';
|
|
import hoLiquidityRoutes from './routes/ho-liquidity-routes';
|
|
import metamaskPriceRoutes from './routes/metamask-prices';
|
|
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
|
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
|
|
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 indexerEnabled: boolean;
|
|
private indexer: MultiChainIndexer | null;
|
|
private omnlPoller: OmnlEventPoller | null;
|
|
private server: Server | null;
|
|
|
|
private resolveTrustProxySetting(): boolean | number | string {
|
|
const raw = (process.env.EXPRESS_TRUST_PROXY ?? process.env.TRUST_PROXY ?? '1').trim();
|
|
const normalized = raw.toLowerCase();
|
|
|
|
if (normalized === 'true') return true;
|
|
if (normalized === 'false') return false;
|
|
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
return raw;
|
|
}
|
|
|
|
constructor() {
|
|
this.app = express();
|
|
this.port = parseInt(process.env.PORT || '3000', 10);
|
|
this.indexerEnabled = this.resolveFeatureFlag('ENABLE_INDEXER', true);
|
|
this.indexer = this.indexerEnabled ? new MultiChainIndexer() : null;
|
|
this.omnlPoller = this.resolveFeatureFlag('ENABLE_OMNL_EVENT_POLLER', false) ? new OmnlEventPoller() : null;
|
|
this.server = null;
|
|
|
|
this.setupMiddleware();
|
|
this.setupRoutes();
|
|
this.setupErrorHandling();
|
|
}
|
|
|
|
private resolveFeatureFlag(name: string, fallback: boolean): boolean {
|
|
const raw = (process.env[name] || '').trim().toLowerCase();
|
|
if (!raw) return fallback;
|
|
if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
|
|
if (['0', 'false', 'no', 'off'].includes(raw)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
private setupMiddleware(): void {
|
|
const trustProxy = this.resolveTrustProxySetting();
|
|
this.app.set('trust proxy', trustProxy);
|
|
|
|
// CORS
|
|
this.app.use(cors());
|
|
|
|
// Compression (Express 5 typings — runtime-compatible)
|
|
this.app.use(compression() as unknown as RequestHandler);
|
|
|
|
// 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,
|
|
forwardedFor: req.get('x-forwarded-for'),
|
|
trustProxy,
|
|
userAgent: req.get('user-agent'),
|
|
});
|
|
next();
|
|
});
|
|
|
|
const publicPath = path.join(__dirname, '../../public');
|
|
if (existsSync(publicPath)) {
|
|
const staticOpts = {
|
|
immutable: true,
|
|
maxAge: '1d',
|
|
} as const;
|
|
this.app.use('/static', express.static(publicPath, staticOpts));
|
|
this.app.use('/omnl/static', express.static(publicPath, staticOpts));
|
|
this.app.use('/reserve/static', express.static(publicPath, staticOpts));
|
|
this.app.use(
|
|
'/chain138-attestation',
|
|
express.static(path.join(publicPath, 'chain138-attestation'), staticOpts)
|
|
);
|
|
}
|
|
}
|
|
|
|
private setupRoutes(): void {
|
|
// Health check
|
|
this.app.get('/health', async (req: Request, res: Response) => {
|
|
if (process.env.OMNL_SETTLEMENT_TERMINAL_ONLY === '1') {
|
|
res.json({
|
|
status: 'healthy',
|
|
mode: 'omnl-settlement-terminal-only',
|
|
timestamp: new Date().toISOString(),
|
|
services: {
|
|
database: 'skipped',
|
|
indexer: 'disabled',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
// Check database connection
|
|
const pool = getDatabasePool();
|
|
await pool.query('SELECT 1');
|
|
|
|
res.json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
services: {
|
|
database: 'connected',
|
|
indexer: this.indexerEnabled ? 'running' : 'disabled',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (this.resolveFeatureFlag('TOKEN_AGGREGATION_DB_OPTIONAL', false)) {
|
|
res.json({
|
|
status: 'degraded',
|
|
mode: 'database-optional',
|
|
timestamp: new Date().toISOString(),
|
|
services: {
|
|
database: 'unavailable',
|
|
indexer: this.indexerEnabled ? 'running' : 'disabled',
|
|
},
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
return;
|
|
}
|
|
res.status(503).json({
|
|
status: 'unhealthy',
|
|
timestamp: new Date().toISOString(),
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
});
|
|
|
|
const dashboardPath = path.join(__dirname, '../../public/omnl-dashboard.html');
|
|
const complianceConsolePath = path.join(__dirname, '../../public/omnl-compliance-console.html');
|
|
const settlementTerminalPath = path.join(__dirname, '../../public/omnl-settlement-terminal.html');
|
|
const reserveDashboardPath = path.join(__dirname, '../../public/reserve-dashboard.html');
|
|
const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html');
|
|
|
|
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
|
|
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
|
if (!tok) return true;
|
|
const q = String(req.query.access_token ?? '').trim();
|
|
const h = String(req.headers['x-omnl-dashboard-token'] ?? '').trim();
|
|
if (q !== tok && h !== tok) {
|
|
res.status(401).type('text/plain').send('Unauthorized: set access_token query or X-OMNL-Dashboard-Token header');
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
this.app.get('/omnl/compliance', (req: Request, res: Response) => {
|
|
if (!authorizeOmnlHtml(req, res)) return;
|
|
if (!existsSync(complianceConsolePath)) {
|
|
res.status(404).type('text/plain').send('omnl-compliance-console.html missing');
|
|
return;
|
|
}
|
|
res.type('html').send(readFileSync(complianceConsolePath, 'utf8'));
|
|
});
|
|
|
|
this.app.get('/omnl/dashboard', (req: Request, res: Response) => {
|
|
if (!authorizeOmnlHtml(req, res)) return;
|
|
if (!existsSync(dashboardPath)) {
|
|
res.status(404).type('text/plain').send('omnl-dashboard.html missing');
|
|
return;
|
|
}
|
|
res.type('html').send(readFileSync(dashboardPath, 'utf8'));
|
|
});
|
|
|
|
this.app.get('/omnl/terminal', (req: Request, res: Response) => {
|
|
if (!authorizeOmnlHtml(req, res)) return;
|
|
if (!existsSync(settlementTerminalPath)) {
|
|
res.status(404).type('text/plain').send('omnl-settlement-terminal.html missing');
|
|
return;
|
|
}
|
|
res.type('html').send(readFileSync(settlementTerminalPath, 'utf8'));
|
|
});
|
|
|
|
/** Public semi-public reserve capacity dashboard (no auth). */
|
|
this.app.get('/reserve', (_req: Request, res: Response) => {
|
|
if (!existsSync(reserveDashboardPath)) {
|
|
res.status(404).type('text/plain').send('reserve-dashboard.html missing');
|
|
return;
|
|
}
|
|
res.type('html').send(readFileSync(reserveDashboardPath, 'utf8'));
|
|
});
|
|
|
|
/** Institutional wrapper (same API; used by reserve.d-bis.org NPM upstream). */
|
|
this.app.get('/reserve/institutional', (_req: Request, res: Response) => {
|
|
const file = existsSync(reserveInstitutionalPath) ? reserveInstitutionalPath : reserveDashboardPath;
|
|
if (!existsSync(file)) {
|
|
res.status(404).type('text/plain').send('reserve dashboard missing');
|
|
return;
|
|
}
|
|
res.type('html').send(readFileSync(file, 'utf8'));
|
|
});
|
|
|
|
// Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts)
|
|
const sendApiV1Catalog = (_req: Request, res: Response) => {
|
|
res.json({
|
|
service: 'token-aggregation',
|
|
version: '1.0.0',
|
|
note: 'Prefix paths with your public API origin (e.g. https://explorer.d-bis.org).',
|
|
paths: {
|
|
catalog: '/api/v1',
|
|
health: '/health',
|
|
chains: '/api/v1/chains',
|
|
networks: '/api/v1/networks',
|
|
config: '/api/v1/config',
|
|
tokens: '/api/v1/tokens',
|
|
tokenDetail: '/api/v1/tokens/{address}',
|
|
quote: '/api/v1/quote',
|
|
bridgeRoutes: '/api/v1/bridge/routes',
|
|
bridgeMessages: '/api/v1/bridge/messages?lane=138-monad',
|
|
bridgeStatus: '/api/v1/bridge/status',
|
|
bridgeMetrics: '/api/v1/bridge/metrics',
|
|
bridgePreflight: '/api/v1/bridge/preflight',
|
|
tokenMappingPairs: '/api/v1/token-mapping/pairs',
|
|
tokenMappingResolve: '/api/v1/token-mapping/resolve',
|
|
reportPolygonMapper: '/api/v1/report/polygon-mapper',
|
|
reportPolygonMapperOfficialExport: '/api/v1/report/polygon-mapper/official-export',
|
|
reportOfficialProtocols: '/api/v1/report/official-protocols',
|
|
reportOpenApi: '/api/v1/report/openapi.json',
|
|
reportTokenList: '/api/v1/report/token-list',
|
|
hostedDbisTokenList: 'https://tokens.d-bis.org/lists/dbis-138.tokenlist.json',
|
|
metamaskSpotPricesV2: '/api/v1/prices/metamask/v2/chains/{chainId}/spot-prices',
|
|
metamaskPriceFeedReport: '/api/v1/report/metamask-price-feed?chainId=138',
|
|
routesTree: '/api/v1/routes/tree',
|
|
plannerProvidersCapabilities: '/api/v2/providers/capabilities',
|
|
plannerRoutesPlan: '/api/v2/routes/plan',
|
|
plannerIntentsPlan: '/api/v2/intents/plan',
|
|
plannerInternalExecutionPlan: '/api/v2/routes/internal-execution-plan',
|
|
reserveCapacity: '/api/v1/reserve/capacity',
|
|
reserveProofIndex: '/api/v1/reserve/proof-index',
|
|
reserveDashboard: '/reserve',
|
|
},
|
|
});
|
|
};
|
|
this.app.get('/api/v1', sendApiV1Catalog);
|
|
this.app.get('/api/v1/', sendApiV1Catalog);
|
|
|
|
// Keep wallet add-chain config on a direct route so it cannot be shadowed by other mounts.
|
|
this.app.get('/api/v1/config/metamask', sendMetamaskConfig);
|
|
this.app.get('/api/v1/metamask', sendMetamaskConfig);
|
|
|
|
// 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/report', polygonMapperReportRoutes);
|
|
this.app.use('/api/v1/report', officialProtocolsReportRoutes);
|
|
this.app.use('/api/v1/report', explorerRegistryOpenApiRoutes);
|
|
this.app.use('/api/v1/bridge', bridgeRoutes);
|
|
this.app.use('/api/v1', routeTreeRoutes);
|
|
this.app.use('/api/v1/token-mapping', tokenMappingRoutes);
|
|
this.app.use('/api/v1', quoteRoutes);
|
|
this.app.use('/api/v1', heatmapRoutes);
|
|
this.app.use('/api/v1', arbitrageRoutes);
|
|
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
|
this.app.use('/api/v1', partnerPayloadRoutes);
|
|
this.app.use('/api/v1', checkpointRoutes);
|
|
this.app.use('/api/v1', reserveCapacityRoutes);
|
|
this.app.use('/api/v1', hoLiquidityRoutes);
|
|
this.app.use('/api/v1/prices/metamask', metamaskPriceRoutes);
|
|
this.app.use('/api/v1', omnlComplianceRoutes);
|
|
this.app.use('/api/v1', omnlTerminalRoutes);
|
|
this.app.use('/api/v1', omnlRoutes);
|
|
this.app.use('/api/v1', omnlIpsasRoutes);
|
|
this.app.use('/api/v1', omnlPayoutJournalRoutes);
|
|
this.app.use('/api/v2', plannerV2Routes);
|
|
|
|
// 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',
|
|
apiV2: '/api/v2',
|
|
omnlOpenApi: '/api/v1/omnl/openapi.json',
|
|
omnlCatalog: '/api/v1/omnl/catalog',
|
|
omnlDashboard: '/omnl/dashboard',
|
|
reserveDashboard: '/reserve',
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
private setupErrorHandling(): void {
|
|
// 404 handler
|
|
this.app.use((req: Request, res: Response) => {
|
|
res.status(404).json({ error: 'Not found' });
|
|
});
|
|
|
|
// Error handler
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Express error handler requires 4-arg signature
|
|
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 {
|
|
// Start server
|
|
this.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`);
|
|
});
|
|
|
|
if (this.indexer) {
|
|
this.indexer
|
|
.initialize()
|
|
.then(() => this.indexer?.startAll())
|
|
.catch((error) => {
|
|
logger.error('Token aggregation indexer failed after API startup:', error);
|
|
});
|
|
} else {
|
|
logger.info('Token aggregation indexer disabled by ENABLE_INDEXER flag');
|
|
}
|
|
|
|
this.omnlPoller?.start();
|
|
} catch (error) {
|
|
logger.error('Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.omnlPoller?.stop();
|
|
this.indexer?.stopAll();
|
|
if (this.server) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.server?.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
this.server = null;
|
|
}
|
|
logger.info('Server stopped');
|
|
}
|
|
}
|