@tezos-x/octez.connect-dapp 4.8.4 → 4.8.6

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.
@@ -6,7 +6,7 @@ import { TransportType, StorageKey, BeaconMessageType, PermissionScope, NetworkT
6
6
  // EncryptPayloadResponse,
7
7
  // EncryptPayloadRequest
8
8
  } from '@tezos-x/octez.connect-types';
9
- 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';
10
10
  import { getAddressFromPublicKey, ExposedPromise, generateGUID, toHex, signMessage, CONTRACT_PREFIX, prefixPublicKey, isValidAddress, getKeypairFromSeed } from '@tezos-x/octez.connect-utils';
11
11
  import { messageEvents } from '../beacon-message-events';
12
12
  import { BeaconEvent, BeaconEventHandler } from '../events';
@@ -18,6 +18,23 @@ import { PostMessageTransport } from '@tezos-x/octez.connect-transport-postmessa
18
18
  import { closeToast, getColorMode, setColorMode, setDesktopList, setExtensionList, setWebList, setiOSList, getiOSList, getDesktopList, getExtensionList, getWebList, isBrowser, isDesktop, isMobileOS, isIOS, currentOS } from '@tezos-x/octez.connect-ui';
19
19
  import { WalletConnectTransport } from '@tezos-x/octez.connect-transport-walletconnect';
20
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
+ };
21
38
  /**
22
39
  * @publicapi
23
40
  *
@@ -51,12 +68,20 @@ export class DAppClient extends Client {
51
68
  walletConnectTransport;
52
69
  wcProjectId;
53
70
  wcRelayUrl;
71
+ /**
72
+ * When true, the WalletConnect transport is not built or listened to. See
73
+ * DAppClientOptions.disableWalletConnect.
74
+ */
75
+ disableWalletConnect;
54
76
  isGetActiveAccountHandled = false;
55
77
  openRequestsOtherTabs = new Set();
56
78
  /**
57
79
  * A map of requests that are currently "open", meaning we have sent them to a wallet and are still awaiting a response.
58
80
  */
59
81
  openRequests = new Map();
82
+ acknowledgedRequests = new Set();
83
+ openRequestTimeouts = new Map();
84
+ requestTimeoutMs;
60
85
  /**
61
86
  * The currently active account. For all requests that are associated to a specific request (operation request, signing request),
62
87
  * the active account is used to determine the network and destination wallet
@@ -67,8 +92,22 @@ export class DAppClient extends Client {
67
92
  */
68
93
  _activePeer = new ExposedPromise();
69
94
  _initPromise;
70
- 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;
71
109
  activeAccountLoaded;
110
+ storageValidated;
72
111
  appMetadataManager;
73
112
  disclaimerText;
74
113
  errorMessages;
