From b5e6af7a2e1e26c750a3ef630023a3692f1adbe6 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 31 Mar 2026 12:27:21 +0000 Subject: [PATCH 1/3] Scaffold/design for the wallet plugin --- packages/kit-plugin-wallet/.gitignore | 1 + packages/kit-plugin-wallet/.prettierignore | 4 + packages/kit-plugin-wallet/LICENSE | 22 + packages/kit-plugin-wallet/README.md | 211 +++ packages/kit-plugin-wallet/package.json | 75 + packages/kit-plugin-wallet/src/index.ts | 455 ++++++ .../kit-plugin-wallet/src/types/global.d.ts | 6 + .../tsconfig.declarations.json | 10 + packages/kit-plugin-wallet/tsconfig.json | 7 + packages/kit-plugin-wallet/tsup.config.ts | 5 + packages/kit-plugin-wallet/vitest.config.mts | 8 + pnpm-lock.yaml | 165 +++ wallet-plugin-spec.md | 1299 +++++++++++++++++ 13 files changed, 2268 insertions(+) create mode 100644 packages/kit-plugin-wallet/.gitignore create mode 100644 packages/kit-plugin-wallet/.prettierignore create mode 100644 packages/kit-plugin-wallet/LICENSE create mode 100644 packages/kit-plugin-wallet/README.md create mode 100644 packages/kit-plugin-wallet/package.json create mode 100644 packages/kit-plugin-wallet/src/index.ts create mode 100644 packages/kit-plugin-wallet/src/types/global.d.ts create mode 100644 packages/kit-plugin-wallet/tsconfig.declarations.json create mode 100644 packages/kit-plugin-wallet/tsconfig.json create mode 100644 packages/kit-plugin-wallet/tsup.config.ts create mode 100644 packages/kit-plugin-wallet/vitest.config.mts create mode 100644 wallet-plugin-spec.md diff --git a/packages/kit-plugin-wallet/.gitignore b/packages/kit-plugin-wallet/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/kit-plugin-wallet/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/kit-plugin-wallet/.prettierignore b/packages/kit-plugin-wallet/.prettierignore new file mode 100644 index 0000000..c52dcf5 --- /dev/null +++ b/packages/kit-plugin-wallet/.prettierignore @@ -0,0 +1,4 @@ +dist/ +test-ledger/ +target/ +CHANGELOG.md diff --git a/packages/kit-plugin-wallet/LICENSE b/packages/kit-plugin-wallet/LICENSE new file mode 100644 index 0000000..f7e0a95 --- /dev/null +++ b/packages/kit-plugin-wallet/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Anza + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md new file mode 100644 index 0000000..1fe389f --- /dev/null +++ b/packages/kit-plugin-wallet/README.md @@ -0,0 +1,211 @@ +# Kit Plugins ➤ Wallet + +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] + +[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/kit-plugin-wallet.svg?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat&label=%40solana%2Fkit-plugin-wallet +[npm-url]: https://www.npmjs.com/package/@solana/kit-plugin-wallet + +This package provides a plugin that adds browser wallet support to your Kit clients using [wallet-standard](https://github.com/wallet-standard/wallet-standard). It handles wallet discovery, connection lifecycle, account selection, and signer creation — and syncs the connected wallet's signer to `client.payer` automatically. + +## Installation + +```sh +pnpm install @solana/kit-plugin-wallet +``` + +## `wallet` plugin + +The wallet plugin adds a `client.wallet` namespace with all wallet state and actions, and wires the connected wallet's signer to `client.payer`. + +### Setup + +```ts +import { createEmptyClient } from '@solana/kit'; +import { rpc } from '@solana/kit-plugin-rpc'; +import { wallet } from '@solana/kit-plugin-wallet'; + +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(wallet({ chain: 'solana:mainnet' })); +``` + +Once a wallet is connected, `client.payer` resolves to the wallet's signer and you can pass it directly to transaction instructions: + +```ts +import { getTransferSolInstruction } from '@solana-program/system'; +import { lamports } from '@solana/kit'; + +// Read registered wallets +const selectedWallet = client.wallet.wallets[0]; + +// Connect a wallet +await client.wallet.connect(selectedWallet); + +// client.payer is now the connected wallet's signer +await client.sendTransaction( + getTransferSolInstruction({ + source: client.payer, + destination: recipientAddress, + amount: lamports(10_000_000n), + }), +); +``` + +### Features + +- `client.wallet.wallets` — All discovered wallets that support the configured chain. + + ```ts + for (const w of client.wallet.wallets) { + console.log(w.name, w.icon); + } + ``` + +- `client.wallet.connected` — The active connection (wallet, account, and signer), or `null` when disconnected. + + ```ts + const { wallet, account, signer } = client.wallet.connected ?? {}; + console.log(account?.address); + ``` + +- `client.wallet.status` — The current connection status: `'pending'`, `'disconnected'`, `'connecting'`, `'connected'`, `'disconnecting'`, or `'reconnecting'`. + +- `client.wallet.connect(wallet)` — Connect to a wallet and select the first newly authorized account. + + ```ts + const accounts = await client.wallet.connect(selectedWallet); + ``` + +- `client.wallet.disconnect()` — Disconnect the active wallet. + +- `client.wallet.selectAccount(account)` — Switch to a different account within an already-authorized wallet without reconnecting. + + ```ts + client.wallet.selectAccount(selectedWallet); + ``` + +- `client.wallet.signMessage(message)` — Sign a raw message with the connected account. + + ```ts + const signature = await client.wallet.signMessage(new TextEncoder().encode('Hello')); + ``` + +- `client.wallet.signIn(input?)` / `client.wallet.signIn(wallet, input?)` — Sign In With Solana (SIWS). The two-argument form connects the wallet implicitly. + + ```ts + // Sign in with the already-connected wallet + const output = await client.wallet.signIn({ domain: window.location.host }); + + // Sign in and connect in one step + const output = await client.wallet.signIn(selectedWallet, { domain: window.location.host }); + ``` + +### Framework integration + +The plugin exposes `subscribe` and `getSnapshot` for binding wallet state to any UI framework. + +**React** — use `useSyncExternalStore` for concurrent-mode-safe rendering: + +```tsx +import { useSyncExternalStore } from 'react'; + +function useWalletState() { + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); +} + +function App() { + const { wallets, connected, status } = useWalletState(); + + if (status === 'pending') return null; // avoid flashing a connect button before auto-reconnect + + if (!connected) { + return wallets.map(w => ( + + )); + } + + return

Connected: {connected.account.address}

