@tezos-x/octez.connect-dapp 4.8.3 → 4.8.5

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 (59) hide show
  1. package/README.md +55 -7
  2. package/dist/cjs/beacon-message-events.js.map +1 -1
  3. package/dist/cjs/dapp-client/DAppClient.d.ts +114 -6
  4. package/dist/cjs/dapp-client/DAppClient.js +986 -392
  5. package/dist/cjs/dapp-client/DAppClient.js.map +1 -1
  6. package/dist/cjs/dapp-client/DAppClientOptions.d.ts +17 -0
  7. package/dist/cjs/dapp-client/DAppClientOptions.js.map +1 -1
  8. package/dist/cjs/events.d.ts +29 -3
  9. package/dist/cjs/events.js +41 -37
  10. package/dist/cjs/events.js.map +1 -1
  11. package/dist/cjs/index.d.ts +4 -2
  12. package/dist/cjs/index.js.map +1 -1
  13. package/dist/cjs/transports/DappP2PTransport.d.ts +1 -1
  14. package/dist/cjs/transports/DappP2PTransport.js.map +1 -1
  15. package/dist/cjs/transports/DappPostMessageTransport.d.ts +1 -1
  16. package/dist/cjs/transports/DappPostMessageTransport.js.map +1 -1
  17. package/dist/cjs/transports/DappWalletConnectTransport.d.ts +1 -1
  18. package/dist/cjs/transports/DappWalletConnectTransport.js.map +1 -1
  19. package/dist/cjs/utils/available-transports.js.map +1 -1
  20. package/dist/cjs/utils/block-explorer.d.ts +2 -2
  21. package/dist/cjs/utils/block-explorer.js +5 -1
  22. package/dist/cjs/utils/block-explorer.js.map +1 -1
  23. package/dist/cjs/utils/get-instance.js +7 -1
  24. package/dist/cjs/utils/get-instance.js.map +1 -1
  25. package/dist/cjs/utils/shorten-string.js.map +1 -1
  26. package/dist/cjs/utils/tzkt-blockexplorer.d.ts +2 -2
  27. package/dist/cjs/utils/tzkt-blockexplorer.js +3 -17
  28. package/dist/cjs/utils/tzkt-blockexplorer.js.map +1 -1
  29. package/dist/esm/beacon-message-events.js.map +1 -1
  30. package/dist/esm/dapp-client/DAppClient.d.ts +114 -6
  31. package/dist/esm/dapp-client/DAppClient.js +991 -386
  32. package/dist/esm/dapp-client/DAppClient.js.map +1 -1
  33. package/dist/esm/dapp-client/DAppClientOptions.d.ts +17 -0
  34. package/dist/esm/dapp-client/DAppClientOptions.js.map +1 -1
  35. package/dist/esm/events.d.ts +29 -3
  36. package/dist/esm/events.js +6 -3
  37. package/dist/esm/events.js.map +1 -1
  38. package/dist/esm/index.d.ts +4 -2
  39. package/dist/esm/index.js.map +1 -1
  40. package/dist/esm/transports/DappP2PTransport.d.ts +1 -1
  41. package/dist/esm/transports/DappP2PTransport.js.map +1 -1
  42. package/dist/esm/transports/DappPostMessageTransport.d.ts +1 -1
  43. package/dist/esm/transports/DappPostMessageTransport.js.map +1 -1
  44. package/dist/esm/transports/DappWalletConnectTransport.d.ts +1 -1
  45. package/dist/esm/transports/DappWalletConnectTransport.js.map +1 -1
  46. package/dist/esm/utils/available-transports.js.map +1 -1
  47. package/dist/esm/utils/block-explorer.d.ts +2 -2
  48. package/dist/esm/utils/block-explorer.js +5 -1
  49. package/dist/esm/utils/block-explorer.js.map +1 -1
  50. package/dist/esm/utils/get-instance.js +7 -1
  51. package/dist/esm/utils/get-instance.js.map +1 -1
  52. package/dist/esm/utils/shorten-string.js.map +1 -1
  53. package/dist/esm/utils/tzkt-blockexplorer.d.ts +2 -2
  54. package/dist/esm/utils/tzkt-blockexplorer.js +3 -17
  55. package/dist/esm/utils/tzkt-blockexplorer.js.map +1 -1
  56. package/dist/octez.connect.dapp.min.js +1 -1066
  57. package/package.json +17 -13
  58. package/LICENCE +0 -19
  59. package/dist/walletbeacon.dapp.min.js +0 -1066
@@ -1,6 +1,4 @@
1
- import axios from 'axios';
2
1
  import bs58check from 'bs58check';
