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

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