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