@bitpay-labs/bitcore-wallet-service 11.11.3 → 11.11.4

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 (109) hide show
  1. package/package.json +4 -4
  2. package/src/config.ts +8 -0
  3. package/src/externalservices/coingecko.ts +6 -1
  4. package/src/externalservices/transak.ts +394 -70
  5. package/src/lib/blockchainexplorer.ts +4 -0
  6. package/src/lib/blockchainexplorers/v8.ts +1 -0
  7. package/src/lib/blockchainmonitor.ts +1 -0
  8. package/src/lib/chain/arc/index.ts +95 -0
  9. package/src/lib/chain/eth/index.ts +41 -23
  10. package/src/lib/chain/index.ts +2 -0
  11. package/src/lib/common/constants.ts +12 -1
  12. package/src/lib/common/defaults.ts +31 -0
  13. package/src/lib/emailservice.ts +3 -1
  14. package/src/lib/errors/errordefinitions.ts +4 -0
  15. package/src/lib/model/preferences.ts +4 -0
  16. package/src/lib/model/wallet.ts +1 -0
  17. package/src/lib/pushnotificationsservice.ts +2 -0
  18. package/src/lib/routes/services.ts +1 -1
  19. package/src/lib/routes/walletdata.ts +2 -0
  20. package/src/lib/server.ts +20 -2
  21. package/test/blockchainexplorer.test.ts +7 -0
  22. package/test/expressapp.test.ts +31 -0
  23. package/test/integration/externalservices/coinGecko.test.ts +42 -2
  24. package/test/integration/externalservices/transak.test.ts +545 -135
  25. package/test/integration/fiatrateservice.test.ts +13 -0
  26. package/test/integration/helpers.ts +1 -0
  27. package/test/integration/server.test.ts +139 -3
  28. package/ts_build/package.json +1 -1
  29. package/ts_build/src/config.d.ts.map +1 -1
  30. package/ts_build/src/config.js +8 -0
  31. package/ts_build/src/config.js.map +1 -1
  32. package/ts_build/src/externalservices/coingecko.d.ts.map +1 -1
  33. package/ts_build/src/externalservices/coingecko.js +6 -1
  34. package/ts_build/src/externalservices/coingecko.js.map +1 -1
  35. package/ts_build/src/externalservices/transak.d.ts +28 -2
  36. package/ts_build/src/externalservices/transak.d.ts.map +1 -1
  37. package/ts_build/src/externalservices/transak.js +274 -57
  38. package/ts_build/src/externalservices/transak.js.map +1 -1
  39. package/ts_build/src/lib/blockchainexplorer.d.ts.map +1 -1
  40. package/ts_build/src/lib/blockchainexplorer.js +4 -0
  41. package/ts_build/src/lib/blockchainexplorer.js.map +1 -1
  42. package/ts_build/src/lib/blockchainexplorers/v8.d.ts.map +1 -1
  43. package/ts_build/src/lib/blockchainexplorers/v8.js +1 -0
  44. package/ts_build/src/lib/blockchainexplorers/v8.js.map +1 -1
  45. package/ts_build/src/lib/blockchainmonitor.d.ts.map +1 -1
  46. package/ts_build/src/lib/blockchainmonitor.js +1 -0
  47. package/ts_build/src/lib/blockchainmonitor.js.map +1 -1
  48. package/ts_build/src/lib/chain/arc/index.d.ts +20 -0
  49. package/ts_build/src/lib/chain/arc/index.d.ts.map +1 -0
  50. package/ts_build/src/lib/chain/arc/index.js +95 -0
  51. package/ts_build/src/lib/chain/arc/index.js.map +1 -0
  52. package/ts_build/src/lib/chain/eth/index.d.ts +1 -0
  53. package/ts_build/src/lib/chain/eth/index.d.ts.map +1 -1
  54. package/ts_build/src/lib/chain/eth/index.js +38 -19
  55. package/ts_build/src/lib/chain/eth/index.js.map +1 -1
  56. package/ts_build/src/lib/chain/index.d.ts.map +1 -1
  57. package/ts_build/src/lib/chain/index.js +2 -0
  58. package/ts_build/src/lib/chain/index.js.map +1 -1
  59. package/ts_build/src/lib/common/constants.d.ts +12 -0
  60. package/ts_build/src/lib/common/constants.d.ts.map +1 -1
  61. package/ts_build/src/lib/common/constants.js +12 -1
  62. package/ts_build/src/lib/common/constants.js.map +1 -1
  63. package/ts_build/src/lib/common/defaults.d.ts +24 -0
  64. package/ts_build/src/lib/common/defaults.d.ts.map +1 -1
  65. package/ts_build/src/lib/common/defaults.js +30 -0
  66. package/ts_build/src/lib/common/defaults.js.map +1 -1
  67. package/ts_build/src/lib/common/index.d.ts +36 -0
  68. package/ts_build/src/lib/common/index.d.ts.map +1 -1
  69. package/ts_build/src/lib/emailservice.d.ts.map +1 -1
  70. package/ts_build/src/lib/emailservice.js +2 -0
  71. package/ts_build/src/lib/emailservice.js.map +1 -1
  72. package/ts_build/src/lib/errors/errordefinitions.d.ts +2 -0
  73. package/ts_build/src/lib/errors/errordefinitions.d.ts.map +1 -1
  74. package/ts_build/src/lib/errors/errordefinitions.js +2 -0
  75. package/ts_build/src/lib/errors/errordefinitions.js.map +1 -1
  76. package/ts_build/src/lib/model/preferences.d.ts +2 -0
  77. package/ts_build/src/lib/model/preferences.d.ts.map +1 -1
  78. package/ts_build/src/lib/model/preferences.js +3 -0
  79. package/ts_build/src/lib/model/preferences.js.map +1 -1
  80. package/ts_build/src/lib/model/wallet.d.ts.map +1 -1
  81. package/ts_build/src/lib/model/wallet.js +1 -0
  82. package/ts_build/src/lib/model/wallet.js.map +1 -1
  83. package/ts_build/src/lib/pushnotificationsservice.d.ts.map +1 -1
  84. package/ts_build/src/lib/pushnotificationsservice.js +2 -0
  85. package/ts_build/src/lib/pushnotificationsservice.js.map +1 -1
  86. package/ts_build/src/lib/routes/services.js +1 -1
  87. package/ts_build/src/lib/routes/services.js.map +1 -1
  88. package/ts_build/src/lib/routes/walletdata.d.ts.map +1 -1
  89. package/ts_build/src/lib/routes/walletdata.js +2 -0
  90. package/ts_build/src/lib/routes/walletdata.js.map +1 -1
  91. package/ts_build/src/lib/server.d.ts.map +1 -1
  92. package/ts_build/src/lib/server.js +17 -2
  93. package/ts_build/src/lib/server.js.map +1 -1
  94. package/ts_build/test/blockchainexplorer.test.js +6 -0
  95. package/ts_build/test/blockchainexplorer.test.js.map +1 -1
  96. package/ts_build/test/expressapp.test.js +30 -0
  97. package/ts_build/test/expressapp.test.js.map +1 -1
  98. package/ts_build/test/integration/externalservices/coinGecko.test.js +38 -2
  99. package/ts_build/test/integration/externalservices/coinGecko.test.js.map +1 -1
  100. package/ts_build/test/integration/externalservices/transak.test.d.ts.map +1 -1
  101. package/ts_build/test/integration/externalservices/transak.test.js +498 -128
  102. package/ts_build/test/integration/externalservices/transak.test.js.map +1 -1
  103. package/ts_build/test/integration/fiatrateservice.test.js +11 -0
  104. package/ts_build/test/integration/fiatrateservice.test.js.map +1 -1
  105. package/ts_build/test/integration/helpers.d.ts.map +1 -1
  106. package/ts_build/test/integration/helpers.js +1 -0
  107. package/ts_build/test/integration/helpers.js.map +1 -1
  108. package/ts_build/test/integration/server.test.js +135 -3
  109. package/ts_build/test/integration/server.test.js.map +1 -1
