@bitpay-labs/bitcore-wallet-service 11.11.2 → 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 (152) 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 +13 -2
  12. package/src/lib/common/defaults.ts +31 -0
  13. package/src/lib/common/utils.ts +18 -1
  14. package/src/lib/emailservice.ts +3 -1
  15. package/src/lib/errors/errordefinitions.ts +4 -0
  16. package/src/lib/model/preferences.ts +4 -0
  17. package/src/lib/model/tsskeygen.ts +16 -0
  18. package/src/lib/model/wallet.ts +1 -0
  19. package/src/lib/pushnotificationsservice.ts +2 -0
  20. package/src/lib/routes/maintenance.ts +11 -2
  21. package/src/lib/routes/middleware/authTssRequest.ts +28 -16
  22. package/src/lib/routes/middleware/verifyTssMessage.ts +4 -1
  23. package/src/lib/routes/services.ts +8 -2
  24. package/src/lib/routes/tss.ts +33 -30
  25. package/src/lib/routes/walletdata.ts +2 -0
  26. package/src/lib/server.ts +82 -19
  27. package/src/lib/storage.ts +39 -0
  28. package/src/lib/tss.ts +111 -7
  29. package/test/blockchainexplorer.test.ts +7 -0
  30. package/test/expressapp.test.ts +130 -0
  31. package/test/integration/externalservices/coinGecko.test.ts +42 -2
  32. package/test/integration/externalservices/transak.test.ts +545 -135
  33. package/test/integration/fiatrateservice.test.ts +13 -0
  34. package/test/integration/helpers.ts +1 -0
  35. package/test/integration/server.test.ts +192 -4
  36. package/test/integration/tss.test.ts +428 -0
  37. package/ts_build/package.json +4 -4
  38. package/ts_build/src/config.d.ts.map +1 -1
  39. package/ts_build/src/config.js +8 -0
  40. package/ts_build/src/config.js.map +1 -1
  41. package/ts_build/src/externalservices/coingecko.d.ts.map +1 -1
  42. package/ts_build/src/externalservices/coingecko.js +6 -1
  43. package/ts_build/src/externalservices/coingecko.js.map +1 -1
  44. package/ts_build/src/externalservices/transak.d.ts +28 -2
  45. package/ts_build/src/externalservices/transak.d.ts.map +1 -1
  46. package/ts_build/src/externalservices/transak.js +274 -57
  47. package/ts_build/src/externalservices/transak.js.map +1 -1
  48. package/ts_build/src/lib/blockchainexplorer.d.ts.map +1 -1
  49. package/ts_build/src/lib/blockchainexplorer.js +4 -0
  50. package/ts_build/src/lib/blockchainexplorer.js.map +1 -1
  51. package/ts_build/src/lib/blockchainexplorers/v8.d.ts.map +1 -1
  52. package/ts_build/src/lib/blockchainexplorers/v8.js +1 -0
  53. package/ts_build/src/lib/blockchainexplorers/v8.js.map +1 -1
  54. package/ts_build/src/lib/blockchainmonitor.d.ts.map +1 -1
  55. package/ts_build/src/lib/blockchainmonitor.js +1 -0
  56. package/ts_build/src/lib/blockchainmonitor.js.map +1 -1
  57. package/ts_build/src/lib/chain/arc/index.d.ts +20 -0
  58. package/ts_build/src/lib/chain/arc/index.d.ts.map +1 -0
  59. package/ts_build/src/lib/chain/arc/index.js +95 -0
  60. package/ts_build/src/lib/chain/arc/index.js.map +1 -0
  61. package/ts_build/src/lib/chain/eth/index.d.ts +1 -0
  62. package/ts_build/src/lib/chain/eth/index.d.ts.map +1 -1
  63. package/ts_build/src/lib/chain/eth/index.js +38 -19
  64. package/ts_build/src/lib/chain/eth/index.js.map +1 -1
  65. package/ts_build/src/lib/chain/index.d.ts.map +1 -1
  66. package/ts_build/src/lib/chain/index.js +2 -0
  67. package/ts_build/src/lib/chain/index.js.map +1 -1
  68. package/ts_build/src/lib/common/constants.d.ts +13 -1
  69. package/ts_build/src/lib/common/constants.d.ts.map +1 -1
  70. package/ts_build/src/lib/common/constants.js +13 -2
  71. package/ts_build/src/lib/common/constants.js.map +1 -1
  72. package/ts_build/src/lib/common/defaults.d.ts +24 -0
  73. package/ts_build/src/lib/common/defaults.d.ts.map +1 -1
  74. package/ts_build/src/lib/common/defaults.js +30 -0
  75. package/ts_build/src/lib/common/defaults.js.map +1 -1
  76. package/ts_build/src/lib/common/index.d.ts +38 -1
  77. package/ts_build/src/lib/common/index.d.ts.map +1 -1
  78. package/ts_build/src/lib/common/utils.d.ts +1 -0
  79. package/ts_build/src/lib/common/utils.d.ts.map +1 -1
  80. package/ts_build/src/lib/common/utils.js +16 -1
  81. package/ts_build/src/lib/common/utils.js.map +1 -1
  82. package/ts_build/src/lib/emailservice.d.ts.map +1 -1
  83. package/ts_build/src/lib/emailservice.js +2 -0
  84. package/ts_build/src/lib/emailservice.js.map +1 -1
  85. package/ts_build/src/lib/errors/errordefinitions.d.ts +2 -0
  86. package/ts_build/src/lib/errors/errordefinitions.d.ts.map +1 -1
  87. package/ts_build/src/lib/errors/errordefinitions.js +2 -0
  88. package/ts_build/src/lib/errors/errordefinitions.js.map +1 -1
  89. package/ts_build/src/lib/model/preferences.d.ts +2 -0
  90. package/ts_build/src/lib/model/preferences.d.ts.map +1 -1
  91. package/ts_build/src/lib/model/preferences.js +3 -0
  92. package/ts_build/src/lib/model/preferences.js.map +1 -1
  93. package/ts_build/src/lib/model/tsskeygen.d.ts +10 -0
  94. package/ts_build/src/lib/model/tsskeygen.d.ts.map +1 -1
  95. package/ts_build/src/lib/model/tsskeygen.js +3 -0
  96. package/ts_build/src/lib/model/tsskeygen.js.map +1 -1
  97. package/ts_build/src/lib/model/wallet.d.ts.map +1 -1
  98. package/ts_build/src/lib/model/wallet.js +1 -0
  99. package/ts_build/src/lib/model/wallet.js.map +1 -1
  100. package/ts_build/src/lib/pushnotificationsservice.d.ts.map +1 -1
  101. package/ts_build/src/lib/pushnotificationsservice.js +2 -0
  102. package/ts_build/src/lib/pushnotificationsservice.js.map +1 -1
  103. package/ts_build/src/lib/routes/maintenance.d.ts.map +1 -1
  104. package/ts_build/src/lib/routes/maintenance.js +12 -4
  105. package/ts_build/src/lib/routes/maintenance.js.map +1 -1
  106. package/ts_build/src/lib/routes/middleware/authTssRequest.d.ts +6 -2
  107. package/ts_build/src/lib/routes/middleware/authTssRequest.d.ts.map +1 -1
  108. package/ts_build/src/lib/routes/middleware/authTssRequest.js +21 -14
  109. package/ts_build/src/lib/routes/middleware/authTssRequest.js.map +1 -1
  110. package/ts_build/src/lib/routes/middleware/verifyTssMessage.d.ts.map +1 -1
  111. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js +2 -1
  112. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js.map +1 -1
  113. package/ts_build/src/lib/routes/services.d.ts.map +1 -1
  114. package/ts_build/src/lib/routes/services.js +7 -2
  115. package/ts_build/src/lib/routes/services.js.map +1 -1
  116. package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
  117. package/ts_build/src/lib/routes/tss.js +32 -30
  118. package/ts_build/src/lib/routes/tss.js.map +1 -1
  119. package/ts_build/src/lib/routes/walletdata.d.ts.map +1 -1
  120. package/ts_build/src/lib/routes/walletdata.js +2 -0
  121. package/ts_build/src/lib/routes/walletdata.js.map +1 -1
  122. package/ts_build/src/lib/server.d.ts +23 -2
  123. package/ts_build/src/lib/server.d.ts.map +1 -1
  124. package/ts_build/src/lib/server.js +37 -5
  125. package/ts_build/src/lib/server.js.map +1 -1
  126. package/ts_build/src/lib/storage.d.ts +9 -0
  127. package/ts_build/src/lib/storage.d.ts.map +1 -1
  128. package/ts_build/src/lib/storage.js +26 -0
  129. package/ts_build/src/lib/storage.js.map +1 -1
  130. package/ts_build/src/lib/tss.d.ts +12 -0
  131. package/ts_build/src/lib/tss.d.ts.map +1 -1
  132. package/ts_build/src/lib/tss.js +64 -5
  133. package/ts_build/src/lib/tss.js.map +1 -1
  134. package/ts_build/test/blockchainexplorer.test.js +6 -0
  135. package/ts_build/test/blockchainexplorer.test.js.map +1 -1
  136. package/ts_build/test/expressapp.test.js +120 -0
  137. package/ts_build/test/expressapp.test.js.map +1 -1
  138. package/ts_build/test/integration/externalservices/coinGecko.test.js +38 -2
  139. package/ts_build/test/integration/externalservices/coinGecko.test.js.map +1 -1
  140. package/ts_build/test/integration/externalservices/transak.test.d.ts.map +1 -1
  141. package/ts_build/test/integration/externalservices/transak.test.js +498 -128
  142. package/ts_build/test/integration/externalservices/transak.test.js.map +1 -1
  143. package/ts_build/test/integration/fiatrateservice.test.js +11 -0
  144. package/ts_build/test/integration/fiatrateservice.test.js.map +1 -1
  145. package/ts_build/test/integration/helpers.d.ts.map +1 -1
  146. package/ts_build/test/integration/helpers.js +1 -0
  147. package/ts_build/test/integration/helpers.js.map +1 -1
  148. package/ts_build/test/integration/server.test.js +182 -3
  149. package/ts_build/test/integration/server.test.js.map +1 -1
  150. package/ts_build/test/integration/tss.test.d.ts.map +1 -1
  151. package/ts_build/test/integration/tss.test.js +344 -0
  152. package/ts_build/test/integration/tss.test.js.map +1 -1
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@bitpay-labs/bitcore-wallet-service",
3
3
  "description": "A service for Mutisig HD Bitcoin Wallets",
