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
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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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×tamp=${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×tamp=${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×tamp=${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×tamp=${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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user