@solana/kit-plugin-wallet 0.0.0 → 0.12.0

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.
package/src/store.ts ADDED
@@ -0,0 +1,863 @@
1
+ import {
2
+ type SignatureBytes,
3
+ SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE,
4
+ SOLANA_ERROR__WALLET__NOT_CONNECTED,
5
+ SolanaError,
6
+ } from '@solana/kit';
7
+ import { createSignerFromWalletAccount } from '@solana/wallet-account-signer';
8
+ import {
9
+ SolanaSignIn,
10
+ type SolanaSignInFeature,
11
+ type SolanaSignInInput,
12
+ type SolanaSignInOutput,
13
+ SolanaSignMessage,
14
+ type SolanaSignMessageFeature,
15
+ } from '@solana/wallet-standard-features';
16
+ import { getWallets } from '@wallet-standard/app';
17
+ import {
18
+ StandardConnect,
19
+ type StandardConnectFeature,
20
+ StandardDisconnect,
21
+ type StandardDisconnectFeature,
22
+ StandardEvents,
23
+ type StandardEventsFeature,
24
+ } from '@wallet-standard/features';
25
+ import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui';
26
+ import { getWalletAccountFeature, getWalletFeature } from '@wallet-standard/ui-features';
27
+ import { getOrCreateUiWalletForStandardWallet, getWalletForHandle } from '@wallet-standard/ui-registry';
28
+
29
+ import type {
30
+ WalletActionOptions,
31
+ WalletNamespace,
32
+ WalletPluginConfig,
33
+ WalletSigner,
34
+ WalletState,
35
+ WalletStatus,
36
+ WalletStorage,
37
+ } from './types';
38
+
39
+ // -- Internal types ---------------------------------------------------------
40
+
41
+ /** @internal */
42
+ export type WalletStore = WalletNamespace & {
43
+ /**
44
+ * Registers a listener notified only when the wallet signer may
45
+ * have changed.
46
+ * Returns an idempotent unsubscribe function.
47
+ *
48
+ * @internal
49
+ */
50
+ subscribeSigner: (listener: () => void) => () => void;
51
+ [Symbol.dispose]: () => void;
52
+ };
53
+
54
+ // Flat internal state for easier updates
55
+ type WalletStoreState = {
56
+ account: UiWalletAccount | null;
57
+ connectedWallet: UiWallet | null;
58
+ signer: WalletSigner | null;
59
+ status: WalletStatus;
60
+ wallets: readonly UiWallet[];
61
+ };
62
+
63
+ // -- Store ------------------------------------------------------------------
64
+
65
+ /** @internal */
66
+ export function createWalletStore(config: WalletPluginConfig): WalletStore {
67
+ // -- SSR guard: on the server, return an inert stub ---------------------
68
+
69
+ if (!__BROWSER__ || __REACTNATIVE__) {
70
+ const ssrSnapshot: WalletState = Object.freeze({
71
+ connected: null,
72
+ status: 'pending' as const,
73
+ wallets: Object.freeze([]) as readonly UiWallet[],
74
+ });
75
+ return {
76
+ connect: () =>
77
+ Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' })),
78
+ disconnect: () => Promise.resolve(),
79
+ getState: () => ssrSnapshot,
80
+ selectAccount: () => {
81
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' });
82
+ },
83
+ signIn: () => Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' })),
84
+ signMessage: () =>
85
+ Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' })),
86
+ subscribe: () => () => {},
87
+ subscribeSigner: () => () => {},
88
+ [Symbol.dispose]: () => {},
89
+ };
90
+ }
91
+
92
+ // -- Browser-only initialization below this point ----------------------
93
+
94
+ let state: WalletStoreState = {
95
+ account: null,
96
+ connectedWallet: null,
97
+ signer: null,
98
+ status: 'pending',
99
+ wallets: [],
100
+ };
101
+
102
+ const listeners = new Set<() => void>();
103
+ const signerListeners = new Set<() => void>();
104
+ let walletEventsCleanup: (() => void) | null = null;
105
+ let reconnectCleanup: (() => void) | null = null;
106
+ let userHasSelected = false;
107
+ let connectGeneration = 0;
108
+ let disposed = false;
109
+ // Wallet that is in the process of disconnecting, stored to guard against re-connecting to it if a future connect fails
110
+ let disconnectingWalletName: string | null = null;
111
+
112
+ // Resolve storage: default to localStorage in browser, null to disable.
113
+ // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed
114
+ // iframes or when third-party storage is blocked, so the default resolution
115
+ // degrades to `null` rather than letting the whole plugin-install chain
116
+ // throw. This matches the best-effort persistence applied to reads/writes.
117
+ function resolveDefaultStorage(): WalletStorage | null {
118
+ try {
119
+ return localStorage;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? resolveDefaultStorage());
126
+ const storageKey = config.storageKey ?? 'kit-wallet';
127
+
128
+ // -- State management --------------------------------------------------
129
+
130
+ function deriveSnapshot(s: WalletStoreState): WalletState {
131
+ return Object.freeze({
132
+ connected:
133
+ s.connectedWallet && s.account
134
+ ? Object.freeze({
135
+ account: s.account,
136
+ signer: s.signer,
137
+ wallet: s.connectedWallet,
138
+ })
139
+ : null,
140
+ status: s.status,
141
+ wallets: s.wallets,
142
+ });
143
+ }
144
+
145
+ let snapshot: WalletState = deriveSnapshot(state);
146
+
147
+ // We set `client.{payer,identity}` to `getState().connected?.signer
148
+ // This is `undefined` if disconnected, `null` if connected to a read-only wallet,
149
+ // or the signer reference otherwise.
150
+ function observableSigner(s: WalletStoreState): WalletSigner | null | undefined {
151
+ return s.connectedWallet && s.account ? s.signer : undefined;
152
+ }
153
+
154
+ function updateState(updates: Partial<WalletStoreState>): void {
155
+ const prev = state;
156
+ state = { ...state, ...updates };
157
+
158
+ // Signer subscribers (`subscribeToPayer` / `subscribeToIdentity`) fire
159
+ // only when the observed signer changes.
160
+ const signerChanged = observableSigner(state) !== observableSigner(prev);
161
+
162
+ if (
163
+ state.connectedWallet !== prev.connectedWallet ||
164
+ state.account !== prev.account ||
165
+ state.status !== prev.status ||
166
+ state.signer !== prev.signer ||
167
+ state.wallets !== prev.wallets
168
+ ) {
169
+ snapshot = deriveSnapshot(state);
170
+ listeners.forEach(l => l());
171
+ }
172
+
173
+ if (signerChanged) {
174
+ signerListeners.forEach(l => l());
175
+ }
176
+ }
177
+
178
+ // -- Signer creation ---------------------------------------------------
179
+
180
+ function tryCreateSigner(account: UiWalletAccount): WalletSigner | null {
181
+ try {
182
+ // `createSignerFromWalletAccount` throws for chains the wallet
183
+ // doesn't support — which we catch below. Non-Solana chains
184
+ // therefore degrade to `signer: null`, matching the read-only
185
+ // wallet contract.
186
+ return createSignerFromWalletAccount(account, config.chain);
187
+ } catch {
188
+ // Wallet doesn't support signing (read-only / watch wallet, or a
189
+ // non-Solana chain).
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // -- Wallet discovery --------------------------------------------------
195
+
196
+ // `getWallets()` is a wallet-standard function that returns the `Wallets` API
197
+ // This includes a `get()` function to get the current list of wallets, and
198
+ // an `on()` function to subscribe to wallet registration/unregistration events.
199
+ const registry = getWallets();
200
+
201
+ function filterWallet(uiWallet: UiWallet): boolean {
202
+ const supportsChain = uiWallet.chains.includes(config.chain);
203
+ const supportsConnect = uiWallet.features.includes(StandardConnect);
204
+ if (!supportsChain || !supportsConnect) return false;
205
+ return config.filter ? config.filter(uiWallet) : true;
206
+ }
207
+
208
+ function buildWalletList(): readonly UiWallet[] {
209
+ return Object.freeze(registry.get().map(getOrCreateUiWalletForStandardWallet).filter(filterWallet));
210
+ }
211
+
212
+ // Rebuilds the filtered wallet list but returns the *existing* `state.wallets`
213
+ // reference when the contents are unchanged. `buildWalletList` always
214
+ // allocates a fresh frozen array, so without this a registry event for a
215
+ // wallet that gets filtered out (or any event that doesn't alter the visible
216
+ // set) would still produce a new snapshot and notify every listener — a
217
+ // spurious re-render. Wallets are compared by reference, in order: the
218
+ // wallet-ui registry hands back a stable UiWallet for a given underlying
219
+ // wallet until its accounts/features/chains change, so reference equality is
220
+ // a sound same-contents check here.
221
+ function reconcileWalletList(): readonly UiWallet[] {
222
+ const next = buildWalletList();
223
+ const prev = state.wallets;
224
+ if (next.length === prev.length && next.every((w, i) => w === prev[i])) {
225
+ return prev;
226
+ }
227
+ return next;
228
+ }
229
+
230
+ // True if a wallet matching `uiWallet` (by name) is currently registered and
231
+ // passes the filter. Used to re-check membership after an awaited connect /
232
+ // sign-in / silent reconnect resolves: the wallet may have unregistered (or
233
+ // dropped a required feature/chain) while its prompt was open, and the
234
+ // unregister handler can't tear down a connection that hasn't been
235
+ // established yet. Compared by name — the same identity the unregister
236
+ // handler uses — because the wallet-ui registry hands back a fresh UiWallet
237
+ // reference once connect adds accounts, so reference equality would not hold.
238
+ function isWalletStillAvailable(uiWallet: UiWallet): boolean {
239
+ return buildWalletList().some(w => w.name === uiWallet.name);
240
+ }
241
+
242
+ updateState({ wallets: buildWalletList() });
243
+
244
+ // Listen for new wallets being registered
245
+ const unsubRegister = registry.on('register', () => {
246
+ updateState({ wallets: reconcileWalletList() });
247
+ });
248
+
249
+ // Listen for wallets being unregistered — if the connected wallet is removed, disconnect
250
+ const unsubUnregister = registry.on('unregister', () => {
251
+ const newWallets = reconcileWalletList();
252
+ const updates: Partial<WalletStoreState> = { wallets: newWallets };
253
+
254
+ // If the connected wallet was unregistered, disconnect locally.
255
+ if (state.connectedWallet && !newWallets.some(w => w.name === state.connectedWallet!.name)) {
256
+ walletEventsCleanup?.();
257
+ walletEventsCleanup = null;
258
+ updates.connectedWallet = null;
259
+ updates.account = null;
260
+ updates.signer = null;
261
+ updates.status = 'disconnected';
262
+ clearPersistedAccount();
263
+ }
264
+
265
+ updateState(updates);
266
+ });
267
+
268
+ // -- Wallet-initiated events -------------------------------------------
269
+
270
+ function cancelReconnect(): void {
271
+ reconnectCleanup?.();
272
+ reconnectCleanup = null;
273
+ }
274
+
275
+ function resubscribeToWalletEvents(uiWallet: UiWallet): void {
276
+ walletEventsCleanup?.();
277
+ walletEventsCleanup = subscribeToWalletEvents(uiWallet);
278
+ }
279
+
280
+ function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void {
281
+ // The store may have been disposed while a connect / sign-in / silent
282
+ // reconnect awaited. The `connectGeneration` guard at each call site
283
+ // doesn't cover the auto-connect IIFE resuming from its storage read —
284
+ // it captures a fresh generation in `attemptSilentReconnect` after
285
+ // disposal. Bail here so a disposed store never re-subscribes to wallet
286
+ // events (a listener the already-run disposer can't clean up) or reports
287
+ // itself connected.
288
+ if (disposed) return;
289
+ const signer = tryCreateSigner(account);
290
+ resubscribeToWalletEvents(wallet);
291
+ disconnectingWalletName = null;
292
+ updateState({ account, connectedWallet: wallet, signer, status: 'connected' });
293
+ if (options?.persist !== false) {
294
+ persistAccount(account, wallet);
295
+ }
296
+ }
297
+
298
+ function refreshUiWallet(staleUiWallet: UiWallet): UiWallet {
299
+ const rawWallet = getWalletForHandle(staleUiWallet);
300
+ return getOrCreateUiWalletForStandardWallet(rawWallet);
301
+ }
302
+
303
+ function subscribeToWalletEvents(uiWallet: UiWallet): () => void {
304
+ if (!uiWallet.features.includes(StandardEvents)) {
305
+ return () => {};
306
+ }
307
+
308
+ const eventsFeature = getWalletFeature(
309
+ uiWallet,
310
+ StandardEvents,
311
+ ) as StandardEventsFeature[typeof StandardEvents];
312
+
313
+ // The handler ignores the changed-property flags (`accounts`, `chains`,
314
+ // `features`) and instead reconciles the active account against the
315
+ // refreshed wallet. The signer is derived entirely from the account's
316
+ // features and chains, and the wallet-ui registry regenerates the
317
+ // account reference precisely when one of those changes — so the
318
+ // account reference is the single source of truth for whether the
319
+ // signer is affected, regardless of which flag the wallet sets.
320
+ return eventsFeature.on('change', () => {
321
+ const refreshed = refreshUiWallet(uiWallet);
322
+
323
+ // If the wallet no longer passes the filter (e.g. it dropped the
324
+ // configured chain or a required feature), disconnect.
325
+ if (!filterWallet(refreshed)) {
326
+ disconnectLocally();
327
+ return;
328
+ }
329
+
330
+ const result = reconcileActiveAccount(refreshed);
331
+ if (result === null) {
332
+ disconnectLocally();
333
+ return;
334
+ }
335
+
336
+ // `connectedWallet` is always refreshed so consumers reading
337
+ // `getState().connected.wallet` see current metadata; the signer
338
+ // (in `result`) only churns when the active account changed, which
339
+ // keeps the snapshot referentially stable on no-op change events.
340
+ // `wallets` is reconciled too: the registry regenerates the wallet
341
+ // handle on a change, so without this the list entry would keep the
342
+ // stale handle (`wallets[i] !== connected.wallet`). `reconcileWalletList`
343
+ // returns the existing reference on a no-op, preserving that stability.
344
+ updateState({ connectedWallet: refreshed, wallets: reconcileWalletList(), ...result });
345
+
346
+ if (result.account) {
347
+ persistAccount(result.account, refreshed);
348
+ }
349
+ });
350
+ }
351
+
352
+ function reconcileActiveAccount(wallet: UiWallet): Partial<WalletStoreState> | null {
353
+ const newAccounts = wallet.accounts;
354
+
355
+ // No accounts left — the caller disconnects.
356
+ if (newAccounts.length === 0) return null;
357
+
358
+ const currentAddress = state.account?.address;
359
+ const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null;
360
+ const activeAccount = stillPresent ?? newAccounts[0];
361
+
362
+ // Same reference — nothing the signer depends on changed, so leave the
363
+ // signer untouched to preserve referential stability.
364
+ if (activeAccount === state.account) return {};
365
+
366
+ // The active account changed (switched, removed, or regenerated because
367
+ // its features/chains changed) — recreate the signer for it.
368
+ return { account: activeAccount, signer: tryCreateSigner(activeAccount) };
369
+ }
370
+
371
+ // -- Connection lifecycle ----------------------------------------------
372
+
373
+ async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise<readonly UiWalletAccount[]> {
374
+ options?.abortSignal?.throwIfAborted();
375
+
376
+ // Resolve the feature before mutating any state. getWalletFeature throws
377
+ // WalletStandardError synchronously when the wallet doesn't support
378
+ // standard:connect — doing this first means an unsupported wallet is a
379
+ // no-op that never tears down an existing connection or cancels a
380
+ // pending reconnect.
381
+ const connectFeature = getWalletFeature(
382
+ uiWallet,
383
+ StandardConnect,
384
+ ) as StandardConnectFeature[typeof StandardConnect];
385
+
386
+ userHasSelected = true;
387
+ cancelReconnect();
388
+ const generation = ++connectGeneration;
389
+ updateState({ status: 'connecting' });
390
+
391
+ try {
392
+ // Snapshot existing accounts before connect.
393
+ const existingAccounts = [...uiWallet.accounts];
394
+
395
+ await connectFeature.connect();
396
+
397
+ // A newer connect/signIn was started while we were awaiting. Reject
398
+ // with an `AbortError` so this superseded call settles in the
399
+ // standard, ignorable way (matching the abort-signal contract) while
400
+ // the newer request owns the connection. The `catch` below leaves
401
+ // that newer connection untouched, since `generation` no longer
402
+ // matches `connectGeneration`.
403
+ if (generation !== connectGeneration) {
404
+ throw new DOMException('Wallet connect was superseded by a newer connect or sign-in', 'AbortError');
405
+ }
406
+
407
+ // The wallet may have unregistered (or dropped a required
408
+ // feature/chain) while its connect prompt was open. The unregister
409
+ // handler only disconnects an already-connected wallet, so without
410
+ // this re-check the store would be left connected to a wallet absent
411
+ // from `wallets`. Throw so the connection failure surfaces as a
412
+ // rejection; the `catch` below reverts to any prior connection.
413
+ if (!isWalletStillAvailable(uiWallet)) {
414
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' });
415
+ }
416
+
417
+ // Refresh UiWallet to get updated accounts after connect.
418
+ const refreshedWallet = refreshUiWallet(uiWallet);
419
+ const allAccounts = refreshedWallet.accounts;
420
+
421
+ // No authorized accounts means there's nothing to connect to. Throw
422
+ // rather than resolving so a caller can't mistake an empty result for
423
+ // a successful connection; the `catch` reverts to any prior wallet.
424
+ if (allAccounts.length === 0) {
425
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' });
426
+ }
427
+
428
+ // Prefer the first newly authorized account.
429
+ const newAccount = allAccounts.find(a => !existingAccounts.some(e => e.address === a.address));
430
+ const activeAccount = newAccount ?? allAccounts[0];
431
+
432
+ setConnected(activeAccount, refreshedWallet);
433
+ return allAccounts;
434
+ } catch (error) {
435
+ // Only act if we're still the active connection attempt; a
436
+ // superseded attempt must leave the newer connection untouched.
437
+ if (generation === connectGeneration) {
438
+ revertToPreviousConnectionOrDisconnect();
439
+ }
440
+ throw error;
441
+ }
442
+ }
443
+
444
+ async function disconnect(options?: WalletActionOptions): Promise<void> {
445
+ options?.abortSignal?.throwIfAborted();
446
+
447
+ // No connected wallet, but an auto-reconnect may still be in flight:
448
+ // during 'reconnecting' a silent reconnect or the late-registration
449
+ // listener can complete and connect, overriding this explicit
450
+ // disconnect. Record the user's intent so auto-connect won't re-arm,
451
+ // tear down the late-registration listener, and bump the generation so
452
+ // any in-flight connect/signIn/silent-reconnect bails at its guard
453
+ // instead of resuming.
454
+ if (!state.connectedWallet) {
455
+ userHasSelected = true;
456
+ cancelReconnect();
457
+ connectGeneration++;
458
+ updateState({ status: 'disconnected' });
459
+ clearPersistedAccount();
460
+ return;
461
+ }
462
+
463
+ const currentWallet = state.connectedWallet;
464
+ // Bump the generation so this disconnect supersedes any connect/signIn
465
+ // that was already in flight (its prompt opened before the user chose to
466
+ // disconnect): that attempt captured an earlier generation, so it will
467
+ // bail at its guard rather than establishing a connection the user has
468
+ // since dismissed. Mirrors the not-connected branch above — newest action
469
+ // wins. A connect/signIn started *after* this disconnect bumps the
470
+ // generation again, so the `finally` guard skips `disconnectLocally` and
471
+ // leaves the newer connection intact.
472
+ const generation = ++connectGeneration;
473
+ // Mark this wallet as disconnecting
474
+ disconnectingWalletName = currentWallet.name;
475
+ updateState({ status: 'disconnecting' });
476
+
477
+ try {
478
+ if (currentWallet.features.includes(StandardDisconnect)) {
479
+ const disconnectFeature = getWalletFeature(
480
+ currentWallet,
481
+ StandardDisconnect,
482
+ ) as StandardDisconnectFeature[typeof StandardDisconnect];
483
+ await disconnectFeature.disconnect();
484
+ }
485
+ } finally {
486
+ // Only clean up if no new connect/signIn started while we were awaiting.
487
+ if (generation === connectGeneration) {
488
+ disconnectLocally();
489
+ }
490
+ }
491
+ }
492
+
493
+ function disconnectLocally(): void {
494
+ walletEventsCleanup?.();
495
+ walletEventsCleanup = null;
496
+ disconnectingWalletName = null;
497
+
498
+ updateState({
499
+ account: null,
500
+ connectedWallet: null,
501
+ signer: null,
502
+ status: 'disconnected',
503
+ // Reconcile the list too: when this runs from the change handler
504
+ // because the connected wallet dropped the configured chain or
505
+ // failed the filter, that wallet must leave `wallets` immediately
506
+ // rather than linger until an unrelated register/unregister event.
507
+ // `reconcileWalletList` returns the existing reference when the
508
+ // visible set is unchanged (the common case for a plain disconnect),
509
+ // so this stays churn-free.
510
+ wallets: reconcileWalletList(),
511
+ });
512
+
513
+ clearPersistedAccount();
514
+ }
515
+
516
+ // A `connect`/`signIn` attempt failed to establish a new connection. While
517
+ // such an attempt is in flight only `status` is moved to 'connecting' — the
518
+ // existing connection's account, signer, event subscription, and persisted
519
+ // key are untouched until `setConnected` runs on success. So if a prior
520
+ // connection is still intact, revert to it (declining the new wallet must not
521
+ // log the user out of the one they had). Otherwise there's nothing to keep,
522
+ // so disconnect cleanly. The active-attempt (`generation`) guard at each call
523
+ // site ensures a superseded attempt never reverts the connection a newer one
524
+ // owns.
525
+ function revertToPreviousConnectionOrDisconnect(): void {
526
+ // Don't revive a wallet with disconnect in progress
527
+ if (state.connectedWallet && state.account && state.connectedWallet.name !== disconnectingWalletName) {
528
+ updateState({ status: 'connected' });
529
+ } else {
530
+ disconnectLocally();
531
+ }
532
+ }
533
+
534
+ function selectAccount(account: UiWalletAccount): void {
535
+ if (!state.connectedWallet) {
536
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' });
537
+ }
538
+ // Reject if this wallet is disconnecting
539
+ if (state.connectedWallet.name === disconnectingWalletName) {
540
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' });
541
+ }
542
+ const refreshed = refreshUiWallet(state.connectedWallet);
543
+ const selectedAccount = refreshed.accounts.find(a => a.address === account.address);
544
+ if (!selectedAccount) {
545
+ throw new SolanaError(SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
546
+ account: account.address,
547
+ operation: 'selectAccount',
548
+ });
549
+ }
550
+ userHasSelected = true;
551
+ // Bump the generation so this selection supersedes any in-flight
552
+ // connect/signIn
553
+ ++connectGeneration;
554
+ // Change status back to `connected` if something in-flight changed it
555
+ // But skip recreating the signer if account is the same as before
556
+ if (selectedAccount === state.account && refreshed === state.connectedWallet) {
557
+ updateState({ status: 'connected' });
558
+ return;
559
+ }
560
+ const signer = tryCreateSigner(selectedAccount);
561
+ updateState({ account: selectedAccount, connectedWallet: refreshed, signer, status: 'connected' });
562
+ persistAccount(selectedAccount, refreshed);
563
+ }
564
+
565
+ // -- Message signing ---------------------------------------------------
566
+
567
+ async function signMessage(message: Uint8Array, options?: WalletActionOptions): Promise<SignatureBytes> {
568
+ options?.abortSignal?.throwIfAborted();
569
+ const { connectedWallet, account } = state;
570
+ if (!connectedWallet || !account) {
571
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' });
572
+ }
573
+ // Use the wallet feature directly rather than going through the cached
574
+ // signer. This decouples message signing from transaction signing —
575
+ // a wallet that supports solana:signMessage but not transaction signing
576
+ // still works. solana:signMessage is an account-scoped feature, so this
577
+ // resolves it against the active account: getWalletAccountFeature throws
578
+ // a WalletStandardError when either the wallet or the account does not
579
+ // support it.
580
+ const signMessageFeature = getWalletAccountFeature(
581
+ account,
582
+ SolanaSignMessage,
583
+ ) as SolanaSignMessageFeature[typeof SolanaSignMessage];
584
+ const [output] = await signMessageFeature.signMessage({ account, message });
585
+ return output.signature as SignatureBytes;
586
+ }
587
+
588
+ // -- Sign In With Solana (SIWS-as-connect) ------------------------------
589
+
590
+ async function signIn(
591
+ uiWallet: UiWallet,
592
+ input: SolanaSignInInput,
593
+ options?: WalletActionOptions,
594
+ ): Promise<SolanaSignInOutput> {
595
+ options?.abortSignal?.throwIfAborted();
596
+
597
+ // Resolve the feature before mutating any state. getWalletFeature throws
598
+ // WalletStandardError synchronously when the wallet doesn't support
599
+ // solana:signIn — doing this first means an unsupported wallet is a
600
+ // no-op that never tears down an existing connection.
601
+ const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn];
602
+
603
+ userHasSelected = true;
604
+ cancelReconnect();
605
+ const generation = ++connectGeneration;
606
+ updateState({ status: 'connecting' });
607
+
608
+ try {
609
+ const [result] = await signInFeature.signIn(input);
610
+
611
+ // A newer connect/signIn was started while we were awaiting. Reject
612
+ // with an `AbortError` (matching the abort-signal contract) so the
613
+ // superseded call settles in the standard, ignorable way while the
614
+ // newer request owns the connection.
615
+ if (generation !== connectGeneration) {
616
+ throw new DOMException('Wallet sign-in was superseded by a newer connect or sign-in', 'AbortError');
617
+ }
618
+
619
+ // The wallet may have unregistered (or dropped a required
620
+ // feature/chain) while its sign-in prompt was open. Throw rather than
621
+ // resolving while the store is left connected to no wallet (or a
622
+ // different one); the `catch` below reverts to any prior connection.
623
+ if (!isWalletStillAvailable(uiWallet)) {
624
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' });
625
+ }
626
+
627
+ // Set up full connection state using the account from the sign-in response.
628
+ const refreshedWallet = refreshUiWallet(uiWallet);
629
+ const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address);
630
+
631
+ if (!activeAccount) {
632
+ // The wallet signed in with an account it doesn't expose — a
633
+ // protocol violation that can't be mapped to a connection. Throw
634
+ // so the failure surfaces as a rejection rather than resolving
635
+ // with a sign-in result the store can't act on; the `catch`
636
+ // reverts to any prior connection.
637
+ throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' });
638
+ }
639
+
640
+ setConnected(activeAccount, refreshedWallet);
641
+ return result;
642
+ } catch (error) {
643
+ if (generation === connectGeneration) {
644
+ revertToPreviousConnectionOrDisconnect();
645
+ }
646
+ throw error;
647
+ }
648
+ }
649
+
650
+ // -- Persistence -------------------------------------------------------
651
+
652
+ function ignoreStorageFailure(operation: () => Promise<void> | void): void {
653
+ void (async () => {
654
+ try {
655
+ await operation();
656
+ } catch {
657
+ // Persistence is best-effort; wallet state is the source of truth.
658
+ }
659
+ })();
660
+ }
661
+
662
+ function persistAccount(account: UiWalletAccount, wallet: UiWallet): void {
663
+ if (!storage) return;
664
+ ignoreStorageFailure(() => storage.setItem(storageKey, `${wallet.name}:${account.address}`));
665
+ }
666
+
667
+ function clearPersistedAccount(): void {
668
+ if (!storage) return;
669
+ ignoreStorageFailure(() => storage.removeItem(storageKey));
670
+ }
671
+
672
+ // -- Auto-connect ------------------------------------------------------
673
+
674
+ if (config.autoConnect !== false && storage) {
675
+ const generation = connectGeneration;
676
+ (async () => {
677
+ const savedKey = await storage.getItem(storageKey);
678
+ // Don't auto-connect if the user has selected a wallet, or if the
679
+ // store was disposed while the storage read was in flight — the
680
+ // disposer has already torn down its listeners and bumped the
681
+ // generation, but this IIFE hasn't captured one yet, so without this
682
+ // check it would resume and silently reconnect into a disposed store.
683
+ if (userHasSelected || disposed) return;
684
+
685
+ if (!savedKey) {
686
+ updateState({ status: 'disconnected' });
687
+ return;
688
+ }
689
+
690
+ const separatorIndex = savedKey.lastIndexOf(':');
691
+ if (separatorIndex === -1) {
692
+ // Malformed saved key.
693
+ updateState({ status: 'disconnected' });
694
+ clearPersistedAccount();
695
+ return;
696
+ }
697
+
698
+ const walletName = savedKey.slice(0, separatorIndex);
699
+ const savedAddress = savedKey.slice(separatorIndex + 1);
700
+ const existing = state.wallets.find(w => w.name === walletName);
701
+
702
+ if (existing) {
703
+ await attemptSilentReconnect(savedAddress, existing);
704
+ } else if (
705
+ registry.get().some(w => {
706
+ const ui = getOrCreateUiWalletForStandardWallet(w);
707
+ return ui.name === walletName;
708
+ })
709
+ ) {
710
+ // Wallet registered but doesn't pass the filter.
711
+ updateState({ status: 'disconnected' });
712
+ clearPersistedAccount();
713
+ } else {
714
+ // Wallet not registered yet — wait for it to appear.
715
+ updateState({ status: 'reconnecting' });
716
+
717
+ // Change state to disconnected after 3s
718
+ // Note that the listener stays alive until reconnect is cancelled, so this is
719
+ // just affecting how long the UI shows reconnecting
720
+ const statusTimeout = setTimeout(() => {
721
+ if (!userHasSelected && state.status === 'reconnecting') {
722
+ updateState({ status: 'disconnected' });
723
+ }
724
+ }, 3000);
725
+
726
+ const unsubRegisterForReconnect = registry.on('register', () => {
727
+ const generation = connectGeneration;
728
+ void (async () => {
729
+ if (userHasSelected) {
730
+ clearTimeout(statusTimeout);
731
+ unsubRegisterForReconnect();
732
+ reconnectCleanup = null;
733
+ return;
734
+ }
735
+ const found = buildWalletList().find(w => w.name === walletName);
736
+ if (found) {
737
+ clearTimeout(statusTimeout);
738
+ unsubRegisterForReconnect();
739
+ reconnectCleanup = null;
740
+ await attemptSilentReconnect(savedAddress, found);
741
+ } else if (
742
+ registry.get().some(w => {
743
+ const ui = getOrCreateUiWalletForStandardWallet(w);
744
+ return ui.name === walletName;
745
+ })
746
+ ) {
747
+ // Wallet registered but filtered out.
748
+ clearTimeout(statusTimeout);
749
+ unsubRegisterForReconnect();
750
+ reconnectCleanup = null;
751
+ updateState({ status: 'disconnected' });
752
+ clearPersistedAccount();
753
+ }
754
+ })().catch(() => {
755
+ // Reconnect failed — fall back to disconnected.
756
+ if (generation === connectGeneration && !userHasSelected) {
757
+ updateState({ status: 'disconnected' });
758
+ }
759
+ });
760
+ });
761
+
762
+ reconnectCleanup = () => {
763
+ clearTimeout(statusTimeout);
764
+ unsubRegisterForReconnect();
765
+ };
766
+ }
767
+ })().catch(() => {
768
+ // Storage read failed — fall back to disconnected.
769
+ if (generation === connectGeneration && !userHasSelected) {
770
+ updateState({ status: 'disconnected' });
771
+ }
772
+ });
773
+ } else {
774
+ // No auto-connect: immediately transition from 'pending' to 'disconnected'.
775
+ updateState({ status: 'disconnected' });
776
+ }
777
+
778
+ async function attemptSilentReconnect(savedAddress: string, uiWallet: UiWallet): Promise<void> {
779
+ const generation = ++connectGeneration;
780
+ updateState({ status: 'reconnecting' });
781
+
782
+ try {
783
+ const connectFeature = getWalletFeature(
784
+ uiWallet,
785
+ StandardConnect,
786
+ ) as StandardConnectFeature[typeof StandardConnect];
787
+ await connectFeature.connect({ silent: true });
788
+
789
+ // A newer connect/signIn was started while we were awaiting — bail.
790
+ if (generation !== connectGeneration) return;
791
+
792
+ // The wallet may have unregistered (or dropped a required
793
+ // feature/chain) while the silent reconnect was in flight.
794
+ if (!isWalletStillAvailable(uiWallet)) {
795
+ updateState({ status: 'disconnected' });
796
+ clearPersistedAccount();
797
+ return;
798
+ }
799
+
800
+ const refreshedWallet = refreshUiWallet(uiWallet);
801
+ const allAccounts = refreshedWallet.accounts;
802
+
803
+ if (allAccounts.length === 0) {
804
+ updateState({ status: 'disconnected' });
805
+ clearPersistedAccount();
806
+ return;
807
+ }
808
+
809
+ // Restore the specific saved account, fall back to first from same wallet.
810
+ const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0];
811
+
812
+ // Persist only when we fell back to a different account
813
+ setConnected(activeAccount, refreshedWallet, {
814
+ persist: activeAccount.address !== savedAddress,
815
+ });
816
+ } catch {
817
+ if (generation === connectGeneration) {
818
+ updateState({ status: 'disconnected' });
819
+ clearPersistedAccount();
820
+ }
821
+ }
822
+ }
823
+
824
+ // -- Public API --------------------------------------------------------
825
+
826
+ return {
827
+ connect,
828
+ disconnect,
829
+ getState: () => snapshot,
830
+ selectAccount,
831
+ signIn,
832
+ signMessage,
833
+ subscribe: (listener: () => void) => {
834
+ listeners.add(listener);
835
+ return () => {
836
+ listeners.delete(listener);
837
+ };
838
+ },
839
+ subscribeSigner: (listener: () => void) => {
840
+ signerListeners.add(listener);
841
+ return () => {
842
+ signerListeners.delete(listener);
843
+ };
844
+ },
845
+ [Symbol.dispose]: () => {
846
+ // Invalidate any in-flight connect/signIn/silent-reconnect so they
847
+ // bail instead of resuming after disposal (which would re-subscribe
848
+ // to wallet events and re-arm cleanup that this disposer already
849
+ // ran). The generation bump covers attempts that already captured a
850
+ // generation; `disposed` covers the auto-connect IIFE, which resumes
851
+ // from its storage read and captures a fresh generation afterward.
852
+ disposed = true;
853
+ connectGeneration++;
854
+ unsubRegister();
855
+ unsubUnregister();
856
+ walletEventsCleanup?.();
857
+ walletEventsCleanup = null;
858
+ cancelReconnect();
859
+ listeners.clear();
860
+ signerListeners.clear();
861
+ },
862
+ };
863
+ }