4
4
  "author": "BitPay Inc",
5
- "version": "11.11.2",
5
+ "version": "11.11.4",
6
6
  "license": "MIT",
7
7
  "main": "ts_build/src/index.js",
8
8
  "types": "./ts_build/src/index.d.ts",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@bitpay-labs/bitcore-logging": "^11.11.2",
45
- "@bitpay-labs/bitcore-tss": "^11.11.2",
46
- "@bitpay-labs/crypto-wallet-core": "^11.11.2",
45
+ "@bitpay-labs/bitcore-tss": "^11.11.4",
46
+ "@bitpay-labs/crypto-wallet-core": "^11.11.4",
47
47
  "@sendgrid/mail": "6.5.4",
48
48
  "abi-decoder": "2.4.0",
49
49
  "async": "0.9.2",
@@ -107,5 +107,5 @@
107
107
  "email": "kjoseph@bitpay.com"
108
108
  }
109
109
  ],
110
- "gitHead": "1a044f2c984f6856f569981eebd91b051ab130d8"
110
+ "gitHead": "1eb34cc33d0628f9304be08a19da5315f140e70f"
111
111
  }
package/src/config.ts CHANGED
@@ -130,6 +130,14 @@ const Config = (): any => {
130
130
  url: 'https://api-eth.bitcore.io'
131
131
  }
