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