@reown/appkit-solana-react-native 2.0.0-alpha.2 → 2.0.0-alpha.3

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.
@@ -26,10 +26,10 @@ import type {
26
26
  PhantomProviderConfig
27
27
  } from '../types';
28
28
 
29
- const SOLANA_CLUSTER_TO_CHAIN_ID_PART: Record<PhantomCluster, string> = {
30
- 'mainnet-beta': solana.id as string,
31
- 'testnet': solanaTestnet.id as string,
32
- 'devnet': solanaDevnet.id as string
29
+ const SOLANA_CLUSTER_TO_CHAIN_ID: Record<PhantomCluster, CaipNetworkId> = {
30
+ 'mainnet-beta': solana.caipNetworkId,
31
+ 'testnet': solanaTestnet.caipNetworkId,
32
+ 'devnet': solanaDevnet.caipNetworkId
33
33
  };
34
34
 
35
35
  const PHANTOM_CONNECTOR_STORAGE_KEY = '@appkit/phantom-connector-data';
@@ -37,7 +37,6 @@ const DAPP_KEYPAIR_STORAGE_KEY = '@appkit/phantom-dapp-secret-key';
37
37
 
38
38
  export class PhantomConnector extends WalletConnector {
39
39
  private readonly config: PhantomConnectorConfig;
40
-
41
40
  private currentCaipNetworkId: CaipNetworkId | null = null;
42
41
  private dappEncryptionKeyPair?: nacl.BoxKeyPair;
43
42
 
@@ -97,31 +96,31 @@ export class PhantomConnector extends WalletConnector {
97
96
  return this.namespaces;
98
97
  }
99
98
 
100
- const defaultChain =
99
+ const defaultChain: CaipNetworkId | undefined =
101
100
  opts?.defaultChain?.split(':')?.[0] === 'solana'
102
- ? opts?.defaultChain?.split(':')[1]
103
- : opts?.namespaces?.['solana']?.chains?.[0]?.split(':')[1];
101
+ ? opts?.defaultChain
102
+ : opts?.namespaces?.['solana']?.chains?.[0];
104
103
 
105
104
  const requestedCluster =
106
105
  this.config.cluster ??
107
- (Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID_PART).find(
108
- key =>
109
- SOLANA_CLUSTER_TO_CHAIN_ID_PART[key as keyof typeof SOLANA_CLUSTER_TO_CHAIN_ID_PART] ===
110
- defaultChain
106
+ (Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID).find(
107
+ key => SOLANA_CLUSTER_TO_CHAIN_ID[key as PhantomCluster] === defaultChain
111
108
  ) as PhantomCluster | undefined);
112
109
 
113
110
  try {
114
111
  const connectResult = await this.getProvider().connect({ cluster: requestedCluster });
115
112
 
116
- const solanaChainIdPart = SOLANA_CLUSTER_TO_CHAIN_ID_PART[connectResult.cluster];
117
- if (!solanaChainIdPart) {
113
+ const solanaChainId = SOLANA_CLUSTER_TO_CHAIN_ID[connectResult.cluster];
114
+ if (!solanaChainId) {
118
115
  throw new Error(
119
116
  `Phantom Connect: Internal - Unknown cluster mapping for ${connectResult.cluster}`
120
117
  );
121
118
  }
122
- this.currentCaipNetworkId = `solana:${solanaChainIdPart}` as CaipNetworkId;
119
+ this.currentCaipNetworkId = solanaChainId;
123
120
 
124
- this.wallet = ConstantsUtil.PHANTOM_CUSTOM_WALLET;
121
+ this.wallet = {
122
+ name: ConstantsUtil.PHANTOM_CUSTOM_WALLET.name
123
+ };
125
124
 
126
125
  const userPublicKey = this.getProvider().getUserPublicKey();
127
126
  if (!userPublicKey) {
@@ -155,6 +154,12 @@ export class PhantomConnector extends WalletConnector {
155
154
  } catch (error: any) {
156
155
  // console.warn(`PhantomConnector: Error during provider disconnect: ${error.message}. Proceeding with local clear.`);
157
156
  }
157
+
158
+ // Cleanup provider resources
159
+ if (this.provider) {
160
+ (this.provider as PhantomProvider).destroy();
161
+ }
162
+
158
163
  await this.clearSession();
159
164
  }
160
165
 
@@ -215,21 +220,16 @@ export class PhantomConnector extends WalletConnector {
215
220
  }
216
221
 
217
222
  override async switchNetwork(network: AppKitNetwork): Promise<void> {
218
- const targetClusterName = Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID_PART).find(
219
- key =>
220
- SOLANA_CLUSTER_TO_CHAIN_ID_PART[key as keyof typeof SOLANA_CLUSTER_TO_CHAIN_ID_PART] ===
221
- network.id
223
+ const targetClusterName = Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID).find(
224
+ key => SOLANA_CLUSTER_TO_CHAIN_ID[key as PhantomCluster] === network.caipNetworkId
222
225
  ) as PhantomCluster | undefined;
223
226
 
224
227
  if (!targetClusterName) {
225
228
  throw new Error(`Cannot switch to unsupported network ID: ${network.id}`);
226
229
  }
227
230
 
228
- const currentClusterName = Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID_PART).find(
229
- key =>
230
- `solana:${
231
- SOLANA_CLUSTER_TO_CHAIN_ID_PART[key as keyof typeof SOLANA_CLUSTER_TO_CHAIN_ID_PART]
232
- }` === this.currentCaipNetworkId
231
+ const currentClusterName = Object.keys(SOLANA_CLUSTER_TO_CHAIN_ID).find(
232
+ key => SOLANA_CLUSTER_TO_CHAIN_ID[key as PhantomCluster] === this.currentCaipNetworkId
233
233
  ) as PhantomCluster | undefined;
234
234
 
235
235
  if (targetClusterName === currentClusterName && this.isConnected()) {
@@ -241,7 +241,7 @@ export class PhantomConnector extends WalletConnector {
241
241
 
242
242
  // Create a temporary options object to guide the new connection
243
243
  const tempConnectOpts: ConnectOptions = {
244
- defaultChain: `solana:${SOLANA_CLUSTER_TO_CHAIN_ID_PART[targetClusterName]}` as CaipNetworkId
244
+ defaultChain: SOLANA_CLUSTER_TO_CHAIN_ID[targetClusterName]
245
245
  };
246
246
 
247
247
  // Attempt to connect to the new cluster
@@ -54,6 +54,10 @@ export class PhantomProvider extends EventEmitter implements Provider {
54
54
  private userPublicKey: string | null = null;
55
55
  private phantomEncryptionPublicKeyBs58: string | null = null;
56
56
 
57
+ // Single subscription management - deep links are sequential by nature
58
+ private activeSubscription: { remove: () => void } | null = null;
59
+ private isOperationPending = false;
60
+
57
61
  constructor(config: PhantomProviderConfig) {
58
62
  super();
59
63
  this.config = config;
@@ -61,6 +65,37 @@ export class PhantomProvider extends EventEmitter implements Provider {
61
65
  this.storage = config.storage;
62
66
  }
63
67
 
68
+ /**
69
+ * Cleanup method to be called when the provider is destroyed
70
+ */
71
+ public destroy(): void {
72
+ this.cleanupActiveSubscription();
73
+ this.removeAllListeners();
74
+ }
75
+
76
+ /**
77
+ * Safely cleanup the active subscription
78
+ */
79
+ private cleanupActiveSubscription(): void {
80
+ if (this.activeSubscription) {
81
+ this.activeSubscription.remove();
82
+ this.activeSubscription = null;
83
+ }
84
+ this.isOperationPending = false;
85
+ }
86
+
87
+ /**
88
+ * Safely set a new subscription, ensuring no operation is pending
89
+ */
90
+ private setActiveSubscription(subscription: { remove: () => void }): void {
91
+ // If there's already a pending operation, reject it
92
+ if (this.isOperationPending) {
93
+ this.cleanupActiveSubscription();
94
+ }
95
+ this.activeSubscription = subscription;
96
+ this.isOperationPending = true;
97
+ }
98
+
64
99
  getUserPublicKey(): string | null {
65
100
  return this.userPublicKey;
66
101
  }
@@ -200,69 +235,76 @@ export class PhantomProvider extends EventEmitter implements Provider {
200
235
  const url = this.buildUrl('connect', connectDeeplinkParams as any);
201
236
 
202
237
  return new Promise<PhantomConnectResult>((resolve, reject) => {
203
- let subscription: { remove: () => void } | null = null;
204
238
  const handleDeepLink = async (event: { url: string }) => {
205
- if (subscription) {
206
- subscription.remove();
207
- }
208
- const fullUrl = event.url;
209
- if (fullUrl.startsWith(this.config.appScheme)) {
210
- const responseUrlParams = new URLSearchParams(
211
- fullUrl.substring(fullUrl.indexOf('?') + 1)
212
- );
213
- const errorCode = responseUrlParams.get('errorCode');
214
- const errorMessage = responseUrlParams.get('errorMessage');
215
- if (errorCode) {
216
- return reject(
217
- new Error(
218
- `Phantom Connection Failed: ${errorMessage || 'Unknown error'} (Code: ${errorCode})`
219
- )
239
+ try {
240
+ this.cleanupActiveSubscription();
241
+ const fullUrl = event.url;
242
+ if (fullUrl.startsWith(this.config.appScheme)) {
243
+ const responseUrlParams = new URLSearchParams(
244
+ fullUrl.substring(fullUrl.indexOf('?') + 1)
220
245
  );
221
- }
222
- const responsePayload: PhantomDeeplinkResponse = {
223
- phantom_encryption_public_key: responseUrlParams.get('phantom_encryption_public_key')!,
224
- nonce: responseUrlParams.get('nonce')!,
225
- data: responseUrlParams.get('data')!
226
- };
227
- if (
228
- !responsePayload.phantom_encryption_public_key ||
229
- !responsePayload.nonce ||
230
- !responsePayload.data
231
- ) {
232
- return reject(new Error('Phantom Connect: Invalid response - missing parameters.'));
233
- }
234
- const decryptedData = this.decryptPayload<DecryptedConnectData>(
235
- responsePayload.data,
236
- responsePayload.nonce,
237
- responsePayload.phantom_encryption_public_key
238
- );
239
- if (!decryptedData || !decryptedData.public_key || !decryptedData.session) {
240
- return reject(
241
- new Error('Phantom Connect: Failed to decrypt or invalid decrypted data.')
246
+ const errorCode = responseUrlParams.get('errorCode');
247
+ const errorMessage = responseUrlParams.get('errorMessage');
248
+ if (errorCode) {
249
+ return reject(
250
+ new Error(
251
+ `Phantom Connection Failed: ${
252
+ errorMessage || 'Unknown error'
253
+ } (Code: ${errorCode})`
254
+ )
255
+ );
256
+ }
257
+ const responsePayload: PhantomDeeplinkResponse = {
258
+ phantom_encryption_public_key: responseUrlParams.get(
259
+ 'phantom_encryption_public_key'
260
+ )!,
261
+ nonce: responseUrlParams.get('nonce')!,
262
+ data: responseUrlParams.get('data')!
263
+ };
264
+ if (
265
+ !responsePayload.phantom_encryption_public_key ||
266
+ !responsePayload.nonce ||
267
+ !responsePayload.data
268
+ ) {
269
+ return reject(new Error('Phantom Connect: Invalid response - missing parameters.'));
270
+ }
271
+ const decryptedData = this.decryptPayload<DecryptedConnectData>(
272
+ responsePayload.data,
273
+ responsePayload.nonce,
274
+ responsePayload.phantom_encryption_public_key
242
275
  );
276
+ if (!decryptedData || !decryptedData.public_key || !decryptedData.session) {
277
+ return reject(
278
+ new Error('Phantom Connect: Failed to decrypt or invalid decrypted data.')
279
+ );
280
+ }
281
+ this.userPublicKey = decryptedData.public_key;
282
+ this.sessionToken = decryptedData.session;
283
+ this.phantomEncryptionPublicKeyBs58 = responsePayload.phantom_encryption_public_key;
284
+
285
+ // Save session on successful connect
286
+ this.saveSession();
287
+
288
+ resolve({
289
+ userPublicKey: this.userPublicKey,
290
+ sessionToken: this.sessionToken,
291
+ phantomEncryptionPublicKeyBs58: this.phantomEncryptionPublicKeyBs58,
292
+ cluster
293
+ });
294
+ } else {
295
+ reject(new Error('Phantom Connect: Unexpected redirect URI.'));
243
296
  }
244
- this.userPublicKey = decryptedData.public_key;
245
- this.sessionToken = decryptedData.session;
246
- this.phantomEncryptionPublicKeyBs58 = responsePayload.phantom_encryption_public_key;
247
-
248
- // Save session on successful connect
249
- this.saveSession();
250
-
251
- resolve({
252
- userPublicKey: this.userPublicKey,
253
- sessionToken: this.sessionToken,
254
- phantomEncryptionPublicKeyBs58: this.phantomEncryptionPublicKeyBs58,
255
- cluster
256
- });
257
- } else {
258
- reject(new Error('Phantom Connect: Unexpected redirect URI.'));
297
+ } catch (error) {
298
+ this.cleanupActiveSubscription();
299
+ reject(error);
259
300
  }
260
301
  };
261
- subscription = Linking.addEventListener('url', handleDeepLink);
302
+
303
+ const subscription = Linking.addEventListener('url', handleDeepLink);
304
+ this.setActiveSubscription(subscription);
305
+
262
306
  Linking.openURL(url).catch(err => {
263
- if (subscription) {
264
- subscription.remove();
265
- }
307
+ this.cleanupActiveSubscription();
266
308
  reject(new Error(`Failed to open Phantom wallet: ${err.message}. Is it installed?`));
267
309
  });
268
310
  }) as Promise<T>;
@@ -299,24 +341,28 @@ export class PhantomProvider extends EventEmitter implements Provider {
299
341
  const url = this.buildUrl('disconnect', disconnectDeeplinkParams as any);
300
342
 
301
343
  return new Promise<void>((resolve, reject) => {
302
- let subscription: { remove: () => void } | null = null;
303
344
  const handleDeepLink = (event: { url: string }) => {
304
- if (subscription) {
305
- subscription.remove();
306
- }
307
- if (event.url.startsWith(this.config.appScheme)) {
308
- this.clearSession();
309
- resolve();
310
- } else {
345
+ try {
346
+ this.cleanupActiveSubscription();
347
+ if (event.url.startsWith(this.config.appScheme)) {
348
+ this.clearSession();
349
+ resolve();
350
+ } else {
351
+ this.clearSession();
352
+ reject(new Error('Phantom Disconnect: Unexpected redirect URI.'));
353
+ }
354
+ } catch (error) {
355
+ this.cleanupActiveSubscription();
311
356
  this.clearSession();
312
- reject(new Error('Phantom Disconnect: Unexpected redirect URI.'));
357
+ reject(error);
313
358
  }
314
359
  };
315
- subscription = Linking.addEventListener('url', handleDeepLink);
360
+
361
+ const subscription = Linking.addEventListener('url', handleDeepLink);
362
+ this.setActiveSubscription(subscription);
363
+
316
364
  Linking.openURL(url).catch(err => {
317
- if (subscription) {
318
- subscription.remove();
319
- }
365
+ this.cleanupActiveSubscription();
320
366
  this.clearSession();
321
367
  reject(new Error(`Failed to open Phantom for disconnection: ${err.message}.`));
322
368
  });
@@ -327,6 +373,7 @@ export class PhantomProvider extends EventEmitter implements Provider {
327
373
  this.sessionToken = null;
328
374
  this.userPublicKey = null;
329
375
  this.phantomEncryptionPublicKeyBs58 = null;
376
+ this.cleanupActiveSubscription();
330
377
  await this.clearSessionStorage();
331
378
  }
332
379
 
@@ -471,56 +518,59 @@ export class PhantomProvider extends EventEmitter implements Provider {
471
518
  }
472
519
 
473
520
  return new Promise<T>((resolve, reject) => {
474
- let subscription: { remove: () => void } | null = null;
475
521
  const handleDeepLink = async (event: { url: string }) => {
476
- if (subscription) {
477
- subscription.remove();
478
- }
479
- const fullUrl = event.url;
480
- if (fullUrl.startsWith(this.config.appScheme)) {
481
- const responseUrlParams = new URLSearchParams(
482
- fullUrl.substring(fullUrl.indexOf('?') + 1)
483
- );
484
- const errorCode = responseUrlParams.get('errorCode');
485
- const errorMessage = responseUrlParams.get('errorMessage');
486
- if (errorCode) {
487
- return reject(
488
- new Error(
489
- `Phantom ${signingMethod} Failed: ${
490
- errorMessage || 'Unknown error'
491
- } (Code: ${errorCode})`
492
- )
522
+ try {
523
+ this.cleanupActiveSubscription();
524
+ const fullUrl = event.url;
525
+ if (fullUrl.startsWith(this.config.appScheme)) {
526
+ const responseUrlParams = new URLSearchParams(
527
+ fullUrl.substring(fullUrl.indexOf('?') + 1)
493
528
  );
494
- }
495
- const responseNonce = responseUrlParams.get('nonce');
496
- const responseData = responseUrlParams.get('data');
497
- if (!responseNonce || !responseData) {
498
- return reject(
499
- new Error(`Phantom ${signingMethod}: Invalid response - missing nonce or data.`)
500
- );
501
- }
502
- const decryptedResult = this.decryptPayload<any>(
503
- responseData,
504
- responseNonce,
505
- this.phantomEncryptionPublicKeyBs58!
506
- );
507
- if (!decryptedResult) {
508
- return reject(
509
- new Error(
510
- `Phantom ${signingMethod}: Failed to decrypt response or invalid decrypted data.`
511
- )
529
+ const errorCode = responseUrlParams.get('errorCode');
530
+ const errorMessage = responseUrlParams.get('errorMessage');
531
+ if (errorCode) {
532
+ return reject(
533
+ new Error(
534
+ `Phantom ${signingMethod} Failed: ${
535
+ errorMessage || 'Unknown error'
536
+ } (Code: ${errorCode})`
537
+ )
538
+ );
539
+ }
540
+ const responseNonce = responseUrlParams.get('nonce');
541
+ const responseData = responseUrlParams.get('data');
542
+ if (!responseNonce || !responseData) {
543
+ return reject(
544
+ new Error(`Phantom ${signingMethod}: Invalid response - missing nonce or data.`)
545
+ );
546
+ }
547
+ const decryptedResult = this.decryptPayload<any>(
548
+ responseData,
549
+ responseNonce,
550
+ this.phantomEncryptionPublicKeyBs58!
512
551
  );
552
+ if (!decryptedResult) {
553
+ return reject(
554
+ new Error(
555
+ `Phantom ${signingMethod}: Failed to decrypt response or invalid decrypted data.`
556
+ )
557
+ );
558
+ }
559
+ resolve(decryptedResult as T);
560
+ } else {
561
+ reject(new Error(`Phantom ${signingMethod}: Unexpected redirect URI.`));
513
562
  }
514
- resolve(decryptedResult as T);
515
- } else {
516
- reject(new Error(`Phantom ${signingMethod}: Unexpected redirect URI.`));
563
+ } catch (error) {
564
+ this.cleanupActiveSubscription();
565
+ reject(error);
517
566
  }
518
567
  };
519
- subscription = Linking.addEventListener('url', handleDeepLink);
568
+
569
+ const subscription = Linking.addEventListener('url', handleDeepLink);
570
+ this.setActiveSubscription(subscription);
571
+
520
572
  Linking.openURL(deeplinkUrl).catch(err => {
521
- if (subscription) {
522
- subscription.remove();
523
- }
573
+ this.cleanupActiveSubscription();
524
574
  reject(
525
575
  new Error(`Failed to open Phantom for ${signingMethod}: ${err.message}. Is it installed?`)
526
576
  );