132
132
  },
133
+ arc: {
134
+ livenet: {
135
+ url: 'https://api-eth.bitcore.io'
136
+ },
137
+ testnet: {
138
+ url: 'https://api-eth.bitcore.io'
139
+ }
140
+ },
133
141
  sol: {
134
142
  livenet: {
135
143
  url: 'https://api-sol.bitcore.io'
@@ -64,6 +64,7 @@ const DEFAULT_COINS: Record<string, string> = {
64
64
  pol: 'polygon-ecosystem-token',
65
65
  sol: 'solana',
66
66
  xrp: 'ripple',
67
+ arc: 'usd-coin',
67
68
  };
68
69
 
69
70
  const DEFAULT_COIN_ENTRIES = Object.entries(DEFAULT_COINS);
@@ -79,6 +80,7 @@ const CHAIN_ALIASES = {
79
80
  } as const satisfies Record<string, BitPayTokenChain>;
80
81
 
81
82
  const COINGECKO_PLATFORM_ID_BY_CHAIN = {
83
+ [Constants.EVM_CHAINS.ARC]: 'arc',
82
84
  [Constants.EVM_CHAINS.ARB]: 'arbitrum-one',
83
85
  [Constants.EVM_CHAINS.BASE]: 'base',
84
86
  [Constants.EVM_CHAINS.ETH]: 'ethereum',
@@ -527,7 +529,7 @@ export class CoinGeckoService {
527
529
 
528
530
  const cacheKey = `cgTokenList:${platformId}`;
529
531
 
530
- return this.withGlobalCache(cacheKey, Defaults.COIN_GECKO_CACHE_DURATION, async () => {
532
+ const tokenList = await this.withGlobalCache(cacheKey, Defaults.COIN_GECKO_CACHE_DURATION, async () => {
531
533
  const { API_KEY } = this.coinGeckoGetCredentials();
532
534
  const headers = this.coinGeckoGetHeaders(API_KEY);
533
535
 
@@ -543,5 +545,8 @@ export class CoinGeckoService {
543
545
  return { ...t, logoURI };
544
546
  });
545
547
  });
548
+ return platformId === 'arc'
549
+ ? tokenList.filter(token => token?.address?.toLowerCase() !== Constants.ARC.USDC_CONTRACT_ADDRESS)
550
+ : tokenList;
546
551
  }
547
552
  }
@@ -1,12 +1,106 @@
1
+ import * as crypto from 'crypto';
2
+ import { Web3 } from '@bitpay-labs/crypto-wallet-core';
1
3
  import * as request from 'request';
2
4
  import config from '../config';
3
5
  import { Utils } from '../lib/common/utils';
4
6
  import { ClientError } from '../lib/errors/clienterror';
7
+ import { Lock } from '../lib/lock';
5
8
  import logger from '../lib/logger';
6
9
  import { checkRequired } from '../lib/server';
10
+ import { Storage } from '../lib/storage';
11
+
12
+ interface TransakKeys {
13
+ ENV: string;
14
+ API: string;
15
+ API_KEY: string;
16
+ SECRET_KEY: string;
17
+ WIDGET_API: string;
18
+ }
19
+
20
+ interface TransakPartnerToken {
21
+ accessToken: string;
22
+ /** Unix epoch in milliseconds */
23
+ expiresAt: number;
24
+ /** Unix epoch in milliseconds */
25
+ fetchedAt: number;
26
+ }
27
+
28
+ // The partner access token from /partners/api/v2/refresh-token is scoped to
29
+ // BitPay's whole Transak account (it can read every order), so it must never
30
+ // leave BWS. It is cached in the shared global cache so every BWS instance
31
+ // reuses one token until it nears expiry, as Transak requires.
32
+ const PARTNER_TOKEN_CACHE_PREFIX = 'transakPartnerToken:';
33
+ const PARTNER_TOKEN_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
34
+ const PARTNER_TOKEN_REFRESH_BUFFER = 10 * 60 * 1000;
35
+ // Used if Transak omits expiresAt or returns one that is already (nearly) past.
36
+ const PARTNER_TOKEN_FALLBACK_TTL = 60 * 60 * 1000;
37
+ // If Transak rejects a token fetched less than this long ago, the rejection
38
+ // isn't about token age, so don't refresh again.
39
+ const PARTNER_TOKEN_MIN_FORCED_REFRESH_AGE = 60 * 1000;
40
+ // Statuses that mean Transak rejected the token itself.
41
+ const PARTNER_TOKEN_REJECTED_STATUSES = [401, 403];
42
+ // If a refresh can't happen, keep serving the current token until this close
43
+ // to its real expiry.
44
+ const PARTNER_TOKEN_EXPIRY_MARGIN = 30 * 1000;
45
+ const PARTNER_TOKEN_REQUEST_TIMEOUT = 10 * 1000;
46
+ // Lock time is well above the request timeout so a holder never outlives its
47
+ // lock (Lock.release is not owner-checked).
48
+ const PARTNER_TOKEN_LOCK_TIME = 30 * 1000;
49
+ const PARTNER_TOKEN_LOCK_WAIT = 15 * 1000;
50
+ // After a failed refresh, don't retry for this long (shared across instances),
51
+ // so an outage or disabled key doesn't turn every checkout into a refresh call.
52
+ const PARTNER_TOKEN_FAILURE_PREFIX = 'transakPartnerTokenFailure:';
53
+ const PARTNER_TOKEN_FAILURE_BACKOFF = 30 * 1000;
54
+
55
+ // App builds and the bitpay.com widget still call getAccessToken and refuse to
56
+ // continue without a non-empty accessToken and an integer expiresAt (seconds).
57
+ // They get this placeholder; BWS ignores whatever token a client sends back.
58
+ const CLIENT_TOKEN_PLACEHOLDER = 'server-managed';
59
+ // Short so a rollback (or old pods during a rolling deploy) only sees the
60
+ // placeholder briefly; getAccessToken no longer calls Transak, so it's cheap.
61
+ const CLIENT_TOKEN_PLACEHOLDER_TTL = 2 * 60; // seconds
62
+
63
+ // Only these client fields are forwarded to Transak's front-end widget.
64
+ const WIDGET_PARAMS = [
65
+ 'walletAddress',
66
+ 'disableWalletAddressForm',
67
+ 'redirectURL',
68
+ 'exchangeScreenTitle',
69
+ 'fiatAmount',
70
+ 'fiatCurrency',
71
+ 'network',
72
+ 'defaultNetwork',
73
+ 'cryptoCurrencyCode',
74
+ 'cryptoCurrencyList',
75
+ 'hideExchangeScreen',
76
+ 'themeColor',
77
+ 'hideMenu',
78
+ 'partnerOrderId',
79
+ 'partnerCustomerId',
80
+ 'paymentMethod',
81
+ 'defaultPaymentMethod',
82
+ ];
83
+
84
+ const ORDER_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
85
+
86
+ // Transak's error message, if any, for logs. Never the whole body.
87
+ function transakErrorDetail(data): string {
88
+ const message = data?.body?.error?.message ?? data?.body?.message;
89
+ if (message == null) return '';
90
+ const text = typeof message === 'string' ? message : JSON.stringify(message);
91
+ return ` (${text.slice(0, 200)})`;
92
+ }
7
93
 
8
94
  export class TransakService {
9
95
  request: any = request;
96
+ storage: Storage;
97
+ lock: Lock;
98
+ partnerTokenLockWait = PARTNER_TOKEN_LOCK_WAIT;
99
+
100
+ constructor(storage: Storage, lock?: Lock) {
101
+ this.storage = storage;
102
+ this.lock = lock ?? new Lock(storage);
103
+ }
10
104
 
11
105
  private transakGetKeys(req, cleanBody: boolean = true) {
12
106
  if (!config.transak) throw new Error('Transak missing credentials');
@@ -22,12 +116,8 @@ export class TransakService {
22
116
  delete req.body.context;
23
117
  }
24
118
 
25
- const keys: {
26
- API: string;
27
- API_KEY: string;
28
- SECRET_KEY: string;
29
- WIDGET_API: string;
30
- } = {
119
+ const keys: TransakKeys = {
120
+ ENV: env,
31
121
  API: config.transak[env].api,
32
122
  API_KEY: config.transak[env].apiKey,
33
123
  SECRET_KEY: config.transak[env].secretKey,
@@ -55,15 +145,94 @@ export class TransakService {
55
145
  return userIp || '';
56
146
  }
57
147
 
58
- transakGetAccessToken(req): Promise<any> {
59
- return new Promise((resolve, reject) => {
60
- const userIp = this.transakGetUserIp(req);
61
- let keys;
62
- try {
63
- keys = this.transakGetKeys(req);
64
- } catch (err) {
65
- return reject(err);
148
+ /**
149
+ * Kept for clients that still fetch a token before checkout. Returns a
150
+ * placeholder in the shape they expect; the real partner token stays in BWS.
151
+ */
152
+ async transakGetAccessToken(req): Promise<{ data: { accessToken: string; expiresAt: number } }> {
153
+ this.transakGetKeys(req);
154
+ return {
155
+ data: {
156
+ accessToken: CLIENT_TOKEN_PLACEHOLDER,
157
+ expiresAt: Math.floor(Date.now() / 1000) + CLIENT_TOKEN_PLACEHOLDER_TTL
66
158
  }
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Cache/lock id for a partner token. Includes a hash of the API key so a
164
+ * rotated key never reuses a token minted for the old one.
165
+ */
166
+ private partnerTokenId(keys: TransakKeys): string {
167
+ const keyHash = crypto.createHash('sha256').update(String(keys.API_KEY)).digest('hex').slice(0, 12);
168
+ return `${keys.ENV}:${keyHash}`;
169
+ }
170
+
171
+ private readCachedPartnerToken(id: string): Promise<TransakPartnerToken | null> {
172
+ return new Promise(resolve => {
173
+ this.storage.checkAndUseGlobalCache(PARTNER_TOKEN_CACHE_PREFIX + id, PARTNER_TOKEN_CACHE_MAX_AGE, (err, values) => {
174
+ if (err) {
175
+ logger.warn(`Transak partner token cache read failed: ${err.message || err}`);
176
+ return resolve(null);
177
+ }
178
+ return resolve(values?.accessToken ? values : null);
179
+ });
180
+ });
181
+ }
182
+
183
+ private storeCachedPartnerToken(id: string, token: TransakPartnerToken): Promise<void> {
184
+ return new Promise(resolve => {
185
+ this.storage.storeGlobalCache(PARTNER_TOKEN_CACHE_PREFIX + id, token, err => {
186
+ if (err) logger.warn(`Transak partner token cache write failed: ${err.message || err}`);
187
+ return resolve();
188
+ });
189
+ });
190
+ }
191
+
192
+ private recentRefreshFailed(id: string): Promise<boolean> {
193
+ return new Promise(resolve => {
194
+ this.storage.checkAndUseGlobalCache(PARTNER_TOKEN_FAILURE_PREFIX + id, PARTNER_TOKEN_FAILURE_BACKOFF, (err, values) => {
195
+ return resolve(!err && !!values);
196
+ });
197
+ });
198
+ }
199
+
200
+ private markRefreshFailed(id: string): Promise<void> {
201
+ return new Promise(resolve => {
202
+ this.storage.storeGlobalCache(PARTNER_TOKEN_FAILURE_PREFIX + id, { failedAt: Date.now() }, () => resolve());
203
+ });
204
+ }
205
+
206
+ private acquirePartnerTokenLock(id: string): Promise<((cb?: () => void) => void) | null> {
207
+ return new Promise(resolve => {
208
+ this.lock.acquire(
209
+ PARTNER_TOKEN_CACHE_PREFIX + id,
210
+ { lockTime: PARTNER_TOKEN_LOCK_TIME, waitTime: this.partnerTokenLockWait },
211
+ (err, release) => {
212
+ if (err) {
213
+ logger.warn(`Transak partner token lock not acquired: ${err.message || err}`);
214
+ return resolve(null);
215
+ }
216
+ return resolve(release);
217
+ }
218
+ );
219
+ });
220
+ }
221
+
222
+ private isUsablePartnerToken(token: TransakPartnerToken | null, staleToken?: string): boolean {
223
+ return !!token?.accessToken
224
+ && token.accessToken !== staleToken
225
+ && token.expiresAt - PARTNER_TOKEN_REFRESH_BUFFER > Date.now();
226
+ }
227
+
228
+ private isUnexpiredPartnerToken(token: TransakPartnerToken | null, staleToken?: string): boolean {
229
+ return !!token?.accessToken
230
+ && token.accessToken !== staleToken
231
+ && token.expiresAt - PARTNER_TOKEN_EXPIRY_MARGIN > Date.now();
232
+ }
233
+
234
+ private fetchPartnerToken(keys: TransakKeys, userIp: string): Promise<TransakPartnerToken> {
235
+ return new Promise((resolve, reject) => {
67
236
  const API = keys.API;
68
237
  const API_KEY = keys.API_KEY;
69
238
  const SECRET_KEY = keys.SECRET_KEY;
@@ -86,27 +255,166 @@ export class TransakService {
86
255
  {
87
256
  headers,
88
257
  body,
89
- json: true
258
+ json: true,
259
+ timeout: PARTNER_TOKEN_REQUEST_TIMEOUT
90
260
  },
91
261
  (err, data) => {
92
262
  if (err) {
93
263
  logger.error(`Transak request failed with error (${URL}): ${err.stack || err}`);
94
264
  return reject(new ClientError('Failed to get Transak access token'));
95
- } else {
96
- const success = data.statusCode >= 200 && data.statusCode < 300;
97
- if (!success) {
98
- logger.warn(`Transak request failed with status code ${data.statusCode}: ${URL}`);
99
- }
100
- if (!data.body) {
101
- return reject(new ClientError('Transak did not return an access token'));
102
- }
103
- return resolve(data.body);
104
265
  }
266
+ const success = data.statusCode >= 200 && data.statusCode < 300;
267
+ const accessToken = data.body?.data?.accessToken;
268
+ if (!success || !accessToken || typeof accessToken !== 'string') {
269
+ logger.warn(`Transak request failed with status code ${data.statusCode}: ${URL}${transakErrorDetail(data)}`);
270
+ return reject(new ClientError('Transak did not return an access token'));
271
+ }
272
+
273
+ // Transak returns expiresAt in seconds; accept milliseconds too. If it
274
+ // is missing or already inside the refresh buffer (e.g. a duration
275
+ // instead of a timestamp), use a fallback TTL rather than treating the
276
+ // token as expired and refreshing on every request.
277
+ const now = Date.now();
278
+ const rawExpiresAt = Number(data.body.data.expiresAt);
279
+ let expiresAt = rawExpiresAt < 1e12 ? rawExpiresAt * 1000 : rawExpiresAt;
280
+ if (!Number.isFinite(expiresAt) || expiresAt - PARTNER_TOKEN_REFRESH_BUFFER <= now) {
281
+ logger.warn(`Transak returned an unusable access token expiresAt (${data.body.data.expiresAt}); using fallback TTL`);
282
+ expiresAt = now + PARTNER_TOKEN_FALLBACK_TTL;
283
+ }
284
+ return resolve({ accessToken, expiresAt, fetchedAt: now });
105
285
  }
106
286
  );
107
287
  });
108
288
  }
109
289
 
290
+ /**
291
+ * Returns the cached partner token if usable, or null if a refresh is
292
+ * needed. Throws if a refresh must not happen right now.
293
+ */
294
+ private async checkPartnerTokenCache(id: string, staleToken?: string): Promise<string | null> {
295
+ const cached = await this.readCachedPartnerToken(id);
296
+ if (this.isUsablePartnerToken(cached, staleToken)) return cached.accessToken;
297
+
298
+ if (staleToken && cached?.accessToken === staleToken
299
+ && Date.now() - (cached.fetchedAt || 0) < PARTNER_TOKEN_MIN_FORCED_REFRESH_AGE) {
300
+ throw new ClientError('Transak rejected the access token');
301
+ }
302
+ if (await this.recentRefreshFailed(id)) {
303
+ throw new ClientError('Failed to get Transak access token');
304
+ }
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * Returns the cached partner access token, refreshing it at most once across
310
+ * all BWS instances when it is missing, near expiry, or equal to staleToken
311
+ * (a token Transak just rejected).
312
+ */
313
+ private async getPartnerToken(keys: TransakKeys, userIp: string, staleToken?: string): Promise<string> {
314
+ const id = this.partnerTokenId(keys);
315
+
316
+ const cached = await this.readCachedPartnerToken(id);
317
+ if (this.isUsablePartnerToken(cached, staleToken)) return cached.accessToken;
318
+
319
+ try {
320
+ return await this.refreshPartnerToken(keys, userIp, id, staleToken);
321
+ } catch (err) {
322
+ // The refresh buffer is proactive. If the refresh can't happen right now
323
+ // (backoff, lock timeout, Transak error), keep serving the current token
324
+ // while Transak would still accept it.
325
+ const current = await this.readCachedPartnerToken(id);
326
+ if (this.isUnexpiredPartnerToken(current, staleToken)) {
327
+ logger.warn(`Transak partner token refresh unavailable; using current token: ${err?.message || err}`);
328
+ return current.accessToken;
329
+ }
330
+ throw err;
331
+ }
332
+ }
333
+
334
+ private async refreshPartnerToken(keys: TransakKeys, userIp: string, id: string, staleToken?: string): Promise<string> {
335
+ const cached = await this.checkPartnerTokenCache(id, staleToken);
336
+ if (cached) return cached;
337
+
338
+ const release = await this.acquirePartnerTokenLock(id);
339
+ if (!release) {
340
+ // Never refresh without the lock: if the holder is slow, every waiter
341
+ // would time out together and refresh at once.
342
+ const afterWait = await this.readCachedPartnerToken(id);
343
+ if (this.isUsablePartnerToken(afterWait, staleToken)) return afterWait.accessToken;
344
+ throw new ClientError('Failed to get Transak access token');
345
+ }
346
+
347
+ try {
348
+ // Another instance may have refreshed (or failed) while we waited.
349
+ const recheck = await this.checkPartnerTokenCache(id, staleToken);
350
+ if (recheck) return recheck;
351
+
352
+ let fresh: TransakPartnerToken;
353
+ try {
354
+ fresh = await this.fetchPartnerToken(keys, userIp);
355
+ } catch (err) {
356
+ await this.markRefreshFailed(id);
357
+ throw err;
358
+ }
359
+ await this.storeCachedPartnerToken(id, fresh);
360
+ return fresh.accessToken;
361
+ } finally {
362
+ release();
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Calls a Transak endpoint with the partner token. If Transak rejects the
368
+ * token (revoked or expired early), refreshes it once and retries.
369
+ */
370
+ private async withPartnerToken(
371
+ keys: TransakKeys,
372
+ userIp: string,
373
+ call: (accessToken: string) => Promise<any>
374
+ ): Promise<any> {
375
+ const accessToken = await this.getPartnerToken(keys, userIp);
376
+ const data = await call(accessToken);
377
+ if (!PARTNER_TOKEN_REJECTED_STATUSES.includes(data?.statusCode)) return data;
378
+
379
+ logger.warn('Transak rejected the cached partner token; refreshing');
380
+ const freshToken = await this.getPartnerToken(keys, userIp, accessToken);
381
+ return call(freshToken);
382
+ }
383
+
384
+ /**
385
+ * True if address is one of the wallet's addresses in BWS. EVM addresses
386
+ * are matched in any case, since BWS stores them checksummed.
387
+ */
388
+ private isWalletAddress(walletId: string, address: string): Promise<boolean> {
389
+ if (!walletId || !address || typeof address !== 'string') return Promise.resolve(false);
390
+ const candidates = new Set([address]);
391
+ if (/^0x[0-9a-fA-F]{40}$/.test(address)) {
392
+ candidates.add(address.toLowerCase());
393
+ candidates.add(Web3.utils.toChecksumAddress(address));
394
+ }
395
+ return new Promise(resolve => {
396
+ this.storage.fetchAddressesByWalletId(walletId, [...candidates], (err, addresses) => {
397
+ if (err) {
398
+ logger.warn(`Transak order address lookup failed: ${err.message || err}`);
399
+ return resolve(false);
400
+ }
401
+ return resolve(!!addresses?.length);
402
+ });
403
+ });
404
+ }
405
+
406
+ private sendRequest(method: 'get' | 'post', URL: string, opts: any, errorMessage: string): Promise<any> {
407
+ return new Promise((resolve, reject) => {
408
+ this.request[method](URL, { ...opts, json: true }, (err, data) => {
409
+ if (err) {
410
+ logger.error(`Transak request failed with error (${URL}): ${err.stack || err}`);
411
+ return reject(new ClientError(errorMessage));
412
+ }
413
+ return resolve(data);
414
+ });
415
+ });
416
+ }
417
+
110
418
  transakGetCryptoCurrencies(req): Promise<any> {
111
419
  return new Promise((resolve, reject) => {
112
420
  let keys;
@@ -259,7 +567,6 @@ export class TransakService {
259
567
  transakGetSignedPaymentUrl(req): Promise<{ urlWithSignature: string }> {
260
568
  return new Promise(async (resolve, reject) => {
261
569
  const appRequiredParams = [
262
- 'accessToken',
263
570
  'walletAddress',
264
571
  'redirectURL',
265
572
  'fiatAmount',
@@ -270,7 +577,7 @@ export class TransakService {
270
577
  'partnerCustomerId',
271
578
  ];
272
579
 
273
- const requiredParams = req.body.context === 'web' ? ['accessToken'] : appRequiredParams;
580
+ const requiredParams = req.body.context === 'web' ? [] : appRequiredParams;
274
581
  const referrerDomain = req.body.referrerDomain ?? req.body.context === 'web' ? 'bitpay.com' : 'bitpay';
275
582
  const userIp = this.transakGetUserIp(req);
276
583
  let keys;
@@ -289,13 +596,16 @@ export class TransakService {
289
596
  const headers = {
290
597
  Accept: 'application/json',
291
598
  'Content-Type': 'application/json',
292
- 'access-token': req.body.accessToken,
293
599
  'x-api-key': API_KEY,
294
600
  'x-user-ip': userIp,
295
601
  };
296
602
 
297
- const widgetBody = { ...req.body };
298
- delete widgetBody.deviceIp;
603
+ // Build widgetParams from an allowlist. Spreading req.body here used to
604
+ // forward accessToken, env and context into Transak's front-end widget.
605
+ const widgetBody = {};
606
+ for (const param of WIDGET_PARAMS) {
607
+ if (req.body[param] !== undefined) widgetBody[param] = req.body[param];
608
+ }
299
609
  const body = {
300
610
  widgetParams: {
301
611
  ...widgetBody,
@@ -306,27 +616,31 @@ export class TransakService {
306
616
 
307
617
  const URL: string = WIDGET_API + '/api/v2/auth/session';
308
618
 
309
- this.request.post(
310
- URL,
311
- {
312
- headers,
313
- body,
314
- json: true
315
- },
316
- (err, data) => {
317
- if (err) {
318
- logger.error(`Transak request failed with error (${URL}): ${err.stack || err}`);
319
- return reject(new ClientError('Failed to get Transak signed payment URL'));
320
- } else {
321
- return resolve({ urlWithSignature: data?.body?.data?.widgetUrl ?? data?.data?.widgetUrl });
322
- }
619
+ try {
620
+ const data = await this.withPartnerToken(keys, userIp, accessToken =>
621
+ this.sendRequest(
622
+ 'post',
623
+ URL,
624
+ { headers: { ...headers, 'access-token': accessToken }, body },
625
+ 'Failed to get Transak signed payment URL'
626
+ )
627
+ );
628
+ const widgetUrl = data?.body?.data?.widgetUrl ?? data?.data?.widgetUrl;
629
+ const success = data?.statusCode >= 200 && data?.statusCode < 300;
630
+ if (!success || !widgetUrl) {
631
+ logger.warn(`Transak request failed with status code ${data?.statusCode}: ${URL}${transakErrorDetail(data)}`);
632
+ return reject(new ClientError('Failed to get Transak signed payment URL'));
323
633
  }
324
- );
634
+ return resolve({ urlWithSignature: widgetUrl });
635
+ } catch (err) {
636
+ return reject(err);
637
+ }
325
638
  });
326
639
  }
327
640
 
328
- transakGetOrderDetails(req): Promise<any> {
641
+ transakGetOrderDetails(req, walletId: string): Promise<any> {
329
642
  return new Promise(async (resolve, reject) => {
643
+ const userIp = this.transakGetUserIp(req);
330
644
  let keys;
331
645
  try {
332
646
  keys = this.transakGetKeys(req, false);
@@ -336,41 +650,51 @@ export class TransakService {
336
650
  const API = keys.API;
337
651
  const API_KEY = keys.API_KEY;
338
652
 
339
- if (!checkRequired(req.body, ['orderId', 'accessToken'])) {
653
+ if (!checkRequired(req.body, ['orderId'])) {
340
654
  return reject(new ClientError("Transak's request missing arguments"));
341
655
  }
342
656
 
657
+ // orderId goes into the URL path of a request made with the partner token;
658
+ // reject anything that could change the path (e.g. "../orders").
659
+ const orderId = String(req.body.orderId);
660
+ if (!ORDER_ID_PATTERN.test(orderId)) {
661
+ return reject(new ClientError('Invalid Transak orderId'));
662
+ }
663
+
343
664
  const headers = {
344
665
  Accept: 'application/json',
345
- 'access-token': req.body.accessToken,
346
666
  'x-api-key': API_KEY,
347
- 'x-user-ip': Utils.getIpFromReq(req),
667
+ 'x-user-ip': userIp,
348
668
  };
349
669
 
350
- const URL: string = API + `/partners/api/v2/order/${req.body.orderId}`;
670
+ const URL: string = API + `/partners/api/v2/order/${orderId}`;
351
671
 
352
- this.request.get(
353
- URL,
354
- {
355
- headers,
356
- json: true
357
- },
358
- (err, data) => {
359
- if (err) {
360
- logger.error(`Transak request failed with error (${URL}): ${err.stack || err}`);
361
- return reject(new ClientError('Failed to get Transak order details'));
362
- } else {
363
- const success = data.statusCode >= 200 && data.statusCode < 300;
364
- if (!success) {
365
- logger.warn(`Transak request failed with status code ${data.statusCode}: ${URL}`);
366
- }
367
- if (!data.body) {
368
- return reject(new ClientError('Transak did not return order details'));
369
- }
370
- return resolve(data.body);
371
- }
672
+ try {
673
+ const data = await this.withPartnerToken(keys, userIp, accessToken =>
674
+ this.sendRequest(
675
+ 'get',
676
+ URL,
677
+ { headers: { ...headers, 'access-token': accessToken } },
678
+ 'Failed to get Transak order details'
679
+ )
680
+ );
681
+ const success = data.statusCode >= 200 && data.statusCode < 300;
682
+ if (!success) {
683
+ logger.warn(`Transak request failed with status code ${data.statusCode}: ${URL}${transakErrorDetail(data)}`);
372
684
  }
373
- );
685
+ if (!data.body) {
686
+ return reject(new ClientError('Transak did not return order details'));
687
+ }
688
+ // An order ID alone isn't proof of ownership: only return an order
689
+ // whose destination address belongs to the requesting wallet.
690
+ if (success && !(await this.isWalletAddress(walletId, data.body?.data?.walletAddress))) {
691
+ logger.warn(`Transak order ${orderId} is not for an address of wallet ${walletId}`);
692
+ return reject(new ClientError('Transak order not found'));
693
+ }
694
+ return resolve(data.body);
695
+ } catch (err) {
696
+ return reject(err);
697
+ }
374
698
  });
375
699
  }
376
700
  }
@@ -48,6 +48,10 @@ const PROVIDERS = {
48
48
  livenet: 'https://api-eth.bitcore.io',
49
49
  sepolia: 'https://api-eth.bitcore.io'
50
50
  },
51
+ arc: {
52
+ livenet: 'https://api-eth.bitcore.io',
53
+ testnet: 'https://api-eth.bitcore.io'
54
+ },
51
55
  sol: {
52
56
  livenet: 'https://api-sol.bitcore.io',
53
57
  testnet: 'https://api-sol.bitcore.io',
@@ -29,6 +29,7 @@ const Bitcore_ = {
29
29
  arb: Bitcore,
30
30
  op: Bitcore,
31
31
  base: Bitcore,
32
+ arc: Bitcore,
32
33
  sol: Bitcore
33
34
  };
34
35
 
@@ -75,6 +75,7 @@ export class BlockchainMonitor {
75
75
  doge: {},
76
76
  ltc: {},
77
77
  arb: {},
78
+ arc: {},
78
79
  base: {},
79
80
  op: {},
80
81
  sol: {},