@@ -37,15 +37,35 @@ 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
+ const crypto = __importStar(require("crypto"));
40
41
  const sinon = __importStar(require("sinon"));
41
42
  require("chai/register-should");
42
43
  const util_1 = __importDefault(require("util"));
43
44
  const server_1 = require("../../../src/lib/server");
45
+ const transak_1 = require("../../../src/externalservices/transak");
44
46
  const TestData = __importStar(require("../../testdata"));
45
47
  const helpers_1 = __importDefault(require("../helpers"));
46
48
  const config_1 = __importDefault(require("../../../src/config"));
47
49
  const logger_1 = __importDefault(require("../../../src/lib/logger"));
48
50
  const should = chai.should();
51
+ const PARTNER_TOKEN = 'partnerToken1';
52
+ const appWidgetBody = () => ({
53
+ walletAddress: 'walletAddress1',
54
+ disableWalletAddressForm: true,
55
+ redirectURL: 'bitpay://transak',
56
+ exchangeScreenTitle: 'Bitpay - Buy crypto',
57
+ fiatAmount: '500',
58
+ fiatCurrency: 'USD',
59
+ network: 'mainnet',
60
+ cryptoCurrencyCode: 'BTC',
61
+ cryptoCurrencyList: 'BTC',
62
+ hideExchangeScreen: true,
63
+ themeColor: '2240C4',
64
+ hideMenu: false,
65
+ partnerOrderId: 'partnerOrderId1',
66
+ partnerCustomerId: 'partnerCustomerId1',
67
+ paymentMethod: 'credit_debit_card',
68
+ });
49
69
  describe('Transak integration', () => {
50
70
  const sandbox = sinon.createSandbox();
51
71
  let server;
@@ -53,6 +73,22 @@ describe('Transak integration', () => {
53
73
  let fakeRequest;
54
74
  let req;
55
75
  let loggerError;
76
+ let refreshCalls;
77
+ let sessionCalls;
78
+ let orderCalls;
79
+ let tokens;
80
+ let tokenExpiresAt;
81
+ let refreshStatus;
82
+ let refreshDelay;
83
+ let sessionStatuses;
84
+ let sessionError;
85
+ let sessionErrorBody;
86
+ let orderStatuses;
87
+ let orderError;
88
+ let orderBody;
89
+ const newReq = (env, extra = {}) => ({ headers: {}, body: { env, ...appWidgetBody(), ...extra } });
90
+ const partnerTokenId = (env, apiKey) => `${env}:${crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 12)}`;
91
+ const updatePartnerTokenCache = (update) => server.storage.db.collection('cache').updateMany({ key: { $regex: '^transakPartnerToken:' } }, update);
56
92
  before(async () => {
57
93
  await helpers_1.default.before();
58
94
  });
@@ -83,9 +119,51 @@ describe('Transak integration', () => {
83
119
  widgetApi: 'widgetApi4',
84
120
  }
85
121
  };
122
+ refreshCalls = [];
123
+ sessionCalls = [];
124
+ orderCalls = [];
125
+ tokens = [PARTNER_TOKEN];
126
+ tokenExpiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;
127
+ refreshStatus = 200;
128
+ refreshDelay = 0;
129
+ sessionStatuses = [];
130
+ sessionError = null;
131
+ sessionErrorBody = { error: { message: 'unauthorized' } };
132
+ orderStatuses = [];
133
+ orderError = null;
134
+ orderBody = 'data';
86
135
  fakeRequest = {
87
- get: (_url, _opts, _cb) => { return _cb(null, { body: 'data' }); },
88
- post: (_url, _opts, _cb) => { return _cb(null, { body: 'data' }); },
136
+ get: (url, opts, cb) => {
137
+ if (url.includes('/partners/api/v2/order/')) {
138
+ orderCalls.push({ url, opts });
139
+ if (orderError)
140
+ return cb(orderError, null);
141
+ return cb(null, { statusCode: orderStatuses.shift() ?? 200, body: orderBody });
142
+ }
143
+ return cb(null, { body: 'data' });
144
+ },
145
+ post: (url, opts, cb) => {
146
+ if (url.endsWith('/partners/api/v2/refresh-token')) {
147
+ refreshCalls.push({ url, opts });
148
+ const accessToken = tokens.length > 1 ? tokens.shift() : tokens[0];
149
+ const respond = () => cb(null, {
150
+ statusCode: refreshStatus,
151
+ body: refreshStatus === 200 ? { data: { accessToken, expiresAt: tokenExpiresAt } } : { error: 'unauthorized' }
152
+ });
153
+ return refreshDelay ? setTimeout(respond, refreshDelay) : respond();
154
+ }
155
+ if (url.endsWith('/api/v2/auth/session')) {
156
+ sessionCalls.push({ url, opts });
157
+ if (sessionError)
158
+ return cb(sessionError, null);
159
+ const statusCode = sessionStatuses.shift() ?? 200;
160
+ return cb(null, {
161
+ statusCode,
162
+ body: statusCode === 200 ? { data: { widgetUrl: 'https://global.transak.com/?session=abc' } } : sessionErrorBody
163
+ });
164
+ }
165
+ return cb(null, { body: 'data' });
166
+ },
89
167
  };
90
168
  await helpers_1.default.beforeEach();
91
169
  ({ wallet } = await helpers_1.default.createAndJoinWallet(1, 1));
@@ -115,57 +193,26 @@ describe('Transak integration', () => {
115
193
  env: 'sandbox',
116
194
  }
117
195
  };
118
- server.externalServices.transak.request = fakeRequest;
196
+ server.externalServices.transak.request = {
197
+ get: () => { throw new Error('should not call Transak'); },
198
+ post: () => { throw new Error('should not call Transak'); },
199
+ };
119
200
  });
120
- it('should work properly if req is OK', async () => {
201
+ it('should return a placeholder without calling Transak', async () => {
121
202
  const data = await server.externalServices.transak.transakGetAccessToken(req);
122
- should.exist(data);
203
+ data.data.accessToken.should.equal('server-managed');
123
204
  });
124
- it('should work properly if req is OK for web', async () => {
205
+ it('should return a placeholder for web without calling Transak', async () => {
125
206
  req.body.context = 'web';
126
207
  const data = await server.externalServices.transak.transakGetAccessToken(req);
127
- should.exist(data);
128
- });
129
- it('should forward the deviceIp as x-user-ip for web context', async () => {
130
- let capturedHeaders;
131
- server.externalServices.transak.request = {
132
- post: (_url, opts, cb) => {
133
- capturedHeaders = opts.headers;
134
- return cb(null, { body: 'data' });
135
- },
136
- };
137
- req.body.context = 'web';
138
- req.body.deviceIp = '203.0.113.42';
139
- await server.externalServices.transak.transakGetAccessToken(req);
140
- capturedHeaders['x-user-ip'].should.equal('203.0.113.42');
141
- });
142
- it('should return error if post returns error', async () => {
143
- const fakeRequest2 = {
144
- post: (_url, _opts, _cb) => { return _cb(new Error('Error'), null); },
145
- };
146
- server.externalServices.transak.request = fakeRequest2;
147
- try {
148
- await server.externalServices.transak.transakGetAccessToken(req);
149
- should.fail('should have thrown');
150
- }
151
- catch (err) {
152
- loggerError.callCount.should.equal(1);
153
- loggerError.args[0][0].should.contain('Transak request failed with error (');
154
- err.message.should.equal('Failed to get Transak access token');
155
- }
208
+ data.data.accessToken.should.equal('server-managed');
156
209
  });
157
- it('should return error and not leak the response if body is empty', async () => {
158
- const fakeRequest2 = {
159
- post: (_url, opts, cb) => { return cb(null, { statusCode: 200, body: undefined, request: { headers: opts.headers } }); },
160
- };
161
- server.externalServices.transak.request = fakeRequest2;
162
- try {
163
- await server.externalServices.transak.transakGetAccessToken(req);
164
- should.fail('should have thrown');
165
- }
166
- catch (err) {
167
- err.message.should.equal('Transak did not return an access token');
168
- }
210
+ it('should return an integer expiresAt in seconds in the future', async () => {
211
+ const now = Math.floor(Date.now() / 1000);
212
+ const { data } = await server.externalServices.transak.transakGetAccessToken(req);
213
+ Number.isInteger(data.expiresAt).should.equal(true);
214
+ data.expiresAt.should.be.above(now);
215
+ data.expiresAt.should.be.at.most(now + 2 * 60 + 1);
169
216
  });
170
217
  it('should return error if transak is commented in config', async () => {
171
218
  config_1.default.transak = undefined;
@@ -372,39 +419,307 @@ describe('Transak integration', () => {
372
419
  headers: {},
373
420
  body: {
374
421
  env: 'production',
375
- accessToken: 'accessToken1',
376
- walletAddress: 'walletAddress1',
377
- redirectURL: 'bitpay://transak',
378
- fiatAmount: '500',
379
- fiatCurrency: 'USD',
380
- network: 'mainnet',
381
- cryptoCurrencyCode: 'BTC',
382
- partnerOrderId: 'partnerOrderId1',
383
- partnerCustomerId: 'partnerCustomerId1',
422
+ accessToken: 'clientSuppliedToken',
423
+ ...appWidgetBody(),
384
424
  }
385
425
  };
386
426
  server.externalServices.transak.request = fakeRequest;
387
427
  });
388
428
  it('should get the paymentUrl properly if req is OK', async () => {
389
429
  const data = await server.externalServices.transak.transakGetSignedPaymentUrl(req);
390
- should.exist(data);
430
+ data.urlWithSignature.should.equal('https://global.transak.com/?session=abc');
391
431
  });
392
432
  it('should get the paymentUrl properly if req is OK for web', async () => {
433
+ req.body.context = 'web';
434
+ const data = await server.externalServices.transak.transakGetSignedPaymentUrl(req);
435
+ data.urlWithSignature.should.equal('https://global.transak.com/?session=abc');
436
+ });
437
+ it('should work for web with only the fields bitpay.com sends and no accessToken', async () => {
393
438
  req.body = {
394
439
  env: 'production',
395
- accessToken: 'accessToken1',
396
440
  context: 'web',
397
441
  walletAddress: 'walletAddress1',
398
- redirectURL: 'bitpay://transak',
442
+ redirectURL: 'https://bitpay.com/buy-crypto',
399
443
  fiatAmount: '500',
400
444
  fiatCurrency: 'USD',
401
- network: 'mainnet',
445
+ defaultNetwork: 'bitcoin',
402
446
  cryptoCurrencyCode: 'BTC',
403
- partnerOrderId: 'partnerOrderId1',
404
- partnerCustomerId: 'partnerCustomerId1',
447
+ deviceIp: '203.0.113.42',
405
448
  };
406
449
  const data = await server.externalServices.transak.transakGetSignedPaymentUrl(req);
407
- should.exist(data);
450
+ should.exist(data.urlWithSignature);
451
+ });
452
+ it('should work for the app without an accessToken', async () => {
453
+ delete req.body.accessToken;
454
+ const data = await server.externalServices.transak.transakGetSignedPaymentUrl(req);
455
+ should.exist(data.urlWithSignature);
456
+ });
457
+ it('should use the server-side partner token and ignore any client accessToken', async () => {
458
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
459
+ sessionCalls.length.should.equal(1);
460
+ sessionCalls[0].opts.headers['access-token'].should.equal(PARTNER_TOKEN);
461
+ refreshCalls[0].opts.headers['api-secret'].should.equal('secretKey2');
462
+ });
463
+ it('should only send allowlisted fields in widgetParams', async () => {
464
+ req.body.context = 'web';
465
+ req.body.deviceIp = '203.0.113.42';
466
+ req.body.somethingElse = 'nope';
467
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
468
+ const widgetParams = sessionCalls[0].opts.body.widgetParams;
469
+ should.not.exist(widgetParams.accessToken);
470
+ should.not.exist(widgetParams.env);
471
+ should.not.exist(widgetParams.context);
472
+ should.not.exist(widgetParams.deviceIp);
473
+ should.not.exist(widgetParams.somethingElse);
474
+ JSON.stringify(sessionCalls[0].opts.body).should.not.contain(PARTNER_TOKEN);
475
+ JSON.stringify(sessionCalls[0].opts.body).should.not.contain('clientSuppliedToken');
476
+ widgetParams.should.deep.equal({
477
+ ...appWidgetBody(),
478
+ apiKey: 'apiKey4',
479
+ referrerDomain: 'bitpay.com',
480
+ });
481
+ });
482
+ it('should refresh the partner token once and reuse it across requests and instances', async () => {
483
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
484
+ await server.externalServices.transak.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'production', ...appWidgetBody() } });
485
+ const otherInstance = new transak_1.TransakService(server.storage, server.lock);
486
+ otherInstance.request = fakeRequest;
487
+ await otherInstance.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'production', ...appWidgetBody() } });
488
+ refreshCalls.length.should.equal(1);
489
+ sessionCalls.length.should.equal(3);
490
+ });
491
+ it('should refresh only once for concurrent requests', async () => {
492
+ refreshDelay = 100;
493
+ await Promise.all([1, 2, 3, 4, 5].map(() => {
494
+ const instance = new transak_1.TransakService(server.storage, server.lock);
495
+ instance.request = fakeRequest;
496
+ return instance.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'production', ...appWidgetBody() } });
497
+ }));
498
+ refreshCalls.length.should.equal(1);
499
+ sessionCalls.length.should.equal(5);
500
+ });
501
+ it('should keep a separate partner token per environment', async () => {
502
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
503
+ await server.externalServices.transak.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'production', context: 'web', ...appWidgetBody() } });
504
+ await server.externalServices.transak.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'sandbox', ...appWidgetBody() } });
505
+ refreshCalls.length.should.equal(3);
506
+ refreshCalls.map(c => c.opts.headers['x-api-key']).should.deep.equal(['apiKey2', 'apiKey4', 'apiKey1']);
507
+ });
508
+ it('should refresh the partner token when it is close to expiring', async () => {
509
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
510
+ await updatePartnerTokenCache({ $set: { 'result.expiresAt': Date.now() + 5 * 60 * 1000 } });
511
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
512
+ refreshCalls.length.should.equal(2);
513
+ });
514
+ it('should use a fallback TTL when Transak returns expiresAt as a duration', async () => {
515
+ tokenExpiresAt = 7 * 24 * 60 * 60;
516
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
517
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
518
+ refreshCalls.length.should.equal(1);
519
+ });
520
+ it('should use a fallback TTL when Transak omits expiresAt', async () => {
521
+ tokenExpiresAt = undefined;
522
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
523
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
524
+ refreshCalls.length.should.equal(1);
525
+ });
526
+ it('should accept expiresAt in milliseconds', async () => {
527
+ tokenExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
528
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
529
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
530
+ refreshCalls.length.should.equal(1);
531
+ });
532
+ it('should not reuse a token cached for a rotated API key', async () => {
533
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
534
+ config_1.default.transak.production.apiKey = 'apiKey2-rotated';
535
+ tokens = ['rotatedToken'];
536
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
537
+ refreshCalls.length.should.equal(2);
538
+ refreshCalls[1].opts.headers['x-api-key'].should.equal('apiKey2-rotated');
539
+ sessionCalls[1].opts.headers['access-token'].should.equal('rotatedToken');
540
+ });
541
+ it('should refresh and retry once when Transak rejects an older partner token', async () => {
542
+ tokens = ['staleToken', 'freshToken'];
543
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
544
+ await updatePartnerTokenCache({ $set: { 'result.fetchedAt': Date.now() - 60 * 60 * 1000 } });
545
+ sessionStatuses = [401, 200];
546
+ const data = await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
547
+ should.exist(data.urlWithSignature);
548
+ refreshCalls.length.should.equal(2);
549
+ sessionCalls.map(c => c.opts.headers['access-token']).should.deep.equal(['staleToken', 'staleToken', 'freshToken']);
550
+ });
551
+ it('should fail rather than return an empty URL on repeated 401s', async () => {
552
+ tokens = ['token1', 'token2', 'token3'];
553
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
554
+ await updatePartnerTokenCache({ $set: { 'result.fetchedAt': Date.now() - 60 * 60 * 1000 } });
555
+ sessionStatuses = [401, 401, 401];
556
+ try {
557
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
558
+ should.fail('should have thrown');
559
+ }
560
+ catch (err) {
561
+ err.message.should.equal('Failed to get Transak signed payment URL');
562
+ }
563
+ sessionCalls.length.should.equal(3);
564
+ refreshCalls.length.should.equal(2);
565
+ });
566
+ it('should not refresh when Transak rejects a token it just issued', async () => {
567
+ sessionStatuses = [401, 401, 401, 401, 401];
568
+ for (let i = 0; i < 5; i++) {
569
+ try {
570
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
571
+ should.fail('should have thrown');
572
+ }
573
+ catch (err) {
574
+ err.message.should.equal('Transak rejected the access token');
575
+ }
576
+ }
577
+ refreshCalls.length.should.equal(1);
578
+ sessionCalls.length.should.equal(5);
579
+ });
580
+ it('should fail if Transak does not return a widget URL', async () => {
581
+ sessionStatuses = [500];
582
+ try {
583
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
584
+ should.fail('should have thrown');
585
+ }
586
+ catch (err) {
587
+ err.message.should.equal('Failed to get Transak signed payment URL');
588
+ }
589
+ });
590
+ it('should fail without refreshing when another request holds the refresh lock too long', async () => {
591
+ const release = await util_1.default.promisify((cb) => server.lock.acquire(`transakPartnerToken:${partnerTokenId('production', 'apiKey2')}`, { lockTime: 60000 }, cb))();
592
+ const transak = server.externalServices.transak;
593
+ transak.partnerTokenLockWait = 200;
594
+ try {
595
+ await transak.transakGetSignedPaymentUrl(req);
596
+ should.fail('should have thrown');
597
+ }
598
+ catch (err) {
599
+ err.message.should.equal('Failed to get Transak access token');
600
+ }
601
+ finally {
602
+ release();
603
+ }
604
+ refreshCalls.length.should.equal(0);
605
+ });
606
+ it('should check the failure backoff before waiting on the refresh lock', async () => {
607
+ refreshStatus = 401;
608
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req).catch(() => { });
609
+ const release = await util_1.default.promisify((cb) => server.lock.acquire(`transakPartnerToken:${partnerTokenId('production', 'apiKey2')}`, { lockTime: 60000 }, cb))();
610
+ const started = Date.now();
611
+ try {
612
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
613
+ should.fail('should have thrown');
614
+ }
615
+ catch (err) {
616
+ err.message.should.equal('Failed to get Transak access token');
617
+ }
618
+ finally {
619
+ release();
620
+ }
621
+ (Date.now() - started).should.be.below(1000);
622
+ refreshCalls.length.should.equal(1);
623
+ });
624
+ it('should retry the refresh once the failure backoff has passed', async () => {
625
+ refreshStatus = 401;
626
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req).catch(() => { });
627
+ await server.storage.db.collection('cache').updateMany({ key: { $regex: '^transakPartnerTokenFailure:' } }, { $set: { ts: Date.now() - 31 * 1000 } });
628
+ refreshStatus = 200;
629
+ const data = await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
630
+ should.exist(data.urlWithSignature);
631
+ refreshCalls.length.should.equal(2);
632
+ });
633
+ it('should keep serving a still-valid token when the proactive refresh fails', async () => {
634
+ tokens = ['currentToken'];
635
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
636
+ await updatePartnerTokenCache({ $set: { 'result.expiresAt': Date.now() + 5 * 60 * 1000 } });
637
+ refreshStatus = 500;
638
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
639
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
640
+ refreshCalls.length.should.equal(2);
641
+ sessionCalls.map(c => c.opts.headers['access-token']).should.deep.equal(['currentToken', 'currentToken', 'currentToken']);
642
+ });
643
+ it('should keep serving a still-valid token when the refresh lock is held too long', async () => {
644
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
645
+ await updatePartnerTokenCache({ $set: { 'result.expiresAt': Date.now() + 5 * 60 * 1000 } });
646
+ const release = await util_1.default.promisify((cb) => server.lock.acquire(`transakPartnerToken:${partnerTokenId('production', 'apiKey2')}`, { lockTime: 60000 }, cb))();
647
+ const transak = server.externalServices.transak;
648
+ transak.partnerTokenLockWait = 200;
649
+ try {
650
+ await transak.transakGetSignedPaymentUrl(newReq('production'));
651
+ }
652
+ finally {
653
+ release();
654
+ }
655
+ refreshCalls.length.should.equal(1);
656
+ sessionCalls[1].opts.headers['access-token'].should.equal(PARTNER_TOKEN);
657
+ });
658
+ it('should not serve a token past its real expiry when the refresh fails', async () => {
659
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
660
+ await updatePartnerTokenCache({ $set: { 'result.expiresAt': Date.now() + 10 * 1000 } });
661
+ refreshStatus = 500;
662
+ try {
663
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
664
+ should.fail('should have thrown');
665
+ }
666
+ catch (err) {
667
+ err.message.should.equal('Transak did not return an access token');
668
+ }
669
+ sessionCalls.length.should.equal(1);
670
+ });
671
+ it('should refresh and retry once when Transak answers 403 for an older token', async () => {
672
+ tokens = ['staleToken', 'freshToken'];
673
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
674
+ await updatePartnerTokenCache({ $set: { 'result.fetchedAt': Date.now() - 60 * 60 * 1000 } });
675
+ sessionStatuses = [403, 200];
676
+ await server.externalServices.transak.transakGetSignedPaymentUrl(newReq('production'));
677
+ sessionCalls.map(c => c.opts.headers['access-token']).should.deep.equal(['staleToken', 'staleToken', 'freshToken']);
678
+ });
679
+ it('should log Transak\'s error message but not the token', async () => {
680
+ const loggerWarn = logger_1.default.warn;
681
+ sessionStatuses = [500];
682
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req).catch(() => null);
683
+ const logged = loggerWarn.args.map(a => String(a[0])).join('\n');
684
+ logged.should.contain('(unauthorized)');
685
+ logged.should.not.contain(PARTNER_TOKEN);
686
+ });
687
+ it('should log a non-string Transak error message as JSON', async () => {
688
+ const loggerWarn = logger_1.default.warn;
689
+ sessionStatuses = [400];
690
+ sessionErrorBody = { error: { message: { code: 'INVALID_PARAMS', fields: ['fiatAmount'] } } };
691
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req).catch(() => null);
692
+ const logged = loggerWarn.args.map(a => String(a[0])).join('\n');
693
+ logged.should.contain('({"code":"INVALID_PARAMS","fields":["fiatAmount"]})');
694
+ });
695
+ it('should log no detail when Transak sends no error message', async () => {
696
+ const loggerWarn = logger_1.default.warn;
697
+ sessionStatuses = [502];
698
+ sessionErrorBody = 'Bad Gateway';
699
+ const err = await server.externalServices.transak.transakGetSignedPaymentUrl(req).catch(e => e);
700
+ err.message.should.equal('Failed to get Transak signed payment URL');
701
+ loggerWarn.args.map(a => String(a[0])).join('\n').should.match(/status code 502: \S+$/m);
702
+ });
703
+ it('should forward defaultPaymentMethod when the app sends it instead of paymentMethod', async () => {
704
+ delete req.body.paymentMethod;
705
+ req.body.defaultPaymentMethod = 'credit_debit_card';
706
+ await server.externalServices.transak.transakGetSignedPaymentUrl(req);
707
+ sessionCalls[0].opts.body.widgetParams.defaultPaymentMethod.should.equal('credit_debit_card');
708
+ should.not.exist(sessionCalls[0].opts.body.widgetParams.paymentMethod);
709
+ });
710
+ it('should fail and back off when the partner token refresh fails', async () => {
711
+ refreshStatus = 401;
712
+ for (let i = 0; i < 3; i++) {
713
+ try {
714
+ await server.externalServices.transak.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'production', ...appWidgetBody() } });
715
+ should.fail('should have thrown');
716
+ }
717
+ catch (err) {
718
+ err.message.should.match(/Transak did not return an access token|Failed to get Transak access token/);
719
+ }
720
+ }
721
+ refreshCalls.length.should.equal(1);
722
+ sessionCalls.length.should.equal(0);
408
723
  });
