@bitpay-labs/bitcore-wallet-service 11.5.2 → 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.
- package/package.json +4 -4
- package/src/externalservices/coingecko.ts +448 -368
- package/src/lib/common/utils.ts +37 -32
- package/src/lib/tss.ts +18 -10
- package/test/integration/externalservices/coinGecko.test.ts +811 -156
- package/test/utils.test.ts +5 -0
- package/ts_build/package.json +3 -3
- package/ts_build/src/externalservices/coingecko.d.ts +24 -20
- package/ts_build/src/externalservices/coingecko.d.ts.map +1 -1
- package/ts_build/src/externalservices/coingecko.js +356 -306
- package/ts_build/src/externalservices/coingecko.js.map +1 -1
- package/ts_build/src/lib/common/index.d.ts +2 -2
- package/ts_build/src/lib/common/utils.d.ts +2 -2
- package/ts_build/src/lib/common/utils.d.ts.map +1 -1
- package/ts_build/src/lib/common/utils.js +28 -29
- package/ts_build/src/lib/common/utils.js.map +1 -1
- package/ts_build/src/lib/tss.d.ts.map +1 -1
- package/ts_build/src/lib/tss.js +2 -10
- package/ts_build/src/lib/tss.js.map +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.d.ts +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.d.ts.map +1 -1
- package/ts_build/test/integration/externalservices/coinGecko.test.js +710 -149
- package/ts_build/test/integration/externalservices/coinGecko.test.js.map +1 -1
- package/ts_build/test/utils.test.js +4 -0
- package/ts_build/test/utils.test.js.map +1 -1
- package/scripts/delme.ts +0 -468
- package/scripts/findEmail.js +0 -32
- package/scripts/getSolStats2.js +0 -81
- package/scripts/walletStats.js +0 -290
- package/src/types/emailservice.d.ts +0 -11
- package/src/types/notifications.d.ts +0 -8
- package/src/types/pushnotificationservice.d.ts +0 -51
- package/ts_build/scripts/delme.d.ts +0 -2
- package/ts_build/scripts/delme.d.ts.map +0 -1
- package/ts_build/scripts/delme.js +0 -453
- package/ts_build/scripts/delme.js.map +0 -1
|
@@ -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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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,
|
|
43
|
-
if (err) logger.warn('
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
212
|
+
logger.warn('Using old cached %s values', logName);
|
|
69
213
|
return resolve(oldvalues);
|
|
70
214
|
}
|
|
71
|
-
|
|
215
|
+
reject(e);
|
|
72
216
|
}
|
|
73
217
|
});
|
|
74
218
|
});
|
|
75
219
|
}
|
|
76
220
|
|
|
77
|
-
private
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
'Accept': 'application/json',
|
|
92
|
-
'x-cg-pro-api-key': apiKey
|
|
93
|
-
};
|
|
253
|
+
resolve(body);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
94
256
|
}
|
|
95
257
|
|
|
96
|
-
private
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
if (
|
|
315
|
+
const defaultId = DEFAULT_COINS[sym];
|
|
316
|
+
if (defaultId) return defaultId;
|
|
121
317
|
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
const credentials = this.coinGeckoGetCredentials();
|
|
220
|
-
const headers = this.coinGeckoGetHeaders(credentials.API_KEY);
|
|
437
|
+
const vsCurrency = currency.toLowerCase();
|
|
221
438
|
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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,
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
.then(resolve)
|
|
349
|
-
.catch(reject);
|
|
466
|
+
const { coin: coinParam, chain: chainParam, tokenAddress: tokenAddressParam } = parseCoinChainTokenAddress(req.query);
|
|
350
467
|
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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<
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
const headers = this.coinGeckoGetHeaders(
|
|
392
|
-
|
|
393
|
-
this.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|