feat(token-aggregation): add historical pricing context, backfill, and indexer hardening
Some checks failed
CI/CD Pipeline / Solidity Contracts (pull_request) Failing after 1m6s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 12m42s
CI/CD Pipeline / Lint and Format (pull_request) Failing after 42s
CI/CD Pipeline / Terraform Validation (pull_request) Failing after 25s
CI/CD Pipeline / Kubernetes Validation (pull_request) Successful in 27s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (pull_request) Failing after 49s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (pull_request) Failing after 26s
Validation / validate-genesis (pull_request) Successful in 36s
Validation / validate-terraform (pull_request) Failing after 28s
Validation / validate-kubernetes (pull_request) Failing after 12s
Validation / validate-smart-contracts (pull_request) Failing after 13s
Validation / validate-security (pull_request) Failing after 1m39s
Validation / validate-documentation (pull_request) Failing after 18s

This commit is contained in:
defiQUG
2026-04-25 23:45:07 -07:00
parent c3b1b2cebc
commit fcd55aa9c4
18 changed files with 1963 additions and 1095 deletions

View File

@@ -8,6 +8,10 @@ import { appendCentralAudit } from '../central-audit';
const router: Router = Router();
const adminRepo = new AdminRepository();
function firstString(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
// Authentication routes (public)
router.post('/auth/login', async (req: Request, res: Response) => {
try {
@@ -110,7 +114,7 @@ router.post('/api-keys', requireRole('admin', 'super_admin'), async (req: AuthRe
router.put('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const id = parseInt(firstString(req.params.id) || '', 10);
const updates: { isActive?: boolean; rateLimitPerMinute?: number; expiresAt?: Date } = {};
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
@@ -142,7 +146,7 @@ router.put('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: Aut
router.delete('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const id = parseInt(firstString(req.params.id) || '', 10);
const oldKey = await adminRepo.getApiKey(id);
await adminRepo.deleteApiKey(id);
@@ -237,7 +241,7 @@ router.post('/endpoints', requireRole('admin', 'super_admin'), async (req: AuthR
router.put('/endpoints/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
const id = parseInt(firstString(req.params.id) || '', 10);
const updates: { endpointUrl?: string; isActive?: boolean; isPrimary?: boolean } = {};
if (req.body.endpointUrl !== undefined) updates.endpointUrl = req.body.endpointUrl;

View File

@@ -166,7 +166,7 @@ router.get('/omnl/ipsas/fineract-compare', omnlSensitiveRouteGuard, async (_req:
*/
router.get('/omnl/ipsas/layer/:layer', (req: Request, res: Response) => {
try {
const layer = req.params.layer;
const layer = Array.isArray(req.params.layer) ? req.params.layer[0] : req.params.layer;
const registry = loadIpsasRegistry();
const hint = registry.monetaryLayerHints[layer];
if (!hint) {

View File

@@ -12,6 +12,8 @@ const mockGetLiveDodoPools = jest.fn();
const mockResolveTokenDisplay = jest.fn();
const mockResolvePoolTokenDisplays = jest.fn();
const mockGetTokenByContract = jest.fn();
const mockGetOHLCV = jest.fn();
const mockGetHistoricalReferencePrice = jest.fn();
jest.mock('../../database/repositories/token-repo', () => ({
TokenRepository: jest.fn().mockImplementation(() => ({
@@ -36,7 +38,7 @@ jest.mock('../../database/repositories/pool-repo', () => ({
jest.mock('../../indexer/ohlcv-generator', () => ({
OHLCVGenerator: jest.fn().mockImplementation(() => ({
getOHLCV: jest.fn().mockResolvedValue([]),
getOHLCV: mockGetOHLCV,
})),
}));
@@ -46,6 +48,7 @@ jest.mock('../../adapters/coingecko-adapter', () => ({
CoinGeckoAdapter: jest.fn().mockImplementation(() => ({
getTokenByContract: mockGetTokenByContract,
getMarketData: mockGetMarketDataAdapter,
getHistoricalReferencePrice: mockGetHistoricalReferencePrice,
getTrending: jest.fn().mockResolvedValue([]),
})),
}));
@@ -110,6 +113,7 @@ describe('Tokens API', () => {
mockGetMarketData.mockResolvedValue(null);
mockGetPoolsByToken.mockResolvedValue([]);
mockGetPool.mockResolvedValue(null);
mockGetOHLCV.mockResolvedValue([]);
mockGetLiveDodoPools.mockResolvedValue([]);
mockResolveTokenDisplay.mockResolvedValue({
address: '',
@@ -123,6 +127,7 @@ describe('Tokens API', () => {
token1: { address: '', symbol: 'UNKNOWN', name: 'Unknown Token', source: 'fallback' },
});
mockGetTokenByContract.mockResolvedValue(null);
mockGetHistoricalReferencePrice.mockResolvedValue(null);
});
afterAll(async () => {
@@ -216,4 +221,170 @@ describe('Tokens API', () => {
});
expect(body.token.canonicalLiquidity).toBeUndefined();
});
it('returns historical price snapshots for a token at a requested timestamp', async () => {
const weth = getCanonicalTokenBySymbol(138, 'WETH');
expect(weth?.addresses[138]).toBeTruthy();
const wethAddress = String(weth?.addresses[138]).toLowerCase();
mockGetOHLCV
.mockResolvedValueOnce([
{
timestamp: new Date('2026-04-26T01:30:00.000Z'),
open: 2488,
high: 2491,
low: 2487,
close: 2490,
volume: 10,
volumeUsd: 24900,
},
]);
const res = await fetch(
`${baseUrl}/api/v1/tokens/${wethAddress}/price-at?chainId=138&timestamp=${encodeURIComponent('2026-04-26T01:33:02.000Z')}`
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, any>;
expect(body).toMatchObject({
chainId: 138,
tokenAddress: wethAddress,
requestedTimestamp: '2026-04-26T01:33:02.000Z',
effectiveTimestamp: '2026-04-26T01:30:00.000Z',
priceUsd: 2490,
source: 'ohlcv_5m',
});
});
it('returns pricing context with current and locked historical snapshots', async () => {
const weth = getCanonicalTokenBySymbol(138, 'WETH');
expect(weth?.addresses[138]).toBeTruthy();
const wethAddress = String(weth?.addresses[138]).toLowerCase();
mockGetOHLCV.mockResolvedValueOnce([
{
timestamp: new Date('2026-04-26T01:30:00.000Z'),
open: 2488,
high: 2491,
low: 2487,
close: 2490,
volume: 10,
volumeUsd: 24900,
},
]);
mockGetMarketData.mockResolvedValue({
chainId: 138,
tokenAddress: wethAddress,
priceUsd: 2490,
volume24h: 1000,
volume7d: 2000,
volume30d: 3000,
liquidityUsd: 4000,
holdersCount: 5,
transfers24h: 6,
lastUpdated: new Date('2026-04-26T03:31:01.988Z'),
});
const res = await fetch(
`${baseUrl}/api/v1/tokens/${wethAddress}/pricing-context?chainId=138&timestamp=${encodeURIComponent('2026-04-26T01:33:02.000Z')}`
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, any>;
expect(body).toMatchObject({
chainId: 138,
tokenAddress: wethAddress,
current: {
chainId: 138,
tokenAddress: wethAddress,
priceUsd: 2490,
stale: false,
},
historical: {
chainId: 138,
tokenAddress: wethAddress,
requestedTimestamp: '2026-04-26T01:33:02.000Z',
effectiveTimestamp: '2026-04-26T01:30:00.000Z',
priceUsd: 2490,
source: 'ohlcv_5m',
locked: true,
},
});
expect(body.current.asOf).toEqual(expect.any(String));
expect(body.current.sourceLayer).toEqual(expect.any(String));
});
it('falls back to canonical CoinGecko history for native reference assets when local OHLCV is unavailable', async () => {
const weth = getCanonicalTokenBySymbol(138, 'WETH');
expect(weth?.addresses[138]).toBeTruthy();
const wethAddress = String(weth?.addresses[138]).toLowerCase();
mockGetHistoricalReferencePrice.mockResolvedValue({
priceUsd: 2484.12,
effectiveTimestamp: new Date('2026-04-26T01:31:00.000Z'),
});
const res = await fetch(
`${baseUrl}/api/v1/tokens/${wethAddress}/pricing-context?chainId=138&timestamp=${encodeURIComponent('2026-04-26T01:33:02.000Z')}`
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, any>;
expect(body.historical).toMatchObject({
chainId: 138,
tokenAddress: wethAddress,
requestedTimestamp: '2026-04-26T01:33:02.000Z',
effectiveTimestamp: '2026-04-26T01:31:00.000Z',
priceUsd: 2484.12,
source: 'coingecko_history',
locked: true,
});
});
it('does not lock stale OHLCV candles that are too far before the requested timestamp', async () => {
const usdt = getCanonicalTokenBySymbol(138, 'USDT');
expect(usdt?.addresses[138]).toBeTruthy();
const usdtAddress = String(usdt?.addresses[138]).toLowerCase();
mockGetOHLCV.mockResolvedValue([
{
timestamp: new Date('2026-04-11T00:00:00.000Z'),
open: 1,
high: 1,
low: 1,
close: 1,
volume: 15,
volumeUsd: 15,
},
]);
mockGetHistoricalReferencePrice.mockResolvedValue(null);
mockGetMarketData.mockResolvedValue({
chainId: 138,
tokenAddress: usdtAddress,
priceUsd: 1,
volume24h: 0,
volume7d: 0,
volume30d: 0,
liquidityUsd: 0,
holdersCount: 0,
transfers24h: 0,
lastUpdated: new Date('2026-04-26T03:31:01.988Z'),
});
const res = await fetch(
`${baseUrl}/api/v1/tokens/${usdtAddress}/pricing-context?chainId=138&timestamp=${encodeURIComponent('2026-04-26T01:33:02.000Z')}`
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, any>;
expect(body.historical).toMatchObject({
chainId: 138,
tokenAddress: usdtAddress,
requestedTimestamp: '2026-04-26T01:33:02.000Z',
source: 'current_market_fallback',
locked: false,
});
});
});

View File

@@ -15,6 +15,7 @@ import {
getCanonicalTokensByChain,
resolveCanonicalQuoteAddress,
} from '../../config/canonical-tokens';
import { resolveCanonicalPriceUsd } from '../../services/canonical-price-oracle';
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
import {
buildExplorerLinks,
@@ -27,10 +28,64 @@ const tokenRepo = new TokenRepository();
const marketDataRepo = new MarketDataRepository();
const poolRepo = new PoolRepository();
const ohlcvGenerator = new OHLCVGenerator();
type HistoricalPriceSource =
| 'swap_event'
| 'ohlcv_5m'
| 'ohlcv_15m'
| 'ohlcv_1h'
| 'ohlcv_4h'
| 'ohlcv_24h'
| 'coingecko_history'
| 'current_market_fallback'
| 'canonical_fallback'
| 'unavailable';
const coingeckoAdapter = new CoinGeckoAdapter();
const cmcAdapter = new CoinMarketCapAdapter();
const dexscreenerAdapter = new DexScreenerAdapter();
function firstString(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
type HistoricalPriceSnapshot = {
chainId: number;
tokenAddress: string;
requestedTimestamp: string;
effectiveTimestamp?: string;
priceUsd?: number;
source: HistoricalPriceSource;
};
type CurrentPriceSnapshot = {
chainId: number;
tokenAddress: string;
priceUsd?: number;
asOf?: string;
sourceLayer: string;
stale: boolean;
marketLastUpdated?: string;
};
const HISTORICAL_INTERVAL_MAX_AGE_MS: Record<'5m' | '15m' | '1h' | '4h' | '24h', number> = {
'5m': 15 * 60 * 1000,
'15m': 45 * 60 * 1000,
'1h': 3 * 60 * 60 * 1000,
'4h': 12 * 60 * 60 * 1000,
'24h': 36 * 60 * 60 * 1000,
};
const COINGECKO_HISTORY_MAX_SKEW_MS = 6 * 60 * 60 * 1000;
function isAcceptableHistoricalTimestamp(
candidateTimestamp: Date,
requestedTimestamp: Date,
maxAgeMs: number
): boolean {
const deltaMs = requestedTimestamp.getTime() - candidateTimestamp.getTime();
return deltaMs >= 0 && deltaMs <= maxAgeMs;
}
function buildMarketPricingExplorer(
chainId: number,
displayAddress: string,
@@ -126,6 +181,189 @@ async function getTokenWithFallback(chainId: number, address: string): Promise<T
};
}
async function resolveHistoricalPriceAt(
chainId: number,
address: string,
timestamp: Date
): Promise<HistoricalPriceSnapshot> {
const normalizedAddress = address.toLowerCase();
const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress);
const requestedTimestamp = timestamp.toISOString();
try {
const direct5m = await ohlcvGenerator.getOHLCV(
chainId,
resolution.lookupAddress,
'5m',
new Date(timestamp.getTime() - 10 * 60 * 1000),
timestamp
);
const directCandidate = direct5m[direct5m.length - 1];
if (
directCandidate?.close != null &&
isAcceptableHistoricalTimestamp(
directCandidate.timestamp,
timestamp,
HISTORICAL_INTERVAL_MAX_AGE_MS['5m']
)
) {
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
effectiveTimestamp: directCandidate.timestamp.toISOString(),
priceUsd: directCandidate.close,
source: 'ohlcv_5m',
};
}
const intervalWindows: Array<{ interval: '15m' | '1h' | '4h' | '24h'; windowMs: number }> = [
{ interval: '15m', windowMs: 2 * 60 * 60 * 1000 },
{ interval: '1h', windowMs: 24 * 60 * 60 * 1000 },
{ interval: '4h', windowMs: 7 * 24 * 60 * 60 * 1000 },
{ interval: '24h', windowMs: 60 * 24 * 60 * 60 * 1000 },
];
for (const { interval, windowMs } of intervalWindows) {
const candles = await ohlcvGenerator.getOHLCV(
chainId,
resolution.lookupAddress,
interval,
new Date(timestamp.getTime() - windowMs),
timestamp
);
const candidate = candles[candles.length - 1];
if (
candidate?.close != null &&
isAcceptableHistoricalTimestamp(
candidate.timestamp,
timestamp,
HISTORICAL_INTERVAL_MAX_AGE_MS[interval]
)
) {
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
effectiveTimestamp: candidate.timestamp.toISOString(),
priceUsd: candidate.close,
source: `ohlcv_${interval}`,
};
}
}
} catch (error) {
logger.warn('Historical OHLCV lookup failed; falling back to current layers', {
chainId,
address: resolution.lookupAddress,
requestedTimestamp,
error,
});
}
const canonicalResolution = resolveCanonicalPriceUsd(chainId, resolution.lookupAddress);
if (canonicalResolution.referenceSymbol) {
const coingeckoHistorical = await coingeckoAdapter.getHistoricalReferencePrice(
canonicalResolution.referenceSymbol,
timestamp
);
if (
coingeckoHistorical?.priceUsd != null &&
isAcceptableHistoricalTimestamp(
coingeckoHistorical.effectiveTimestamp,
timestamp,
COINGECKO_HISTORY_MAX_SKEW_MS
)
) {
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
effectiveTimestamp: coingeckoHistorical.effectiveTimestamp.toISOString(),
priceUsd: coingeckoHistorical.priceUsd,
source: 'coingecko_history',
};
}
}
const marketData = await marketDataRepo.getMarketData(chainId, resolution.lookupAddress);
if (marketData?.priceUsd != null) {
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
effectiveTimestamp: marketData.lastUpdated instanceof Date ? marketData.lastUpdated.toISOString() : new Date(marketData.lastUpdated).toISOString(),
priceUsd: marketData.priceUsd,
source: 'current_market_fallback',
};
}
const canonicalPricing = resolveUsdValuation({
chainId,
normalizedAddress: resolution.lookupAddress,
indexer: null,
coingecko: undefined,
cmc: undefined,
dexscreener: undefined,
});
if (canonicalPricing.priceUsd != null) {
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
effectiveTimestamp: canonicalPricing.asOf,
priceUsd: canonicalPricing.priceUsd,
source: 'canonical_fallback',
};
}
return {
chainId,
tokenAddress: normalizedAddress,
requestedTimestamp,
source: 'unavailable',
};
}
async function resolveCurrentPriceSnapshot(
chainId: number,
address: string
): Promise<CurrentPriceSnapshot> {
const normalizedAddress = address.toLowerCase();
const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress);
const marketData = await marketDataRepo.getMarketData(chainId, resolution.lookupAddress);
const pricing = resolveUsdValuation({
chainId,
normalizedAddress: resolution.lookupAddress,
indexer: marketData,
coingecko: undefined,
cmc: undefined,
dexscreener: undefined,
});
const asOf = pricing.asOf
|| (marketData?.lastUpdated instanceof Date
? marketData.lastUpdated.toISOString()
: marketData?.lastUpdated
? new Date(marketData.lastUpdated).toISOString()
: undefined);
return {
chainId,
tokenAddress: normalizedAddress,
priceUsd: pricing.priceUsd,
asOf,
sourceLayer: pricing.sourceLayer,
stale: pricing.stale,
marketLastUpdated:
marketData?.lastUpdated instanceof Date
? marketData.lastUpdated.toISOString()
: marketData?.lastUpdated
? new Date(marketData.lastUpdated).toISOString()
: undefined,
};
}
async function getTokensWithFallback(
chainId: number,
limit: number,
@@ -250,7 +488,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
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;
const address = firstString(req.params.address) || '';
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
@@ -338,7 +576,7 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
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;
const address = firstString(req.params.address) || '';
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
@@ -404,11 +642,13 @@ router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Req
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;
const address = firstString(req.params.address) || '';
const interval = firstString(req.query.interval as string | string[] | undefined) || '1h';
const fromRaw = firstString(req.query.from as string | string[] | undefined);
const toRaw = firstString(req.query.to as string | string[] | undefined);
const from = fromRaw ? new Date(fromRaw) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const to = toRaw ? new Date(toRaw) : new Date();
const poolAddress = firstString(req.query.poolAddress as string | string[] | undefined);
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
@@ -439,10 +679,83 @@ router.get('/tokens/:address/ohlcv', cacheMiddleware(5 * 60 * 1000), async (req:
}
});
router.get('/tokens/:address/price-at', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = firstString(req.params.address) || '';
const rawTimestamp = String(firstString(req.query.timestamp as string | string[] | undefined) || '').trim();
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
if (!rawTimestamp) {
return res.status(400).json({ error: 'timestamp is required' });
}
const timestamp = new Date(rawTimestamp);
if (Number.isNaN(timestamp.getTime())) {
return res.status(400).json({ error: 'timestamp must be a valid ISO-8601 datetime' });
}
const historicalPrice = await resolveHistoricalPriceAt(chainId, address, timestamp);
res.json(historicalPrice);
} catch (error) {
logger.error('Error fetching historical token price:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/tokens/:address/pricing-context', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const address = firstString(req.params.address) || '';
const rawTimestamp = String(firstString(req.query.timestamp as string | string[] | undefined) || '').trim();
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
}
const token = await getTokenWithFallback(chainId, address.toLowerCase());
if (!token) {
return res.status(404).json({ error: 'Token not found' });
}
const current = await resolveCurrentPriceSnapshot(chainId, address);
let historical: (HistoricalPriceSnapshot & { locked: boolean }) | undefined;
if (rawTimestamp) {
const timestamp = new Date(rawTimestamp);
if (Number.isNaN(timestamp.getTime())) {
return res.status(400).json({ error: 'timestamp must be a valid ISO-8601 datetime' });
}
const historicalPrice = await resolveHistoricalPriceAt(chainId, address, timestamp);
historical = {
...historicalPrice,
locked:
historicalPrice.source.startsWith('ohlcv_') ||
historicalPrice.source === 'swap_event' ||
historicalPrice.source === 'coingecko_history',
};
}
return res.json({
chainId,
tokenAddress: address.toLowerCase(),
current,
historical,
});
} catch (error) {
logger.error('Error fetching pricing context:', error);
return 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;
const address = firstString(req.params.address) || '';
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });
@@ -466,7 +779,7 @@ router.get('/tokens/:address/signals', cacheMiddleware(5 * 60 * 1000), async (re
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;
const query = firstString(req.query.q as string | string[] | undefined) || '';
if (!chainId || !query) {
return res.status(400).json({ error: 'chainId and q (query) are required' });
@@ -488,7 +801,7 @@ router.get('/search', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
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;
const poolAddress = firstString(req.params.poolAddress) || '';
if (!chainId) {
return res.status(400).json({ error: 'chainId is required' });