409
724
  it('should return error if there is some missing arguments', async () => {
410
725
  delete req.body.context;
@@ -418,75 +733,32 @@ describe('Transak integration', () => {
418
733
  }
419
734
  });
420
735
  it('should forward the deviceIp as x-user-ip for web context', async () => {
421
- let capturedHeaders;
422
- server.externalServices.transak.request = {
423
- post: (_url, opts, cb) => {
424
- capturedHeaders = opts.headers;
425
- return cb(null, { body: 'data' });
426
- },
427
- };
428
736
  req.body.context = 'web';
429
737
  req.body.deviceIp = '203.0.113.42';
430
738
  await server.externalServices.transak.transakGetSignedPaymentUrl(req);
431
- capturedHeaders['x-user-ip'].should.equal('203.0.113.42');
739
+ sessionCalls[0].opts.headers['x-user-ip'].should.equal('203.0.113.42');
740
+ refreshCalls[0].opts.headers['x-user-ip'].should.equal('203.0.113.42');
432
741
  });
433
742
  it('should canonicalize an IPv4-mapped IPv6 deviceIp for web context', async () => {
434
- let capturedHeaders;
435
- server.externalServices.transak.request = {
436
- post: (_url, opts, cb) => {
437
- capturedHeaders = opts.headers;
438
- return cb(null, { body: 'data' });
439
- },
440
- };
441
743
  req.body.context = 'web';
442
744
  req.body.deviceIp = '::ffff:203.0.113.42';
443
745
  await server.externalServices.transak.transakGetSignedPaymentUrl(req);
444
- capturedHeaders['x-user-ip'].should.equal('203.0.113.42');
445
- });
446
- it('should not leak deviceIp into the widgetParams sent to Transak', async () => {
447
- let capturedBody;
448
- server.externalServices.transak.request = {
449
- post: (_url, opts, cb) => {
450
- capturedBody = opts.body;
451
- return cb(null, { body: 'data' });
452
- },
453
- };
454
- req.body.context = 'web';
455
- req.body.deviceIp = '203.0.113.42';
456
- await server.externalServices.transak.transakGetSignedPaymentUrl(req);
457
- should.not.exist(capturedBody.widgetParams.deviceIp);
746
+ sessionCalls[0].opts.headers['x-user-ip'].should.equal('203.0.113.42');
458
747
  });
459
748
  it('should ignore a body deviceIp for non-web context and use the request IP', async () => {
460
- let capturedHeaders;
461
- server.externalServices.transak.request = {
462
- post: (_url, opts, cb) => {
463
- capturedHeaders = opts.headers;
464
- return cb(null, { body: 'data' });
465
- },
466
- };
467
749
  req.headers['x-forwarded-for'] = '198.51.100.7';
468
750
  req.body.deviceIp = '203.0.113.42';
469
751
  await server.externalServices.transak.transakGetSignedPaymentUrl(req);
470
- capturedHeaders['x-user-ip'].should.equal('198.51.100.7');
752
+ sessionCalls[0].opts.headers['x-user-ip'].should.equal('198.51.100.7');
471
753
  });
472
754
  it('should fall back to the request IP for web context when deviceIp is not forwarded', async () => {
473
- let capturedHeaders;
474
- server.externalServices.transak.request = {
475
- post: (_url, opts, cb) => {
476
- capturedHeaders = opts.headers;
477
- return cb(null, { body: 'data' });
478
- },
479
- };
480
755
  req.body.context = 'web';
481
756
  req.headers['x-forwarded-for'] = '198.51.100.7';
482
757
  await server.externalServices.transak.transakGetSignedPaymentUrl(req);
483
- capturedHeaders['x-user-ip'].should.equal('198.51.100.7');
758
+ sessionCalls[0].opts.headers['x-user-ip'].should.equal('198.51.100.7');
484
759
  });
485
760
  it('should return error if post returns error', async () => {
486
- const fakeRequest2 = {
487
- post: (_url, _opts, _cb) => { return _cb(new Error('Error'), null); },
488
- };
489
- server.externalServices.transak.request = fakeRequest2;
761
+ sessionError = new Error('Error');
490
762
  try {
491
763
  await server.externalServices.transak.transakGetSignedPaymentUrl(req);
492
764
  should.fail('should have thrown');
@@ -509,33 +781,134 @@ describe('Transak integration', () => {
509
781
  });
510
782
  });
511
783
  describe('#transakGetOrderDetails', () => {
512
- beforeEach(() => {
784
+ let ownedAddress;
785
+ beforeEach(async () => {
513
786
  req = {
514
787
  headers: {},
515
788
  body: {
516
789
  env: 'sandbox',
517
- orderId: 'orderId1',
518
- accessToken: 'accessToken1',
790
+ orderId: '6f1d2c3b-1a2b-4c5d-8e9f-0a1b2c3d4e5f',
519
791
  }
520
792
  };
521
793
  server.externalServices.transak.request = fakeRequest;
794
+ ownedAddress = (await util_1.default.promisify(server.createAddress).call(server, {})).address;
795
+ orderBody = { data: { walletAddress: ownedAddress, status: 'PROCESSING' } };
796
+ });
797
+ it('should return an order for an address of the requesting wallet', async () => {
798
+ const data = await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
799
+ data.should.deep.equal({ data: { walletAddress: ownedAddress, status: 'PROCESSING' } });
800
+ });
801
+ it('should not return another wallet\'s order', async () => {
802
+ const { server: otherServer } = await helpers_1.default.createAndJoinWallet(1, 1, { offset: 1 });
803
+ const otherAddress = (await util_1.default.promisify(otherServer.createAddress).call(otherServer, {})).address;
804
+ orderBody = { data: { walletAddress: otherAddress, status: 'PROCESSING' } };
805
+ try {
806
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
807
+ should.fail('should have thrown');
808
+ }
809
+ catch (err) {
810
+ err.message.should.equal('Transak order not found');
811
+ }
812
+ otherServer.externalServices.transak.request = fakeRequest;
813
+ const data = await otherServer.externalServices.transak.transakGetOrderDetails({ headers: {}, body: { ...req.body } }, otherServer.walletId);
814
+ data.data.walletAddress.should.equal(otherAddress);
815
+ });
816
+ it('should not return an order without a wallet address', async () => {
817
+ orderBody = { data: { status: 'PROCESSING' } };
818
+ try {
819
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
820
+ should.fail('should have thrown');
821
+ }
822
+ catch (err) {
823
+ err.message.should.equal('Transak order not found');
824
+ }
825
+ });
826
+ it('should not return an order without a requesting wallet', async () => {
827
+ try {
828
+ await server.externalServices.transak.transakGetOrderDetails(req, undefined);
829
+ should.fail('should have thrown');
830
+ }
831
+ catch (err) {
832
+ err.message.should.equal('Transak order not found');
833
+ }
834
+ });
835
+ it('should match an EVM order address in any case', async () => {
836
+ const { server: ethServer } = await helpers_1.default.createAndJoinWallet(1, 1, { coin: 'eth', offset: 1 });
837
+ ethServer.externalServices.transak.request = fakeRequest;
838
+ const ethAddress = (await util_1.default.promisify(ethServer.createAddress).call(ethServer, {})).address;
839
+ ethAddress.should.not.equal(ethAddress.toLowerCase());
840
+ for (const walletAddress of [ethAddress.toLowerCase(), ethAddress.toUpperCase().replace('0X', '0x')]) {
841
+ orderBody = { data: { walletAddress, status: 'PROCESSING' } };
842
+ const data = await ethServer.externalServices.transak.transakGetOrderDetails({ headers: {}, body: { ...req.body } }, ethServer.walletId);
843
+ data.data.walletAddress.should.equal(walletAddress);
844
+ }
845
+ });
846
+ it('should pass through a Transak error without an ownership check', async () => {
847
+ orderStatuses = [400];
848
+ orderBody = { error: { statusCode: 400, message: 'Invalid orderId' } };
849
+ const data = await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
850
+ data.should.deep.equal({ error: { statusCode: 400, message: 'Invalid orderId' } });
522
851
  });
523
852
  it('should work properly if req is OK', async () => {
524
- const data = await server.externalServices.transak.transakGetOrderDetails(req);
853
+ const data = await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
525
854
  should.exist(data);
526
855
  });
527
856
  it('should work properly if req is OK for web', async () => {
528
857
  req.body.context = 'web';
529
- const data = await server.externalServices.transak.transakGetOrderDetails(req);
858
+ const data = await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
530
859
  should.exist(data);
531
860
  });
861
+ it('should use the server-side partner token and ignore any client accessToken', async () => {
862
+ req.body.accessToken = 'clientSuppliedToken';
863
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
864
+ orderCalls.length.should.equal(1);
865
+ orderCalls[0].url.should.equal('api1/partners/api/v2/order/6f1d2c3b-1a2b-4c5d-8e9f-0a1b2c3d4e5f');
866
+ orderCalls[0].opts.headers['access-token'].should.equal(PARTNER_TOKEN);
867
+ });
868
+ it('should forward the deviceIp as x-user-ip for web context', async () => {
869
+ req.body.context = 'web';
870
+ req.body.deviceIp = '203.0.113.42';
871
+ req.headers['x-forwarded-for'] = '198.51.100.7';
872
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
873
+ orderCalls[0].opts.headers['x-user-ip'].should.equal('203.0.113.42');
874
+ });
875
+ it('should ignore a body deviceIp for non-web context and use the request IP', async () => {
876
+ req.body.deviceIp = '203.0.113.42';
877
+ req.headers['x-forwarded-for'] = '198.51.100.7';
878
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
879
+ orderCalls[0].opts.headers['x-user-ip'].should.equal('198.51.100.7');
880
+ });
881
+ it('should share the partner token with signedPaymentUrl', async () => {
882
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
883
+ await server.externalServices.transak.transakGetSignedPaymentUrl({ headers: {}, body: { env: 'sandbox', ...appWidgetBody() } });
884
+ refreshCalls.length.should.equal(1);
885
+ });
886
+ it('should refresh and retry once when Transak rejects an older partner token', async () => {
887
+ tokens = ['staleToken', 'freshToken'];
888
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
889
+ await updatePartnerTokenCache({ $set: { 'result.fetchedAt': Date.now() - 60 * 60 * 1000 } });
890
+ orderStatuses = [401, 200];
891
+ await server.externalServices.transak.transakGetOrderDetails({ headers: {}, body: { ...req.body } }, server.walletId);
892
+ orderCalls.map(c => c.opts.headers['access-token']).should.deep.equal(['staleToken', 'staleToken', 'freshToken']);
893
+ });
894
+ for (const orderId of ['../orders', '../orders?limit=100', 'abc/def', 'abc?x=1', 'a#b', '', 'a'.repeat(65)]) {
895
+ it(`should reject orderId ${JSON.stringify(orderId)} without calling Transak`, async () => {
896
+ req.body.orderId = orderId;
897
+ try {
898
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
899
+ should.fail('should have thrown');
900
+ }
901
+ catch (err) {
902
+ err.message.should.match(/Invalid Transak orderId|Transak's request missing arguments/);
903
+ }
904
+ orderCalls.length.should.equal(0);
905
+ refreshCalls.length.should.equal(0);
906
+ });
907
+ }
532
908
  it('should return error if get returns error', async () => {
533
- const fakeRequest2 = {
534
- get: (_url, _opts, _cb) => { return _cb(new Error('Error'), null); },
535
- };
536
- server.externalServices.transak.request = fakeRequest2;
909
+ orderError = new Error('Error');
537
910
  try {
538
- await server.externalServices.transak.transakGetOrderDetails(req);
911
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
539
912
  should.fail('should have thrown');
540
913
  }
541
914
  catch (err) {
@@ -546,12 +919,9 @@ describe('Transak integration', () => {
546
919
  ;
547
920
  });
548
921
  it('should return error and not leak the response if body is empty', async () => {
549
- const fakeRequest2 = {
550
- get: (_url, opts, cb) => { return cb(null, { statusCode: 200, body: undefined, request: { headers: opts.headers } }); },
551
- };
552
- server.externalServices.transak.request = fakeRequest2;
922
+ orderBody = undefined;
553
923
  try {
554
- await server.externalServices.transak.transakGetOrderDetails(req);
924
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
555
925
  should.fail('should have thrown');
556
926
  }
557
927
  catch (err) {
@@ -562,7 +932,7 @@ describe('Transak integration', () => {
562
932
  it('should return error if there is some missing arguments', async () => {
563
933
  delete req.body.orderId;
564
934
  try {
565
- await server.externalServices.transak.transakGetOrderDetails(req);
935
+ await server.externalServices.transak.transakGetOrderDetails(req, server.walletId);
566
936
  should.fail('should have thrown');
567
937
  }
568
938
  catch (err) {