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