@@ -76,6 +115,11 @@ export class DAppClient extends Client {
76
115
  storageValidator;
77
116
  beaconIDB = new IndexedDBStorage('beacon', ['bug_report', 'metrics']);
78
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;
79
123
  multiTabChannel = new MultiTabChannel('octez.connect-sdk-channel', this.onBCMessageHandler.bind(this), this.onElectedLeaderhandler.bind(this));
80
124
  constructor(config) {
81
125
  super({
@@ -83,8 +127,11 @@ export class DAppClient extends Client {
83
127
  ...config
84
128
  });
85
129
  this.description = config.description;
86
- this.wcProjectId = config.walletConnectOptions?.projectId || '24469fd0a06df227b6e5f7dc7de0ff4f';
87
- 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
+ }
88
135
  this.featuredWallets = config.featuredWallets;
89
136
  this.events = new BeaconEventHandler(config.eventHandlers, config.disableDefaultEvents ?? false);
90
137
  this.blockExplorer = config.blockExplorer ?? new TzktBlockExplorer();
@@ -97,24 +144,32 @@ export class DAppClient extends Client {
97
144
  this.enableAppSwitching =
98
145
  config.enableAppSwitching === undefined ? true : !!config.enableAppSwitching;
99
146
  this.enableMetrics = config.enableMetrics ? true : false;
147
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
100
148
  // Subscribe to storage changes and update the active account if it changes on other tabs
101
149
  this.storage.subscribeToStorageChanged(async (event) => {
150
+ if (event.oldValue === event.newValue) {
151
+ return;
152
+ }
102
153
  if (event.eventType === 'storageCleared') {
103
- this.setActiveAccount(undefined);
154
+ await this.setActiveAccount(undefined);
104
155
  return;
105
156
  }
106
157
  if (event.eventType === 'entryModified') {
107
158
  if (event.key === this.storage.getPrefixedKey(StorageKey.ACTIVE_ACCOUNT)) {
108
159
  const accountIdentifier = event.newValue;
109
160
  if (!accountIdentifier || accountIdentifier === 'undefined') {
110
- this.setActiveAccount(undefined);
161
+ await this.setActiveAccount(undefined);
111
162
  }
112
163
  else {
113
164
  const account = await this.getAccount(accountIdentifier);
114
- this.setActiveAccount(account);
165
+ await this.setActiveAccount(account);
115
166
  }
116
167
  return;
117
168
  }
169
+ if (event.key === this.storage.getPrefixedKey(StorageKey.ACCOUNTS)) {
170
+ await this.recoverActiveAccountFromAccountsChange();
171
+ return;
172
+ }
118
173
  if (event.key === this.storage.getPrefixedKey(StorageKey.ENABLE_METRICS)) {
119
174
  this.enableMetrics = !!(await this.storage.get(StorageKey.ENABLE_METRICS));
120
175
  return;
@@ -130,30 +185,42 @@ export class DAppClient extends Client {
130
185
  this.activeAccountLoaded = this.storage
131
186
  .get(StorageKey.ACTIVE_ACCOUNT)
132
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
+ }
133
194
  if (activeAccountIdentifier) {
134
- 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
+ }
135
200
  await this.setActiveAccount(account);
136
201
  return account;
137
202
  }
138
- else {
139
- await this.setActiveAccount(undefined);
203
+ if (accounts.length > 0 && this.hasStoredActiveAccountPointer()) {
204
+ await this.deactivateInvalidAccountState('missing_active_account');
140
205
  return undefined;
141
206
  }
207
+ await this.setActiveAccount(undefined);
208
+ return undefined;
142
209
  })
143
210
  .catch(async (storageError) => {
144
211
  logger.error(storageError);
145
- await this.resetInvalidState(false);
146
- this.events.emit(BeaconEvent.INVALID_ACCOUNT_DEACTIVATED);
212
+ await this.deactivateInvalidAccountState('invalid_active_account_storage');
147
213
  return undefined;
148
214
  });
149
215
  this.handleResponse = async (message, connectionInfo) => {
150
- const typedMessage = message.version === '3'
216
+ const isV3WrappedMessage = message.version === '3' && 'message' in message;
217
+ const typedMessage = isV3WrappedMessage
151
218
  ? message.message
152
219
  : message;
153
- let appMetadata = message.version === '3'
220
+ let appMetadata = isV3WrappedMessage
154
221
  ? typedMessage.blockchainData?.appMetadata
155
222
  : typedMessage.appMetadata;
156
- if (!appMetadata && message.version === '3') {
223
+ if (!appMetadata && isV3WrappedMessage) {
157
224
  const storedMetadata = await Promise.all([
158
225
  this.storage.get(StorageKey.TRANSPORT_P2P_PEERS_DAPP),
159
226
  this.storage.get(StorageKey.TRANSPORT_WALLETCONNECT_PEERS_DAPP),
@@ -182,8 +249,11 @@ export class DAppClient extends Client {
182
249
  },
183
250
  id: message.id
184
251
  });
185
- if (typedMessage.type !== BeaconMessageType.Acknowledge) {
186
- this.openRequestsOtherTabs.delete(message.id);
252
+ if (typedMessage.type === BeaconMessageType.Acknowledge) {
253
+ this.clearOpenRequestTimeout(message.id);
254
+ }
255
+ else {
256
+ this.clearOpenRequest(message.id);
187
257
  }
188
258
  return;
189
259
  }
@@ -210,15 +280,19 @@ export class DAppClient extends Client {
210
280
  await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
211
281
  };
212
282
  if (openRequest && typedMessage.type === BeaconMessageType.Acknowledge) {
213
- this.analytics.track('event', 'DAppClient', 'Acknowledge received from Wallet');
214
- logger.log('handleResponse', `acknowledge message received for ${message.id}`);
215
- this.events
216
- .emit(BeaconEvent.ACKNOWLEDGE_RECEIVED, {
217
- message: typedMessage,
218
- extraInfo: {},
219
- walletInfo: await this.getWalletInfo()
220
- })
221
- .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
+ }
222
296
  }
223
297
  else if (openRequest) {
224
298
  if (typedMessage.type === BeaconMessageType.PermissionResponse && appMetadata) {
@@ -230,7 +304,8 @@ export class DAppClient extends Client {
230
304
  else {
231
305
  openRequest.resolve({ message, connectionInfo });
232
306
  }
233
- this.openRequests.delete(typedMessage.id);
307
+ this.clearOpenRequest(message.id);
308
+ this.acknowledgedRequests.delete(message.id);
234
309
  }
235
310
  else {
236
311
  if (typedMessage.type === BeaconMessageType.Disconnect) {
@@ -239,16 +314,23 @@ export class DAppClient extends Client {
239
314
  else if (typedMessage.type === BeaconMessageType.ChangeAccountRequest) {
240
315
  await this.onNewAccount(typedMessage, connectionInfo);
241
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
+ }
242
324
  }
243
325
  if (this._transport.isResolved()) {
244
326
  const transport = await this.transport;
245
327
  if (transport instanceof WalletConnectTransport &&
246
- !this.openRequests.has('session_update')) {
247
- 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());
248
330
  }
249
331
  }
250
332
  };
251
- this.storageValidator
333
+ this.storageValidated = this.storageValidator
252
334
  .validate()
253
335
  .then(async (isValid) => {
254
336
  const account = await this.activeAccountLoaded;
@@ -264,15 +346,22 @@ export class DAppClient extends Client {
264
346
  url: info.deeplink
265
347
  });
266
348
  const nowValid = await this.storageValidator.validate();
267
- if (!nowValid) {
268
- this.resetInvalidState(false);
349
+ if (!nowValid && account) {
350
+ await this.deactivateInvalidAccountState('storage_validation_failed');
351
+ }
352
+ else if (!nowValid) {
353
+ await this.resetInvalidState(false);
269
354
  }
270
355
  }
271
356
  if (account && account.origin.type !== 'p2p') {
272
- 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));
273
361
  }
274
362
  })
275
363
  .catch((err) => logger.error(err.message));
364
+ this.retainStorageValidationPromise();
276
365
  this.sendMetrics('enable-metrics?' + this.addQueryParam('version', SDK_VERSION), undefined, (res) => {
277
366
  if (!res.ok) {
278
367
  res.status === 426
@@ -343,9 +432,43 @@ export class DAppClient extends Client {
343
432
  const transport = (await this._transport.promise);
344
433
  await transport.waitForResolution();
345
434
  this.openRequestsOtherTabs.add(message.id);
346
- isV3
435
+ const requestPromise = isV3
347
436
  ? this.makeRequestV3(message.data, message.id)
348
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
+ };
349
472
  }
350
473
  async createStateSnapshot() {
351
474
  if (!localStorage || !this.enableMetrics) {
@@ -371,10 +494,7 @@ export class DAppClient extends Client {
371
494
  this.storage.set(StorageKey.USER_ID, this.userId);
372
495
  }
373
496
  async initInternalTransports() {
374
- const seed = await this.storage.get(StorageKey.BEACON_SDK_SECRET_SEED);
375
- if (!seed) {
376
- throw new Error('Secret seed not found');
377
- }
497
+ const seed = await this.getOrCreateSDKSecretSeed();
378
498
  const keyPair = await getKeypairFromSeed(seed);
379
499
  if (this.postMessageTransport || this.p2pTransport || this.walletConnectTransport) {
380
500
  return;
@@ -383,6 +503,11 @@ export class DAppClient extends Client {
383
503
  await this.addListener(this.postMessageTransport);
384
504
  this.p2pTransport = new DappP2PTransport(this.name, keyPair, this.storage, this.matrixNodes, this.iconUrl, this.appUrl);
385
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
+ }
386
511
  const wcOptions = {
387
512
  projectId: this.wcProjectId,
388
513
  relayUrl: this.wcRelayUrl,
@@ -400,6 +525,37 @@ export class DAppClient extends Client {
400
525
  this.initEvents();
401
526
  await this.addListener(this.walletConnectTransport);
402
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
+ }
403
559
  initEvents() {
404
560
  if (!this.walletConnectTransport) {
405
561
  return;
@@ -431,20 +587,26 @@ export class DAppClient extends Client {
431
587
  });
432
588
  }
433
589
  else {
434
- this.events.emit(BeaconEvent.PERMISSION_REQUEST_ERROR, {
435
- errorResponse: { errorType: BeaconErrorType.ABORTED_ERROR },
436
- walletInfo
437
- });
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());
438
597
  }
439
598
  }
440
599
  async channelClosedHandler(type) {
600
+ if (!this._transport.isResolved()) {
601
+ await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
602
+ return;
603
+ }
441
604
  const transport = await this.transport;
442
605
  if (transport.type !== type) {
443
606
  return;
444
607
  }
445
608
  await this.events.emit(BeaconEvent.CHANNEL_CLOSED);
446
- this.setActiveAccount(undefined);
447
- await this.disconnect();
609
+ await this.disconnect({ notifyPeers: false });
448
610
  }
449
611
  /**
450
612
  * Destroy the instance.
@@ -454,11 +616,73 @@ export class DAppClient extends Client {
454
616
  * If you wish to disconnect your dApp, use `disconnect` instead.
455
617
  */
456
618
  async destroy() {
619
+ this.destroyed = true;
457
620
  await this.createStateSnapshot();
621
+ await this.destroyInternalTransports();
458
622
  await super.destroy();
459
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
+ }
460
677
  async init(transport, substratePairing) {
678
+ this.assertNotDestroyed();
679
+ const requestedSubstratePairing = substratePairing === true;
461
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
+ }
462
686
  return this._initPromise;
463
687
  }
464
688
  try {
@@ -467,160 +691,265 @@ export class DAppClient extends Client {
467
691
  catch {
468
692
  //
469
693
  }
470
- this._initPromise = new Promise(async (resolve) => {
471
- if (transport) {
472
- await this.addListener(transport);
473
- resolve(await super.init(transport));
474
- }
475
- else if (this._transport.isSettled()) {
476
- await (await this.transport).connect();
477
- resolve(await super.init(await this.transport));
478
- }
479
- else {
480
- const activeAccount = await this.getActiveAccount();
481
- const stopListening = () => {
482
- if (this.postMessageTransport) {
483
- this.postMessageTransport.stopListeningForNewPeers().catch(console.error);
484
- }
485
- if (this.p2pTransport) {
486
- this.p2pTransport.stopListeningForNewPeers().catch(console.error);
487
- }
488
- if (this.walletConnectTransport) {
489
- this.walletConnectTransport.stopListeningForNewPeers().catch(console.error);
490
- }
491
- };
492
- await this.initInternalTransports();
493
- if (!this.postMessageTransport || !this.p2pTransport || !this.walletConnectTransport) {
494
- 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));
495
714
  }
496
- this.postMessageTransport.connect().then().catch(console.error);
497
- if (activeAccount && activeAccount.origin) {
498
- const origin = activeAccount.origin.type;
499
- // Select the transport that matches the active account
500
- if (origin === Origin.EXTENSION) {
501
- resolve(await super.init(this.postMessageTransport));
502
- }
503
- else if (origin === Origin.P2P) {
504
- resolve(await super.init(this.p2pTransport));
505
- }
506
- else if (origin === Origin.WALLETCONNECT) {
507
- resolve(await super.init(this.walletConnectTransport));
508
- }
715
+ else if (this._transport.isSettled()) {
716
+ await (await this.transport).connect();
717
+ resolve(await super.init(await this.transport));
509
718
  }
510
719
  else {
511
- const p2pTransport = this.p2pTransport;
512
- const postMessageTransport = this.postMessageTransport;
513
- const walletConnectTransport = this.walletConnectTransport;
514
- postMessageTransport
515
- .listenForNewPeer((peer) => {
516
- logger.log('init', 'postmessage transport peer connected', peer);
517
- this.analytics.track('event', 'DAppClient', 'Extension connected', {
518
- 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);
519
821
  });
520
- this.events
521
- .emit(BeaconEvent.PAIR_SUCCESS, peer)
522
- .catch((emitError) => console.warn(emitError));
523
- this.setActivePeer(peer).catch(console.error);
524
- this.setTransport(this.postMessageTransport).catch(console.error);
525
- stopListening();
526
- resolve(TransportType.POST_MESSAGE);
527
- })
528
- .catch(console.error);
529
- p2pTransport
530
- .listenForNewPeer((peer) => {
531
- logger.log('init', 'p2p transport peer connected', peer);
532
- this.analytics.track('event', 'DAppClient', 'octez.connect Wallet connected', {
533
- 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()));
534
851
  });
535
- this.events
536
- .emit(BeaconEvent.PAIR_SUCCESS, peer)
537
- .catch((emitError) => console.warn(emitError));
538
- this.setActivePeer(peer).catch(console.error);
539
- this.setTransport(this.p2pTransport).catch(console.error);
540
- stopListening();
541
- resolve(TransportType.P2P);
542
- })
543
- .catch(console.error);
544
- walletConnectTransport
545
- .listenForNewPeer((peer) => {
546
- logger.log('init', 'walletconnect transport peer connected', peer);
547
- this.analytics.track('event', 'DAppClient', 'WalletConnect Wallet connected', {
548
- 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()));
549
863
  });
550
864
  this.events
551
- .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
+ })
552
878
  .catch((emitError) => console.warn(emitError));
553
- this.setActivePeer(peer).catch(console.error);
554
- this.setTransport(this.walletConnectTransport).catch(console.error);
555
- stopListening();
556
- resolve(TransportType.WALLETCONNECT);
557
- })
558
- .catch(console.error);
559
- PostMessageTransport.getAvailableExtensions()
560
- .then(async (extensions) => {
561
- this.analytics.track('event', 'DAppClient', 'Extensions detected', { extensions });
562
- })
563
- .catch((error) => {
564
- this._initPromise = undefined;
565
- console.error(error);
566
- });
567
- const abortHandler = async () => {
568
- logger.log('init', 'ABORTED');
569
- this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'abort'));
570
- await Promise.all([
571
- postMessageTransport.disconnect(),
572
- // p2pTransport.disconnect(), do not abort connection manually
573
- walletConnectTransport.disconnect()
574
- ]);
575
- this.postMessageTransport = this.walletConnectTransport = this.p2pTransport = undefined;
576
- this._activeAccount.isResolved() && this.clearActiveAccount();
577
- this._initPromise = undefined;
578
- };
579
- const serializer = new Serializer();
580
- const p2pPeerInfo = new Promise(async (resolve) => {
581
- try {
582
- await p2pTransport.connect();
583
- }
584
- catch (err) {
585
- logger.error(err);
586
- await this.hideUI(['alert']); // hide pairing alert
587
- setTimeout(() => this.events.emit(BeaconEvent.GENERIC_ERROR, err.message), 1000);
588
- abortHandler();
589
- resolve('');
590
- return;
591
- }
592
- resolve(await serializer.serialize(await p2pTransport.getPairingRequestInfo()));
593
- });
594
- const walletConnectPeerInfo = new Promise(async (resolve) => {
595
- resolve((await walletConnectTransport.getPairingRequestInfo()).uri);
596
- });
597
- const postmessagePeerInfo = new Promise(async (resolve) => {
598
- resolve(await serializer.serialize(await postMessageTransport.getPairingRequestInfo()));
599
- });
600
- this.events
601
- .emit(BeaconEvent.PAIR_INIT, {
602
- p2pPeerInfo,
603
- postmessagePeerInfo,
604
- walletConnectPeerInfo,
605
- networkType: this.network.type,
606
- abortedHandler: abortHandler.bind(this),
607
- disclaimerText: this.disclaimerText,
608
- analytics: this.analytics,
609
- featuredWallets: this.featuredWallets,
610
- substratePairing
611
- })
612
- .catch((emitError) => console.warn(emitError));
879
+ }
613
880
  }
614
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
+ }
615
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);
616
892
  return this._initPromise;
617
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
+ }
618
910
  /**
619
911
  * Returns the active account
620
912
  */
621
913
  async getActiveAccount() {
914
+ this.assertNotDestroyed();
622
915
  return this._activeAccount.promise;
623
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
+ }
624
953
  async isInvalidState(account) {
625
954
  const activeAccount = await this._activeAccount.promise;
626
955
  return !activeAccount
@@ -628,11 +957,15 @@ export class DAppClient extends Client {
628
957
  : activeAccount?.address !== account?.address && !this.isGetActiveAccountHandled;
629
958
  }
630
959
  async resetInvalidState(emit = true) {
631
- this.accountManager.removeAllAccounts();
960
+ await this.accountManager.removeAllAccounts();
632
961
  this._activeAccount = ExposedPromise.resolve(undefined);
633
- this.storage.set(StorageKey.ACTIVE_ACCOUNT, undefined);
634
- emit && this.events.emit(BeaconEvent.INVALID_ACTIVE_ACCOUNT_STATE);
635
- !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
+ }
636
969
  await Promise.all([
637
970
  this.postMessageTransport?.disconnect(),
638
971
  this.walletConnectTransport?.disconnect()
@@ -648,7 +981,8 @@ export class DAppClient extends Client {
648
981
  * @param account The account that will be set as the active account
649
982
  */
650
983
  async setActiveAccount(account) {
651
- if (!this.isGetActiveAccountHandled) {
984
+ const activeAccountAlreadyCleared = !account && (await this.isActiveAccountAlreadyCleared());
985
+ if (!activeAccountAlreadyCleared && !this.isGetActiveAccountHandled) {
652
986
  console.warn(`An active account has been received, but no active subscription was found for BeaconEvent.ACTIVE_ACCOUNT_SET.`);
653
987
  }
654
988
  if (account && this._activeAccount.isSettled() && (await this.isInvalidState(account))) {
@@ -660,9 +994,9 @@ export class DAppClient extends Client {
660
994
  }
661
995
  // when I'm resetting the activeAccount
662
996
  if (!account && this._activeAccount.isResolved() && (await this.getActiveAccount())) {
663
- const transport = await this.transport;
997
+ const transport = this._transport.isResolved() ? await this.transport : undefined;
664
998
  const activeAccount = await this.getActiveAccount();
665
- if (!transport || !activeAccount) {
999
+ if (!activeAccount) {
666
1000
  return;
667
1001
  }
668
1002
  if (!this.debounceSetActiveAccount && transport instanceof WalletConnectTransport) {
@@ -678,22 +1012,15 @@ export class DAppClient extends Client {
678
1012
  type: 'DISCONNECT'
679
1013
  });
680
1014
  }
681
- Array.from(this.openRequests.entries())
682
- .filter(([id, _promise]) => id !== 'session_update')
683
- .forEach(([id, promise]) => {
684
- promise.reject({
685
- type: BeaconMessageType.Error,
686
- errorType: BeaconErrorType.ABORTED_ERROR,
687
- id,
688
- senderId: '',
689
- version: '2'
690
- });
691
- });
692
- this.openRequests.clear();
1015
+ this.abortOpenRequests();
693
1016
  this.debounceSetActiveAccount = false;
694
1017
  }
695
1018
  }
696
- 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()) {
697
1024
  // If the promise has already been resolved we need to create a new one.
698
1025
  this._activeAccount = ExposedPromise.resolve(account);
699
1026
  }
@@ -734,10 +1061,37 @@ export class DAppClient extends Client {
734
1061
  await this.setActivePeer(undefined);
735
1062
  await this.setTransport(undefined);
736
1063
  }
737
- await this.storage.set(StorageKey.ACTIVE_ACCOUNT, account ? account.accountIdentifier : undefined);
738
- 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
+ }
739
1073
  return;
740
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
+ }
741
1095
  /**
742
1096
  * Clear the active account
743
1097
  */
@@ -812,30 +1166,16 @@ export class DAppClient extends Client {
812
1166
  })
813
1167
  };
814
1168
  }
815
- async updateMetricsStorage(payload) {
816
- const queue = await this.beaconIDB.getAllKeys('metrics');
817
- if (queue.length >= 1000) {
818
- const key = queue.shift();
819
- this.beaconIDB.delete(key.toString(), 'metrics');
820
- }
821
- this.beaconIDB.set(String(Date.now()), payload, 'metrics');
822
- }
823
- sendMetrics(uri, options, thenHandler, catchHandler) {
824
- if (!this.enableMetrics && uri === 'performance-metrics/save') {
825
- options && this.updateMetricsStorage(options.body);
826
- }
827
- if (!this.enableMetrics) {
828
- return;
829
- }
830
- fetch(`${BACKEND_URL}/${uri}`, options)
831
- .then((res) => thenHandler && thenHandler(res))
832
- .catch((err) => {
833
- console.warn('Network error encountered. Metrics sharing have been automatically disabled.');
834
- logger.error(err.message);
835
- this.enableMetrics = false; // in the event of a network error, stop sending metrics
836
- catchHandler && catchHandler(err);
837
- });
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;
838
1177
  }
1178
+ /* eslint-enable @typescript-eslint/no-unused-vars */
839
1179
  async checkMakeRequest() {
840
1180
  const isResolved = this._transport.isResolved();
841
1181
  const isWCInstance = isResolved && (await this.transport) instanceof WalletConnectTransport;
@@ -880,14 +1220,19 @@ export class DAppClient extends Client {
880
1220
  /**
881
1221
  * Remove all peers and all accounts that have been connected through those peers
882
1222
  */
883
- async removeAllPeers(sendDisconnectToPeers = false) {
884
- const transport = await this.transport;
1223
+ async removeAllPeers(sendDisconnectToPeers = false, options = {}) {
1224
+ const transport = (await this.transport);
885
1225
  const peers = await transport.getPeers();
886
- const removePeerResult = transport.removeAllPeers();
1226
+ const removePeerResult = await transport.removeAllPeers();
887
1227
  await this.removeAccountsForPeers(peers);
888
1228
  if (sendDisconnectToPeers) {
889
1229
  const disconnectPromises = peers.map((peer) => this.sendDisconnectToPeer(peer, transport));
890
- await Promise.all(disconnectPromises);
1230
+ if (options.ignoreDisconnectErrors) {
1231
+ await Promise.allSettled(disconnectPromises);
1232
+ }
1233
+ else {
1234
+ await Promise.all(disconnectPromises);
1235
+ }
891
1236
  }
892
1237
  return removePeerResult;
893
1238
  }
@@ -992,13 +1337,15 @@ export class DAppClient extends Client {
992
1337
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
993
1338
  const logId = `makeRequestV3 ${Date.now()}`;
994
1339
  logger.time(true, logId);
995
- const { message: response, connectionInfo } = await this.makeRequestV3(request).catch(async (requestError) => {
996
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
997
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
998
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
999
- logger.time(false, logId);
1000
- throw await this.handleRequestError(request, requestError);
1001
- });
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;
1002
1349
  logger.time(false, logId);
1003
1350
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
1004
1351
  logger.log('RESPONSE V3', response, connectionInfo);
@@ -1062,14 +1409,18 @@ export class DAppClient extends Client {
1062
1409
  const res = (await this.checkMakeRequest())
1063
1410
  ? this.makeRequestV3(request)
1064
1411
  : this.makeRequestBC(request);
1065
- res.catch(async (requestError) => {
1066
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1067
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1068
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1069
- logger.time(false, logId);
1070
- throw await this.handleRequestError(request, requestError);
1071
- });
1072
- 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;
1073
1424
  logger.time(false, logId);
1074
1425
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1075
1426
  await blockchain.handleResponse({
@@ -1093,16 +1444,34 @@ export class DAppClient extends Client {
1093
1444
  * @param input The message details we need to prepare the PermissionRequest message.
1094
1445
  */
1095
1446
  async requestPermissions(input) {
1096
- if (input?.network) {
1097
- 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;
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
+ }
1098
1467
  }
1468
+ }
1469
+ async requestPermissionsInternal(scopes) {
1099
1470
  const request = {
1100
1471
  appMetadata: await this.getOwnAppMetadata(),
1101
1472
  type: BeaconMessageType.PermissionRequest,
1102
1473
  network: this.network,
1103
- scopes: input && input.scopes
1104
- ? input.scopes
1105
- : [PermissionScope.OPERATION_REQUEST, PermissionScope.SIGN]
1474
+ scopes
1106
1475
  };
1107
1476
  this.analytics.track('event', 'DAppClient', 'Permission requested');
1108
1477
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'start'));
@@ -1111,14 +1480,18 @@ export class DAppClient extends Client {
1111
1480
  const res = (await this.checkMakeRequest()) || !(await this.getActiveAccount())
1112
1481
  ? this.makeRequest(request, undefined, undefined)
1113
1482
  : this.makeRequestBC(request);
1114
- res.catch(async (requestError) => {
1115
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1116
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1117
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1118
- logger.time(false, logId);
1119
- throw await this.handleRequestError(request, requestError);
1120
- });
1121
- 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;
1122
1495
  logger.time(false, logId);
1123
1496
  this.sendMetrics('performance-metrics/save', await this.buildPayload('connect', 'success'));
1124
1497
  logger.log('requestPermissions', '######## MESSAGE #######');
@@ -1145,6 +1518,17 @@ export class DAppClient extends Client {
1145
1518
  });
1146
1519
  return output;
1147
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
+ }
1148
1532
  /**
1149
1533
  * Send a proof of event request to the wallet. The wallet will either accept or decline the challenge.
1150
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.
@@ -1171,14 +1555,18 @@ export class DAppClient extends Client {
1171
1555
  const res = (await this.checkMakeRequest())
1172
1556
  ? this.makeRequest(request)
1173
1557
  : this.makeRequestBC(request);
1174
- res.catch(async (requestError) => {
1175
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1176
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1177
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1178
- logger.time(false, logId);
1179
- throw await this.handleRequestError(request, requestError);
1180
- });
1181
- 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;
1182
1570
  logger.time(false, logId);
1183
1571
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1184
1572
  this.analytics.track('event', 'DAppClient', `Proof of event challenge ${message.isAccepted ? 'accepted' : 'refused'}`, { address: activeAccount.address });
@@ -1217,14 +1605,18 @@ export class DAppClient extends Client {
1217
1605
  const res = (await this.checkMakeRequest())
1218
1606
  ? this.makeRequest(request)
1219
1607
  : this.makeRequestBC(request);
1220
- res.catch(async (requestError) => {
1221
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1222
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1223
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1224
- logger.time(false, logId);
1225
- throw await this.handleRequestError(request, requestError);
1226
- });
1227
- 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;
1228
1620
  logger.time(false, logId);
1229
1621
  this.analytics.track('event', 'DAppClient', `Simulated proof of event challenge ${!message.errorMessage ? 'accepted' : 'refused'}`, { address: activeAccount.address });
1230
1622
  await this.notifySuccess(request, {
@@ -1284,14 +1676,18 @@ export class DAppClient extends Client {
1284
1676
  const res = (await this.checkMakeRequest())
1285
1677
  ? this.makeRequest(request)
1286
1678
  : this.makeRequestBC(request);
1287
- res.catch(async (requestError) => {
1288
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1289
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1290
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1291
- logger.time(false, logId);
1292
- throw await this.handleRequestError(request, requestError);
1293
- });
1294
- 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;
1295
1691
  logger.time(false, logId);
1296
1692
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1297
1693
  await this.notifySuccess(request, {
@@ -1379,14 +1775,18 @@ export class DAppClient extends Client {
1379
1775
  const res = (await this.checkMakeRequest())
1380
1776
  ? this.makeRequest(request)
1381
1777
  : this.makeRequestBC(request);
1382
- res.catch(async (requestError) => {
1383
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1384
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1385
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1386
- logger.time(false, logId);
1387
- throw await this.handleRequestError(request, requestError);
1388
- });
1389
- 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;
1390
1790
  logger.time(false, logId);
1391
1791
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1392
1792
  await this.notifySuccess(request, {
@@ -1426,14 +1826,18 @@ export class DAppClient extends Client {
1426
1826
  const res = (await this.checkMakeRequest())
1427
1827
  ? this.makeRequest(request)
1428
1828
  : this.makeRequestBC(request);
1429
- res.catch(async (requestError) => {
1430
- requestError.errorType === BeaconErrorType.ABORTED_ERROR
1431
- ? this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'abort'))
1432
- : this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'error'));
1433
- logger.time(false, logId);
1434
- throw await this.handleRequestError(request, requestError);
1435
- });
1436
- 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;
1437
1841
  logger.time(false, logId);
1438
1842
  this.sendMetrics('performance-metrics/save', await this.buildPayload('message', 'success'));
1439
1843
  await this.notifySuccess(request, {
@@ -1499,11 +1903,14 @@ export class DAppClient extends Client {
1499
1903
  * @param peersToRemove An array of peers for which accounts should be removed
1500
1904
  */
1501
1905
  async removeAccountsForPeers(peersToRemove) {
1906
+ if (peersToRemove.length === 0) {
1907
+ return;
1908
+ }
1502
1909
  const peerIdsToRemove = peersToRemove.map((peer) => peer.senderId);
1503
1910
  return this.removeAccountsForPeerIds(peerIdsToRemove);
1504
1911
  }
1505
1912
  async removeAccountsForPeerIds(peerIds) {
1506
- const accounts = await this.accountManager.getAccounts();
1913
+ const accounts = (await this.accountManager.getAccounts()) ?? [];
1507
1914
  // Remove all accounts with origin of the specified peer
1508
1915
  const accountsToRemove = accounts.filter((account) => peerIds.includes(account.senderId));
1509
1916
  const accountIdentifiersToRemove = accountsToRemove.map((accountInfo) => accountInfo.accountIdentifier);
@@ -1516,6 +1923,44 @@ export class DAppClient extends Client {
1516
1923
  }
1517
1924
  }
1518
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
+ }
1519
1964
  /**
1520
1965
  * This message handles errors that we receive from the wallet.
1521
1966
  *
@@ -1683,18 +2128,8 @@ export class DAppClient extends Client {
1683
2128
  }
1684
2129
  async makeRequest(requestInput, skipResponse, otherTabMessageId) {
1685
2130
  const messageId = otherTabMessageId ?? (await generateGUID());
1686
- if (this._initPromise && this.isInitPending) {
1687
- await Promise.all([
1688
- this.postMessageTransport?.disconnect(),
1689
- this.walletConnectTransport?.disconnect()
1690
- ]);
1691
- this._initPromise = undefined;
1692
- this.hideUI(['toast']);
1693
- }
1694
2131
  logger.log('makeRequest', 'starting');
1695
- this.isInitPending = true;
1696
2132
  await this.init();
1697
- this.isInitPending = false;
1698
2133
  logger.log('makeRequest', 'after init');
1699
2134
  if (await this.addRequestAndCheckIfRateLimited()) {
1700
2135
  this.events
@@ -1720,20 +2155,37 @@ export class DAppClient extends Client {
1720
2155
  exposed = new ExposedPromise();
1721
2156
  this.addOpenRequest(request.id, exposed);
1722
2157
  }
1723
- const payload = await new Serializer().serialize(request);
1724
- const account = await this.getActiveAccount();
1725
- const peer = await this.getPeer(account);
1726
- const walletInfo = await this.getWalletInfo(peer, account);
1727
- logger.log('makeRequest', 'sending message', request);
1728
2158
  try {
1729
- ;
1730
- (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);
1731
2165
  if (request.type !== BeaconMessageType.PermissionRequest ||
1732
2166
  (this._activeAccount.isResolved() && (await this._activeAccount.promise))) {
1733
2167
  this.tryToAppSwitch();
1734
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
+ }
1735
2184
  }
1736
2185
  catch (sendError) {
2186
+ if (!skipResponse) {
2187
+ this.clearOpenRequest(request.id);
2188
+ }
1737
2189
  this.events.emit(BeaconEvent.INTERNAL_ERROR, {
1738
2190
  text: 'Unable to send message. If this problem persists, please reset the connection and pair your wallet again.',
1739
2191
  buttons: [
@@ -1748,21 +2200,6 @@ export class DAppClient extends Client {
1748
2200
  });
1749
2201
  throw sendError;
1750
2202
  }
1751
- if (!otherTabMessageId) {
1752
- this.events
1753
- .emit(messageEvents[requestInput.type].sent, {
1754
- walletInfo: {
1755
- ...walletInfo,
1756
- name: walletInfo.name ?? 'Wallet'
1757
- },
1758
- extraInfo: {
1759
- resetCallback: async () => {
1760
- this.disconnect();
1761
- }
1762
- }
1763
- })
1764
- .catch((emitError) => console.warn(emitError));
1765
- }
1766
2203
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1767
2204
  return exposed?.promise; // TODO: fix type
1768
2205
  }
@@ -1775,19 +2212,9 @@ export class DAppClient extends Client {
1775
2212
  * @param account The account that the message will be sent to
1776
2213
  */
1777
2214
  async makeRequestV3(requestInput, otherTabMessageId) {
1778
- if (this._initPromise && this.isInitPending) {
1779
- await Promise.all([
1780
- this.postMessageTransport?.disconnect(),
1781
- this.walletConnectTransport?.disconnect()
1782
- ]);
1783
- this._initPromise = undefined;
1784
- this.hideUI(['toast']);
1785
- }
1786
2215
  const messageId = otherTabMessageId ?? (await generateGUID());
1787
2216
  logger.log('makeRequest', 'starting');
1788
- this.isInitPending = true;
1789
2217
  await this.init(undefined, true);
1790
- this.isInitPending = false;
1791
2218
  logger.log('makeRequest', 'after init');
1792
2219
  if (await this.addRequestAndCheckIfRateLimited()) {
1793
2220
  this.events
@@ -1806,20 +2233,34 @@ export class DAppClient extends Client {
1806
2233
  };
1807
2234
  const exposed = new ExposedPromise();
1808
2235
  this.addOpenRequest(request.id, exposed);
1809
- const payload = await new Serializer().serialize(request);
1810
- const account = await this.getActiveAccount();
1811
- const peer = await this.getPeer(account);
1812
- const walletInfo = await this.getWalletInfo(peer, account);
1813
- logger.log('makeRequest', 'sending message', request);
1814
2236
  try {
1815
- ;
1816
- (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);
1817
2243
  if (request.message.type !== BeaconMessageType.PermissionRequest ||
1818
2244
  (this._activeAccount.isResolved() && (await this._activeAccount.promise))) {
1819
2245
  this.tryToAppSwitch();
1820
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));
1821
2261
  }
1822
2262
  catch (sendError) {
2263
+ this.clearOpenRequest(request.id);
1823
2264
  this.events.emit(BeaconEvent.INTERNAL_ERROR, {
1824
2265
  text: 'Unable to send message. If this problem persists, please reset the connection and pair your wallet again.',
1825
2266
  buttons: [
@@ -1834,20 +2275,6 @@ export class DAppClient extends Client {
1834
2275
  });
1835
2276
  throw sendError;
1836
2277
  }
1837
- const index = requestInput.type;
1838
- this.events
1839
- .emit(messageEvents[index].sent, {
1840
- walletInfo: {
1841
- ...walletInfo,
1842
- name: walletInfo.name ?? 'Wallet'
1843
- },
1844
- extraInfo: {
1845
- resetCallback: async () => {
1846
- this.disconnect();
1847
- }
1848
- }
1849
- })
1850
- .catch((emitError) => console.warn(emitError));
1851
2278
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1852
2279
  return exposed.promise; // TODO: fix type
1853
2280
  }
@@ -1887,24 +2314,140 @@ export class DAppClient extends Client {
1887
2314
  this.addOpenRequest(id, exposed);
1888
2315
  return exposed.promise;
1889
2316
  }
1890
- 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();
1891
2339
  if (!this._transport.isResolved()) {
1892
- 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;
1893
2347
  }
1894
- const transport = await this.transport;
2348
+ const transport = (await this.transport);
1895
2349
  if (transport.connectionStatus === TransportStatus.NOT_CONNECTED) {
1896
- throw new Error('Not connected.');
2350
+ await this.clearActiveAccount();
2351
+ this.abortOpenRequests();
2352
+ return;
1897
2353
  }
1898
2354
  await this.createStateSnapshot();
1899
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);
1900
2359
  await this.clearActiveAccount();
1901
- if (!(transport instanceof WalletConnectTransport)) {
1902
- await transport.disconnect();
1903
- }
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() {
1904
2390
  this.postMessageTransport = undefined;
1905
2391
  this.p2pTransport = undefined;
1906
2392
  this.walletConnectTransport = undefined;
1907
- 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;
1908
2451
  }
1909
2452
  /**
1910
2453
  * Adds a requests to the "openRequests" set so we know what messages have already been answered/handled.
@@ -1914,7 +2457,64 @@ export class DAppClient extends Client {
1914
2457
  */
1915
2458
  addOpenRequest(id, promise) {
1916
2459
  logger.log('addOpenRequest', this.name, `adding request ${id} and waiting for answer`);
2460
+ this.clearOpenRequestTimeout(id);
2461
+ this.openRequests.delete(id);
1917
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();
1918
2518
  }
1919
2519
  async sendNotificationWithAccessToken(notification) {
1920
2520
  const { url, recipient, title, body, payload, protocolIdentifier, accessToken } = notification;