From f4407da7ff39404b4596d9e496b20aeee1412f10 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 7 Apr 2026 15:19:20 +0000 Subject: [PATCH 01/36] Implement kit-plugin-wallet store, types, and tests Implements the wallet plugin's core store (createWalletStore) with wallet discovery, connection lifecycle, signer creation, and subscribable state management. Tests are split into store tests (state management, lifecycle) and wallet tests (plugin integration, payer getter). --- packages/kit-plugin-wallet/src/store.ts | 569 ++++++++++- packages/kit-plugin-wallet/src/types.ts | 3 +- packages/kit-plugin-wallet/test/_setup.ts | 223 +++++ packages/kit-plugin-wallet/test/store.test.ts | 884 ++++++++++++++++++ .../kit-plugin-wallet/test/wallet.test.ts | 93 ++ packages/kit-plugin-wallet/vitest.config.mts | 17 +- 6 files changed, 1782 insertions(+), 7 deletions(-) create mode 100644 packages/kit-plugin-wallet/test/_setup.ts create mode 100644 packages/kit-plugin-wallet/test/store.test.ts create mode 100644 packages/kit-plugin-wallet/test/wallet.test.ts diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 958912d..0413311 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -1,4 +1,38 @@ -import type { WalletNamespace, WalletPluginConfig } from './types'; +import type { SignatureBytes } from '@solana/kit'; +import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; +import { + SolanaSignIn, + type SolanaSignInFeature, + type SolanaSignInInput, + type SolanaSignInOutput, + SolanaSignMessage, + type SolanaSignMessageFeature, +} from '@solana/wallet-standard-features'; +import { getWallets } from '@wallet-standard/app'; +import { + StandardConnect, + type StandardConnectFeature, + StandardDisconnect, + type StandardDisconnectFeature, + StandardEvents, + type StandardEventsFeature, +} from '@wallet-standard/features'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { getWalletFeature } from '@wallet-standard/ui-features'; +import { + getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, +} from '@wallet-standard/ui-registry'; + +import type { + WalletNamespace, + WalletPluginConfig, + WalletSigner, + WalletState, + WalletStatus, + WalletStorage, +} from './types'; +import { WalletNotConnectedError } from './types'; // -- Internal types --------------------------------------------------------- @@ -6,9 +40,538 @@ export type WalletStore = WalletNamespace & { [Symbol.dispose]: () => void; }; +// Flat internal state for easier updates +type WalletStoreState = { + account: UiWalletAccount | null; + connectedWallet: UiWallet | null; + signer: WalletSigner | null; + status: WalletStatus; + wallets: readonly UiWallet[]; +}; + // -- Store ------------------------------------------------------------------ /** @internal */ -export function createWalletStore(_config: WalletPluginConfig): WalletStore { - throw new Error('not implemented'); +export function createWalletStore(config: WalletPluginConfig): WalletStore { + // -- SSR guard: on the server, return an inert stub --------------------- + + if (!__BROWSER__) { + const ssrSnapshot: WalletState = Object.freeze({ + connected: null, + status: 'pending' as const, + wallets: Object.freeze([]) as readonly UiWallet[], + }); + return { + connect: () => { + throw new WalletNotConnectedError('connect'); + }, + disconnect: () => Promise.resolve(), + getState: () => ssrSnapshot, + selectAccount: () => { + throw new WalletNotConnectedError('selectAccount'); + }, + signIn: () => { + throw new WalletNotConnectedError('signIn'); + }, + signMessage: () => { + throw new WalletNotConnectedError('signMessage'); + }, + subscribe: () => () => {}, + [Symbol.dispose]: () => {}, + }; + } + + // -- Browser-only initialization below this point ---------------------- + + let state: WalletStoreState = { + account: null, + connectedWallet: null, + signer: null, + status: 'pending', + wallets: [], + }; + + const listeners = new Set<() => void>(); + let walletEventsCleanup: (() => void) | null = null; + let reconnectCleanup: (() => void) | null = null; + let userHasSelected = false; + + // Resolve storage: default to localStorage in browser, null to disable. + const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? localStorage); + const storageKey = config.storageKey ?? 'kit-wallet'; + + // -- State management -------------------------------------------------- + + function deriveSnapshot(s: WalletStoreState): WalletState { + return Object.freeze({ + connected: + s.connectedWallet && s.account + ? Object.freeze({ + account: s.account, + signer: s.signer, + wallet: s.connectedWallet, + }) + : null, + status: s.status, + wallets: s.wallets, + }); + } + + let snapshot: WalletState = deriveSnapshot(state); + + function setState(updates: Partial): void { + const prev = state; + state = { ...state, ...updates }; + + // Only create a new snapshot when a snapshot-relevant field changed. + // This ensures referential stability for useSyncExternalStore. + if ( + state.wallets !== prev.wallets || + state.connectedWallet !== prev.connectedWallet || + state.account !== prev.account || + state.status !== prev.status || + state.signer !== prev.signer + ) { + snapshot = deriveSnapshot(state); + } + + listeners.forEach(l => l()); + } + + // -- Signer creation --------------------------------------------------- + + function tryCreateSigner(account: UiWalletAccount): WalletSigner | null { + try { + return createSignerFromWalletAccount(account, config.chain); + } catch { + // Wallet doesn't support signing (e.g. read-only / watch wallet). + return null; + } + } + + // -- Wallet discovery -------------------------------------------------- + + const registry = getWallets(); + + function filterWallet(uiWallet: UiWallet): boolean { + const supportsChain = uiWallet.chains.includes(config.chain); + const supportsConnect = uiWallet.features.includes(StandardConnect); + if (!supportsChain || !supportsConnect) return false; + return config.filter ? config.filter(uiWallet) : true; + } + + function buildWalletList(): readonly UiWallet[] { + return Object.freeze( + registry + .get() + .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED) + .filter(filterWallet), + ); + } + + setState({ wallets: buildWalletList() }); + + // Listen for new wallets being registered + const unsubRegister = registry.on('register', () => { + setState({ wallets: buildWalletList() }); + }); + + // Listen for wallets being unregistered — if the connected wallet is removed, disconnect + const unsubUnregister = registry.on('unregister', () => { + const newWallets = buildWalletList(); + const updates: Partial = { wallets: newWallets }; + + // If the connected wallet was unregistered, disconnect locally. + if (state.connectedWallet && !newWallets.some(w => w.name === state.connectedWallet!.name)) { + walletEventsCleanup?.(); + walletEventsCleanup = null; + updates.connectedWallet = null; + updates.account = null; + updates.signer = null; + updates.status = 'disconnected'; + void storage?.removeItem(storageKey); + } + + setState(updates); + }); + + // -- Wallet-initiated events ------------------------------------------- + + function cancelReconnect(): void { + reconnectCleanup?.(); + reconnectCleanup = null; + } + + function resubscribeToWalletEvents(uiWallet: UiWallet): void { + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); + } + + function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void { + const signer = tryCreateSigner(account); + resubscribeToWalletEvents(wallet); + setState({ account, connectedWallet: wallet, signer, status: 'connected' }); + if (options?.persist !== false) { + persistAccount(account, wallet); + } + } + + function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { + const rawWallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(staleUiWallet); + return getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(rawWallet); + } + + function subscribeToWalletEvents(uiWallet: UiWallet): () => void { + if (!uiWallet.features.includes(StandardEvents)) { + return () => {}; + } + + const eventsFeature = getWalletFeature( + uiWallet, + StandardEvents, + ) as StandardEventsFeature[typeof StandardEvents]; + + return eventsFeature.on('change', properties => { + const refreshed = refreshUiWallet(uiWallet); + + // If the wallet no longer passes the filter, disconnect. + if (!filterWallet(refreshed)) { + disconnectLocally(); + return; + } + + let updates: Partial = { connectedWallet: refreshed }; + + if (properties.accounts) { + const result = handleAccountsChanged(refreshed); + if (result === null) { + disconnectLocally(); + return; + } + updates = { ...updates, ...result }; + } + if (properties.features) { + updates = { ...updates, ...handleFeaturesChanged() }; + } + + setState(updates); + + if (updates.account) { + persistAccount(updates.account, refreshed); + } + }); + } + + function handleAccountsChanged(wallet: UiWallet): Partial | null { + const newAccounts = wallet.accounts; + + if (newAccounts.length === 0) return null; + + const currentAddress = state.account?.address; + const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; + const activeAccount = stillPresent ?? newAccounts[0]; + + // Same reference — nothing changed for this account. + if (activeAccount === state.account) return {}; + + // Account changed (new account, or same address with updated features). + return { account: activeAccount, signer: tryCreateSigner(activeAccount) }; + } + + function handleFeaturesChanged(): Partial { + // Features changed but wallet is still valid — recreate signer + // to pick up new capabilities or drop removed ones. + if (state.account) { + return { signer: tryCreateSigner(state.account) }; + } + return {}; + } + + // -- Connection lifecycle ---------------------------------------------- + + async function connect(uiWallet: UiWallet): Promise { + userHasSelected = true; + cancelReconnect(); + setState({ status: 'connecting' }); + + try { + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect, + ) as StandardConnectFeature[typeof StandardConnect]; + + // Snapshot existing accounts before connect. + const existingAccounts = [...uiWallet.accounts]; + + await connectFeature.connect(); + + // Refresh UiWallet to get updated accounts after connect. + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; + + if (allAccounts.length === 0) { + // New wallet has no accounts — disconnect + disconnectLocally(); + return allAccounts; + } + + // Prefer the first newly authorized account. + const newAccount = allAccounts.find(a => !existingAccounts.some(e => e.address === a.address)); + const activeAccount = newAccount ?? allAccounts[0]; + + setConnected(activeAccount, refreshedWallet); + return allAccounts; + } catch (error) { + disconnectLocally(); + throw error; + } + } + + async function disconnect(): Promise { + if (!state.connectedWallet) return; + + const currentWallet = state.connectedWallet; + setState({ status: 'disconnecting' }); + + try { + if (currentWallet && currentWallet.features.includes(StandardDisconnect)) { + const disconnectFeature = getWalletFeature( + currentWallet, + StandardDisconnect, + ) as StandardDisconnectFeature[typeof StandardDisconnect]; + await disconnectFeature.disconnect(); + } + } finally { + disconnectLocally(); + } + } + + function disconnectLocally(): void { + walletEventsCleanup?.(); + walletEventsCleanup = null; + + setState({ + account: null, + connectedWallet: null, + signer: null, + status: 'disconnected', + }); + + void storage?.removeItem(storageKey); + } + + function selectAccount(account: UiWalletAccount): void { + if (!state.connectedWallet) { + throw new WalletNotConnectedError('selectAccount'); + } + userHasSelected = true; + const signer = tryCreateSigner(account); + setState({ account, signer }); + persistAccount(account, state.connectedWallet); + } + + // -- Message signing --------------------------------------------------- + + async function signMessage(message: Uint8Array): Promise { + const { connectedWallet, account } = state; + if (!connectedWallet || !account) { + throw new WalletNotConnectedError('signMessage'); + } + // Use the wallet feature directly rather than going through the cached + // signer. This decouples message signing from transaction signing — + // a wallet that supports solana:signMessage but not transaction signing + // still works. getWalletFeature throws WalletStandardError if the + // feature is not supported. + const signMessageFeature = getWalletFeature( + connectedWallet, + SolanaSignMessage, + ) as SolanaSignMessageFeature[typeof SolanaSignMessage]; + const [output] = await signMessageFeature.signMessage({ account, message }); + return output.signature as SignatureBytes; + } + + // -- Sign In With Solana (SIWS-as-connect) ------------------------------ + + async function signIn(uiWallet: UiWallet, input: SolanaSignInInput): Promise { + userHasSelected = true; + cancelReconnect(); + + // getWalletFeature throws WalletStandardError if not supported. + const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; + const [result] = await signInFeature.signIn(input); + + // Set up full connection state using the account from the sign-in response. + const refreshedWallet = refreshUiWallet(uiWallet); + const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); + + if (!activeAccount) { + // The signed-in account isn't in the wallet — bad state, disconnect + // so the user can try a fresh connect/sign-in. + disconnectLocally(); + return result; + } + + setConnected(activeAccount, refreshedWallet); + return result; + } + + // -- Persistence ------------------------------------------------------- + + function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { + void storage?.setItem(storageKey, `${wallet.name}:${account.address}`); + } + + // -- Auto-connect ------------------------------------------------------ + + if (config.autoConnect !== false && storage) { + (async () => { + const savedKey = await storage.getItem(storageKey); + // Don't auto-connect if the user has selected a wallet + if (userHasSelected) return; + + if (!savedKey) { + setState({ status: 'disconnected' }); + return; + } + + const separatorIndex = savedKey.lastIndexOf(':'); + if (separatorIndex === -1) { + // Malformed saved key. + setState({ status: 'disconnected' }); + await storage.removeItem(storageKey); + return; + } + + const walletName = savedKey.slice(0, separatorIndex); + const savedAddress = savedKey.slice(separatorIndex + 1); + const existing = state.wallets.find(w => w.name === walletName); + + if (existing) { + await attemptSilentReconnect(savedAddress, existing); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + return ui.name === walletName; + }) + ) { + // Wallet registered but doesn't pass the filter. + setState({ status: 'disconnected' }); + await storage.removeItem(storageKey); + } else { + // Wallet not registered yet — wait for it to appear. + setState({ status: 'reconnecting' }); + + // Change state to disconnected after 3s + // Note that the listener stays alive until reconnect is cancelled, so this is + // just affecting how long the UI shows reconnecting + const statusTimeout = setTimeout(() => { + if (!userHasSelected && state.status === 'reconnecting') { + setState({ status: 'disconnected' }); + } + }, 3000); + + const unsubRegisterForReconnect = registry.on( + 'register', + () => + void (async () => { + if (userHasSelected) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + return; + } + const found = buildWalletList().find(w => w.name === walletName); + if (found) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + await attemptSilentReconnect(savedAddress, found); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + return ui.name === walletName; + }) + ) { + // Wallet registered but filtered out. + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + setState({ status: 'disconnected' }); + await storage.removeItem(storageKey); + } + })(), + ); + + reconnectCleanup = () => { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + }; + } + })().catch(() => { + // Storage read failed — fall back to disconnected. + if (!userHasSelected) { + setState({ status: 'disconnected' }); + } + }); + } else { + // No auto-connect: immediately transition from 'pending' to 'disconnected'. + setState({ status: 'disconnected' }); + } + + async function attemptSilentReconnect(savedAddress: string, uiWallet: UiWallet): Promise { + setState({ status: 'reconnecting' }); + + try { + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect, + ) as StandardConnectFeature[typeof StandardConnect]; + await connectFeature.connect({ silent: true }); + + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; + + if (allAccounts.length === 0) { + setState({ status: 'disconnected' }); + await storage?.removeItem(storageKey); + return; + } + + // Check again: user may have connected manually while we were awaiting. + if (userHasSelected) return; + + // Restore the specific saved account, fall back to first from same wallet. + const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0]; + + setConnected(activeAccount, refreshedWallet, { persist: false }); + } catch { + setState({ status: 'disconnected' }); + await storage?.removeItem(storageKey); + } + } + + // -- Public API -------------------------------------------------------- + + return { + connect, + disconnect, + getState: () => snapshot, + selectAccount, + signIn, + signMessage, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + [Symbol.dispose]: () => { + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + cancelReconnect(); + listeners.clear(); + }, + }; } diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 290e98d..d8a2404 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -292,8 +292,9 @@ export type WalletNamespace = { * * @see {@link walletSigner} * @see {@link WalletNamespace} + * + * @note this is not part of Kit plugin-interfaces, as it depends on wallet-standard types */ -// TODO: would be moved to kit plugin-interfaces export type ClientWithWallet = { /** The wallet namespace — state, actions, and framework integration. */ readonly wallet: WalletNamespace; diff --git a/packages/kit-plugin-wallet/test/_setup.ts b/packages/kit-plugin-wallet/test/_setup.ts new file mode 100644 index 0000000..3ebdba8 --- /dev/null +++ b/packages/kit-plugin-wallet/test/_setup.ts @@ -0,0 +1,223 @@ +import { address } from '@solana/kit'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { afterEach, beforeEach, vi } from 'vitest'; + +import type { WalletStorage } from '../src/types'; + +// -- Mock helpers ----------------------------------------------------------- + +export function createMockAccount(addr = '11111111111111111111111111111111'): UiWalletAccount { + return { + address: address(addr), + chains: ['solana:mainnet'] as const, + features: ['solana:signTransaction'] as const, + icon: 'data:image/png;base64,', + label: 'Account 1', + publicKey: new Uint8Array(32), + } as unknown as UiWalletAccount; +} + +type MockWalletOptions = { + accounts?: UiWalletAccount[]; + chains?: string[]; + features?: string[]; + name?: string; +}; + +export function createMockUiWallet(opts: MockWalletOptions = {}): UiWallet { + return { + accounts: opts.accounts ?? [], + chains: opts.chains ?? ['solana:mainnet'], + features: opts.features ?? ['standard:connect', 'standard:events'], + icon: 'data:image/png;base64,', + name: opts.name ?? 'MockWallet', + version: '1.0.0', + } as unknown as UiWallet; +} + +export function createMockStorage(initial?: Record): WalletStorage { + const data = new Map(initial ? Object.entries(initial) : []); + return { + getItem: (key: string) => data.get(key) ?? null, + removeItem: (key: string) => { + data.delete(key); + }, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + }; +} + +// -- Mocks ------------------------------------------------------------------ + +// Registry mock: tracks registered wallets and event handlers. +export const registeredWallets: ReturnType[] = []; +export const registryListeners: Record void>> = { + register: [], + unregister: [], +}; + +function createMockRawWallet(uiWallet: UiWallet) { + return { + accounts: [...uiWallet.accounts], + chains: [...uiWallet.chains], + features: Object.fromEntries(uiWallet.features.map(f => [f, { version: '1.0.0' }])), + icon: uiWallet.icon, + name: uiWallet.name, + version: uiWallet.version, + }; +} + +vi.mock('@wallet-standard/app', () => ({ + getWallets: () => ({ + get: () => registeredWallets, + on: (event: string, listener: (...args: unknown[]) => void) => { + registryListeners[event]?.push(listener); + // Return an unsubscribe function that removes this listener. + return () => { + const list = registryListeners[event]; + if (list) { + const idx = list.indexOf(listener); + if (idx >= 0) list.splice(idx, 1); + } + }; + }, + }), +})); + +// Map raw wallets to UiWallets. refreshUiWallet round-trips through this: +// getWalletForHandle returns the raw wallet, getOrCreateUiWallet maps it +// back to the latest UiWallet. Calling registerWallet with the same name +// updates the mapping, so refreshUiWallet returns the updated wallet. +const rawToUi = new Map(); +const nameToRaw = new Map(); + +vi.mock('@wallet-standard/ui-registry', () => ({ + getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: (raw: object) => { + const ui = rawToUi.get(raw); + if (!ui) throw new Error('No UiWallet registered for this raw wallet'); + return ui; + }, + getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: (ui: UiWallet) => { + // Return the latest raw wallet for this name, so refreshUiWallet + // picks up updated registrations. + const raw = nameToRaw.get(ui.name); + if (!raw) throw new Error(`No raw wallet registered for "${ui.name}"`); + return raw; + }, +})); + +// Track the connect/disconnect/events mocks per wallet. +export let connectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +export let disconnectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +export let signInMock: ReturnType = vi.fn().mockResolvedValue([{}]); +export let signMessageMock: ReturnType = vi.fn().mockResolvedValue([{ signature: new Uint8Array(64) }]); +export let eventListenerCleanup: ReturnType = vi.fn(); + +// Captured wallet event handler — tests call this to simulate wallet change events. +export let walletEventHandler: ((properties: Record) => void) | null = null; + +type IdentifierString = `${string}:${string}`; + +vi.mock('@wallet-standard/ui-features', () => ({ + getWalletFeature: (wallet: UiWallet, feature: IdentifierString) => { + if (!wallet.features.includes(feature)) { + throw new Error(`Wallet "${wallet.name}" does not support ${feature}`); + } + if (feature === 'standard:connect') { + return { connect: connectMock, version: '1.0.0' }; + } + if (feature === 'standard:disconnect') { + return { disconnect: disconnectMock, version: '1.0.0' }; + } + if (feature === 'standard:events') { + return { + on: (_event: string, handler: (properties: Record) => void) => { + walletEventHandler = handler; + return eventListenerCleanup; + }, + version: '1.0.0', + }; + } + if (feature === 'solana:signIn') { + return { signIn: signInMock, version: '1.0.0' }; + } + if (feature === 'solana:signMessage') { + return { signMessage: signMessageMock, version: '1.0.0' }; + } + throw new Error(`Feature ${feature} not supported`); + }, +})); + +// Signer mock. +export const mockSignerAddress = address('11111111111111111111111111111111'); +export const mockSigner: { address: typeof mockSignerAddress; modifyAndSignTransactions: ReturnType } = { + address: mockSignerAddress, + modifyAndSignTransactions: vi.fn(), +}; + +export let createSignerMock = vi.fn<(...args: unknown[]) => unknown>().mockReturnValue(mockSigner); + +vi.mock('@solana/wallet-account-signer', () => ({ + createSignerFromWalletAccount: (...args: unknown[]) => createSignerMock(...args), +})); + +// -- Helpers ---------------------------------------------------------------- + +export function registerWallet(uiWallet: UiWallet): void { + const raw = createMockRawWallet(uiWallet); + rawToUi.set(raw, uiWallet); + nameToRaw.set(uiWallet.name, raw); + registeredWallets.push(raw); +} + +/** Update an already-registered wallet's UiWallet mapping (for event handler tests). */ +export function updateRegisteredWallet(uiWallet: UiWallet): void { + const raw = nameToRaw.get(uiWallet.name); + if (raw) { + rawToUi.set(raw, uiWallet); + } +} + +/** Simulate a wallet registering after store creation (for late-registration tests). */ +export function lateRegisterWallet(uiWallet: UiWallet): void { + registerWallet(uiWallet); + registryListeners.register.forEach(l => l()); +} + +/** Simulate a wallet being unregistered (removed from registry). */ +export function unregisterWallet(uiWallet: UiWallet): void { + const raw = nameToRaw.get(uiWallet.name); + if (raw) { + const idx = registeredWallets.indexOf(raw as (typeof registeredWallets)[number]); + if (idx >= 0) registeredWallets.splice(idx, 1); + rawToUi.delete(raw); + nameToRaw.delete(uiWallet.name); + } + registryListeners.unregister.forEach(l => l()); +} + +function clearRegistry(): void { + registeredWallets.length = 0; + rawToUi.clear(); + nameToRaw.clear(); + registryListeners.register = []; + registryListeners.unregister = []; +} + +// -- Lifecycle -------------------------------------------------------------- + +beforeEach(() => { + clearRegistry(); + connectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + disconnectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + signInMock = vi.fn().mockResolvedValue([{}]); + signMessageMock = vi.fn().mockResolvedValue([{ signature: new Uint8Array(64) }]); + createSignerMock = vi.fn<(...args: unknown[]) => unknown>().mockReturnValue(mockSigner); + eventListenerCleanup = vi.fn(); + walletEventHandler = null; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts new file mode 100644 index 0000000..2f584a2 --- /dev/null +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -0,0 +1,884 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createWalletStore } from '../src/store'; +import type { WalletStorage } from '../src/types'; +import { WalletNotConnectedError } from '../src/types'; +import { + connectMock, + createMockAccount, + createMockStorage, + createMockUiWallet, + createSignerMock, + disconnectMock, + lateRegisterWallet, + registerWallet, + registryListeners, + signInMock, + signMessageMock, + unregisterWallet, + updateRegisteredWallet, + walletEventHandler, +} from './_setup'; + +describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { + it('returns pending status on the server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(store.getState().status).toBe('pending'); + expect(store.getState().wallets).toEqual([]); + expect(store.getState().connected).toBeNull(); + }); + + it('throws WalletNotConnectedError for connect on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const mockWallet = createMockUiWallet(); + expect(() => store.connect(mockWallet)).toThrow(WalletNotConnectedError); + }); + + it('disconnect is a no-op on server', async () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + await expect(store.disconnect()).resolves.toBeUndefined(); + }); + + it('throws WalletNotConnectedError for signMessage on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(() => store.signMessage(new Uint8Array())).toThrow(WalletNotConnectedError); + }); + + it('throws WalletNotConnectedError for signIn on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const mockWallet = createMockUiWallet(); + expect(() => store.signIn(mockWallet, {})).toThrow(WalletNotConnectedError); + }); + + it('subscribe returns unsubscribe function on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const unsub = store.subscribe(() => {}); + expect(typeof unsub).toBe('function'); + }); + + it('dispose is a noop on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(() => store[Symbol.dispose]()).not.toThrow(); + }); +}); + +describe.skipIf(!__BROWSER__)('store (browser)', () => { + it('starts in disconnected status when no storage/autoConnect', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().status).toBe('disconnected'); + }); + + it('discovers registered wallets', () => { + const mockWallet = createMockUiWallet({ name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().wallets.length).toBe(1); + }); + + it('filters wallets by chain', () => { + const wrongChain = createMockUiWallet({ chains: ['solana:devnet'], name: 'DevnetWallet' }); + registerWallet(wrongChain); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().wallets.length).toBe(0); + }); + + it('applies custom filter', () => { + const w1 = createMockUiWallet({ name: 'Allowed' }); + const w2 = createMockUiWallet({ name: 'Blocked' }); + registerWallet(w1); + registerWallet(w2); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.name === 'Allowed', + storage: null, + }); + expect(store.getState().wallets.length).toBe(1); + expect(store.getState().wallets[0].name).toBe('Allowed'); + }); + + it('disconnects when connected wallet is unregistered', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + unregisterWallet(mockWallet); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + expect(state.wallets.length).toBe(0); + }); + + it('connects to a wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const accounts = await store.connect(mockWallet); + + expect(accounts.length).toBe(1); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected).not.toBeNull(); + expect(state.connected!.account.address).toBe(account.address); + expect(connectMock).toHaveBeenCalledOnce(); + }); + + it('prefers newly authorized account on connect', async () => { + const existingAccount = createMockAccount(); + const newAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + + // Wallet starts with one account visible before connect. + const mockWallet = createMockUiWallet({ + accounts: [existingAccount], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // After connect, the wallet gains a new account. + connectMock.mockImplementationOnce(() => { + updateRegisteredWallet( + createMockUiWallet({ + accounts: [existingAccount, newAccount], + name: 'TestWallet', + }), + ); + return Promise.resolve(); + }); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + // Should select the newly authorized account, not the pre-existing one. + expect(store.getState().connected!.account.address).toBe(newAccount.address); + }); + + it('transitions through connecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + let resolveConnect!: () => void; + connectMock.mockReturnValueOnce( + new Promise(r => { + resolveConnect = r; + }), + ); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const connectPromise = store.connect(mockWallet); + + expect(store.getState().status).toBe('connecting'); + + resolveConnect(); + await connectPromise; + + expect(store.getState().status).toBe('connected'); + }); + + it('disconnects from a wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + await store.disconnect(); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + expect(disconnectMock).toHaveBeenCalledOnce(); + }); + + it('transitions through disconnecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + let resolveDisconnect!: () => void; + disconnectMock.mockReturnValueOnce( + new Promise(r => { + resolveDisconnect = r; + }), + ); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const disconnectPromise = store.disconnect(); + + expect(store.getState().status).toBe('disconnecting'); + + resolveDisconnect(); + await disconnectPromise; + + expect(store.getState().status).toBe('disconnected'); + }); + + it('disconnect is a no-op when not connected', async () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(disconnectMock).not.toHaveBeenCalled(); + }); + + it('notifies subscribers on state change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const listener = vi.fn(); + store.subscribe(listener); + + // selectAccount is a single, synchronous state change. + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + store.selectAccount(account2); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('unsubscribe stops notifications', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const listener = vi.fn(); + const unsub = store.subscribe(listener); + unsub(); + + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + store.selectAccount(account2); + expect(listener).not.toHaveBeenCalled(); + }); + + it('selectAccount throws when not connected', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const account = createMockAccount(); + expect(() => store.selectAccount(account)).toThrow(WalletNotConnectedError); + }); + + it('selectAccount switches accounts', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().connected!.account.address).toBe(account1.address); + + store.selectAccount(account2); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('signMessage throws when not connected', async () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(WalletNotConnectedError); + }); + + it('signMessage calls wallet feature directly when connected', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const expectedSignature = new Uint8Array(64).fill(42); + signMessageMock.mockResolvedValueOnce([{ signature: expectedSignature }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const message = new Uint8Array([1, 2, 3]); + const result = await store.signMessage(message); + + expect(signMessageMock).toHaveBeenCalledExactlyOnceWith({ account, message }); + expect(result).toBe(expectedSignature); + }); + + it('signMessage throws when wallet does not support solana:signMessage', async () => { + const account = createMockAccount(); + // Wallet has standard:connect and standard:events but NOT solana:signMessage. + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'NoSignMessage', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(); + }); + + it('signIn throws when wallet does not support solana:signIn', async () => { + const mockWallet = createMockUiWallet({ name: 'NoSignIn' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + // mockWallet does not include 'solana:signIn' in features, + // so getWalletFeature will throw. + await expect(store.signIn(mockWallet, {})).rejects.toThrow(); + }); + + it('signIn connects via SIWS and sets up connection state', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + signInMock.mockResolvedValueOnce([{ account: { address: account.address } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const result = await store.signIn(mockWallet, { domain: 'example.com' }); + + expect(signInMock).toHaveBeenCalledWith({ domain: 'example.com' }); + expect(result).toEqual({ account: { address: account.address } }); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected).not.toBeNull(); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('signIn disconnects when signed-in account is not in wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // Return an account address that doesn't match any wallet account. + signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const result = await store.signIn(mockWallet, {}); + + // Should still return the result, but not establish connection. + expect(result).toEqual({ account: { address: 'nonexistent' } }); + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('reverts to disconnected if connect is rejected', async () => { + const mockWallet = createMockUiWallet({ name: 'TestWallet' }); + registerWallet(mockWallet); + connectMock.mockRejectedValueOnce(new Error('User rejected')); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.connect(mockWallet)).rejects.toThrow('User rejected'); + expect(store.getState().status).toBe('disconnected'); + }); + + it('clears previous connection when connect fails', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + // Previous connection should be fully cleared. + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('clears previous connection when new wallet returns zero accounts', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + await store.connect(wallet2); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('getState returns referentially stable snapshots', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const snap1 = store.getState(); + const snap2 = store.getState(); + expect(snap1).toBe(snap2); + }); + + it('cleanup via Symbol.dispose removes registry listeners', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(registryListeners.register.length).toBeGreaterThan(0); + + store[Symbol.dispose](); + expect(registryListeners.register.length).toBe(0); + expect(registryListeners.unregister.length).toBe(0); + }); +}); + +// -- Wallet event handler tests ----------------------------------------------- + +describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { + async function connectToWallet( + store: ReturnType, + wallet: ReturnType, + ) { + await store.connect(wallet); + expect(walletEventHandler).not.toBeNull(); + } + + it('disconnects when wallet no longer passes filter after feature change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signTransaction'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.features.includes('solana:signTransaction'), + storage: null, + }); + await connectToWallet(store, mockWallet); + + // Wallet drops signTransaction — no longer passes filter. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('disconnects when wallet drops configured chain', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Wallet drops mainnet support. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + chains: ['solana:devnet'], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ chains: true }); + + expect(store.getState().status).toBe('disconnected'); + }); + + it('switches to first account when current account is removed', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + expect(store.getState().connected!.account.address).toBe(account1.address); + + // Wallet removes account1, only account2 remains. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account2], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account2.address); + }); + + it('skips update when current account reference is unchanged', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + const snapshotBefore = store.getState(); + + // Trigger accounts change but with same account reference. + walletEventHandler!({ accounts: true }); + + // Snapshot should be stable — no unnecessary state change. + expect(store.getState()).toBe(snapshotBefore); + }); + + it('disconnects when all accounts are removed', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Wallet removes all accounts. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('stays connected and has signer after features change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Trigger feature change — store should stay connected with a valid signer. + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.signer).toBeDefined(); + }); + + it('signer becomes null after features change when signing is dropped', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + expect(store.getState().connected!.signer).not.toBeNull(); + + // Wallet drops signing support — bridge function throws. + createSignerMock.mockImplementation(() => { + throw new Error('No signing features'); + }); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.signer).toBeNull(); + }); +}); + +// -- Auto-connect tests ------------------------------------------------------- + +describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('auto-connects when storage has a saved wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + expect(connectMock).toHaveBeenCalledWith({ silent: true }); + }); + + it('transitions to disconnected when storage is empty', async () => { + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + }); + + it('clears storage and disconnects for malformed saved key', async () => { + const storage = createMockStorage({ 'kit-wallet': 'no-separator' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('does not auto-connect when autoConnect is false', () => { + const storage = createMockStorage({ 'kit-wallet': 'TestWallet:abc' }); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + + // Should immediately be disconnected, no async. + expect(store.getState().status).toBe('disconnected'); + }); + + it('userHasSelected prevents auto-connect from overriding', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'SavedWallet' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'UserWallet' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${account1.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // User connects before auto-connect resolves. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + await vi.advanceTimersByTimeAsync(0); + + // Auto-connect should not override the user's choice. + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('persists account on connect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + + expect(storage.getItem('kit-wallet')).toBe(`TestWallet:${account.address}`); + }); + + it('clears storage on disconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + expect(storage.getItem('kit-wallet')).not.toBeNull(); + + await store.disconnect(); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('falls back to disconnected when silent reconnect fails', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + connectMock.mockRejectedValueOnce(new Error('Silent connect failed')); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('falls back to disconnected when storage read rejects', async () => { + const storage: WalletStorage = { + getItem: () => Promise.reject(new Error('storage unavailable')), + removeItem: () => {}, + setItem: () => {}, + }; + + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + }); +}); + +// -- Late wallet registration tests ------------------------------------------- + +describe.skipIf(!__BROWSER__)('store late wallet registration (browser)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('reconnects when saved wallet registers late', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + // Wallet not registered yet — should be reconnecting. + expect(store.getState().status).toBe('reconnecting'); + + // Wallet registers late. + const lateWallet = createMockUiWallet({ + accounts: [account], + name: 'LateWallet', + }); + lateRegisterWallet(lateWallet); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('reverts to disconnected after timeout when wallet never registers', async () => { + const storage = createMockStorage({ 'kit-wallet': 'MissingWallet:abc' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + await vi.advanceTimersByTimeAsync(3000); + expect(store.getState().status).toBe('disconnected'); + }); + + it('still reconnects if wallet appears after timeout', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + await vi.advanceTimersByTimeAsync(3000); + expect(store.getState().status).toBe('disconnected'); + + // Wallet appears after the timeout — should still silently reconnect. + const lateWallet = createMockUiWallet({ + accounts: [account], + name: 'LateWallet', + }); + lateRegisterWallet(lateWallet); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('clears storage when saved wallet is registered but filtered out', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `FilteredWallet:${account.address}` }); + + // Wallet is registered but doesn't pass the custom filter. + const filteredWallet = createMockUiWallet({ + accounts: [account], + name: 'FilteredWallet', + }); + registerWallet(filteredWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.name !== 'FilteredWallet', + storage, + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); +}); diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts new file mode 100644 index 0000000..4c5921c --- /dev/null +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -0,0 +1,93 @@ +import { createClient, extendClient } from '@solana/kit'; +import { describe, expect, it, vi } from 'vitest'; + +import { wallet, walletAsPayer } from '../src'; +import { createMockAccount, createMockUiWallet, mockSigner, registerWallet } from './_setup'; + +describe.skipIf(!__BROWSER__)('wallet plugin (browser)', () => { + it('adds wallet namespace to client', () => { + const client = createClient().use(wallet({ chain: 'solana:mainnet', storage: null })); + expect(client.wallet).toBeDefined(); + expect(client.wallet.getState().status).toBe('disconnected'); + }); + + it('preserves existing client properties', () => { + const client = createClient() + .use(client => extendClient(client, { myField: 'hello' })) + .use(wallet({ chain: 'solana:mainnet', storage: null })); + expect(client.myField).toBe('hello'); + }); + + it('cleanup via Symbol.dispose cleans up store', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const client = createClient().use(wallet({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + expect(client.wallet.getState().status).toBe('connected'); + + client[Symbol.dispose](); + + // Listener should fire without errors after dispose (no-op). + const listener = vi.fn(); + client.wallet.subscribe(listener); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe.skipIf(!__BROWSER__)('walletAsPayer plugin (browser)', () => { + it('payer is undefined when not connected', () => { + const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + expect(client.payer).toBeUndefined(); + }); + + it('payer returns the signer when connected', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.payer).toBe(mockSigner); + }); + + it('payer becomes undefined after disconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + expect(client.payer).toBe(mockSigner); + + await client.wallet.disconnect(); + expect(client.payer).toBeUndefined(); + }); + + it('preserves existing client properties', () => { + const client = createClient() + .use(client => extendClient(client, { myField: 'hello' })) + .use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + expect(client.myField).toBe('hello'); + }); + + it('cleanup via Symbol.dispose does not throw', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(() => client[Symbol.dispose]()).not.toThrow(); + }); +}); diff --git a/packages/kit-plugin-wallet/vitest.config.mts b/packages/kit-plugin-wallet/vitest.config.mts index 1839df6..6d74911 100644 --- a/packages/kit-plugin-wallet/vitest.config.mts +++ b/packages/kit-plugin-wallet/vitest.config.mts @@ -1,9 +1,20 @@ -import { defineConfig } from 'vitest/config'; - +import { defineConfig, mergeConfig } from 'vitest/config'; import { getVitestConfig } from '../../vitest.config.base.mjs'; +function withWalletMocks(config: ReturnType) { + return mergeConfig(config, { + test: { + setupFiles: ['./test/_setup.ts'], + }, + }); +} + export default defineConfig({ test: { - projects: [getVitestConfig('browser'), getVitestConfig('node'), getVitestConfig('react-native')], + projects: [ + withWalletMocks(getVitestConfig('browser')), + withWalletMocks(getVitestConfig('node')), + withWalletMocks(getVitestConfig('react-native')), + ], }, }); From 23eb8bc950f617a59771b04a846c9dca1078567a Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 13 Apr 2026 17:56:53 +0000 Subject: [PATCH 02/36] Add changeset, document in list of plugins --- .changeset/busy-clowns-stick.md | 5 +++++ CONTRIBUTING.md | 1 + README.md | 1 + packages/kit-plugin-wallet/src/types.ts | 17 +++++++++++++---- 4 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .changeset/busy-clowns-stick.md diff --git a/.changeset/busy-clowns-stick.md b/.changeset/busy-clowns-stick.md new file mode 100644 index 0000000..ce0003b --- /dev/null +++ b/.changeset/busy-clowns-stick.md @@ -0,0 +1,5 @@ +--- +'@solana/kit-plugin-wallet': minor +--- + +Add `walletSigner`, `walletIdentity`, `walletPayer`, and `walletWithoutSigner` plugins for framework-agnostic wallet management using wallet-standard. Provides wallet discovery, connection lifecycle, signer creation, auto-connect persistence, subscribable state for UI frameworks, and dynamic payer/identity integration. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c6fa0..cf56f58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ This is a monorepo managed with [pnpm](https://pnpm.io/) and [Turborepo](https:/ | [`@solana/kit-plugin-signer`](./packages/kit-plugin-signer) | Signer, payer, and identity plugins. | | [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | LiteSVM support plugin. | | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | Transaction planning and execution plugins. | +| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | Browser wallet support plugins. | The repo also contains a deprecated umbrella package (`@solana/kit-plugins`) and several deprecated single-purpose packages (`@solana/kit-plugin-payer`, `@solana/kit-plugin-airdrop`, `@solana/kit-client-rpc`, `@solana/kit-client-litesvm`). They re-export from the active packages via `export *` for backward compatibility, but new code should import from the individual `kit-plugin-*` packages above directly. diff --git a/README.md b/README.md index cdf0ae8..b31fcce 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ This repo provides the following individual plugin packages. You can learn more | [`@solana/kit-plugin-signer`](./packages/kit-plugin-signer) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-signer.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-signer) | Signer, payer, and identity plugins | `signer`, `payer`, `identity`, `signerFromFile`, `payerFromFile`, `identityFromFile`, `generatedSigner`, `generatedPayer`, `generatedIdentity`, `generatedSignerWithSol`, `generatedPayerWithSol`, `generatedIdentityWithSol`, `airdropSigner`, `airdropPayer`, `airdropIdentity` | | [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-litesvm.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-litesvm) | LiteSVM support | `litesvm`, `litesvmConnection`, `litesvmAirdrop`, `litesvmGetMinimumBalance`, `litesvmTransactionPlanner`, `litesvmTransactionPlanExecutor` | | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-instruction-plan.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-instruction-plan) | Transaction planning and execution | `transactionPlanner`, `transactionPlanExecutor`, `planAndSendTransactions` | +| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-wallet) | Browser wallet support | `walletSigner`, `walletIdentity`, `walletPayer`, `walletWithoutSigner` | ## Community Plugins diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index d8a2404..f22c7e9 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -95,10 +95,10 @@ export type WalletActionOptions = { * @example * ```ts * // Use sessionStorage - * wallet({ chain: 'solana:mainnet', storage: sessionStorage }); + * walletSigner({ chain: 'solana:mainnet', storage: sessionStorage }); * * // Custom async adapter - * wallet({ + * walletSigner({ * chain: 'solana:mainnet', * storage: { * getItem: (key) => myStore.get(key), @@ -115,8 +115,12 @@ export type WalletStorage = { }; /** - * Configuration for the wallet plugins ({@link walletSigner}, - * {@link walletPayer}, {@link walletIdentity}, {@link walletWithoutSigner}). + * Configuration for the wallet plugins. + * + * @see {@link walletSigner} + * @see {@link walletIdentity} + * @see {@link walletPayer} + * @see {@link walletWithoutSigner} */ export type WalletPluginConfig = { /** @@ -289,8 +293,13 @@ export type WalletNamespace = { * Properties added to the client by the wallet plugins. * * All wallet state and actions are namespaced under `client.wallet`. + * Depending on which plugin variant is used, `client.payer` and/or + * `client.identity` may also be set to the connected wallet's signer. * * @see {@link walletSigner} + * @see {@link walletPayer} + * @see {@link walletIdentity} + * @see {@link walletWithoutSigner} * @see {@link WalletNamespace} * * @note this is not part of Kit plugin-interfaces, as it depends on wallet-standard types From 4a2206dfcae9f4e2ed9ffff3fbe08731128a50c1 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 14 Apr 2026 08:56:38 +0000 Subject: [PATCH 03/36] Address review feedback for wallet plugin Addresses review feedback from #191: - signIn now sets status: 'connecting' and wraps in try/catch with disconnectLocally on failure, matching connect's error handling pattern. - setState only notifies listeners when the snapshot actually changed, avoiding unnecessary re-renders in non-React frameworks. - Added connectGeneration counter to prevent concurrent connect/signIn/attemptSilentReconnect calls from leaking event subscriptions or overwriting each other's state. - Removed redundant currentWallet && guard in disconnect. New tests for signIn status transitions, signIn rejection, listener suppression on no-op setState, and concurrent connect/signIn race conditions. --- packages/kit-plugin-wallet/src/store.ts | 78 +++-- packages/kit-plugin-wallet/test/store.test.ts | 312 +++++++++++++++++- packages/kit-plugin-wallet/tsconfig.json | 2 +- 3 files changed, 351 insertions(+), 41 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 0413311..4df0791 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -95,6 +95,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { let walletEventsCleanup: (() => void) | null = null; let reconnectCleanup: (() => void) | null = null; let userHasSelected = false; + let connectGeneration = 0; // Resolve storage: default to localStorage in browser, null to disable. const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? localStorage); @@ -123,8 +124,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const prev = state; state = { ...state, ...updates }; - // Only create a new snapshot when a snapshot-relevant field changed. - // This ensures referential stability for useSyncExternalStore. + // Only create a new snapshot and notify listeners when a + // snapshot-relevant field actually changed. This ensures + // referential stability for useSyncExternalStore and avoids + // unnecessary re-renders in non-React frameworks. if ( state.wallets !== prev.wallets || state.connectedWallet !== prev.connectedWallet || @@ -133,9 +136,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { state.signer !== prev.signer ) { snapshot = deriveSnapshot(state); + listeners.forEach(l => l()); } - - listeners.forEach(l => l()); } // -- Signer creation --------------------------------------------------- @@ -292,6 +294,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { async function connect(uiWallet: UiWallet): Promise { userHasSelected = true; cancelReconnect(); + const generation = ++connectGeneration; setState({ status: 'connecting' }); try { @@ -305,12 +308,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { await connectFeature.connect(); + // A newer connect/signIn was started while we were awaiting — bail. + if (generation !== connectGeneration) return []; + // Refresh UiWallet to get updated accounts after connect. const refreshedWallet = refreshUiWallet(uiWallet); const allAccounts = refreshedWallet.accounts; if (allAccounts.length === 0) { - // New wallet has no accounts — disconnect disconnectLocally(); return allAccounts; } @@ -322,7 +327,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { setConnected(activeAccount, refreshedWallet); return allAccounts; } catch (error) { - disconnectLocally(); + // Only clean up if we're still the active connection attempt. + if (generation === connectGeneration) { + disconnectLocally(); + } throw error; } } @@ -334,7 +342,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { setState({ status: 'disconnecting' }); try { - if (currentWallet && currentWallet.features.includes(StandardDisconnect)) { + if (currentWallet.features.includes(StandardDisconnect)) { const disconnectFeature = getWalletFeature( currentWallet, StandardDisconnect, @@ -395,24 +403,36 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { async function signIn(uiWallet: UiWallet, input: SolanaSignInInput): Promise { userHasSelected = true; cancelReconnect(); + const generation = ++connectGeneration; + setState({ status: 'connecting' }); - // getWalletFeature throws WalletStandardError if not supported. - const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; - const [result] = await signInFeature.signIn(input); + try { + // getWalletFeature throws WalletStandardError if not supported. + const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; + const [result] = await signInFeature.signIn(input); - // Set up full connection state using the account from the sign-in response. - const refreshedWallet = refreshUiWallet(uiWallet); - const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); + // A newer connect/signIn was started while we were awaiting — bail. + if (generation !== connectGeneration) return result; - if (!activeAccount) { - // The signed-in account isn't in the wallet — bad state, disconnect - // so the user can try a fresh connect/sign-in. - disconnectLocally(); + // Set up full connection state using the account from the sign-in response. + const refreshedWallet = refreshUiWallet(uiWallet); + const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); + + if (!activeAccount) { + // The signed-in account isn't in the wallet — bad state, disconnect + // so the user can try a fresh connect/sign-in. + disconnectLocally(); + return result; + } + + setConnected(activeAccount, refreshedWallet); return result; + } catch (error) { + if (generation === connectGeneration) { + disconnectLocally(); + } + throw error; } - - setConnected(activeAccount, refreshedWallet); - return result; } // -- Persistence ------------------------------------------------------- @@ -499,7 +519,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { setState({ status: 'disconnected' }); await storage.removeItem(storageKey); } - })(), + })().catch(() => { + // Reconnect failed — fall back to disconnected. + setState({ status: 'disconnected' }); + }), ); reconnectCleanup = () => { @@ -519,6 +542,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } async function attemptSilentReconnect(savedAddress: string, uiWallet: UiWallet): Promise { + const generation = ++connectGeneration; setState({ status: 'reconnecting' }); try { @@ -528,6 +552,9 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { ) as StandardConnectFeature[typeof StandardConnect]; await connectFeature.connect({ silent: true }); + // A newer connect/signIn was started while we were awaiting — bail. + if (generation !== connectGeneration) return; + const refreshedWallet = refreshUiWallet(uiWallet); const allAccounts = refreshedWallet.accounts; @@ -537,16 +564,15 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { return; } - // Check again: user may have connected manually while we were awaiting. - if (userHasSelected) return; - // Restore the specific saved account, fall back to first from same wallet. const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0]; setConnected(activeAccount, refreshedWallet, { persist: false }); } catch { - setState({ status: 'disconnected' }); - await storage?.removeItem(storageKey); + if (generation === connectGeneration) { + setState({ status: 'disconnected' }); + await storage?.removeItem(storageKey); + } } } diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 2f584a2..09e7604 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -175,19 +175,15 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { }); registerWallet(mockWallet); - let resolveConnect!: () => void; - connectMock.mockReturnValueOnce( - new Promise(r => { - resolveConnect = r; - }), - ); + const { promise, resolve } = Promise.withResolvers(); + connectMock.mockReturnValueOnce(promise); const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const connectPromise = store.connect(mockWallet); expect(store.getState().status).toBe('connecting'); - resolveConnect(); + resolve(); await connectPromise; expect(store.getState().status).toBe('connected'); @@ -221,12 +217,8 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { }); registerWallet(mockWallet); - let resolveDisconnect!: () => void; - disconnectMock.mockReturnValueOnce( - new Promise(r => { - resolveDisconnect = r; - }), - ); + const { promise, resolve } = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(promise); const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); await store.connect(mockWallet); @@ -235,7 +227,7 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().status).toBe('disconnecting'); - resolveDisconnect(); + resolve(); await disconnectPromise; expect(store.getState().status).toBe('disconnected'); @@ -294,6 +286,25 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(() => store.selectAccount(account)).toThrow(WalletNotConnectedError); }); + it('does not notify listeners when nothing changed', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const listener = vi.fn(); + store.subscribe(listener); + + // selectAccount with the same account — no state change. + store.selectAccount(account); + expect(listener).not.toHaveBeenCalled(); + }); + it('selectAccount switches accounts', async () => { const account1 = createMockAccount(); const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); @@ -386,6 +397,43 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(state.connected!.account.address).toBe(account.address); }); + it('signIn reverts to disconnected if rejected', async () => { + const mockWallet = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + signInMock.mockRejectedValueOnce(new Error('User rejected')); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.signIn(mockWallet, {})).rejects.toThrow('User rejected'); + expect(store.getState().status).toBe('disconnected'); + }); + + it('signIn transitions through connecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const { promise, resolve } = Promise.withResolvers(); + signInMock.mockReturnValueOnce(promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const signInPromise = store.signIn(mockWallet, {}); + + expect(store.getState().status).toBe('connecting'); + + resolve([{ account: { address: account.address } }]); + await signInPromise; + + expect(store.getState().status).toBe('connected'); + }); + it('signIn disconnects when signed-in account is not in wallet', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ @@ -454,6 +502,242 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected).toBeNull(); }); + it('stale connect rejection does not disconnect the active connection', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start connecting to wallet1. + const firstPromise = store.connect(wallet1); + + // User gives up on wallet1 and connects to wallet2 instead. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + // Wallet1 prompt is dismissed — rejection should not disconnect wallet2. + first.reject(new Error('User rejected')); + await expect(firstPromise).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('stale connect rejection does not interfere with in-flight connect', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Both connects are in flight. + const firstPromise = store.connect(wallet1); + const secondPromise = store.connect(wallet2); + + // Wallet1 is dismissed while wallet2 is still pending. + first.reject(new Error('User rejected')); + await expect(firstPromise).rejects.toThrow('User rejected'); + + // Store should still be connecting (wallet2 is in flight). + expect(store.getState().status).toBe('connecting'); + + // Wallet2 completes — should connect normally. + second.resolve(); + await secondPromise; + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('concurrent connect calls do not leak event subscriptions', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start connecting to wallet1, but don't await yet. + const firstPromise = store.connect(wallet1); + + // Start connecting to wallet2 — this should supersede wallet1. + const secondPromise = store.connect(wallet2); + + // Resolve the first connect — it should be stale and bail. + first.resolve(); + await firstPromise; + await secondPromise; + + // wallet2 should be connected, not wallet1. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('concurrent signIn does not apply stale result', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ + accounts: [account2], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + signInMock + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([{ account: { address: account2.address } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start signIn to wallet1, but don't await yet. + const firstPromise = store.signIn(wallet1, {}); + + // Start signIn to wallet2 — this should supersede wallet1. + const secondPromise = store.signIn(wallet2, {}); + + // Resolve the first signIn — it should be stale and bail. + first.resolve([{ account: { address: account1.address } }]); + await firstPromise; + await secondPromise; + + // wallet2 should be connected, not wallet1. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('connect supersedes in-flight signIn', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const pendingSignIn = Promise.withResolvers(); + signInMock.mockReturnValueOnce(pendingSignIn.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start signIn, then connect to a different wallet before it resolves. + const signInPromise = store.signIn(wallet1, {}); + await store.connect(wallet2); + + // signIn resolves stale — should not overwrite wallet2's connection. + pendingSignIn.resolve([{ account: { address: account1.address } }]); + await signInPromise; + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('explicit connect supersedes in-flight auto-reconnect', async () => { + vi.useFakeTimers(); + + const savedAccount = createMockAccount(); + const userAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const savedWallet = createMockUiWallet({ accounts: [savedAccount], name: 'SavedWallet' }); + const userWallet = createMockUiWallet({ accounts: [userAccount], name: 'UserWallet' }); + registerWallet(userWallet); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${savedAccount.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User explicitly connects while waiting for saved wallet. + await store.connect(userWallet); + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + // Saved wallet registers late — reconnect should be stale. + const reconnectConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(reconnectConnect.promise); + lateRegisterWallet(savedWallet); + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + + // User's explicit choice should not be overridden. + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + vi.useRealTimers(); + }); + + it('in-flight connect supersedes concurrent auto-reconnect via generation counter', async () => { + vi.useFakeTimers(); + + const savedAccount = createMockAccount(); + const userAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const savedWallet = createMockUiWallet({ accounts: [savedAccount], name: 'SavedWallet' }); + const userWallet = createMockUiWallet({ accounts: [userAccount], name: 'UserWallet' }); + registerWallet(userWallet); + + // Hold user's connect pending so both are in flight simultaneously. + const userConnect = Promise.withResolvers(); + const reconnectConnect = Promise.withResolvers(); + connectMock + .mockReturnValueOnce(userConnect.promise) // user's connect + .mockReturnValueOnce(reconnectConnect.promise); // auto-reconnect's silent connect + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${savedAccount.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User starts connecting — still pending. + const userPromise = store.connect(userWallet); + expect(store.getState().status).toBe('connecting'); + + // Saved wallet registers — auto-reconnect fires while user connect is in flight. + lateRegisterWallet(savedWallet); + await vi.advanceTimersByTimeAsync(0); + + // Auto-reconnect resolves first — but stale, so user's connecting status holds. + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('connecting'); + + // User's connect resolves second. + userConnect.resolve(); + await userPromise; + + // User's connect should win — it has the later generation. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + vi.useRealTimers(); + }); + it('getState returns referentially stable snapshots', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const snap1 = store.getState(); diff --git a/packages/kit-plugin-wallet/tsconfig.json b/packages/kit-plugin-wallet/tsconfig.json index 7a4f027..aac85ad 100644 --- a/packages/kit-plugin-wallet/tsconfig.json +++ b/packages/kit-plugin-wallet/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { "lib": [] }, + "compilerOptions": { "lib": ["ES2024.Promise"] }, "display": "@solana/kit-plugin-wallet", "extends": "../../tsconfig.json", "include": ["src", "test"] From 5f26980ffde7a9646d11475bb64df2309437f858 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 14 Apr 2026 16:07:39 +0000 Subject: [PATCH 04/36] Address second round of review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix signer/account mismatch when both accounts and features change in the same wallet event — skip the features handler since the account handler already creates a fresh signer. - Guard disconnect() with connectGeneration so its finally block doesn't clobber a concurrent connect(). - Validate selectAccount — throw if the account isn't in the connected wallet. - Add .catch() on the late-registration async IIFE to prevent unhandled rejections. - Existing subscriber tests updated to register wallets with both accounts for selectAccount validation. --- packages/kit-plugin-wallet/src/store.ts | 16 +++- packages/kit-plugin-wallet/test/store.test.ts | 89 +++++++++++++++++-- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 4df0791..57a8e9a 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -252,7 +252,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } updates = { ...updates, ...result }; } - if (properties.features) { + // Skip features handler when accounts also changed — the account + // handler already creates a fresh signer for the new account. + // Running both would compute the signer from the stale account + // in state (setState hasn't been called yet). + if (properties.features && !properties.accounts) { updates = { ...updates, ...handleFeaturesChanged() }; } @@ -339,6 +343,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (!state.connectedWallet) return; const currentWallet = state.connectedWallet; + const generation = connectGeneration; setState({ status: 'disconnecting' }); try { @@ -350,7 +355,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { await disconnectFeature.disconnect(); } } finally { - disconnectLocally(); + // Only clean up if no new connect/signIn started while we were awaiting. + if (generation === connectGeneration) { + disconnectLocally(); + } } } @@ -372,6 +380,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (!state.connectedWallet) { throw new WalletNotConnectedError('selectAccount'); } + const refreshed = refreshUiWallet(state.connectedWallet); + if (!refreshed.accounts.some(a => a.address === account.address)) { + throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); + } userHasSelected = true; const signer = tryCreateSigner(account); setState({ account, signer }); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 09e7604..16fc050 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -241,9 +241,10 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { }); it('notifies subscribers on state change', async () => { - const account = createMockAccount(); + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); const mockWallet = createMockUiWallet({ - accounts: [account], + accounts: [account1, account2], name: 'TestWallet', }); registerWallet(mockWallet); @@ -255,15 +256,15 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { store.subscribe(listener); // selectAccount is a single, synchronous state change. - const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); store.selectAccount(account2); expect(listener).toHaveBeenCalledOnce(); }); it('unsubscribe stops notifications', async () => { - const account = createMockAccount(); + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); const mockWallet = createMockUiWallet({ - accounts: [account], + accounts: [account1, account2], name: 'TestWallet', }); registerWallet(mockWallet); @@ -275,7 +276,6 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { const unsub = store.subscribe(listener); unsub(); - const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); store.selectAccount(account2); expect(listener).not.toHaveBeenCalled(); }); @@ -322,6 +322,21 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected!.account.address).toBe(account2.address); }); + it('selectAccount throws for account not in wallet', async () => { + const account = createMockAccount(); + const foreignAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + expect(() => store.selectAccount(foreignAccount)).toThrow('not available in wallet'); + }); + it('signMessage throws when not connected', async () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(WalletNotConnectedError); @@ -738,6 +753,38 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { vi.useRealTimers(); }); + it('disconnect does not clobber concurrent connect', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold disconnect pending. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // User connects to wallet2 while disconnect is in flight. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + // Disconnect resolves — should not wipe out wallet2's connection. + pendingDisconnect.resolve(); + await disconnectPromise; + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + it('getState returns referentially stable snapshots', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const snap1 = store.getState(); @@ -935,6 +982,36 @@ describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { expect(state.status).toBe('connected'); expect(state.connected!.signer).toBeNull(); }); + it('uses new account signer when both accounts and features change', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Clear calls from connect so we only see the event-triggered call. + createSignerMock.mockClear(); + + // Wallet removes account1 and changes features in the same event. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account2], + features: ['standard:connect', 'standard:events', 'solana:signAndSendTransaction'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true, features: true }); + + // Signer should be created for account2 (the new account), not account1. + expect(store.getState().connected!.account.address).toBe(account2.address); + expect(createSignerMock).toHaveBeenCalledOnce(); + expect(createSignerMock.mock.calls[0][0]).toHaveProperty('address', account2.address); + }); }); // -- Auto-connect tests ------------------------------------------------------- From bd93e0ba74d34ff40b7529e4556db24d85d438b4 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 13:28:37 +0000 Subject: [PATCH 05/36] Refactor to use ClientWithPayer and ClientWithIdentity --- packages/kit-plugin-wallet/src/store.ts | 15 ++--- packages/kit-plugin-wallet/test/store.test.ts | 28 +++++--- .../kit-plugin-wallet/test/wallet.test.ts | 64 ++++++++++++++----- 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 57a8e9a..4bc5d6d 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -1,4 +1,4 @@ -import type { SignatureBytes } from '@solana/kit'; +import { type SignatureBytes, SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; import { SolanaSignIn, @@ -32,7 +32,6 @@ import type { WalletStatus, WalletStorage, } from './types'; -import { WalletNotConnectedError } from './types'; // -- Internal types --------------------------------------------------------- @@ -63,18 +62,18 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { }); return { connect: () => { - throw new WalletNotConnectedError('connect'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); }, disconnect: () => Promise.resolve(), getState: () => ssrSnapshot, selectAccount: () => { - throw new WalletNotConnectedError('selectAccount'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); }, signIn: () => { - throw new WalletNotConnectedError('signIn'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); }, signMessage: () => { - throw new WalletNotConnectedError('signMessage'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); }, subscribe: () => () => {}, [Symbol.dispose]: () => {}, @@ -378,7 +377,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { function selectAccount(account: UiWalletAccount): void { if (!state.connectedWallet) { - throw new WalletNotConnectedError('selectAccount'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); } const refreshed = refreshUiWallet(state.connectedWallet); if (!refreshed.accounts.some(a => a.address === account.address)) { @@ -395,7 +394,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { async function signMessage(message: Uint8Array): Promise { const { connectedWallet, account } = state; if (!connectedWallet || !account) { - throw new WalletNotConnectedError('signMessage'); + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); } // Use the wallet feature directly rather than going through the cached // signer. This decouples message signing from transaction signing — diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 16fc050..84e0c1f 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1,8 +1,8 @@ +import { SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createWalletStore } from '../src/store'; import type { WalletStorage } from '../src/types'; -import { WalletNotConnectedError } from '../src/types'; import { connectMock, createMockAccount, @@ -28,10 +28,12 @@ describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { expect(store.getState().connected).toBeNull(); }); - it('throws WalletNotConnectedError for connect on server', () => { + it('throws for connect on server', () => { const store = createWalletStore({ chain: 'solana:mainnet' }); const mockWallet = createMockUiWallet(); - expect(() => store.connect(mockWallet)).toThrow(WalletNotConnectedError); + expect(() => store.connect(mockWallet)).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); }); it('disconnect is a no-op on server', async () => { @@ -39,15 +41,19 @@ describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { await expect(store.disconnect()).resolves.toBeUndefined(); }); - it('throws WalletNotConnectedError for signMessage on server', () => { + it('throws for signMessage on server', () => { const store = createWalletStore({ chain: 'solana:mainnet' }); - expect(() => store.signMessage(new Uint8Array())).toThrow(WalletNotConnectedError); + expect(() => store.signMessage(new Uint8Array())).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }), + ); }); - it('throws WalletNotConnectedError for signIn on server', () => { + it('throws for signIn on server', () => { const store = createWalletStore({ chain: 'solana:mainnet' }); const mockWallet = createMockUiWallet(); - expect(() => store.signIn(mockWallet, {})).toThrow(WalletNotConnectedError); + expect(() => store.signIn(mockWallet, {})).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); }); it('subscribe returns unsubscribe function on server', () => { @@ -283,7 +289,9 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { it('selectAccount throws when not connected', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const account = createMockAccount(); - expect(() => store.selectAccount(account)).toThrow(WalletNotConnectedError); + expect(() => store.selectAccount(account)).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }), + ); }); it('does not notify listeners when nothing changed', async () => { @@ -339,7 +347,9 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { it('signMessage throws when not connected', async () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); - await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(WalletNotConnectedError); + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }), + ); }); it('signMessage calls wallet feature directly when connected', async () => { diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts index 4c5921c..df8063f 100644 --- a/packages/kit-plugin-wallet/test/wallet.test.ts +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -1,12 +1,12 @@ import { createClient, extendClient } from '@solana/kit'; import { describe, expect, it, vi } from 'vitest'; -import { wallet, walletAsPayer } from '../src'; +import { walletPayer, walletSigner, walletWithoutSigner } from '../src'; import { createMockAccount, createMockUiWallet, mockSigner, registerWallet } from './_setup'; -describe.skipIf(!__BROWSER__)('wallet plugin (browser)', () => { +describe.skipIf(!__BROWSER__)('walletWithoutSigner plugin (browser)', () => { it('adds wallet namespace to client', () => { - const client = createClient().use(wallet({ chain: 'solana:mainnet', storage: null })); + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); expect(client.wallet).toBeDefined(); expect(client.wallet.getState().status).toBe('disconnected'); }); @@ -14,7 +14,7 @@ describe.skipIf(!__BROWSER__)('wallet plugin (browser)', () => { it('preserves existing client properties', () => { const client = createClient() .use(client => extendClient(client, { myField: 'hello' })) - .use(wallet({ chain: 'solana:mainnet', storage: null })); + .use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); expect(client.myField).toBe('hello'); }); @@ -23,7 +23,7 @@ describe.skipIf(!__BROWSER__)('wallet plugin (browser)', () => { const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); registerWallet(mockWallet); - const client = createClient().use(wallet({ chain: 'solana:mainnet', storage: null })); + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); await client.wallet.connect(mockWallet); expect(client.wallet.getState().status).toBe('connected'); @@ -36,10 +36,10 @@ describe.skipIf(!__BROWSER__)('wallet plugin (browser)', () => { }); }); -describe.skipIf(!__BROWSER__)('walletAsPayer plugin (browser)', () => { - it('payer is undefined when not connected', () => { - const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); - expect(client.payer).toBeUndefined(); +describe.skipIf(!__BROWSER__)('walletPayer plugin (browser)', () => { + it('payer throws when not connected', () => { + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow('No signing wallet connected'); }); it('payer returns the signer when connected', async () => { @@ -50,13 +50,13 @@ describe.skipIf(!__BROWSER__)('walletAsPayer plugin (browser)', () => { }); registerWallet(mockWallet); - const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); await client.wallet.connect(mockWallet); expect(client.payer).toBe(mockSigner); }); - it('payer becomes undefined after disconnect', async () => { + it('payer throws after disconnect', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ accounts: [account], @@ -65,18 +65,18 @@ describe.skipIf(!__BROWSER__)('walletAsPayer plugin (browser)', () => { }); registerWallet(mockWallet); - const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); await client.wallet.connect(mockWallet); expect(client.payer).toBe(mockSigner); await client.wallet.disconnect(); - expect(client.payer).toBeUndefined(); + expect(() => client.payer).toThrow('No signing wallet connected'); }); it('preserves existing client properties', () => { const client = createClient() .use(client => extendClient(client, { myField: 'hello' })) - .use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + .use(walletPayer({ chain: 'solana:mainnet', storage: null })); expect(client.myField).toBe('hello'); }); @@ -85,9 +85,43 @@ describe.skipIf(!__BROWSER__)('walletAsPayer plugin (browser)', () => { const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); registerWallet(mockWallet); - const client = createClient().use(walletAsPayer({ chain: 'solana:mainnet', storage: null })); + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); await client.wallet.connect(mockWallet); expect(() => client[Symbol.dispose]()).not.toThrow(); }); }); + +describe.skipIf(!__BROWSER__)('walletSigner plugin (browser)', () => { + it('sets both payer and identity', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.payer).toBe(mockSigner); + expect(client.identity).toBe(mockSigner); + }); + + it('both payer and identity throw when not connected', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow('No signing wallet connected'); + expect(() => client.identity).toThrow('No signing wallet connected'); + }); +}); + +describe.skipIf(!__BROWSER__)('wallet plugin duplicate guard', () => { + it('throws when using two wallet plugins on the same client', () => { + expect(() => + createClient() + .use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })) + // @ts-expect-error — intentionally testing runtime guard for duplicate wallet plugins + .use(walletPayer({ chain: 'solana:mainnet', storage: null })), + ).toThrow('Only one wallet plugin can be used per client'); + }); +}); From 67906d92d486aebad3dabe39c4550e1e34bc0c12 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 17 Apr 2026 11:33:47 +0000 Subject: [PATCH 06/36] Cast chain to SolanaChain when creating wallet signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that \`WalletPluginConfig.chain\` types as \`SolanaChain | (IdentifierString & {})\`, \`tryCreateSigner\` has to cast before calling \`createSignerFromWalletAccount\` (which types only \`SolanaChain\`). The cast is safe because the existing try/catch already handles the runtime case — non-Solana chains make the bridge function throw, which degrades to \`signer: null\`, matching the read-only-wallet contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit-plugin-wallet/src/store.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 4bc5d6d..257e288 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -1,5 +1,6 @@ import { type SignatureBytes, SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; import { SolanaSignIn, type SolanaSignInFeature, @@ -143,9 +144,16 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { function tryCreateSigner(account: UiWalletAccount): WalletSigner | null { try { - return createSignerFromWalletAccount(account, config.chain); + // `config.chain` widens to `SolanaChain | (IdentifierString & {})` + // for the custom-chain escape hatch. `createSignerFromWalletAccount` + // only types `SolanaChain`, but its runtime throws for chains it + // doesn't understand — which we catch below. Non-Solana chains + // therefore degrade to `signer: null`, matching the read-only + // wallet contract. + return createSignerFromWalletAccount(account, config.chain as SolanaChain); } catch { - // Wallet doesn't support signing (e.g. read-only / watch wallet). + // Wallet doesn't support signing (read-only / watch wallet, or a + // non-Solana chain). return null; } } From 7452f21442e68427d906f931e87d979db4b7b769 Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 20 Apr 2026 09:02:34 +0000 Subject: [PATCH 07/36] Address review comments - Rename internal setState helper to updateState - Render pending state for react-native as well as SSR - Add a comment for getWallets --- packages/kit-plugin-wallet/src/store.ts | 51 +++++++++++++------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 257e288..76bd6ec 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -55,7 +55,7 @@ type WalletStoreState = { export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- SSR guard: on the server, return an inert stub --------------------- - if (!__BROWSER__) { + if (!__BROWSER__ || __REACTNATIVE__) { const ssrSnapshot: WalletState = Object.freeze({ connected: null, status: 'pending' as const, @@ -120,7 +120,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { let snapshot: WalletState = deriveSnapshot(state); - function setState(updates: Partial): void { + function updateState(updates: Partial): void { const prev = state; state = { ...state, ...updates }; @@ -160,6 +160,9 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Wallet discovery -------------------------------------------------- + // `getWallets()` is a wallet-standard function that returns the `Wallets` API + // This includes a `get()` function to get the current list of wallets, and + // an `on()` function to subscribe to wallet registration/unregistration events. const registry = getWallets(); function filterWallet(uiWallet: UiWallet): boolean { @@ -178,11 +181,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { ); } - setState({ wallets: buildWalletList() }); + updateState({ wallets: buildWalletList() }); // Listen for new wallets being registered const unsubRegister = registry.on('register', () => { - setState({ wallets: buildWalletList() }); + updateState({ wallets: buildWalletList() }); }); // Listen for wallets being unregistered — if the connected wallet is removed, disconnect @@ -201,7 +204,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { void storage?.removeItem(storageKey); } - setState(updates); + updateState(updates); }); // -- Wallet-initiated events ------------------------------------------- @@ -219,7 +222,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void { const signer = tryCreateSigner(account); resubscribeToWalletEvents(wallet); - setState({ account, connectedWallet: wallet, signer, status: 'connected' }); + updateState({ account, connectedWallet: wallet, signer, status: 'connected' }); if (options?.persist !== false) { persistAccount(account, wallet); } @@ -267,7 +270,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { updates = { ...updates, ...handleFeaturesChanged() }; } - setState(updates); + updateState(updates); if (updates.account) { persistAccount(updates.account, refreshed); @@ -306,7 +309,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; - setState({ status: 'connecting' }); + updateState({ status: 'connecting' }); try { const connectFeature = getWalletFeature( @@ -351,7 +354,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const currentWallet = state.connectedWallet; const generation = connectGeneration; - setState({ status: 'disconnecting' }); + updateState({ status: 'disconnecting' }); try { if (currentWallet.features.includes(StandardDisconnect)) { @@ -373,7 +376,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { walletEventsCleanup?.(); walletEventsCleanup = null; - setState({ + updateState({ account: null, connectedWallet: null, signer: null, @@ -393,7 +396,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } userHasSelected = true; const signer = tryCreateSigner(account); - setState({ account, signer }); + updateState({ account, signer }); persistAccount(account, state.connectedWallet); } @@ -423,7 +426,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; - setState({ status: 'connecting' }); + updateState({ status: 'connecting' }); try { // getWalletFeature throws WalletStandardError if not supported. @@ -469,14 +472,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (userHasSelected) return; if (!savedKey) { - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); return; } const separatorIndex = savedKey.lastIndexOf(':'); if (separatorIndex === -1) { // Malformed saved key. - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); await storage.removeItem(storageKey); return; } @@ -494,18 +497,18 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { }) ) { // Wallet registered but doesn't pass the filter. - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); await storage.removeItem(storageKey); } else { // Wallet not registered yet — wait for it to appear. - setState({ status: 'reconnecting' }); + updateState({ status: 'reconnecting' }); // Change state to disconnected after 3s // Note that the listener stays alive until reconnect is cancelled, so this is // just affecting how long the UI shows reconnecting const statusTimeout = setTimeout(() => { if (!userHasSelected && state.status === 'reconnecting') { - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); } }, 3000); @@ -535,12 +538,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { clearTimeout(statusTimeout); unsubRegisterForReconnect(); reconnectCleanup = null; - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); await storage.removeItem(storageKey); } })().catch(() => { // Reconnect failed — fall back to disconnected. - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); }), ); @@ -552,17 +555,17 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { })().catch(() => { // Storage read failed — fall back to disconnected. if (!userHasSelected) { - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); } }); } else { // No auto-connect: immediately transition from 'pending' to 'disconnected'. - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); } async function attemptSilentReconnect(savedAddress: string, uiWallet: UiWallet): Promise { const generation = ++connectGeneration; - setState({ status: 'reconnecting' }); + updateState({ status: 'reconnecting' }); try { const connectFeature = getWalletFeature( @@ -578,7 +581,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const allAccounts = refreshedWallet.accounts; if (allAccounts.length === 0) { - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); await storage?.removeItem(storageKey); return; } @@ -589,7 +592,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { setConnected(activeAccount, refreshedWallet, { persist: false }); } catch { if (generation === connectGeneration) { - setState({ status: 'disconnected' }); + updateState({ status: 'disconnected' }); await storage?.removeItem(storageKey); } } From 47c1cfe438e3b20e715cf0b79840cba233103ad2 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 23 Apr 2026 10:34:23 +0000 Subject: [PATCH 08/36] Add abort signal to async wallet actions --- packages/kit-plugin-wallet/src/store.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 76bd6ec..a58d9a4 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -26,6 +26,7 @@ import { } from '@wallet-standard/ui-registry'; import type { + WalletActionOptions, WalletNamespace, WalletPluginConfig, WalletSigner, @@ -305,7 +306,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Connection lifecycle ---------------------------------------------- - async function connect(uiWallet: UiWallet): Promise { + async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; @@ -349,7 +351,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } } - async function disconnect(): Promise { + async function disconnect(options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); if (!state.connectedWallet) return; const currentWallet = state.connectedWallet; @@ -402,7 +405,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Message signing --------------------------------------------------- - async function signMessage(message: Uint8Array): Promise { + async function signMessage(message: Uint8Array, options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); const { connectedWallet, account } = state; if (!connectedWallet || !account) { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); @@ -422,7 +426,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Sign In With Solana (SIWS-as-connect) ------------------------------ - async function signIn(uiWallet: UiWallet, input: SolanaSignInInput): Promise { + async function signIn( + uiWallet: UiWallet, + input: SolanaSignInInput, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; From 42e7887a6acb56dcd9f22f5a76bb74b8e2b57976 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 9 Jun 2026 09:18:19 +0000 Subject: [PATCH 09/36] Fix lint --- packages/kit-plugin-wallet/vitest.config.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit-plugin-wallet/vitest.config.mts b/packages/kit-plugin-wallet/vitest.config.mts index 6d74911..3a25df5 100644 --- a/packages/kit-plugin-wallet/vitest.config.mts +++ b/packages/kit-plugin-wallet/vitest.config.mts @@ -1,4 +1,5 @@ import { defineConfig, mergeConfig } from 'vitest/config'; + import { getVitestConfig } from '../../vitest.config.base.mjs'; function withWalletMocks(config: ReturnType) { From c2d9aac75db8a737178113298cffc1cdff58bc09 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 9 Jun 2026 15:26:06 +0000 Subject: [PATCH 10/36] Refine selectAccount, persistence, and concurrency edge cases Resolve the wallet-owned account object in `selectAccount`, and no-op when re-selecting the already-active account to avoid recreating the signer (a fresh object each call) and triggering a spurious re-render. Make persistence best-effort: `setItem`/`removeItem` failures are swallowed since the live wallet connection is the source of truth, and this is documented on `WalletStorage`. Reject superseded `connect`/`signIn` calls with a `DOMException` `AbortError` so an accidental double-click still connects via the newer request while the orphaned promise settles in the standard, ignorable way. Bump the connection generation on dispose so in-flight async work bails instead of re-subscribing to wallet events after teardown. --- packages/kit-plugin-wallet/README.md | 10 + packages/kit-plugin-wallet/src/store.ts | 73 ++++++-- packages/kit-plugin-wallet/src/types.ts | 16 ++ packages/kit-plugin-wallet/test/store.test.ts | 177 +++++++++++++++++- 4 files changed, 253 insertions(+), 23 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 535d3a4..f66cd72 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -137,6 +137,16 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref const accounts = await client.wallet.connect(selectedWallet); ``` + If another `connect` or `signIn` starts before this one resolves (e.g. an accidental double-click), the newer request wins and owns the connection — the superseded call rejects with a `DOMException` whose `name` is `'AbortError'`. Ignore it the way you'd ignore any aborted operation: + + ```ts + try { + await client.wallet.connect(selectedWallet); + } catch (e) { + if ((e as Error).name !== 'AbortError') throw e; // a real failure + } + ``` + - **`disconnect()`** — Disconnect the active wallet. - **`selectAccount(account)`** — Switch to a different account within an already-authorized wallet without reconnecting. diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index a58d9a4..8245d3e 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -202,7 +202,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { updates.account = null; updates.signer = null; updates.status = 'disconnected'; - void storage?.removeItem(storageKey); + clearPersistedAccount(); } updateState(updates); @@ -324,8 +324,15 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { await connectFeature.connect(); - // A newer connect/signIn was started while we were awaiting — bail. - if (generation !== connectGeneration) return []; + // A newer connect/signIn was started while we were awaiting. Reject + // with an `AbortError` so this superseded call settles in the + // standard, ignorable way (matching the abort-signal contract) while + // the newer request owns the connection. The `catch` below leaves + // that newer connection untouched, since `generation` no longer + // matches `connectGeneration`. + if (generation !== connectGeneration) { + throw new DOMException('Wallet connect was superseded by a newer connect or sign-in', 'AbortError'); + } // Refresh UiWallet to get updated accounts after connect. const refreshedWallet = refreshUiWallet(uiWallet); @@ -386,7 +393,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { status: 'disconnected', }); - void storage?.removeItem(storageKey); + clearPersistedAccount(); } function selectAccount(account: UiWalletAccount): void { @@ -394,13 +401,21 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); } const refreshed = refreshUiWallet(state.connectedWallet); - if (!refreshed.accounts.some(a => a.address === account.address)) { + const selectedAccount = refreshed.accounts.find(a => a.address === account.address); + if (!selectedAccount) { throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); } userHasSelected = true; - const signer = tryCreateSigner(account); - updateState({ account, signer }); - persistAccount(account, state.connectedWallet); + // No-op when re-selecting the already-active account. Skipping here + // avoids recreating the signer — which is a fresh object each call — + // and the spurious listener notification (and re-render) that the new + // signer reference would otherwise trigger. + if (selectedAccount === state.account && refreshed === state.connectedWallet) { + return; + } + const signer = tryCreateSigner(selectedAccount); + updateState({ account: selectedAccount, connectedWallet: refreshed, signer }); + persistAccount(selectedAccount, refreshed); } // -- Message signing --------------------------------------------------- @@ -442,8 +457,13 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; const [result] = await signInFeature.signIn(input); - // A newer connect/signIn was started while we were awaiting — bail. - if (generation !== connectGeneration) return result; + // A newer connect/signIn was started while we were awaiting. Reject + // with an `AbortError` (matching the abort-signal contract) so the + // superseded call settles in the standard, ignorable way while the + // newer request owns the connection. + if (generation !== connectGeneration) { + throw new DOMException('Wallet sign-in was superseded by a newer connect or sign-in', 'AbortError'); + } // Set up full connection state using the account from the sign-in response. const refreshedWallet = refreshUiWallet(uiWallet); @@ -468,8 +488,24 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Persistence ------------------------------------------------------- + function ignoreStorageFailure(operation: () => Promise | void): void { + void (async () => { + try { + await operation(); + } catch { + // Persistence is best-effort; wallet state is the source of truth. + } + })(); + } + function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { - void storage?.setItem(storageKey, `${wallet.name}:${account.address}`); + if (!storage) return; + ignoreStorageFailure(() => storage.setItem(storageKey, `${wallet.name}:${account.address}`)); + } + + function clearPersistedAccount(): void { + if (!storage) return; + ignoreStorageFailure(() => storage.removeItem(storageKey)); } // -- Auto-connect ------------------------------------------------------ @@ -489,7 +525,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (separatorIndex === -1) { // Malformed saved key. updateState({ status: 'disconnected' }); - await storage.removeItem(storageKey); + clearPersistedAccount(); return; } @@ -507,7 +543,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { ) { // Wallet registered but doesn't pass the filter. updateState({ status: 'disconnected' }); - await storage.removeItem(storageKey); + clearPersistedAccount(); } else { // Wallet not registered yet — wait for it to appear. updateState({ status: 'reconnecting' }); @@ -548,7 +584,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { unsubRegisterForReconnect(); reconnectCleanup = null; updateState({ status: 'disconnected' }); - await storage.removeItem(storageKey); + clearPersistedAccount(); } })().catch(() => { // Reconnect failed — fall back to disconnected. @@ -591,7 +627,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (allAccounts.length === 0) { updateState({ status: 'disconnected' }); - await storage?.removeItem(storageKey); + clearPersistedAccount(); return; } @@ -602,7 +638,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } catch { if (generation === connectGeneration) { updateState({ status: 'disconnected' }); - await storage?.removeItem(storageKey); + clearPersistedAccount(); } } } @@ -623,6 +659,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { }; }, [Symbol.dispose]: () => { + // Invalidate any in-flight connect/signIn/silent-reconnect so they + // bail at their generation guard instead of resuming after disposal + // (which would re-subscribe to wallet events and re-arm cleanup that + // this disposer already ran). + connectGeneration++; unsubRegister(); unsubUnregister(); walletEventsCleanup?.(); diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index f22c7e9..6b6b26c 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -92,6 +92,13 @@ export type WalletActionOptions = { * `localStorage` and `sessionStorage` satisfy this interface directly. * Async backends (IndexedDB, encrypted storage) may return `Promise`s. * + * Writes are fire-and-forget: the plugin does not await `setItem`/`removeItem` + * and swallows any rejection (the live wallet connection is the source of + * truth, so a failed persist is non-fatal). A resolved action therefore does + * not guarantee the write has landed, and rapid successive writes are not + * ordered. This is intentional — persistence only records which account to + * silently reconnect to on the next load. + * * @example * ```ts * // Use sessionStorage @@ -205,6 +212,11 @@ export type WalletNamespace = { * * @returns All accounts from the wallet after connection. * @throws The wallet's rejection error if the user declines the prompt. + * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or + * `signIn` is started before this call resolves. The newer request wins + * and owns the resulting connection; this superseded call rejects so it + * can be ignored like any other aborted operation (e.g. an accidental + * double-click still connects — only the orphaned first promise rejects). * @throws `options.abortSignal.reason` if the signal is already aborted * when the action is called. Aborts after the wallet call has been * dispatched do not take effect. @@ -253,6 +265,10 @@ export type WalletNamespace = { * * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signIn`. + * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or + * `signIn` is started before this call resolves. The newer request wins + * and owns the resulting connection; this superseded call rejects so it + * can be ignored like any other aborted operation. * @throws `options.abortSignal.reason` if the signal is already aborted * when the action is called. Aborts after the wallet call has been * dispatched do not take effect. diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 84e0c1f..a08021b 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -305,12 +305,19 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); await store.connect(mockWallet); + const before = store.getState(); const listener = vi.fn(); store.subscribe(listener); + // Clear calls from connect so we only observe selectAccount. + createSignerMock.mockClear(); - // selectAccount with the same account — no state change. + // selectAccount with the same account — no state change. The signer is + // a fresh object on every creation, so recreating it here would churn + // the snapshot and notify listeners; the no-op guard must skip it. store.selectAccount(account); expect(listener).not.toHaveBeenCalled(); + expect(createSignerMock).not.toHaveBeenCalled(); + expect(store.getState()).toBe(before); }); it('selectAccount switches accounts', async () => { @@ -330,6 +337,46 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected!.account.address).toBe(account2.address); }); + it('selectAccount uses the wallet-owned account for the selected address', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const refreshedAccount2 = { + ...account2, + features: ['solana:signAndSendTransaction'] as const, + label: 'Refreshed Account 2', + publicKey: new Uint8Array(32).fill(2), + }; + const staleAccount2 = { + ...account2, + features: [] as const, + label: 'Stale Account 2', + publicKey: new Uint8Array(32).fill(9), + }; + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account1, refreshedAccount2], + name: 'TestWallet', + }), + ); + createSignerMock.mockClear(); + + store.selectAccount(staleAccount2); + + const state = store.getState(); + expect(state.connected!.account).toBe(refreshedAccount2); + expect(state.connected!.wallet.accounts[1]).toBe(refreshedAccount2); + expect(createSignerMock).toHaveBeenCalledWith(refreshedAccount2, 'solana:mainnet'); + }); + it('selectAccount throws for account not in wallet', async () => { const account = createMockAccount(); const foreignAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); @@ -607,9 +654,10 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { // Start connecting to wallet2 — this should supersede wallet1. const secondPromise = store.connect(wallet2); - // Resolve the first connect — it should be stale and bail. + // Resolve the first connect — it was superseded, so it rejects with an + // AbortError rather than applying its result. first.resolve(); - await firstPromise; + await expect(firstPromise).rejects.toThrow('superseded'); await secondPromise; // wallet2 should be connected, not wallet1. @@ -617,6 +665,37 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected!.account.address).toBe(account2.address); }); + it('rejects a superseded connect with an AbortError (double-click still connects)', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Two clicks on the same wallet before the first resolves. + const firstPromise = store.connect(mockWallet); + const secondPromise = store.connect(mockWallet); + + first.resolve(); + + // The orphaned first click rejects with a recognizable AbortError that + // consumers ignore by convention, ... + const error = await firstPromise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe('AbortError'); + + // ... while the second click establishes the connection. + await secondPromise; + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + it('concurrent signIn does not apply stale result', async () => { const account1 = createMockAccount(); const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); @@ -646,9 +725,10 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { // Start signIn to wallet2 — this should supersede wallet1. const secondPromise = store.signIn(wallet2, {}); - // Resolve the first signIn — it should be stale and bail. + // Resolve the first signIn — it was superseded, so it rejects with an + // AbortError rather than applying its result. first.resolve([{ account: { address: account1.address } }]); - await firstPromise; + await expect(firstPromise).rejects.toThrow('superseded'); await secondPromise; // wallet2 should be connected, not wallet1. @@ -677,9 +757,10 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { const signInPromise = store.signIn(wallet1, {}); await store.connect(wallet2); - // signIn resolves stale — should not overwrite wallet2's connection. + // signIn resolves stale — it was superseded, so it rejects with an + // AbortError and must not overwrite wallet2's connection. pendingSignIn.resolve([{ account: { address: account1.address } }]); - await signInPromise; + await expect(signInPromise).rejects.toThrow('superseded'); expect(store.getState().status).toBe('connected'); expect(store.getState().connected!.account.address).toBe(account2.address); @@ -1114,6 +1195,28 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(storage.getItem('kit-wallet')).toBe(`TestWallet:${account.address}`); }); + it('connects when persistence writes throw synchronously', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage: WalletStorage = { + getItem: () => null, + removeItem: () => {}, + setItem: () => { + throw new Error('storage write failed'); + }, + }; + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await expect(store.connect(mockWallet)).resolves.toHaveLength(1); + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + it('clears storage on disconnect', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ @@ -1132,6 +1235,30 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(storage.getItem('kit-wallet')).toBeNull(); }); + it('disconnects when persistence removal throws synchronously', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage: WalletStorage = { + getItem: () => null, + removeItem: () => { + throw new Error('storage remove failed'); + }, + setItem: () => {}, + }; + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + + await expect(store.disconnect()).resolves.toBeUndefined(); + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + it('falls back to disconnected when silent reconnect fails', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ @@ -1165,6 +1292,42 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(store.getState().status).toBe('disconnected'); }); + + it('does not reconnect when disposed mid silent reconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // Hold the silent connect open so we can dispose while it's in flight. + let resolveConnect!: () => void; + connectMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveConnect = resolve; + }), + ); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // Let auto-connect reach the awaited silent connect. + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // Dispose while the silent reconnect is still awaiting, then let it resolve. + store[Symbol.dispose](); + resolveConnect(); + await vi.advanceTimersByTimeAsync(0); + + // The resumed reconnect must not establish a connection or subscribe to + // wallet events after disposal. + expect(store.getState().status).not.toBe('connected'); + expect(store.getState().connected).toBeNull(); + expect(walletEventHandler).toBeNull(); + }); }); // -- Late wallet registration tests ------------------------------------------- From 2ef7dded52a71d128a97b05235aeb496bc95f11e Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 08:28:03 +0000 Subject: [PATCH 11/36] Update package.json scripts to match other plugins --- packages/kit-plugin-wallet/package.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index cc5b276..e11af90 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -49,11 +49,10 @@ "scripts": { "build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json", "dev": "vitest --project node", - "lint": "eslint . && prettier --check .", - "lint:fix": "eslint --fix . && prettier --write .", - "test": "pnpm test:types && pnpm test:treeshakability", + "test": "pnpm test:types && pnpm test:treeshakability && pnpm test:unit", "test:treeshakability": "for file in dist/index.*.mjs; do agadoo $file; done", - "test:types": "tsc --noEmit" + "test:types": "tsc --noEmit", + "test:unit": "vitest run" }, "dependencies": { "@solana/wallet-account-signer": "^6.9.0", @@ -74,4 +73,4 @@ "supports bigint and not dead", "maintained node versions" ] -} +} \ No newline at end of file From d509997f43b00f408273f4bba1faad43a4d26db5 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 08:34:23 +0000 Subject: [PATCH 12/36] Bump to Kit 6.9.0, use new SolanaError codes --- packages/kit-plugin-wallet/package.json | 4 ++-- packages/kit-plugin-wallet/src/wallet.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index e11af90..5e276a5 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -67,10 +67,10 @@ "@wallet-standard/ui-registry": "^1.1.1" }, "peerDependencies": { - "@solana/kit": "^6.6.0" + "@solana/kit": "^6.9.0" }, "browserslist": [ "supports bigint and not dead", "maintained node versions" ] -} \ No newline at end of file +} diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 817b254..9e22f8f 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -1,4 +1,12 @@ -import { type ClientWithIdentity, type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; +import { + type ClientWithIdentity, + type ClientWithPayer, + extendClient, + SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, + SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE, + SolanaError, + withCleanup, +} from '@solana/kit'; import { createWalletStore } from './store'; import type { ClientWithWallet, WalletPluginConfig } from './types'; @@ -16,12 +24,10 @@ function defineSignerGetter( get() { const state = store.getState(); if (!state.connected) { - // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); - throw new Error(`No signing wallet connected (status: ${state.status})`); + throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); } if (!state.connected.signer) { - // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); - throw new Error('Connected wallet does not support signing'); + throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); } return state.connected.signer; }, From 05cf2699590a09f1f48694724ffea46d108815d2 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 08:37:42 +0000 Subject: [PATCH 13/36] Rename deprecated imports from wallet-standard/ui-registry --- packages/kit-plugin-wallet/src/store.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 8245d3e..5fdd952 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -20,10 +20,7 @@ import { } from '@wallet-standard/features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; import { getWalletFeature } from '@wallet-standard/ui-features'; -import { - getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, - getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, -} from '@wallet-standard/ui-registry'; +import { getOrCreateUiWalletForStandardWallet, getWalletForHandle } from '@wallet-standard/ui-registry'; import type { WalletActionOptions, @@ -174,12 +171,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } function buildWalletList(): readonly UiWallet[] { - return Object.freeze( - registry - .get() - .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED) - .filter(filterWallet), - ); + return Object.freeze(registry.get().map(getOrCreateUiWalletForStandardWallet).filter(filterWallet)); } updateState({ wallets: buildWalletList() }); @@ -230,8 +222,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { - const rawWallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(staleUiWallet); - return getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(rawWallet); + const rawWallet = getWalletForHandle(staleUiWallet); + return getOrCreateUiWalletForStandardWallet(rawWallet); } function subscribeToWalletEvents(uiWallet: UiWallet): () => void { @@ -537,7 +529,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { await attemptSilentReconnect(savedAddress, existing); } else if ( registry.get().some(w => { - const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + const ui = getOrCreateUiWalletForStandardWallet(w); return ui.name === walletName; }) ) { @@ -575,7 +567,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { await attemptSilentReconnect(savedAddress, found); } else if ( registry.get().some(w => { - const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + const ui = getOrCreateUiWalletForStandardWallet(w); return ui.name === walletName; }) ) { From ce19a0b27e16eec0dae17c633adf551c97297173 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 08:45:29 +0000 Subject: [PATCH 14/36] Remove now unneeded cast from createSignerFromWalletAccount call --- packages/kit-plugin-wallet/src/store.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 5fdd952..e6c1a45 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -1,6 +1,5 @@ import { type SignatureBytes, SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; -import type { SolanaChain } from '@solana/wallet-standard-chains'; import { SolanaSignIn, type SolanaSignInFeature, @@ -142,13 +141,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { function tryCreateSigner(account: UiWalletAccount): WalletSigner | null { try { - // `config.chain` widens to `SolanaChain | (IdentifierString & {})` - // for the custom-chain escape hatch. `createSignerFromWalletAccount` - // only types `SolanaChain`, but its runtime throws for chains it - // doesn't understand — which we catch below. Non-Solana chains + // `createSignerFromWalletAccount` throws for chains the wallet + // doesn't support — which we catch below. Non-Solana chains // therefore degrade to `signer: null`, matching the read-only // wallet contract. - return createSignerFromWalletAccount(account, config.chain as SolanaChain); + return createSignerFromWalletAccount(account, config.chain); } catch { // Wallet doesn't support signing (read-only / watch wallet, or a // non-Solana chain). From 2289e152262a1c70223dbb4abebb697277139c36 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 09:26:23 +0000 Subject: [PATCH 15/36] fixup! Rename deprecated imports from wallet-standard/ui-registry --- packages/kit-plugin-wallet/test/_setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit-plugin-wallet/test/_setup.ts b/packages/kit-plugin-wallet/test/_setup.ts index 3ebdba8..e7561ef 100644 --- a/packages/kit-plugin-wallet/test/_setup.ts +++ b/packages/kit-plugin-wallet/test/_setup.ts @@ -93,12 +93,12 @@ const rawToUi = new Map(); const nameToRaw = new Map(); vi.mock('@wallet-standard/ui-registry', () => ({ - getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: (raw: object) => { + getOrCreateUiWalletForStandardWallet: (raw: object) => { const ui = rawToUi.get(raw); if (!ui) throw new Error('No UiWallet registered for this raw wallet'); return ui; }, - getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: (ui: UiWallet) => { + getWalletForHandle: (ui: UiWallet) => { // Return the latest raw wallet for this name, so refreshUiWallet // picks up updated registrations. const raw = nameToRaw.get(ui.name); From f5db399449ac42a7674467dce5ef00182ee36960 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 09:27:34 +0000 Subject: [PATCH 16/36] Handle missing signIn feature + no-op wallet change events --- packages/kit-plugin-wallet/README.md | 2 + packages/kit-plugin-wallet/src/store.ts | 69 +++++++------- packages/kit-plugin-wallet/src/types.ts | 5 +- packages/kit-plugin-wallet/test/store.test.ts | 93 ++++++++++++++++++- .../kit-plugin-wallet/test/wallet.test.ts | 51 +++++++++- 5 files changed, 179 insertions(+), 41 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index f66cd72..39522d9 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -244,6 +244,8 @@ By default the plugin uses `localStorage` to remember the last connected wallet All four wallet plugins are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. +React Native is treated the same way as the server: wallet-standard browser discovery is not available, so the plugin returns the same inert stub (`status` stays `'pending'`, actions throw). Native wallet integration would need a different discovery mechanism and is out of scope for this plugin. + ```ts const client = createClient() .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index e6c1a45..c1b2619 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -233,66 +233,60 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { StandardEvents, ) as StandardEventsFeature[typeof StandardEvents]; - return eventsFeature.on('change', properties => { + // The handler ignores the changed-property flags (`accounts`, `chains`, + // `features`) and instead reconciles the active account against the + // refreshed wallet. The signer is derived entirely from the account's + // features and chains, and the wallet-ui registry regenerates the + // account reference precisely when one of those changes — so the + // account reference is the single source of truth for whether the + // signer is affected, regardless of which flag the wallet sets. + return eventsFeature.on('change', () => { const refreshed = refreshUiWallet(uiWallet); - // If the wallet no longer passes the filter, disconnect. + // If the wallet no longer passes the filter (e.g. it dropped the + // configured chain or a required feature), disconnect. if (!filterWallet(refreshed)) { disconnectLocally(); return; } - let updates: Partial = { connectedWallet: refreshed }; - - if (properties.accounts) { - const result = handleAccountsChanged(refreshed); - if (result === null) { - disconnectLocally(); - return; - } - updates = { ...updates, ...result }; - } - // Skip features handler when accounts also changed — the account - // handler already creates a fresh signer for the new account. - // Running both would compute the signer from the stale account - // in state (setState hasn't been called yet). - if (properties.features && !properties.accounts) { - updates = { ...updates, ...handleFeaturesChanged() }; + const result = reconcileActiveAccount(refreshed); + if (result === null) { + disconnectLocally(); + return; } - updateState(updates); + // `connectedWallet` is always refreshed so consumers reading + // `getState().connected.wallet` see current metadata; the signer + // (in `result`) only churns when the active account changed, which + // keeps the snapshot referentially stable on no-op change events. + updateState({ connectedWallet: refreshed, ...result }); - if (updates.account) { - persistAccount(updates.account, refreshed); + if (result.account) { + persistAccount(result.account, refreshed); } }); } - function handleAccountsChanged(wallet: UiWallet): Partial | null { + function reconcileActiveAccount(wallet: UiWallet): Partial | null { const newAccounts = wallet.accounts; + // No accounts left — the caller disconnects. if (newAccounts.length === 0) return null; const currentAddress = state.account?.address; const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; const activeAccount = stillPresent ?? newAccounts[0]; - // Same reference — nothing changed for this account. + // Same reference — nothing the signer depends on changed, so leave the + // signer untouched to preserve referential stability. if (activeAccount === state.account) return {}; - // Account changed (new account, or same address with updated features). + // The active account changed (switched, removed, or regenerated because + // its features/chains changed) — recreate the signer for it. return { account: activeAccount, signer: tryCreateSigner(activeAccount) }; } - function handleFeaturesChanged(): Partial { - // Features changed but wallet is still valid — recreate signer - // to pick up new capabilities or drop removed ones. - if (state.account) { - return { signer: tryCreateSigner(state.account) }; - } - return {}; - } - // -- Connection lifecycle ---------------------------------------------- async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise { @@ -436,14 +430,19 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { options?: WalletActionOptions, ): Promise { options?.abortSignal?.throwIfAborted(); + + // Resolve the feature before mutating any state. getWalletFeature throws + // WalletStandardError synchronously when the wallet doesn't support + // solana:signIn — doing this first means an unsupported wallet is a + // no-op that never tears down an existing connection. + const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; + userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; updateState({ status: 'connecting' }); try { - // getWalletFeature throws WalletStandardError if not supported. - const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; const [result] = await signInFeature.signIn(input); // A newer connect/signIn was started while we were awaiting. Reject diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 6b6b26c..1e0faf8 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -18,8 +18,9 @@ export type WalletSigner = TransactionSigner | (MessageSigner & TransactionSigne * The connection status of the wallet plugin. * * - `pending` — not yet initialized. Initial state on both server and browser. - * On the server this state is permanent. In the browser it resolves to - * `disconnected` or `reconnecting` once the storage check completes. + * On the server — and in React Native, which is treated the same way — this + * state is permanent. In the browser it resolves to `disconnected` or + * `reconnecting` once the storage check completes. * - `disconnected` — initialized, no wallet connected. * - `connecting` — a user-initiated connection request is in progress. * - `connected` — a wallet is connected. diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index a08021b..e81bd36 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -447,6 +447,30 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { await expect(store.signIn(mockWallet, {})).rejects.toThrow(); }); + it('signIn on an unsupported wallet leaves an existing connection intact', async () => { + const account = createMockAccount(); + // The connected wallet supports connect/events but NOT solana:signIn. + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + // signIn must reject because the feature is unsupported — and because + // the feature is resolved before any state is mutated, the live + // connection (and its status) must be untouched. + await expect(store.signIn(mockWallet, {})).rejects.toThrow(); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + }); + it('signIn connects via SIWS and sets up connection state', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ @@ -960,6 +984,40 @@ describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { expect(store.getState().status).toBe('disconnected'); }); + it('recreates the signer when the active account is regenerated on a chains change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + chains: ['solana:mainnet', 'solana:devnet'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Clear calls from connect so we only see the event-triggered call. + createSignerMock.mockClear(); + + // The wallet narrows its chains and the registry regenerates the active + // account (new reference, same address) while still supporting mainnet. + const refreshedAccount = { ...account, chains: ['solana:mainnet'] as const }; + updateRegisteredWallet( + createMockUiWallet({ + accounts: [refreshedAccount], + chains: ['solana:mainnet'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ chains: true }); + + // Still connected, and the signer was recreated for the regenerated account. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(createSignerMock).toHaveBeenCalledOnce(); + expect(createSignerMock.mock.calls[0][0]).toHaveProperty('address', account.address); + }); + it('switches to first account when current account is removed', async () => { const account1 = createMockAccount(); const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); @@ -1051,7 +1109,30 @@ describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { expect(state.connected!.signer).toBeDefined(); }); - it('signer becomes null after features change when signing is dropped', async () => { + it('does not churn the snapshot or signer on a no-op change event', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + const before = store.getState(); + createSignerMock.mockClear(); + + // A change event fires but nothing the signer depends on changed — the + // registry returns the same account/wallet handles. The snapshot must + // stay referentially stable and the signer must not be recreated. + walletEventHandler!({ features: true }); + + expect(store.getState()).toBe(before); + expect(createSignerMock).not.toHaveBeenCalled(); + }); + + it('signer becomes null when the active account is regenerated without signing support', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ accounts: [account], @@ -1063,10 +1144,18 @@ describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { await connectToWallet(store, mockWallet); expect(store.getState().connected!.signer).not.toBeNull(); - // Wallet drops signing support — bridge function throws. + // The active account is regenerated (new reference, same address) + // without signing features, so the bridge function now throws. + const readOnlyAccount = { ...account, features: [] as const }; createSignerMock.mockImplementation(() => { throw new Error('No signing features'); }); + updateRegisteredWallet( + createMockUiWallet({ + accounts: [readOnlyAccount], + name: 'TestWallet', + }), + ); walletEventHandler!({ features: true }); const state = store.getState(); diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts index df8063f..7a6e761 100644 --- a/packages/kit-plugin-wallet/test/wallet.test.ts +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -1,8 +1,8 @@ import { createClient, extendClient } from '@solana/kit'; import { describe, expect, it, vi } from 'vitest'; -import { walletPayer, walletSigner, walletWithoutSigner } from '../src'; -import { createMockAccount, createMockUiWallet, mockSigner, registerWallet } from './_setup'; +import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../src'; +import { createMockAccount, createMockUiWallet, createSignerMock, mockSigner, registerWallet } from './_setup'; describe.skipIf(!__BROWSER__)('walletWithoutSigner plugin (browser)', () => { it('adds wallet namespace to client', () => { @@ -115,6 +115,53 @@ describe.skipIf(!__BROWSER__)('walletSigner plugin (browser)', () => { }); }); +describe.skipIf(!__BROWSER__)('walletIdentity plugin (browser)', () => { + it('sets identity but not payer', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.identity).toBe(mockSigner); + // walletIdentity must not define a payer getter. + expect('payer' in client).toBe(false); + }); + + it('identity throws when not connected', () => { + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + expect(() => client.identity).toThrow('No signing wallet connected'); + }); +}); + +describe.skipIf(!__BROWSER__)('wallet plugin read-only wallet (browser)', () => { + it('connects with a null signer and payer throws SIGNER_NOT_AVAILABLE', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'ReadOnlyWallet' }); + registerWallet(mockWallet); + + // The bridge function throws — the wallet supports no signing features. + createSignerMock.mockImplementation(() => { + throw new Error('No signing features'); + }); + + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + // Connection succeeds, but with no signer. + const { connected } = client.wallet.getState(); + expect(connected).not.toBeNull(); + expect(connected!.signer).toBeNull(); + + // The dynamic payer getter surfaces the read-only state. + expect(() => client.payer).toThrow('Connected wallet does not support signing'); + }); +}); + describe.skipIf(!__BROWSER__)('wallet plugin duplicate guard', () => { it('throws when using two wallet plugins on the same client', () => { expect(() => From c96d700df36ee25c762552ce7814c2a86a93c1b2 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 10:34:29 +0000 Subject: [PATCH 17/36] fix disconnect during auto-reconnect --- packages/kit-plugin-wallet/src/store.ts | 17 +++++- packages/kit-plugin-wallet/test/store.test.ts | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index c1b2619..b361274 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -343,7 +343,22 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { async function disconnect(options?: WalletActionOptions): Promise { options?.abortSignal?.throwIfAborted(); - if (!state.connectedWallet) return; + + // No connected wallet, but an auto-reconnect may still be in flight: + // during 'reconnecting' a silent reconnect or the late-registration + // listener can complete and connect, overriding this explicit + // disconnect. Record the user's intent so auto-connect won't re-arm, + // tear down the late-registration listener, and bump the generation so + // any in-flight connect/signIn/silent-reconnect bails at its guard + // instead of resuming. + if (!state.connectedWallet) { + userHasSelected = true; + cancelReconnect(); + connectGeneration++; + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } const currentWallet = state.connectedWallet; const generation = connectGeneration; diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index e81bd36..77d9f21 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1482,6 +1482,58 @@ describe.skipIf(!__BROWSER__)('store late wallet registration (browser)', () => expect(store.getState().connected!.account.address).toBe(account.address); }); + it('disconnect while waiting for a late-registering wallet prevents it from connecting', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User explicitly disconnects while still waiting for the saved wallet. + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + + // The saved wallet registers late — it must not override the disconnect. + const lateWallet = createMockUiWallet({ accounts: [account], name: 'LateWallet' }); + lateRegisterWallet(lateWallet); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + expect(connectMock).not.toHaveBeenCalled(); + }); + + it('disconnect during an in-flight silent reconnect prevents it from connecting', async () => { + const account = createMockAccount(); + const wallet = createMockUiWallet({ accounts: [account], name: 'SavedWallet' }); + registerWallet(wallet); + + // Hold the silent reconnect's connect pending so it's in flight when the + // user disconnects. + const reconnectConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(reconnectConnect.promise); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User disconnects while the silent reconnect is still awaiting connect. + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + + // Silent reconnect resolves — but it's stale, so it must not connect. + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + it('clears storage when saved wallet is registered but filtered out', async () => { const account = createMockAccount(); const storage = createMockStorage({ 'kit-wallet': `FilteredWallet:${account.address}` }); From 3897f70cf36cbcdea0fcec2cbc1b2a59b721962d Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 10:50:11 +0000 Subject: [PATCH 18/36] Handle error from reading localStorage global --- packages/kit-plugin-wallet/src/store.ts | 14 ++++++++++- packages/kit-plugin-wallet/test/store.test.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index b361274..c6e2ceb 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -95,7 +95,19 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { let connectGeneration = 0; // Resolve storage: default to localStorage in browser, null to disable. - const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? localStorage); + // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed + // iframes or when third-party storage is blocked, so the default resolution + // degrades to `null` rather than letting the whole plugin-install chain + // throw. This matches the best-effort persistence applied to reads/writes. + function resolveDefaultStorage(): WalletStorage | null { + try { + return localStorage; + } catch { + return null; + } + } + + const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? resolveDefaultStorage()); const storageKey = config.storageKey ?? 'kit-wallet'; // -- State management -------------------------------------------------- diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 77d9f21..32ef0fa 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1306,6 +1306,31 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(store.getState().connected!.account.address).toBe(account.address); }); + it('does not throw at install time when accessing localStorage throws', () => { + // Sandboxed iframes / blocked third-party storage make even *reading* + // the `localStorage` global throw a `SecurityError`. The default storage + // resolution must degrade to disabled persistence rather than throwing + // out of `createWalletStore` (and the `.use(walletSigner(...))` chain). + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + get() { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }, + }); + + try { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(store.getState().status).toBe('disconnected'); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'localStorage', descriptor); + } else { + delete (globalThis as { localStorage?: unknown }).localStorage; + } + } + }); + it('clears storage on disconnect', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ From 3b8cfca4824031646539159c1b6866ea5ef263a7 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 11:03:31 +0000 Subject: [PATCH 19/36] Improve handling of wallet unregistering --- packages/kit-plugin-wallet/src/store.ts | 38 ++++++++++++ packages/kit-plugin-wallet/test/_setup.ts | 7 ++- packages/kit-plugin-wallet/test/store.test.ts | 62 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index c6e2ceb..b103080 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -183,6 +183,18 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { return Object.freeze(registry.get().map(getOrCreateUiWalletForStandardWallet).filter(filterWallet)); } + // True if a wallet matching `uiWallet` (by name) is currently registered and + // passes the filter. Used to re-check membership after an awaited connect / + // sign-in / silent reconnect resolves: the wallet may have unregistered (or + // dropped a required feature/chain) while its prompt was open, and the + // unregister handler can't tear down a connection that hasn't been + // established yet. Compared by name — the same identity the unregister + // handler uses — because the wallet-ui registry hands back a fresh UiWallet + // reference once connect adds accounts, so reference equality would not hold. + function isWalletStillAvailable(uiWallet: UiWallet): boolean { + return buildWalletList().some(w => w.name === uiWallet.name); + } + updateState({ wallets: buildWalletList() }); // Listen for new wallets being registered @@ -329,6 +341,16 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { throw new DOMException('Wallet connect was superseded by a newer connect or sign-in', 'AbortError'); } + // The wallet may have unregistered (or dropped a required + // feature/chain) while its connect prompt was open. The unregister + // handler only disconnects an already-connected wallet, so without + // this re-check the store would be left connected to a wallet absent + // from `wallets`. Bail the same way an empty account list does. + if (!isWalletStillAvailable(uiWallet)) { + disconnectLocally(); + return []; + } + // Refresh UiWallet to get updated accounts after connect. const refreshedWallet = refreshUiWallet(uiWallet); const allAccounts = refreshedWallet.accounts; @@ -480,6 +502,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { throw new DOMException('Wallet sign-in was superseded by a newer connect or sign-in', 'AbortError'); } + // The wallet may have unregistered (or dropped a required + // feature/chain) while its sign-in prompt was open. Bail rather than + // leaving the store connected to a wallet absent from `wallets`. + if (!isWalletStillAvailable(uiWallet)) { + disconnectLocally(); + return result; + } + // Set up full connection state using the account from the sign-in response. const refreshedWallet = refreshUiWallet(uiWallet); const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); @@ -637,6 +667,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // A newer connect/signIn was started while we were awaiting — bail. if (generation !== connectGeneration) return; + // The wallet may have unregistered (or dropped a required + // feature/chain) while the silent reconnect was in flight. + if (!isWalletStillAvailable(uiWallet)) { + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } + const refreshedWallet = refreshUiWallet(uiWallet); const allAccounts = refreshedWallet.accounts; diff --git a/packages/kit-plugin-wallet/test/_setup.ts b/packages/kit-plugin-wallet/test/_setup.ts index e7561ef..b29b69d 100644 --- a/packages/kit-plugin-wallet/test/_setup.ts +++ b/packages/kit-plugin-wallet/test/_setup.ts @@ -191,8 +191,11 @@ export function unregisterWallet(uiWallet: UiWallet): void { if (raw) { const idx = registeredWallets.indexOf(raw as (typeof registeredWallets)[number]); if (idx >= 0) registeredWallets.splice(idx, 1); - rawToUi.delete(raw); - nameToRaw.delete(uiWallet.name); + // Intentionally leave `rawToUi`/`nameToRaw` intact. The real + // `@wallet-standard/ui-registry` resolves handles by object identity, + // so `getWalletForHandle`/`getOrCreateUiWalletForStandardWallet` keep + // working for an unregistered wallet — only the registry's `get()` + // list drops it. } registryListeners.unregister.forEach(l => l()); } diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 32ef0fa..2a08d95 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -125,6 +125,68 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(state.wallets.length).toBe(0); }); + it('does not connect when the wallet unregisters during an in-flight connect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Pause the wallet's connect() so we can unregister mid-flight. + const { promise, resolve } = Promise.withResolvers(); + connectMock.mockReturnValueOnce(promise); + + const connectPromise = store.connect(mockWallet); + + // The wallet unregisters while its connect prompt is open. state.connectedWallet + // is still null at this point, so the unregister handler doesn't disconnect — it + // only drops the wallet from the list. + unregisterWallet(mockWallet); + expect(store.getState().wallets.length).toBe(0); + + // The connect prompt now resolves. + resolve(); + await connectPromise; + + // The store must not be left connected to a wallet absent from the list. + const state = store.getState(); + expect(state.connected).toBeNull(); + expect(state.status).toBe('disconnected'); + expect(state.wallets.length).toBe(0); + }); + + it('does not connect when the wallet unregisters during an in-flight signIn', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Pause the wallet's signIn() so we can unregister mid-flight. + const { promise, resolve } = Promise.withResolvers(); + signInMock.mockReturnValueOnce(promise); + + const signInPromise = store.signIn(mockWallet, {}); + + unregisterWallet(mockWallet); + expect(store.getState().wallets.length).toBe(0); + + resolve([{ account: { address: account.address } }]); + await signInPromise; + + const state = store.getState(); + expect(state.connected).toBeNull(); + expect(state.status).toBe('disconnected'); + expect(state.wallets.length).toBe(0); + }); + it('connects to a wallet', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ From 8968e652f108afaf858d4c0c44cd5469854b8ad1 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 13:26:35 +0000 Subject: [PATCH 20/36] Better handle reverting to previous connection when a connect fails --- packages/kit-plugin-wallet/README.md | 2 + packages/kit-plugin-wallet/src/store.ts | 36 ++++++-- packages/kit-plugin-wallet/src/types.ts | 19 +++- packages/kit-plugin-wallet/test/store.test.ts | 86 +++++++++++++++++-- 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 39522d9..e59b287 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -137,6 +137,8 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref const accounts = await client.wallet.connect(selectedWallet); ``` + Connecting to a different wallet keeps the current one connected until the new connection succeeds. If the attempt fails — the user declines the prompt, the wallet authorizes no accounts, or it becomes unavailable — the previous connection is left untouched rather than torn down. The same holds for `signIn`. While the attempt is in flight, `status` is `'connecting'` but `getState().connected` still describes the existing connection. + If another `connect` or `signIn` starts before this one resolves (e.g. an accidental double-click), the newer request wins and owns the connection — the superseded call rejects with a `DOMException` whose `name` is `'AbortError'`. Ignore it the way you'd ignore any aborted operation: ```ts diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index b103080..53d932b 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -347,7 +347,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // this re-check the store would be left connected to a wallet absent // from `wallets`. Bail the same way an empty account list does. if (!isWalletStillAvailable(uiWallet)) { - disconnectLocally(); + revertToPreviousConnectionOrDisconnect(); return []; } @@ -356,7 +356,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const allAccounts = refreshedWallet.accounts; if (allAccounts.length === 0) { - disconnectLocally(); + revertToPreviousConnectionOrDisconnect(); return allAccounts; } @@ -367,9 +367,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { setConnected(activeAccount, refreshedWallet); return allAccounts; } catch (error) { - // Only clean up if we're still the active connection attempt. + // Only act if we're still the active connection attempt; a + // superseded attempt must leave the newer connection untouched. if (generation === connectGeneration) { - disconnectLocally(); + revertToPreviousConnectionOrDisconnect(); } throw error; } @@ -428,6 +429,23 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { clearPersistedAccount(); } + // A `connect`/`signIn` attempt failed to establish a new connection. While + // such an attempt is in flight only `status` is moved to 'connecting' — the + // existing connection's account, signer, event subscription, and persisted + // key are untouched until `setConnected` runs on success. So if a prior + // connection is still intact, revert to it (declining the new wallet must not + // log the user out of the one they had). Otherwise there's nothing to keep, + // so disconnect cleanly. The active-attempt (`generation`) guard at each call + // site ensures a superseded attempt never reverts the connection a newer one + // owns. + function revertToPreviousConnectionOrDisconnect(): void { + if (state.connectedWallet && state.account) { + updateState({ status: 'connected' }); + } else { + disconnectLocally(); + } + } + function selectAccount(account: UiWalletAccount): void { if (!state.connectedWallet) { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); @@ -506,7 +524,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // feature/chain) while its sign-in prompt was open. Bail rather than // leaving the store connected to a wallet absent from `wallets`. if (!isWalletStillAvailable(uiWallet)) { - disconnectLocally(); + revertToPreviousConnectionOrDisconnect(); return result; } @@ -515,9 +533,9 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); if (!activeAccount) { - // The signed-in account isn't in the wallet — bad state, disconnect - // so the user can try a fresh connect/sign-in. - disconnectLocally(); + // The signed-in account isn't in the wallet — bad state. Revert to + // any prior connection so the user can try a fresh connect/sign-in. + revertToPreviousConnectionOrDisconnect(); return result; } @@ -525,7 +543,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { return result; } catch (error) { if (generation === connectGeneration) { - disconnectLocally(); + revertToPreviousConnectionOrDisconnect(); } throw error; } diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 1e0faf8..f679065 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -22,7 +22,11 @@ export type WalletSigner = TransactionSigner | (MessageSigner & TransactionSigne * state is permanent. In the browser it resolves to `disconnected` or * `reconnecting` once the storage check completes. * - `disconnected` — initialized, no wallet connected. - * - `connecting` — a user-initiated connection request is in progress. + * - `connecting` — a user-initiated connection request is in progress. When + * connecting to a different wallet while one is already connected, the + * existing connection is preserved until the new one succeeds — so + * {@link WalletState.connected} can be non-null during this state, and a + * failed attempt reverts to the previous connection. * - `connected` — a wallet is connected. * - `disconnecting` — a user-initiated disconnection request is in progress. * - `reconnecting` — auto-connect in progress (connecting to persisted wallet). @@ -45,6 +49,11 @@ export type WalletState = { * * `signer` is `null` for read-only / watch-only wallets that do not * support any signing feature. + * + * Independent of {@link status}: when {@link connect} or {@link signIn} is + * called for a different wallet while one is already connected, `connected` + * keeps describing the existing connection while `status` is `'connecting'`, + * and a failed attempt leaves it in place. */ readonly connected: { readonly account: UiWalletAccount; @@ -211,6 +220,11 @@ export type WalletNamespace = { * newly authorized account (or the first account if reconnecting). Creates * and caches a signer for the active account. * + * If a wallet is already connected, it stays connected until the new one is + * established: a failed attempt (rejected prompt, no authorized accounts, or + * the wallet becoming unavailable) leaves the previous connection in place + * rather than disconnecting it. + * * @returns All accounts from the wallet after connection. * @throws The wallet's rejection error if the user declines the prompt. * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or @@ -264,6 +278,9 @@ export type WalletNamespace = { * To sign in with the already-connected wallet, pass * `getState().connected.wallet`. * + * Like {@link connect}, a failed sign-in to a different wallet leaves any + * existing connection in place rather than disconnecting it. + * * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signIn`. * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 2a08d95..a7f8c90 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -614,6 +614,53 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(state.connected).toBeNull(); }); + it('reverts to the previous connection when signIn to another wallet is rejected', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + signInMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.signIn(wallet2, {})).rejects.toThrow('User rejected'); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('reverts to the previous connection when the signed-in account is not in the new wallet', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + // wallet2 reports an account that isn't among its accounts. + signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); + await store.signIn(wallet2, {}); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + it('reverts to disconnected if connect is rejected', async () => { const mockWallet = createMockUiWallet({ name: 'TestWallet' }); registerWallet(mockWallet); @@ -624,7 +671,7 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().status).toBe('disconnected'); }); - it('clears previous connection when connect fails', async () => { + it('reverts to the previous connection when connect to another wallet fails', async () => { const account = createMockAccount(); const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); const wallet2 = createMockUiWallet({ name: 'Wallet2' }); @@ -638,12 +685,14 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { connectMock.mockRejectedValueOnce(new Error('User rejected')); await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); - // Previous connection should be fully cleared. - expect(store.getState().status).toBe('disconnected'); - expect(store.getState().connected).toBeNull(); + // Declining wallet2 should leave wallet1 connected, not log the user out. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); }); - it('clears previous connection when new wallet returns zero accounts', async () => { + it('reverts to the previous connection when a new wallet returns zero accounts', async () => { const account = createMockAccount(); const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); const wallet2 = createMockUiWallet({ accounts: [], name: 'Wallet2' }); @@ -656,8 +705,31 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { await store.connect(wallet2); - expect(store.getState().status).toBe('disconnected'); - expect(store.getState().connected).toBeNull(); + // wallet2 never established a connection, so wallet1 stays connected. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('retains the persisted account when connect to another wallet fails', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account.address}`); + + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + // The persisted key for wallet1 must survive the failed connect so the + // next page load can still silently reconnect to it. + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account.address}`); }); it('stale connect rejection does not disconnect the active connection', async () => { From 279690308689b38a9f85d1c0e26e3ba2a9f1234d Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 15:00:16 +0000 Subject: [PATCH 21/36] Resolve signMessage against the account, not the wallet --- packages/kit-plugin-wallet/src/store.ts | 12 +-- packages/kit-plugin-wallet/test/_setup.ts | 74 +++++++++++++------ packages/kit-plugin-wallet/test/store.test.ts | 28 ++++++- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 53d932b..8ce786e 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -18,7 +18,7 @@ import { type StandardEventsFeature, } from '@wallet-standard/features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; -import { getWalletFeature } from '@wallet-standard/ui-features'; +import { getWalletAccountFeature, getWalletFeature } from '@wallet-standard/ui-features'; import { getOrCreateUiWalletForStandardWallet, getWalletForHandle } from '@wallet-standard/ui-registry'; import type { @@ -479,10 +479,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // Use the wallet feature directly rather than going through the cached // signer. This decouples message signing from transaction signing — // a wallet that supports solana:signMessage but not transaction signing - // still works. getWalletFeature throws WalletStandardError if the - // feature is not supported. - const signMessageFeature = getWalletFeature( - connectedWallet, + // still works. solana:signMessage is an account-scoped feature, so this + // resolves it against the active account: getWalletAccountFeature throws + // a WalletStandardError when either the wallet or the account does not + // support it. + const signMessageFeature = getWalletAccountFeature( + account, SolanaSignMessage, ) as SolanaSignMessageFeature[typeof SolanaSignMessage]; const [output] = await signMessageFeature.signMessage({ account, message }); diff --git a/packages/kit-plugin-wallet/test/_setup.ts b/packages/kit-plugin-wallet/test/_setup.ts index b29b69d..e6d7339 100644 --- a/packages/kit-plugin-wallet/test/_setup.ts +++ b/packages/kit-plugin-wallet/test/_setup.ts @@ -1,4 +1,8 @@ import { address } from '@solana/kit'; +import { + WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, + WalletStandardError, +} from '@wallet-standard/errors'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; import { afterEach, beforeEach, vi } from 'vitest'; @@ -6,11 +10,14 @@ import type { WalletStorage } from '../src/types'; // -- Mock helpers ----------------------------------------------------------- -export function createMockAccount(addr = '11111111111111111111111111111111'): UiWalletAccount { +export function createMockAccount( + addr = '11111111111111111111111111111111', + features: readonly string[] = ['solana:signTransaction'], +): UiWalletAccount { return { address: address(addr), chains: ['solana:mainnet'] as const, - features: ['solana:signTransaction'] as const, + features, icon: 'data:image/png;base64,', label: 'Account 1', publicKey: new Uint8Array(32), @@ -119,33 +126,52 @@ export let walletEventHandler: ((properties: Record) => void) | type IdentifierString = `${string}:${string}`; +function resolveFeatureImpl(feature: IdentifierString): unknown { + if (feature === 'standard:connect') { + return { connect: connectMock, version: '1.0.0' }; + } + if (feature === 'standard:disconnect') { + return { disconnect: disconnectMock, version: '1.0.0' }; + } + if (feature === 'standard:events') { + return { + on: (_event: string, handler: (properties: Record) => void) => { + walletEventHandler = handler; + return eventListenerCleanup; + }, + version: '1.0.0', + }; + } + if (feature === 'solana:signIn') { + return { signIn: signInMock, version: '1.0.0' }; + } + if (feature === 'solana:signMessage') { + return { signMessage: signMessageMock, version: '1.0.0' }; + } + throw new Error(`Feature ${feature} not supported`); +} + vi.mock('@wallet-standard/ui-features', () => ({ + // Mirrors the real `getWalletAccountFeature`: it first checks the account's + // own feature list, throwing a `WalletStandardError` when the account + // doesn't support the feature, then resolves the wallet-level + // implementation. + getWalletAccountFeature: (account: UiWalletAccount, feature: IdentifierString) => { + if (!account.features.includes(feature)) { + throw new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, { + address: account.address, + featureName: feature, + supportedChains: [...account.chains], + supportedFeatures: [...account.features], + }); + } + return resolveFeatureImpl(feature); + }, getWalletFeature: (wallet: UiWallet, feature: IdentifierString) => { if (!wallet.features.includes(feature)) { throw new Error(`Wallet "${wallet.name}" does not support ${feature}`); } - if (feature === 'standard:connect') { - return { connect: connectMock, version: '1.0.0' }; - } - if (feature === 'standard:disconnect') { - return { disconnect: disconnectMock, version: '1.0.0' }; - } - if (feature === 'standard:events') { - return { - on: (_event: string, handler: (properties: Record) => void) => { - walletEventHandler = handler; - return eventListenerCleanup; - }, - version: '1.0.0', - }; - } - if (feature === 'solana:signIn') { - return { signIn: signInMock, version: '1.0.0' }; - } - if (feature === 'solana:signMessage') { - return { signMessage: signMessageMock, version: '1.0.0' }; - } - throw new Error(`Feature ${feature} not supported`); + return resolveFeatureImpl(feature); }, })); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index a7f8c90..2bf8ab2 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1,4 +1,5 @@ import { SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; +import { isWalletStandardError } from '@wallet-standard/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createWalletStore } from '../src/store'; @@ -461,8 +462,11 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { ); }); - it('signMessage calls wallet feature directly when connected', async () => { - const account = createMockAccount(); + it('signMessage calls the account feature directly when connected', async () => { + const account = createMockAccount('11111111111111111111111111111111', [ + 'solana:signTransaction', + 'solana:signMessage', + ]); const mockWallet = createMockUiWallet({ accounts: [account], features: ['standard:connect', 'standard:events', 'solana:signMessage'], @@ -499,6 +503,26 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(); }); + it('signMessage throws a WalletStandardError when the connected account does not support solana:signMessage', async () => { + // The wallet advertises solana:signMessage, but the active account does + // not carry it in its own feature list. Feature support must be checked + // at the account level, so this should surface a WalletStandardError + // rather than being passed through to the wallet. + const account = createMockAccount('11111111111111111111111111111111', ['solana:signTransaction']); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'AccountWithoutSignMessage', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toSatisfy(isWalletStandardError); + expect(signMessageMock).not.toHaveBeenCalled(); + }); + it('signIn throws when wallet does not support solana:signIn', async () => { const mockWallet = createMockUiWallet({ name: 'NoSignIn' }); registerWallet(mockWallet); From d2f705f5bac35db996483af1a470b93ae5613153 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 15:25:39 +0000 Subject: [PATCH 22/36] Avoid re-rendering if the filtered wallet list is unchanged after un/register --- packages/kit-plugin-wallet/src/store.ts | 22 +++++++++++-- packages/kit-plugin-wallet/test/store.test.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 8ce786e..67c1b68 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -183,6 +183,24 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { return Object.freeze(registry.get().map(getOrCreateUiWalletForStandardWallet).filter(filterWallet)); } + // Rebuilds the filtered wallet list but returns the *existing* `state.wallets` + // reference when the contents are unchanged. `buildWalletList` always + // allocates a fresh frozen array, so without this a registry event for a + // wallet that gets filtered out (or any event that doesn't alter the visible + // set) would still produce a new snapshot and notify every listener — a + // spurious re-render. Wallets are compared by reference, in order: the + // wallet-ui registry hands back a stable UiWallet for a given underlying + // wallet until its accounts/features/chains change, so reference equality is + // a sound same-contents check here. + function reconcileWalletList(): readonly UiWallet[] { + const next = buildWalletList(); + const prev = state.wallets; + if (next.length === prev.length && next.every((w, i) => w === prev[i])) { + return prev; + } + return next; + } + // True if a wallet matching `uiWallet` (by name) is currently registered and // passes the filter. Used to re-check membership after an awaited connect / // sign-in / silent reconnect resolves: the wallet may have unregistered (or @@ -199,12 +217,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // Listen for new wallets being registered const unsubRegister = registry.on('register', () => { - updateState({ wallets: buildWalletList() }); + updateState({ wallets: reconcileWalletList() }); }); // Listen for wallets being unregistered — if the connected wallet is removed, disconnect const unsubUnregister = registry.on('unregister', () => { - const newWallets = buildWalletList(); + const newWallets = reconcileWalletList(); const updates: Partial = { wallets: newWallets }; // If the connected wallet was unregistered, disconnect locally. diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 2bf8ab2..db01f14 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -106,6 +106,37 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().wallets[0].name).toBe('Allowed'); }); + it('does not notify listeners when a register event leaves the filtered wallet list unchanged', () => { + const visible = createMockUiWallet({ name: 'Visible' }); + registerWallet(visible); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const before = store.getState(); + const listener = vi.fn(); + store.subscribe(listener); + + // A wallet for a different chain registers — it's filtered out, so the + // visible wallet list is unchanged. The snapshot must stay referentially + // stable and no listener should fire. + lateRegisterWallet(createMockUiWallet({ chains: ['solana:devnet'], name: 'OtherChain' })); + + expect(listener).not.toHaveBeenCalled(); + expect(store.getState()).toBe(before); + expect(store.getState().wallets).toBe(before.wallets); + }); + + it('notifies listeners when a register event adds a wallet to the filtered list', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const listener = vi.fn(); + store.subscribe(listener); + + lateRegisterWallet(createMockUiWallet({ name: 'Appeared' })); + + expect(listener).toHaveBeenCalledOnce(); + expect(store.getState().wallets.length).toBe(1); + expect(store.getState().wallets[0].name).toBe('Appeared'); + }); + it('disconnects when connected wallet is unregistered', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ From d6e3094b3197b69c66b6527db0ba8b9b4c009023 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 16:09:53 +0000 Subject: [PATCH 23/36] Throw if connect/signIn becomes unavailable/does not succeed --- packages/kit-plugin-wallet/README.md | 2 +- packages/kit-plugin-wallet/src/store.ts | 30 +++++++++------- packages/kit-plugin-wallet/src/types.ts | 25 +++++++++---- packages/kit-plugin-wallet/test/store.test.ts | 35 +++++++++++++------ 4 files changed, 61 insertions(+), 31 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index e59b287..5a895a4 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -137,7 +137,7 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref const accounts = await client.wallet.connect(selectedWallet); ``` - Connecting to a different wallet keeps the current one connected until the new connection succeeds. If the attempt fails — the user declines the prompt, the wallet authorizes no accounts, or it becomes unavailable — the previous connection is left untouched rather than torn down. The same holds for `signIn`. While the attempt is in flight, `status` is `'connecting'` but `getState().connected` still describes the existing connection. + A successful call resolves only once the wallet is connected; any failure rejects. Connecting to a different wallet keeps the current one connected until the new connection succeeds — so if the attempt fails (the user declines the prompt, the wallet authorizes no accounts, or it becomes unavailable), the previous connection is left untouched rather than torn down. The same holds for `signIn`, which rejects rather than resolving with its sign-in output when the connection can't be established. While the attempt is in flight, `status` is `'connecting'` but `getState().connected` still describes the existing connection. If another `connect` or `signIn` starts before this one resolves (e.g. an accidental double-click), the newer request wins and owns the connection — the superseded call rejects with a `DOMException` whose `name` is `'AbortError'`. Ignore it the way you'd ignore any aborted operation: diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 67c1b68..567eab3 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -363,19 +363,21 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // feature/chain) while its connect prompt was open. The unregister // handler only disconnects an already-connected wallet, so without // this re-check the store would be left connected to a wallet absent - // from `wallets`. Bail the same way an empty account list does. + // from `wallets`. Throw so the connection failure surfaces as a + // rejection; the `catch` below reverts to any prior connection. if (!isWalletStillAvailable(uiWallet)) { - revertToPreviousConnectionOrDisconnect(); - return []; + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); } // Refresh UiWallet to get updated accounts after connect. const refreshedWallet = refreshUiWallet(uiWallet); const allAccounts = refreshedWallet.accounts; + // No authorized accounts means there's nothing to connect to. Throw + // rather than resolving so a caller can't mistake an empty result for + // a successful connection; the `catch` reverts to any prior wallet. if (allAccounts.length === 0) { - revertToPreviousConnectionOrDisconnect(); - return allAccounts; + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); } // Prefer the first newly authorized account. @@ -541,11 +543,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } // The wallet may have unregistered (or dropped a required - // feature/chain) while its sign-in prompt was open. Bail rather than - // leaving the store connected to a wallet absent from `wallets`. + // feature/chain) while its sign-in prompt was open. Throw rather than + // resolving while the store is left connected to no wallet (or a + // different one); the `catch` below reverts to any prior connection. if (!isWalletStillAvailable(uiWallet)) { - revertToPreviousConnectionOrDisconnect(); - return result; + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); } // Set up full connection state using the account from the sign-in response. @@ -553,10 +555,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); if (!activeAccount) { - // The signed-in account isn't in the wallet — bad state. Revert to - // any prior connection so the user can try a fresh connect/sign-in. - revertToPreviousConnectionOrDisconnect(); - return result; + // The wallet signed in with an account it doesn't expose — a + // protocol violation that can't be mapped to a connection. Throw + // so the failure surfaces as a rejection rather than resolving + // with a sign-in result the store can't act on; the `catch` + // reverts to any prior connection. + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); } setConnected(activeAccount, refreshedWallet); diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index f679065..35ed49f 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -220,13 +220,18 @@ export type WalletNamespace = { * newly authorized account (or the first account if reconnecting). Creates * and caches a signer for the active account. * - * If a wallet is already connected, it stays connected until the new one is - * established: a failed attempt (rejected prompt, no authorized accounts, or - * the wallet becoming unavailable) leaves the previous connection in place + * Resolving means the wallet is connected; any failure rejects. If a wallet + * is already connected, it stays connected until the new one is established: + * a failed attempt — a rejected prompt, no authorized accounts, or the + * wallet becoming unavailable — leaves the previous connection in place * rather than disconnecting it. * * @returns All accounts from the wallet after connection. * @throws The wallet's rejection error if the user declines the prompt. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if the wallet + * authorizes no accounts, or unregisters (or drops a required + * feature/chain) while its connect prompt is open. Any previously + * connected wallet is left in place. * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or * `signIn` is started before this call resolves. The newer request wins * and owns the resulting connection; this superseded call rejects so it @@ -269,8 +274,9 @@ export type WalletNamespace = { * Sign In With Solana (SIWS-as-connect). * * Connects the wallet, calls `solana:signIn`, sets the returned account as - * active, and creates a signer. After completion, the client is in the same - * state as if {@link connect} had been called. + * active, and creates a signer. Resolving means the client is in the same + * state as if {@link connect} had been called; any failure to connect + * rejects rather than resolving while disconnected. * * All fields on `SolanaSignInInput` are optional — pass `{}` if no sign-in * customization is needed. @@ -278,11 +284,16 @@ export type WalletNamespace = { * To sign in with the already-connected wallet, pass * `getState().connected.wallet`. * - * Like {@link connect}, a failed sign-in to a different wallet leaves any - * existing connection in place rather than disconnecting it. + * Like {@link connect}, a failed sign-in to a different wallet rejects and + * leaves any existing connection in place rather than disconnecting it. * + * @returns The wallet's sign-in output, once the connection is established. * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signIn`. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if the wallet + * unregisters (or drops a required feature/chain) while its sign-in prompt + * is open, or signs in with an account it does not expose. Any previously + * connected wallet is left in place. * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or * `signIn` is started before this call resolves. The newer request wins * and owns the resulting connection; this superseded call rejects so it diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index db01f14..13162c3 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -157,7 +157,7 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(state.wallets.length).toBe(0); }); - it('does not connect when the wallet unregisters during an in-flight connect', async () => { + it('rejects when the wallet unregisters during an in-flight connect', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ accounts: [account], @@ -181,7 +181,11 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { // The connect prompt now resolves. resolve(); - await connectPromise; + // The connection could not be established, so the attempt rejects rather + // than resolving while the store is left disconnected. + await expect(connectPromise).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); // The store must not be left connected to a wallet absent from the list. const state = store.getState(); @@ -190,7 +194,7 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(state.wallets.length).toBe(0); }); - it('does not connect when the wallet unregisters during an in-flight signIn', async () => { + it('rejects when the wallet unregisters during an in-flight signIn', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ accounts: [account], @@ -211,7 +215,11 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().wallets.length).toBe(0); resolve([{ account: { address: account.address } }]); - await signInPromise; + // signIn cannot connect to a wallet absent from the list, so it rejects + // rather than resolving with the sign-in output while disconnected. + await expect(signInPromise).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); const state = store.getState(); expect(state.connected).toBeNull(); @@ -647,7 +655,7 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().status).toBe('connected'); }); - it('signIn disconnects when signed-in account is not in wallet', async () => { + it('signIn rejects when the signed-in account is not in the wallet', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ accounts: [account], @@ -660,10 +668,12 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); - const result = await store.signIn(mockWallet, {}); + // The wallet signed in with an account it doesn't expose — a protocol + // violation that can't be mapped to a connection, so signIn rejects. + await expect(store.signIn(mockWallet, {})).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); - // Should still return the result, but not establish connection. - expect(result).toEqual({ account: { address: 'nonexistent' } }); const state = store.getState(); expect(state.status).toBe('disconnected'); expect(state.connected).toBeNull(); @@ -708,7 +718,9 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { // wallet2 reports an account that isn't among its accounts. signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); - await store.signIn(wallet2, {}); + await expect(store.signIn(wallet2, {})).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); const state = store.getState(); expect(state.status).toBe('connected'); @@ -758,7 +770,10 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { await store.connect(wallet1); expect(store.getState().status).toBe('connected'); - await store.connect(wallet2); + // wallet2 authorized no accounts, so the connect attempt rejects. + await expect(store.connect(wallet2)).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); // wallet2 never established a connection, so wallet1 stays connected. const state = store.getState(); From 3682d1ab03d6982d1199d8614ac8458294e2a001 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 16:22:27 +0000 Subject: [PATCH 24/36] Improve test coverage --- packages/kit-plugin-wallet/test/store.test.ts | 108 ++++++++++++++++++ .../kit-plugin-wallet/test/wallet.test.ts | 36 +++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 13162c3..a65895c 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1121,6 +1121,114 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { }); }); +// -- Abort signal tests ------------------------------------------------------- + +describe.skipIf(!__BROWSER__)('store action abort signals (browser)', () => { + it('rejects connect with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // The pre-abort check is the first thing connect does, so it bails before + // moving to 'connecting' or invoking the wallet's connect feature. + await expect(store.connect(mockWallet, { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(connectMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('rejects disconnect with the signal reason and leaves the connection intact when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // disconnect bails before calling the wallet's disconnect feature, so the + // existing connection is left untouched. + await expect(store.disconnect({ abortSignal: controller.signal })).rejects.toThrow('aborted by caller'); + expect(disconnectMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('connected'); + }); + + it('rejects signMessage with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount('11111111111111111111111111111111', [ + 'solana:signTransaction', + 'solana:signMessage', + ]); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + await expect(store.signMessage(new Uint8Array(), { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(signMessageMock).not.toHaveBeenCalled(); + }); + + it('rejects signIn with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // The pre-abort check runs before signIn resolves the feature or moves to + // 'connecting', so the wallet is never prompted. + await expect(store.signIn(mockWallet, {}, { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(signInMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('disconnected'); + }); + + it('throws an AbortError by default when the signal is aborted without a reason', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(); + + // An abort with no explicit reason surfaces the default DOMException, so + // callers can detect the cancellation via the standard AbortError name. + const error = await store.connect(mockWallet, { abortSignal: controller.signal }).catch((e: unknown) => e); + expect((error as DOMException).name).toBe('AbortError'); + expect(connectMock).not.toHaveBeenCalled(); + }); +}); + // -- Wallet event handler tests ----------------------------------------------- describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts index 7a6e761..731a2e2 100644 --- a/packages/kit-plugin-wallet/test/wallet.test.ts +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -1,4 +1,4 @@ -import { createClient, extendClient } from '@solana/kit'; +import { createClient, extendClient, SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, SolanaError } from '@solana/kit'; import { describe, expect, it, vi } from 'vitest'; import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../src'; @@ -172,3 +172,37 @@ describe.skipIf(!__BROWSER__)('wallet plugin duplicate guard', () => { ).toThrow('Only one wallet plugin can be used per client'); }); }); + +describe.skipIf(__BROWSER__)('wallet plugins (SSR / non-browser)', () => { + // On the server the store is an inert stub that reports 'pending' rather than + // 'disconnected', so the signer getters surface NO_SIGNER_CONNECTED with that + // status. This is the path a shared client chain hits during SSR. + const NO_SIGNER_ON_SERVER = new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: 'pending' }); + + it('adds the wallet namespace in pending status', () => { + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); + expect(client.wallet).toBeDefined(); + expect(client.wallet.getState().status).toBe('pending'); + }); + + it('walletPayer payer throws NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('walletIdentity identity throws NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + expect(() => client.identity).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('walletSigner payer and identity both throw NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow(NO_SIGNER_ON_SERVER); + expect(() => client.identity).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('cleanup via Symbol.dispose does not throw', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client[Symbol.dispose]()).not.toThrow(); + }); +}); From b2ebcdd79c0773006752fe6f8d2c21fcfacd0d90 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 16:58:20 +0000 Subject: [PATCH 25/36] Avoid auto-connect after store is disposed --- packages/kit-plugin-wallet/src/store.ts | 26 +++++++++++++---- packages/kit-plugin-wallet/test/store.test.ts | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 567eab3..2181be2 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -93,6 +93,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { let reconnectCleanup: (() => void) | null = null; let userHasSelected = false; let connectGeneration = 0; + let disposed = false; // Resolve storage: default to localStorage in browser, null to disable. // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed @@ -252,6 +253,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void { + // The store may have been disposed while a connect / sign-in / silent + // reconnect awaited. The `connectGeneration` guard at each call site + // doesn't cover the auto-connect IIFE resuming from its storage read — + // it captures a fresh generation in `attemptSilentReconnect` after + // disposal. Bail here so a disposed store never re-subscribes to wallet + // events (a listener the already-run disposer can't clean up) or reports + // itself connected. + if (disposed) return; const signer = tryCreateSigner(account); resubscribeToWalletEvents(wallet); updateState({ account, connectedWallet: wallet, signer, status: 'connected' }); @@ -600,8 +609,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (config.autoConnect !== false && storage) { (async () => { const savedKey = await storage.getItem(storageKey); - // Don't auto-connect if the user has selected a wallet - if (userHasSelected) return; + // Don't auto-connect if the user has selected a wallet, or if the + // store was disposed while the storage read was in flight — the + // disposer has already torn down its listeners and bumped the + // generation, but this IIFE hasn't captured one yet, so without this + // check it would resume and silently reconnect into a disposed store. + if (userHasSelected || disposed) return; if (!savedKey) { updateState({ status: 'disconnected' }); @@ -755,9 +768,12 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { }, [Symbol.dispose]: () => { // Invalidate any in-flight connect/signIn/silent-reconnect so they - // bail at their generation guard instead of resuming after disposal - // (which would re-subscribe to wallet events and re-arm cleanup that - // this disposer already ran). + // bail instead of resuming after disposal (which would re-subscribe + // to wallet events and re-arm cleanup that this disposer already + // ran). The generation bump covers attempts that already captured a + // generation; `disposed` covers the auto-connect IIFE, which resumes + // from its storage read and captures a fresh generation afterward. + disposed = true; connectGeneration++; unsubRegister(); unsubUnregister(); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index a65895c..ac0a28a 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1754,6 +1754,34 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(store.getState().connected).toBeNull(); expect(walletEventHandler).toBeNull(); }); + + it('does not reconnect when disposed during the auto-connect storage read', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // Dispose synchronously, before the auto-connect IIFE resumes from its + // awaited storage read — the window React StrictMode's + // mount → cleanup → remount hits. Disposal must invalidate the in-flight + // auto-connect even though it hasn't yet captured a connect generation. + store[Symbol.dispose](); + + // Let the storage read resolve and the silent reconnect (if any) run. + await vi.advanceTimersByTimeAsync(0); + + // The disposed store must not silently connect, leak a wallet-events + // subscription, or report itself connected. + expect(store.getState().status).not.toBe('connected'); + expect(store.getState().connected).toBeNull(); + expect(walletEventHandler).toBeNull(); + expect(connectMock).not.toHaveBeenCalled(); + }); }); // -- Late wallet registration tests ------------------------------------------- From 74e929894863407cf425fc3021ac261e9bb75395 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 17:28:17 +0000 Subject: [PATCH 26/36] Fix disconnect not superseding an inflight connect/signIn --- packages/kit-plugin-wallet/src/store.ts | 10 +++++- packages/kit-plugin-wallet/test/store.test.ts | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 2181be2..c66def4 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -425,7 +425,15 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } const currentWallet = state.connectedWallet; - const generation = connectGeneration; + // Bump the generation so this disconnect supersedes any connect/signIn + // that was already in flight (its prompt opened before the user chose to + // disconnect): that attempt captured an earlier generation, so it will + // bail at its guard rather than establishing a connection the user has + // since dismissed. Mirrors the not-connected branch above — newest action + // wins. A connect/signIn started *after* this disconnect bumps the + // generation again, so the `finally` guard skips `disconnectLocally` and + // leaves the newer connection intact. + const generation = ++connectGeneration; updateState({ status: 'disconnecting' }); try { diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index ac0a28a..e2b9e3f 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1104,6 +1104,38 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected!.account.address).toBe(account2.address); }); + it('disconnect supersedes an in-flight connect (newest action wins)', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold a connect to wallet2 pending, then disconnect while it's in flight. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + const disconnectPromise = store.disconnect(); + await disconnectPromise; + expect(store.getState().status).toBe('disconnected'); + + // The superseded connect resolves last — it must not establish wallet2, + // since disconnect was the user's later action. + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + it('getState returns referentially stable snapshots', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const snap1 = store.getState(); From 272fc0e9caa4ba5eb25a5696a1b7852a4dacce5d Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 10 Jun 2026 18:28:48 +0000 Subject: [PATCH 27/36] Reconcile wallet list inn change events --- packages/kit-plugin-wallet/src/store.ts | 14 +++- packages/kit-plugin-wallet/test/store.test.ts | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index c66def4..1502ff2 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -311,7 +311,11 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // `getState().connected.wallet` see current metadata; the signer // (in `result`) only churns when the active account changed, which // keeps the snapshot referentially stable on no-op change events. - updateState({ connectedWallet: refreshed, ...result }); + // `wallets` is reconciled too: the registry regenerates the wallet + // handle on a change, so without this the list entry would keep the + // stale handle (`wallets[i] !== connected.wallet`). `reconcileWalletList` + // returns the existing reference on a no-op, preserving that stability. + updateState({ connectedWallet: refreshed, wallets: reconcileWalletList(), ...result }); if (result.account) { persistAccount(result.account, refreshed); @@ -461,6 +465,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { connectedWallet: null, signer: null, status: 'disconnected', + // Reconcile the list too: when this runs from the change handler + // because the connected wallet dropped the configured chain or + // failed the filter, that wallet must leave `wallets` immediately + // rather than linger until an unrelated register/unregister event. + // `reconcileWalletList` returns the existing reference when the + // visible set is unchanged (the common case for a plain disconnect), + // so this stays churn-free. + wallets: reconcileWalletList(), }); clearPersistedAccount(); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index e2b9e3f..555c80c 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1536,6 +1536,72 @@ describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { expect(createSignerMock).toHaveBeenCalledOnce(); expect(createSignerMock.mock.calls[0][0]).toHaveProperty('address', account2.address); }); + + it('refreshes the connected wallet entry in the wallet list on a change event', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // The registry regenerates the wallet handle (new reference, same name) + // — e.g. it authorized an additional account — while still passing the + // filter. + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account, account2], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + // The wallet-list entry must be the same refreshed handle as + // connected.wallet, not the stale pre-change reference — otherwise UI + // rendering from the list shows pre-change accounts/features. + expect(state.wallets[0]).toBe(state.connected!.wallet); + }); + + it('drops the connected wallet from the wallet list when it no longer passes the filter', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signTransaction'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.features.includes('solana:signTransaction'), + storage: null, + }); + await connectToWallet(store, mockWallet); + expect(store.getState().wallets.length).toBe(1); + + // Wallet drops signTransaction — it no longer passes the filter, so the + // store disconnects. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + // The now-ineligible wallet must be dropped immediately, not linger in + // the list until an unrelated register/unregister event. + expect(state.wallets.length).toBe(0); + }); }); // -- Auto-connect tests ------------------------------------------------------- From 4446a5d90f9b17a20a4ba0ba6c9d94c75bb4febf Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 15:40:44 +0000 Subject: [PATCH 28/36] Move @wallet-standard/errors to dev dependency --- packages/kit-plugin-wallet/package.json | 4 +++- pnpm-lock.yaml | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index 5e276a5..2e3cda4 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -60,12 +60,14 @@ "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/app": "^1.1.1", "@wallet-standard/base": "^1.1.1", - "@wallet-standard/errors": "^0.1.2", "@wallet-standard/features": "^1.1.1", "@wallet-standard/ui": "^1.0.3", "@wallet-standard/ui-features": "^1.0.3", "@wallet-standard/ui-registry": "^1.1.1" }, + "devDependencies": { + "@wallet-standard/errors": "^0.1.2" + }, "peerDependencies": { "@solana/kit": "^6.9.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e38299b..b97448c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,9 +199,6 @@ importers: '@wallet-standard/base': specifier: ^1.1.1 version: 1.1.1 - '@wallet-standard/errors': - specifier: ^0.1.2 - version: 0.1.2 '@wallet-standard/features': specifier: ^1.1.1 version: 1.1.1 @@ -214,6 +211,10 @@ importers: '@wallet-standard/ui-registry': specifier: ^1.1.1 version: 1.1.1 + devDependencies: + '@wallet-standard/errors': + specifier: ^0.1.2 + version: 0.1.2 packages/kit-plugins: dependencies: From 1a3865a2fe5df4395525a02a5eaf2b7ae21a1ad1 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 15:49:14 +0000 Subject: [PATCH 29/36] Make SSR no-op use rejected promises --- packages/kit-plugin-wallet/src/store.ts | 14 +++++--------- packages/kit-plugin-wallet/test/store.test.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 1502ff2..1a7c503 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -59,20 +59,16 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { wallets: Object.freeze([]) as readonly UiWallet[], }); return { - connect: () => { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); - }, + connect: () => + Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' })), disconnect: () => Promise.resolve(), getState: () => ssrSnapshot, selectAccount: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); }, - signIn: () => { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); - }, - signMessage: () => { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); - }, + signIn: () => Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' })), + signMessage: () => + Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' })), subscribe: () => () => {}, [Symbol.dispose]: () => {}, }; diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 555c80c..9a1e1f3 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -29,10 +29,10 @@ describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { expect(store.getState().connected).toBeNull(); }); - it('throws for connect on server', () => { + it('throws for connect on server', async () => { const store = createWalletStore({ chain: 'solana:mainnet' }); const mockWallet = createMockUiWallet(); - expect(() => store.connect(mockWallet)).toThrow( + await expect(() => store.connect(mockWallet)).rejects.toThrow( new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), ); }); @@ -42,17 +42,17 @@ describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { await expect(store.disconnect()).resolves.toBeUndefined(); }); - it('throws for signMessage on server', () => { + it('throws for signMessage on server', async () => { const store = createWalletStore({ chain: 'solana:mainnet' }); - expect(() => store.signMessage(new Uint8Array())).toThrow( + await expect(() => store.signMessage(new Uint8Array())).rejects.toThrow( new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }), ); }); - it('throws for signIn on server', () => { + it('throws for signIn on server', async () => { const store = createWalletStore({ chain: 'solana:mainnet' }); const mockWallet = createMockUiWallet(); - expect(() => store.signIn(mockWallet, {})).toThrow( + await expect(() => store.signIn(mockWallet, {})).rejects.toThrow( new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), ); }); From 9986851a45dc6121910c02533dd4cf06091955f9 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 15:52:14 +0000 Subject: [PATCH 30/36] nit updates --- packages/kit-plugin-wallet/src/store.ts | 71 ++++++++++++------------ packages/kit-plugin-wallet/src/types.ts | 1 + packages/kit-plugin-wallet/src/wallet.ts | 7 ++- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 1a7c503..e0c3a46 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -498,6 +498,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { const refreshed = refreshUiWallet(state.connectedWallet); const selectedAccount = refreshed.accounts.find(a => a.address === account.address); if (!selectedAccount) { + // Note: no Kit SolanaError for this case throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); } userHasSelected = true; @@ -623,6 +624,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // -- Auto-connect ------------------------------------------------------ if (config.autoConnect !== false && storage) { + const generation = connectGeneration; (async () => { const savedKey = await storage.getItem(storageKey); // Don't auto-connect if the user has selected a wallet, or if the @@ -673,40 +675,41 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } }, 3000); - const unsubRegisterForReconnect = registry.on( - 'register', - () => - void (async () => { - if (userHasSelected) { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - return; - } - const found = buildWalletList().find(w => w.name === walletName); - if (found) { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - await attemptSilentReconnect(savedAddress, found); - } else if ( - registry.get().some(w => { - const ui = getOrCreateUiWalletForStandardWallet(w); - return ui.name === walletName; - }) - ) { - // Wallet registered but filtered out. - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - updateState({ status: 'disconnected' }); - clearPersistedAccount(); - } - })().catch(() => { - // Reconnect failed — fall back to disconnected. + const unsubRegisterForReconnect = registry.on('register', () => { + const generation = connectGeneration; + void (async () => { + if (userHasSelected) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + return; + } + const found = buildWalletList().find(w => w.name === walletName); + if (found) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + await attemptSilentReconnect(savedAddress, found); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet(w); + return ui.name === walletName; + }) + ) { + // Wallet registered but filtered out. + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; updateState({ status: 'disconnected' }); - }), - ); + clearPersistedAccount(); + } + })().catch(() => { + // Reconnect failed — fall back to disconnected. + if (generation === connectGeneration && !userHasSelected) { + updateState({ status: 'disconnected' }); + } + }); + }); reconnectCleanup = () => { clearTimeout(statusTimeout); @@ -715,7 +718,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { } })().catch(() => { // Storage read failed — fall back to disconnected. - if (!userHasSelected) { + if (generation === connectGeneration && !userHasSelected) { updateState({ status: 'disconnected' }); } }); diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 35ed49f..5f5184e 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -267,6 +267,7 @@ export type WalletNamespace = { * caches a new signer for the selected account. * * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is connected. + * @throws Error if the specified account is not among the currently connected wallet's accounts. */ selectAccount: (account: UiWalletAccount) => void; diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 9e22f8f..2169e56 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -34,7 +34,12 @@ function defineSignerGetter( }); } -function createPlugin(config: WalletPluginConfig, signerProperties: string[]) { +type SignerProperties = 'payer' | 'identity'; + +function createPlugin( + config: WalletPluginConfig, + signerProperties: SignerProperties[], +) { return (client: T): Disposable & Omit & TAdditions => { if ('wallet' in client) { throw new Error( From 375496fb668123e6cb011adcbb2ff974046bff9a Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 17:10:56 +0000 Subject: [PATCH 31/36] Protect against reconnecting to a disconnecting wallet --- packages/kit-plugin-wallet/src/store.ts | 9 +- packages/kit-plugin-wallet/test/store.test.ts | 101 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index e0c3a46..cbe3917 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -90,6 +90,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { let userHasSelected = false; let connectGeneration = 0; let disposed = false; + // Wallet that is in the process of disconnecting, stored to guard against re-connecting to it if a future connect fails + let disconnectingWalletName: string | null = null; // Resolve storage: default to localStorage in browser, null to disable. // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed @@ -259,6 +261,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (disposed) return; const signer = tryCreateSigner(account); resubscribeToWalletEvents(wallet); + disconnectingWalletName = null; updateState({ account, connectedWallet: wallet, signer, status: 'connected' }); if (options?.persist !== false) { persistAccount(account, wallet); @@ -434,6 +437,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // generation again, so the `finally` guard skips `disconnectLocally` and // leaves the newer connection intact. const generation = ++connectGeneration; + // Mark this wallet as disconnecting + disconnectingWalletName = currentWallet.name; updateState({ status: 'disconnecting' }); try { @@ -455,6 +460,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { function disconnectLocally(): void { walletEventsCleanup?.(); walletEventsCleanup = null; + disconnectingWalletName = null; updateState({ account: null, @@ -484,7 +490,8 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // site ensures a superseded attempt never reverts the connection a newer one // owns. function revertToPreviousConnectionOrDisconnect(): void { - if (state.connectedWallet && state.account) { + // Don't revive a wallet with disconnect in progress + if (state.connectedWallet && state.account && state.connectedWallet.name !== disconnectingWalletName) { updateState({ status: 'connected' }); } else { disconnectLocally(); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 9a1e1f3..2f5949a 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1136,6 +1136,107 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected).toBeNull(); }); + it('a failed connect does not revive a wallet that was concurrently disconnected', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account1.address}`); + + // User disconnects wallet1; hold its wallet-side teardown pending. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // While the disconnect is in flight the user starts connecting to + // wallet2; hold its prompt open. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + + // Disconnect resolves — wallet1 is now disconnected at the wallet level. + // It supersedes the connect's `finally` guard, so local state is left + // intact on the bet that the newer connect will replace it. + pendingDisconnect.resolve(); + await disconnectPromise; + + // The user then declines wallet2. The revert must NOT restore wallet1 — + // the user's last completed action disconnected it. + pendingConnect.reject(new Error('User rejected')); + await expect(connectPromise).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + // Persistence for wallet1 must be cleared too, so the next load doesn't + // silently reconnect to a wallet the user disconnected. + expect(await storage.getItem('kit-wallet')).toBeNull(); + }); + + it('a failed connect that rejects before the concurrent disconnect resolves does not revive the wallet', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // The opposite ordering: wallet2 is declined while the disconnect is + // still in flight. The teardown marker is set when disconnect begins, so + // the revert still refuses to revive wallet1 even though the disconnect's + // `finally` hasn't run yet. + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + + pendingDisconnect.resolve(); + await disconnectPromise; + expect(store.getState().status).toBe('disconnected'); + }); + + it('reverts to the previous connection when no disconnect was in flight', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // No disconnect involved — declining wallet2 must still revert to the + // genuinely-live wallet1 connection (guards against the marker leaking + // across unrelated reverts). + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.wallet.name).toBe('Wallet1'); + }); + it('getState returns referentially stable snapshots', () => { const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); const snap1 = store.getState(); From 13a5140fdb8c632e1d3e2e387fed9d77d02c529e Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 17:32:43 +0000 Subject: [PATCH 32/36] Bump connect generation in selectAccount --- packages/kit-plugin-wallet/src/store.ts | 1 + packages/kit-plugin-wallet/test/store.test.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index cbe3917..0291005 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -509,6 +509,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); } userHasSelected = true; + ++connectGeneration; // No-op when re-selecting the already-active account. Skipping here // avoids recreating the signer — which is a fresh object each call — // and the spurious listener notification (and re-render) that the new diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 2f5949a..26a2122 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1136,6 +1136,44 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().connected).toBeNull(); }); + it('selectAccount supersedes an in-flight connect (newest action wins)', async () => { + const account1a = createMockAccount(); + const account1b = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const account2 = createMockAccount('So11111111111111111111111111111111111111112'); + const wallet1 = createMockUiWallet({ + accounts: [account1a, account1b], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().connected!.account.address).toBe(account1a.address); + + // Hold a connect to wallet2 pending. wallet1 is still fully connected + // while wallet2's prompt is open. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + + // The user switches accounts on the still-connected wallet1 — a later + // action than the in-flight connect. + store.selectAccount(account1b); + expect(store.getState().connected!.account.address).toBe(account1b.address); + + // The superseded connect resolves last — it must not establish wallet2 + // and overwrite the user's just-made selection. + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + const state = store.getState(); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account1b.address); + }); + it('a failed connect does not revive a wallet that was concurrently disconnected', async () => { const account1 = createMockAccount(); const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); From 3d04a586fdd14ce7b06efe0d546d4749a8ca5190 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 18:02:50 +0000 Subject: [PATCH 33/36] Resolve connect feature at the start of connect --- packages/kit-plugin-wallet/src/store.ts | 16 +++++--- packages/kit-plugin-wallet/test/store.test.ts | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 0291005..a93a5df 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -345,17 +345,23 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise { options?.abortSignal?.throwIfAborted(); + + // Resolve the feature before mutating any state. getWalletFeature throws + // WalletStandardError synchronously when the wallet doesn't support + // standard:connect — doing this first means an unsupported wallet is a + // no-op that never tears down an existing connection or cancels a + // pending reconnect. + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect, + ) as StandardConnectFeature[typeof StandardConnect]; + userHasSelected = true; cancelReconnect(); const generation = ++connectGeneration; updateState({ status: 'connecting' }); try { - const connectFeature = getWalletFeature( - uiWallet, - StandardConnect, - ) as StandardConnectFeature[typeof StandardConnect]; - // Snapshot existing accounts before connect. const existingAccounts = [...uiWallet.accounts]; diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 26a2122..209bc70 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -297,6 +297,43 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { expect(store.getState().status).toBe('connected'); }); + it('connect throws when the wallet does not support standard:connect', async () => { + const mockWallet = createMockUiWallet({ features: ['standard:events'], name: 'NoConnect' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.connect(mockWallet)).rejects.toThrow(); + }); + + it('connect to an unsupported wallet leaves an existing connection intact', async () => { + const account = createMockAccount(); + const supported = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'Supported', + }); + // A wallet without standard:connect — connect cannot establish a session. + const unsupported = createMockUiWallet({ features: ['standard:events'], name: 'NoConnect' }); + registerWallet(supported); + registerWallet(unsupported); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(supported); + expect(store.getState().status).toBe('connected'); + + // The feature is resolved before any state is mutated, so connecting to + // an unsupported wallet must reject without publishing a 'connecting' + // status or otherwise disturbing the live connection. + const listener = vi.fn(); + store.subscribe(listener); + await expect(store.connect(unsupported)).rejects.toThrow(); + + expect(listener).not.toHaveBeenCalled(); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + }); + it('disconnects from a wallet', async () => { const account = createMockAccount(); const mockWallet = createMockUiWallet({ From 079bd443335282eeb5446ecf9ca9ec444f842253 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 18:11:05 +0000 Subject: [PATCH 34/36] Persist in attemptSilentReconnect if address has changed --- packages/kit-plugin-wallet/src/store.ts | 5 ++- packages/kit-plugin-wallet/test/store.test.ts | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index a93a5df..35c1e21 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -775,7 +775,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { // Restore the specific saved account, fall back to first from same wallet. const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0]; - setConnected(activeAccount, refreshedWallet, { persist: false }); + // Persist only when we fell back to a different account + setConnected(activeAccount, refreshedWallet, { + persist: activeAccount.address !== savedAddress, + }); } catch { if (generation === connectGeneration) { updateState({ status: 'disconnected' }); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 209bc70..19968f6 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1978,6 +1978,46 @@ describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { expect(storage.getItem('kit-wallet')).toBeNull(); }); + it('persists the fallback account when the saved account is gone', async () => { + const account = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // The saved address no longer belongs to the wallet, so the reconnect + // falls back to the first available account. + const storage = createMockStorage({ 'kit-wallet': 'TestWallet:11111111111111111111111111111111' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().connected!.account.address).toBe(account.address); + // The stale address must be overwritten so future loads don't repeat the + // find-miss-fallback dance. + expect(storage.getItem('kit-wallet')).toBe(`TestWallet:${account.address}`); + }); + + it('does not rewrite storage on a normal reconnect to the saved account', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const setItem = vi.spyOn(storage, 'setItem'); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().connected!.account.address).toBe(account.address); + // The saved address matched, so the reconnect must not write to storage. + expect(setItem).not.toHaveBeenCalled(); + }); + it('falls back to disconnected when storage read rejects', async () => { const storage: WalletStorage = { getItem: () => Promise.reject(new Error('storage unavailable')), From a6e8c3bab802732b84e3939bd1987ac8f8c1ec77 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 18:13:06 +0000 Subject: [PATCH 35/36] Docblock nits --- packages/kit-plugin-wallet/src/store.ts | 1 + packages/kit-plugin-wallet/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 35c1e21..053a006 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -33,6 +33,7 @@ import type { // -- Internal types --------------------------------------------------------- +/** @internal */ export type WalletStore = WalletNamespace & { [Symbol.dispose]: () => void; }; diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 5f5184e..87ac0cf 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -341,6 +341,7 @@ export type WalletNamespace = { * All wallet state and actions are namespaced under `client.wallet`. * Depending on which plugin variant is used, `client.payer` and/or * `client.identity` may also be set to the connected wallet's signer. + * This is not part of Kit plugin-interfaces, as it depends on wallet-standard types * * @see {@link walletSigner} * @see {@link walletPayer} @@ -348,7 +349,6 @@ export type WalletNamespace = { * @see {@link walletWithoutSigner} * @see {@link WalletNamespace} * - * @note this is not part of Kit plugin-interfaces, as it depends on wallet-standard types */ export type ClientWithWallet = { /** The wallet namespace — state, actions, and framework integration. */ From 0a1f210cfe10b35036b5743ac2b0514ec47f7a3c Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 12 Jun 2026 18:32:48 +0000 Subject: [PATCH 36/36] Fix race conditions in selectAccount --- packages/kit-plugin-wallet/src/store.ts | 15 ++-- packages/kit-plugin-wallet/test/store.test.ts | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 053a006..c3318a0 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -509,6 +509,10 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { if (!state.connectedWallet) { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); } + // Reject if this wallet is disconnecting + if (state.connectedWallet.name === disconnectingWalletName) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); + } const refreshed = refreshUiWallet(state.connectedWallet); const selectedAccount = refreshed.accounts.find(a => a.address === account.address); if (!selectedAccount) { @@ -516,16 +520,17 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore { throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); } userHasSelected = true; + // Bump the generation so this selection supersedes any in-flight + // connect/signIn ++connectGeneration; - // No-op when re-selecting the already-active account. Skipping here - // avoids recreating the signer — which is a fresh object each call — - // and the spurious listener notification (and re-render) that the new - // signer reference would otherwise trigger. + // Change status back to `connected` if something in-flight changed it + // But skip recreating the signer if account is the same as before if (selectedAccount === state.account && refreshed === state.connectedWallet) { + updateState({ status: 'connected' }); return; } const signer = tryCreateSigner(selectedAccount); - updateState({ account: selectedAccount, connectedWallet: refreshed, signer }); + updateState({ account: selectedAccount, connectedWallet: refreshed, signer, status: 'connected' }); persistAccount(selectedAccount, refreshed); } diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts index 19968f6..8332e99 100644 --- a/packages/kit-plugin-wallet/test/store.test.ts +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -1209,6 +1209,78 @@ describe.skipIf(!__BROWSER__)('store (browser)', () => { const state = store.getState(); expect(state.connected!.wallet.name).toBe('Wallet1'); expect(state.connected!.account.address).toBe(account1b.address); + // The superseded connect had moved status to 'connecting'; selectAccount + // must settle it back to 'connected' rather than leaving the store + // stranded mid-connect when wallet1 is in fact live. + expect(state.status).toBe('connected'); + }); + + it('selectAccount restores connected status even when re-selecting the active account', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold a connect to wallet2 pending — status moves to 'connecting' while + // wallet1 stays live. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + expect(store.getState().status).toBe('connecting'); + + // Re-select the already-active account. This hits the no-op early return, + // but must still supersede the in-flight connect and settle the status. + store.selectAccount(account1); + expect(store.getState().status).toBe('connected'); + + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account1.address); + }); + + it('selectAccount on a wallet mid-disconnect throws and does not revive it', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1, account2], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + registerWallet(wallet1); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account1.address}`); + + // Hold the wallet-side disconnect pending so it's in flight. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + expect(store.getState().status).toBe('disconnecting'); + + // Switching accounts on a wallet being torn down must reject rather than + // supersede the disconnect, re-persist, and leave the store half-connected. + expect(() => store.selectAccount(account2)).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }), + ); + + // The disconnect completes cleanly — the rejected selectAccount left it + // intact, so the store ends fully disconnected with persistence cleared. + pendingDisconnect.resolve(); + await disconnectPromise; + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + expect(await storage.getItem('kit-wallet')).toBeNull(); }); it('a failed connect does not revive a wallet that was concurrently disconnected', async () => {