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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,65 +37,207 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  const chai = __importStar(require("chai"));
40
- require("chai/register-should");
40
+ const sinon_1 = __importDefault(require("sinon"));
41
41
  const util_1 = __importDefault(require("util"));
42
42
  const server_1 = require("../../../src/lib/server");
43
43
  const TestData = __importStar(require("../../testdata"));
44
44
  const helpers_1 = __importDefault(require("../helpers"));
45
45
  const config_1 = __importDefault(require("../../../src/config"));
46
+ const logger_1 = __importDefault(require("../../../src/lib/logger"));
46
47
  const should = chai.should();
48
+ function toURL(url) {
49
+ try {
50
+ return new URL(url);
51
+ }
52
+ catch {
53
+ return new URL(url, 'https://example.com');
54
+ }
55
+ }
56
+ function parseRequestedIdsFromMarketsUrl(url) {
57
+ const u = toURL(url);
58
+ const ids = u.searchParams.get('ids');
59
+ return ids ? ids.split(',').filter(Boolean) : [];
60
+ }
61
+ function matchCoinIdFromMarketChartUrl(url) {
62
+ const u = toURL(url);
63
+ const parts = u.pathname.split('/').filter(Boolean);
64
+ const coinsIdx = parts.indexOf('coins');
65
+ if (coinsIdx === -1)
66
+ return undefined;
67
+ const id = parts[coinsIdx + 1];
68
+ const next = parts[coinsIdx + 2];
69
+ if (id && next === 'market_chart')
70
+ return decodeURIComponent(id);
71
+ return undefined;
72
+ }
73
+ function matchCoinIdFromInfoUrl(url) {
74
+ const u = toURL(url);
75
+ const parts = u.pathname.split('/').filter(Boolean);
76
+ const coinsIdx = parts.indexOf('coins');
77
+ if (coinsIdx === -1)
78
+ return undefined;
79
+ const id = parts[coinsIdx + 1];
80
+ if (!id)
81
+ return undefined;
82
+ if (id === 'list' || id === 'markets')
83
+ return undefined;
84
+ const tail = parts.slice(coinsIdx + 2);
85
+ if (tail.length === 0)
86
+ return decodeURIComponent(id);
87
+ return undefined;
88
+ }
89
+ function matchCoinContract(url) {
90
+ const u = toURL(url);
91
+ const parts = u.pathname.split('/').filter(Boolean);
92
+ const coinsIdx = parts.indexOf('coins');
93
+ if (coinsIdx === -1)
94
+ return undefined;
95
+ const platformId = parts[coinsIdx + 1];
96
+ const next = parts[coinsIdx + 2];
97
+ const address = parts[coinsIdx + 3];
98
+ if (platformId && next === 'contract' && address) {
99
+ return { platformId: decodeURIComponent(platformId), address: decodeURIComponent(address) };
100
+ }
101
+ return undefined;
102
+ }
103
+ function matchTokenListPlatformId(url) {
104
+ const u = toURL(url);
105
+ const parts = u.pathname.split('/').filter(Boolean);
106
+ const idx = parts.indexOf('token_lists');
107
+ if (idx === -1)
108
+ return undefined;
109
+ const platformId = parts[idx + 1];
110
+ const file = parts[idx + 2];
111
+ if (platformId && file === 'all.json')
112
+ return decodeURIComponent(platformId);
113
+ return undefined;
114
+ }
115
+ function createCoinGeckoRequestStub(handlers) {
116
+ return {
117
+ get: (url, _opts, cb) => {
118
+ try {
119
+ const u = toURL(url);
120
+ const pathname = u.pathname;
121
+ if (pathname.endsWith('/v3/coins/markets')) {
122
+ const ids = parseRequestedIdsFromMarketsUrl(url);
123
+ return cb(null, { body: handlers.onMarkets ? handlers.onMarkets(ids) : [] });
124
+ }
125
+ const contract = matchCoinContract(url);
126
+ if (contract) {
127
+ return cb(null, {
128
+ body: handlers.onCoinContract ? handlers.onCoinContract(contract.platformId, contract.address) : {}
129
+ });
130
+ }
131
+ const marketChartId = matchCoinIdFromMarketChartUrl(url);
132
+ if (marketChartId) {
133
+ return cb(null, { body: handlers.onMarketChart ? handlers.onMarketChart(marketChartId) : { prices: [] } });
134
+ }
135
+ const coinInfoId = matchCoinIdFromInfoUrl(url);
136
+ if (coinInfoId) {
137
+ return cb(null, { body: handlers.onCoinInfo ? handlers.onCoinInfo(coinInfoId) : {} });
138
+ }
139
+ const tokenListPlatformId = matchTokenListPlatformId(url);
140
+ if (tokenListPlatformId) {
141
+ return cb(null, { body: handlers.onTokenList ? handlers.onTokenList(tokenListPlatformId) : { tokens: [] } });
142
+ }
143
+ return cb(new Error('unexpected url'));
144
+ }
145
+ catch (e) {
146
+ return cb(e);
147
+ }
148
+ }
149
+ };
150
+ }
47
151
  describe('CoinGecko integration', function () {
48
152
  this.timeout(5000);
153
+ const sandbox = sinon_1.default.createSandbox();
49
154
  let server;
50
155
  let wallet;
51
156
  let fakeRequest;
52
- let req;
157
+ const cg = () => server.externalServices.coinGecko;
158
+ const getMarketStats = (query, code = 'USD') => cg().coinGeckoGetMarketStats({ params: { code }, query });
159
+ const getFiatRates = (query, code = 'USD') => cg().coinGeckoGetFiatRates({ params: { code }, query });
160
+ const getTokens = (req = {}) => cg().coinGeckoGetTokens(req);
161
+ function forceGlobalCacheMisses() {
162
+ sandbox.stub(cg().storage, 'checkAndUseGlobalCache').callsFake((_key, _duration, cb) => cb(null, null, null));
163
+ sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
164
+ }
53
165
  before(async () => {
54
166
  await helpers_1.default.before();
55
167
  });
56
168
  beforeEach(async () => {
57
169
  config_1.default.coinGecko = {
58
- api: 'xxxx',
59
- };
60
- fakeRequest = {
61
- get: (_url, _opts, _cb) => {
62
- return _cb(null, { body: { tokens: [{
63
- chainId: 1,
64
- address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
65
- symbol: 'USDC',
66
- name: 'USD Coin',
67
- decimals: 6,
68
- logoURI: 'xxxxxx'
69
- }] } });
70
- },
170
+ api: 'https://pro-api.coingecko.com/api'
71
171
  };
72
172
  await helpers_1.default.beforeEach();
73
173
  ({ wallet } = await helpers_1.default.createAndJoinWallet(1, 1));
74
174
  const priv = TestData.copayers[0].privKey_1H_0;
75
175
  const sig = helpers_1.default.signMessage('hello world', priv);
76
- (server = await util_1.default.promisify(server_1.WalletService.getInstanceWithAuth).call(server_1.WalletService, {
176
+ server = await util_1.default.promisify(server_1.WalletService.getInstanceWithAuth).call(server_1.WalletService, {
77
177
  copayerId: wallet.copayers[0].id,
78
178
  message: 'hello world',
79
179
  signature: sig,
80
180
  clientVersion: 'bwc-2.0.0',
81
- walletId: '123',
82
- }));
181
+ walletId: '123'
182
+ });
83
183
  });
84
184
  after(async () => {
85
185
  await helpers_1.default.after();
86
186
  });
187
+ afterEach(() => {
188
+ sandbox.restore();
189
+ });
87
190
  describe('#coinGeckoGetTokenData', () => {
88
191
  beforeEach(() => {
89
- server.externalServices.coinGecko.request = fakeRequest;
192
+ fakeRequest = createCoinGeckoRequestStub({
193
+ onTokenList: _platformId => ({
194
+ tokens: [
195
+ {
196
+ chainId: 1,
197
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
198
+ symbol: 'USDC',
199
+ name: 'USD Coin',
200
+ decimals: 6,
201
+ logoURI: 'xxxxxx'
202
+ }
203
+ ]
204
+ })
205
+ });
206
+ cg().request = fakeRequest;
90
207
  });
91
208
  it('should get coinGecko list of tokens data', async () => {
92
- const data = await server.externalServices.coinGecko.coinGeckoGetTokens({});
209
+ const data = await getTokens({});
93
210
  should.exist(data);
94
211
  });
212
+ it('should map chain aliases to the correct token list platform', async () => {
213
+ forceGlobalCacheMisses();
214
+ let platformSeen;
215
+ fakeRequest = createCoinGeckoRequestStub({
216
+ onTokenList: platformId => {
217
+ platformSeen = platformId;
218
+ return {
219
+ tokens: [
220
+ {
221
+ chainId: 137,
222
+ address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
223
+ symbol: 'USDC',
224
+ name: 'USD Coin',
225
+ decimals: 6,
226
+ logoURI: 'xxxxxx'
227
+ }
228
+ ]
229
+ };
230
+ }
231
+ });
232
+ cg().request = fakeRequest;
233
+ const data = await getTokens({ params: { chain: 'pol' } });
234
+ should.exist(data);
235
+ platformSeen.should.equal('polygon-pos');
236
+ });
95
237
  it('should return error if coinGecko is commented in config', async () => {
96
238
  config_1.default.coinGecko = undefined;
97
239
  try {
98
- await server.externalServices.coinGecko.coinGeckoGetTokens({});
240
+ await getTokens({});
99
241
  should.fail('should have thrown');
100
242
  }
101
243
  catch (err) {
@@ -105,102 +247,293 @@ describe('CoinGecko integration', function () {
105
247
  });
106
248
  describe('#coinGeckoGetMarketStats', () => {
107
249
  beforeEach(() => {
108
- config_1.default.coinGecko = {
109
- api: 'https://pro-api.coingecko.com/api',
110
- };
111
- fakeRequest = {
112
- get: (url, _opts, cb) => {
113
- const defaultIds = [
114
- 'bitcoin',
115
- 'ethereum',
116
- 'ripple',
117
- 'solana',
118
- 'dogecoin',
119
- 'bitcoin-cash',
120
- 'shiba-inu',
121
- 'polygon-ecosystem-token',
122
- 'apecoin',
123
- 'litecoin',
124
- 'wrapped-bitcoin',
125
- 'weth'
126
- ];
127
- const marketById = {
128
- bitcoin: {
129
- id: 'bitcoin',
130
- symbol: 'btc',
131
- name: 'Bitcoin',
132
- image: 'btc.png',
133
- current_price: 100,
134
- total_volume: 200,
135
- circulating_supply: 300,
136
- market_cap: 400,
137
- last_updated: '2020-01-01T00:00:00.000Z'
138
- },
139
- ethereum: {
140
- id: 'ethereum',
141
- symbol: 'eth',
142
- name: 'Ethereum',
143
- image: 'eth.png',
144
- current_price: 10,
145
- total_volume: 20,
146
- circulating_supply: 30,
147
- market_cap: 40,
148
- last_updated: '2020-01-01T00:00:00.000Z'
149
- }
150
- };
151
- const aboutById = {
152
- bitcoin: 'About BTC',
153
- ethereum: 'About ETH'
154
- };
155
- if (url.includes('/v3/coins/markets')) {
156
- return cb(null, {
157
- body: [
158
- ...defaultIds.map(id => marketById[id]
159
- ? marketById[id]
160
- : {
161
- id,
162
- symbol: id.replace(/[^a-z]/g, '').slice(0, 4),
163
- name: id,
164
- image: `${id}.png`,
165
- current_price: 1,
166
- total_volume: 1,
167
- circulating_supply: 1,
168
- market_cap: 1,
169
- last_updated: '2020-01-01T00:00:00.000Z'
170
- })
171
- ]
172
- });
173
- }
174
- const mChart = url.match(/\/v3\/coins\/([^/]+)\/market_chart\?/);
175
- if (mChart?.[1]) {
176
- const id = decodeURIComponent(mChart[1]);
177
- if (id === 'bitcoin')
178
- return cb(null, { body: { prices: [[0, 90], [1, 110], [2, 95]] } });
179
- if (id === 'ethereum')
180
- return cb(null, { body: { prices: [[0, 9], [1, 12], [2, 8]] } });
181
- return cb(null, { body: { prices: [[0, 1], [1, 1], [2, 1]] } });
182
- }
183
- const mInfo = url.match(/\/v3\/coins\/([^?]+)\?/);
184
- if (mInfo?.[1]) {
185
- const id = decodeURIComponent(mInfo[1]);
186
- return cb(null, { body: { description: { en: aboutById[id] || `About ${id}` } } });
187
- }
188
- return cb(new Error('unexpected url'));
250
+ const marketById = {
251
+ bitcoin: {
252
+ id: 'bitcoin',
253
+ symbol: 'btc',
254
+ name: 'Bitcoin',
255
+ image: 'btc.png',
256
+ current_price: 100,
257
+ total_volume: 200,
258
+ circulating_supply: 300,
259
+ market_cap: 400,
260
+ last_updated: '2020-01-01T00:00:00.000Z'
189
261
  }
190
262
  };
191
- server.externalServices.coinGecko.request = fakeRequest;
263
+ fakeRequest = createCoinGeckoRequestStub({
264
+ onMarkets: ids => ids.map(id => marketById[id]
265
+ ? marketById[id]
266
+ : {
267
+ id,
268
+ symbol: id.replace(/[^a-z]/g, '').slice(0, 4),
269
+ name: id,
270
+ image: `${id}.png`,
271
+ current_price: 1,
272
+ total_volume: 1,
273
+ circulating_supply: 1,
274
+ market_cap: 1,
275
+ last_updated: '2020-01-01T00:00:00.000Z'
276
+ }),
277
+ onMarketChart: id => {
278
+ if (id === 'bitcoin')
279
+ return { prices: [[0, 90], [1, 110], [2, 95]] };
280
+ return { prices: [[0, 1], [1, 1], [2, 1]] };
281
+ },
282
+ onCoinInfo: id => ({ description: { en: id === 'bitcoin' ? 'About BTC' : `About ${id}` } })
283
+ });
284
+ cg().request = fakeRequest;
192
285
  });
193
286
  it('should get market stats for a single coin', async () => {
194
- const data = await server.externalServices.coinGecko.coinGeckoGetMarketStats({ params: { code: 'ARS' }, query: { coin: 'btc' } });
287
+ const data = await getMarketStats({ coin: 'btc' }, 'ARS');
195
288
  should.exist(data);
196
289
  data.should.have.length(1);
197
290
  data[0].about.should.equal('About BTC');
198
291
  data[0].low52w.should.equal(90);
199
292
  });
293
+ it('should reject non-default coin symbols without tokenAddress', async () => {
294
+ try {
295
+ await getMarketStats({ coin: 'WBTC' });
296
+ should.fail('should have thrown');
297
+ }
298
+ catch (err) {
299
+ err.message.should.equal('Unsupported coin. For token symbols, pass `chain` and `tokenAddress`.');
300
+ }
301
+ });
302
+ it('should require tokenAddress when chain is provided for market stats', async () => {
303
+ try {
304
+ await getMarketStats({ coin: ['btc'], chain: ['eth'] });
305
+ should.fail('should have thrown');
306
+ }
307
+ catch (err) {
308
+ err.message.should.equal('chain is only supported for token lookups; provide tokenAddress or omit chain for native coins');
309
+ }
310
+ });
311
+ it('should reject invalid chain values for default marketstats coins', async () => {
312
+ try {
313
+ await getMarketStats({ coin: 'BTC', chain: '!!!' });
314
+ should.fail('should have thrown');
315
+ }
316
+ catch (err) {
317
+ err.message.should.equal('Unsupported chain');
318
+ }
319
+ });
320
+ it('should resolve a token unambiguously when tokenAddress is provided', async () => {
321
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
322
+ fakeRequest = createCoinGeckoRequestStub({
323
+ onCoinContract: (platformId, address) => {
324
+ platformId.should.equal('arbitrum-one');
325
+ address.should.equal(tokenAddress);
326
+ return {
327
+ id: 'arbitrum-bridged-usdc-arbitrum',
328
+ symbol: 'usdc.e',
329
+ asset_platform_id: 'arbitrum-one'
330
+ };
331
+ },
332
+ onMarkets: ids => ids.map(id => ({
333
+ id,
334
+ symbol: 'usdc.e',
335
+ name: 'Arbitrum Bridged USDC (Arbitrum)',
336
+ image: `${id}.png`,
337
+ current_price: 1,
338
+ total_volume: 1,
339
+ circulating_supply: 1,
340
+ market_cap: 1,
341
+ last_updated: '2020-01-01T00:00:00.000Z'
342
+ })),
343
+ onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
344
+ onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'arbitrum-one' })
345
+ });
346
+ cg().request = fakeRequest;
347
+ const data = await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
348
+ should.exist(data);
349
+ data.should.have.length(1);
350
+ data[0].name.should.equal('Arbitrum Bridged USDC (Arbitrum)');
351
+ });
352
+ it('should resolve market stats by chain + tokenAddress when coin is omitted', async () => {
353
+ const tokenAddress = '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8';
354
+ fakeRequest = createCoinGeckoRequestStub({
355
+ onCoinContract: (platformId, address) => {
356
+ platformId.should.equal('arbitrum-one');
357
+ address.should.equal(tokenAddress);
358
+ return {
359
+ id: 'bridged-usdc-arbitrum',
360
+ symbol: 'usdc',
361
+ asset_platform_id: 'arbitrum-one'
362
+ };
363
+ },
364
+ onMarkets: ids => ids.map(id => ({
365
+ id,
366
+ symbol: 'usdc',
367
+ name: 'Arbitrum Bridged USDC (Arbitrum)',
368
+ image: `${id}.png`,
369
+ current_price: 1,
370
+ total_volume: 1,
371
+ circulating_supply: 1,
372
+ market_cap: 1,
373
+ last_updated: '2020-01-01T00:00:00.000Z'
374
+ })),
375
+ onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
376
+ onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'arbitrum-one' })
377
+ });
378
+ cg().request = fakeRequest;
379
+ const data = await getMarketStats({ chain: 'arb', tokenAddress });
380
+ should.exist(data);
381
+ data.should.have.length(1);
382
+ data[0].name.should.equal('Arbitrum Bridged USDC (Arbitrum)');
383
+ });
384
+ it('should map pol alias to polygon-pos for token marketstats lookup', async () => {
385
+ const tokenAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
386
+ fakeRequest = createCoinGeckoRequestStub({
387
+ onCoinContract: (platformId, address) => {
388
+ platformId.should.equal('polygon-pos');
389
+ address.should.equal(tokenAddress);
390
+ return {
391
+ id: 'usd-coin',
392
+ symbol: 'usdc',
393
+ asset_platform_id: 'polygon-pos'
394
+ };
395
+ },
396
+ onMarkets: ids => ids.map(id => ({
397
+ id,
398
+ symbol: 'usdc',
399
+ name: 'USD Coin (PoS)',
400
+ image: `${id}.png`,
401
+ current_price: 1,
402
+ total_volume: 1,
403
+ circulating_supply: 1,
404
+ market_cap: 1,
405
+ last_updated: '2020-01-01T00:00:00.000Z'
406
+ })),
407
+ onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
408
+ onCoinInfo: _id => ({ description: { en: 'About token' }, asset_platform_id: 'polygon-pos' })
409
+ });
410
+ cg().request = fakeRequest;
411
+ const data = await getMarketStats({ chain: 'pol', tokenAddress });
412
+ should.exist(data);
413
+ data.should.have.length(1);
414
+ data[0].name.should.equal('USD Coin (PoS)');
415
+ });
416
+ it('should reject tokenAddress when returned token chain does not match chain', async () => {
417
+ forceGlobalCacheMisses();
418
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
419
+ fakeRequest = createCoinGeckoRequestStub({
420
+ onCoinContract: () => ({
421
+ id: 'arbitrum-bridged-usdc-arbitrum',
422
+ symbol: 'usdc.e',
423
+ asset_platform_id: 'ethereum'
424
+ })
425
+ });
426
+ cg().request = fakeRequest;
427
+ try {
428
+ await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
429
+ should.fail('should have thrown');
430
+ }
431
+ catch (err) {
432
+ err.message.should.equal('tokenAddress does not match chain');
433
+ }
434
+ });
435
+ it('should map tokenAddress contract 404s to Unsupported tokenAddress', async () => {
436
+ forceGlobalCacheMisses();
437
+ const tokenAddress = '0x000000000000000000000000000000000000dead';
438
+ fakeRequest = createCoinGeckoRequestStub({
439
+ onCoinContract: () => ({
440
+ status: { error_code: 404, error_message: 'not found' }
441
+ })
442
+ });
443
+ cg().request = fakeRequest;
444
+ try {
445
+ await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
446
+ should.fail('should have thrown');
447
+ }
448
+ catch (err) {
449
+ err.message.should.equal('Unsupported tokenAddress');
450
+ }
451
+ });
452
+ it('should reject invalid EVM tokenAddress format before lookup', async () => {
453
+ const getSpy = sandbox.spy();
454
+ cg().request = { get: getSpy };
455
+ try {
456
+ await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress: 'invalid-evm-address' });
457
+ should.fail('should have thrown');
458
+ }
459
+ catch (err) {
460
+ err.message.should.equal('Invalid tokenAddress');
461
+ err.statusCode.should.equal(400);
462
+ }
463
+ getSpy.callCount.should.equal(0);
464
+ });
465
+ it('should reject tokenAddress when returned token symbol does not match coin', async () => {
466
+ forceGlobalCacheMisses();
467
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
468
+ fakeRequest = createCoinGeckoRequestStub({
469
+ onCoinContract: () => ({
470
+ id: 'arbitrum-bridged-usdc-arbitrum',
471
+ symbol: 'usdc.e'
472
+ })
473
+ });
474
+ cg().request = fakeRequest;
475
+ try {
476
+ await getMarketStats({ coin: 'WBTC', chain: 'arb', tokenAddress });
477
+ should.fail('should have thrown');
478
+ }
479
+ catch (err) {
480
+ err.message.should.equal('tokenAddress does not match coin');
481
+ }
482
+ });
483
+ it('should use DB cache for default marketstats coin', async () => {
484
+ const checkCacheStub = sandbox
485
+ .stub(cg().storage, 'checkAndUseGlobalCache')
486
+ .callsFake((_key, _duration, cb) => cb(null, null, null));
487
+ const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
488
+ const data = await getMarketStats({ coin: 'btc' });
489
+ should.exist(data);
490
+ checkCacheStub.callCount.should.equal(1);
491
+ checkCacheStub.getCall(0).args[0].should.equal('cgMarketStats:usd:bitcoin');
492
+ storeCacheStub.callCount.should.equal(1);
493
+ storeCacheStub.getCall(0).args[0].should.equal('cgMarketStats:usd:bitcoin');
494
+ });
495
+ it('should bypass DB cache for token marketstats requests', async () => {
496
+ const checkCacheStub = sandbox
497
+ .stub(cg().storage, 'checkAndUseGlobalCache')
498
+ .callsFake((_key, _duration, cb) => cb(null, null, null));
499
+ const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
500
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
501
+ fakeRequest = createCoinGeckoRequestStub({
502
+ onCoinContract: (platformId, address) => {
503
+ platformId.should.equal('arbitrum-one');
504
+ address.should.equal(tokenAddress);
505
+ return {
506
+ id: 'arbitrum-bridged-usdc-arbitrum',
507
+ symbol: 'usdc.e',
508
+ asset_platform_id: 'arbitrum-one'
509
+ };
510
+ },
511
+ onMarkets: ids => ids.map(id => ({
512
+ id,
513
+ symbol: 'usdc.e',
514
+ name: 'Arbitrum Bridged USDC (Arbitrum)',
515
+ image: `${id}.png`,
516
+ current_price: 1,
517
+ total_volume: 1,
518
+ circulating_supply: 1,
519
+ market_cap: 1,
520
+ last_updated: '2020-01-01T00:00:00.000Z'
521
+ })),
522
+ onMarketChart: _id => ({ prices: [[0, 1], [1, 1]] }),
523
+ onCoinInfo: _id => ({ description: { en: 'About token' } })
524
+ });
525
+ cg().request = fakeRequest;
526
+ const data = await getMarketStats({ coin: 'USDC.e', chain: 'arb', tokenAddress });
527
+ should.exist(data);
528
+ const checkedKeys = checkCacheStub.getCalls().map(c => c.args[0]);
529
+ const storedKeys = storeCacheStub.getCalls().map(c => c.args[0]);
530
+ checkedKeys.some(k => typeof k === 'string' && k.startsWith('cgMarketStats:')).should.equal(false);
531
+ storedKeys.some(k => typeof k === 'string' && k.startsWith('cgMarketStats:')).should.equal(false);
532
+ });
200
533
  it('should return error if coinGecko is commented in config', async () => {
201
534
  config_1.default.coinGecko = undefined;
202
535
  try {
203
- await server.externalServices.coinGecko.coinGeckoGetMarketStats({ params: { code: 'USD' }, query: { coin: 'btc' } });
536
+ await getMarketStats({ coin: 'btc' });
204
537
  should.fail('should have thrown');
205
538
  }
206
539
  catch (err) {
@@ -210,77 +543,282 @@ describe('CoinGecko integration', function () {
210
543
  });
211
544
  describe('#coinGeckoGetFiatRates', () => {
212
545
  beforeEach(() => {
213
- config_1.default.coinGecko = {
214
- api: 'https://pro-api.coingecko.com/api',
215
- };
216
- fakeRequest = {
217
- get: (url, _opts, cb) => {
218
- const mChart = url.match(/\/v3\/coins\/([^/]+)\/market_chart\?/);
219
- if (mChart?.[1]) {
220
- const id = decodeURIComponent(mChart[1]);
221
- if (id === 'bitcoin')
222
- return cb(null, { body: { prices: [[1, 100], [2, 110]] } });
223
- if (id === 'bitcoin-cash')
224
- return cb(null, { body: { prices: [[1, 200], [2, 210]] } });
225
- return cb(null, { body: { prices: [[1, 1], [2, 1]] } });
226
- }
227
- return cb(new Error('unexpected url'));
228
- }
546
+ const pricesById = {
547
+ bitcoin: [[1, 100], [2, 110]],
548
+ 'bitcoin-cash': [[1, 200], [2, 210]]
229
549
  };
230
- server.externalServices.coinGecko.request = fakeRequest;
550
+ fakeRequest = createCoinGeckoRequestStub({
551
+ onMarketChart: id => ({ prices: pricesById[id] || [[1, 1], [2, 1]] }),
552
+ });
553
+ cg().request = fakeRequest;
231
554
  });
232
555
  it('should get fiat rates with default days', async () => {
233
- const data = await server.externalServices.coinGecko.coinGeckoGetFiatRates({
234
- params: { code: 'USD' },
235
- query: { coin: 'BTC' }
236
- });
556
+ const data = await getFiatRates({ coin: 'BTC' });
237
557
  should.exist(data);
238
558
  data.should.deep.equal([
239
559
  { ts: 1, rate: 100 },
240
560
  { ts: 2, rate: 110 }
241
561
  ]);
242
562
  });
563
+ it('should reject invalid chain values for default coins', async () => {
564
+ try {
565
+ await getFiatRates({ coin: 'BTC', chain: '!!!' });
566
+ should.fail('should have thrown');
567
+ }
568
+ catch (err) {
569
+ err.message.should.equal('Unsupported chain');
570
+ }
571
+ });
572
+ it('should require tokenAddress when chain is provided for fiat rates', async () => {
573
+ try {
574
+ await getFiatRates({ coin: 'BTC', chain: 'eth' });
575
+ should.fail('should have thrown');
576
+ }
577
+ catch (err) {
578
+ err.message.should.equal('chain is only supported for token lookups; provide tokenAddress or omit chain for native coins');
579
+ }
580
+ });
243
581
  it('should get fiat rates with explicit days', async () => {
244
- const data = await server.externalServices.coinGecko.coinGeckoGetFiatRates({
245
- params: { code: 'USD' },
246
- query: { coin: 'BTC', days: 365 }
247
- });
582
+ const data = await getFiatRates({ coin: 'BTC', days: 365 });
248
583
  should.exist(data);
249
584
  data.should.have.length(2);
250
585
  data[0].rate.should.equal(100);
251
586
  });
252
587
  it('should support BCH coin mapping', async () => {
253
- const data = await server.externalServices.coinGecko.coinGeckoGetFiatRates({
254
- params: { code: 'USD' },
255
- query: { coin: 'BCH' }
256
- });
588
+ const data = await getFiatRates({ coin: 'BCH' });
257
589
  should.exist(data);
258
590
  data[0].rate.should.equal(200);
259
591
  });
260
- it('should return error on invalid days', async () => {
592
+ it('should reject non-default coin symbols without tokenAddress', async () => {
261
593
  try {
262
- await server.externalServices.coinGecko.coinGeckoGetFiatRates({
263
- params: { code: 'USD' },
264
- query: { coin: 'BTC', days: 0 }
265
- });
594
+ await getFiatRates({ coin: 'WBTC' });
595
+ should.fail('should have thrown');
596
+ }
597
+ catch (err) {
598
+ err.message.should.equal('Unsupported coin. For token symbols, pass `chain` and `tokenAddress`.');
599
+ }
600
+ });
601
+ it('should resolve fiat rates unambiguously when tokenAddress is provided', async () => {
602
+ forceGlobalCacheMisses();
603
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
604
+ fakeRequest = createCoinGeckoRequestStub({
605
+ onCoinContract: (platformId, address) => {
606
+ platformId.should.equal('arbitrum-one');
607
+ address.should.equal(tokenAddress);
608
+ return {
609
+ id: 'arbitrum-bridged-usdc-arbitrum',
610
+ symbol: 'usdc.e',
611
+ asset_platform_id: 'arbitrum-one'
612
+ };
613
+ },
614
+ onMarketChart: id => id === 'arbitrum-bridged-usdc-arbitrum'
615
+ ? { prices: [[1, 77], [2, 78]] }
616
+ : { prices: [[1, 1], [2, 1]] }
617
+ });
618
+ cg().request = fakeRequest;
619
+ const data = await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
620
+ should.exist(data);
621
+ data.should.deep.equal([
622
+ { ts: 1, rate: 77 },
623
+ { ts: 2, rate: 78 }
624
+ ]);
625
+ });
626
+ it('should resolve fiat rates by chain + tokenAddress when coin is omitted', async () => {
627
+ forceGlobalCacheMisses();
628
+ const tokenAddress = '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8';
629
+ fakeRequest = createCoinGeckoRequestStub({
630
+ onCoinContract: (platformId, address) => {
631
+ platformId.should.equal('arbitrum-one');
632
+ address.should.equal(tokenAddress);
633
+ return {
634
+ id: 'bridged-usdc-arbitrum',
635
+ symbol: 'usdc',
636
+ asset_platform_id: 'arbitrum-one'
637
+ };
638
+ },
639
+ onMarketChart: id => id === 'bridged-usdc-arbitrum'
640
+ ? { prices: [[1, 99], [2, 100]] }
641
+ : { prices: [[1, 1], [2, 1]] }
642
+ });
643
+ cg().request = fakeRequest;
644
+ const data = await getFiatRates({ chain: 'arb', tokenAddress });
645
+ should.exist(data);
646
+ data.should.deep.equal([
647
+ { ts: 1, rate: 99 },
648
+ { ts: 2, rate: 100 }
649
+ ]);
650
+ });
651
+ it('should map pol alias to polygon-pos for token fiatrates lookup', async () => {
652
+ forceGlobalCacheMisses();
653
+ const tokenAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
654
+ fakeRequest = createCoinGeckoRequestStub({
655
+ onCoinContract: (platformId, address) => {
656
+ platformId.should.equal('polygon-pos');
657
+ address.should.equal(tokenAddress);
658
+ return {
659
+ id: 'usd-coin',
660
+ symbol: 'usdc',
661
+ asset_platform_id: 'polygon-pos'
662
+ };
663
+ },
664
+ onMarketChart: id => id === 'usd-coin'
665
+ ? { prices: [[1, 11], [2, 12]] }
666
+ : { prices: [[1, 1], [2, 1]] }
667
+ });
668
+ cg().request = fakeRequest;
669
+ const data = await getFiatRates({ chain: 'pol', tokenAddress });
670
+ should.exist(data);
671
+ data.should.deep.equal([
672
+ { ts: 1, rate: 11 },
673
+ { ts: 2, rate: 12 }
674
+ ]);
675
+ });
676
+ it('should require a valid chain when tokenAddress is provided', async () => {
677
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
678
+ try {
679
+ await getFiatRates({ coin: 'USDC.e', tokenAddress });
680
+ should.fail('should have thrown');
681
+ }
682
+ catch (err) {
683
+ err.message.should.equal('tokenAddress requires valid chain');
684
+ }
685
+ });
686
+ it('should reject invalid Solana tokenAddress format before lookup', async () => {
687
+ const getSpy = sandbox.spy();
688
+ cg().request = { get: getSpy };
689
+ try {
690
+ await getFiatRates({ chain: 'sol', tokenAddress: '0xdeadbeef' });
691
+ should.fail('should have thrown');
692
+ }
693
+ catch (err) {
694
+ err.message.should.equal('Invalid tokenAddress');
695
+ err.statusCode.should.equal(400);
696
+ }
697
+ getSpy.callCount.should.equal(0);
698
+ });
699
+ it('should rethrow non-404 tokenAddress lookup errors', async () => {
700
+ forceGlobalCacheMisses();
701
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
702
+ fakeRequest = createCoinGeckoRequestStub({
703
+ onCoinContract: () => ({
704
+ status: { error_code: 429, error_message: 'rate limited' }
705
+ })
706
+ });
707
+ cg().request = fakeRequest;
708
+ try {
709
+ await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
710
+ should.fail('should have thrown');
711
+ }
712
+ catch (err) {
713
+ err.message.should.equal('coinGecko rate limit');
714
+ }
715
+ });
716
+ it('should reject fiat rates tokenAddress when returned token symbol does not match coin', async () => {
717
+ forceGlobalCacheMisses();
718
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
719
+ fakeRequest = createCoinGeckoRequestStub({
720
+ onCoinContract: () => ({
721
+ id: 'arbitrum-bridged-usdc-arbitrum',
722
+ symbol: 'usdc.e'
723
+ })
724
+ });
725
+ cg().request = fakeRequest;
726
+ try {
727
+ await getFiatRates({ coin: 'WBTC', chain: 'arb', tokenAddress });
728
+ should.fail('should have thrown');
729
+ }
730
+ catch (err) {
731
+ err.message.should.equal('tokenAddress does not match coin');
732
+ }
733
+ });
734
+ it('should accept array query params for days', async () => {
735
+ const data = await getFiatRates({ coin: 'BTC', days: ['365'] });
736
+ should.exist(data);
737
+ data.should.deep.equal([
738
+ { ts: 1, rate: 100 },
739
+ { ts: 2, rate: 110 }
740
+ ]);
741
+ });
742
+ it('should treat whitespace coin as missing and return default rates', async () => {
743
+ const data = await getFiatRates({ coin: ' ' });
744
+ should.exist(data);
745
+ Object.keys(data)
746
+ .sort()
747
+ .should.deep.equal(['bch', 'btc', 'doge', 'eth', 'ltc', 'pol', 'sol', 'xrp']);
748
+ });
749
+ it('should reject overly long coin symbols', async () => {
750
+ try {
751
+ await getFiatRates({ coin: 'x'.repeat(100) });
752
+ should.fail('should have thrown');
753
+ }
754
+ catch (err) {
755
+ err.message.should.equal('Unsupported coin');
756
+ }
757
+ });
758
+ it('should reject overly long days values', async () => {
759
+ try {
760
+ await getFiatRates({ coin: 'BTC', days: 'x'.repeat(100) });
266
761
  should.fail('should have thrown');
267
762
  }
268
763
  catch (err) {
269
764
  err.message.should.equal('Invalid days');
270
765
  }
271
766
  });
272
- it('should return rates for all supported coins if missing coin', async () => {
273
- const data = await server.externalServices.coinGecko.coinGeckoGetFiatRates({
274
- params: { code: 'USD' },
275
- query: {}
767
+ it('should use DB cache for default fiatrates coin', async () => {
768
+ const checkCacheStub = sandbox
769
+ .stub(cg().storage, 'checkAndUseGlobalCache')
770
+ .callsFake((_key, _duration, cb) => cb(null, null, null));
771
+ const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
772
+ const data = await getFiatRates({ coin: 'BTC' });
773
+ should.exist(data);
774
+ checkCacheStub.callCount.should.equal(1);
775
+ checkCacheStub.getCall(0).args[0].should.equal('cgFiatRates:bitcoin:usd:100000');
776
+ storeCacheStub.callCount.should.equal(1);
777
+ storeCacheStub.getCall(0).args[0].should.equal('cgFiatRates:bitcoin:usd:100000');
778
+ });
779
+ it('should bypass DB cache for token fiatrates requests', async () => {
780
+ const checkCacheStub = sandbox
781
+ .stub(cg().storage, 'checkAndUseGlobalCache')
782
+ .callsFake((_key, _duration, cb) => cb(null, null, null));
783
+ const storeCacheStub = sandbox.stub(cg().storage, 'storeGlobalCache').callsFake((_key, _values, cb) => cb(null));
784
+ const tokenAddress = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';
785
+ fakeRequest = createCoinGeckoRequestStub({
786
+ onCoinContract: (platformId, address) => {
787
+ platformId.should.equal('arbitrum-one');
788
+ address.should.equal(tokenAddress);
789
+ return {
790
+ id: 'arbitrum-bridged-usdc-arbitrum',
791
+ symbol: 'usdc.e',
792
+ asset_platform_id: 'arbitrum-one'
793
+ };
794
+ },
795
+ onMarketChart: id => id === 'arbitrum-bridged-usdc-arbitrum'
796
+ ? { prices: [[1, 77], [2, 78]] }
797
+ : { prices: [[1, 1], [2, 1]] }
276
798
  });
799
+ cg().request = fakeRequest;
800
+ const data = await getFiatRates({ coin: 'USDC.e', chain: 'arb', tokenAddress });
277
801
  should.exist(data);
278
- data.should.have.property('btc');
279
- data.should.have.property('bch');
280
- data.should.have.property('eth');
281
- data.should.have.property('pol');
282
- data.should.have.property('wbtc');
283
- data.should.have.property('weth');
802
+ const checkedKeys = checkCacheStub.getCalls().map(c => c.args[0]);
803
+ const storedKeys = storeCacheStub.getCalls().map(c => c.args[0]);
804
+ checkedKeys.some(k => typeof k === 'string' && k.startsWith('cgFiatRates:')).should.equal(false);
805
+ storedKeys.some(k => typeof k === 'string' && k.startsWith('cgFiatRates:')).should.equal(false);
806
+ });
807
+ it('should return error on invalid days', async () => {
808
+ try {
809
+ await getFiatRates({ coin: 'BTC', days: 0 });
810
+ should.fail('should have thrown');
811
+ }
812
+ catch (err) {
813
+ err.message.should.equal('Invalid days');
814
+ }
815
+ });
816
+ it('should return rates for default coins if missing coin', async () => {
817
+ const data = await getFiatRates({});
818
+ should.exist(data);
819
+ Object.keys(data)
820
+ .sort()
821
+ .should.deep.equal(['bch', 'btc', 'doge', 'eth', 'ltc', 'pol', 'sol', 'xrp']);
284
822
  data.btc.should.deep.equal([
285
823
  { ts: 1, rate: 100 },
286
824
  { ts: 2, rate: 110 }
@@ -290,6 +828,29 @@ describe('CoinGecko integration', function () {
290
828
  { ts: 2, rate: 210 }
291
829
  ]);
292
830
  });
831
+ it('should omit a failed default coin and log a warning', async () => {
832
+ const warnSpy = sandbox.spy(logger_1.default, 'warn');
833
+ forceGlobalCacheMisses();
834
+ const baseGet = fakeRequest.get;
835
+ fakeRequest.get = (url, opts, cb) => {
836
+ const mChart = url.match(/\/v3\/coins\/([^/]+)\/market_chart\?/);
837
+ if (mChart?.[1]) {
838
+ const id = decodeURIComponent(mChart[1]);
839
+ if (id === 'bitcoin-cash')
840
+ return cb(new Error('boom'));
841
+ }
842
+ return baseGet(url, opts, cb);
843
+ };
844
+ const data = await getFiatRates({});
845
+ should.exist(data);
846
+ Object.keys(data).should.not.include('bch');
847
+ Object.keys(data).should.include('btc');
848
+ const bchWarns = warnSpy
849
+ .getCalls()
850
+ .map(c => c.args)
851
+ .filter(args => args?.[0]?.toString().includes('CoinGecko fiat rates fetch failed') && args?.[1]?.coin === 'bch');
852
+ bchWarns.length.should.be.greaterThan(0);
853
+ });
293
854
  });
294
855
  });
295
856
  //# sourceMappingURL=coinGecko.test.js.map