@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.
- package/package.json +2 -2
- 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
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
171
|
+
this.storage.checkAndUseGlobalCache(cacheKey, duration, async (err, values, oldvalues) => {
|
|
56
172
|
if (err)
|
|
57
|
-
logger_1.default.warn('
|
|
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
|
|
62
|
-
|
|
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
|
|
71
|
-
|
|
180
|
+
logger_1.default.warn('Could not cache %s: %o', logName, storeErr);
|
|
181
|
+
resolve(fetched);
|
|
72
182
|
});
|
|
73
183
|
}
|
|
74
184
|
catch (e) {
|
|
75
|
-
|
|
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
|
|
188
|
+
logger_1.default.warn('Using old cached %s values', logName);
|
|
81
189
|
return resolve(oldvalues);
|
|
82
190
|
}
|
|
83
|
-
|
|
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
|
-
|
|
224
|
+
resolve(body);
|
|
166
225
|
});
|
|
167
226
|
});
|
|
168
227
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
}
|