3
- import { BeaconEvent } from '../events';
4
2
  import { TransportType, StorageKey, BeaconMessageType, PermissionScope, NetworkType, Origin, BeaconErrorType, SigningType, ColorMode, TransportStatus
5
3
  // PermissionRequestV3
6
4
  // RequestEncryptPayloadInput,
@@ -8,11 +6,11 @@ import { TransportType, StorageKey, BeaconMessageType, PermissionScope, NetworkT
8
6
  // EncryptPayloadResponse,
9
7
  // EncryptPayloadRequest
10
8
  } from '@tezos-x/octez.connect-types';
11
- import { Client, AppMetadataManager, Serializer, LocalStorage, getAccountIdentifier, getSenderId, Logger, StorageValidator, SDK_VERSION, IndexedDBStorage, MultiTabChannel, BACKEND_URL, getError } from '@tezos-x/octez.connect-core';
9
+ import { Client, AppMetadataManager, Serializer, LocalStorage, getAccountIdentifier, getSenderId, Logger, StorageValidator, SDK_VERSION, IndexedDBStorage, MultiTabChannel, getError } from '@tezos-x/octez.connect-core';
12
10
  import { getAddressFromPublicKey, ExposedPromise, generateGUID, toHex, signMessage, CONTRACT_PREFIX, prefixPublicKey, isValidAddress, getKeypairFromSeed } from '@tezos-x/octez.connect-utils';
13
11
  import { messageEvents } from '../beacon-message-events';
12
+ import { BeaconEvent, BeaconEventHandler } from '../events';
14
13
  import { TzktBlockExplorer } from '../utils/tzkt-blockexplorer';
15
- import { BeaconEventHandler } from '@tezos-x/octez.connect-dapp';
16
14
  import { DappPostMessageTransport } from '../transports/DappPostMessageTransport';
17
15
  import { DappP2PTransport } from '../transports/DappP2PTransport';
18
16
  import { DappWalletConnectTransport } from '../transports/DappWalletConnectTransport';
@@ -20,6 +18,23 @@ import { PostMessageTransport } from '@tezos-x/octez.connect-transport-postmessa
20
18
  import { closeToast, getColorMode, setColorMode, setDesktopList, setExtensionList, setWebList, setiOSList, getiOSList, getDesktopList, getExtensionList, getWebList, isBrowser, isDesktop, isMobileOS, isIOS, currentOS } from '@tezos-x/octez.connect-ui';
21
19
  import { WalletConnectTransport } from '@tezos-x/octez.connect-transport-walletconnect';
22
20
  const logger = new Logger('DAppClient');
21
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
22
+ const SESSION_UPDATE_REQUEST_ID = 'session_update';
23
+ const createLazyPromise = (factory) => {
24
+ let promise;
25
+ const getPromise = () => {
26
+ if (!promise) {
27
+ promise = factory();
28
+ }
29
+ return promise;
30
+ };
31
+ const lazyPromise = {
32
+ then: (onfulfilled, onrejected) => getPromise().then(onfulfilled, onrejected),
33
+ catch: (onrejected) => getPromise().catch(onrejected),
34
+ finally: (onfinally) => getPromise().finally(onfinally ?? undefined)
35
+ };
36
+ return lazyPromise;
37
+ };
23
38
  /**
24
39
  * @publicapi
25
40
  *
@@ -53,12 +68,20 @@ export class DAppClient extends Client {
53
68
  walletConnectTransport;
54
69
  wcProjectId;
55
70
  wcRelayUrl;
71
+ /**
72
+ * When true, the WalletConnect transport is not built or listened to. See
73
+ * DAppClientOptions.disableWalletConnect.
74
+ */
75
+ disableWalletConnect;
56
76
  isGetActiveAccountHandled = false;
57
77
  openRequestsOtherTabs = new Set();
58
78
  /**
59
79
  * A map of requests that are currently "open", meaning we have sent them to a wallet and are still awaiting a response.
60
80
  */
61
81
  openRequests = new Map();
82
+ acknowledgedRequests = new Set();
83
+ openRequestTimeouts = new Map();
84
+ requestTimeoutMs;
62
85
  /**
63
86
  * The currently active account. For all requests that are associated to a specific request (operation request, signing request),
64
87
  * the active account is used to determine the network and destination wallet
@@ -69,8 +92,22 @@ export class DAppClient extends Client {
69
92
  */
70
93
  _activePeer = new ExposedPromise();
71
94
  _initPromise;
72
- isInitPending = false;
95
+ /**
96
+ * Rejector for the in-flight {@link _initPromise}. Populated while init is
97
+ * awaiting a peer pairing; cleared automatically when {@link _initPromise}
98
+ * settles. Abort paths (modal close, WC session-proposal rejection,
99
+ * transport-level connection failure) call this to unwedge any
100
+ * `await this.init()` caller, surfacing the error to handleRequestError
101
+ * instead of hanging.
102
+ */
103
+ _initReject;
104
+ _initSubstratePairing;
105
+ _requestPermissionsPromise;
106
+ _requestPermissionsKey;
107
+ _sdkSecretSeedPromise;
108
+ destroyed = false;
73
109
  activeAccountLoaded;
110
+ storageValidated;
74
111
  appMetadataManager;
75
112
  disclaimerText;
76
113
  errorMessages;
@@ -78,6 +115,11 @@ export class DAppClient extends Client {
78
115
  storageValidator;
79
116
  beaconIDB = new IndexedDBStorage('beacon', ['bug_report', 'metrics']);
80
117
  debounceSetActiveAccount = false;
118
+ // Constructor-time invalid storage recovery can race through multiple async paths.
119
+ // Keep this guard scoped to those startup paths.
120
+ hasEmittedInvalidAccountDeactivated = false;
121
+ _disconnectPromise;
122
+ _disconnectNotifyPeers = false;
81
123
  multiTabChannel = new MultiTabChannel('octez.connect-sdk-channel', this.onBCMessageHandler.bind(this), this.onElectedLeaderhandler.bind(this));
82
124
  constructor(config) {
83
125
  super({
@@ -85,8 +127,11 @@ export class DAppClient extends Client {
85
127
  ...config
86
128
  });
87
129
  this.description = config.description;
88
- this.wcProjectId = config.walletConnectOptions?.projectId || '24469fd0a06df227b6e5f7dc7de0ff4f';
89
- this.wcRelayUrl = config.walletConnectOptions?.relayUrl;
130
+ this.disableWalletConnect = config.disableWalletConnect ?? false;
131
+ if (!this.disableWalletConnect) {
132
+ this.wcProjectId = config.walletConnectOptions?.projectId || '24469fd0a06df227b6e5f7dc7de0ff4f';
133
+ this.wcRelayUrl = config.walletConnectOptions?.relayUrl;
134
+ }
90
135
  this.featuredWallets = config.featuredWallets;
91
136
  this.events = new BeaconEventHandler(config.eventHandlers, config.disableDefaultEvents ?? false);
92
137
  this.blockExplorer = config.blockExplorer ?? new TzktBlockExplorer();
@@ -99,24 +144,32 @@ export class DAppClient extends Client {
99
144
  this.enableAppSwitching =
100
145
  config.enableAppSwitching === undefined ? true : !!config.enableAppSwitching;
101
146
  this.enableMetrics = config.enableMetrics ? true : false;
147
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
102
148
  // Subscribe to storage changes and update the active account if it changes on other tabs
103
149
  this.storage.subscribeToStorageChanged(async (event) => {
150
+ if (event.oldValue === event.newValue) {
151
+ return;
152
+ }
104
153
  if (event.eventType === 'storageCleared') {
105
- this.setActiveAccount(undefined);
154
+ await this.setActiveAccount(undefined);
106
155
  return;
107
156
  }
108
157
  if (event.eventType === 'entryModified') {
109
158
  if (event.key === this.storage.getPrefixedKey(StorageKey.ACTIVE_ACCOUNT)) {
110
159
  const accountIdentifier = event.newValue;
111
160
  if (!accountIdentifier || accountIdentifier === 'undefined') {
112
- this.setActiveAccount(undefined);
161
+ await this.setActiveAccount(undefined);
113
162
  }
114
163
  else {
115
164
  const account = await this.getAccount(accountIdentifier);
116
- this.setActiveAccount(account);
165
+ await this.setActiveAccount(account);
117
166
  }
118
167
  return;
119
168
  }
169
+ if (event.key === this.storage.getPrefixedKey(StorageKey.ACCOUNTS)) {
170
+ await this.recoverActiveAccountFromAccountsChange();
171
+ return;
172
+ }
120
173
  if (event.key === this.storage.getPrefixedKey(StorageKey.ENABLE_METRICS)) {
121
174
  this.enableMetrics = !!(await this.storage.get(StorageKey.ENABLE_METRICS));
122
175
  return;
@@ -132,30 +185,42 @@ export class DAppClient extends Client {
132
185
  this.activeAccountLoaded = this.storage
133
186
  .get(StorageKey.ACTIVE_ACCOUNT)
134
187
  .then(async (activeAccountIdentifier) => {
188
+ const accounts = await this.accountManager.getAccounts();
189
+ if (activeAccountIdentifier === 'undefined') {
190
+ // Non-normalizing storage backends can still surface legacy sentinel strings directly.
191
+ await this.deactivateInvalidAccountState('missing_active_account');
192
+ return undefined;
193
+ }
135
194
  if (activeAccountIdentifier) {
136
- const account = await this.accountManager.getAccount(activeAccountIdentifier);
195
+ const account = accounts.find((storedAccount) => storedAccount.accountIdentifier === activeAccountIdentifier);
196
+ if (!account) {
197
+ await this.deactivateInvalidAccountState('missing_active_account');
198
+ return undefined;
199
+ }
137
200
  await this.setActiveAccount(account);
138
201
  return account;
139
202
  }
140
- else {
141
- await this.setActiveAccount(undefined);
203
+ if (accounts.length > 0 && this.hasStoredActiveAccountPointer()) {
204
+ await this.deactivateInvalidAccountState('missing_active_account');
142
205
  return undefined;
143
206
  }
207
+ await this.setActiveAccount(undefined);
208
+ return undefined;
144
209
  })
145
210
  .catch(async (storageError) => {
146
211
  logger.error(storageError);
147
- await this.resetInvalidState(false);
148
- this.events.emit(BeaconEvent.INVALID_ACCOUNT_DEACTIVATED);
212
+ await this.deactivateInvalidAccountState('invalid_active_account_storage');
149
213
  return undefined;
150
214
  });
151
215
  this.handleResponse = async (message, connectionInfo) => {
152
- const typedMessage = message.version === '3'
216
+ const isV3WrappedMessage = message.version === '3' && 'message' in message;
217
+ const typedMessage = isV3WrappedMessage
153
218
  ? message.message
154
219
  : message;
155
- let appMetadata = message.version === '3'
220
+ let appMetadata = isV3WrappedMessage
156
221
  ? typedMessage.blockchainData?.appMetadata
157
222
  : typedMessage.appMetadata;
158
- if (!appMetadata && message.version === '3') {
223
+ if (!appMetadata && isV3WrappedMessage) {
159
224
  const storedMetadata = await Promise.all([
160
225
  this.storage.get(StorageKey.TRANSPORT_P2P_PEERS_DAPP),
161
226
  this.storage.get(StorageKey.TRANSPORT_WALLETCONNECT_PEERS_DAPP),
@@ -184,8 +249,11 @@ export class DAppClient extends Client {
184
249
  },
185
250
  id: message.id
186
251
  });
187
- if (typedMessage.type !== BeaconMessageType.Acknowledge) {
188
- this.openRequestsOtherTabs.delete(message.id);
252
+ if (typedMessage.type === BeaconMessageType.Acknowledge) {
253
+ this.clearOpenRequestTimeout(message.id);
254
+ }
255
+ else {
256
+ this.clearOpenRequest(message.id);
189
257
  }
190
258
  return;
191
259
  }
@@ -212,15 +280,19 @@ export class DAppClient extends Client {
212
280
  await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
213
281
  };
214
282
  if (openRequest && typedMessage.type === BeaconMessageType.Acknowledge) {
215
- this.analytics.track('event', 'DAppClient', 'Acknowledge received from Wallet');
216
- logger.log('handleResponse', `acknowledge message received for ${message.id}`);
217
- this.events
218
- .emit(BeaconEvent.ACKNOWLEDGE_RECEIVED, {
219
- message: typedMessage,
220
- extraInfo: {},
221
- walletInfo: await this.getWalletInfo()
222
- })
223
- .catch(console.error);
283
+ if (!this.acknowledgedRequests.has(message.id)) {
284
+ this.acknowledgedRequests.add(message.id);
285
+ this.clearOpenRequestTimeout(message.id);
286
+ this.analytics.track('event', 'DAppClient', 'Acknowledge received from Wallet');
287
+ logger.log('handleResponse', `acknowledge message received for ${message.id}`);
288
+ this.events
289
+ .emit(BeaconEvent.ACKNOWLEDGE_RECEIVED, {
290
+ message: typedMessage,
291
+ extraInfo: {},
292
+ walletInfo: await this.getWalletInfo()
293
+ })
294
+ .catch((emitError) => logger.error('handleResponse', emitError));
295
+ }
224
296
  }
225
297
  else if (openRequest) {
226
298
  if (typedMessage.type === BeaconMessageType.PermissionResponse && appMetadata) {
@@ -232,7 +304,8 @@ export class DAppClient extends Client {
232
304
  else {
233
305
  openRequest.resolve({ message, connectionInfo });
234
306
  }
235
- this.openRequests.delete(typedMessage.id);
307
+ this.clearOpenRequest(message.id);
308
+ this.acknowledgedRequests.delete(message.id);
236
309
  }
237
310
  else {
238
311
  if (typedMessage.type === BeaconMessageType.Disconnect) {
@@ -241,16 +314,23 @@ export class DAppClient extends Client {
241
314
  else if (typedMessage.type === BeaconMessageType.ChangeAccountRequest) {
242
315
  await this.onNewAccount(typedMessage, connectionInfo);
243
316
  }
317
+ else {
318
+ logger.warn('handleResponse', 'received response for unknown or closed request', {
319
+ id: message.id,
320
+ type: typedMessage.type,
321
+ connectionInfo
322
+ });
323
+ }
244
324
  }
245
325
  if (this._transport.isResolved()) {
246
326
  const transport = await this.transport;
247
327
  if (transport instanceof WalletConnectTransport &&
248
- !this.openRequests.has('session_update')) {
249
- this.openRequests.set('session_update', new ExposedPromise());
328
+ !this.openRequests.has(SESSION_UPDATE_REQUEST_ID)) {
329
+ this.openRequests.set(SESSION_UPDATE_REQUEST_ID, new ExposedPromise());
250
330
  }
251
331
  }
252
332
  };
253
- this.storageValidator
333
+ this.storageValidated = this.storageValidator
254
334
  .validate()
255
335
  .then(async (isValid) => {
256
336
  const account = await this.activeAccountLoaded;
@@ -266,15 +346,22 @@ export class DAppClient extends Client {
266
346
  url: info.deeplink
267
347
  });
268
348
  const nowValid = await this.storageValidator.validate();
269
- if (!nowValid) {
270
- this.resetInvalidState(false);
349
+ if (!nowValid && account) {
350
+ await this.deactivateInvalidAccountState('storage_validation_failed');
351
+ }
352
+ else if (!nowValid) {
353
+ await this.resetInvalidState(false);
271
354
  }
272
355
  }
273
356
  if (account && account.origin.type !== 'p2p') {
274
- this.init();
357
+ // Fire-and-forget eager init for stored sessions. init() can now
358
+ // reject (e.g. when an abort path settles _initPromise), so swallow
359
+ // here; downstream callers of init() handle rejection on their own.
360
+ this.init().catch((initError) => logger.error('eager init failed', initError?.message ?? initError));
275
361
  }
276
362
  })
277
363
  .catch((err) => logger.error(err.message));
364
+ this.retainStorageValidationPromise();
278
365
  this.sendMetrics('enable-metrics?' + this.addQueryParam('version', SDK_VERSION), undefined, (res) => {
279
366
  if (!res.ok) {
280
367
  res.status === 426
@@ -345,9 +432,43 @@ export class DAppClient extends Client {
345
432
  const transport = (await this._transport.promise);
346
433
  await transport.waitForResolution();
347
434
  this.openRequestsOtherTabs.add(message.id);
348
- isV3
435
+ const requestPromise = isV3
349
436
  ? this.makeRequestV3(message.data, message.id)
350
437
  : this.makeRequest(message.data, false, message.id);
438
+ requestPromise.catch((requestError) => {
439
+ const errorResponse = this.buildDelegatedRequestError(message.id, requestError);
440
+ this.multiTabChannel.postMessage({
441
+ type: 'RESPONSE',
442
+ data: {
443
+ message: errorResponse,
444
+ connectionInfo: {
445
+ origin: Origin.WALLETCONNECT,
446
+ id: ''
447
+ }
448
+ },
449
+ id: message.id
450
+ });
451
+ this.clearOpenRequest(message.id);
452
+ });
453
+ }
454
+ buildDelegatedRequestError(id, error) {
455
+ if (typeof error === 'object' &&
456
+ error !== null &&
457
+ 'type' in error &&
458
+ 'errorType' in error &&
459
+ error.type === BeaconMessageType.Error) {
460
+ return {
461
+ ...error,
462
+ id
463
+ };
464
+ }
465
+ return {
466
+ id,
467
+ type: BeaconMessageType.Error,
468
+ errorType: BeaconErrorType.UNKNOWN_ERROR,
469
+ senderId: '',
470
+ version: '2'
471
+ };
351
472
  }
352
473
  async createStateSnapshot() {
353
474
  if (!localStorage || !this.enableMetrics) {
@@ -373,10 +494,7 @@ export class DAppClient extends Client {
373
494
  this.storage.set(StorageKey.USER_ID, this.userId);
374
495
  }
375
496
  async initInternalTransports() {
376
- const seed = await this.storage.get(StorageKey.BEACON_SDK_SECRET_SEED);
377
- if (!seed) {
378
- throw new Error('Secret seed not found');
379
- }
497
+ const seed = await this.getOrCreateSDKSecretSeed();
380
498
  const keyPair = await getKeypairFromSeed(seed);
381
499
  if (this.postMessageTransport || this.p2pTransport || this.walletConnectTransport) {
382
500
  return;
@@ -385,6 +503,11 @@ export class DAppClient extends Client {
385
503
  await this.addListener(this.postMessageTransport);
386
504
  this.p2pTransport = new DappP2PTransport(this.name, keyPair, this.storage, this.matrixNodes, this.iconUrl, this.appUrl);
387
505
  await this.addListener(this.p2pTransport);
506
+ // Skip WalletConnect entirely when disabled (e.g. inside a Firefox MV3 content-script
507
+ // compartment where the WC provider cannot run). postMessage + P2P are unaffected.
508
+ if (this.disableWalletConnect) {
509
+ return;
510
+ }
388
511
  const wcOptions = {
389
512
  projectId: this.wcProjectId,
390
513
  relayUrl: this.wcRelayUrl,
@@ -402,6 +525,37 @@ export class DAppClient extends Client {
402
525
  this.initEvents();
403
526
  await this.addListener(this.walletConnectTransport);
404
527
  }
528
+ async getOrCreateSDKSecretSeed() {
529
+ if (this._sdkSecretSeedPromise) {
530
+ return this._sdkSecretSeedPromise;
531
+ }
532
+ this._sdkSecretSeedPromise = this.loadOrCreateSDKSecretSeed()
533
+ .finally(() => {
534
+ this._sdkSecretSeedPromise = undefined;
535
+ })
536
+ .catch((error) => {
537
+ throw error;
538
+ });
539
+ return this._sdkSecretSeedPromise;
540
+ }
541
+ async loadOrCreateSDKSecretSeed() {
542
+ const existingSeed = await this.storage.get(StorageKey.BEACON_SDK_SECRET_SEED);
543
+ if (this.isValidSDKSecretSeed(existingSeed)) {
544
+ return existingSeed;
545
+ }
546
+ await this.storage.delete(StorageKey.BEACON_SDK_SECRET_SEED);
547
+ this._keyPair = new ExposedPromise();
548
+ this._beaconId = new ExposedPromise();
549
+ await this.initSDK();
550
+ const restoredSeed = await this.storage.get(StorageKey.BEACON_SDK_SECRET_SEED);
551
+ if (this.isValidSDKSecretSeed(restoredSeed)) {
552
+ return restoredSeed;
553
+ }
554
+ throw new Error('Secret seed not found');
555
+ }
556
+ isValidSDKSecretSeed(seed) {
557
+ return typeof seed === 'string' && seed !== '' && seed !== 'undefined' && seed !== 'null';
558
+ }
405
559
  initEvents() {
406
560
  if (!this.walletConnectTransport) {
407
561
  return;
@@ -433,20 +587,26 @@ export class DAppClient extends Client {
433
587
  });
434
588
  }
435
589
  else {
436
- this.events.emit(BeaconEvent.PERMISSION_REQUEST_ERROR, {
437
- errorResponse: { errorType: BeaconErrorType.ABORTED_ERROR },
438
- walletInfo
439
- });
590
+ // The WC transport reports session-proposal rejection through this event
591
+ // when there are no active listeners yet (no peer paired). Reject the
592
+ // in-flight init promise so `await dapp.requestPermissions()` unwinds
593
+ // through requestPermissions' catch -> handleRequestError, which both
594
+ // emits PERMISSION_REQUEST_ERROR and resets transport state. Without
595
+ // this, the dapp wedges with a pending `_initPromise`.
596
+ this._initReject?.(this.createAbortedError());
440
597
  }
441
598
  }
442
599
  async channelClosedHandler(type) {
600
+ if (!this._transport.isResolved()) {
601
+ await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
602
+ return;
603
+ }
443
604
  const transport = await this.transport;
444
605
  if (transport.type !== type) {
445
606
  return;
446
607
  }
447
608
  await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
448
- this.setActiveAccount(undefined);
449
- await this.disconnect();
609
+ await this.disconnect({ notifyPeers: false });
450
610
  }
451
611
  /**
452
612
  * Destroy the instance.
@@ -456,11 +616,73 @@ export class DAppClient extends Client {
456
616
  * If you wish to disconnect your dApp, use `disconnect` instead.
457
617
  */
458
618
  async destroy() {
619
+ this.destroyed = true;
459
620
  await this.createStateSnapshot();
621
+ await this.destroyInternalTransports();
460
622
  await super.destroy();
461
623
  }
624
+ async destroyInternalTransports() {
625
+ this.abortPendingInit();
626
+ const transports = [];
627
+ if (this.postMessageTransport) {
628
+ transports.push(this.postMessageTransport);
629
+ }
630
+ if (this.p2pTransport) {
631
+ transports.push(this.p2pTransport);
632
+ }
633
+ if (this.walletConnectTransport) {
634
+ transports.push(this.walletConnectTransport);
635
+ }
636
+ if (transports.length > 0) {
637
+ await this.cleanup(transports);
638
+ await Promise.all(transports.map(async (transport) => {
639
+ await transport.disconnect();
640
+ if ('doClientCleanup' in transport) {
641
+ await transport.doClientCleanup();
642
+ }
643
+ }));
644
+ this.postMessageTransport = this.p2pTransport = this.walletConnectTransport = undefined;
645
+ await this.setTransport(undefined);
646
+ }
647
+ await this.multiTabChannel.close();
648
+ }
649
+ abortPendingInit() {
650
+ this._initReject?.(this.createAbortedError());
651
+ this._initPromise = undefined;
652
+ this._initReject = undefined;
653
+ this._initSubstratePairing = undefined;
654
+ this._requestPermissionsPromise = undefined;
655
+ this._requestPermissionsKey = undefined;
656
+ }
657
+ createAbortedError(id = '') {
658
+ return {
659
+ type: BeaconMessageType.Error,
660
+ id,
661
+ senderId: '',
662
+ version: '2',
663
+ errorType: BeaconErrorType.ABORTED_ERROR
664
+ };
665
+ }
666
+ /**
667
+ * @internal
668
+ */
669
+ isDestroyed() {
670
+ return this.destroyed;
671
+ }
672
+ assertNotDestroyed() {
673
+ if (this.destroyed) {
674
+ throw new Error('DAppClient has been destroyed and cannot be used again.');
675
+ }
676
+ }
462
677
  async init(transport, substratePairing) {
678
+ this.assertNotDestroyed();
679
+ const requestedSubstratePairing = substratePairing === true;
463
680
  if (this._initPromise) {
681
+ if (!transport &&
682
+ this._initReject &&
683
+ this._initSubstratePairing !== requestedSubstratePairing) {
684
+ throw new Error('Cannot start a permission request with different pairing options while another pairing is pending');
685
+ }
464
686
  return this._initPromise;
465
687
  }
466
688
  try {
@@ -469,160 +691,265 @@ export class DAppClient extends Client {
469
691
  catch {
470
692
  //
471
693
  }
472
- this._initPromise = new Promise(async (resolve) => {
473
- if (transport) {
474
- await this.addListener(transport);
475
- resolve(await super.init(transport));
476
- }
477
- else if (this._transport.isSettled()) {
478
- await (await this.transport).connect();
479
- resolve(await super.init(await this.transport));
480
- }
481
- else {
482
- const activeAccount = await this.getActiveAccount();
483
- const stopListening = () => {
484
- if (this.postMessageTransport) {
485
- this.postMessageTransport.stopListeningForNewPeers().catch(console.error);
486
- }
487
- if (this.p2pTransport) {
488
- this.p2pTransport.stopListeningForNewPeers().catch(console.error);
489
- }
490
- if (this.walletConnectTransport) {
491
- this.walletConnectTransport.stopListeningForNewPeers().catch(console.error);
492
- }
493
- };
494
- await this.initInternalTransports();
495
- if (!this.postMessageTransport || !this.p2pTransport || !this.walletConnectTransport) {
496
- return;
694
+ this._initPromise = new Promise(async (resolve, reject) => {
695
+ this._initSubstratePairing = requestedSubstratePairing;
696
+ // Capture reject so abort paths (modal close, transport-level rejection)
697
+ // can unwedge a pending `await this.init()`. _initReject is cleared by
698
+ // the .finally below whether the promise resolves or rejects, so the
699
+ // field accurately means "currently rejectable" rather than "most recent
700
+ // rejector".
701
+ this._initReject = (reason) => {
702
+ this._initPromise = undefined;
703
+ reject(reason);
704
+ };
705
+ // The body uses `new Promise(async ...)` (an anti-pattern: unhandled
706
+ // throws are swallowed). Wrap it so any unexpected throw rejects the
707
+ // promise instead of stranding `await this.init()` callers forever.
708
+ // A proper structural fix would replace the async-executor entirely
709
+ // with chained promises; left as follow-up to keep this PR focused.
710
+ try {
711
+ if (transport) {
712
+ await this.addListener(transport);
713
+ resolve(await super.init(transport));
497
714
  }
498
- this.postMessageTransport.connect().then().catch(console.error);
499
- if (activeAccount && activeAccount.origin) {
500
- const origin = activeAccount.origin.type;
501
- // Select the transport that matches the active account
502
- if (origin === Origin.EXTENSION) {
503
- resolve(await super.init(this.postMessageTransport));
504
- }
505
- else if (origin === Origin.P2P) {
506
- resolve(await super.init(this.p2pTransport));
507
- }
508
- else if (origin === Origin.WALLETCONNECT) {
509
- resolve(await super.init(this.walletConnectTransport));
510
- }
715
+ else if (this._transport.isSettled()) {
716
+ await (await this.transport).connect();
717
+ resolve(await super.init(await this.transport));
511
718
  }
512
719
  else {
513
- const p2pTransport = this.p2pTransport;
514
- const postMessageTransport = this.postMessageTransport;
515
- const walletConnectTransport = this.walletConnectTransport;
516
- postMessageTransport
517
- .listenForNewPeer((peer) => {
518
- logger.log('init', 'postmessage transport peer connected', peer);
519
- this.analytics.track('event', 'DAppClient', 'Extension connected', {
520
- peerName: peer.name
720
+ const activeAccount = await this.getActiveAccount();
721
+ const stopListening = () => {
722
+ const onError = (err) => logger.error('stopListeningForNewPeers', err);
723
+ if (this.postMessageTransport) {
724
+ this.postMessageTransport.stopListeningForNewPeers().catch(onError);
725
+ }
726
+ if (this.p2pTransport) {
727
+ this.p2pTransport.stopListeningForNewPeers().catch(onError);
728
+ }
729
+ if (this.walletConnectTransport) {
730
+ this.walletConnectTransport.stopListeningForNewPeers().catch(onError);
731
+ }
732
+ };
733
+ await this.initInternalTransports();
734
+ if (!this.postMessageTransport ||
735
+ !this.p2pTransport ||
736
+ (!this.disableWalletConnect && !this.walletConnectTransport)) {
737
+ // Bare return here used to strand the promise forever -- no UI is
738
+ // emitted, no peer ever pairs, no abort path fires. Reject explicitly
739
+ // so callers see a deterministic error. (WalletConnect is exempt when
740
+ // disableWalletConnect is set -- its transport is intentionally absent.)
741
+ const noTransportError = {
742
+ type: BeaconMessageType.Error,
743
+ id: '',
744
+ senderId: '',
745
+ version: '2',
746
+ errorType: BeaconErrorType.UNKNOWN_ERROR
747
+ };
748
+ reject(noTransportError);
749
+ return;
750
+ }
751
+ this.postMessageTransport.connect().then().catch(console.error);
752
+ if (activeAccount && activeAccount.origin) {
753
+ const origin = activeAccount.origin.type;
754
+ // Select the transport that matches the active account
755
+ if (origin === Origin.EXTENSION) {
756
+ resolve(await super.init(this.postMessageTransport));
757
+ }
758
+ else if (origin === Origin.P2P) {
759
+ resolve(await super.init(this.p2pTransport));
760
+ }
761
+ else if (origin === Origin.WALLETCONNECT && this.walletConnectTransport) {
762
+ resolve(await super.init(this.walletConnectTransport));
763
+ }
764
+ }
765
+ else {
766
+ const p2pTransport = this.p2pTransport;
767
+ const postMessageTransport = this.postMessageTransport;
768
+ const walletConnectTransport = this.walletConnectTransport;
769
+ postMessageTransport
770
+ .listenForNewPeer((peer) => {
771
+ logger.log('init', 'postmessage transport peer connected', peer);
772
+ this.analytics.track('event', 'DAppClient', 'Extension connected', {
773
+ peerName: peer.name
774
+ });
775
+ this.events
776
+ .emit(BeaconEvent.PAIR_SUCCESS, peer)
777
+ .catch((emitError) => console.warn(emitError));
778
+ this.setActivePeer(peer).catch(console.error);
779
+ this.setTransport(this.postMessageTransport).catch(console.error);
780
+ stopListening();
781
+ resolve(TransportType.POST_MESSAGE);
782
+ })
783
+ .catch(console.error);
784
+ p2pTransport
785
+ .listenForNewPeer((peer) => {
786
+ logger.log('init', 'p2p transport peer connected', peer);
787
+ this.analytics.track('event', 'DAppClient', 'octez.connect Wallet connected', {
788
+ peerName: peer.name
789
+ });
790
+ this.events
791
+ .emit(BeaconEvent.PAIR_SUCCESS, peer)
792
+ .catch((emitError) => console.warn(emitError));
793
+ this.setActivePeer(peer).catch(console.error);
794
+ this.setTransport(this.p2pTransport).catch(console.error);
795
+ stopListening();
796
+ resolve(TransportType.P2P);
797
+ })
798
+ .catch(console.error);
799
+ walletConnectTransport
800
+ ?.listenForNewPeer((peer) => {
801
+ logger.log('init', 'walletconnect transport peer connected', peer);
802
+ this.analytics.track('event', 'DAppClient', 'WalletConnect Wallet connected', {
803
+ peerName: peer.name
804
+ });
805
+ this.events
806
+ .emit(BeaconEvent.PAIR_SUCCESS, peer)
807
+ .catch((emitError) => console.warn(emitError));
808
+ this.setActivePeer(peer).catch(console.error);
809
+ this.setTransport(this.walletConnectTransport).catch(console.error);
810
+ stopListening();
811
+ resolve(TransportType.WALLETCONNECT);
812
+ })
813
+ .catch(console.error);
814
+ PostMessageTransport.getAvailableExtensions()
815
+ .then(async (extensions) => {
816
+ this.analytics.track('event', 'DAppClient', 'Extensions detected', { extensions });
817
+ })
818
+ .catch((error) => {
819
+ this._initPromise = undefined;
820
+ console.error(error);
521
821
  });
522
- this.events
523
- .emit(BeaconEvent.PAIR_SUCCESS, peer)
524
- .catch((emitError) => console.warn(emitError));
525
- this.setActivePeer(peer).catch(console.error);
526
- this.setTransport(this.postMessageTransport).catch(console.error);
527
- stopListening();
528
- resolve(TransportType.POST_MESSAGE);
529
- })
530
- .catch(console.error);
531
- p2pTransport
532
- .listenForNewPeer((peer) => {
533
- logger.log('init', 'p2p transport peer connected', peer);
534
- this.analytics.track('event', 'DAppClient', 'octez.connect Wallet connected', {
535
- peerName: peer.name
822
+ const abortHandler = async () => {
823
+ logger.log('init', 'ABORTED');
824
+ this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'abort'));
825
+ await Promise.all([
826
+ postMessageTransport.disconnect(),
827
+ // p2pTransport.disconnect(), do not abort connection manually
828
+ walletConnectTransport?.disconnect()
829
+ ]);
830
+ this.postMessageTransport = this.walletConnectTransport = this.p2pTransport = undefined;
831
+ this._activeAccount.isResolved() && this.clearActiveAccount();
832
+ // Reject _initPromise so any awaiter (makeRequest -> requestPermissions)
833
+ // unwinds via handleRequestError instead of hanging. _initReject
834
+ // also clears _initPromise as a side effect.
835
+ this._initReject?.(this.createAbortedError());
836
+ };
837
+ const serializer = new Serializer();
838
+ const p2pPeerInfo = new Promise(async (resolve) => {
839
+ try {
840
+ await p2pTransport.connect();
841
+ }
842
+ catch (err) {
843
+ logger.error(err);
844
+ await this.hideUI(['alert']); // hide pairing alert
845
+ setTimeout(() => this.events.emit(BeaconEvent.GENERIC_ERROR, err.message), 1000);
846
+ abortHandler().catch((abortErr) => logger.warn('init', 'abortHandler', abortErr));
847
+ resolve('');
848
+ return;
849
+ }
850
+ resolve(await serializer.serialize(await p2pTransport.getPairingRequestInfo()));
536
851
  });
537
- this.events
538
- .emit(BeaconEvent.PAIR_SUCCESS, peer)
539
- .catch((emitError) => console.warn(emitError));
540
- this.setActivePeer(peer).catch(console.error);
541
- this.setTransport(this.p2pTransport).catch(console.error);
542
- stopListening();
543
- resolve(TransportType.P2P);
544
- })
545
- .catch(console.error);
546
- walletConnectTransport
547
- .listenForNewPeer((peer) => {
548
- logger.log('init', 'walletconnect transport peer connected', peer);
549
- this.analytics.track('event', 'DAppClient', 'WalletConnect Wallet connected', {
550
- peerName: peer.name
852
+ const walletConnectPeerInfo = walletConnectTransport
853
+ ? createLazyPromise(() => walletConnectTransport
854
+ .getPairingRequestInfo()
855
+ .then((pairingRequestInfo) => pairingRequestInfo.uri)
856
+ .catch((error) => {
857
+ logger.warn('init', 'walletconnect pairing request failed', error);
858
+ return '';
859
+ }))
860
+ : undefined;
861
+ const postmessagePeerInfo = new Promise(async (resolve) => {
862
+ resolve(await serializer.serialize(await postMessageTransport.getPairingRequestInfo()));
551
863
  });
552
864
  this.events
553
- .emit(BeaconEvent.PAIR_SUCCESS, peer)
865
+ .emit(BeaconEvent.PAIR_INIT, {
866
+ p2pPeerInfo,
867
+ postmessagePeerInfo,
868
+ // Omitted entirely when WalletConnect is disabled, so the pairing UI
869
+ // never tries to read a peer-info promise for a transport that isn't there.
870
+ ...(walletConnectPeerInfo ? { walletConnectPeerInfo } : {}),
871
+ networkType: this.network.type,
872
+ abortedHandler: abortHandler.bind(this),
873
+ disclaimerText: this.disclaimerText,
874
+ analytics: this.analytics,
875
+ featuredWallets: this.featuredWallets,
876
+ substratePairing
877
+ })
554
878
  .catch((emitError) => console.warn(emitError));
555
- this.setActivePeer(peer).catch(console.error);
556
- this.setTransport(this.walletConnectTransport).catch(console.error);
557
- stopListening();
558
- resolve(TransportType.WALLETCONNECT);
559
- })
560
- .catch(console.error);
561
- PostMessageTransport.getAvailableExtensions()
562
- .then(async (extensions) => {
563
- this.analytics.track('event', 'DAppClient', 'Extensions detected', { extensions });
564
- })
565
- .catch((error) => {
566
- this._initPromise = undefined;
567
- console.error(error);
568
- });
569
- const abortHandler = async () => {
570
- logger.log('init', 'ABORTED');
571
- this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'abort'));
572
- await Promise.all([
573
- postMessageTransport.disconnect(),
574
- // p2pTransport.disconnect(), do not abort connection manually
575
- walletConnectTransport.disconnect()
576
- ]);
577
- this.postMessageTransport = this.walletConnectTransport = this.p2pTransport = undefined;
578
- this._activeAccount.isResolved() && this.clearActiveAccount();
579
- this._initPromise = undefined;
580
- };
581
- const serializer = new Serializer();
582
- const p2pPeerInfo = new Promise(async (resolve) => {
583
- try {
584
- await p2pTransport.connect();
585
- }
586
- catch (err) {
587
- logger.error(err);
588
- await this.hideUI(['alert']); // hide pairing alert
589
- setTimeout(() => this.events.emit(BeaconEvent.GENERIC_ERROR, err.message), 1000);
590
- abortHandler();
591
- resolve('');
592
- return;
593
- }
594
- resolve(await serializer.serialize(await p2pTransport.getPairingRequestInfo()));
595
- });
596
- const walletConnectPeerInfo = new Promise(async (resolve) => {
597
- resolve((await walletConnectTransport.getPairingRequestInfo()).uri);
598
- });
599
- const postmessagePeerInfo = new Promise(async (resolve) => {
600
- resolve(await serializer.serialize(await postMessageTransport.getPairingRequestInfo()));
601
- });
602
- this.events
603
- .emit(BeaconEvent.PAIR_INIT, {
604
- p2pPeerInfo,
605
- postmessagePeerInfo,
606
- walletConnectPeerInfo,
607
- networkType: this.network.type,
608
- abortedHandler: abortHandler.bind(this),
609
- disclaimerText: this.disclaimerText,
610
- analytics: this.analytics,
611
- featuredWallets: this.featuredWallets,
612
- substratePairing
613
- })
614
- .catch((emitError) => console.warn(emitError));
879
+ }
615
880
  }
616
881
  }
882
+ catch (err) {
883
+ // An async-executor throw would otherwise be silently swallowed,
884
+ // leaving _initPromise pending and every awaiter stranded.
885
+ reject(err);
886
+ }
617
887
  });
888
+ // Drop the _initReject reference whether init resolved or rejected, so the
889
+ // field is never stale. Using an explicit helper keeps the dangling
890
+ // observer chain out of statement position (which the lint rules forbid).
891
+ this.clearInitRejectOnSettle(this._initPromise);
618
892
  return this._initPromise;
619
893
  }
894
+ /**
895
+ * Attach a settle observer to {@link _initPromise} that drops
896
+ * {@link _initReject} once the promise settles (resolved or rejected).
897
+ * Errors on the observer chain are swallowed; only the original
898
+ * {@link _initPromise} surfaces them to its awaiters.
899
+ */
900
+ clearInitRejectOnSettle(initPromise) {
901
+ initPromise
902
+ .finally(() => {
903
+ this._initReject = undefined;
904
+ this._initSubstratePairing = undefined;
905
+ })
906
+ .catch(() => {
907
+ // observer-only; original promise's rejection is delivered to awaiters
908
+ });
909
+ }
620
910
  /**
621
911
  * Returns the active account
622
912
  */
623
913
  async getActiveAccount() {
914
+ this.assertNotDestroyed();
624
915
  return this._activeAccount.promise;
625
916
  }
917
+ retainStorageValidationPromise() {
918
+ // Constructor validation is intentionally fire-and-forget, but retaining
919
+ // the promise gives tests a deterministic hook without changing public API.
920
+ this.storageValidated.catch((err) => logger.error(err.message));
921
+ }
922
+ async deactivateInvalidAccountState(reason) {
923
+ if (this.hasEmittedInvalidAccountDeactivated) {
924
+ return;
925
+ }
926
+ this.hasEmittedInvalidAccountDeactivated = true;
927
+ logger.log('deactivateInvalidAccountState', reason);
928
+ if (reason === 'missing_active_account') {
929
+ await this.repairMissingActiveAccount();
930
+ }
931
+ else {
932
+ await this.resetInvalidState(false);
933
+ }
934
+ await this.events.emit(BeaconEvent.INVALID_ACCOUNT_DEACTIVATED, { reason });
935
+ }
936
+ async repairMissingActiveAccount() {
937
+ this._activeAccount = ExposedPromise.resolve(undefined);
938
+ await this.storage.delete(StorageKey.ACTIVE_ACCOUNT);
939
+ await this.setActivePeer(undefined);
940
+ }
941
+ hasStoredActiveAccountPointer() {
942
+ // LocalStorage-specific repair: that backend is where literal sentinel strings were produced.
943
+ if (typeof localStorage === 'undefined') {
944
+ return false;
945
+ }
946
+ try {
947
+ return localStorage.getItem(this.storage.getPrefixedKey(StorageKey.ACTIVE_ACCOUNT)) !== null;
948
+ }
949
+ catch {
950
+ return false;
951
+ }
952
+ }
626
953
  async isInvalidState(account) {
627
954
  const activeAccount = await this._activeAccount.promise;
628
955
  return !activeAccount
@@ -630,11 +957,15 @@ export class DAppClient extends Client {
630
957
  : activeAccount?.address !== account?.address && !this.isGetActiveAccountHandled;
631
958
  }
632
959
  async resetInvalidState(emit = true) {
633
- this.accountManager.removeAllAccounts();
960
+ await this.accountManager.removeAllAccounts();
634
961
  this._activeAccount = ExposedPromise.resolve(undefined);
635
- this.storage.set(StorageKey.ACTIVE_ACCOUNT, undefined);
636
- emit && this.events.emit(BeaconEvent.INVALID_ACTIVE_ACCOUNT_STATE);
637
- !emit && this.hideUI(['alert']);
962
+ await this.storage.delete(StorageKey.ACTIVE_ACCOUNT);
963
+ if (emit) {
964
+ await this.events.emit(BeaconEvent.INVALID_ACTIVE_ACCOUNT_STATE);
965
+ }
966
+ else {
967
+ await this.hideUI(['alert']);
968
+ }
638
969
  await Promise.all([
639
970
  this.postMessageTransport?.disconnect(),
640
971
  this.walletConnectTransport?.disconnect()
@@ -650,7 +981,8 @@ export class DAppClient extends Client {
650
981
  * @param account The account that will be set as the active account
651
982
  */
652
983
  async setActiveAccount(account) {
653
- if (!this.isGetActiveAccountHandled) {
984
+ const activeAccountAlreadyCleared = !account && (await this.isActiveAccountAlreadyCleared());
985
+ if (!activeAccountAlreadyCleared && !this.isGetActiveAccountHandled) {
654
986
  console.warn(`An active account has been received, but no active subscription was found for BeaconEvent.ACTIVE_ACCOUNT_SET.`);
655
987
  }
656
988
  if (account && this._activeAccount.isSettled() && (await this.isInvalidState(account))) {
@@ -662,9 +994,9 @@ export class DAppClient extends Client {
662
994
  }
663
995
  // when I'm resetting the activeAccount
664
996
  if (!account && this._activeAccount.isResolved() && (await this.getActiveAccount())) {
665
- const transport = await this.transport;
997
+ const transport = this._transport.isResolved() ? await this.transport : undefined;
666
998
  const activeAccount = await this.getActiveAccount();
667
- if (!transport || !activeAccount) {
999
+ if (!activeAccount) {
668
1000
  return;
669
1001
  }
670
1002
  if (!this.debounceSetActiveAccount && transport instanceof WalletConnectTransport) {
@@ -680,22 +1012,15 @@ export class DAppClient extends Client {
680
1012
  type: 'DISCONNECT'
681
1013
  });
682
1014
  }
683
- Array.from(this.openRequests.entries())
684
- .filter(([id, _promise]) => id !== 'session_update')
685
- .forEach(([id, promise]) => {
686
- promise.reject({
687
- type: BeaconMessageType.Error,
688
- errorType: BeaconErrorType.ABORTED_ERROR,
689
- id,
690
- senderId: '',
691
- version: '2'
692
- });
693
- });
694
- this.openRequests.clear();
1015
+ this.abortOpenRequests();
695
1016
  this.debounceSetActiveAccount = false;
696
1017
  }
697
1018
  }
698
- if (this._activeAccount.isSettled()) {
1019
+ if (activeAccountAlreadyCleared) {
1020
+ // Keep peer and transport cleanup below idempotent, but do not write storage or emit
1021
+ // another ACTIVE_ACCOUNT_SET(undefined) for an already-empty account.
1022
+ }
1023
+ else if (this._activeAccount.isSettled()) {
699
1024
  // If the promise has already been resolved we need to create a new one.
700
1025
  this._activeAccount = ExposedPromise.resolve(account);
701
1026
  }
@@ -736,10 +1061,37 @@ export class DAppClient extends Client {
736
1061
  await this.setActivePeer(undefined);
737
1062
  await this.setTransport(undefined);
738
1063
  }
739
- await this.storage.set(StorageKey.ACTIVE_ACCOUNT, account ? account.accountIdentifier : undefined);
740
- await this.events.emit(BeaconEvent.ACTIVE_ACCOUNT_SET, account);
1064
+ if (account) {
1065
+ await this.storage.set(StorageKey.ACTIVE_ACCOUNT, account.accountIdentifier);
1066
+ }
1067
+ else if (!activeAccountAlreadyCleared) {
1068
+ await this.storage.delete(StorageKey.ACTIVE_ACCOUNT);
1069
+ }
1070
+ if (!activeAccountAlreadyCleared) {
1071
+ await this.events.emit(BeaconEvent.ACTIVE_ACCOUNT_SET, account);
1072
+ }
741
1073
  return;
742
1074
  }
1075
+ async isActiveAccountAlreadyCleared() {
1076
+ return this._activeAccount.isResolved() && !(await this.getActiveAccount());
1077
+ }
1078
+ async recoverActiveAccountFromAccountsChange() {
1079
+ const activeAccountIdentifier = await this.storage.get(StorageKey.ACTIVE_ACCOUNT);
1080
+ if (!activeAccountIdentifier || activeAccountIdentifier === 'undefined') {
1081
+ return;
1082
+ }
1083
+ if (this._activeAccount.isResolved()) {
1084
+ const activeAccount = await this.getActiveAccount();
1085
+ if (activeAccount?.accountIdentifier === activeAccountIdentifier) {
1086
+ return;
1087
+ }
1088
+ }
1089
+ const account = await this.getAccount(activeAccountIdentifier);
1090
+ if (!account) {
1091
+ return;
1092
+ }
1093
+ await this.setActiveAccount(account);
1094
+ }
743
1095
  /**
744
1096
  * Clear the active account
745
1097
  */
@@ -814,30 +1166,16 @@ export class DAppClient extends Client {
814
1166
  })
815
1167
  };
816
1168
  }
817
- async updateMetricsStorage(payload) {
818
- const queue = await this.beaconIDB.getAllKeys('metrics');
819
- if (queue.length >= 1000) {
820
- const key = queue.shift();
821
- this.beaconIDB.delete(key.toString(), 'metrics');
822
- }
823
- this.beaconIDB.set(String(Date.now()), payload, 'metrics');
824
- }
825
- sendMetrics(uri, options, thenHandler, catchHandler) {
826
- if (!this.enableMetrics && uri === 'performance-metrics/save') {
827
- options && this.updateMetricsStorage(options.body);
828
- }
829
- if (!this.enableMetrics) {
830
- return;
831
- }
832
- fetch(`${BACKEND_URL}/${uri}`, options)
833
- .then((res) => thenHandler && thenHandler(res))
834
- .catch((err) => {
835
- console.warn('Network error encountered. Metrics sharing have been automatically disabled.');
836
- logger.error(err.message);
837
- this.enableMetrics = false; // in the event of a network error, stop sending metrics
838
- catchHandler && catchHandler(err);
839
- });
1169
+ // Metrics phone home to a Papers-controlled endpoint that the ECAD fork
1170
+ // does not control or ingest. The disabled path also queued payloads into
1171
+ // IndexedDB before the DB was ready, surfacing as unhandledRejection.
1172
+ // Hard-disabled here; full removal (option, storage key, IDB store,
1173
+ // BACKEND_URL constant, all call sites) tracked as a follow-up.
1174
+ /* eslint-disable @typescript-eslint/no-unused-vars -- hard-disabled stub keeps its signature for call-site type-safety; params are intentionally unused pending full removal. */
1175
+ sendMetrics(_uri, _options, _thenHandler, _catchHandler) {
1176
+ return;
840
1177
  }
1178
+ /* eslint-enable @typescript-eslint/no-unused-vars */
841
1179
  async checkMakeRequest() {
842
1180
  const isResolved = this._transport.isResolved();
843
1181
  const isWCInstance = isResolved && (await this.transport) instanceof WalletConnectTransport;
@@ -882,14 +1220,19 @@ export class DAppClient extends Client {
882
1220
  /**
883
1221
  * Remove all peers and all accounts that have been connected through those peers
884
1222
  */
885
- async removeAllPeers(sendDisconnectToPeers = false) {
886
- const transport = await this.transport;
1223
+ async removeAllPeers(sendDisconnectToPeers = false, options = {}) {
1224
+ const transport = (await this.transport);
887
1225
  const peers = await transport.getPeers();
888
- const removePeerResult = transport.removeAllPeers();
1226
+ const removePeerResult = await transport.removeAllPeers();
889
1227
  await this.removeAccountsForPeers(peers);
890
1228
  if (sendDisconnectToPeers) {
891
1229
  const disconnectPromises = peers.map((peer) => this.sendDisconnectToPeer(peer, transport));
892
- await Promise.all(disconnectPromises);
1230
+ if (options.ignoreDisconnectErrors) {
1231
+ await Promise.allSettled(disconnectPromises);
1232
+ }
1233
+ else {
1234
+ await Promise.all(disconnectPromises);
1235
+ }
893
1236
  }
894
1237
  return removePeerResult;
895
1238
  }
@@ -994,13 +1337,15 @@ export class DAppClient extends Client {
994
1337
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
995
1338
  const logId = `makeRequestV3 ${Date.now()}`;
996
1339
  logger.time(true, logId);
997
- const { message: response, connectionInfo } = await this.makeRequestV3(request).catch(async (requestError) => {
998
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
999
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1000
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1001
- logger.time(false, logId);
1002
- throw await this.handleRequestError(request, requestError);
1003
- });
1340
+ let resolved;
1341
+ try {
1342
+ resolved = await this.makeRequestV3(request);
1343
+ }
1344
+ catch (requestError) {
1345
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1346
+ throw requestError;
1347
+ }
1348
+ const { message: response, connectionInfo } = resolved;
1004
1349
  logger.time(false, logId);
1005
1350
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
1006
1351
  logger.log('RESPONSE V3', response, connectionInfo);
@@ -1064,14 +1409,18 @@ export class DAppClient extends Client {
1064
1409
  const res = (await this.checkMakeRequest())
1065
1410
  ? this.makeRequestV3(request)
1066
1411
  : this.makeRequestBC(request);
1067
- res.catch(async (requestError) => {
1068
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1069
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1070
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1071
- logger.time(false, logId);
1072
- throw await this.handleRequestError(request, requestError);
1073
- });
1074
- const { message: response, connectionInfo } = (await res);
1412
+ let resolved;
1413
+ try {
1414
+ resolved = await res;
1415
+ }
1416
+ catch (requestError) {
1417
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1418
+ throw requestError;
1419
+ }
1420
+ if (!resolved) {
1421
+ throw new Error('Internal error: makeRequest returned no result');
1422
+ }
1423
+ const { message: response, connectionInfo } = resolved;
1075
1424
  logger.time(false, logId);
1076
1425
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1077
1426
  await blockchain.handleResponse({
@@ -1095,16 +1444,34 @@ export class DAppClient extends Client {
1095
1444
  * @param input The message details we need to prepare the PermissionRequest message.
1096
1445
  */
1097
1446
  async requestPermissions(input) {
1098
- if (input?.network) {
1099
- throw new Error('[BEACON] the "network" property is no longer accepted in input. Please provide it when instantiating DAppClient.');
1447
+ this.assertNotDestroyed();
1448
+ const scopes = this.getPermissionRequestScopes(input);
1449
+ const requestPermissionsKey = this.getPermissionRequestKey(scopes);
1450
+ if (this._requestPermissionsPromise) {
1451
+ if (this._requestPermissionsKey !== requestPermissionsKey) {
1452
+ throw new Error('Cannot start a permission request with different scopes while another permission request is pending');
1453
+ }
1454
+ return this._requestPermissionsPromise;
1100
1455
  }
1456
+ const requestPermissionsPromise = this.requestPermissionsInternal(scopes);
1457
+ this._requestPermissionsPromise = requestPermissionsPromise;
1458
+ this._requestPermissionsKey = requestPermissionsKey;
1459
+ try {
1460
+ return await requestPermissionsPromise;
1461
+ }
1462
+ finally {
1463
+ if (this._requestPermissionsPromise === requestPermissionsPromise) {
1464
+ this._requestPermissionsPromise = undefined;
1465
+ this._requestPermissionsKey = undefined;
1466
+ }
1467
+ }
1468
+ }
1469
+ async requestPermissionsInternal(scopes) {
1101
1470
  const request = {
1102
1471
  appMetadata: await this.getOwnAppMetadata(),
1103
1472
  type: BeaconMessageType.PermissionRequest,
1104
1473
  network: this.network,
1105
- scopes: input && input.scopes
1106
- ? input.scopes
1107
- : [PermissionScope.OPERATION_REQUEST, PermissionScope.SIGN]
1474
+ scopes
1108
1475
  };
1109
1476
  this.analytics.track('event', 'DAppClient', 'Permission requested');
1110
1477
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
@@ -1113,14 +1480,18 @@ export class DAppClient extends Client {
1113
1480
  const res = (await this.checkMakeRequest()) || !(await this.getActiveAccount())
1114
1481
  ? this.makeRequest(request, undefined, undefined)
1115
1482
  : this.makeRequestBC(request);
1116
- res.catch(async (requestError) => {
1117
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1118
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1119
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1120
- logger.time(false, logId);
1121
- throw await this.handleRequestError(request, requestError);
1122
- });
1123
- const { message, connectionInfo } = (await res);
1483
+ let resolved;
1484
+ try {
1485
+ resolved = await res;
1486
+ }
1487
+ catch (requestError) {
1488
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1489
+ throw requestError;
1490
+ }
1491
+ if (!resolved) {
1492
+ throw new Error('Internal error: makeRequest returned no result');
1493
+ }
1494
+ const { message, connectionInfo } = resolved;
1124
1495
  logger.time(false, logId);
1125
1496
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'success'));
1126
1497
  logger.log('requestPermissions', '######## MESSAGE #######');
@@ -1147,6 +1518,17 @@ export class DAppClient extends Client {
1147
1518
  });
1148
1519
  return output;
1149
1520
  }
1521
+ getPermissionRequestScopes(input) {
1522
+ if (input && 'network' in input) {
1523
+ throw new Error('[BEACON] the "network" property is no longer accepted in input. Please provide it when instantiating DAppClient.');
1524
+ }
1525
+ return input && input.scopes
1526
+ ? input.scopes
1527
+ : [PermissionScope.OPERATION_REQUEST, PermissionScope.SIGN];
1528
+ }
1529
+ getPermissionRequestKey(scopes) {
1530
+ return [...scopes].sort().join('|');
1531
+ }
1150
1532
  /**
1151
1533
  * Send a proof of event request to the wallet. The wallet will either accept or decline the challenge.
1152
1534
  * If it is accepted, the challenge will be stored, meaning that even if the user refresh the page, the DAppClient will keep checking if the challenge has been fulfilled.
@@ -1173,14 +1555,18 @@ export class DAppClient extends Client {
1173
1555
  const res = (await this.checkMakeRequest())
1174
1556
  ? this.makeRequest(request)
1175
1557
  : this.makeRequestBC(request);
1176
- res.catch(async (requestError) => {
1177
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1178
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1179
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1180
- logger.time(false, logId);
1181
- throw await this.handleRequestError(request, requestError);
1182
- });
1183
- const { message, connectionInfo } = (await res);
1558
+ let resolved;
1559
+ try {
1560
+ resolved = await res;
1561
+ }
1562
+ catch (requestError) {
1563
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1564
+ throw requestError;
1565
+ }
1566
+ if (!resolved) {
1567
+ throw new Error('Internal error: makeRequest returned no result');
1568
+ }
1569
+ const { message, connectionInfo } = resolved;
1184
1570
  logger.time(false, logId);
1185
1571
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1186
1572
  this.analytics.track('event', 'DAppClient', `Proof of event challenge ${message.isAccepted ? 'accepted' : 'refused'}`, { address: activeAccount.address });
@@ -1219,14 +1605,18 @@ export class DAppClient extends Client {
1219
1605
  const res = (await this.checkMakeRequest())
1220
1606
  ? this.makeRequest(request)
1221
1607
  : this.makeRequestBC(request);
1222
- res.catch(async (requestError) => {
1223
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1224
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1225
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1226
- logger.time(false, logId);
1227
- throw await this.handleRequestError(request, requestError);
1228
- });
1229
- const { message, connectionInfo } = (await res);
1608
+ let resolved;
1609
+ try {
1610
+ resolved = await res;
1611
+ }
1612
+ catch (requestError) {
1613
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1614
+ throw requestError;
1615
+ }
1616
+ if (!resolved) {
1617
+ throw new Error('Internal error: makeRequest returned no result');
1618
+ }
1619
+ const { message, connectionInfo } = resolved;
1230
1620
  logger.time(false, logId);
1231
1621
  this.analytics.track('event', 'DAppClient', `Simulated proof of event challenge ${!message.errorMessage ? 'accepted' : 'refused'}`, { address: activeAccount.address });
1232
1622
  await this.notifySuccess(request, {
@@ -1286,14 +1676,18 @@ export class DAppClient extends Client {
1286
1676
  const res = (await this.checkMakeRequest())
1287
1677
  ? this.makeRequest(request)
1288
1678
  : this.makeRequestBC(request);
1289
- res.catch(async (requestError) => {
1290
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1291
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1292
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1293
- logger.time(false, logId);
1294
- throw await this.handleRequestError(request, requestError);
1295
- });
1296
- const { message, connectionInfo } = (await res);
1679
+ let resolved;
1680
+ try {
1681
+ resolved = await res;
1682
+ }
1683
+ catch (requestError) {
1684
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1685
+ throw requestError;
1686
+ }
1687
+ if (!resolved) {
1688
+ throw new Error('Internal error: makeRequest returned no result');
1689
+ }
1690
+ const { message, connectionInfo } = resolved;
1297
1691
  logger.time(false, logId);
1298
1692
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1299
1693
  await this.notifySuccess(request, {
@@ -1381,14 +1775,18 @@ export class DAppClient extends Client {
1381
1775
  const res = (await this.checkMakeRequest())
1382
1776
  ? this.makeRequest(request)
1383
1777
  : this.makeRequestBC(request);
1384
- res.catch(async (requestError) => {
1385
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1386
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1387
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1388
- logger.time(false, logId);
1389
- throw await this.handleRequestError(request, requestError);
1390
- });
1391
- const { message, connectionInfo } = (await res);
1778
+ let resolved;
1779
+ try {
1780
+ resolved = await res;
1781
+ }
1782
+ catch (requestError) {
1783
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1784
+ throw requestError;
1785
+ }
1786
+ if (!resolved) {
1787
+ throw new Error('Internal error: makeRequest returned no result');
1788
+ }
1789
+ const { message, connectionInfo } = resolved;
1392
1790
  logger.time(false, logId);
1393
1791
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1394
1792
  await this.notifySuccess(request, {
@@ -1428,14 +1826,18 @@ export class DAppClient extends Client {
1428
1826
  const res = (await this.checkMakeRequest())
1429
1827
  ? this.makeRequest(request)
1430
1828
  : this.makeRequestBC(request);
1431
- res.catch(async (requestError) => {
1432
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1433
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1434
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1435
- logger.time(false, logId);
1436
- throw await this.handleRequestError(request, requestError);
1437
- });
1438
- const { message, connectionInfo } = (await res);
1829
+ let resolved;
1830
+ try {
1831
+ resolved = await res;
1832
+ }
1833
+ catch (requestError) {
1834
+ await this.runRequestErrorSideEffects(request, requestError, logId);
1835
+ throw requestError;
1836
+ }
1837
+ if (!resolved) {
1838
+ throw new Error('Internal error: makeRequest returned no result');
1839
+ }
1840
+ const { message, connectionInfo } = resolved;
1439
1841
  logger.time(false, logId);
1440
1842
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1441
1843
  await this.notifySuccess(request, {
@@ -1501,11 +1903,14 @@ export class DAppClient extends Client {
1501
1903
  * @param peersToRemove An array of peers for which accounts should be removed
1502
1904
  */
1503
1905
  async removeAccountsForPeers(peersToRemove) {
1906
+ if (peersToRemove.length === 0) {
1907
+ return;
1908
+ }
1504
1909
  const peerIdsToRemove = peersToRemove.map((peer) => peer.senderId);
1505
1910
  return this.removeAccountsForPeerIds(peerIdsToRemove);
1506
1911
  }
1507
1912
  async removeAccountsForPeerIds(peerIds) {
1508
- const accounts = await this.accountManager.getAccounts();
1913
+ const accounts = (await this.accountManager.getAccounts()) ?? [];
1509
1914
  // Remove all accounts with origin of the specified peer
1510
1915
  const accountsToRemove = accounts.filter((account) => peerIds.includes(account.senderId));
1511
1916
  const accountIdentifiersToRemove = accountsToRemove.map((accountInfo) => accountInfo.accountIdentifier);
@@ -1518,6 +1923,44 @@ export class DAppClient extends Client {
1518
1923
  }
1519
1924
  }
1520
1925
  }
1926
+ /**
1927
+ * Run the standard request-error side effects (abort/error metric,
1928
+ * timer stop, {@link handleRequestError}) on a single awaited control
1929
+ * flow so callers can re-throw the original {@link ErrorResponse} without
1930
+ * leaking an UnhandledPromiseRejection.
1931
+ *
1932
+ * Why this exists: every request method historically used a fire-and-
1933
+ * forget `res.catch(handler)` whose handler did `throw await
1934
+ * this.handleRequestError(...)`. The catch returned a detached promise
1935
+ * nobody awaited; `handleRequestError`'s thrown wrapped error became an
1936
+ * unhandled rejection. The bug rarely surfaced before the dapp-init
1937
+ * promise was made rejectable, because pre-pairing rejection paths used
1938
+ * to hang instead of routing through these handlers. Once init started
1939
+ * rejecting reliably, the unhandled rejection started firing on every
1940
+ * WC session-proposal rejection. We swallow the wrapped throw here so
1941
+ * callers can re-throw the original `ErrorResponse`, matching the
1942
+ * post-pairing rejection contract at the openRequest path.
1943
+ *
1944
+ * @param request The request we sent
1945
+ * @param requestError The error we received
1946
+ * @param logId The {@link logger.time} label opened for this request
1947
+ */
1948
+ async runRequestErrorSideEffects(request, rawError, logId) {
1949
+ // V3 request shapes (PermissionRequestV3, BlockchainRequestV3) flow through
1950
+ // here too. They aren't part of BeaconRequestInputMessage, but
1951
+ // handleRequestError only inspects `.type` plus optional fields it already
1952
+ // guards on. Catch params are `unknown` under strict TS, same story.
1953
+ const typedRequest = request;
1954
+ const requestError = rawError;
1955
+ if (requestError.errorType === BeaconErrorType.ABORTED_ERROR) {
1956
+ this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'));
1957
+ }
1958
+ else {
1959
+ this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1960
+ }
1961
+ logger.time(false, logId);
1962
+ await this.handleRequestError(typedRequest, requestError).catch(() => undefined);
1963
+ }
1521
1964
  /**
1522
1965
  * This message handles errors that we receive from the wallet.
1523
1966
  *
@@ -1685,18 +2128,8 @@ export class DAppClient extends Client {
1685
2128
  }
1686
2129
  async makeRequest(requestInput, skipResponse, otherTabMessageId) {
1687
2130
  const messageId = otherTabMessageId ?? (await generateGUID());
1688
- if (this._initPromise && this.isInitPending) {
1689
- await Promise.all([
1690
- this.postMessageTransport?.disconnect(),
1691
- this.walletConnectTransport?.disconnect()
1692
- ]);
1693
- this._initPromise = undefined;
1694
- this.hideUI(['toast']);
1695
- }
1696
2131
  logger.log('makeRequest', 'starting');
1697
- this.isInitPending = true;
1698
2132
  await this.init();
1699
- this.isInitPending = false;
1700
2133
  logger.log('makeRequest', 'after init');
1701
2134
  if (await this.addRequestAndCheckIfRateLimited()) {
1702
2135
  this.events
@@ -1722,20 +2155,37 @@ export class DAppClient extends Client {
1722
2155
  exposed = new ExposedPromise();
1723
2156
  this.addOpenRequest(request.id, exposed);
1724
2157
  }
1725
- const payload = await new Serializer().serialize(request);
1726
- const account = await this.getActiveAccount();
1727
- const peer = await this.getPeer(account);
1728
- const walletInfo = await this.getWalletInfo(peer, account);
1729
- logger.log('makeRequest', 'sending message', request);
1730
2158
  try {
1731
- ;
1732
- (await this.transport).send(payload, peer);
2159
+ const payload = await new Serializer().serialize(request);
2160
+ const account = await this.getActiveAccount();
2161
+ const peer = await this.getPeer(account);
2162
+ const walletInfo = await this.getWalletInfo(peer, account);
2163
+ logger.log('makeRequest', 'sending message', request);
2164
+ await (await this.transport).send(payload, peer);
1733
2165
  if (request.type !== BeaconMessageType.PermissionRequest ||
1734
2166
  (this._activeAccount.isResolved() && (await this._activeAccount.promise))) {
1735
2167
  this.tryToAppSwitch();
1736
2168
  }
2169
+ if (!otherTabMessageId) {
2170
+ this.events
2171
+ .emit(messageEvents[requestInput.type].sent, {
2172
+ walletInfo: {
2173
+ ...walletInfo,
2174
+ name: walletInfo.name ?? 'Wallet'
2175
+ },
2176
+ extraInfo: {
2177
+ resetCallback: async () => {
2178
+ await this.disconnect();
2179
+ }
2180
+ }
2181
+ })
2182
+ .catch((emitError) => logger.warn('makeRequest', emitError));
2183
+ }
1737
2184
  }
1738
2185
  catch (sendError) {
2186
+ if (!skipResponse) {
2187
+ this.clearOpenRequest(request.id);
2188
+ }
1739
2189
  this.events.emit(BeaconEvent.INTERNAL_ERROR, {
1740
2190
  text: 'Unable to send message. If this problem persists, please reset the connection and pair your wallet again.',
1741
2191
  buttons: [
@@ -1750,21 +2200,6 @@ export class DAppClient extends Client {
1750
2200
  });
1751
2201
  throw sendError;
1752
2202
  }
1753
- if (!otherTabMessageId) {
1754
- this.events
1755
- .emit(messageEvents[requestInput.type].sent, {
1756
- walletInfo: {
1757
- ...walletInfo,
1758
- name: walletInfo.name ?? 'Wallet'
1759
- },
1760
- extraInfo: {
1761
- resetCallback: async () => {
1762
- this.disconnect();
1763
- }
1764
- }
1765
- })
1766
- .catch((emitError) => console.warn(emitError));
1767
- }
1768
2203
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1769
2204
  return exposed?.promise; // TODO: fix type
1770
2205
  }
@@ -1777,19 +2212,9 @@ export class DAppClient extends Client {
1777
2212
  * @param account The account that the message will be sent to
1778
2213
  */
1779
2214
  async makeRequestV3(requestInput, otherTabMessageId) {
1780
- if (this._initPromise && this.isInitPending) {
1781
- await Promise.all([
1782
- this.postMessageTransport?.disconnect(),
1783
- this.walletConnectTransport?.disconnect()
1784
- ]);
1785
- this._initPromise = undefined;
1786
- this.hideUI(['toast']);
1787
- }
1788
2215
  const messageId = otherTabMessageId ?? (await generateGUID());
1789
2216
  logger.log('makeRequest', 'starting');
1790
- this.isInitPending = true;
1791
2217
  await this.init(undefined, true);
1792
- this.isInitPending = false;
1793
2218
  logger.log('makeRequest', 'after init');
1794
2219
  if (await this.addRequestAndCheckIfRateLimited()) {
1795
2220
  this.events
@@ -1808,20 +2233,34 @@ export class DAppClient extends Client {
1808
2233
  };
1809
2234
  const exposed = new ExposedPromise();
1810
2235
  this.addOpenRequest(request.id, exposed);
1811
- const payload = await new Serializer().serialize(request);
1812
- const account = await this.getActiveAccount();
1813
- const peer = await this.getPeer(account);
1814
- const walletInfo = await this.getWalletInfo(peer, account);
1815
- logger.log('makeRequest', 'sending message', request);
1816
2236
  try {
1817
- ;
1818
- (await this.transport).send(payload, peer);
2237
+ const payload = await new Serializer().serialize(request);
2238
+ const account = await this.getActiveAccount();
2239
+ const peer = await this.getPeer(account);
2240
+ const walletInfo = await this.getWalletInfo(peer, account);
2241
+ logger.log('makeRequest', 'sending message', request);
2242
+ await (await this.transport).send(payload, peer);
1819
2243
  if (request.message.type !== BeaconMessageType.PermissionRequest ||
1820
2244
  (this._activeAccount.isResolved() && (await this._activeAccount.promise))) {
1821
2245
  this.tryToAppSwitch();
1822
2246
  }
2247
+ const index = requestInput.type;
2248
+ this.events
2249
+ .emit(messageEvents[index].sent, {
2250
+ walletInfo: {
2251
+ ...walletInfo,
2252
+ name: walletInfo.name ?? 'Wallet'
2253
+ },
2254
+ extraInfo: {
2255
+ resetCallback: async () => {
2256
+ await this.disconnect();
2257
+ }
2258
+ }
2259
+ })
2260
+ .catch((emitError) => logger.warn('makeRequestV3', emitError));
1823
2261
  }
1824
2262
  catch (sendError) {
2263
+ this.clearOpenRequest(request.id);
1825
2264
  this.events.emit(BeaconEvent.INTERNAL_ERROR, {
1826
2265
  text: 'Unable to send message. If this problem persists, please reset the connection and pair your wallet again.',
1827
2266
  buttons: [
@@ -1836,20 +2275,6 @@ export class DAppClient extends Client {
1836
2275
  });
1837
2276
  throw sendError;
1838
2277
  }
1839
- const index = requestInput.type;
1840
- this.events
1841
- .emit(messageEvents[index].sent, {
1842
- walletInfo: {
1843
- ...walletInfo,
1844
- name: walletInfo.name ?? 'Wallet'
1845
- },
1846
- extraInfo: {
1847
- resetCallback: async () => {
1848
- this.disconnect();
1849
- }
1850
- }
1851
- })
1852
- .catch((emitError) => console.warn(emitError));
1853
2278
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1854
2279
  return exposed.promise; // TODO: fix type
1855
2280
  }
@@ -1889,24 +2314,140 @@ export class DAppClient extends Client {
1889
2314
  this.addOpenRequest(id, exposed);
1890
2315
  return exposed.promise;
1891
2316
  }
1892
- async disconnect() {
2317
+ /**
2318
+ * Disconnects all resolved transports (postMessage, P2P, WalletConnect), removes their peers,
2319
+ * and clears active account state. After calling, the DAppClient remains usable for a new
2320
+ * requestPermissions().
2321
+ */
2322
+ async disconnect(options = {}) {
2323
+ const { notifyPeers = true } = options;
2324
+ if (!this._disconnectPromise) {
2325
+ this._disconnectNotifyPeers = false;
2326
+ }
2327
+ this._disconnectNotifyPeers = this._disconnectNotifyPeers || notifyPeers;
2328
+ if (this._disconnectPromise) {
2329
+ return this._disconnectPromise;
2330
+ }
2331
+ this._disconnectPromise = (async () => this.disconnectInternal())().finally(() => {
2332
+ this._disconnectPromise = undefined;
2333
+ this._disconnectNotifyPeers = false;
2334
+ });
2335
+ return this._disconnectPromise;
2336
+ }
2337
+ async disconnectInternal() {
2338
+ this.abortPendingInit();
1893
2339
  if (!this._transport.isResolved()) {
1894
- throw new Error('No transport available.');
2340
+ const initializedTransports = this.getInitializedTransports();
2341
+ this.abortOpenRequests();
2342
+ await this.removeAllPeersFromTransports(initializedTransports);
2343
+ await this.clearActiveAccount();
2344
+ await this.disconnectResolvedTransports(initializedTransports);
2345
+ this.clearInternalTransportReferences();
2346
+ return;
1895
2347
  }
1896
- const transport = await this.transport;
2348
+ const transport = (await this.transport);
1897
2349
  if (transport.connectionStatus === TransportStatus.NOT_CONNECTED) {
1898
- throw new Error('Not connected.');
2350
+ await this.clearActiveAccount();
2351
+ this.abortOpenRequests();
2352
+ return;
1899
2353
  }
1900
2354
  await this.createStateSnapshot();
1901
2355
  this.sendMetrics('performance-metrics/save', await this.buildPayload('disconnect', 'start'));
2356
+ this.abortOpenRequests();
2357
+ const transports = this.getResolvedTransports(transport);
2358
+ await this.removeAllPeersFromTransports(transports);
1902
2359
  await this.clearActiveAccount();
1903
- if (!(transport instanceof WalletConnectTransport)) {
1904
- await transport.disconnect();
1905
- }
2360
+ await this.disconnectResolvedTransports(transports);
2361
+ this.clearInternalTransportReferences();
2362
+ this.sendMetrics('performance-metrics/save', await this.buildPayload('disconnect', 'success'));
2363
+ }
2364
+ getInitializedTransports() {
2365
+ const transports = [];
2366
+ const addTransport = (transport) => {
2367
+ if (transport && !transports.includes(transport)) {
2368
+ transports.push(transport);
2369
+ }
2370
+ };
2371
+ addTransport(this.postMessageTransport);
2372
+ addTransport(this.p2pTransport);
2373
+ addTransport(this.walletConnectTransport);
2374
+ return transports;
2375
+ }
2376
+ getResolvedTransports(selectedTransport) {
2377
+ const transports = this.getInitializedTransports();
2378
+ const addTransport = (transport) => {
2379
+ if (transport && !transports.includes(transport)) {
2380
+ transports.push(transport);
2381
+ }
2382
+ };
2383
+ addTransport(this.postMessageTransport);
2384
+ addTransport(this.p2pTransport);
2385
+ addTransport(this.walletConnectTransport);
2386
+ addTransport(selectedTransport);
2387
+ return transports;
2388
+ }
2389
+ clearInternalTransportReferences() {
1906
2390
  this.postMessageTransport = undefined;
1907
2391
  this.p2pTransport = undefined;
1908
2392
  this.walletConnectTransport = undefined;
1909
- this.sendMetrics('performance-metrics/save', await this.buildPayload('disconnect', 'success'));
2393
+ }
2394
+ async removeAllPeersFromTransports(transports) {
2395
+ const peersByTransport = await Promise.all(transports.map(async (transport) => {
2396
+ const transportPeers = await transport.getPeers();
2397
+ return {
2398
+ transport,
2399
+ peers: transportPeers
2400
+ };
2401
+ }));
2402
+ await Promise.all(peersByTransport.map(({ transport }) => transport.removeAllPeers()));
2403
+ const peersToRemove = peersByTransport.flatMap(({ peers: transportPeers }) => transportPeers);
2404
+ await this.removeAccountsForPeers(peersToRemove);
2405
+ // If cleanup already removed peers, a late notify upgrade has nothing left to notify.
2406
+ const sendDisconnectToPeers = this._disconnectNotifyPeers;
2407
+ if (!sendDisconnectToPeers) {
2408
+ return;
2409
+ }
2410
+ await Promise.allSettled(this.getUniquePeerNotifications(peersByTransport).map(({ transport, peer }) => this.sendDisconnectToPeer(peer, transport)));
2411
+ }
2412
+ getUniquePeerNotifications(peersByTransport) {
2413
+ const notifications = [];
2414
+ const seen = new Set();
2415
+ peersByTransport.forEach(({ transport, peers: transportPeers }) => {
2416
+ transportPeers.forEach((peer) => {
2417
+ const key = this.getPeerNotificationKey(transport, peer);
2418
+ if (seen.has(key)) {
2419
+ return;
2420
+ }
2421
+ seen.add(key);
2422
+ notifications.push({ transport, peer });
2423
+ });
2424
+ });
2425
+ return notifications;
2426
+ }
2427
+ getPeerNotificationKey(transport, peer) {
2428
+ return [transport.type, peer.publicKey, peer.senderId, peer.id].filter(Boolean).join(':');
2429
+ }
2430
+ async disconnectResolvedTransports(transports) {
2431
+ const results = await Promise.allSettled(transports.map(async (transport) => {
2432
+ if (this.isTransportConnected(transport)) {
2433
+ await transport.disconnect();
2434
+ }
2435
+ if (this.hasClientCleanup(transport)) {
2436
+ await transport.doClientCleanup();
2437
+ }
2438
+ }));
2439
+ results.forEach((result) => {
2440
+ if (result.status === 'rejected') {
2441
+ logger.warn('disconnectResolvedTransports', result.reason);
2442
+ }
2443
+ });
2444
+ }
2445
+ hasClientCleanup(transport) {
2446
+ const transportWithClientCleanup = transport;
2447
+ return typeof transportWithClientCleanup.doClientCleanup === 'function';
2448
+ }
2449
+ isTransportConnected(transport) {
2450
+ return transport.connectionStatus !== TransportStatus.NOT_CONNECTED;
1910
2451
  }
1911
2452
  /**
1912
2453
  * Adds a requests to the "openRequests" set so we know what messages have already been answered/handled.
@@ -1916,7 +2457,64 @@ export class DAppClient extends Client {
1916
2457
  */
1917
2458
  addOpenRequest(id, promise) {
1918
2459
  logger.log('addOpenRequest', this.name, `adding request ${id} and waiting for answer`);
2460
+ this.clearOpenRequestTimeout(id);
2461
+ this.openRequests.delete(id);
1919
2462
  this.openRequests.set(id, promise);
2463
+ this.addOpenRequestTimeout(id, promise);
2464
+ }
2465
+ addOpenRequestTimeout(id, promise) {
2466
+ if (id === SESSION_UPDATE_REQUEST_ID) {
2467
+ return;
2468
+ }
2469
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs <= 0) {
2470
+ return;
2471
+ }
2472
+ const timeout = setTimeout(() => {
2473
+ if (this.openRequests.get(id) !== promise) {
2474
+ return;
2475
+ }
2476
+ const errorResponse = {
2477
+ id,
2478
+ type: BeaconMessageType.Error,
2479
+ errorType: BeaconErrorType.PEER_UNREACHABLE,
2480
+ senderId: '',
2481
+ version: '2'
2482
+ };
2483
+ promise.reject(errorResponse);
2484
+ this.clearOpenRequest(id);
2485
+ this.events.emit(BeaconEvent.CHANNEL_CLOSED).catch((emitError) => {
2486
+ logger.error('addOpenRequestTimeout', emitError);
2487
+ });
2488
+ }, this.requestTimeoutMs);
2489
+ this.openRequestTimeouts.set(id, timeout);
2490
+ }
2491
+ clearOpenRequest(id) {
2492
+ this.clearOpenRequestTimeout(id);
2493
+ this.openRequests.delete(id);
2494
+ this.openRequestsOtherTabs.delete(id);
2495
+ }
2496
+ clearOpenRequestTimeout(id) {
2497
+ const timeout = this.openRequestTimeouts.get(id);
2498
+ if (timeout) {
2499
+ clearTimeout(timeout);
2500
+ this.openRequestTimeouts.delete(id);
2501
+ }
2502
+ }
2503
+ clearAllOpenRequestTimeouts() {
2504
+ this.openRequestTimeouts.forEach((timeout) => clearTimeout(timeout));
2505
+ this.openRequestTimeouts.clear();
2506
+ }
2507
+ abortOpenRequests() {
2508
+ Array.from(this.openRequests.entries())
2509
+ .filter(([id]) => id !== SESSION_UPDATE_REQUEST_ID)
2510
+ .forEach(([id, promise]) => {
2511
+ promise.reject(this.createAbortedError(id));
2512
+ this.clearOpenRequest(id);
2513
+ });
2514
+ this.openRequests.clear();
2515
+ this.openRequestsOtherTabs.clear();
2516
+ this.acknowledgedRequests.clear();
2517
+ this.clearAllOpenRequestTimeouts();
1920
2518
  }
1921
2519
  async sendNotificationWithAccessToken(notification) {
1922
2520
  const { url, recipient, title, body, payload, protocolIdentifier, accessToken } = notification;
@@ -1938,26 +2536,33 @@ export class DAppClient extends Client {
1938
2536
  const signature = await signMessage(payloadBytes, {
1939
2537
  secretKey: Buffer.from(keypair.secretKey)
1940
2538
  });
1941
- const notificationResponse = await axios.post(`${url}/send`, {
1942
- recipient,
1943
- title,
1944
- body,
1945
- timestamp,
1946
- payload,
1947
- accessToken,
1948
- protocolIdentifier,
1949
- sender: {
1950
- name: this.name,
1951
- publicKey,
1952
- signature
1953
- }
2539
+ const notificationResponse = await fetch(`${url}/send`, {
2540
+ method: 'POST',
2541
+ headers: { 'Content-Type': 'application/json' },
2542
+ body: JSON.stringify({
2543
+ recipient,
2544
+ title,
2545
+ body,
2546
+ timestamp,
2547
+ payload,
2548
+ accessToken,
2549
+ protocolIdentifier,
2550
+ sender: {
2551
+ name: this.name,
2552
+ publicKey,
2553
+ signature
2554
+ }
2555
+ })
1954
2556
  });
1955
- return notificationResponse.data;
2557
+ if (!notificationResponse.ok) {
2558
+ throw new Error(`sendNotification failed: ${notificationResponse.status} ${notificationResponse.statusText}`);
2559
+ }
2560
+ return notificationResponse.json();
1956
2561
  }
1957
2562
  async onNewAccount(message, connectionInfo) {
1958
2563
  // TODO: Migration code. Remove sometime after 1.0.0 release.
1959
2564
  const tempPK = message.publicKey || message.pubkey || message.pubKey;
1960
- const publicKey = !!tempPK ? prefixPublicKey(tempPK) : undefined;
2565
+ const publicKey = tempPK ? prefixPublicKey(tempPK) : undefined;
1961
2566
  if (!publicKey && !message.address) {
1962
2567
  throw new Error('PublicKey or Address must be defined');
1963
2568
  }