; +} +``` + +**Vue** — use a `shallowRef` composable: + +```ts +import { onMounted, onUnmounted, shallowRef } from 'vue'; + +function useWalletState() { + const state = shallowRef(client.wallet.getSnapshot()); + onMounted(() => { + const unsub = client.wallet.subscribe(() => { + state.value = client.wallet.getSnapshot(); + }); + onUnmounted(unsub); + }); + return state; +} +``` + +**Svelte** — wrap in a `readable` store: + +```ts +import { readable } from 'svelte/store'; + +export const walletState = readable(client.wallet.getSnapshot(), set => { + return client.wallet.subscribe(() => set(client.wallet.getSnapshot())); +}); +``` + +### Persistence + +By default the plugin uses `localStorage` to remember the last connected wallet and auto-reconnects on the next page load. Pass `storage: null` to disable, or provide a custom adapter (e.g. `sessionStorage` or an IndexedDB wrapper): + +```ts +wallet({ + chain: 'solana:mainnet', + storage: sessionStorage, // use session storage instead + storageKey: 'my-app:wallet', // custom key (default: 'kit-wallet') + autoConnect: false, // disable silent reconnect +}); +``` + +### SSR / server-side rendering + +The plugin is safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw `WalletNotConnectedError`, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. + +```ts +// This client chain works on both server and browser. +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(serverKeypair)) + .use(wallet({ chain: 'solana:mainnet' })); + +// Server: client.wallet.status === 'pending', client.payer === serverKeypair +// Browser: auto-connects, client.payer becomes the wallet signer +``` + +### Wallet discovery filtering + +Use the `filter` option to restrict which wallets appear in `client.wallet.wallets`: + +```ts +wallet({ + chain: 'solana:mainnet', + // Only show wallets that support signAndSendTransaction + filter: w => w.features.includes('solana:signAndSendTransaction'), +}); +``` + +### Cleanup + +The plugin implements `[Symbol.dispose]`, so it integrates with the `using` declaration or explicit disposal: + +```ts +{ + using client = createEmptyClient().use(wallet({ chain: 'solana:mainnet' })); + // registry listeners and storage subscriptions are cleaned up on scope exit +} +``` diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json new file mode 100644 index 0000000..03be17e --- /dev/null +++ b/packages/kit-plugin-wallet/package.json @@ -0,0 +1,75 @@ +{ + "name": "@solana/kit-plugin-wallet", + "version": "0.1.0", + "description": "Wallet connection plugin for Kit clients", + "exports": { + "types": "./dist/types/index.d.ts", + "react-native": "./dist/index.react-native.mjs", + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + } + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "react-native": "./dist/index.react-native.mjs", + "types": "./dist/types/index.d.ts", + "type": "commonjs", + "files": [ + "./dist/types", + "./dist/index.*", + "./src/" + ], + "sideEffects": false, + "keywords": [ + "solana", + "kit", + "plugin", + "wallet", + "wallet-standard", + "signer" + ], + "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:treeshakability": "for file in dist/index.*.mjs; do agadoo $file; done", + "test:types": "tsc --noEmit" + }, + "peerDependencies": { + "@solana/kit": "^6.6.0" + }, + "dependencies": { + "@solana/wallet-account-signer": "^6.6.0", + "@solana/wallet-standard-chains": "^1.1.1", + "@solana/wallet-standard-features": "^1.3.0", + "@wallet-standard/app": "^1.1.0", + "@wallet-standard/errors": "^0.1.1", + "@wallet-standard/features": "^1.1.0", + "@wallet-standard/ui": "^1.0.1", + "@wallet-standard/ui-features": "^1.0.1", + "@wallet-standard/ui-registry": "^1.0.1" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anza-xyz/kit-plugins" + }, + "bugs": { + "url": "http://github.com/anza-xyz/kit-plugins/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ] +} diff --git a/packages/kit-plugin-wallet/src/index.ts b/packages/kit-plugin-wallet/src/index.ts new file mode 100644 index 0000000..0f82886 --- /dev/null +++ b/packages/kit-plugin-wallet/src/index.ts @@ -0,0 +1,455 @@ +import { ClientWithPayer, extendClient, MessageSigner, SignatureBytes, TransactionSigner } from '@solana/kit'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; +import type { SolanaSignInInput, SolanaSignInOutput } from '@solana/wallet-standard-features'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; + +// -- Public types ----------------------------------------------------------- + +/** + * 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. + * - `disconnected` — initialized, no wallet connected. + * - `connecting` — a user-initiated connection request is in progress. + * - `connected` — a wallet is connected. + * - `disconnecting` — a user-initiated disconnection request is in progress. + * - `reconnecting` — auto-connect in progress (connecting to persisted wallet). + */ +export type WalletStatus = 'connected' | 'connecting' | 'disconnected' | 'disconnecting' | 'pending' | 'reconnecting'; + +/** + * The active wallet connection — the wallet, the selected account, and the + * account's signer (or `null` for read-only / watch-only wallets that do not + * support any signing feature). + * + * Available as `client.wallet.connected` when a wallet is connected. + * + * @see {@link WalletNamespace.connected} + */ +export type WalletConnection = { + /** The currently selected account within the connected wallet. */ + readonly account: UiWalletAccount; + /** + * The signer for the active account, or `null` for read-only wallets. + * + * Satisfies `TransactionSigner` when non-null. May additionally implement + * `MessageSigner` if the wallet supports `solana:signMessage`. + */ + readonly signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + /** The connected wallet. */ + readonly wallet: UiWallet; +}; + +/** + * A snapshot of the wallet plugin state at a point in time. + * + * Referentially stable when unchanged — suitable for use with + * `useSyncExternalStore` and similar framework primitives. + * + * The `connected` field uses `hasSigner` rather than the signer object itself + * to avoid unnecessary re-renders when the signer reference changes. The + * actual signer is accessible via {@link WalletConnection.signer}. + * + * @see {@link WalletNamespace.getSnapshot} + */ +export type WalletStateSnapshot = { + /** + * The active connection, or `null` when disconnected. + * + * `hasSigner` is `false` for read-only / watch-only wallets. + */ + readonly connected: { + readonly account: UiWalletAccount; + /** Whether the connected account has a signer. */ + readonly hasSigner: boolean; + readonly wallet: UiWallet; + } | null; + /** The current connection status. */ + readonly status: WalletStatus; + /** All discovered wallets matching the configured chain and filter. */ + readonly wallets: readonly UiWallet[]; +}; + +/** + * A pluggable storage adapter for persisting the selected wallet account. + * + * Follows the Web Storage API shape (`getItem`/`setItem`/`removeItem`). + * `localStorage` and `sessionStorage` satisfy this interface directly. + * Async backends (IndexedDB, encrypted storage) may return `Promise`s. + * + * @example + * ```ts + * // Use sessionStorage + * wallet({ chain: 'solana:mainnet', storage: sessionStorage }); + * + * // Custom async adapter + * wallet({ + * chain: 'solana:mainnet', + * storage: { + * getItem: (key) => myStore.get(key), + * setItem: (key, value) => myStore.set(key, value), + * removeItem: (key) => myStore.delete(key), + * }, + * }); + * ``` + */ +export type WalletStorage = { + getItem(key: string): Promise | string | null; + removeItem(key: string): Promise | void; + setItem(key: string, value: string): Promise | void; +}; + +/** + * Configuration for the {@link wallet} plugin. + */ +export type WalletPluginConfig = { + /** + * Whether to attempt silent reconnection on startup using the persisted + * wallet account from `storage`. + * + * Has no effect if `storage` is `null`. + * + * @default true + */ + autoConnect?: boolean; + + /** + * The Solana chain this client targets (e.g. `'solana:mainnet'`). + * + * One client = one chain. To switch networks, create a separate client + * with a different chain and RPC endpoint. + */ + chain: SolanaChain; + + /** + * Optional filter function for wallet discovery. Called for each wallet + * that supports the configured chain and `standard:connect`. Return `true` + * to include the wallet, `false` to exclude it. + * + * @example + * ```ts + * // Require signAndSendTransaction + * filter: (w) => w.features.includes('solana:signAndSendTransaction') + * + * // Whitelist specific wallets + * filter: (w) => ['SomeWallet', 'SomeOtherWallet'].includes(w.name) + * ``` + */ + filter?: (wallet: UiWallet) => boolean; + + /** + * Storage adapter for persisting the selected wallet account across page + * loads. Pass `null` to disable persistence entirely. + * + * When omitted in a browser environment, `localStorage` is used by default. + * On the server, storage is always skipped regardless of this option. + * + * @default localStorage (in browser) + * @see {@link WalletStorage} + */ + storage?: WalletStorage | null; + + /** + * Storage key used for persistence. + * + * @default 'kit-wallet' + */ + storageKey?: string; + + /** + * Whether to sync the connected wallet's signer to `client.payer`. + * + * When `true` (default), a dynamic `payer` getter is defined on the client. + * When no wallet is connected the getter returns whatever `client.payer` was + * before the wallet plugin was installed (the fallback payer), or `undefined` + * if no prior payer was configured. + * + * @default true + */ + usePayer?: boolean; +}; + +/** + * The `wallet` namespace exposed on the client as `client.wallet`. + * + * Contains all wallet state, actions, and framework integration helpers. + * Framework adapters (React, Vue, Svelte, etc.) should bind to + * `subscribe` and `getSnapshot` rather than individual getters. + * + * @see {@link WalletApi} + */ +export type WalletNamespace = { + // -- Actions -- + /** + * Connect to a wallet. Calls `standard:connect`, then selects the first + * newly authorized account (or the first account if reconnecting). Creates + * and caches a signer for the active account. + * + * @returns All accounts from the wallet after connection. + * @throws The wallet's rejection error if the user declines the prompt. + */ + connect: (wallet: UiWallet) => Promise; + + /** + * The active connection — wallet, account, and signer — or `null` when + * disconnected. For rendering, prefer reading from {@link getSnapshot} + * to avoid tearing. + */ + readonly connected: WalletConnection | null; + + /** Disconnect the active wallet. Calls `standard:disconnect` if supported. */ + disconnect: () => Promise; + + /** + * Get a referentially stable snapshot of the full wallet state. + * The same object reference is returned on subsequent calls as long as + * nothing has changed. + * + * @see {@link WalletStateSnapshot} + */ + getSnapshot: () => WalletStateSnapshot; + + /** + * Switch to a different account within the connected wallet. Creates and + * caches a new signer for the selected account. + * + * @throws {@link WalletNotConnectedError} if no wallet is connected. + */ + selectAccount: (account: UiWalletAccount) => void; + + /** + * Sign In With Solana. + * + * **Overload 1** — sign in with the already-connected wallet: + * ```ts + * const output = await client.wallet.signIn({ domain: window.location.host }); + * ``` + * + * **Overload 2** — sign in with a specific wallet (SIWS-as-connect): + * implicitly connects, sets the returned account as active, and creates a + * signer, leaving the client in the same state as after `connect()`. + * ```ts + * const output = await client.wallet.signIn(uiWallet, { domain: window.location.host }); + * ``` + * + * @throws {@link WalletNotConnectedError} (overload 1) if no wallet is connected. + * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` + * if the wallet does not support `solana:signIn`. + */ + signIn: { + (input?: SolanaSignInInput): Promise; + (wallet: UiWallet, input?: SolanaSignInInput): Promise; + }; + + /** + * Sign an arbitrary message with the connected account. + * + * @throws {@link WalletNotConnectedError} if no wallet is connected or the + * wallet is read-only (no signer). + * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` + * if the wallet does not support `solana:signMessage`. + */ + signMessage: (message: Uint8Array) => Promise; + + /** Current connection status. */ + readonly status: WalletStatus; + + // -- Framework integration -- + + /** + * Subscribe to any wallet state change. Compatible with React's + * `useSyncExternalStore` and similar framework primitives. + * + * @returns An unsubscribe function. + * + * @example + * ```ts + * // React + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + * ``` + */ + subscribe: (listener: () => void) => () => void; + + // -- State (getters) -- + /** All discovered wallets matching the configured chain and filter. */ + readonly wallets: readonly UiWallet[]; +}; + +/** + * Properties added to the client by the {@link wallet} plugin. + * + * All wallet state and actions are namespaced under `client.wallet`. + * `client.payer` remains at the top level and dynamically resolves to the + * connected wallet's signer (with fallback to any previously configured payer). + * + * @see {@link wallet} + * @see {@link WalletNamespace} + */ +// TODO: would be moved to kit plugin-interfaces +export type ClientWithWallet = { + /** The wallet namespace — state, actions, and framework integration. */ + readonly wallet: WalletNamespace; +}; + +// -- Error ------------------------------------------------------------------ + +/** + * Thrown when a wallet operation is attempted but no wallet is connected + * (or the connected wallet is read-only and has no signer). + * + * @example + * ```ts + * try { + * await client.wallet.signMessage(message); + * } catch (e) { + * if (e instanceof WalletNotConnectedError) { + * console.error('Connect a wallet first'); + * } + * } + * ``` + */ +// TODO: we should probably add this error to Kit - it'd be useful for any similar wallet functionality +export class WalletNotConnectedError extends Error { + /** The name of the operation that was attempted. */ + readonly operation: string; + + constructor(operation: string) { + super(`Cannot ${operation}: no wallet connected`); + this.name = 'WalletNotConnectedError'; + this.operation = operation; + } +} + +// -- Internal types --------------------------------------------------------- + +type WalletStoreState = { + account: UiWalletAccount | null; + connectedWallet: UiWallet | null; + signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + status: WalletStatus; + wallets: readonly UiWallet[]; +}; + +type WalletStore = { + connect: (wallet: UiWallet) => Promise; + [Symbol.dispose]: () => void; + disconnect: () => Promise; + getConnected: () => WalletConnection | null; + getSnapshot: () => WalletStateSnapshot; + getState: () => WalletStoreState; + selectAccount: (account: UiWalletAccount) => void; + signIn: { + (input?: SolanaSignInInput): Promise; + (wallet: UiWallet, input?: SolanaSignInInput): Promise; + }; + signMessage: (message: Uint8Array) => Promise; + subscribe: (listener: () => void) => () => void; +}; + +// -- Store ------------------------------------------------------------------ + +function createWalletStore(_config: WalletPluginConfig): WalletStore { + throw new Error('not implemented'); +} + +// -- Plugin ----------------------------------------------------------------- + +type WalletPluginReturn = ClientWithWallet & + Disposable & + Omit & + Partial; + +/** + * A framework-agnostic Kit plugin that manages wallet discovery, connection + * lifecycle, and signer creation using wallet-standard. + * + * When connected, the plugin syncs the wallet signer to `client.payer` via a + * dynamic getter, falling back to any previously configured payer when + * disconnected. All wallet state and actions are namespaced under + * `client.wallet`. The plugin exposes subscribable state for framework adapters + * (React, Vue, Svelte, Solid, etc.) to consume. + * + * **SSR-safe.** The plugin can be included in a shared client chain that runs + * on both server and browser. On the server, status stays `'pending'`, actions + * throw {@link WalletNotConnectedError}, and no registry listeners or storage + * reads are made. The same client chain works everywhere: + * + * ```ts + * const client = createEmptyClient() + * .use(rpc('https://api.mainnet-beta.solana.com')) + * .use(payer(backendKeypair)) + * .use(wallet({ chain: 'solana:mainnet' })); + * + * // Server: client.wallet.status === 'pending', client.payer === backendKeypair + * // Browser: auto-connect fires, client.payer becomes the wallet signer + * ``` + * + * @param config - Plugin configuration. + * + * @example + * ```ts + * import { createEmptyClient } from '@solana/kit'; + * import { rpc } from '@solana/kit-plugin-rpc'; + * import { wallet } from '@solana/kit-plugin-wallet'; + * + * const client = createEmptyClient() + * .use(rpc('https://api.mainnet-beta.solana.com')) + * .use(wallet({ chain: 'solana:mainnet' })); + * + * // Connect a wallet + * const [firstAccount] = await client.wallet.connect(uiWallet); + * + * // Subscribe to state changes (React) + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + * ``` + * + * @see {@link WalletPluginConfig} + * @see {@link WalletApi} + */ +export function wallet(config: WalletPluginConfig) { + return (client: T): WalletPluginReturn => { + const store = createWalletStore(config); + + const fallbackClient = 'payer' in client ? (client as T & { payer: TransactionSigner }) : null; + + const walletObj: Omit = { + connect: (w: UiWallet) => store.connect(w), + disconnect: () => store.disconnect(), + getSnapshot: () => store.getSnapshot(), + selectAccount: (a: UiWalletAccount) => store.selectAccount(a), + signIn: store.signIn, + signMessage: (msg: Uint8Array) => store.signMessage(msg), + subscribe: (l: () => void) => store.subscribe(l), + }; + + // Define getters for the rest of the state properties + for (const [key, fn] of Object.entries({ + connected: () => store.getConnected(), + status: () => store.getState().status, + wallets: () => store.getState().wallets, + } as Record unknown>)) { + Object.defineProperty(walletObj, key, { configurable: true, enumerable: true, get: fn }); + } + + const obj = extendClient(client, { + wallet: walletObj as WalletNamespace, + // TODO: This will use withCleanup after the next Kit release + [Symbol.dispose]: () => store[Symbol.dispose](), + }); + + if (config.usePayer !== false) { + Object.defineProperty(obj, 'payer', { + configurable: true, + enumerable: true, + get() { + // Note that we only read `client.payer` here, to allow the fallback to be defined with a get function + return store.getState().signer ?? fallbackClient?.payer; + }, + }); + } + + return obj as WalletPluginReturn; + }; +} diff --git a/packages/kit-plugin-wallet/src/types/global.d.ts b/packages/kit-plugin-wallet/src/types/global.d.ts new file mode 100644 index 0000000..13de8a7 --- /dev/null +++ b/packages/kit-plugin-wallet/src/types/global.d.ts @@ -0,0 +1,6 @@ +declare const __BROWSER__: boolean; +declare const __ESM__: boolean; +declare const __NODEJS__: boolean; +declare const __REACTNATIVE__: boolean; +declare const __TEST__: boolean; +declare const __VERSION__: string; diff --git a/packages/kit-plugin-wallet/tsconfig.declarations.json b/packages/kit-plugin-wallet/tsconfig.declarations.json new file mode 100644 index 0000000..dc2d27b --- /dev/null +++ b/packages/kit-plugin-wallet/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["src/index.ts", "src/types"] +} diff --git a/packages/kit-plugin-wallet/tsconfig.json b/packages/kit-plugin-wallet/tsconfig.json new file mode 100644 index 0000000..7a4f027 --- /dev/null +++ b/packages/kit-plugin-wallet/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { "lib": [] }, + "display": "@solana/kit-plugin-wallet", + "extends": "../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/packages/kit-plugin-wallet/tsup.config.ts b/packages/kit-plugin-wallet/tsup.config.ts new file mode 100644 index 0000000..55e9945 --- /dev/null +++ b/packages/kit-plugin-wallet/tsup.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'tsup'; + +import { getPackageBuildConfigs } from '../../tsup.config.base'; + +export default defineConfig(getPackageBuildConfigs()); diff --git a/packages/kit-plugin-wallet/vitest.config.mts b/packages/kit-plugin-wallet/vitest.config.mts new file mode 100644 index 0000000..8fd0137 --- /dev/null +++ b/packages/kit-plugin-wallet/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +import { getVitestConfig } from '../../vitest.config.base.mjs'; + +export default defineConfig({ + test: { + projects: [getVitestConfig('browser'), getVitestConfig('node'), getVitestConfig('react-native')], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72fd9f1..e8aff0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,39 @@ importers: specifier: ^6.6.0 version: 6.6.0(typescript@5.9.3) + packages/kit-plugin-wallet: + dependencies: + '@solana/kit': + specifier: ^6.6.0 + version: 6.6.0(typescript@5.9.3) + '@solana/wallet-account-signer': + specifier: ^6.6.0 + version: 6.6.0(typescript@5.9.3) + '@solana/wallet-standard-chains': + specifier: ^1.1.1 + version: 1.1.1 + '@solana/wallet-standard-features': + specifier: ^1.3.0 + version: 1.3.0 + '@wallet-standard/app': + specifier: ^1.1.0 + version: 1.1.0 + '@wallet-standard/errors': + specifier: ^0.1.1 + version: 0.1.1 + '@wallet-standard/features': + specifier: ^1.1.0 + version: 1.1.0 + '@wallet-standard/ui': + specifier: ^1.0.1 + version: 1.0.1 + '@wallet-standard/ui-features': + specifier: ^1.0.1 + version: 1.0.1 + '@wallet-standard/ui-registry': + specifier: ^1.0.1 + version: 1.0.1 + packages/kit-plugins: dependencies: '@solana/kit': @@ -1661,6 +1694,23 @@ packages: typescript: optional: true + '@solana/wallet-account-signer@6.6.0': + resolution: {integrity: sha512-kdBSwBviUCWKtvTEaxnLMRfphu32b2SjdXPGQJvbb2ma8Sued9vRUb7JvjE81/Mka2NhtU5+C1UDh5wEHlNjQA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/wallet-standard-chains@1.1.1': + resolution: {integrity: sha512-Us3TgL4eMVoVWhuC4UrePlYnpWN+lwteCBlhZDUhFZBJ5UMGh94mYPXno3Ho7+iHPYRtuCi/ePvPcYBqCGuBOw==} + engines: {node: '>=16'} + + '@solana/wallet-standard-features@1.3.0': + resolution: {integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==} + engines: {node: '>=16'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2017,6 +2067,43 @@ packages: '@vitest/utils@4.1.2': resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@wallet-standard/app@1.1.0': + resolution: {integrity: sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==} + engines: {node: '>=16'} + + '@wallet-standard/base@1.1.0': + resolution: {integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==} + engines: {node: '>=16'} + + '@wallet-standard/errors@0.1.1': + resolution: {integrity: sha512-V8Ju1Wvol8i/VDyQOHhjhxmMVwmKiwyxUZBnHhtiPZJTWY0U/Shb2iEWyGngYEbAkp2sGTmEeNX1tVyGR7PqNw==} + engines: {node: '>=16'} + hasBin: true + + '@wallet-standard/features@1.1.0': + resolution: {integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==} + engines: {node: '>=16'} + + '@wallet-standard/ui-compare@1.0.1': + resolution: {integrity: sha512-Qr6AjgxTgTNgjUm/HQend08jFCUJ2ugbONpbC1hSl4Ndul+theJV3CwVZ2ffKun584bHoR8OAibJ+QA4ecogEA==} + engines: {node: '>=16'} + + '@wallet-standard/ui-core@1.0.0': + resolution: {integrity: sha512-pnpBfxJois0fIAI0IBJ6hopOguw81JniB6DzOs5J7C16W7/M2kC0OKHQFKrz6cgSGMq8X0bPA8nZTXFTSNbURg==} + engines: {node: '>=16'} + + '@wallet-standard/ui-features@1.0.1': + resolution: {integrity: sha512-0/lZFx599bGcDEvisAWtbFMuRM/IuqP/o0vbhAeQdLWsWsaqFTUIKZtMt8JJq+fFBMQGc6tuRH6ehrgm+Y0biQ==} + engines: {node: '>=16'} + + '@wallet-standard/ui-registry@1.0.1': + resolution: {integrity: sha512-+SeXEwSoyqEWv9B6JLxRioRlgN5ksSFObZMf+XKm2U+vwmc/mfm43I8zw5wvGBpubzmywbe2eejd5k/snyx+uA==} + engines: {node: '>=16'} + + '@wallet-standard/ui@1.0.1': + resolution: {integrity: sha512-3b1iSfHOB3YpuBM645ZAgA0LMGZv+3Eh4y9lM3kS+NnvK4NxwnEdn1mLbFxevRhyulNjFZ50m2Cq5mpEOYs2mw==} + engines: {node: '>=16'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2238,6 +2325,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -5230,6 +5321,34 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/wallet-account-signer@6.6.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@5.9.3) + '@solana/codecs-core': 6.6.0(typescript@5.9.3) + '@solana/keys': 6.6.0(typescript@5.9.3) + '@solana/promises': 6.6.0(typescript@5.9.3) + '@solana/signers': 6.6.0(typescript@5.9.3) + '@solana/transaction-messages': 6.6.0(typescript@5.9.3) + '@solana/transactions': 6.6.0(typescript@5.9.3) + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/errors': 0.1.1 + '@wallet-standard/ui': 1.0.1 + '@wallet-standard/ui-registry': 1.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/wallet-standard-chains@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@solana/wallet-standard-features@1.3.0': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + '@standard-schema/spec@1.1.0': {} '@turbo/darwin-64@2.9.3': @@ -5621,6 +5740,50 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@wallet-standard/app@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/base@1.1.0': {} + + '@wallet-standard/errors@0.1.1': + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + + '@wallet-standard/features@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/ui-compare@1.0.1': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/ui-core': 1.0.0 + '@wallet-standard/ui-registry': 1.0.1 + + '@wallet-standard/ui-core@1.0.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/ui-features@1.0.1': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/errors': 0.1.1 + '@wallet-standard/ui-core': 1.0.0 + '@wallet-standard/ui-registry': 1.0.1 + + '@wallet-standard/ui-registry@1.0.1': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/errors': 0.1.1 + '@wallet-standard/ui-core': 1.0.0 + + '@wallet-standard/ui@1.0.1': + dependencies: + '@wallet-standard/ui-compare': 1.0.1 + '@wallet-standard/ui-core': 1.0.0 + '@wallet-standard/ui-features': 1.0.1 + acorn-jsx@5.3.2(acorn@7.4.1): dependencies: acorn: 7.4.1 @@ -5839,6 +6002,8 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@4.1.1: {} diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md new file mode 100644 index 0000000..368d999 --- /dev/null +++ b/wallet-plugin-spec.md @@ -0,0 +1,1299 @@ +# RFC: `wallet` Plugin for Kit + +**Status:** Draft +**Package:** `@solana/kit-plugin-wallet` + +## Prerequisites + +This spec builds on two changes that must land first: + +- **Plugin lifecycle utilities** (`extendClient`, `withCleanup`) in `@solana/plugin-core` — `extendClient` provides descriptor-preserving client extension (preserves getters and symbols through plugin composition). `withCleanup` provides `Symbol.dispose`-based cleanup chaining. `addUse` is updated to use `Object.defineProperties` instead of spread to preserve property descriptors. See the plugin lifecycle RFC. +- **Bridge function** (`createSignerFromWalletAccount`) in `@solana/wallet-account-signer` — a framework-agnostic function that takes a `UiWalletAccount` and a `SolanaChain` and returns a `TransactionSendingSigner` or `TransactionModifyingSigner` (and optionally `MessageSigner`). Extracted from the logic currently in `@solana/react`'s `useWalletAccountTransactionSigner` / `useWalletAccountTransactionSendingSigner` hooks. + +### Dependencies + +```json +{ + "peerDependencies": { + "@solana/kit": "^6.x" + }, + "dependencies": { + "@solana/wallet-account-signer": "^1.x", + "@wallet-standard/app": "^1.x", + "@wallet-standard/ui": "^1.x", + "@wallet-standard/ui-features": "^1.x", + "@wallet-standard/ui-registry": "^1.x" + } +} +``` + +The bridge function (`createSignerFromWalletAccount`) is consumed via `@solana/wallet-account-signer`. `extendClient` and `withCleanup` are consumed via `@solana/kit`. + +## Summary + +A framework-agnostic Kit plugin that manages wallet discovery, connection lifecycle, and signer creation using wallet-standard. When a wallet is connected, the plugin optionally syncs its signer to the client's `payer` slot via a dynamic getter, falling back to any previously configured payer when no wallet is connected. The plugin exposes subscribable wallet state for framework adapters (React, Vue, Svelte, etc.) to consume without coupling to any specific UI framework. + +**SSR-safe.** The plugin can be included in the same client chain on both server and browser. On the server (`typeof window === 'undefined'`), it gracefully degrades — status stays `'pending'`, wallet list is empty, payer falls back, storage is skipped, no registry listeners are created. On the browser it initializes fully. This means a single client chain works everywhere: + +```typescript +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(backendKeypair)) + .use(wallet({ chain: 'solana:mainnet' })) + .use(systemProgram()) + .use(sendTransactions()); + +// Server: client.wallet.status === 'pending', client.payer === backendKeypair +// Browser: auto-connect fires, client.payer becomes wallet signer +``` + +## Motivation + +Kit provides a composable plugin system for building Solana clients. The existing `payer` plugin works well for static signers (backend keypairs, generated signers), but frontend dApps need wallet integration — discovery, user-initiated connection, account switching, and reactive state for UI rendering. + +Today, `@solana/react` bridges wallet-standard wallets into Kit signers via React hooks (`useWalletAccountTransactionSigner`, etc.), but this logic is locked inside React. There is no framework-agnostic layer that manages wallet state and provides Kit-compatible signers. + +This plugin fills that gap. It sits between wallet-standard's raw discovery API and framework-specific hooks, providing: + +- Automatic wallet discovery via `getWallets()` +- Connection lifecycle management (connect, disconnect, silent reconnect) +- Signer creation and caching via the bridge function +- Dynamic payer integration with fallback +- A subscribable store that any framework can bind to +- Cleanup via `withCleanup` and `Symbol.dispose` + +### Where it fits + +``` ++---------------------------------------------------+ +| React hooks / Vue composables / Svelte stores | <- Framework adapters (thin) +| Subscribe to client wallet state for rendering | ++---------------------------------------------------+ +| wallet() plugin | <- THIS SPEC +| Discovery, connection, signer, payer, state | ++---------------------------------------------------+ +| Kit plugin client | +| .use(rpc(...)) | +| .use(wallet({ ... })) <- or .use(payer(...)) | +| .use(sendTransactions()) | ++---------------------------------------------------+ +| createSignerFromWalletAccount() | <- @solana/wallet-account-signer ++---------------------------------------------------+ +| extendClient / withCleanup | <- Plugin lifecycle (in plugin-core) ++---------------------------------------------------+ +| wallet-standard @solana/signers Kit | ++---------------------------------------------------+ +``` + +### Relationship to `@solana/react` + +This plugin extracts the wallet management logic currently embedded in `@solana/react`'s `SelectedWalletAccountContextProvider` (persistence, auto-restore, account selection, signer creation) into a framework-agnostic layer. Once this plugin ships, `@solana/react` can be rewritten as a thin adapter over `client.wallet.subscribe` / `client.wallet.getSnapshot` — a handful of hooks rather than a full wallet management implementation. The same approach applies to Vue, Svelte, and Solid adapters, all consuming the same plugin. + +### Relationship to `payer` + +The `wallet` plugin and `payer` plugin both set `client.payer`. Use `payer()` for static signers (backend keypairs, scripts) and `wallet()` for dApps with user-facing wallet connections. + +When both are needed (e.g. a fallback backend signer with wallet override), `payer` should come first in the plugin chain. The wallet plugin captures whatever is in `client.payer` at installation time as its fallback: + +```typescript +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(backendKeypair)) // static payer set first + .use(wallet({ chain: 'solana:mainnet' })) // captures keypair as fallback + .use(sendTransactions()); + +// No wallet connected -> client.payer returns backendKeypair +// Wallet connected -> client.payer returns wallet signer +// Wallet disconnects -> client.payer returns backendKeypair again +``` + +The wallet plugin uses `Object.defineProperty` for a dynamic `payer` getter. `addUse` preserves this getter descriptor through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve it. The `sendTransactions` plugin's closure reads `client.payer` at transaction time, which resolves via the getter to whichever signer is current. + +Downstream plugins (e.g. `sendTransactions()`) depend only on `client.payer` being a `TransactionSigner`. They do not know or care whether it was set by `payer()` or `wallet()`. + +## API Surface + +### Plugin creation + +```typescript +import { wallet } from '@solana/kit-plugin-wallet'; + +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(wallet({ + chain: 'solana:mainnet', + usePayer: true, + autoConnect: true, + })) + .use(sendTransactions()); +``` + +### Client type extension + +The `wallet` plugin adds the following to the client: + +```typescript +type WalletApi = { + wallet: { + // -- State (getters) -- + + /** All discovered wallet-standard wallets, filtered by chain, standard:connect, and optional filter function. */ + readonly wallets: readonly UiWallet[]; + + /** + * The currently connected wallet, account, and signer — or null when + * disconnected. Signer is null for read-only wallets. Wallet and account + * are always both present when connected. + */ + readonly connected: WalletConnection | null; + + /** Current connection status. */ + readonly status: WalletStatus; + + // -- Actions (methods) -- + + /** + * Connect to a wallet. Calls standard:connect on the wallet, then + * selects the first newly authorized account (or the first account + * if reconnecting). Creates and caches a signer for the active account. + * Returns all accounts from the wallet after connection. + */ + connect: (wallet: UiWallet) => Promise; + + /** Disconnect the active wallet. Calls standard:disconnect if supported. */ + disconnect: () => Promise; + + /** + * Switch to a different account within the connected wallet. + * Creates and caches a new signer for the selected account. + */ + selectAccount: (account: UiWalletAccount) => void; + + /** + * Sign an arbitrary message with the connected account. + * Throws if no account is connected or if the wallet does not + * support the solana:signMessage feature. + * Delegates to the MessageSigner returned by the bridge function. + */ + signMessage: (message: Uint8Array) => Promise; + + /** + * Sign In With Solana. + * + * Overload 1: sign in with the already-connected wallet. + * Throws if no wallet is connected or if the wallet does not + * support the solana:signIn feature. + * + * Overload 2: sign in with a specific wallet (SIWS-as-connect). + * Implicitly connects the wallet, sets the returned account as + * active, creates and caches a signer. After completion, the + * client is in the same state as if connect() had been called. + */ + signIn(input?: SolanaSignInInput): Promise; + signIn(wallet: UiWallet, input?: SolanaSignInInput): Promise; + + // -- Framework integration (methods) -- + + /** + * Subscribe to any wallet state change. Compatible with React's + * useSyncExternalStore and similar framework primitives. + * Returns an unsubscribe function. + */ + subscribe: (listener: () => void) => () => void; + + /** + * Get a snapshot of the full wallet state. Referentially stable + * when unchanged. Useful for framework adapters. + */ + getSnapshot: () => WalletStateSnapshot; + }; +}; + +type WalletConnection = { + wallet: UiWallet; + account: UiWalletAccount; + /** The signer for the active account, or null for read-only wallets. */ + signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; +}; + +type WalletStatus = + | 'pending' // not yet initialized (SSR, or browser before first storage/registry check) + | 'disconnected' // initialized, no wallet connected + | 'connecting' // user-initiated connection in progress + | 'connected' // wallet connected, account + signer active + | 'disconnecting' // user-initiated disconnection in progress + | 'reconnecting'; // auto-connect in progress (restoring previous session) + +type WalletStateSnapshot = { + wallets: readonly UiWallet[]; + connected: { + wallet: UiWallet; + account: UiWalletAccount; + hasSigner: boolean; + } | null; + status: WalletStatus; +}; +``` + +The snapshot includes `hasSigner` (a stable boolean) rather than the signer object itself — framework components should not render based on the signer (it would cause unnecessary re-renders on referential changes). `hasSigner` provides the UI branching signal. The actual signer is accessible via `client.wallet.connected.signer` for use in instruction building and manual signing. + +```tsx +const { connected, status } = useSyncExternalStore( + client.wallet.subscribe, + client.wallet.getSnapshot, +); + +if (status === 'pending') return null; +if (!connected) return ; +if (!connected.hasSigner) return ; +return ; +``` + +### Dynamic payer (when `usePayer: true`) + +When configured with `usePayer: true` (default), the plugin defines a `payer` getter via `Object.defineProperty`. The updated `addUse` preserves this getter descriptor through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve it. When any code accesses `client.payer`, the getter returns the current wallet signer or fallback. + +When `usePayer: false`, the wallet plugin does not touch `client.payer`. + +### Cleanup + +The plugin registers cleanup via `withCleanup`, spread into the return object: + +```typescript +// Explicit cleanup +client[Symbol.dispose](); + +// With using syntax (TypeScript 5.2+) +{ + using client = createEmptyClient() + .use(rpc('https://...')) + .use(wallet({ chain: 'solana:mainnet' })) + .use(sendTransactions()); +} // cleanup runs automatically + +// In React +useEffect(() => { + const client = createEmptyClient() + .use(rpc('https://...')) + .use(wallet({ chain: 'solana:mainnet' })); + setClient(client); + return () => client[Symbol.dispose](); +}, []); +``` + +Cleanup unsubscribes from wallet-standard registry events, any active wallet's `standard:events` listener, and the auto-reconnect registry watcher (if still pending). Multiple disposable plugins chain in LIFO order. + +## Implementation + +### Plugin function + +```typescript +import { extendClient, withCleanup } from '@solana/kit'; +import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; +import { + SolanaError, + SOLANA_ERROR__WALLET__NOT_CONNECTED, + SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, +} from '@solana/errors'; +import { getWallets } from '@wallet-standard/app'; +import { + getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, +} from '@wallet-standard/ui-registry'; +import { getWalletFeature } from '@wallet-standard/ui-features'; + +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import type { TransactionSigner, MessageSigner, SolanaChain } from '@solana/kit'; + +export function wallet(config: WalletPluginConfig) { + return (client: T) => { + const store = createWalletStore(config); + + const fallbackPayer = 'payer' in client + ? (client as T & { payer: TransactionSigner }).payer + : undefined; + + // Build the wallet namespace object + const walletObj: Record = { + connect: (w: UiWallet) => store.connect(w), + disconnect: () => store.disconnect(), + selectAccount: (a: UiWalletAccount) => store.selectAccount(a), + signMessage: (msg: Uint8Array) => store.signMessage(msg), + signIn: (...args: [SolanaSignInInput?] | [UiWallet, SolanaSignInInput?]) => store.signIn(...args), + subscribe: (l: () => void) => store.subscribe(l), + getSnapshot: () => store.getSnapshot(), + }; + + // State reads as getters on the wallet namespace + Object.defineProperty(walletObj, 'wallets', { + get: () => store.getState().wallets, + enumerable: true, + }); + Object.defineProperty(walletObj, 'connected', { + get: () => store.getConnected(), + enumerable: true, + }); + Object.defineProperty(walletObj, 'status', { + get: () => store.getState().status, + enumerable: true, + }); + + const obj = extendClient(client, { + wallet: walletObj, + ...withCleanup(client, () => store.destroy()), + }); + + // payer stays top-level + if (config.usePayer !== false) { + Object.defineProperty(obj, 'payer', { + get() { + return store.getState().signer ?? fallbackPayer; + }, + enumerable: true, + configurable: true, + }); + } + + return obj; + }; +} +``` + +### Internal store + +The store is a plain object with state management -- no external dependencies. It follows the same subscribe/getSnapshot contract as React's `useSyncExternalStore`. + +#### State shape + +```typescript +type WalletStoreState = { + wallets: readonly UiWallet[]; + connectedWallet: UiWallet | null; + account: UiWalletAccount | null; + /** + * Cached signer derived from the active account via + * createSignerFromWalletAccount(). May include MessageSigner + * if the wallet supports solana:signMessage. + */ + signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + status: WalletStatus; +}; +``` + +#### Store implementation + +```typescript +function createWalletStore(config: WalletPluginConfig) { + const isBrowser = typeof window !== 'undefined'; + + let state: WalletStoreState = { + wallets: [], + connectedWallet: null, + account: null, + signer: null, + status: 'pending', + }; + + let snapshot: WalletStateSnapshot = deriveSnapshot(state); + const listeners = new Set<() => void>(); + let walletEventsCleanup: (() => void) | null = null; + let reconnectCleanup: (() => void) | null = null; + + // Tracks whether the user has made an explicit selection (connect or selectAccount). + // When true, auto-restore from storage will not override the user's choice. + let userHasSelected = false; + + // Resolve storage: skip on server, default to localStorage in browser. + const storage = !isBrowser + ? null + : config.storage === null + ? null + : config.storage ?? localStorage; + const storageKey = config.storageKey ?? 'kit-wallet'; + + // -- State management -- + + function setState(updates: Partial) { + const prev = state; + state = { ...state, ...updates }; + + // Only create new connected object if connection-relevant fields changed + if ( + state.connectedWallet !== prev.connectedWallet || + state.account !== prev.account || + state.signer !== prev.signer + ) { + connected = deriveConnected(state); + } + + // Only create new snapshot if snapshot-relevant fields changed. + // This ensures referential stability for useSyncExternalStore — + // a signer recreation that doesn't change hasSigner won't cause + // React to re-render. + if ( + state.wallets !== prev.wallets || + state.connectedWallet !== prev.connectedWallet || + state.account !== prev.account || + state.status !== prev.status || + (state.signer !== null) !== (prev.signer !== null) + ) { + snapshot = deriveSnapshot(state); + } + + listeners.forEach((l) => l()); + } + + function deriveConnected(s: WalletStoreState): WalletConnection | null { + if (!s.connectedWallet || !s.account) return null; + return Object.freeze({ + wallet: s.connectedWallet, + account: s.account, + signer: s.signer, + }); + } + + function deriveSnapshot(s: WalletStoreState): WalletStateSnapshot { + return Object.freeze({ + wallets: s.wallets, + connected: s.connectedWallet && s.account + ? Object.freeze({ + wallet: s.connectedWallet, + account: s.account, + hasSigner: s.signer !== null, + }) + : null, + status: s.status, + }); + } + + let connected: WalletConnection | null = deriveConnected(state); + + // -- SSR: skip all browser-only initialization -- + + if (!isBrowser) { + return { + getState: () => state, + getConnected: () => null, + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + connect: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); }, + disconnect: () => Promise.resolve(), + selectAccount: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); }, + signMessage: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); }, + signIn: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); }, + destroy: () => {}, + }; + } + + // -- Browser-only initialization below this point -- + + // -- Signer creation (resilient to read-only wallets) -- + + function tryCreateSigner( + account: UiWalletAccount, + ): TransactionSigner | (MessageSigner & TransactionSigner) | null { + try { + return createSignerFromWalletAccount(account, config.chain); + } catch { + // Wallet doesn't support signing (e.g. read-only / watch wallet). + // Connection proceeds without a signer — account is still usable + // for discovery, display, and persistence. + return null; + } + } + + // -- Wallet discovery -- + + const registry = getWallets(); + + function filterWallet(wallet: Wallet): boolean { + const uiWallet = + getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(wallet); + const supportsChain = uiWallet.chains.includes(config.chain); + const supportsConnect = uiWallet.features.includes('standard:connect'); + if (!supportsChain || !supportsConnect) return false; + // Apply custom filter if provided + return config.filter ? config.filter(uiWallet) : true; + } + + function buildWalletList(): readonly UiWallet[] { + return Object.freeze( + registry.get() + .filter(filterWallet) + .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), + ); + } + + setState({ wallets: buildWalletList() }); + + const unsubRegister = registry.on('register', () => { + setState({ wallets: buildWalletList() }); + }); + const unsubUnregister = registry.on('unregister', () => { + const newWallets = buildWalletList(); + const updates: Partial = { wallets: newWallets }; + + 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'; + storage?.removeItem(storageKey); + } + + setState(updates); + }); + + // -- Connection lifecycle -- + + async function connect(uiWallet: UiWallet): Promise { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; + setState({ status: 'connecting' }); + + try { + const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as + StandardConnectFeature['standard:connect']; + + // Snapshot existing accounts before connect — the wallet may + // already have some accounts visible. + const existingAccounts = [...uiWallet.accounts]; + + await connectFeature.connect(); + + // After connect, read accounts from uiWallet.accounts (already + // UiWalletAccount[]). The connect call's side effect is to populate + // this list — we don't need to map the raw WalletAccount[] return. + const allAccounts = uiWallet.accounts; + + if (allAccounts.length === 0) { + setState({ status: 'disconnected' }); + return allAccounts; + } + + // Prefer the first newly authorized account. If none are new + // (e.g. re-connecting to an already-visible wallet), take the first. + const newAccount = allAccounts.find( + (a) => !existingAccounts.some((e) => e.address === a.address), + ); + const activeAccount = newAccount ?? allAccounts[0]; + + const signer = tryCreateSigner(activeAccount); + + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); + + setState({ + connectedWallet: uiWallet, + account: activeAccount, + signer, + status: 'connected', + }); + + persistAccount(activeAccount); + return allAccounts; + } catch (error) { + setState({ status: 'disconnected' }); + throw error; + } + } + + async function disconnect(): Promise { + const currentWallet = state.connectedWallet; + setState({ status: 'disconnecting' }); + + try { + if (currentWallet && currentWallet.features.includes('standard:disconnect')) { + const disconnectFeature = getWalletFeature( + currentWallet, 'standard:disconnect', + ) as StandardDisconnectFeature['standard:disconnect']; + await disconnectFeature.disconnect(); + } + } finally { + // Always clear local state and storage, even if standard:disconnect + // threw (network error, wallet bug). This is intentionally fail-safe: + // a broken disconnect should not leave the user in a state where they + // auto-reconnect into a potentially corrupt session on next page load. + walletEventsCleanup?.(); + walletEventsCleanup = null; + + setState({ + connectedWallet: null, + account: null, + signer: null, + status: 'disconnected', + }); + + storage?.removeItem(storageKey); + } + } + + /** + * Clear local state without calling standard:disconnect on the wallet. + * Used for wallet-initiated disconnections (accounts removed, chain/feature + * changes) where the wallet already knows it disconnected. Synchronous, + * so it can't race with other event handlers. + */ + function disconnectLocally(): void { + walletEventsCleanup?.(); + walletEventsCleanup = null; + + setState({ + connectedWallet: null, + account: null, + signer: null, + status: 'disconnected', + }); + + storage?.removeItem(storageKey); + } + + function selectAccount(account: UiWalletAccount): void { + if (!state.connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'selectAccount', + }); + } + userHasSelected = true; + const signer = tryCreateSigner(account); + setState({ account, signer }); + persistAccount(account); + } + + // -- Message signing -- + + async function signMessage(message: Uint8Array): Promise { + const { signer, connectedWallet } = state; + if (!signer || !connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'signMessage', + }); + } + if (!('modifyAndSignMessages' in signer)) { + throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { + walletName: connectedWallet.name, + featureName: 'solana:signMessage', + }); + } + // Delegate to the MessageSigner returned by createSignerFromWalletAccount. + // Exact call signature depends on Kit's MessageSigner interface. + const results = await (signer as MessageSigner).modifyAndSignMessages([message]); + return results[0]; + } + + // -- Sign In With Solana -- + + async function signIn(walletOrInput?: UiWallet | SolanaSignInInput, maybeInput?: SolanaSignInInput): Promise { + // Determine which overload was called + const isWalletForm = walletOrInput && 'features' in walletOrInput; + const targetWallet = isWalletForm ? walletOrInput as UiWallet : state.connectedWallet; + const input = isWalletForm ? maybeInput : walletOrInput as SolanaSignInInput | undefined; + + if (!targetWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'signIn', + }); + } + if (!targetWallet.features.includes('solana:signIn')) { + throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { + walletName: targetWallet.name, + featureName: 'solana:signIn', + }); + } + + const signInFeature = getWalletFeature(targetWallet, 'solana:signIn') as + SolanaSignInFeature['solana:signIn']; + const [result] = await signInFeature.signIn(input ? [input] : [{}]); + + // If called with a wallet (SIWS-as-connect), set up connection state + // using the account returned by the sign-in response. + if (isWalletForm) { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; + + const account = result.account; // UiWalletAccount from the sign-in response + const signer = tryCreateSigner(account); + + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(targetWallet); + + setState({ + connectedWallet: targetWallet, + account, + signer, + status: 'connected', + }); + + persistAccount(account); + } + + return result; + } + + // -- Wallet-initiated events -- + + function subscribeToWalletEvents(uiWallet: UiWallet): () => void { + if (!uiWallet.features.includes('standard:events')) { + return () => {}; + } + + const eventsFeature = getWalletFeature(uiWallet, 'standard:events') as + StandardEventsFeature['standard:events']; + + return eventsFeature.on('change', (properties) => { + if (properties.accounts) { + handleAccountsChanged(uiWallet); + } + if (properties.chains) { + handleChainsChanged(uiWallet); + } + if (properties.features) { + handleFeaturesChanged(uiWallet); + } + }); + } + + function handleAccountsChanged(uiWallet: UiWallet): void { + const newAccounts = uiWallet.accounts; + + if (newAccounts.length === 0) { + disconnectLocally(); + return; + } + + const currentAddress = state.account?.address; + const stillPresent = currentAddress + ? newAccounts.find((a) => a.address === currentAddress) + : null; + const activeAccount = stillPresent ?? newAccounts[0]; + + const signer = tryCreateSigner(activeAccount); + setState({ account: activeAccount, signer }); + persistAccount(activeAccount); + } + + function handleChainsChanged(uiWallet: UiWallet): void { + if (!uiWallet.chains.includes(config.chain)) { + disconnectLocally(); + return; + } + // Chain support shifted but our chain is still valid — recreate + // signer in case chain-related capabilities changed. + if (state.account) { + const signer = tryCreateSigner(state.account); + setState({ signer }); + } + } + + function handleFeaturesChanged(uiWallet: UiWallet): void { + // Re-run the filter — if the wallet no longer passes, disconnect. + if (config.filter && !config.filter(uiWallet)) { + disconnectLocally(); + return; + } + // Features changed but wallet is still valid — recreate signer + // to pick up new capabilities (e.g. solana:signMessage added) + // or drop removed ones. createSignerFromWalletAccount is cheap. + if (state.account) { + const signer = tryCreateSigner(state.account); + setState({ signer }); + } + } + + // -- Auto-connect -- + + if (config.autoConnect !== false && storage) { + // Wrapped in async IIFE because storage.getItem may return a Promise + // (e.g. IndexedDB). Plugin setup still returns synchronously — status + // stays 'pending' until the storage read resolves. + (async () => { + const savedKey = await storage.getItem(storageKey); + if (userHasSelected) return; + + if (!savedKey) { + setState({ status: 'disconnected' }); + return; + } + + const separatorIndex = savedKey.lastIndexOf(':'); + if (separatorIndex === -1) { + // Malformed saved key + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + return; + } + + const walletName = savedKey.slice(0, separatorIndex); + const existing = state.wallets.find((w) => w.name === walletName); + + if (existing) { + attemptSilentReconnect(savedKey, existing); + } else if ( + registry.get().some((w) => { + const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + return ui.name === walletName; + }) + ) { + // Wallet is registered but doesn't pass the filter (wrong chain, + // missing standard:connect, or rejected by config.filter). + // Clear stale persistence — don't wait for it. + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + } else { + // Wallet not registered yet — watch for it to appear. + // Revert status to 'disconnected' after 3s to avoid a perpetual + // spinner if the wallet is uninstalled. Keep the listener alive + // so slow-loading extensions can still silently reconnect. + setState({ status: 'reconnecting' }); + + const statusTimeout = setTimeout(() => { + if (!userHasSelected && state.status === 'reconnecting') { + setState({ status: 'disconnected' }); + } + }, 3000); + + const unsubRegisterForReconnect = registry.on('register', () => { + if (userHasSelected) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + return; + } + const found = buildWalletList().find((w) => w.name === walletName); + if (found) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + attemptSilentReconnect(savedKey, 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 — clear stale persistence + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + } + }); + + reconnectCleanup = () => { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + }; + } + })(); + } else { + // No auto-connect: immediately transition from 'pending' to 'disconnected' + setState({ status: 'disconnected' }); + } + + async function attemptSilentReconnect( + savedAccountKey: string, + uiWallet: UiWallet, + ): Promise { + setState({ status: 'reconnecting' }); + + try { + const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as + StandardConnectFeature['standard:connect']; + await connectFeature.connect({ silent: true }); + + // Read accounts from uiWallet.accounts after connect. + const allAccounts = uiWallet.accounts; + + if (allAccounts.length === 0) { + setState({ status: 'disconnected' }); + storage?.removeItem(storageKey); + return; + } + + // Check again: user may have connected manually while we were awaiting + if (userHasSelected) return; + + // Restore specific saved account, fall back to first from same wallet + const savedAddress = savedAccountKey.slice(savedAccountKey.lastIndexOf(':') + 1); + const activeAccount = allAccounts.find((a) => a.address === savedAddress) + ?? allAccounts[0]; + + const signer = tryCreateSigner(activeAccount); + + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); + + setState({ + connectedWallet: uiWallet, + account: activeAccount, + signer, + status: 'connected', + }); + } catch { + setState({ status: 'disconnected' }); + storage?.removeItem(storageKey); + } + } + + // -- Persistence -- + + function persistAccount(account: UiWalletAccount): void { + storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); + } + + // -- Public store API -- + + return { + getState: () => state, + getConnected: () => connected, + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + connect, + disconnect, + selectAccount, + signMessage, + signIn, + destroy: () => { + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + reconnectCleanup?.(); + reconnectCleanup = null; + listeners.clear(); + }, + }; +} +``` + +## Signer Caching + +The signer is created via `tryCreateSigner()` (wrapping `createSignerFromWalletAccount()`) when an account becomes active, and stored in `state.signer`. It is not recreated on every `client.payer` access -- the getter simply reads `state.signer`. + +If `createSignerFromWalletAccount` throws (e.g. the wallet doesn't support any signing features), `tryCreateSigner` catches the error and returns `null`. The wallet is still connected — the account is set, events work, persistence works — but `state.signer` is `null` and `hasSigner` in the snapshot is `false`. The payer getter falls back to whatever payer was configured before the wallet plugin. + +This ensures referential stability, which matters for React's dependency arrays and avoids redundant codec/wrapper creation. + +The signer is invalidated and recreated when: + +- The user connects to a different wallet +- The user switches accounts via `selectAccount` +- The wallet emits a `change` event that updates accounts +- The wallet emits a `change` event for features (e.g. `solana:signMessage` added or removed) +- The wallet emits a `change` event for chains (if the configured chain is still supported) + +A feature change event may cause a previously null signer to become non-null (e.g. a watch wallet adds signing support) or vice versa. + +### Signer types + +The bridge function (`createSignerFromWalletAccount`) inspects the wallet's features and returns the appropriate signer type: + +- `TransactionModifyingSigner` if the wallet supports `solana:signTransaction` +- `TransactionSendingSigner` if the wallet supports `solana:signAndSendTransaction` +- `MessageSigner` (intersected with the above) if the wallet supports `solana:signMessage` +- Throws if the wallet supports none of the above (caught by `tryCreateSigner`) + +All variants satisfy `TransactionSigner`, which is what `client.payer` expects. Kit's transaction execution automatically uses the appropriate signing path (e.g. `TransactionSendingSigner` lets the wallet submit the transaction itself). The `signMessage` method on the client checks at runtime whether the cached signer includes `MessageSigner`. The wallet plugin delegates signer construction entirely to the bridge function. + +## Payer Integration Detail + +### How the getter works + +The wallet plugin uses `Object.defineProperty` to define a dynamic getter on `payer`, after building the client with `extendClient`. The getter reads from the internal wallet store: + +```typescript +Object.defineProperty(obj, 'payer', { + get() { + return store.getState().signer ?? fallbackPayer; + }, + enumerable: true, + configurable: true, +}); +``` + +This getter is preserved through subsequent `.use()` calls because: + +1. `addUse` (updated in the plugin lifecycle RFC) uses `Object.getOwnPropertyDescriptors` instead of spread, preserving the getter. +2. Subsequent plugins using `extendClient` also preserve it. +3. The final frozen client returned by `addUse` retains the getter -- `Object.freeze` does not strip getters. + +Note: downstream plugins should use `extendClient` rather than spread to ensure the payer getter is preserved. + +### Interaction with sendTransactions plugin + +The `sendTransactions` plugin accesses `client.payer` at transaction time. Because the payer is a getter, it always resolves to the current value: + +- User connects wallet -> next `sendTransaction` call uses the wallet signer +- User switches accounts -> next `sendTransaction` call uses the new account's signer +- User disconnects -> next `sendTransaction` call uses the fallback payer (or fails if none) + +No client reconstruction is needed. The client is a long-lived object. + +## Subscribability Contract + +The `subscribe` and `getSnapshot` methods on `client.wallet` follow the contract expected by React's `useSyncExternalStore`. + +`subscribe` fires on every state change — including signer-only changes that don't affect the snapshot. Listeners don't know what changed; they just know "something changed." + +`getSnapshot` returns a memoized frozen object. A new snapshot reference is only created when snapshot-relevant fields change (wallets, connected wallet, account, status, or `hasSigner`). Crucially, a signer recreation that doesn't change `hasSigner` (e.g. a feature change that doesn't add or remove signing capability) fires `subscribe` listeners but returns the same snapshot reference from `getSnapshot`. React's `useSyncExternalStore` compares references, sees no change, and skips the re-render. + +The `connected` getter on `client.wallet` follows the same principle — a new object is only created when wallet, account, or signer identity changes. Since signer recreation produces a new object, `connected` does get a new reference on every signer recreation. Consumers that need to avoid re-renders on signer recreation should use the snapshot (`hasSigner`) rather than the getter. + +Individual accessors (`client.wallet.wallets`, `client.wallet.connected`, `client.wallet.status`) are getters that read the current state from the store. They are provided for non-React consumers or cases where you only need one piece of state. `client.wallet.connected` includes the signer for use in instruction building and manual signing. + +### Framework adapter examples + +**React:** +```tsx +function useWalletState(client) { + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); +} +``` + +**Vue:** +```typescript +function useWalletState(client) { + const state = shallowRef(client.wallet.getSnapshot()); + onMounted(() => { + const unsub = client.wallet.subscribe(() => { state.value = client.wallet.getSnapshot(); }); + onUnmounted(unsub); + }); + return state; +} +``` + +**Svelte:** +```typescript +const walletState = readable(client.wallet.getSnapshot(), (set) => { + return client.wallet.subscribe(() => set(client.wallet.getSnapshot())); +}); +``` + +**Solid:** +```typescript +const [walletState, setWalletState] = createSignal(client.wallet.getSnapshot()); +onMount(() => { + onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getSnapshot()))); +}); +``` + +## Storage + +### Storage adapter + +Persistence is handled via a pluggable storage adapter following the Web Storage API shape. `localStorage` and `sessionStorage` can be passed directly with zero wrapping. + +```typescript +type WalletStorage = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; +``` + +The type is duck-typed rather than extending the DOM `Storage` interface, so it works in any environment without requiring DOM lib types. The async-compatible signatures match wagmi's storage interface. `localStorage` and `sessionStorage` satisfy this directly since sync return values are valid where `T | Promise` is expected. Async backends like IndexedDB or encrypted storage can return Promises. + +### What is persisted + +The plugin persists a `walletName:accountAddress` string (e.g. `"Phantom:ABC123..."`). This identifies both the wallet and the specific account the user selected. On reconnect, the plugin attempts to restore the exact account; if that account is no longer available but the wallet is, it falls back to the first available account. + +This matches the persistence format used by Kit's existing React hooks. + +### Default storage + +When no `storage` option is provided, the plugin defaults to `localStorage` in the browser. On the server, storage is always skipped regardless of the option. + +### Storage examples + +```typescript +// Default — uses localStorage in browser, skipped on server +wallet({ chain: 'solana:mainnet' }) + +// Use sessionStorage +wallet({ chain: 'solana:mainnet', storage: sessionStorage }) + +// Use a reactive store +wallet({ + chain: 'solana:mainnet', + storage: { + getItem: (key) => myStore.getState().walletKey, + setItem: (key, value) => myStore.setState({ walletKey: value }), + removeItem: (key) => myStore.setState({ walletKey: null }), + }, +}) + +// Disable persistence explicitly +wallet({ chain: 'solana:mainnet', storage: null }) +``` + +## Configuration + +```typescript +type WalletPluginConfig = { + /** + * The Solana chain this client targets. + * One client = one chain. To switch networks, + * create a separate client with a different chain and RPC endpoint. + */ + chain: SolanaChain; + + /** + * Optional filter function for wallet discovery. + * Called for each wallet that supports the configured chain and + * standard:connect. Return true to include the wallet, false to exclude. + * Useful for requiring specific features, whitelisting wallets, + * or any other application-specific filtering. + * + * @example + * // Require signAndSendTransaction + * filter: (w) => w.features.includes('solana:signAndSendTransaction') + * + * @example + * // Whitelist specific wallets + * filter: (w) => ['Phantom', 'Solflare'].includes(w.name) + */ + filter?: (wallet: UiWallet) => boolean; + + /** + * Whether to sync the connected wallet's signer to client.payer. + * @default true + */ + usePayer?: boolean; + + /** + * Whether to attempt silent reconnection on startup using + * the persisted wallet account from storage. + * @default true + */ + autoConnect?: boolean; + + /** + * Storage adapter for persisting the selected wallet account. + * Follows the Web Storage API shape (getItem/setItem/removeItem). + * Supports both sync and async backends. + * localStorage and sessionStorage satisfy this interface directly. + * Pass null to disable persistence entirely. + * Ignored on the server (storage is always skipped in SSR). + * @default localStorage + */ + storage?: WalletStorage | null; + + /** + * Storage key used for persistence. + * @default 'kit-wallet' + */ + storageKey?: string; +}; +``` + +## Error Handling + +### Error codes + +Two new error codes added to `@solana/errors`: + +```typescript +SOLANA_ERROR__WALLET__NOT_CONNECTED +// context: { operation: string } +// message: "Cannot $operation: no wallet connected" + +SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED +// context: { walletName: string, featureName: string } +// message: "Wallet \"$walletName\" does not support $featureName" +``` + +Wallet-originated errors (e.g. user rejecting a connection prompt) are propagated unchanged. + +### Error behavior + +| Scenario | Behavior | +|----------|----------| +| SSR (server environment) | Status stays `'pending'`, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED` | +| User rejects connection prompt | `connect()` propagates wallet error, status returns to `disconnected` | +| Wallet does not support signing | Connection succeeds, `hasSigner` is `false`, payer falls back, sign methods throw | +| Wallet does not pass filter | Filtered out at discovery time; disconnected if filter fails after feature change | +| Wallet unregisters while connected | Automatic disconnection, subscribers notified | +| Silent reconnect fails | Status -> `disconnected`, persisted account cleared | +| No wallets discovered | `state.wallets` is empty, UI can prompt user to install a wallet | +| `standard:disconnect` not supported | Disconnect proceeds -- clear local state regardless | +| `selectAccount` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'selectAccount' }` | +| `signMessage` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signMessage' }` | +| `signMessage` on wallet without feature | Throws `SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED` with `{ featureName: 'solana:signMessage' }` | +| `signIn` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signIn' }` | +| `signIn` on wallet without feature | Throws `SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED` with `{ featureName: 'solana:signIn' }` | + +`connect()` and `disconnect()` propagate wallet errors to the caller unchanged. Internal errors (reconnect failures, storage errors) are logged via `console.warn` but do not throw. + +## Design Decisions + +**Descriptor-preserving composition.** The plugin uses `extendClient` from plugin-core to build the client, preserving getters and symbol-keyed properties from previous plugins. The updated `addUse` also preserves descriptors through `.use()` calls. Downstream plugins should use `extendClient` rather than spread to avoid flattening the dynamic payer getter. + +**Single chain per client.** Signers are bound to a specific chain at creation time. Switching chains requires a different RPC endpoint too. One client = one network. + +**Single wallet connection.** One active wallet at a time. dApps needing multiple can access `client.wallet.wallets` and manage additional connections via wallet-standard APIs. + +**SSR-safe.** The plugin gracefully degrades on the server — status stays `'pending'`, wallet list is empty, payer falls back, storage is skipped, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED`. The same client chain works on both server and browser without conditional `.use()` calls. + +**`pending` status.** Initial status is `'pending'`, not `'disconnected'`. This lets UI distinguish "we haven't checked yet" (render nothing / skeleton) from "we checked and there's no wallet" (render connect button). On the server, status stays `'pending'` permanently. In the browser, it transitions to `'disconnected'` or `'reconnecting'` once the storage read completes. + +**`localStorage` as default storage.** The plugin defaults to `localStorage` in the browser and skips storage on the server. Consumers don't need to reference `localStorage` directly (which would throw a `ReferenceError` on the server), and the common case requires no configuration. + +**Single subscribe listener.** Fires on any state change. Frameworks needing field-level selectivity use their own selector patterns (e.g. `useSyncExternalStoreWithSelector`). + +**Plugin ordering with `payer`.** `payer()` first, then `wallet()`. The wallet plugin captures the existing payer as its fallback. + +**Web Storage API for persistence.** Duck-typed to match the `getItem`/`setItem`/`removeItem` shape used by wagmi and Zustand. Supports both sync and async backends — `localStorage` can be passed directly, and async backends (IndexedDB, encrypted storage) return Promises. + +**Account-level persistence.** Persists `walletName:accountAddress` (parsed with `lastIndexOf(':')` since base58 addresses never contain colons). Preserves the user's account selection across sessions. + +**Sync-only cleanup.** All cleanup operations (unsubscribing from events, clearing listeners) are synchronous. `Symbol.asyncDispose` support may be added to plugin-core later as a separate utility. + +**SIWS-as-connect.** `signIn` supports two overloads — sign in with the connected wallet, or sign in with a specific wallet to implicitly connect. The wallet form sets up full connection state using the account returned in the sign-in response. + +**Signer recreation on wallet events.** When the wallet emits feature or chain changes, the signer is recreated to reflect new capabilities (e.g. `solana:signMessage` added) or drop removed ones. `createSignerFromWalletAccount` is cheap (no network calls), so this is practical on every event. + +**Local disconnect for wallet-initiated events.** `disconnectLocally()` clears local state synchronously without calling `standard:disconnect` on the wallet. Used when the wallet itself initiated the change (accounts removed, chain/feature changes). Avoids the async gap and redundant round-trip of calling back to a wallet that already knows it disconnected. + +**Status timeout on reconnect.** When waiting for a previously connected wallet to register, status reverts from `reconnecting` to `disconnected` after 3 seconds. The registry listener stays alive — if the wallet appears later, it silently reconnects. This prevents a perpetual spinner for uninstalled wallets while still supporting slow-loading extensions. + +**`userHasSelected` flag.** Tracks whether the user has made an explicit choice (via `connect`, `selectAccount`, or `signIn` with a wallet). When true, the auto-restore flow will not override the user's selection, matching the `wasSetterInvokedRef` pattern from `@solana/react`. + +**Read-only wallet support.** `tryCreateSigner` wraps `createSignerFromWalletAccount` in a try/catch. If the wallet doesn't support any signing features (e.g. a watch-only wallet), connection still succeeds — the account is set, events fire, persistence works. `hasSigner` in the snapshot lets UI distinguish connected-with-signer from connected-without-signer. The payer getter falls back when `signer` is `null`. + +--- + +## Implementation notes (post-review) + +The following deviations and fixes were identified during spec review and should be applied during implementation rather than requiring a spec revision. + +**`withCleanup` not yet released.** `withCleanup` has landed in `@solana/plugin-core` but is not yet in a released build of `@solana/kit`. Replace `...withCleanup(client, () => store.destroy())` with a direct property: +```typescript +[Symbol.dispose]: () => store.destroy(), +``` +This won't chain with other dispose plugins (LIFO ordering won't apply) but is sufficient until `withCleanup` ships. + +**Missing dependency: `@wallet-standard/features`.** `WalletPluginConfig.features` uses the `IdentifierArray` type from `@wallet-standard/features`. Add it to `dependencies`: +```json +"@wallet-standard/features": "^1.x" +``` + +**`extendClient` source.** The Prerequisites section mentions `@solana/plugin-core`, but the correct import in practice is `from '@solana/kit'` (which re-exports it). `withCleanup` is not imported at all (see above). + +**Unhandled rejection in auto-connect IIFE.** The fire-and-forget `(async () => { ... })()` has no top-level error handler. If `storage.getItem()` rejects, it produces an unhandled promise rejection. Add a `.catch()` that resets status to `'disconnected'` when storage fails before `userHasSelected` is set. + +**`autoConnect` JSDoc.** The `autoConnect` config option has no effect when `storage` is not provided (the block is gated on `config.autoConnect !== false && storage`). Add a note to the JSDoc: *"Has no effect if `storage` is not provided."* + +**`signIn` overload discriminant (low risk).** The `'features' in walletOrInput` check works for now but would misfire if `SolanaSignInInput` ever gains a `features` field. A more defensive check would combine multiple `UiWallet`-exclusive fields (e.g. `'accounts' in walletOrInput && 'chains' in walletOrInput`). From ca6a002f7b828e418cc0f6419c8b31d56a1312a1 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 2 Apr 2026 13:47:42 +0000 Subject: [PATCH 2/3] Update wallet plugin scaffold - All state is now exclusively stored on the snapshot, including the signer - Fallback payer is no longer used - Split into 2 plugins, `wallet` which does not touch payer, and `walletAsPayer` which replaces `client.payer` with the selected wallet --- packages/kit-plugin-wallet/src/index.ts | 267 ++-- wallet-plugin-spec.md | 1693 +++++++++++------------ 2 files changed, 973 insertions(+), 987 deletions(-) diff --git a/packages/kit-plugin-wallet/src/index.ts b/packages/kit-plugin-wallet/src/index.ts index 0f82886..6837f95 100644 --- a/packages/kit-plugin-wallet/src/index.ts +++ b/packages/kit-plugin-wallet/src/index.ts @@ -1,8 +1,16 @@ -import { ClientWithPayer, extendClient, MessageSigner, SignatureBytes, TransactionSigner } from '@solana/kit'; +import { extendClient, MessageSigner, SignatureBytes, TransactionSigner } from '@solana/kit'; import type { SolanaChain } from '@solana/wallet-standard-chains'; import type { SolanaSignInInput, SolanaSignInOutput } from '@solana/wallet-standard-features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +/** + * The signer type for a connected wallet account. + * + * Always satisfies `TransactionSigner`. Additionally implements `MessageSigner` + * when the wallet supports `solana:signMessage`. + */ +export type WalletSigner = TransactionSigner | (MessageSigner & TransactionSigner); + // -- Public types ----------------------------------------------------------- /** @@ -19,38 +27,13 @@ import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; */ export type WalletStatus = 'connected' | 'connecting' | 'disconnected' | 'disconnecting' | 'pending' | 'reconnecting'; -/** - * The active wallet connection — the wallet, the selected account, and the - * account's signer (or `null` for read-only / watch-only wallets that do not - * support any signing feature). - * - * Available as `client.wallet.connected` when a wallet is connected. - * - * @see {@link WalletNamespace.connected} - */ -export type WalletConnection = { - /** The currently selected account within the connected wallet. */ - readonly account: UiWalletAccount; - /** - * The signer for the active account, or `null` for read-only wallets. - * - * Satisfies `TransactionSigner` when non-null. May additionally implement - * `MessageSigner` if the wallet supports `solana:signMessage`. - */ - readonly signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; - /** The connected wallet. */ - readonly wallet: UiWallet; -}; - /** * A snapshot of the wallet plugin state at a point in time. * - * Referentially stable when unchanged — suitable for use with - * `useSyncExternalStore` and similar framework primitives. - * - * The `connected` field uses `hasSigner` rather than the signer object itself - * to avoid unnecessary re-renders when the signer reference changes. The - * actual signer is accessible via {@link WalletConnection.signer}. + * Returned by {@link WalletNamespace.getSnapshot}. The same object reference + * is returned on successive calls as long as nothing has changed — a new + * object is only created when a field actually changes. This ensures + * `useSyncExternalStore` only triggers re-renders on meaningful state changes. * * @see {@link WalletNamespace.getSnapshot} */ @@ -58,12 +41,13 @@ export type WalletStateSnapshot = { /** * The active connection, or `null` when disconnected. * - * `hasSigner` is `false` for read-only / watch-only wallets. + * `signer` is `null` for read-only / watch-only wallets that do not + * support any signing feature. */ readonly connected: { readonly account: UiWalletAccount; - /** Whether the connected account has a signer. */ - readonly hasSigner: boolean; + /** The signer for the active account, or `null` for read-only wallets. */ + readonly signer: WalletSigner | null; readonly wallet: UiWallet; } | null; /** The current connection status. */ @@ -102,7 +86,7 @@ export type WalletStorage = { }; /** - * Configuration for the {@link wallet} plugin. + * Configuration for the {@link wallet} and {@link walletAsPayer} plugins. */ export type WalletPluginConfig = { /** @@ -157,31 +141,20 @@ export type WalletPluginConfig = { * @default 'kit-wallet' */ storageKey?: string; - - /** - * Whether to sync the connected wallet's signer to `client.payer`. - * - * When `true` (default), a dynamic `payer` getter is defined on the client. - * When no wallet is connected the getter returns whatever `client.payer` was - * before the wallet plugin was installed (the fallback payer), or `undefined` - * if no prior payer was configured. - * - * @default true - */ - usePayer?: boolean; }; /** * The `wallet` namespace exposed on the client as `client.wallet`. * - * Contains all wallet state, actions, and framework integration helpers. - * Framework adapters (React, Vue, Svelte, etc.) should bind to - * `subscribe` and `getSnapshot` rather than individual getters. + * All wallet state is accessed via {@link getSnapshot}. Use {@link subscribe} + * to be notified of changes and integrate with framework primitives such as + * React's `useSyncExternalStore`. * - * @see {@link WalletApi} + * @see {@link ClientWithWallet} */ export type WalletNamespace = { // -- Actions -- + /** * Connect to a wallet. Calls `standard:connect`, then selects the first * newly authorized account (or the first account if reconnecting). Creates @@ -192,20 +165,14 @@ export type WalletNamespace = { */ connect: (wallet: UiWallet) => Promise; - /** - * The active connection — wallet, account, and signer — or `null` when - * disconnected. For rendering, prefer reading from {@link getSnapshot} - * to avoid tearing. - */ - readonly connected: WalletConnection | null; - /** Disconnect the active wallet. Calls `standard:disconnect` if supported. */ disconnect: () => Promise; + // -- State -- /** - * Get a referentially stable snapshot of the full wallet state. - * The same object reference is returned on subsequent calls as long as - * nothing has changed. + * Get a referentially stable snapshot of the full wallet state. A new + * object is only created when a field actually changes, so React's + * `useSyncExternalStore` skips re-renders when nothing meaningful changed. * * @see {@link WalletStateSnapshot} */ @@ -253,11 +220,6 @@ export type WalletNamespace = { */ signMessage: (message: Uint8Array) => Promise; - /** Current connection status. */ - readonly status: WalletStatus; - - // -- Framework integration -- - /** * Subscribe to any wallet state change. Compatible with React's * `useSyncExternalStore` and similar framework primitives. @@ -266,23 +228,18 @@ export type WalletNamespace = { * * @example * ```ts - * // React * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); * ``` */ subscribe: (listener: () => void) => () => void; - - // -- State (getters) -- - /** All discovered wallets matching the configured chain and filter. */ - readonly wallets: readonly UiWallet[]; }; /** * Properties added to the client by the {@link wallet} plugin. * * All wallet state and actions are namespaced under `client.wallet`. - * `client.payer` remains at the top level and dynamically resolves to the - * connected wallet's signer (with fallback to any previously configured payer). + * `client.payer` is not affected — use the {@link walletAsPayer} plugin to + * set the payer dynamically from the connected wallet. * * @see {@link wallet} * @see {@link WalletNamespace} @@ -293,6 +250,22 @@ export type ClientWithWallet = { readonly wallet: WalletNamespace; }; +/** + * Properties added to the client by the {@link walletAsPayer} plugin. + * + * Extends {@link ClientWithWallet} with a dynamic `payer` getter. When a + * signing-capable wallet is connected, `client.payer` returns the wallet + * signer. When disconnected or when the wallet is read-only, `client.payer` + * is `undefined`. + * + * @see {@link walletAsPayer} + * @see {@link ClientWithWallet} + */ +export type ClientWithWalletAsPayer = ClientWithWallet & { + /** The connected wallet signer, or `undefined` when disconnected / read-only. */ + readonly payer: TransactionSigner | undefined; +}; + // -- Error ------------------------------------------------------------------ /** @@ -327,7 +300,7 @@ export class WalletNotConnectedError extends Error { type WalletStoreState = { account: UiWalletAccount | null; connectedWallet: UiWallet | null; - signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + signer: WalletSigner | null; status: WalletStatus; wallets: readonly UiWallet[]; }; @@ -336,7 +309,6 @@ type WalletStore = { connect: (wallet: UiWallet) => Promise; [Symbol.dispose]: () => void; disconnect: () => Promise; - getConnected: () => WalletConnection | null; getSnapshot: () => WalletStateSnapshot; getState: () => WalletStoreState; selectAccount: (account: UiWalletAccount) => void; @@ -356,34 +328,42 @@ function createWalletStore(_config: WalletPluginConfig): WalletStore { // -- Plugin ----------------------------------------------------------------- -type WalletPluginReturn = ClientWithWallet & - Disposable & - Omit & - Partial; +function buildWalletNamespace(store: WalletStore): WalletNamespace { + return { + connect: (w: UiWallet) => store.connect(w), + disconnect: () => store.disconnect(), + getSnapshot: () => store.getSnapshot(), + selectAccount: (a: UiWalletAccount) => store.selectAccount(a), + signIn: store.signIn, + signMessage: (msg: Uint8Array) => store.signMessage(msg), + subscribe: (l: () => void) => store.subscribe(l), + }; +} /** * A framework-agnostic Kit plugin that manages wallet discovery, connection * lifecycle, and signer creation using wallet-standard. * - * When connected, the plugin syncs the wallet signer to `client.payer` via a - * dynamic getter, falling back to any previously configured payer when - * disconnected. All wallet state and actions are namespaced under - * `client.wallet`. The plugin exposes subscribable state for framework adapters - * (React, Vue, Svelte, Solid, etc.) to consume. + * Adds the `wallet` namespace to the client without touching `client.payer`. + * Use this alongside the `payer()` plugin for backend signers, or when the + * wallet's signer is used explicitly in instructions rather than as the + * default payer. To set `client.payer` dynamically from the connected wallet, + * use {@link walletAsPayer} instead. * - * **SSR-safe.** The plugin can be included in a shared client chain that runs - * on both server and browser. On the server, status stays `'pending'`, actions - * throw {@link WalletNotConnectedError}, and no registry listeners or storage - * reads are made. The same client chain works everywhere: + * **SSR-safe.** Can be included in a shared client chain that runs on both + * server and browser. On the server, status stays `'pending'`, actions throw + * {@link WalletNotConnectedError}, and no registry listeners or storage reads + * are made. * * ```ts * const client = createEmptyClient() * .use(rpc('https://api.mainnet-beta.solana.com')) * .use(payer(backendKeypair)) - * .use(wallet({ chain: 'solana:mainnet' })); + * .use(wallet({ chain: 'solana:mainnet' })) + * .use(planAndSendTransactions()); * - * // Server: client.wallet.status === 'pending', client.payer === backendKeypair - * // Browser: auto-connect fires, client.payer becomes the wallet signer + * // client.payer is always backendKeypair (wallet plugin does not touch it) + * // client.wallet.getSnapshot().connected?.signer for manual use * ``` * * @param config - Plugin configuration. @@ -399,57 +379,92 @@ type WalletPluginReturn = ClientWithWallet & * .use(wallet({ chain: 'solana:mainnet' })); * * // Connect a wallet - * const [firstAccount] = await client.wallet.connect(uiWallet); + * await client.wallet.connect(uiWallet); * * // Subscribe to state changes (React) * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); * ``` * + * @see {@link walletAsPayer} * @see {@link WalletPluginConfig} - * @see {@link WalletApi} + * @see {@link ClientWithWallet} */ export function wallet(config: WalletPluginConfig) { - return (client: T): WalletPluginReturn => { + return (client: T): ClientWithWallet & Disposable & Omit => { const store = createWalletStore(config); - const fallbackClient = 'payer' in client ? (client as T & { payer: TransactionSigner }) : null; - - const walletObj: Omit = { - connect: (w: UiWallet) => store.connect(w), - disconnect: () => store.disconnect(), - getSnapshot: () => store.getSnapshot(), - selectAccount: (a: UiWalletAccount) => store.selectAccount(a), - signIn: store.signIn, - signMessage: (msg: Uint8Array) => store.signMessage(msg), - subscribe: (l: () => void) => store.subscribe(l), - }; - - // Define getters for the rest of the state properties - for (const [key, fn] of Object.entries({ - connected: () => store.getConnected(), - status: () => store.getState().status, - wallets: () => store.getState().wallets, - } as Record unknown>)) { - Object.defineProperty(walletObj, key, { configurable: true, enumerable: true, get: fn }); - } + return extendClient(client, { + wallet: buildWalletNamespace(store), + // TODO: This will use withCleanup after the next Kit release + [Symbol.dispose]: () => store[Symbol.dispose](), + }) as ClientWithWallet & Disposable & Omit; + }; +} + +/** + * A framework-agnostic Kit plugin that manages wallet discovery, connection + * lifecycle, and signer creation using wallet-standard — and syncs the + * connected wallet's signer to `client.payer` via a dynamic getter. + * + * When a signing-capable wallet is connected, `client.payer` returns the + * wallet signer. When disconnected or when the wallet is read-only, + * `client.payer` is `undefined`. Use the base {@link wallet} plugin instead + * if you need `client.payer` to be controlled by a separate `payer()` plugin. + * + * **SSR-safe.** Can be included in a shared client chain that runs on both + * server and browser. On the server, status stays `'pending'`, `client.payer` + * is `undefined`, and no registry listeners or storage reads are made. + * + * ```ts + * const client = createEmptyClient() + * .use(rpc('https://api.mainnet-beta.solana.com')) + * .use(walletAsPayer({ chain: 'solana:mainnet' })) + * .use(planAndSendTransactions()); + * + * // Server: status === 'pending', client.payer === undefined + * // Browser: auto-connect fires, client.payer becomes the wallet signer + * ``` + * + * @param config - Plugin configuration. + * + * @example + * ```ts + * import { createEmptyClient } from '@solana/kit'; + * import { rpc } from '@solana/kit-plugin-rpc'; + * import { walletAsPayer } from '@solana/kit-plugin-wallet'; + * + * const client = createEmptyClient() + * .use(rpc('https://api.mainnet-beta.solana.com')) + * .use(walletAsPayer({ chain: 'solana:mainnet' })); + * + * // Connect a wallet + * await client.wallet.connect(uiWallet); + * // client.payer now returns the wallet signer + * ``` + * + * @see {@link wallet} + * @see {@link WalletPluginConfig} + * @see {@link ClientWithWalletAsPayer} + */ +export function walletAsPayer(config: WalletPluginConfig) { + return (client: T): ClientWithWalletAsPayer & Disposable & Omit => { + const store = createWalletStore(config); const obj = extendClient(client, { - wallet: walletObj as WalletNamespace, + wallet: buildWalletNamespace(store), // TODO: This will use withCleanup after the next Kit release [Symbol.dispose]: () => store[Symbol.dispose](), }); - if (config.usePayer !== false) { - Object.defineProperty(obj, 'payer', { - configurable: true, - enumerable: true, - get() { - // Note that we only read `client.payer` here, to allow the fallback to be defined with a get function - return store.getState().signer ?? fallbackClient?.payer; - }, - }); - } - - return obj as WalletPluginReturn; + Object.defineProperty(obj, 'payer', { + configurable: true, + enumerable: true, + get() { + const { signer } = store.getState(); + return signer !== null ? signer : undefined; + }, + }); + + return obj as ClientWithWalletAsPayer & Disposable & Omit; }; } diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index 368d999..0e6ac0d 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -14,16 +14,16 @@ This spec builds on two changes that must land first: ```json { - "peerDependencies": { - "@solana/kit": "^6.x" - }, - "dependencies": { - "@solana/wallet-account-signer": "^1.x", - "@wallet-standard/app": "^1.x", - "@wallet-standard/ui": "^1.x", - "@wallet-standard/ui-features": "^1.x", - "@wallet-standard/ui-registry": "^1.x" - } + "peerDependencies": { + "@solana/kit": "^6.x" + }, + "dependencies": { + "@solana/wallet-account-signer": "^1.x", + "@wallet-standard/app": "^1.x", + "@wallet-standard/ui": "^1.x", + "@wallet-standard/ui-features": "^1.x", + "@wallet-standard/ui-registry": "^1.x" + } } ``` @@ -33,17 +33,18 @@ The bridge function (`createSignerFromWalletAccount`) is consumed via `@solana/w A framework-agnostic Kit plugin that manages wallet discovery, connection lifecycle, and signer creation using wallet-standard. When a wallet is connected, the plugin optionally syncs its signer to the client's `payer` slot via a dynamic getter, falling back to any previously configured payer when no wallet is connected. The plugin exposes subscribable wallet state for framework adapters (React, Vue, Svelte, etc.) to consume without coupling to any specific UI framework. -**SSR-safe.** The plugin can be included in the same client chain on both server and browser. On the server (`typeof window === 'undefined'`), it gracefully degrades — status stays `'pending'`, wallet list is empty, payer falls back, storage is skipped, no registry listeners are created. On the browser it initializes fully. This means a single client chain works everywhere: +**SSR-safe.** Both `wallet` and `walletAsPayer` can be included in the same client chain on both server and browser. On the server (`typeof window === 'undefined'`), the plugin gracefully degrades — status stays `'pending'`, wallet list is empty, payer is `undefined` (when using `walletAsPayer`), storage is skipped, no registry listeners are created. On the browser it initializes fully. This means a single client chain works everywhere: ```typescript +import { walletAsPayer } from '@solana/kit-plugin-wallet'; + const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(payer(backendKeypair)) - .use(wallet({ chain: 'solana:mainnet' })) - .use(systemProgram()) - .use(sendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(systemProgram()) + .use(planAndSendTransactions()); -// Server: client.wallet.status === 'pending', client.payer === backendKeypair +// Server: status === 'pending', client.payer === undefined // Browser: auto-connect fires, client.payer becomes wallet signer ``` @@ -58,7 +59,7 @@ This plugin fills that gap. It sits between wallet-standard's raw discovery API - Automatic wallet discovery via `getWallets()` - Connection lifecycle management (connect, disconnect, silent reconnect) - Signer creation and caching via the bridge function -- Dynamic payer integration with fallback +- Dynamic payer integration (via `walletAsPayer`) - A subscribable store that any framework can bind to - Cleanup via `withCleanup` and `Symbol.dispose` @@ -75,7 +76,7 @@ This plugin fills that gap. It sits between wallet-standard's raw discovery API | Kit plugin client | | .use(rpc(...)) | | .use(wallet({ ... })) <- or .use(payer(...)) | -| .use(sendTransactions()) | +| .use(planAndSendTransactions()) | +---------------------------------------------------+ | createSignerFromWalletAccount() | <- @solana/wallet-account-signer +---------------------------------------------------+ @@ -91,169 +92,175 @@ This plugin extracts the wallet management logic currently embedded in `@solana/ ### Relationship to `payer` -The `wallet` plugin and `payer` plugin both set `client.payer`. Use `payer()` for static signers (backend keypairs, scripts) and `wallet()` for dApps with user-facing wallet connections. +The package exports two plugin functions that differ in how they interact with `client.payer`: -When both are needed (e.g. a fallback backend signer with wallet override), `payer` should come first in the plugin chain. The wallet plugin captures whatever is in `client.payer` at installation time as its fallback: +**`wallet()`** — adds the `wallet` namespace but does not touch `client.payer`. Use alongside the `payer()` plugin for backend signers, or when the wallet's signer is used explicitly in instructions rather than as the default payer. + +**`walletAsPayer()`** — adds the `wallet` namespace and overrides `client.payer` with a dynamic getter. When connected with a signing-capable account, `client.payer` returns the wallet signer. When disconnected or read-only, `client.payer` is `undefined`. ```typescript +import { walletAsPayer } from '@solana/kit-plugin-wallet'; +import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan'; + const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(payer(backendKeypair)) // static payer set first - .use(wallet({ chain: 'solana:mainnet' })) // captures keypair as fallback - .use(sendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); -// No wallet connected -> client.payer returns backendKeypair +// No wallet connected -> client.payer is undefined // Wallet connected -> client.payer returns wallet signer -// Wallet disconnects -> client.payer returns backendKeypair again +// Wallet disconnects -> client.payer is undefined ``` -The wallet plugin uses `Object.defineProperty` for a dynamic `payer` getter. `addUse` preserves this getter descriptor through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve it. The `sendTransactions` plugin's closure reads `client.payer` at transaction time, which resolves via the getter to whichever signer is current. +`walletAsPayer` uses `Object.defineProperty` for the dynamic `payer` getter. `addUse` preserves this getter descriptor through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve it. -Downstream plugins (e.g. `sendTransactions()`) depend only on `client.payer` being a `TransactionSigner`. They do not know or care whether it was set by `payer()` or `wallet()`. +Downstream plugins (e.g. `planAndSendTransactions()`) depend only on `client.payer` being a `TransactionSigner`. They do not know or care whether it was set by `payer()` or `walletAsPayer()`. ## API Surface ### Plugin creation ```typescript -import { wallet } from '@solana/kit-plugin-wallet'; +import { wallet, walletAsPayer } from '@solana/kit-plugin-wallet'; +// Wallet as payer — most dApps const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(wallet({ - chain: 'solana:mainnet', - usePayer: true, - autoConnect: true, - })) - .use(sendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); +// client.payer is TransactionSigner | undefined + +// Wallet alongside a static payer +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(backendKeypair)) + .use(wallet({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); +// client.payer is TransactionSigner (from payer plugin, untouched) +// client.wallet.getSnapshot().connected?.signer for manual use ``` ### Client type extension -The `wallet` plugin adds the following to the client: +Both `wallet` and `walletAsPayer` add the `wallet` namespace to the client. `walletAsPayer` additionally overrides `client.payer`: ```typescript -type WalletApi = { - wallet: { - // -- State (getters) -- - - /** All discovered wallet-standard wallets, filtered by chain, standard:connect, and optional filter function. */ - readonly wallets: readonly UiWallet[]; - - /** - * The currently connected wallet, account, and signer — or null when - * disconnected. Signer is null for read-only wallets. Wallet and account - * are always both present when connected. - */ - readonly connected: WalletConnection | null; - - /** Current connection status. */ - readonly status: WalletStatus; - - // -- Actions (methods) -- - - /** - * Connect to a wallet. Calls standard:connect on the wallet, then - * selects the first newly authorized account (or the first account - * if reconnecting). Creates and caches a signer for the active account. - * Returns all accounts from the wallet after connection. - */ - connect: (wallet: UiWallet) => Promise; - - /** Disconnect the active wallet. Calls standard:disconnect if supported. */ - disconnect: () => Promise; - - /** - * Switch to a different account within the connected wallet. - * Creates and caches a new signer for the selected account. - */ - selectAccount: (account: UiWalletAccount) => void; - - /** - * Sign an arbitrary message with the connected account. - * Throws if no account is connected or if the wallet does not - * support the solana:signMessage feature. - * Delegates to the MessageSigner returned by the bridge function. - */ - signMessage: (message: Uint8Array) => Promise; - - /** - * Sign In With Solana. - * - * Overload 1: sign in with the already-connected wallet. - * Throws if no wallet is connected or if the wallet does not - * support the solana:signIn feature. - * - * Overload 2: sign in with a specific wallet (SIWS-as-connect). - * Implicitly connects the wallet, sets the returned account as - * active, creates and caches a signer. After completion, the - * client is in the same state as if connect() had been called. - */ - signIn(input?: SolanaSignInInput): Promise; - signIn(wallet: UiWallet, input?: SolanaSignInInput): Promise; - - // -- Framework integration (methods) -- - - /** - * Subscribe to any wallet state change. Compatible with React's - * useSyncExternalStore and similar framework primitives. - * Returns an unsubscribe function. - */ - subscribe: (listener: () => void) => () => void; - - /** - * Get a snapshot of the full wallet state. Referentially stable - * when unchanged. Useful for framework adapters. - */ - getSnapshot: () => WalletStateSnapshot; - }; +type ClientWithWallet = { + wallet: { + // -- State -- + + /** + * Subscribe to any wallet state change. Compatible with React's + * useSyncExternalStore and similar framework primitives. + * Returns an unsubscribe function. + */ + subscribe: (listener: () => void) => () => void; + + /** + * Get a snapshot of the full wallet state. Referentially stable + * when unchanged — a new object is only created when a + * snapshot-relevant field actually changes. + */ + getSnapshot: () => WalletStateSnapshot; + + // -- Actions -- + + /** + * Connect to a wallet. Calls standard:connect on the wallet, then + * selects the first newly authorized account (or the first account + * if reconnecting). Creates and caches a signer for the active account. + * Returns all accounts from the wallet after connection. + */ + connect: (wallet: UiWallet) => Promise; + + /** Disconnect the active wallet. Calls standard:disconnect if supported. */ + disconnect: () => Promise; + + /** + * Switch to a different account within the connected wallet. + * Creates and caches a new signer for the selected account. + */ + selectAccount: (account: UiWalletAccount) => void; + + /** + * Sign an arbitrary message with the connected account. + * Throws if no account is connected or if the wallet does not + * support the solana:signMessage feature. + * Delegates to the MessageSigner returned by the bridge function. + */ + signMessage: (message: Uint8Array) => Promise; + + /** + * Sign In With Solana. + * + * Overload 1: sign in with the already-connected wallet. + * Throws if no wallet is connected or if the wallet does not + * support the solana:signIn feature. + * + * Overload 2: sign in with a specific wallet (SIWS-as-connect). + * Implicitly connects the wallet, sets the returned account as + * active, creates and caches a signer. After completion, the + * client is in the same state as if connect() had been called. + */ + signIn(input?: SolanaSignInInput): Promise; + signIn(wallet: UiWallet, input?: SolanaSignInInput): Promise; + }; }; -type WalletConnection = { - wallet: UiWallet; - account: UiWalletAccount; - /** The signer for the active account, or null for read-only wallets. */ - signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; +/** + * Extends ClientWithWallet with a dynamic payer getter. + * client.payer returns the wallet signer when connected, + * or undefined when disconnected / read-only. + */ +type ClientWithWalletAsPayer = ClientWithWallet & { + readonly payer: TransactionSigner | undefined; }; +export function wallet(config: WalletPluginConfig): (client: T) => T & ClientWithWallet; + +export function walletAsPayer(config: WalletPluginConfig): (client: T) => T & ClientWithWalletAsPayer; + type WalletStatus = - | 'pending' // not yet initialized (SSR, or browser before first storage/registry check) - | 'disconnected' // initialized, no wallet connected - | 'connecting' // user-initiated connection in progress - | 'connected' // wallet connected, account + signer active - | 'disconnecting' // user-initiated disconnection in progress - | 'reconnecting'; // auto-connect in progress (restoring previous session) + | 'pending' // not yet initialized (SSR, or browser before first storage/registry check) + | 'disconnected' // initialized, no wallet connected + | 'connecting' // user-initiated connection in progress + | 'connected' // wallet connected, account + signer active + | 'disconnecting' // user-initiated disconnection in progress + | 'reconnecting'; // auto-connect in progress (restoring previous session) type WalletStateSnapshot = { - wallets: readonly UiWallet[]; - connected: { - wallet: UiWallet; - account: UiWalletAccount; - hasSigner: boolean; - } | null; - status: WalletStatus; + wallets: readonly UiWallet[]; + connected: { + wallet: UiWallet; + account: UiWalletAccount; + /** The signer for the active account, or null for read-only wallets. */ + signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + } | null; + status: WalletStatus; }; ``` -The snapshot includes `hasSigner` (a stable boolean) rather than the signer object itself — framework components should not render based on the signer (it would cause unnecessary re-renders on referential changes). `hasSigner` provides the UI branching signal. The actual signer is accessible via `client.wallet.connected.signer` for use in instruction building and manual signing. +All wallet state is accessed via `getSnapshot()`. The snapshot is a frozen object, memoized — a new reference is only created when a field actually changes (checked via reference equality in `setState`). This ensures `useSyncExternalStore` only triggers re-renders when something meaningful changed. ```tsx -const { connected, status } = useSyncExternalStore( - client.wallet.subscribe, - client.wallet.getSnapshot, -); +const { connected, status, wallets } = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); if (status === 'pending') return null; -if (!connected) return ; -if (!connected.hasSigner) return ; -return ; +if (!connected) return ; +if (!connected.signer) return ; + +// Use signer in event handlers +const handleSend = () => { + buildTransaction({ authority: connected.signer, payer: client.payer }); +}; +return ; ``` -### Dynamic payer (when `usePayer: true`) +### Dynamic payer (via `walletAsPayer`) -When configured with `usePayer: true` (default), the plugin defines a `payer` getter via `Object.defineProperty`. The updated `addUse` preserves this getter descriptor through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve it. When any code accesses `client.payer`, the getter returns the current wallet signer or fallback. +`walletAsPayer` defines a `payer` getter via `Object.defineProperty`. The getter returns the current wallet signer, or `undefined` when disconnected or when the wallet is read-only. -When `usePayer: false`, the wallet plugin does not touch `client.payer`. +`wallet` does not touch `client.payer`. ### Cleanup @@ -265,19 +272,19 @@ client[Symbol.dispose](); // With using syntax (TypeScript 5.2+) { - using client = createEmptyClient() - .use(rpc('https://...')) - .use(wallet({ chain: 'solana:mainnet' })) - .use(sendTransactions()); + using client = createEmptyClient() + .use(rpc('https://...')) + .use(wallet({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); } // cleanup runs automatically // In React useEffect(() => { - const client = createEmptyClient() - .use(rpc('https://...')) - .use(wallet({ chain: 'solana:mainnet' })); - setClient(client); - return () => client[Symbol.dispose](); + const client = createEmptyClient() + .use(rpc('https://...')) + .use(wallet({ chain: 'solana:mainnet' })); + setClient(client); + return () => client[Symbol.dispose](); }, []); ``` @@ -291,70 +298,59 @@ Cleanup unsubscribes from wallet-standard registry events, any active wallet's ` import { extendClient, withCleanup } from '@solana/kit'; import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; import { - SolanaError, - SOLANA_ERROR__WALLET__NOT_CONNECTED, - SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, + SolanaError, + SOLANA_ERROR__WALLET__NOT_CONNECTED, + SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, } from '@solana/errors'; import { getWallets } from '@wallet-standard/app'; -import { - getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, -} from '@wallet-standard/ui-registry'; +import { getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry'; import { getWalletFeature } from '@wallet-standard/ui-features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; import type { TransactionSigner, MessageSigner, SolanaChain } from '@solana/kit'; export function wallet(config: WalletPluginConfig) { - return (client: T) => { - const store = createWalletStore(config); - - const fallbackPayer = 'payer' in client - ? (client as T & { payer: TransactionSigner }).payer - : undefined; - - // Build the wallet namespace object - const walletObj: Record = { - connect: (w: UiWallet) => store.connect(w), - disconnect: () => store.disconnect(), - selectAccount: (a: UiWalletAccount) => store.selectAccount(a), - signMessage: (msg: Uint8Array) => store.signMessage(msg), - signIn: (...args: [SolanaSignInInput?] | [UiWallet, SolanaSignInInput?]) => store.signIn(...args), - subscribe: (l: () => void) => store.subscribe(l), - getSnapshot: () => store.getSnapshot(), + return (client: T) => { + const store = createWalletStore(config); + + return extendClient(client, { + wallet: buildWalletNamespace(store), + ...withCleanup(client, () => store.destroy()), + }); }; +} - // State reads as getters on the wallet namespace - Object.defineProperty(walletObj, 'wallets', { - get: () => store.getState().wallets, - enumerable: true, - }); - Object.defineProperty(walletObj, 'connected', { - get: () => store.getConnected(), - enumerable: true, - }); - Object.defineProperty(walletObj, 'status', { - get: () => store.getState().status, - enumerable: true, - }); +export function walletAsPayer(config: WalletPluginConfig) { + return (client: T) => { + const store = createWalletStore(config); - const obj = extendClient(client, { - wallet: walletObj, - ...withCleanup(client, () => store.destroy()), - }); + const obj = extendClient(client, { + wallet: buildWalletNamespace(store), + ...withCleanup(client, () => store.destroy()), + }); - // payer stays top-level - if (config.usePayer !== false) { - Object.defineProperty(obj, 'payer', { - get() { - return store.getState().signer ?? fallbackPayer; - }, - enumerable: true, - configurable: true, - }); - } + Object.defineProperty(obj, 'payer', { + get() { + return store.getState().signer; + }, + enumerable: true, + configurable: true, + }); + + return obj; + }; +} - return obj; - }; +function buildWalletNamespace(store: WalletStore) { + return { + subscribe: (l: () => void) => store.subscribe(l), + getSnapshot: () => store.getSnapshot(), + connect: (w: UiWallet) => store.connect(w), + disconnect: () => store.disconnect(), + selectAccount: (a: UiWalletAccount) => store.selectAccount(a), + signMessage: (msg: Uint8Array) => store.signMessage(msg), + signIn: (...args: [SolanaSignInInput?] | [UiWallet, SolanaSignInInput?]) => store.signIn(...args), + }; } ``` @@ -366,16 +362,16 @@ The store is a plain object with state management -- no external dependencies. I ```typescript type WalletStoreState = { - wallets: readonly UiWallet[]; - connectedWallet: UiWallet | null; - account: UiWalletAccount | null; - /** - * Cached signer derived from the active account via - * createSignerFromWalletAccount(). May include MessageSigner - * if the wallet supports solana:signMessage. - */ - signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; - status: WalletStatus; + wallets: readonly UiWallet[]; + connectedWallet: UiWallet | null; + account: UiWalletAccount | null; + /** + * Cached signer derived from the active account via + * createSignerFromWalletAccount(). May include MessageSigner + * if the wallet supports solana:signMessage. + */ + signer: TransactionSigner | (MessageSigner & TransactionSigner) | null; + status: WalletStatus; }; ``` @@ -383,599 +379,575 @@ type WalletStoreState = { ```typescript function createWalletStore(config: WalletPluginConfig) { - const isBrowser = typeof window !== 'undefined'; - - let state: WalletStoreState = { - wallets: [], - connectedWallet: null, - account: null, - signer: null, - status: 'pending', - }; - - let snapshot: WalletStateSnapshot = deriveSnapshot(state); - const listeners = new Set<() => void>(); - let walletEventsCleanup: (() => void) | null = null; - let reconnectCleanup: (() => void) | null = null; - - // Tracks whether the user has made an explicit selection (connect or selectAccount). - // When true, auto-restore from storage will not override the user's choice. - let userHasSelected = false; - - // Resolve storage: skip on server, default to localStorage in browser. - const storage = !isBrowser - ? null - : config.storage === null - ? null - : config.storage ?? localStorage; - const storageKey = config.storageKey ?? 'kit-wallet'; - - // -- State management -- - - function setState(updates: Partial) { - const prev = state; - state = { ...state, ...updates }; - - // Only create new connected object if connection-relevant fields changed - if ( - state.connectedWallet !== prev.connectedWallet || - state.account !== prev.account || - state.signer !== prev.signer - ) { - connected = deriveConnected(state); + const isBrowser = typeof window !== 'undefined'; + + let state: WalletStoreState = { + wallets: [], + connectedWallet: null, + account: null, + signer: null, + status: 'pending', + }; + + let snapshot: WalletStateSnapshot = deriveSnapshot(state); + const listeners = new Set<() => void>(); + let walletEventsCleanup: (() => void) | null = null; + let reconnectCleanup: (() => void) | null = null; + + // Tracks whether the user has made an explicit selection (connect or selectAccount). + // When true, auto-restore from storage will not override the user's choice. + let userHasSelected = false; + + // Resolve storage: skip on server, default to localStorage in browser. + const storage = !isBrowser ? null : config.storage === null ? null : (config.storage ?? localStorage); + const storageKey = config.storageKey ?? 'kit-wallet'; + + // -- State management -- + + function setState(updates: Partial) { + const prev = state; + state = { ...state, ...updates }; + + // Only create a new snapshot if snapshot-relevant fields changed. + // This ensures referential stability for useSyncExternalStore — + // React's Object.is comparison sees the same reference and skips + // the re-render when nothing meaningful changed. + 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()); } - // Only create new snapshot if snapshot-relevant fields changed. - // This ensures referential stability for useSyncExternalStore — - // a signer recreation that doesn't change hasSigner won't cause - // React to re-render. - if ( - state.wallets !== prev.wallets || - state.connectedWallet !== prev.connectedWallet || - state.account !== prev.account || - state.status !== prev.status || - (state.signer !== null) !== (prev.signer !== null) - ) { - snapshot = deriveSnapshot(state); + function deriveSnapshot(s: WalletStoreState): WalletStateSnapshot { + return Object.freeze({ + wallets: s.wallets, + connected: + s.connectedWallet && s.account + ? Object.freeze({ + wallet: s.connectedWallet, + account: s.account, + signer: s.signer, + }) + : null, + status: s.status, + }); } - listeners.forEach((l) => l()); - } + // -- SSR: skip all browser-only initialization -- + + if (!isBrowser) { + return { + getState: () => state, + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + connect: () => { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); + }, + disconnect: () => Promise.resolve(), + selectAccount: () => { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); + }, + signMessage: () => { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); + }, + signIn: () => { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); + }, + destroy: () => {}, + }; + } - function deriveConnected(s: WalletStoreState): WalletConnection | null { - if (!s.connectedWallet || !s.account) return null; - return Object.freeze({ - wallet: s.connectedWallet, - account: s.account, - signer: s.signer, - }); - } - - function deriveSnapshot(s: WalletStoreState): WalletStateSnapshot { - return Object.freeze({ - wallets: s.wallets, - connected: s.connectedWallet && s.account - ? Object.freeze({ - wallet: s.connectedWallet, - account: s.account, - hasSigner: s.signer !== null, - }) - : null, - status: s.status, - }); - } + // -- Browser-only initialization below this point -- - let connected: WalletConnection | null = deriveConnected(state); + // -- Signer creation (resilient to read-only wallets) -- - // -- SSR: skip all browser-only initialization -- + function tryCreateSigner(account: UiWalletAccount): TransactionSigner | (MessageSigner & TransactionSigner) | null { + try { + return createSignerFromWalletAccount(account, config.chain); + } catch { + // Wallet doesn't support signing (e.g. read-only / watch wallet). + // Connection proceeds without a signer — account is still usable + // for discovery, display, and persistence. + return null; + } + } - if (!isBrowser) { - return { - getState: () => state, - getConnected: () => null, - getSnapshot: () => snapshot, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - connect: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); }, - disconnect: () => Promise.resolve(), - selectAccount: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); }, - signMessage: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }); }, - signIn: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); }, - destroy: () => {}, - }; - } - - // -- Browser-only initialization below this point -- - - // -- Signer creation (resilient to read-only wallets) -- - - function tryCreateSigner( - account: UiWalletAccount, - ): TransactionSigner | (MessageSigner & TransactionSigner) | null { - try { - return createSignerFromWalletAccount(account, config.chain); - } catch { - // Wallet doesn't support signing (e.g. read-only / watch wallet). - // Connection proceeds without a signer — account is still usable - // for discovery, display, and persistence. - return null; + // -- Wallet discovery -- + + const registry = getWallets(); + + function filterWallet(wallet: Wallet): boolean { + const uiWallet = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(wallet); + const supportsChain = uiWallet.chains.includes(config.chain); + const supportsConnect = uiWallet.features.includes('standard:connect'); + if (!supportsChain || !supportsConnect) return false; + // Apply custom filter if provided + return config.filter ? config.filter(uiWallet) : true; } - } - // -- Wallet discovery -- + function buildWalletList(): readonly UiWallet[] { + return Object.freeze( + registry + .get() + .filter(filterWallet) + .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), + ); + } - const registry = getWallets(); + setState({ wallets: buildWalletList() }); - function filterWallet(wallet: Wallet): boolean { - const uiWallet = - getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(wallet); - const supportsChain = uiWallet.chains.includes(config.chain); - const supportsConnect = uiWallet.features.includes('standard:connect'); - if (!supportsChain || !supportsConnect) return false; - // Apply custom filter if provided - return config.filter ? config.filter(uiWallet) : true; - } + const unsubRegister = registry.on('register', () => { + setState({ wallets: buildWalletList() }); + }); + const unsubUnregister = registry.on('unregister', () => { + const newWallets = buildWalletList(); + const updates: Partial = { wallets: newWallets }; + + 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'; + storage?.removeItem(storageKey); + } + + setState(updates); + }); - function buildWalletList(): readonly UiWallet[] { - return Object.freeze( - registry.get() - .filter(filterWallet) - .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), - ); - } + // -- Connection lifecycle -- - setState({ wallets: buildWalletList() }); + async function connect(uiWallet: UiWallet): Promise { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; + setState({ status: 'connecting' }); - const unsubRegister = registry.on('register', () => { - setState({ wallets: buildWalletList() }); - }); - const unsubUnregister = registry.on('unregister', () => { - const newWallets = buildWalletList(); - const updates: Partial = { wallets: newWallets }; - - 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'; - storage?.removeItem(storageKey); - } + try { + const connectFeature = getWalletFeature( + uiWallet, + 'standard:connect', + ) as StandardConnectFeature['standard:connect']; - setState(updates); - }); + // Snapshot existing accounts before connect — the wallet may + // already have some accounts visible. + const existingAccounts = [...uiWallet.accounts]; - // -- Connection lifecycle -- + await connectFeature.connect(); - async function connect(uiWallet: UiWallet): Promise { - userHasSelected = true; - reconnectCleanup?.(); - reconnectCleanup = null; - setState({ status: 'connecting' }); + // After connect, read accounts from uiWallet.accounts (already + // UiWalletAccount[]). The connect call's side effect is to populate + // this list — we don't need to map the raw WalletAccount[] return. + const allAccounts = uiWallet.accounts; - try { - const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as - StandardConnectFeature['standard:connect']; + if (allAccounts.length === 0) { + setState({ status: 'disconnected' }); + return allAccounts; + } - // Snapshot existing accounts before connect — the wallet may - // already have some accounts visible. - const existingAccounts = [...uiWallet.accounts]; + // Prefer the first newly authorized account. If none are new + // (e.g. re-connecting to an already-visible wallet), take the first. + const newAccount = allAccounts.find(a => !existingAccounts.some(e => e.address === a.address)); + const activeAccount = newAccount ?? allAccounts[0]; - await connectFeature.connect(); + const signer = tryCreateSigner(activeAccount); - // After connect, read accounts from uiWallet.accounts (already - // UiWalletAccount[]). The connect call's side effect is to populate - // this list — we don't need to map the raw WalletAccount[] return. - const allAccounts = uiWallet.accounts; + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); - if (allAccounts.length === 0) { - setState({ status: 'disconnected' }); - return allAccounts; - } - - // Prefer the first newly authorized account. If none are new - // (e.g. re-connecting to an already-visible wallet), take the first. - const newAccount = allAccounts.find( - (a) => !existingAccounts.some((e) => e.address === a.address), - ); - const activeAccount = newAccount ?? allAccounts[0]; - - const signer = tryCreateSigner(activeAccount); - - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(uiWallet); - - setState({ - connectedWallet: uiWallet, - account: activeAccount, - signer, - status: 'connected', - }); - - persistAccount(activeAccount); - return allAccounts; - } catch (error) { - setState({ status: 'disconnected' }); - throw error; + setState({ + connectedWallet: uiWallet, + account: activeAccount, + signer, + status: 'connected', + }); + + persistAccount(activeAccount); + return allAccounts; + } catch (error) { + setState({ status: 'disconnected' }); + throw error; + } } - } - - async function disconnect(): Promise { - const currentWallet = state.connectedWallet; - setState({ status: 'disconnecting' }); - - try { - if (currentWallet && currentWallet.features.includes('standard:disconnect')) { - const disconnectFeature = getWalletFeature( - currentWallet, 'standard:disconnect', - ) as StandardDisconnectFeature['standard:disconnect']; - await disconnectFeature.disconnect(); - } - } finally { - // Always clear local state and storage, even if standard:disconnect - // threw (network error, wallet bug). This is intentionally fail-safe: - // a broken disconnect should not leave the user in a state where they - // auto-reconnect into a potentially corrupt session on next page load. - walletEventsCleanup?.(); - walletEventsCleanup = null; - - setState({ - connectedWallet: null, - account: null, - signer: null, - status: 'disconnected', - }); - storage?.removeItem(storageKey); + async function disconnect(): Promise { + const currentWallet = state.connectedWallet; + setState({ status: 'disconnecting' }); + + try { + if (currentWallet && currentWallet.features.includes('standard:disconnect')) { + const disconnectFeature = getWalletFeature( + currentWallet, + 'standard:disconnect', + ) as StandardDisconnectFeature['standard:disconnect']; + await disconnectFeature.disconnect(); + } + } finally { + // Always clear local state and storage, even if standard:disconnect + // threw (network error, wallet bug). This is intentionally fail-safe: + // a broken disconnect should not leave the user in a state where they + // auto-reconnect into a potentially corrupt session on next page load. + walletEventsCleanup?.(); + walletEventsCleanup = null; + + setState({ + connectedWallet: null, + account: null, + signer: null, + status: 'disconnected', + }); + + storage?.removeItem(storageKey); + } } - } - - /** - * Clear local state without calling standard:disconnect on the wallet. - * Used for wallet-initiated disconnections (accounts removed, chain/feature - * changes) where the wallet already knows it disconnected. Synchronous, - * so it can't race with other event handlers. - */ - function disconnectLocally(): void { - walletEventsCleanup?.(); - walletEventsCleanup = null; - - setState({ - connectedWallet: null, - account: null, - signer: null, - status: 'disconnected', - }); - storage?.removeItem(storageKey); - } + /** + * Clear local state without calling standard:disconnect on the wallet. + * Used for wallet-initiated disconnections (accounts removed, chain/feature + * changes) where the wallet already knows it disconnected. Synchronous, + * so it can't race with other event handlers. + */ + function disconnectLocally(): void { + walletEventsCleanup?.(); + walletEventsCleanup = null; + + setState({ + connectedWallet: null, + account: null, + signer: null, + status: 'disconnected', + }); - function selectAccount(account: UiWalletAccount): void { - if (!state.connectedWallet) { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { - operation: 'selectAccount', - }); - } - userHasSelected = true; - const signer = tryCreateSigner(account); - setState({ account, signer }); - persistAccount(account); - } - - // -- Message signing -- - - async function signMessage(message: Uint8Array): Promise { - const { signer, connectedWallet } = state; - if (!signer || !connectedWallet) { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { - operation: 'signMessage', - }); - } - if (!('modifyAndSignMessages' in signer)) { - throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { - walletName: connectedWallet.name, - featureName: 'solana:signMessage', - }); + storage?.removeItem(storageKey); } - // Delegate to the MessageSigner returned by createSignerFromWalletAccount. - // Exact call signature depends on Kit's MessageSigner interface. - const results = await (signer as MessageSigner).modifyAndSignMessages([message]); - return results[0]; - } - - // -- Sign In With Solana -- - - async function signIn(walletOrInput?: UiWallet | SolanaSignInInput, maybeInput?: SolanaSignInInput): Promise { - // Determine which overload was called - const isWalletForm = walletOrInput && 'features' in walletOrInput; - const targetWallet = isWalletForm ? walletOrInput as UiWallet : state.connectedWallet; - const input = isWalletForm ? maybeInput : walletOrInput as SolanaSignInInput | undefined; - - if (!targetWallet) { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { - operation: 'signIn', - }); + + function selectAccount(account: UiWalletAccount): void { + if (!state.connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'selectAccount', + }); + } + userHasSelected = true; + const signer = tryCreateSigner(account); + setState({ account, signer }); + persistAccount(account); } - if (!targetWallet.features.includes('solana:signIn')) { - throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { - walletName: targetWallet.name, - featureName: 'solana:signIn', - }); + + // -- Message signing -- + + async function signMessage(message: Uint8Array): Promise { + const { signer, connectedWallet } = state; + if (!signer || !connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'signMessage', + }); + } + if (!('modifyAndSignMessages' in signer)) { + throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { + walletName: connectedWallet.name, + featureName: 'solana:signMessage', + }); + } + // Delegate to the MessageSigner returned by createSignerFromWalletAccount. + // Exact call signature depends on Kit's MessageSigner interface. + const results = await (signer as MessageSigner).modifyAndSignMessages([message]); + return results[0]; } - const signInFeature = getWalletFeature(targetWallet, 'solana:signIn') as - SolanaSignInFeature['solana:signIn']; - const [result] = await signInFeature.signIn(input ? [input] : [{}]); + // -- Sign In With Solana -- + + async function signIn( + walletOrInput?: UiWallet | SolanaSignInInput, + maybeInput?: SolanaSignInInput, + ): Promise { + // Determine which overload was called + const isWalletForm = walletOrInput && 'features' in walletOrInput; + const targetWallet = isWalletForm ? (walletOrInput as UiWallet) : state.connectedWallet; + const input = isWalletForm ? maybeInput : (walletOrInput as SolanaSignInInput | undefined); + + if (!targetWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'signIn', + }); + } + if (!targetWallet.features.includes('solana:signIn')) { + throw new SolanaError(SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED, { + walletName: targetWallet.name, + featureName: 'solana:signIn', + }); + } + + const signInFeature = getWalletFeature(targetWallet, 'solana:signIn') as SolanaSignInFeature['solana:signIn']; + const [result] = await signInFeature.signIn(input ? [input] : [{}]); + + // If called with a wallet (SIWS-as-connect), set up connection state + // using the account returned by the sign-in response. + if (isWalletForm) { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; - // If called with a wallet (SIWS-as-connect), set up connection state - // using the account returned by the sign-in response. - if (isWalletForm) { - userHasSelected = true; - reconnectCleanup?.(); - reconnectCleanup = null; + const account = result.account; // UiWalletAccount from the sign-in response + const signer = tryCreateSigner(account); - const account = result.account; // UiWalletAccount from the sign-in response - const signer = tryCreateSigner(account); + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(targetWallet); - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(targetWallet); + setState({ + connectedWallet: targetWallet, + account, + signer, + status: 'connected', + }); - setState({ - connectedWallet: targetWallet, - account, - signer, - status: 'connected', - }); + persistAccount(account); + } - persistAccount(account); + return result; } - return result; - } - - // -- Wallet-initiated events -- - - function subscribeToWalletEvents(uiWallet: UiWallet): () => void { - if (!uiWallet.features.includes('standard:events')) { - return () => {}; + // -- Wallet-initiated events -- + + function subscribeToWalletEvents(uiWallet: UiWallet): () => void { + if (!uiWallet.features.includes('standard:events')) { + return () => {}; + } + + const eventsFeature = getWalletFeature(uiWallet, 'standard:events') as StandardEventsFeature['standard:events']; + + return eventsFeature.on('change', properties => { + if (properties.accounts) { + handleAccountsChanged(uiWallet); + } + if (properties.chains) { + handleChainsChanged(uiWallet); + } + if (properties.features) { + handleFeaturesChanged(uiWallet); + } + }); } - const eventsFeature = getWalletFeature(uiWallet, 'standard:events') as - StandardEventsFeature['standard:events']; - - return eventsFeature.on('change', (properties) => { - if (properties.accounts) { - handleAccountsChanged(uiWallet); - } - if (properties.chains) { - handleChainsChanged(uiWallet); - } - if (properties.features) { - handleFeaturesChanged(uiWallet); - } - }); - } + function handleAccountsChanged(uiWallet: UiWallet): void { + const newAccounts = uiWallet.accounts; - function handleAccountsChanged(uiWallet: UiWallet): void { - const newAccounts = uiWallet.accounts; + if (newAccounts.length === 0) { + disconnectLocally(); + return; + } - if (newAccounts.length === 0) { - disconnectLocally(); - return; - } + const currentAddress = state.account?.address; + const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; + const activeAccount = stillPresent ?? newAccounts[0]; - const currentAddress = state.account?.address; - const stillPresent = currentAddress - ? newAccounts.find((a) => a.address === currentAddress) - : null; - const activeAccount = stillPresent ?? newAccounts[0]; - - const signer = tryCreateSigner(activeAccount); - setState({ account: activeAccount, signer }); - persistAccount(activeAccount); - } - - function handleChainsChanged(uiWallet: UiWallet): void { - if (!uiWallet.chains.includes(config.chain)) { - disconnectLocally(); - return; - } - // Chain support shifted but our chain is still valid — recreate - // signer in case chain-related capabilities changed. - if (state.account) { - const signer = tryCreateSigner(state.account); - setState({ signer }); + const signer = tryCreateSigner(activeAccount); + setState({ account: activeAccount, signer }); + persistAccount(activeAccount); } - } - function handleFeaturesChanged(uiWallet: UiWallet): void { - // Re-run the filter — if the wallet no longer passes, disconnect. - if (config.filter && !config.filter(uiWallet)) { - disconnectLocally(); - return; - } - // Features changed but wallet is still valid — recreate signer - // to pick up new capabilities (e.g. solana:signMessage added) - // or drop removed ones. createSignerFromWalletAccount is cheap. - if (state.account) { - const signer = tryCreateSigner(state.account); - setState({ signer }); + function handleChainsChanged(uiWallet: UiWallet): void { + if (!uiWallet.chains.includes(config.chain)) { + disconnectLocally(); + return; + } + // Chain support shifted but our chain is still valid — recreate + // signer in case chain-related capabilities changed. + if (state.account) { + const signer = tryCreateSigner(state.account); + setState({ signer }); + } } - } - // -- Auto-connect -- - - if (config.autoConnect !== false && storage) { - // Wrapped in async IIFE because storage.getItem may return a Promise - // (e.g. IndexedDB). Plugin setup still returns synchronously — status - // stays 'pending' until the storage read resolves. - (async () => { - const savedKey = await storage.getItem(storageKey); - if (userHasSelected) return; + function handleFeaturesChanged(uiWallet: UiWallet): void { + // Re-run the filter — if the wallet no longer passes, disconnect. + if (config.filter && !config.filter(uiWallet)) { + disconnectLocally(); + return; + } + // Features changed but wallet is still valid — recreate signer + // to pick up new capabilities (e.g. solana:signMessage added) + // or drop removed ones. createSignerFromWalletAccount is cheap. + if (state.account) { + const signer = tryCreateSigner(state.account); + setState({ signer }); + } + } - if (!savedKey) { + // -- Auto-connect -- + + if (config.autoConnect !== false && storage) { + // Wrapped in async IIFE because storage.getItem may return a Promise + // (e.g. IndexedDB). Plugin setup still returns synchronously — status + // stays 'pending' until the storage read resolves. + (async () => { + const savedKey = await storage.getItem(storageKey); + if (userHasSelected) return; + + if (!savedKey) { + setState({ status: 'disconnected' }); + return; + } + + const separatorIndex = savedKey.lastIndexOf(':'); + if (separatorIndex === -1) { + // Malformed saved key + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + return; + } + + const walletName = savedKey.slice(0, separatorIndex); + const existing = state.wallets.find(w => w.name === walletName); + + if (existing) { + attemptSilentReconnect(savedKey, existing); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); + return ui.name === walletName; + }) + ) { + // Wallet is registered but doesn't pass the filter (wrong chain, + // missing standard:connect, or rejected by config.filter). + // Clear stale persistence — don't wait for it. + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + } else { + // Wallet not registered yet — watch for it to appear. + // Revert status to 'disconnected' after 3s to avoid a perpetual + // spinner if the wallet is uninstalled. Keep the listener alive + // so slow-loading extensions can still silently reconnect. + setState({ status: 'reconnecting' }); + + const statusTimeout = setTimeout(() => { + if (!userHasSelected && state.status === 'reconnecting') { + setState({ status: 'disconnected' }); + } + }, 3000); + + const unsubRegisterForReconnect = registry.on('register', () => { + if (userHasSelected) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + return; + } + const found = buildWalletList().find(w => w.name === walletName); + if (found) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + attemptSilentReconnect(savedKey, 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 — clear stale persistence + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + storage.removeItem(storageKey); + setState({ status: 'disconnected' }); + } + }); + + reconnectCleanup = () => { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + }; + } + })(); + } else { + // No auto-connect: immediately transition from 'pending' to 'disconnected' setState({ status: 'disconnected' }); - return; - } + } - const separatorIndex = savedKey.lastIndexOf(':'); - if (separatorIndex === -1) { - // Malformed saved key - storage.removeItem(storageKey); - setState({ status: 'disconnected' }); - return; - } - - const walletName = savedKey.slice(0, separatorIndex); - const existing = state.wallets.find((w) => w.name === walletName); - - if (existing) { - attemptSilentReconnect(savedKey, existing); - } else if ( - registry.get().some((w) => { - const ui = getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(w); - return ui.name === walletName; - }) - ) { - // Wallet is registered but doesn't pass the filter (wrong chain, - // missing standard:connect, or rejected by config.filter). - // Clear stale persistence — don't wait for it. - storage.removeItem(storageKey); - setState({ status: 'disconnected' }); - } else { - // Wallet not registered yet — watch for it to appear. - // Revert status to 'disconnected' after 3s to avoid a perpetual - // spinner if the wallet is uninstalled. Keep the listener alive - // so slow-loading extensions can still silently reconnect. + async function attemptSilentReconnect(savedAccountKey: string, uiWallet: UiWallet): Promise { setState({ status: 'reconnecting' }); - const statusTimeout = setTimeout(() => { - if (!userHasSelected && state.status === 'reconnecting') { - setState({ status: 'disconnected' }); - } - }, 3000); - - const unsubRegisterForReconnect = registry.on('register', () => { - if (userHasSelected) { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - return; - } - const found = buildWalletList().find((w) => w.name === walletName); - if (found) { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - attemptSilentReconnect(savedKey, 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 — clear stale persistence - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - reconnectCleanup = null; - storage.removeItem(storageKey); + try { + const connectFeature = getWalletFeature( + uiWallet, + 'standard:connect', + ) as StandardConnectFeature['standard:connect']; + await connectFeature.connect({ silent: true }); + + // Read accounts from uiWallet.accounts after connect. + const allAccounts = uiWallet.accounts; + + if (allAccounts.length === 0) { + setState({ status: 'disconnected' }); + storage?.removeItem(storageKey); + return; + } + + // Check again: user may have connected manually while we were awaiting + if (userHasSelected) return; + + // Restore specific saved account, fall back to first from same wallet + const savedAddress = savedAccountKey.slice(savedAccountKey.lastIndexOf(':') + 1); + const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0]; + + const signer = tryCreateSigner(activeAccount); + + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); + + setState({ + connectedWallet: uiWallet, + account: activeAccount, + signer, + status: 'connected', + }); + } catch { setState({ status: 'disconnected' }); - } - }); - - reconnectCleanup = () => { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - }; - } - })(); - } else { - // No auto-connect: immediately transition from 'pending' to 'disconnected' - setState({ status: 'disconnected' }); - } - - async function attemptSilentReconnect( - savedAccountKey: string, - uiWallet: UiWallet, - ): Promise { - setState({ status: 'reconnecting' }); - - try { - const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as - StandardConnectFeature['standard:connect']; - await connectFeature.connect({ silent: true }); - - // Read accounts from uiWallet.accounts after connect. - const allAccounts = uiWallet.accounts; - - if (allAccounts.length === 0) { - setState({ status: 'disconnected' }); - storage?.removeItem(storageKey); - return; - } - - // Check again: user may have connected manually while we were awaiting - if (userHasSelected) return; - - // Restore specific saved account, fall back to first from same wallet - const savedAddress = savedAccountKey.slice(savedAccountKey.lastIndexOf(':') + 1); - const activeAccount = allAccounts.find((a) => a.address === savedAddress) - ?? allAccounts[0]; - - const signer = tryCreateSigner(activeAccount); - - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(uiWallet); - - setState({ - connectedWallet: uiWallet, - account: activeAccount, - signer, - status: 'connected', - }); - } catch { - setState({ status: 'disconnected' }); - storage?.removeItem(storageKey); + storage?.removeItem(storageKey); + } } - } - // -- Persistence -- + // -- Persistence -- - function persistAccount(account: UiWalletAccount): void { - storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); - } + function persistAccount(account: UiWalletAccount): void { + storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); + } - // -- Public store API -- + // -- Public store API -- - return { - getState: () => state, - getConnected: () => connected, - getSnapshot: () => snapshot, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - connect, - disconnect, - selectAccount, - signMessage, - signIn, - destroy: () => { - unsubRegister(); - unsubUnregister(); - walletEventsCleanup?.(); - walletEventsCleanup = null; - reconnectCleanup?.(); - reconnectCleanup = null; - listeners.clear(); - }, - }; + return { + getState: () => state, + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + connect, + disconnect, + selectAccount, + signMessage, + signIn, + destroy: () => { + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + reconnectCleanup?.(); + reconnectCleanup = null; + listeners.clear(); + }, + }; } ``` @@ -983,7 +955,7 @@ function createWalletStore(config: WalletPluginConfig) { The signer is created via `tryCreateSigner()` (wrapping `createSignerFromWalletAccount()`) when an account becomes active, and stored in `state.signer`. It is not recreated on every `client.payer` access -- the getter simply reads `state.signer`. -If `createSignerFromWalletAccount` throws (e.g. the wallet doesn't support any signing features), `tryCreateSigner` catches the error and returns `null`. The wallet is still connected — the account is set, events work, persistence works — but `state.signer` is `null` and `hasSigner` in the snapshot is `false`. The payer getter falls back to whatever payer was configured before the wallet plugin. +If `createSignerFromWalletAccount` throws (e.g. the wallet doesn't support any signing features), `tryCreateSigner` catches the error and returns `null`. The wallet is still connected — the account is set, events work, persistence works — but `snapshot.connected.signer` is `null`. When using `walletAsPayer`, `client.payer` is `undefined` (no signer available). This ensures referential stability, which matters for React's dependency arrays and avoids redundant codec/wrapper creation. @@ -1012,15 +984,15 @@ All variants satisfy `TransactionSigner`, which is what `client.payer` expects. ### How the getter works -The wallet plugin uses `Object.defineProperty` to define a dynamic getter on `payer`, after building the client with `extendClient`. The getter reads from the internal wallet store: +`walletAsPayer` uses `Object.defineProperty` to define a dynamic getter on `payer`. The getter returns the current wallet signer, or `undefined` when disconnected or read-only: ```typescript Object.defineProperty(obj, 'payer', { - get() { - return store.getState().signer ?? fallbackPayer; - }, - enumerable: true, - configurable: true, + get() { + return store.getState().signer; + }, + enumerable: true, + configurable: true, }); ``` @@ -1032,61 +1004,63 @@ This getter is preserved through subsequent `.use()` calls because: Note: downstream plugins should use `extendClient` rather than spread to ensure the payer getter is preserved. -### Interaction with sendTransactions plugin +### Interaction with planAndSendTransactions plugin -The `sendTransactions` plugin accesses `client.payer` at transaction time. Because the payer is a getter, it always resolves to the current value: +The `planAndSendTransactions` plugin accesses `client.payer` at transaction time. Because the payer is a getter, it always resolves to the current value: -- User connects wallet -> next `sendTransaction` call uses the wallet signer -- User switches accounts -> next `sendTransaction` call uses the new account's signer -- User disconnects -> next `sendTransaction` call uses the fallback payer (or fails if none) +- User connects wallet → next transaction uses the wallet signer +- User switches accounts → next transaction uses the new account's signer +- User disconnects → `client.payer` is `undefined`, transaction fails explicitly No client reconstruction is needed. The client is a long-lived object. ## Subscribability Contract -The `subscribe` and `getSnapshot` methods on `client.wallet` follow the contract expected by React's `useSyncExternalStore`. +All wallet state is accessed via `client.wallet.getSnapshot()`. There are no individual getters. -`subscribe` fires on every state change — including signer-only changes that don't affect the snapshot. Listeners don't know what changed; they just know "something changed." +`subscribe` fires on every internal state change. Listeners don't know what changed; they just know "something changed." -`getSnapshot` returns a memoized frozen object. A new snapshot reference is only created when snapshot-relevant fields change (wallets, connected wallet, account, status, or `hasSigner`). Crucially, a signer recreation that doesn't change `hasSigner` (e.g. a feature change that doesn't add or remove signing capability) fires `subscribe` listeners but returns the same snapshot reference from `getSnapshot`. React's `useSyncExternalStore` compares references, sees no change, and skips the re-render. - -The `connected` getter on `client.wallet` follows the same principle — a new object is only created when wallet, account, or signer identity changes. Since signer recreation produces a new object, `connected` does get a new reference on every signer recreation. Consumers that need to avoid re-renders on signer recreation should use the snapshot (`hasSigner`) rather than the getter. - -Individual accessors (`client.wallet.wallets`, `client.wallet.connected`, `client.wallet.status`) are getters that read the current state from the store. They are provided for non-React consumers or cases where you only need one piece of state. `client.wallet.connected` includes the signer for use in instruction building and manual signing. +`getSnapshot` returns a memoized frozen object. A new snapshot reference is only created when a snapshot field actually changes (compared via reference equality in `setState`). React's `useSyncExternalStore` uses `Object.is` to compare successive `getSnapshot` returns — same reference means no re-render, new reference means re-render. Because we control when the snapshot object is recreated, re-renders only happen on meaningful state changes. ### Framework adapter examples **React:** + ```tsx function useWalletState(client) { - return useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); } ``` **Vue:** + ```typescript function useWalletState(client) { - const state = shallowRef(client.wallet.getSnapshot()); - onMounted(() => { - const unsub = client.wallet.subscribe(() => { state.value = client.wallet.getSnapshot(); }); - onUnmounted(unsub); - }); - return state; + const state = shallowRef(client.wallet.getSnapshot()); + onMounted(() => { + const unsub = client.wallet.subscribe(() => { + state.value = client.wallet.getSnapshot(); + }); + onUnmounted(unsub); + }); + return state; } ``` **Svelte:** + ```typescript -const walletState = readable(client.wallet.getSnapshot(), (set) => { - return client.wallet.subscribe(() => set(client.wallet.getSnapshot())); +const walletState = readable(client.wallet.getSnapshot(), set => { + return client.wallet.subscribe(() => set(client.wallet.getSnapshot())); }); ``` **Solid:** + ```typescript const [walletState, setWalletState] = createSignal(client.wallet.getSnapshot()); onMount(() => { - onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getSnapshot()))); + onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getSnapshot()))); }); ``` @@ -1098,9 +1072,9 @@ Persistence is handled via a pluggable storage adapter following the Web Storage ```typescript type WalletStorage = { - getItem(key: string): string | null | Promise; - setItem(key: string, value: string): void | Promise; - removeItem(key: string): void | Promise; + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; }; ``` @@ -1120,82 +1094,76 @@ When no `storage` option is provided, the plugin defaults to `localStorage` in t ```typescript // Default — uses localStorage in browser, skipped on server -wallet({ chain: 'solana:mainnet' }) +wallet({ chain: 'solana:mainnet' }); // Use sessionStorage -wallet({ chain: 'solana:mainnet', storage: sessionStorage }) +wallet({ chain: 'solana:mainnet', storage: sessionStorage }); // Use a reactive store wallet({ - chain: 'solana:mainnet', - storage: { - getItem: (key) => myStore.getState().walletKey, - setItem: (key, value) => myStore.setState({ walletKey: value }), - removeItem: (key) => myStore.setState({ walletKey: null }), - }, -}) + chain: 'solana:mainnet', + storage: { + getItem: key => myStore.getState().walletKey, + setItem: (key, value) => myStore.setState({ walletKey: value }), + removeItem: key => myStore.setState({ walletKey: null }), + }, +}); // Disable persistence explicitly -wallet({ chain: 'solana:mainnet', storage: null }) +wallet({ chain: 'solana:mainnet', storage: null }); ``` ## Configuration ```typescript type WalletPluginConfig = { - /** - * The Solana chain this client targets. - * One client = one chain. To switch networks, - * create a separate client with a different chain and RPC endpoint. - */ - chain: SolanaChain; - - /** - * Optional filter function for wallet discovery. - * Called for each wallet that supports the configured chain and - * standard:connect. Return true to include the wallet, false to exclude. - * Useful for requiring specific features, whitelisting wallets, - * or any other application-specific filtering. - * - * @example - * // Require signAndSendTransaction - * filter: (w) => w.features.includes('solana:signAndSendTransaction') - * - * @example - * // Whitelist specific wallets - * filter: (w) => ['Phantom', 'Solflare'].includes(w.name) - */ - filter?: (wallet: UiWallet) => boolean; - - /** - * Whether to sync the connected wallet's signer to client.payer. - * @default true - */ - usePayer?: boolean; - - /** - * Whether to attempt silent reconnection on startup using - * the persisted wallet account from storage. - * @default true - */ - autoConnect?: boolean; - - /** - * Storage adapter for persisting the selected wallet account. - * Follows the Web Storage API shape (getItem/setItem/removeItem). - * Supports both sync and async backends. - * localStorage and sessionStorage satisfy this interface directly. - * Pass null to disable persistence entirely. - * Ignored on the server (storage is always skipped in SSR). - * @default localStorage - */ - storage?: WalletStorage | null; - - /** - * Storage key used for persistence. - * @default 'kit-wallet' - */ - storageKey?: string; + /** + * The Solana chain this client targets. + * One client = one chain. To switch networks, + * create a separate client with a different chain and RPC endpoint. + */ + chain: SolanaChain; + + /** + * Optional filter function for wallet discovery. + * Called for each wallet that supports the configured chain and + * standard:connect. Return true to include the wallet, false to exclude. + * Useful for requiring specific features, whitelisting wallets, + * or any other application-specific filtering. + * + * @example + * // Require signAndSendTransaction + * filter: (w) => w.features.includes('solana:signAndSendTransaction') + * + * @example + * // Whitelist specific wallets + * filter: (w) => ['Phantom', 'Solflare'].includes(w.name) + */ + filter?: (wallet: UiWallet) => boolean; + + /** + * Whether to attempt silent reconnection on startup using + * the persisted wallet account from storage. + * @default true + */ + autoConnect?: boolean; + + /** + * Storage adapter for persisting the selected wallet account. + * Follows the Web Storage API shape (getItem/setItem/removeItem). + * Supports both sync and async backends. + * localStorage and sessionStorage satisfy this interface directly. + * Pass null to disable persistence entirely. + * Ignored on the server (storage is always skipped in SSR). + * @default localStorage + */ + storage?: WalletStorage | null; + + /** + * Storage key used for persistence. + * @default 'kit-wallet' + */ + storageKey?: string; }; ``` @@ -1206,11 +1174,11 @@ type WalletPluginConfig = { Two new error codes added to `@solana/errors`: ```typescript -SOLANA_ERROR__WALLET__NOT_CONNECTED +SOLANA_ERROR__WALLET__NOT_CONNECTED; // context: { operation: string } // message: "Cannot $operation: no wallet connected" -SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED +SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED; // context: { walletName: string, featureName: string } // message: "Wallet \"$walletName\" does not support $featureName" ``` @@ -1219,21 +1187,21 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate ### Error behavior -| Scenario | Behavior | -|----------|----------| -| SSR (server environment) | Status stays `'pending'`, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED` | -| User rejects connection prompt | `connect()` propagates wallet error, status returns to `disconnected` | -| Wallet does not support signing | Connection succeeds, `hasSigner` is `false`, payer falls back, sign methods throw | -| Wallet does not pass filter | Filtered out at discovery time; disconnected if filter fails after feature change | -| Wallet unregisters while connected | Automatic disconnection, subscribers notified | -| Silent reconnect fails | Status -> `disconnected`, persisted account cleared | -| No wallets discovered | `state.wallets` is empty, UI can prompt user to install a wallet | -| `standard:disconnect` not supported | Disconnect proceeds -- clear local state regardless | -| `selectAccount` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'selectAccount' }` | -| `signMessage` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signMessage' }` | +| Scenario | Behavior | +| --------------------------------------- | ------------------------------------------------------------------------------------------------- | +| SSR (server environment) | Status stays `'pending'`, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED` | +| User rejects connection prompt | `connect()` propagates wallet error, status returns to `disconnected` | +| Wallet does not support signing | Connection succeeds, `connected.signer` is `null`, payer is `undefined`, sign methods throw | +| Wallet does not pass filter | Filtered out at discovery time; disconnected if filter fails after feature change | +| Wallet unregisters while connected | Automatic disconnection, subscribers notified | +| Silent reconnect fails | Status -> `disconnected`, persisted account cleared | +| No wallets discovered | `state.wallets` is empty, UI can prompt user to install a wallet | +| `standard:disconnect` not supported | Disconnect proceeds -- clear local state regardless | +| `selectAccount` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'selectAccount' }` | +| `signMessage` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signMessage' }` | | `signMessage` on wallet without feature | Throws `SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED` with `{ featureName: 'solana:signMessage' }` | -| `signIn` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signIn' }` | -| `signIn` on wallet without feature | Throws `SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED` with `{ featureName: 'solana:signIn' }` | +| `signIn` without connection | Throws `SOLANA_ERROR__WALLET__NOT_CONNECTED` with `{ operation: 'signIn' }` | +| `signIn` on wallet without feature | Throws `SOLANA_ERROR__WALLET__FEATURE_NOT_SUPPORTED` with `{ featureName: 'solana:signIn' }` | `connect()` and `disconnect()` propagate wallet errors to the caller unchanged. Internal errors (reconnect failures, storage errors) are logged via `console.warn` but do not throw. @@ -1243,9 +1211,9 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **Single chain per client.** Signers are bound to a specific chain at creation time. Switching chains requires a different RPC endpoint too. One client = one network. -**Single wallet connection.** One active wallet at a time. dApps needing multiple can access `client.wallet.wallets` and manage additional connections via wallet-standard APIs. +**Single wallet connection.** One active wallet at a time. dApps needing multiple can access `getSnapshot().wallets` and manage additional connections via wallet-standard APIs. -**SSR-safe.** The plugin gracefully degrades on the server — status stays `'pending'`, wallet list is empty, payer falls back, storage is skipped, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED`. The same client chain works on both server and browser without conditional `.use()` calls. +**SSR-safe.** Both `wallet` and `walletAsPayer` gracefully degrade on the server — status stays `'pending'`, wallet list is empty, payer is `undefined` (when using `walletAsPayer`), storage is skipped, all actions throw `SOLANA_ERROR__WALLET__NOT_CONNECTED`. The same client chain works on both server and browser without conditional `.use()` calls. **`pending` status.** Initial status is `'pending'`, not `'disconnected'`. This lets UI distinguish "we haven't checked yet" (render nothing / skeleton) from "we checked and there's no wallet" (render connect button). On the server, status stays `'pending'` permanently. In the browser, it transitions to `'disconnected'` or `'reconnecting'` once the storage read completes. @@ -1253,7 +1221,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **Single subscribe listener.** Fires on any state change. Frameworks needing field-level selectivity use their own selector patterns (e.g. `useSyncExternalStoreWithSelector`). -**Plugin ordering with `payer`.** `payer()` first, then `wallet()`. The wallet plugin captures the existing payer as its fallback. +**Two plugins: `wallet` and `walletAsPayer`.** The payer decision is expressed at the type level, not via a config flag. `wallet()` adds wallet state and actions without touching `client.payer`. `walletAsPayer()` additionally overrides `client.payer` with a dynamic getter. The choice is in which function you import — the types tell you exactly what you get. **Web Storage API for persistence.** Duck-typed to match the `getItem`/`setItem`/`removeItem` shape used by wagmi and Zustand. Supports both sync and async backends — `localStorage` can be passed directly, and async backends (IndexedDB, encrypted storage) return Promises. @@ -1271,7 +1239,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **`userHasSelected` flag.** Tracks whether the user has made an explicit choice (via `connect`, `selectAccount`, or `signIn` with a wallet). When true, the auto-restore flow will not override the user's selection, matching the `wasSetterInvokedRef` pattern from `@solana/react`. -**Read-only wallet support.** `tryCreateSigner` wraps `createSignerFromWalletAccount` in a try/catch. If the wallet doesn't support any signing features (e.g. a watch-only wallet), connection still succeeds — the account is set, events fire, persistence works. `hasSigner` in the snapshot lets UI distinguish connected-with-signer from connected-without-signer. The payer getter falls back when `signer` is `null`. +**Read-only wallet support.** `tryCreateSigner` wraps `createSignerFromWalletAccount` in a try/catch. If the wallet doesn't support any signing features (e.g. a watch-only wallet), connection still succeeds — the account is set, events fire, persistence works. `connected.signer` in the snapshot is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` is `undefined`. --- @@ -1280,12 +1248,15 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate The following deviations and fixes were identified during spec review and should be applied during implementation rather than requiring a spec revision. **`withCleanup` not yet released.** `withCleanup` has landed in `@solana/plugin-core` but is not yet in a released build of `@solana/kit`. Replace `...withCleanup(client, () => store.destroy())` with a direct property: + ```typescript [Symbol.dispose]: () => store.destroy(), ``` + This won't chain with other dispose plugins (LIFO ordering won't apply) but is sufficient until `withCleanup` ships. **Missing dependency: `@wallet-standard/features`.** `WalletPluginConfig.features` uses the `IdentifierArray` type from `@wallet-standard/features`. Add it to `dependencies`: + ```json "@wallet-standard/features": "^1.x" ``` @@ -1294,6 +1265,6 @@ This won't chain with other dispose plugins (LIFO ordering won't apply) but is s **Unhandled rejection in auto-connect IIFE.** The fire-and-forget `(async () => { ... })()` has no top-level error handler. If `storage.getItem()` rejects, it produces an unhandled promise rejection. Add a `.catch()` that resets status to `'disconnected'` when storage fails before `userHasSelected` is set. -**`autoConnect` JSDoc.** The `autoConnect` config option has no effect when `storage` is not provided (the block is gated on `config.autoConnect !== false && storage`). Add a note to the JSDoc: *"Has no effect if `storage` is not provided."* +**`autoConnect` JSDoc.** The `autoConnect` config option has no effect when `storage` is not provided (the block is gated on `config.autoConnect !== false && storage`). Add a note to the JSDoc: _"Has no effect if `storage` is not provided."_ **`signIn` overload discriminant (low risk).** The `'features' in walletOrInput` check works for now but would misfire if `SolanaSignInInput` ever gains a `features` field. A more defensive check would combine multiple `UiWallet`-exclusive fields (e.g. `'accounts' in walletOrInput && 'chains' in walletOrInput`). From 525ec5c7b7a257fd595c339e03633125deef98b6 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 2 Apr 2026 15:01:29 +0000 Subject: [PATCH 3/3] Refactor to use withCleanup for plugin cleanup --- packages/kit-plugin-wallet/src/index.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/kit-plugin-wallet/src/index.ts b/packages/kit-plugin-wallet/src/index.ts index 6837f95..35385cb 100644 --- a/packages/kit-plugin-wallet/src/index.ts +++ b/packages/kit-plugin-wallet/src/index.ts @@ -1,4 +1,4 @@ -import { extendClient, MessageSigner, SignatureBytes, TransactionSigner } from '@solana/kit'; +import { extendClient, MessageSigner, SignatureBytes, TransactionSigner, withCleanup } from '@solana/kit'; import type { SolanaChain } from '@solana/wallet-standard-chains'; import type { SolanaSignInInput, SolanaSignInOutput } from '@solana/wallet-standard-features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; @@ -393,11 +393,12 @@ export function wallet(config: WalletPluginConfig) { return (client: T): ClientWithWallet & Disposable & Omit => { const store = createWalletStore(config); - return extendClient(client, { - wallet: buildWalletNamespace(store), - // TODO: This will use withCleanup after the next Kit release - [Symbol.dispose]: () => store[Symbol.dispose](), - }) as ClientWithWallet & Disposable & Omit; + return withCleanup( + extendClient(client, { + wallet: buildWalletNamespace(store), + }), + () => store[Symbol.dispose](), + ) as ClientWithWallet & Disposable & Omit; }; } @@ -450,11 +451,12 @@ export function walletAsPayer(config: WalletPluginConfig) { return (client: T): ClientWithWalletAsPayer & Disposable & Omit => { const store = createWalletStore(config); - const obj = extendClient(client, { - wallet: buildWalletNamespace(store), - // TODO: This will use withCleanup after the next Kit release - [Symbol.dispose]: () => store[Symbol.dispose](), - }); + const obj = withCleanup( + extendClient(client, { + wallet: buildWalletNamespace(store), + }), + () => store[Symbol.dispose](), + ); Object.defineProperty(obj, 'payer', { configurable: true,