@bitpay-labs/bitcore-wallet-service 11.6.0 → 11.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,11 @@
1
+ import { Validation } from '@bitpay-labs/crypto-wallet-core';
1
2
  import * as request from 'request';
2
3
  import config from '../config';
4
+ import { Constants } from '../lib/common/constants';
3
5
  import { Defaults } from '../lib/common/defaults';
4
6
  import logger from '../lib/logger';
5
7
  import { Storage } from '../lib/storage';
8
+ import type { Request } from 'express';
6
9
 
7
10
  type CoinMarketStats = {
8
11
  symbol: string;
@@ -18,436 +21,513 @@ type CoinMarketStats = {
18
21
  about?: string;
19
22
  };
20
23
 
21
- const SUPPORTED_FIAT_CODES = new Set(Defaults.FIAT_CURRENCIES.map(f => f.code));
24
+ type FiatRatePoint = { ts: number; rate: number };
25
+
26
+ type TokenListToken = {
27
+ chainId: number;
28
+ address: string;
29
+ symbol: string;
30
+ name: string;
31
+ decimals: number;
32
+ logoURI: string;
33
+ };
34
+
35
+ type CoinGeckoRequest = Pick<Request, 'params' | 'query'>;
36
+
37
+ const SUPPORTED_FIAT_CODES = new Set<string>(Defaults.FIAT_CURRENCIES.map(f => f.code));
38
+
39
+ const ALLOWED_DAYS = new Set([1, 7, 30, 90, 365, 1825]);
40
+ const DEFAULT_ALL_TIME_DAYS = 100000;
41
+
42
+ const MAX_QUERY_PARAM_LENGTH = 64;
43
+ const CHAIN_PARAM_PATTERN = /^[a-z0-9-_]+$/i;
44
+
45
+ const COIN_GECKO_REQUEST_TIMEOUT_MS = 15000;
46
+
47
+ /**
48
+ * Default market-stats / fiat-rates coins when the caller does not specify a coin.
49
+ *
50
+ * Intentionally limited to the subset shown by default in the BitPay app's Exchange Rates view,
51
+ * and cached in the DB.
52
+ */
53
+ const DEFAULT_COINS: Record<string, string> = {
54
+ btc: 'bitcoin',
55
+ bch: 'bitcoin-cash',
56
+ doge: 'dogecoin',
57
+ eth: 'ethereum',
58
+ ltc: 'litecoin',
59
+ pol: 'polygon-ecosystem-token',
60
+ sol: 'solana',
61
+ xrp: 'ripple',
62
+ };
63
+
64
+ const DEFAULT_COIN_ENTRIES = Object.entries(DEFAULT_COINS);
65
+ const DEFAULT_IDS = DEFAULT_COIN_ENTRIES.map(([, id]) => id);
66
+ const DEFAULT_ID_SET = new Set(DEFAULT_IDS);
67
+
68
+ type EvmChain = (typeof Constants.EVM_CHAINS)[keyof typeof Constants.EVM_CHAINS];
69
+ type SvmChain = (typeof Constants.SVM_CHAINS)[keyof typeof Constants.SVM_CHAINS];
70
+ type BitPayTokenChain = EvmChain | SvmChain;
71
+
72
+ const CHAIN_ALIASES = {
73
+ pol: Constants.EVM_CHAINS.MATIC
74
+ } as const satisfies Record<string, BitPayTokenChain>;
75
+
76
+ const COINGECKO_PLATFORM_ID_BY_CHAIN = {
77
+ [Constants.EVM_CHAINS.ARB]: 'arbitrum-one',
78
+ [Constants.EVM_CHAINS.BASE]: 'base',
79
+ [Constants.EVM_CHAINS.ETH]: 'ethereum',
80
+ [Constants.EVM_CHAINS.MATIC]: 'polygon-pos',
81
+ [Constants.EVM_CHAINS.OP]: 'optimistic-ethereum',
82
+ [Constants.SVM_CHAINS.SOL]: 'solana'
83
+ } as const satisfies Record<BitPayTokenChain, string>;
84
+
85
+ const CHAIN_BY_COINGECKO_PLATFORM_ID = Object.entries(COINGECKO_PLATFORM_ID_BY_CHAIN).reduce(
86
+ (acc, [chain, platformId]) => {
87
+ acc[platformId] = chain as BitPayTokenChain;
88
+ return acc;
89
+ },
90
+ {} as Record<string, BitPayTokenChain>
91
+ );
92
+
93
+ function isBitPayTokenChain(value: string): value is BitPayTokenChain {
94
+ return Object.prototype.hasOwnProperty.call(COINGECKO_PLATFORM_ID_BY_CHAIN, value);
95
+ }
96
+
97
+ const first = (value: unknown) => (Array.isArray(value) ? value[0] : value);
98
+
99
+ function badRequest(message: string): Error {
100
+ const err: any = new Error(message);
101
+ err.statusCode = 400;
102
+ return err;
103
+ }
104
+
105
+ function getQueryString(query: Record<string, unknown> | undefined, key: string): string | undefined {
106
+ const raw = first(query?.[key]);
107
+ if (raw === undefined || raw === null) return undefined;
108
+
109
+ const s = (typeof raw === 'string' ? raw : typeof raw === 'number' || typeof raw === 'boolean' ? String(raw) : '')
110
+ .trim();
111
+
112
+ if (!s) return undefined;
113
+ if (s.length > MAX_QUERY_PARAM_LENGTH) throw badRequest('Query param too long');
114
+ return s;
115
+ }
116
+
117
+ function validateQueryParam(query: Record<string, unknown> | undefined, key: string): string | undefined {
118
+ try {
119
+ return getQueryString(query, key);
120
+ } catch {
121
+ throw badRequest(`Unsupported ${key}`);
122
+ }
123
+ }
124
+
125
+ function parseCoinChainTokenAddress(
126
+ query: Record<string, unknown> | undefined
127
+ ): { coin?: string; chain?: string; tokenAddress?: string } {
128
+ return {
129
+ coin: validateQueryParam(query, 'coin'),
130
+ chain: validateQueryParam(query, 'chain'),
131
+ tokenAddress: validateQueryParam(query, 'tokenAddress')
132
+ };
133
+ }
134
+
135
+ function normalizeChainSafe(chain: unknown): string | undefined {
136
+ const c = (chain ?? '').toString().trim().toLowerCase();
137
+ if (!c) return undefined;
138
+ if (c.length > MAX_QUERY_PARAM_LENGTH) return undefined;
139
+ if (!CHAIN_PARAM_PATTERN.test(c)) return undefined;
140
+
141
+ const normalizedChain = CHAIN_ALIASES[c] || (isBitPayTokenChain(c) ? c : undefined);
142
+
143
+ if (!normalizedChain) return undefined;
144
+
145
+ return COINGECKO_PLATFORM_ID_BY_CHAIN[normalizedChain];
146
+ }
147
+
148
+ function asNumberOrNull(value: any): number | null {
149
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
150
+ }
151
+
152
+ function isValidTokenAddressForPlatform(tokenAddress: string, platformId: string): boolean {
153
+ const chain = CHAIN_BY_COINGECKO_PLATFORM_ID[platformId];
154
+ if (!chain) return false;
155
+ return Validation.validateAddress(chain, 'mainnet', tokenAddress);
156
+ }
22
157
 
23
158
  export class CoinGeckoService {
24
159
  request: any = request;
25
160
  storage: Storage;
26
161
 
27
- constructor(storage) {
162
+ constructor(storage: Storage) {
28
163
  this.storage = storage;
29
164
  }
30
165
 
31
- private async coinGeckoGetFiatRatesForId(
32
- args: {
33
- id: string;
34
- vsCurrency: string;
35
- days: number;
166
+ private coinGeckoGetCredentials(): { API: string; API_KEY?: string } {
167
+ if (!config.coinGecko) throw new Error('coinGecko missing credentials');
168
+ return { API: config.coinGecko.api, API_KEY: config.coinGecko.apiKey };
169
+ }
170
+
171
+ private coinGeckoGetHeaders(apiKey?: string): Record<string, string | undefined> {
172
+ return {
173
+ 'Content-Type': 'application/json',
174
+ Accept: 'application/json',
175
+ 'x-cg-pro-api-key': apiKey
176
+ };
177
+ }
178
+
179
+ private cgUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
180
+ const { API } = this.coinGeckoGetCredentials();
181
+ const base = API.endsWith('/') ? API.slice(0, -1) : API;
182
+ const url = new URL(path.startsWith('/') ? `${base}${path}` : `${base}/${path}`);
183
+ if (params) {
184
+ for (const [k, v] of Object.entries(params)) {
185
+ if (v === undefined) continue;
186
+ url.searchParams.set(k, String(v));
187
+ }
36
188
  }
37
- ): Promise<Array<{ ts: number; rate: number }>> {
38
- const { id, vsCurrency, days } = args;
39
- const cacheKey = `cgFiatRates:${id}:${vsCurrency}:${days}`;
189
+ return url.toString();
190
+ }
40
191
 
192
+ private async withGlobalCache<T>(
193
+ cacheKey: string,
194
+ duration: number,
195
+ fetcher: () => Promise<T>,
196
+ logName = cacheKey
197
+ ): Promise<T> {
41
198
  return new Promise((resolve, reject) => {
42
- this.storage.checkAndUseGlobalCache(cacheKey, Defaults.COIN_GECKO_MARKET_STATS_CACHE_DURATION, async (err, values, oldvalues) => {
43
- if (err) logger.warn('CoinGecko fiat rates cache check failed: %o', err);
44
- if (values) return resolve(values);
199
+ this.storage.checkAndUseGlobalCache(cacheKey, duration, async (err, values, oldvalues) => {
200
+ if (err) logger.warn('%s cache check failed: %o', logName, err);
201
+ if (values !== undefined && values !== null) return resolve(values);
45
202
 
46
203
  try {
47
- const credentials = this.coinGeckoGetCredentials();
48
- const headers = this.coinGeckoGetHeaders(credentials.API_KEY);
49
- const intervalParam = days >= 90 ? '&interval=daily' : '';
50
- const chartUrl = `${credentials.API}/v3/coins/${encodeURIComponent(id)}/market_chart?vs_currency=${encodeURIComponent(
51
- vsCurrency
52
- )}&days=${encodeURIComponent(days.toString())}${intervalParam}`;
53
- const chartBody = await this.coinGeckoGetJson(chartUrl, headers);
54
-
55
- const prices: Array<[number, number]> = chartBody?.prices || [];
56
- const response = prices.map(p => ({ ts: p[0], rate: p[1] }));
57
-
58
- this.storage.storeGlobalCache(cacheKey, response, storeErr => {
59
- if (storeErr) logger.warn('Could not cache CoinGecko fiat rates: %o', storeErr);
60
- return resolve(response);
204
+ const fetched = await fetcher();
205
+ this.storage.storeGlobalCache(cacheKey, fetched, storeErr => {
206
+ if (storeErr) logger.warn('Could not cache %s: %o', logName, storeErr);
207
+ resolve(fetched);
61
208
  });
62
209
  } catch (e: any) {
63
- const statusCode = e?.statusCode;
64
- if (statusCode === 429 && oldvalues) {
65
- return resolve(oldvalues);
66
- }
210
+ if (e?.statusCode === 429 && oldvalues) return resolve(oldvalues);
67
211
  if (oldvalues) {
68
- logger.warn('Using old cached CoinGecko fiat rates values');
212
+ logger.warn('Using old cached %s values', logName);
69
213
  return resolve(oldvalues);
70
214
  }
71
- return reject(e);
215
+ reject(e);
72
216
  }
73
217
  });
74
218
  });
75
219
  }
76
220
 
77
- private coinGeckoGetCredentials() {
78
- if (!config.coinGecko) throw new Error('coinGecko missing credentials');
221
+ private coinGeckoGetJson(url: string, headers: Record<string, string | undefined>): Promise<any> {
222
+ return new Promise((resolve, reject) => {
223
+ this.request.get(url, { headers, json: true, timeout: COIN_GECKO_REQUEST_TIMEOUT_MS }, (err, data) => {
224
+ const body = data?.body;
225
+ const status = body?.status;
226
+ const httpStatusCode = data?.statusCode;
227
+ const coinGeckoErrorCode = status?.error_code;
228
+
229
+ if (err) {
230
+ logger.error('CoinGecko request failed: %o', { url, httpStatusCode, coinGeckoErrorCode, err, body });
231
+ return reject(err.body ?? err);
232
+ }
79
233
 
80
- const credentials = {
81
- API: config.coinGecko.api,
82
- API_KEY: config.coinGecko.apiKey,
83
- };
234
+ if (httpStatusCode === 429 || coinGeckoErrorCode === 429) {
235
+ logger.warn('CoinGecko rate limit: %o', { url, httpStatusCode, coinGeckoErrorCode, body });
236
+ const rateLimitErr: any = new Error('coinGecko rate limit');
237
+ rateLimitErr.statusCode = 429;
238
+ rateLimitErr.coinGeckoErrorCode = coinGeckoErrorCode;
239
+ rateLimitErr.body = body;
240
+ return reject(rateLimitErr);
241
+ }
84
242
 
85
- return credentials;
86
- }
243
+ const hasCoinGeckoError = !!(status?.error_code || status?.error_message);
244
+ if ((httpStatusCode && httpStatusCode >= 400) || hasCoinGeckoError) {
245
+ logger.error('CoinGecko request failed: %o', { url, httpStatusCode, coinGeckoErrorCode, body });
246
+ const cgErr: any = new Error(status?.error_message || body?.status || 'coinGecko error');
247
+ cgErr.statusCode = httpStatusCode || coinGeckoErrorCode;
248
+ cgErr.coinGeckoErrorCode = coinGeckoErrorCode;
249
+ cgErr.body = body;
250
+ return reject(cgErr);
251
+ }
87
252
 
88
- private coinGeckoGetHeaders(apiKey: string): Record<string, string | undefined> {
89
- return {
90
- 'Content-Type': 'application/json',
91
- 'Accept': 'application/json',
92
- 'x-cg-pro-api-key': apiKey
93
- };
253
+ resolve(body);
254
+ });
255
+ });
94
256
  }
95
257
 
96
- private getDefaultMarketStatsSymbols(): string[] {
97
- return ['BTC', 'ETH', 'XRP', 'SOL', 'DOGE', 'BCH', 'SHIB', 'POL', 'APE', 'LTC', 'WBTC', 'WETH'];
98
- }
258
+ private async resolveCoinGeckoId(coin: string | undefined, chain?: unknown, tokenAddress?: string): Promise<string> {
259
+ const sym = (coin || '').trim().toLowerCase();
260
+ if (sym && sym.length > MAX_QUERY_PARAM_LENGTH) throw badRequest('Unsupported coin');
99
261
 
100
- private resolveCoinGeckoId(coin: string): string {
101
- const normalized = coin.trim().toLowerCase();
102
- const symbolToIdMap: Record<string, string> = {
103
- btc: 'bitcoin',
104
- eth: 'ethereum',
105
- xrp: 'ripple',
106
- sol: 'solana',
107
- doge: 'dogecoin',
108
- bch: 'bitcoin-cash',
109
- shib: 'shiba-inu',
110
- pol: 'polygon-ecosystem-token',
111
- ape: 'apecoin',
112
- ltc: 'litecoin',
113
- wbtc: 'wrapped-bitcoin',
114
- weth: 'weth'
115
- };
262
+ const chainRaw = first(chain);
263
+ const chainProvided = chainRaw !== undefined && chainRaw !== null && String(chainRaw).trim().length > 0;
264
+ const platformId = normalizeChainSafe(chainRaw);
265
+ const normalizedTokenAddress = tokenAddress?.trim();
266
+
267
+ // If chain is provided, tokenAddress is required for deterministic token resolution.
268
+ // Keep invalid-chain errors specific when chain validation fails.
269
+ if (chainProvided && !normalizedTokenAddress) {
270
+ if (!platformId) throw badRequest('Unsupported chain');
271
+ throw badRequest('chain is only supported for token lookups; provide tokenAddress or omit chain for native coins');
272
+ }
273
+
274
+ // If a token contract address is provided, require a valid chain and resolve via contract lookup.
275
+ if (normalizedTokenAddress) {
276
+ if (!platformId) throw badRequest('tokenAddress requires valid chain');
277
+ if (!isValidTokenAddressForPlatform(normalizedTokenAddress, platformId)) {
278
+ throw badRequest('Invalid tokenAddress');
279
+ }
116
280
 
117
- if (symbolToIdMap[normalized]) return symbolToIdMap[normalized];
281
+ const { API_KEY } = this.coinGeckoGetCredentials();
282
+ const headers = this.coinGeckoGetHeaders(API_KEY);
283
+ try {
284
+ const url = this.cgUrl(
285
+ `/v3/coins/${encodeURIComponent(platformId)}/contract/${encodeURIComponent(normalizedTokenAddress)}`
286
+ );
287
+ const body = await this.coinGeckoGetJson(url, headers);
288
+ const id = (body?.id ?? '').toString().trim().toLowerCase();
289
+ if (!id) throw badRequest('Unsupported tokenAddress');
290
+ const tokenSymbol = (body?.symbol ?? '').toString().trim().toLowerCase();
291
+ if (sym && (!tokenSymbol || tokenSymbol !== sym)) throw badRequest('tokenAddress does not match coin');
292
+ const tokenAssetPlatformId = (body?.asset_platform_id ?? '').toString().trim().toLowerCase();
293
+ const platforms = body?.platforms;
294
+ const detailPlatforms = body?.detail_platforms;
295
+ const platformKeys = (obj: any) => (obj && typeof obj === 'object' ? Object.keys(obj).map(k => k.toLowerCase()) : []);
296
+ const platformSignals = [
297
+ ...(tokenAssetPlatformId ? [tokenAssetPlatformId] : []),
298
+ ...platformKeys(platforms),
299
+ ...platformKeys(detailPlatforms)
300
+ ];
301
+ if (platformSignals.length > 0 && !platformSignals.includes(platformId)) {
302
+ throw badRequest('tokenAddress does not match chain');
303
+ }
304
+ return id;
305
+ } catch (e: any) {
306
+ if (e?.statusCode === 404) {
307
+ throw badRequest('Unsupported tokenAddress');
308
+ }
309
+ throw e;
310
+ }
311
+ }
312
+
313
+ if (!sym) throw badRequest('Unsupported coin');
118
314
 
119
- const knownIds = new Set(Object.values(symbolToIdMap));
120
- if (knownIds.has(normalized)) return normalized;
315
+ const defaultId = DEFAULT_COINS[sym];
316
+ if (defaultId) return defaultId;
121
317
 
122
- throw new Error(`Unsupported coin '${coin}'`);
318
+ // Coin-only requests are intentionally restricted to DEFAULT_COINS.
319
+ // Tokens must be requested with both chain + tokenAddress.
320
+ throw badRequest('Unsupported coin. For token symbols, pass `chain` and `tokenAddress`.');
123
321
  }
124
322
 
125
- private coinGeckoGetJson(url: string, headers: Record<string, string | undefined>): Promise<any> {
126
- return new Promise((resolve, reject) => {
127
- this.request.get(
128
- url,
129
- {
130
- headers,
131
- json: true
132
- },
133
- (err, data) => {
134
- const body = data?.body;
135
- const status = body?.status;
136
- const httpStatusCode = data?.statusCode;
137
- const coinGeckoErrorCode = status?.error_code;
138
-
139
- if (err) {
140
- logger.error('CoinGecko request failed: %o', {
141
- url,
142
- httpStatusCode,
143
- coinGeckoErrorCode,
144
- err: err?.message || err,
145
- body
146
- });
147
- return reject(err.body ?? err);
148
- }
323
+ private async fetchFiatRatesForId(id: string, vsCurrency: string, days: number): Promise<FiatRatePoint[]> {
324
+ const { API_KEY } = this.coinGeckoGetCredentials();
325
+ const headers = this.coinGeckoGetHeaders(API_KEY);
149
326
 
150
- if (httpStatusCode === 429 || coinGeckoErrorCode === 429) {
151
- logger.warn('CoinGecko rate limit: %o', { url, httpStatusCode, coinGeckoErrorCode, body });
152
- const rateLimitErr: any = new Error('coinGecko rate limit');
153
- rateLimitErr.statusCode = 429;
154
- return reject(rateLimitErr);
155
- }
327
+ const params: any = { vs_currency: vsCurrency, days };
328
+ if (days >= 90) params.interval = 'daily';
156
329
 
157
- const hasCoinGeckoError = !!(status?.error_code || status?.error_message);
158
- if ((httpStatusCode && httpStatusCode >= 400) || hasCoinGeckoError) {
159
- logger.error('CoinGecko request failed: %o', { url, httpStatusCode, coinGeckoErrorCode, body });
160
- const cgErr: any = new Error(status?.error_message || body?.status || 'coinGecko error');
161
- cgErr.statusCode = httpStatusCode || coinGeckoErrorCode;
162
- cgErr.coinGeckoErrorCode = coinGeckoErrorCode;
163
- cgErr.body = body;
164
- return reject(cgErr);
165
- }
330
+ const url = this.cgUrl(`/v3/coins/${encodeURIComponent(id)}/market_chart`, params);
331
+ const body = await this.coinGeckoGetJson(url, headers);
166
332
 
167
- return resolve(body);
168
- }
169
- );
170
- });
333
+ const prices = Array.isArray(body?.prices) ? body.prices : null;
334
+ if (!prices) throw new Error('CoinGecko market_chart response missing prices');
335
+
336
+ return prices
337
+ .map((p: any) => ({ ts: p?.[0], rate: p?.[1] }))
338
+ .filter((p: any) => typeof p.ts === 'number' && typeof p.rate === 'number');
171
339
  }
172
340
 
173
- coinGeckoGetMarketStats(req): Promise<CoinMarketStats[]> {
174
- return new Promise((resolve, reject) => {
175
- const currency = (req.params?.['code'] || 'USD').toString().toUpperCase();
176
- if (!SUPPORTED_FIAT_CODES.has(currency)) {
177
- return reject(new Error(`Unsupported fiat currency code '${currency}'`));
178
- }
179
- const vsCurrency = currency.toLowerCase();
180
- const coinParam = (req.query?.coin as string) || null;
181
- const symbols = this.getDefaultMarketStatsSymbols();
341
+ private async getFiatRatesForId(
342
+ id: string,
343
+ vsCurrency: string,
344
+ days: number,
345
+ useDbCache: boolean
346
+ ): Promise<FiatRatePoint[]> {
347
+ if (!useDbCache) return this.fetchFiatRatesForId(id, vsCurrency, days);
182
348
 
183
- let requestedId: string | null = null;
184
- if (coinParam) {
185
- try {
186
- requestedId = this.resolveCoinGeckoId(coinParam);
187
- } catch (e) {
188
- return reject(e);
189
- }
190
- }
349
+ const cacheKey = `cgFiatRates:${id}:${vsCurrency}:${days}`;
350
+ return this.withGlobalCache(cacheKey, Defaults.COIN_GECKO_MARKET_STATS_CACHE_DURATION, () =>
351
+ this.fetchFiatRatesForId(id, vsCurrency, days)
352
+ );
353
+ }
191
354
 
192
- const filterResponse = (stats: CoinMarketStats[]) => {
193
- if (!coinParam) return stats;
355
+ private async fetchMarketStats(ids: string[], vsCurrency: string): Promise<CoinMarketStats[]> {
356
+ const { API_KEY } = this.coinGeckoGetCredentials();
357
+ const headers = this.coinGeckoGetHeaders(API_KEY);
358
+
359
+ const marketsUrl = this.cgUrl('/v3/coins/markets', {
360
+ vs_currency: vsCurrency,
361
+ ids: ids.join(','),
362
+ order: 'market_cap_desc',
363
+ per_page: 250,
364
+ page: 1,
365
+ sparkline: false
366
+ });
194
367
 
195
- const normalizedSymbol = coinParam.trim().toUpperCase();
196
- const defaultSymbolSet = new Set(symbols.map(s => s.toUpperCase()));
197
- if (defaultSymbolSet.has(normalizedSymbol)) {
198
- return stats.filter(s => s.symbol === normalizedSymbol);
199
- }
368
+ const marketsBody = await this.coinGeckoGetJson(marketsUrl, headers);
369
+ if (!Array.isArray(marketsBody)) throw new Error('Could not get market data');
370
+
371
+ const marketById = new Map<string, any>();
372
+ for (const m of marketsBody) {
373
+ const id = (m?.id ?? '').toString().toLowerCase();
374
+ if (id) marketById.set(id, m);
375
+ }
200
376
 
201
- const defaultIds = symbols.map(s => this.resolveCoinGeckoId(s));
202
- const idx = requestedId ? defaultIds.indexOf(requestedId) : -1;
203
- if (idx >= 0) {
204
- const requestedSymbol = symbols[idx].toUpperCase();
205
- return stats.filter(s => s.symbol === requestedSymbol);
377
+ const stats = await Promise.all(
378
+ ids.map(async id => {
379
+ const market = marketById.get(id);
380
+ if (!market) throw new Error(`Could not get market data for '${id}'`);
381
+
382
+ const chartUrl = this.cgUrl(`/v3/coins/${encodeURIComponent(id)}/market_chart`, {
383
+ vs_currency: vsCurrency,
384
+ days: 365,
385
+ interval: 'daily'
386
+ });
387
+
388
+ const infoUrl = this.cgUrl(`/v3/coins/${encodeURIComponent(id)}`, {
389
+ localization: false,
390
+ tickers: false,
391
+ market_data: false,
392
+ community_data: false,
393
+ developer_data: false,
394
+ sparkline: false
395
+ });
396
+
397
+ const [chartBody, infoBody] = await Promise.all([
398
+ this.coinGeckoGetJson(chartUrl, headers),
399
+ this.coinGeckoGetJson(infoUrl, headers)
400
+ ]);
401
+
402
+ const prices = Array.isArray(chartBody?.prices) ? chartBody.prices : [];
403
+ let high52w: number | null = null;
404
+ let low52w: number | null = null;
405
+ for (const p of prices) {
406
+ const price = p?.[1];
407
+ if (typeof price !== 'number') continue;
408
+ if (high52w === null || price > high52w) high52w = price;
409
+ if (low52w === null || price < low52w) low52w = price;
206
410
  }
207
411
 
208
- return [];
209
- };
412
+ const about = infoBody?.description?.en;
413
+
414
+ return {
415
+ symbol: (market?.symbol || '').toString().toUpperCase(),
416
+ name: (market?.name || id).toString(),
417
+ image: typeof market?.image === 'string' ? market.image : undefined,
418
+ price: asNumberOrNull(market?.current_price),
419
+ high52w,
420
+ low52w,
421
+ volume24h: asNumberOrNull(market?.total_volume),
422
+ circulatingSupply: asNumberOrNull(market?.circulating_supply),
423
+ marketCap: asNumberOrNull(market?.market_cap),
424
+ lastUpdated: typeof market?.last_updated === 'string' ? market.last_updated : undefined,
425
+ about: typeof about === 'string' ? about : undefined
426
+ } as CoinMarketStats;
427
+ })
428
+ );
429
+
430
+ return stats;
431
+ }
210
432
 
211
- let ids: string[];
212
- try {
213
- ids = symbols.map(s => this.resolveCoinGeckoId(s));
214
- } catch (e) {
215
- return reject(e);
216
- }
433
+ async coinGeckoGetMarketStats(req: CoinGeckoRequest): Promise<CoinMarketStats[]> {
434
+ const currency = (req.params?.['code'] || 'USD').toString().toUpperCase();
435
+ if (!SUPPORTED_FIAT_CODES.has(currency)) throw badRequest(`Unsupported fiat currency code '${currency}'`);
217
436
 
218
- const cacheKey = `cgMarketStats:${vsCurrency}`;
219
- const credentials = this.coinGeckoGetCredentials();
220
- const headers = this.coinGeckoGetHeaders(credentials.API_KEY);
437
+ const vsCurrency = currency.toLowerCase();
221
438
 
222
- this.storage.checkAndUseGlobalCache(cacheKey, Defaults.COIN_GECKO_MARKET_STATS_CACHE_DURATION, async (err, values, oldvalues) => {
223
- if (err) logger.warn('CoinGecko market stats cache check failed: %o', err);
224
- if (values) return resolve(filterResponse(values));
439
+ const { coin: coinParam, chain: chainParam, tokenAddress: tokenAddressParam } = parseCoinChainTokenAddress(req.query);
225
440
 
226
- try {
227
- const marketsUrl = `${credentials.API}/v3/coins/markets?vs_currency=${encodeURIComponent(vsCurrency)}&ids=${encodeURIComponent(
228
- ids.join(',')
229
- )}&order=market_cap_desc&per_page=250&page=1&sparkline=false`;
230
- const marketsBody = await this.coinGeckoGetJson(marketsUrl, headers);
441
+ let ids: string[];
442
+ let cacheKey: string | null = null;
231
443
 
232
- if (!Array.isArray(marketsBody)) throw new Error('Could not get market data');
444
+ if (coinParam || chainParam || tokenAddressParam) {
445
+ const id = await this.resolveCoinGeckoId(coinParam, chainParam, tokenAddressParam);
446
+ ids = [id];
447
+ if (DEFAULT_ID_SET.has(id)) cacheKey = `cgMarketStats:${vsCurrency}:${id}`;
448
+ } else {
449
+ ids = DEFAULT_IDS.slice();
450
+ cacheKey = `cgMarketStats:${vsCurrency}`;
451
+ }
233
452
 
234
- const marketById = new Map<string, any>();
235
- for (const marketItem of marketsBody) {
236
- if (marketItem?.id) marketById.set(marketItem.id, marketItem);
237
- }
453
+ if (!cacheKey) return this.fetchMarketStats(ids, vsCurrency);
238
454
 
239
- const perCoinStats = await Promise.all(
240
- ids.map(async id => {
241
- const marketItem = marketById.get(id);
242
- if (!marketItem) throw new Error(`Could not get market data for '${id}'`);
243
-
244
- const chartUrl = `${credentials.API}/v3/coins/${encodeURIComponent(
245
- id
246
- )}/market_chart?vs_currency=${encodeURIComponent(vsCurrency)}&days=365&interval=daily`;
247
- const infoUrl = `${credentials.API}/v3/coins/${encodeURIComponent(
248
- id
249
- )}?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false&sparkline=false`;
250
-
251
- const [chartBody, infoBody] = await Promise.all([
252
- this.coinGeckoGetJson(chartUrl, headers),
253
- this.coinGeckoGetJson(infoUrl, headers)
254
- ]);
255
-
256
- const prices: Array<[number, number]> = chartBody?.prices || [];
257
- let high52w: number | null = null;
258
- let low52w: number | null = null;
259
- for (const p of prices) {
260
- const price = p?.[1];
261
- if (typeof price !== 'number') continue;
262
- if (high52w === null || price > high52w) high52w = price;
263
- if (low52w === null || price < low52w) low52w = price;
264
- }
265
-
266
- const about = infoBody?.description?.en;
267
-
268
- const stats: CoinMarketStats = {
269
- symbol: (marketItem?.symbol || '').toUpperCase(),
270
- name: marketItem?.name,
271
- image: marketItem?.image,
272
- price: typeof marketItem?.current_price === 'number' ? marketItem.current_price : null,
273
- high52w,
274
- low52w,
275
- volume24h: typeof marketItem?.total_volume === 'number' ? marketItem.total_volume : null,
276
- circulatingSupply:
277
- typeof marketItem?.circulating_supply === 'number' ? marketItem.circulating_supply : null,
278
- marketCap: typeof marketItem?.market_cap === 'number' ? marketItem.market_cap : null,
279
- lastUpdated: marketItem?.last_updated,
280
- about: typeof about === 'string' ? about : undefined
281
- };
282
-
283
- return { id, stats };
284
- })
285
- );
286
-
287
- const order = new Map<string, number>();
288
- let i = 0;
289
- for (const s of symbols) {
290
- order.set(this.resolveCoinGeckoId(s), i);
291
- i++;
292
- }
293
- const sortedStats = perCoinStats
294
- .slice()
295
- .sort((a, b) => {
296
- const ai = order.get(a.id) ?? Number.MAX_SAFE_INTEGER;
297
- const bi = order.get(b.id) ?? Number.MAX_SAFE_INTEGER;
298
- return ai - bi;
299
- })
300
- .map(entry => entry.stats);
301
-
302
- const response = sortedStats;
303
- this.storage.storeGlobalCache(cacheKey, response, storeErr => {
304
- if (storeErr) logger.warn('Could not cache CoinGecko market stats: %o', storeErr);
305
- return resolve(filterResponse(response));
306
- });
307
- } catch (e: any) {
308
- const statusCode = e?.statusCode;
309
- if (statusCode === 429 && oldvalues) {
310
- return resolve(filterResponse(oldvalues));
311
- }
312
- if (oldvalues) {
313
- logger.warn('Using old cached CoinGecko market stats values');
314
- return resolve(filterResponse(oldvalues));
315
- }
316
- return reject(e);
317
- }
318
- });
319
- });
455
+ return this.withGlobalCache(cacheKey, Defaults.COIN_GECKO_MARKET_STATS_CACHE_DURATION, () =>
456
+ this.fetchMarketStats(ids, vsCurrency)
457
+ );
320
458
  }
321
459
 
322
- coinGeckoGetFiatRates(req): Promise<Record<string, Array<{ ts: number; rate: number }>> | Array<{ ts: number; rate: number }>> {
323
- return new Promise((resolve, reject) => {
324
- const currency = (req.params?.['code'] || '').toString().toUpperCase();
325
- if (!SUPPORTED_FIAT_CODES.has(currency)) {
326
- return reject(new Error(`Unsupported fiat currency code '${currency}'`));
327
- }
328
- const vsCurrency = currency.toLowerCase();
329
- const coinParam = (req.query?.coin as string) || null;
330
-
331
- const allowedDays = new Set([1, 7, 30, 90, 365, 1825]);
332
- let days = 100000; // default to all time (daily rates)
333
- if (req.query?.days !== undefined) {
334
- const parsed = +req.query.days;
335
- if (!allowedDays.has(parsed)) return reject(new Error('Invalid days'));
336
- days = parsed;
337
- }
460
+ async coinGeckoGetFiatRates(req: CoinGeckoRequest): Promise<Record<string, FiatRatePoint[]> | FiatRatePoint[]> {
461
+ const currency = (req.params?.['code'] || '').toString().toUpperCase();
462
+ if (!SUPPORTED_FIAT_CODES.has(currency)) throw badRequest(`Unsupported fiat currency code '${currency}'`);
338
463
 
339
- if (coinParam) {
340
- let id: string;
341
- try {
342
- id = this.resolveCoinGeckoId(coinParam);
343
- } catch (e) {
344
- return reject(e);
345
- }
464
+ const vsCurrency = currency.toLowerCase();
346
465
 
347
- this.coinGeckoGetFiatRatesForId({ id, vsCurrency, days })
348
- .then(resolve)
349
- .catch(reject);
466
+ const { coin: coinParam, chain: chainParam, tokenAddress: tokenAddressParam } = parseCoinChainTokenAddress(req.query);
350
467
 
351
- return;
468
+ let days = DEFAULT_ALL_TIME_DAYS;
469
+ try {
470
+ const daysParam = getQueryString(req.query, 'days');
471
+ if (daysParam !== undefined) {
472
+ const parsed = +daysParam;
473
+ if (!ALLOWED_DAYS.has(parsed)) throw badRequest('Invalid days');
474
+ days = parsed;
352
475
  }
476
+ } catch {
477
+ throw badRequest('Invalid days');
478
+ }
353
479
 
354
- const symbols = this.getDefaultMarketStatsSymbols();
355
-
356
- Promise.allSettled(
357
- symbols.map(async symbol => {
358
- const coin = symbol.trim().toLowerCase();
359
- const id = this.resolveCoinGeckoId(symbol);
360
- const rates = await this.coinGeckoGetFiatRatesForId({ id, vsCurrency, days });
361
- return { coin, rates };
362
- })
363
- )
364
- .then(results => {
365
- const out: Record<string, Array<{ ts: number; rate: number }>> = {};
366
-
367
- for (const r of results) {
368
- if (r.status === 'fulfilled') {
369
- out[r.value.coin] = r.value.rates;
370
- }
371
- }
480
+ if (coinParam || chainParam || tokenAddressParam) {
481
+ const id = await this.resolveCoinGeckoId(coinParam, chainParam, tokenAddressParam);
482
+ const useDbCache = DEFAULT_ID_SET.has(id);
483
+ return this.getFiatRatesForId(id, vsCurrency, days, useDbCache);
484
+ }
372
485
 
373
- return resolve(out);
374
- })
375
- .catch(reject);
376
- });
486
+ const tasks = DEFAULT_COIN_ENTRIES.map(([, id]) => this.getFiatRatesForId(id, vsCurrency, days, true));
487
+ const results = await Promise.allSettled(tasks);
488
+
489
+ const out: Record<string, FiatRatePoint[]> = {};
490
+ for (const [i, r] of results.entries()) {
491
+ const coin = DEFAULT_COIN_ENTRIES[i][0];
492
+ if (r.status === 'fulfilled') out[coin] = r.value;
493
+ else {
494
+ const reason: any = r.reason;
495
+ logger.warn('CoinGecko fiat rates fetch failed: %o', {
496
+ coin,
497
+ statusCode: reason?.statusCode,
498
+ err: reason?.message || reason
499
+ });
500
+ }
501
+ }
502
+
503
+ return out;
377
504
  }
378
505
 
379
- coinGeckoGetTokens(req): Promise<Array<{
380
- chainId: string;
381
- address: string;
382
- symbol: string;
383
- name: string;
384
- decimals: number;
385
- logoURI: string;
386
- }>> {
387
- return new Promise((resolve, reject) => {
388
- const chain = req.params?.['chain'] || 'eth';
389
- const cacheKey = `cgTokenList:${chain}`;
390
- const credentials = this.coinGeckoGetCredentials();
391
- const headers = this.coinGeckoGetHeaders(credentials.API_KEY);
392
-
393
- this.storage.checkAndUseGlobalCache(cacheKey, Defaults.COIN_GECKO_CACHE_DURATION, (err, values, oldvalues) => {
394
- if (err) logger.warn('CoinGecko token list cache check failed: %o', err);
395
- if (values) return resolve(values);
396
-
397
- const assetPlatformMap = {
398
- eth: 'ethereum',
399
- matic: 'polygon-pos',
400
- pol: 'polygon-pos',
401
- arb: 'arbitrum-one',
402
- base: 'base',
403
- op: 'optimistic-ethereum',
404
- sol: 'solana',
405
- };
406
-
407
- const assetId = assetPlatformMap[chain];
408
- if (!assetId) return reject(new Error(`Unsupported chain '${chain}'`));
409
-
410
- const URL: string = `${credentials.API}/v3/token_lists/${assetId}/all.json`;
411
-
412
- this.request.get(
413
- URL,
414
- {
415
- headers,
416
- json: true
417
- },
418
- (err, data) => {
419
- const tokens = data?.body?.tokens;
420
- const status = data?.body?.status;
421
- if (err) {
422
- logger.warn('An error occurred while retrieving the CoinGecko token list: %o', err);
423
- if (oldvalues) {
424
- logger.warn('Using old cached CoinGecko token list values');
425
- return resolve(oldvalues);
426
- }
427
- return reject(err.body ?? err);
428
- } else if (status?.error_code === 429 && oldvalues) {
429
- return resolve(oldvalues);
430
- } else {
431
- if (!tokens) {
432
- if (oldvalues) {
433
- logger.warn('No token list available... using old cached CoinGecko token list values');
434
- return resolve(oldvalues);
435
- }
436
- return reject(new Error(`Could not get tokens list. Code: ${status?.error_code}. Error: ${status?.error_message || 'Unknown error'}`));
437
- }
438
- const updatedTokens = tokens.map(token => {
439
- if (token.logoURI?.includes('/thumb/')) {
440
- token.logoURI = token.logoURI.replace('/thumb/', '/large/');
441
- }
442
- return token;
443
- });
444
- this.storage.storeGlobalCache(cacheKey, updatedTokens, storeErr => {
445
- if (storeErr) logger.warn('Could not cache CoinGecko token list: %o', storeErr);
446
- return resolve(updatedTokens);
447
- });
448
- }
449
- });
506
+ async coinGeckoGetTokens(req: CoinGeckoRequest): Promise<TokenListToken[]> {
507
+ const chain = (req.params?.['chain'] || 'eth').toString();
508
+
509
+ const platformId = normalizeChainSafe(chain);
510
+ if (!platformId) {
511
+ throw badRequest(`Unsupported chain '${chain}'`);
512
+ }
513
+
514
+ const cacheKey = `cgTokenList:${platformId}`;
515
+
516
+ return this.withGlobalCache(cacheKey, Defaults.COIN_GECKO_CACHE_DURATION, async () => {
517
+ const { API_KEY } = this.coinGeckoGetCredentials();
518
+ const headers = this.coinGeckoGetHeaders(API_KEY);
519
+
520
+ const url = this.cgUrl(`/v3/token_lists/${encodeURIComponent(platformId)}/all.json`);
521
+ const body = await this.coinGeckoGetJson(url, headers);
522
+
523
+ const tokens = Array.isArray(body?.tokens) ? body.tokens : null;
524
+ if (!tokens) throw new Error('Could not get tokens list');
525
+
526
+ return tokens.map((t: any) => {
527
+ if (!t || typeof t !== 'object') return t;
528
+ const logoURI = t?.logoURI?.includes('/thumb/') ? t.logoURI.replace('/thumb/', '/large/') : t.logoURI;
529
+ return { ...t, logoURI };
450
530
  });
451
531
  });
452
532
  }
453
- }
533
+ }