From 4604d77e72f817e2ffeaec372d30f428e4bcb033 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 31 Mar 2026 12:27:21 +0000 Subject: [PATCH 01/16] 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 | 486 ++++++ wallet-plugin-spec.md | 1299 +++++++++++++++++ 13 files changed, 2589 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 2eef33c..cb2b10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,39 @@ importers: specifier: ^6.9.0 version: 6.9.0(typescript@6.0.3) + packages/kit-plugin-wallet: + dependencies: + '@solana/kit': + specifier: ^6.9.0 + version: 6.9.0(typescript@6.0.3) + '@solana/wallet-account-signer': + specifier: ^6.6.0 + version: 6.6.0(typescript@6.0.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': @@ -1161,6 +1194,15 @@ packages: typescript: optional: true + '@solana/addresses@6.6.0': + resolution: {integrity: sha512-P0sblHfk7vYfzo2aya/7JxqVDT/6gFvTON9MKS5XBKkgg7jbSUjMHTyhIxIrYPQegd483AuAqjcMp/psJBNnGA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/addresses@6.9.0': resolution: {integrity: sha512-tWnG2L6lo/ZhcMT019F3myDsH87MM8EZbTO0cgwgvVPlEdIGblROFF3tGVrb7FVCOlbPI0ONCFyPbnrmR58LsA==} engines: {node: '>=20.18.0'} @@ -1170,6 +1212,15 @@ packages: typescript: optional: true + '@solana/assertions@6.6.0': + resolution: {integrity: sha512-zzu3svXpssVJwPyHxzi1cWDL1xRN+iD1BhLK0EhATUgxjAdSP9HtKra0bisURRNLH4QuPp7HBzjEWaBUftc4nQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/assertions@6.9.0': resolution: {integrity: sha512-FjWWD6e0in+HFsHMvU2zKCbyPfKtDW6iGXZZ9+Qg1QUYpO1AEObsya3F7hb9RkZKUueK4WwWAQnIuvEUp3A1uA==} engines: {node: '>=20.18.0'} @@ -1179,6 +1230,15 @@ packages: typescript: optional: true + '@solana/codecs-core@6.6.0': + resolution: {integrity: sha512-sjVgIDnOp5ZTnrv7p1bq6UXm1uOTa8vVvm+tHdHiaBkYcCrcUx9XwAlODfpEW8GBXihdq7dYs6xwj+80jzjmeA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/codecs-core@6.9.0': resolution: {integrity: sha512-F2BmLecG/1nTtnjyD509NsEc254pxJKa2bpvotymv1lL1WfEn3zchcZ9SMIiLyL4G6J8b9F3OKIq2YSZho2AOQ==} engines: {node: '>=20.18.0'} @@ -1188,6 +1248,15 @@ packages: typescript: optional: true + '@solana/codecs-data-structures@6.6.0': + resolution: {integrity: sha512-EFv8HyANnvU9JaXqoXO480dxC+r+fV7FeUIbRuGJG5bUrhkfmHyMSV9w/GrMs6Jo4YgRHh8z5klrX6VR2od+xg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/codecs-data-structures@6.9.0': resolution: {integrity: sha512-f7GYtiHafvJDhqiwzUUSr/6AYSK4DCw6quPmA80NZGtkNiFa+g6LoJy2wbC0wp2dxvCwNpxf6x3ILCYRutAvvg==} engines: {node: '>=20.18.0'} @@ -1197,6 +1266,15 @@ packages: typescript: optional: true + '@solana/codecs-numbers@6.6.0': + resolution: {integrity: sha512-ykRsdKQgEgNL/ci4dhVIusG3KLiaQVDnjQecDmzlyTJ4EFMvCvvb9W3uCOwLKjSvCqPPj0NgSmpIIc/mahYhSQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/codecs-numbers@6.9.0': resolution: {integrity: sha512-XMI0FOHV2h7yPAllxWCX8z+J1msidNjXzN1mRjH5KR6C+vfzyKa2xWHve0bNSV/bjVAhqqhc7dQCpBKuF4+ScQ==} engines: {node: '>=20.18.0'} @@ -1206,6 +1284,18 @@ packages: typescript: optional: true + '@solana/codecs-strings@6.6.0': + resolution: {integrity: sha512-YK1IzJyymuiKsEdYXqswt+CaZMJ8YcTwsQrUd4KfdUKUo1o1Bz3HxzTeuFfMqn0K+Yv+U5V7JVhO90gzJIMB2g==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ^5.0.0 + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + '@solana/codecs-strings@6.9.0': resolution: {integrity: sha512-PTqYQxMsmdfEEq29bV1AnALD4FjFEsSxOj1fYNqooOSTEQEpUoYEQtsd55/kBsnIKltXbvYwXYXBusm19n1sQA==} engines: {node: '>=20.18.0'} @@ -1227,6 +1317,16 @@ packages: typescript: optional: true + '@solana/errors@6.6.0': + resolution: {integrity: sha512-8MlqxF3NWWT+nzvq08/7uPyx3u7zOGBR7ZmYvczWxM37pPcBmGEgsruWqw120Zk2Z1spzqOzXd/uTbXBxanH4Q==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/errors@6.9.0': resolution: {integrity: sha512-7i+b07KMnkbHvFlz7uWade3jvyc22UmVm8o9taxPK8YV3JNM/NkS8oQFvMac2MIaLPAlEs7I8MHyVLUal1yY4g==} engines: {node: '>=20.18.0'} @@ -1255,6 +1355,15 @@ packages: typescript: optional: true + '@solana/functional@6.6.0': + resolution: {integrity: sha512-Cmyr/fg3G5cbCJPQ2yB4qVJAUYghCRoQ0vjl1z88TV40S64h0xUCm8tiaMyJZeEjL2nKHzfCXhgbtnlpliRHmg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/functional@6.9.0': resolution: {integrity: sha512-sgNHOaIjETZZuziZdlwPsU5EjBVj5M0dUbwrSQTTNZe0SxX3pQ1QFVcs5KyvdS7AQcpBVdLjx4CfQjdKXk52GA==} engines: {node: '>=20.18.0'} @@ -1273,6 +1382,15 @@ packages: typescript: optional: true + '@solana/instructions@6.6.0': + resolution: {integrity: sha512-RMLFKlDbHXIN5RSd5j2dPv8dayHkASHiNzUVZjgd/IXdOw6MK32PfII3Uk5DYPRifEo74fUsX3VRZKK+TdTO1A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/instructions@6.9.0': resolution: {integrity: sha512-LZfJx3bGdUSbGaswoOEPHygticqkCg3TusRczPJXyCmKhoQzPCcGQQ99qMzP7Wg8pEV5tWA5t7tycf8E237ydg==} engines: {node: '>=20.18.0'} @@ -1282,6 +1400,15 @@ packages: typescript: optional: true + '@solana/keys@6.6.0': + resolution: {integrity: sha512-iXBnzFnuPnq6DgLIvVaEp0Y21nEkOUlcxEXPqKc+Wliqu2lOUiqfukETwlglomA/2Fo8QP3248jfFqcYWNaUeQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/keys@6.9.0': resolution: {integrity: sha512-1g2QARiqSjNqT0EIqLDLQ5vRm7hCsbqgFwFAp5GsMV/8BTYT8s1Ct2wLHDZiJ4eAX6beTHVf8LbOBfVejtn3oQ==} engines: {node: '>=20.18.0'} @@ -1300,6 +1427,15 @@ packages: typescript: optional: true + '@solana/nominal-types@6.6.0': + resolution: {integrity: sha512-kKCOBf60iHop7qYs/cr+t4lQf6QQQ8wsko0tV42rfmAEsAHTnmGCGOeh9s8fq4Wd4NeolOi/4qVrWQo3B/cmcw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/nominal-types@6.9.0': resolution: {integrity: sha512-ouhrnY7a6nsLXRGcariwcmHDdXroCNqOuzwtdjKt2c8e8Drwao9yxPH2VoViNgpq8IGNJeQMEI1TVnoJZRn0gw==} engines: {node: '>=20.18.0'} @@ -1309,6 +1445,15 @@ packages: typescript: optional: true + '@solana/offchain-messages@6.6.0': + resolution: {integrity: sha512-g3ERmMxlJcVThJlRYSu7EtZ2JHE89LbbxwNGARPU8dRbDXzPkBJwbaka3JqbDlIBZEUnYsKiaaA6wYPvxpwQNw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/offchain-messages@6.9.0': resolution: {integrity: sha512-qK3tqRPb+E0kmTz5qFXZbEdF4pyzfOWRZjyVESHVGemDDeGzZ1SV3zAxcA6HBCnv4wCBnlyaDPw8t+5sryNMAw==} engines: {node: '>=20.18.0'} @@ -1363,6 +1508,15 @@ packages: typescript: optional: true + '@solana/promises@6.6.0': + resolution: {integrity: sha512-EVNVrHh9MiNHXAuvlloOxgTWElMJrC9IrgbfGro8T1v9epLJD953Q1Wi7kU5oT3hMRDZaJ31Gk2VUUdOjNQWgA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/promises@6.9.0': resolution: {integrity: sha512-227PlXRi6KZX4ODYTkJitr9InSa79NTquI72slay4gzxO9VmMepgvYdMAX6kawdN5pt+VzaklKhNhWXk50Pi9g==} engines: {node: '>=20.18.0'} @@ -1462,6 +1616,15 @@ packages: typescript: optional: true + '@solana/rpc-types@6.6.0': + resolution: {integrity: sha512-CGa9Nc9rJfLs7DgI8XdpCsabI3FnZW/XFMFM00eUOJcSQEff2PDC/IUkLvKY/5A++/xzA3EB5r3ck+b/ktPEpQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/rpc-types@6.9.0': resolution: {integrity: sha512-iFhPzZK3qiQ1lhfNTNBTI7BIs5PfWZSgRLD3enKm8ZAQggzvUklfO3KPh47jVsc/Jsr1UGPH8M3o3m17qjO1Cg==} engines: {node: '>=20.18.0'} @@ -1480,6 +1643,15 @@ packages: typescript: optional: true + '@solana/signers@6.6.0': + resolution: {integrity: sha512-TL5ZscoF+y4QfN80cs3MndGLMijOFIFJKwuRRXEDtSUfd35O7w+TpK1Bmxd+YVCXZv+KYNTVKdon6QTLihP+QA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/signers@6.9.0': resolution: {integrity: sha512-x7WyoRm9IORMqeSqNivZgyY+RERPkmqWxpINPD13kUH+oaZzonORIgxk2Lz+u5iPRXiJPkdRPrQ4FoFWv8i6kQ==} engines: {node: '>=20.18.0'} @@ -1516,6 +1688,15 @@ packages: typescript: optional: true + '@solana/transaction-messages@6.6.0': + resolution: {integrity: sha512-wxsS+7JKs6SzX/8ibuAp4UUNAR9/zeJmCUtD5sT7Cu9Fy3qGCmZhrxY9t265+UCMrAUZso5Lw7plIm4b2UuyUA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/transaction-messages@6.9.0': resolution: {integrity: sha512-OWpryt0w6SHlwHx12Vd1wvx2QwSGBXAIUEHTCtkctcM3AaZRy5cIl7CAq9iD5PgahUsaOyRLBV0zlCJcC2JrJA==} engines: {node: '>=20.18.0'} @@ -1525,6 +1706,15 @@ packages: typescript: optional: true + '@solana/transactions@6.6.0': + resolution: {integrity: sha512-wAuqS64hcuwjc8ZIceX3+VeaM8DiO7LsmMfyJWDqolb79flUdDuLMg5yGU+bt29t3+ZBPNG4KPfIzs5y/7X2dQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/transactions@6.9.0': resolution: {integrity: sha512-uKPzLwHbjwChfVl82he17ntkh02PfgnMMhN7uOAC+VbkIt1O+EEw8sX87gi6kdG/EV+QBDQXm9PLAo5W0tYylw==} engines: {node: '>=20.18.0'} @@ -1534,6 +1724,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==} @@ -1620,6 +1827,43 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@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@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -1699,6 +1943,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + 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'} @@ -3119,6 +3367,18 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/addresses@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/assertions': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-strings': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/addresses@6.9.0(typescript@6.0.3)': dependencies: '@solana/assertions': 6.9.0(typescript@6.0.3) @@ -3131,18 +3391,38 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/assertions@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/assertions@6.9.0(typescript@6.0.3)': dependencies: '@solana/errors': 6.9.0(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 + '@solana/codecs-core@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/codecs-core@6.9.0(typescript@6.0.3)': dependencies: '@solana/errors': 6.9.0(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 + '@solana/codecs-data-structures@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/codecs-data-structures@6.9.0(typescript@6.0.3)': dependencies: '@solana/codecs-core': 6.9.0(typescript@6.0.3) @@ -3151,6 +3431,13 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@solana/codecs-numbers@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/codecs-numbers@6.9.0(typescript@6.0.3)': dependencies: '@solana/codecs-core': 6.9.0(typescript@6.0.3) @@ -3158,6 +3445,14 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@solana/codecs-strings@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/codecs-strings@6.9.0(typescript@6.0.3)': dependencies: '@solana/codecs-core': 6.9.0(typescript@6.0.3) @@ -3179,6 +3474,13 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/errors@6.6.0(typescript@6.0.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + optionalDependencies: + typescript: 6.0.3 + '@solana/errors@6.9.0(typescript@6.0.3)': dependencies: chalk: 5.6.2 @@ -3197,6 +3499,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@solana/functional@6.6.0(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + '@solana/functional@6.9.0(typescript@6.0.3)': optionalDependencies: typescript: 6.0.3 @@ -3214,6 +3520,13 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/instructions@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@solana/instructions@6.9.0(typescript@6.0.3)': dependencies: '@solana/codecs-core': 6.9.0(typescript@6.0.3) @@ -3221,6 +3534,18 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@solana/keys@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/assertions': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-strings': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/keys@6.9.0(typescript@6.0.3)': dependencies: '@solana/assertions': 6.9.0(typescript@6.0.3) @@ -3268,10 +3593,29 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate + '@solana/nominal-types@6.6.0(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + '@solana/nominal-types@6.9.0(typescript@6.0.3)': optionalDependencies: typescript: 6.0.3 + '@solana/offchain-messages@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-data-structures': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/codecs-strings': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/keys': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/offchain-messages@6.9.0(typescript@6.0.3)': dependencies: '@solana/addresses': 6.9.0(typescript@6.0.3) @@ -3342,6 +3686,10 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/promises@6.6.0(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + '@solana/promises@6.9.0(typescript@6.0.3)': optionalDependencies: typescript: 6.0.3 @@ -3456,6 +3804,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@solana/rpc-types@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/codecs-strings': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc-types@6.9.0(typescript@6.0.3)': dependencies: '@solana/addresses': 6.9.0(typescript@6.0.3) @@ -3486,6 +3847,22 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/signers@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/instructions': 6.6.0(typescript@6.0.3) + '@solana/keys': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + '@solana/offchain-messages': 6.6.0(typescript@6.0.3) + '@solana/transaction-messages': 6.6.0(typescript@6.0.3) + '@solana/transactions': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/signers@6.9.0(typescript@6.0.3)': dependencies: '@solana/addresses': 6.9.0(typescript@6.0.3) @@ -3540,6 +3917,22 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate + '@solana/transaction-messages@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-data-structures': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/functional': 6.6.0(typescript@6.0.3) + '@solana/instructions': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + '@solana/rpc-types': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/transaction-messages@6.9.0(typescript@6.0.3)': dependencies: '@solana/addresses': 6.9.0(typescript@6.0.3) @@ -3556,6 +3949,25 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/transactions@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/codecs-data-structures': 6.6.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.6.0(typescript@6.0.3) + '@solana/codecs-strings': 6.6.0(typescript@6.0.3) + '@solana/errors': 6.6.0(typescript@6.0.3) + '@solana/functional': 6.6.0(typescript@6.0.3) + '@solana/instructions': 6.6.0(typescript@6.0.3) + '@solana/keys': 6.6.0(typescript@6.0.3) + '@solana/nominal-types': 6.6.0(typescript@6.0.3) + '@solana/rpc-types': 6.6.0(typescript@6.0.3) + '@solana/transaction-messages': 6.6.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/transactions@6.9.0(typescript@6.0.3)': dependencies: '@solana/addresses': 6.9.0(typescript@6.0.3) @@ -3575,6 +3987,34 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/wallet-account-signer@6.6.0(typescript@6.0.3)': + dependencies: + '@solana/addresses': 6.6.0(typescript@6.0.3) + '@solana/codecs-core': 6.6.0(typescript@6.0.3) + '@solana/keys': 6.6.0(typescript@6.0.3) + '@solana/promises': 6.6.0(typescript@6.0.3) + '@solana/signers': 6.6.0(typescript@6.0.3) + '@solana/transaction-messages': 6.6.0(typescript@6.0.3) + '@solana/transactions': 6.6.0(typescript@6.0.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: 6.0.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.16': @@ -3659,6 +4099,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@8.15.0: {} agadoo@3.0.0: @@ -3720,6 +4204,8 @@ snapshots: dependencies: readdirp: 4.1.2 + 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 1f9e04c86f705c252ee85882d9e81cb1a6817204 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 2 Apr 2026 13:47:42 +0000 Subject: [PATCH 02/16] 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 d48ac42c234bb51611c2ffd1966f0a17c63b2687 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 2 Apr 2026 15:01:29 +0000 Subject: [PATCH 03/16] 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, From 6ed826e7ad62974c3320b8c20bc81540d354972d Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 6 Apr 2026 09:36:16 +0000 Subject: [PATCH 04/16] Update wallet plugin scaffold design --- packages/kit-plugin-wallet/src/index.ts | 62 +- wallet-plugin-spec.md | 1551 +++++++++++------------ 2 files changed, 785 insertions(+), 828 deletions(-) diff --git a/packages/kit-plugin-wallet/src/index.ts b/packages/kit-plugin-wallet/src/index.ts index 35385cb..67824d6 100644 --- a/packages/kit-plugin-wallet/src/index.ts +++ b/packages/kit-plugin-wallet/src/index.ts @@ -30,14 +30,14 @@ export type WalletStatus = 'connected' | 'connecting' | 'disconnected' | 'discon /** * A snapshot of the wallet plugin state at a point in time. * - * Returned by {@link WalletNamespace.getSnapshot}. The same object reference + * Returned by {@link WalletNamespace.getState}. 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} + * @see {@link WalletNamespace.getState} */ -export type WalletStateSnapshot = { +export type WalletState = { /** * The active connection, or `null` when disconnected. * @@ -170,13 +170,13 @@ export type WalletNamespace = { // -- State -- /** - * Get a referentially stable snapshot of the full wallet state. A new - * object is only created when a field actually changes, so React's + * Get the current wallet state. Referentially stable — 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} + * @see {@link WalletState} */ - getSnapshot: () => WalletStateSnapshot; + getState: () => WalletState; /** * Switch to a different account within the connected wallet. Creates and @@ -228,7 +228,7 @@ export type WalletNamespace = { * * @example * ```ts - * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); * ``` */ subscribe: (listener: () => void) => () => void; @@ -297,27 +297,8 @@ export class WalletNotConnectedError extends Error { // -- Internal types --------------------------------------------------------- -type WalletStoreState = { - account: UiWalletAccount | null; - connectedWallet: UiWallet | null; - signer: WalletSigner | null; - status: WalletStatus; - wallets: readonly UiWallet[]; -}; - -type WalletStore = { - connect: (wallet: UiWallet) => Promise; +type WalletStore = WalletNamespace & { [Symbol.dispose]: () => void; - disconnect: () => Promise; - 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 ------------------------------------------------------------------ @@ -328,18 +309,6 @@ function createWalletStore(_config: WalletPluginConfig): WalletStore { // -- Plugin ----------------------------------------------------------------- -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. @@ -363,7 +332,7 @@ function buildWalletNamespace(store: WalletStore): WalletNamespace { * .use(planAndSendTransactions()); * * // client.payer is always backendKeypair (wallet plugin does not touch it) - * // client.wallet.getSnapshot().connected?.signer for manual use + * // client.wallet.getState().connected?.signer for manual use * ``` * * @param config - Plugin configuration. @@ -382,7 +351,7 @@ function buildWalletNamespace(store: WalletStore): WalletNamespace { * await client.wallet.connect(uiWallet); * * // Subscribe to state changes (React) - * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); * ``` * * @see {@link walletAsPayer} @@ -395,7 +364,7 @@ export function wallet(config: WalletPluginConfig) { return withCleanup( extendClient(client, { - wallet: buildWalletNamespace(store), + wallet: store, }), () => store[Symbol.dispose](), ) as ClientWithWallet & Disposable & Omit; @@ -453,7 +422,7 @@ export function walletAsPayer(config: WalletPluginConfig) { const obj = withCleanup( extendClient(client, { - wallet: buildWalletNamespace(store), + wallet: store, }), () => store[Symbol.dispose](), ); @@ -462,11 +431,10 @@ export function walletAsPayer(config: WalletPluginConfig) { configurable: true, enumerable: true, get() { - const { signer } = store.getState(); - return signer !== null ? signer : undefined; + return obj.wallet.getState().connected?.signer ?? undefined; }, }); - return obj as ClientWithWalletAsPayer & Disposable & Omit; + return obj as unknown as ClientWithWalletAsPayer & Disposable & Omit; }; } diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index 0e6ac0d..07db052 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" + } } ``` @@ -39,10 +39,10 @@ A framework-agnostic Kit plugin that manages wallet discovery, connection lifecy import { walletAsPayer } from '@solana/kit-plugin-wallet'; const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })) - .use(systemProgram()) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(systemProgram()) + .use(planAndSendTransactions()); // Server: status === 'pending', client.payer === undefined // Browser: auto-connect fires, client.payer becomes wallet signer @@ -88,7 +88,7 @@ This plugin fills that gap. It sits between wallet-standard's raw discovery API ### 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. +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.getState` — 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` @@ -103,9 +103,9 @@ 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(walletAsPayer({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // No wallet connected -> client.payer is undefined // Wallet connected -> client.payer returns wallet signer @@ -125,19 +125,19 @@ 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(walletAsPayer({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .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()); + .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.wallet.getState().connected?.signer for manual use ``` ### Client type extension @@ -146,65 +146,65 @@ Both `wallet` and `walletAsPayer` add the `wallet` namespace to the client. `wal ```typescript 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; - }; + 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 the current wallet state. Referentially stable + * when unchanged — a new object is only created when a + * state field actually changes. + */ + getState: () => WalletState; + + // -- 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; + }; }; /** @@ -213,37 +213,42 @@ type ClientWithWallet = { * or undefined when disconnected / read-only. */ type ClientWithWalletAsPayer = ClientWithWallet & { - readonly payer: TransactionSigner | undefined; + readonly payer: TransactionSigner | undefined; }; -export function wallet(config: WalletPluginConfig): (client: T) => T & ClientWithWallet; +export function wallet(config: WalletPluginConfig): + (client: T) => T & ClientWithWallet; -export function walletAsPayer(config: WalletPluginConfig): (client: T) => T & ClientWithWalletAsPayer; +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) - -type WalletStateSnapshot = { - 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; + | '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 WalletState = { + 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; }; ``` -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. +All wallet state is accessed via `getState()`. The returned object is frozen and 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, wallets } = useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); +const { connected, status, wallets } = useSyncExternalStore( + client.wallet.subscribe, + client.wallet.getState, +); if (status === 'pending') return null; if (!connected) return ; @@ -272,19 +277,19 @@ client[Symbol.dispose](); // With using syntax (TypeScript 5.2+) { - using client = createEmptyClient() - .use(rpc('https://...')) - .use(wallet({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + 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](); }, []); ``` @@ -298,80 +303,70 @@ 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); + return (client: T) => { + const store = createWalletStore(config); - return extendClient(client, { - wallet: buildWalletNamespace(store), - ...withCleanup(client, () => store.destroy()), - }); - }; + return extendClient(client, { + wallet: store, + ...withCleanup(client, () => store.destroy()), + }); + }; } export function walletAsPayer(config: WalletPluginConfig) { - return (client: T) => { - const store = createWalletStore(config); + return (client: T) => { + const store = createWalletStore(config); - const obj = extendClient(client, { - wallet: buildWalletNamespace(store), - ...withCleanup(client, () => store.destroy()), - }); - - Object.defineProperty(obj, 'payer', { - get() { - return store.getState().signer; - }, - enumerable: true, - configurable: true, - }); + const obj = extendClient(client, { + wallet: store, + ...withCleanup(client, () => store.destroy()), + }); - return obj; - }; -} + Object.defineProperty(obj, 'payer', { + get() { + return obj.wallet.getState().connected?.signer; + }, + enumerable: true, + configurable: true, + }); -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), - }; + 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`. +The store is a plain object with state management -- no external dependencies. It follows the same subscribe/getState 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; + 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; }; ``` @@ -379,575 +374,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 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()); + const isBrowser = typeof window !== 'undefined'; + + let state: WalletStoreState = { + wallets: [], + connectedWallet: null, + account: null, + signer: null, + status: 'pending', + }; + + let snapshot: WalletState = 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); } - 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, - }); - } - - // -- 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: () => {}, - }; - } - - // -- 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 -- + listeners.forEach((l) => l()); + } + + function deriveSnapshot(s: WalletStoreState): WalletState { + 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, + }); + } - const registry = getWallets(); + // -- SSR: skip all browser-only initialization -- - 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; + if (!isBrowser) { + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: () => snapshot, + 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; } + } - function buildWalletList(): readonly UiWallet[] { - return Object.freeze( - registry - .get() - .filter(filterWallet) - .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), - ); - } + // -- Wallet discovery -- - setState({ wallets: buildWalletList() }); + const registry = getWallets(); - 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 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; + } - // -- Connection lifecycle -- + function buildWalletList(): readonly UiWallet[] { + return Object.freeze( + registry.get() + .filter(filterWallet) + .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), + ); + } - async function connect(uiWallet: UiWallet): Promise { - userHasSelected = true; - reconnectCleanup?.(); - reconnectCleanup = null; - setState({ status: 'connecting' }); + setState({ wallets: buildWalletList() }); - 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]; + 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); + } - await connectFeature.connect(); + setState(updates); + }); - // 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; + // -- Connection lifecycle -- - if (allAccounts.length === 0) { - setState({ status: 'disconnected' }); - return allAccounts; - } + async function connect(uiWallet: UiWallet): Promise { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; + setState({ status: 'connecting' }); - // 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]; + try { + const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as + StandardConnectFeature['standard:connect']; - const signer = tryCreateSigner(activeAccount); + // Snapshot existing accounts before connect — the wallet may + // already have some accounts visible. + const existingAccounts = [...uiWallet.accounts]; - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(uiWallet); + await connectFeature.connect(); - setState({ - connectedWallet: uiWallet, - account: activeAccount, - signer, - status: 'connected', - }); + // 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; - persistAccount(activeAccount); - return allAccounts; - } catch (error) { - setState({ status: 'disconnected' }); - throw error; - } + 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', + }); - 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); - } + 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', + }); - /** - * 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); + } - storage?.removeItem(storageKey); + function selectAccount(account: UiWalletAccount): void { + if (!state.connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'selectAccount', + }); } - - 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); + 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', + }); } - - // -- 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]; + 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', + }); } - // -- 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 signInFeature = getWalletFeature(targetWallet, 'solana:signIn') as + SolanaSignInFeature['solana:signIn']; + const [result] = await signInFeature.signIn(input ? [input] : [{}]); - const account = result.account; // UiWalletAccount from the sign-in response - const signer = tryCreateSigner(account); + // 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; - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(targetWallet); + const account = result.account; // UiWalletAccount from the sign-in response + const signer = tryCreateSigner(account); - setState({ - connectedWallet: targetWallet, - account, - signer, - status: 'connected', - }); + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(targetWallet); - persistAccount(account); - } + setState({ + connectedWallet: targetWallet, + account, + signer, + status: 'connected', + }); - return result; + persistAccount(account); } - // -- 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); - } - }); - } + return result; + } - function handleAccountsChanged(uiWallet: UiWallet): void { - const newAccounts = uiWallet.accounts; + // -- Wallet-initiated events -- - if (newAccounts.length === 0) { - disconnectLocally(); - return; - } + 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 currentAddress = state.account?.address; - const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; - const activeAccount = stillPresent ?? newAccounts[0]; + function handleAccountsChanged(uiWallet: UiWallet): void { + const newAccounts = uiWallet.accounts; - const signer = tryCreateSigner(activeAccount); - setState({ account: activeAccount, signer }); - persistAccount(activeAccount); + if (newAccounts.length === 0) { + disconnectLocally(); + return; } - 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 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 }); - } + 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; - // -- 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' + if (!savedKey) { setState({ status: 'disconnected' }); - } + return; + } - async function attemptSilentReconnect(savedAccountKey: string, uiWallet: UiWallet): Promise { + 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' }); - 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 { + const statusTimeout = setTimeout(() => { + if (!userHasSelected && state.status === 'reconnecting') { setState({ status: 'disconnected' }); - storage?.removeItem(storageKey); - } - } + } + }, 3000); - // -- Persistence -- + 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' }); + } + }); - function persistAccount(account: UiWalletAccount): void { - storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); + 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); } + } - // -- Public store API -- + // -- Persistence -- - 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(); - }, - }; + function persistAccount(account: UiWalletAccount): void { + storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); + } + + // -- Public API (exposed as client.wallet) -- + + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: () => snapshot, + connect, + disconnect, + selectAccount, + signMessage, + signIn, + destroy: () => { + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + reconnectCleanup?.(); + reconnectCleanup = null; + listeners.clear(); + }, + }; } ``` @@ -955,7 +950,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 `snapshot.connected.signer` is `null`. When using `walletAsPayer`, `client.payer` is `undefined` (no signer available). +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 `getState().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. @@ -988,11 +983,11 @@ All variants satisfy `TransactionSigner`, which is what `client.payer` expects. ```typescript Object.defineProperty(obj, 'payer', { - get() { - return store.getState().signer; - }, - enumerable: true, - configurable: true, + get() { + return obj.wallet.getState().connected?.signer; + }, + enumerable: true, + configurable: true, }); ``` @@ -1016,51 +1011,45 @@ No client reconstruction is needed. The client is a long-lived object. ## Subscribability Contract -All wallet state is accessed via `client.wallet.getSnapshot()`. There are no individual getters. +All wallet state is accessed via `client.wallet.getState()`. There are no individual getters. `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 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. +`getState` returns a memoized frozen object. A new reference is only created when a state field actually changes (compared via reference equality in `setState`). React's `useSyncExternalStore` uses `Object.is` to compare successive `getState` returns — same reference means no re-render, new reference means re-render. Because we control when the 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.getState); } ``` **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.getState()); + onMounted(() => { + const unsub = client.wallet.subscribe(() => { state.value = client.wallet.getState(); }); + 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.getState(), (set) => { + return client.wallet.subscribe(() => set(client.wallet.getState())); }); ``` **Solid:** - ```typescript -const [walletState, setWalletState] = createSignal(client.wallet.getSnapshot()); +const [walletState, setWalletState] = createSignal(client.wallet.getState()); onMount(() => { - onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getSnapshot()))); + onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getState()))); }); ``` @@ -1072,9 +1061,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; }; ``` @@ -1094,76 +1083,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 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; }; ``` @@ -1174,11 +1163,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" ``` @@ -1187,21 +1176,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, `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' }` | +| 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. @@ -1211,7 +1200,7 @@ 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 `getSnapshot().wallets` and manage additional connections via wallet-standard APIs. +**Single wallet connection.** One active wallet at a time. dApps needing multiple can access `getState().wallets` and manage additional connections via wallet-standard APIs. **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. @@ -1239,7 +1228,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. `connected.signer` in the snapshot is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` is `undefined`. +**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 state is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` is `undefined`. --- From 620de9f0dc68edc9ff29f97c5a0c4e8558575696 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 7 Apr 2026 15:49:53 +0000 Subject: [PATCH 05/16] Split code into store/types/wallet --- packages/kit-plugin-wallet/README.md | 144 ++++---- packages/kit-plugin-wallet/src/index.ts | 442 +---------------------- packages/kit-plugin-wallet/src/store.ts | 14 + packages/kit-plugin-wallet/src/types.ts | 297 +++++++++++++++ packages/kit-plugin-wallet/src/wallet.ts | 134 +++++++ 5 files changed, 524 insertions(+), 507 deletions(-) create mode 100644 packages/kit-plugin-wallet/src/store.ts create mode 100644 packages/kit-plugin-wallet/src/types.ts create mode 100644 packages/kit-plugin-wallet/src/wallet.ts diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 1fe389f..1a0d752 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -7,7 +7,12 @@ [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. +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. + +Two plugin functions are exported: + +- **`wallet()`** — adds a `client.wallet` namespace with state and actions, without touching `client.payer`. +- **`walletAsPayer()`** — same as `wallet()`, but also syncs the connected wallet's signer to `client.payer` via a dynamic getter. ## Installation @@ -15,84 +20,100 @@ This package provides a plugin that adds browser wallet support to your Kit clie 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 +## Quick start ```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' })); + +// Read discovered wallets from state +const { wallets } = client.wallet.getState(); + +// Connect a wallet +await client.wallet.connect(wallets[0]); + +// client.payer is now the connected wallet's signer +``` + +## `wallet` plugin + +Adds `client.wallet` without touching `client.payer`. Use this alongside the `payer()` plugin for backend signers, or when the wallet's signer is used explicitly in instructions. + +```ts import { wallet } from '@solana/kit-plugin-wallet'; const client = createEmptyClient() .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(backendKeypair)) .use(wallet({ chain: 'solana:mainnet' })); + +// client.payer is always backendKeypair +// client.wallet.getState().connected?.signer for manual use ``` -Once a wallet is connected, `client.payer` resolves to the wallet's signer and you can pass it directly to transaction instructions: +## `walletAsPayer` plugin + +Adds `client.wallet` and overrides `client.payer` with a dynamic getter. When a signing-capable wallet is connected, `client.payer` returns the wallet signer. When disconnected or read-only, `client.payer` is `undefined`. ```ts -import { getTransferSolInstruction } from '@solana-program/system'; -import { lamports } from '@solana/kit'; +import { walletAsPayer } from '@solana/kit-plugin-wallet'; -// Read registered wallets -const selectedWallet = client.wallet.wallets[0]; +const client = createEmptyClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletAsPayer({ chain: 'solana:mainnet' })); -// 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), - }), -); +// client.payer === client.wallet.getState().connected?.signer ``` -### Features +## State and actions + +All wallet state is accessed via `client.wallet.getState()`, which returns a referentially stable `WalletState` object (new reference only when something changes). -- `client.wallet.wallets` — All discovered wallets that support the configured chain. +- **`getState().wallets`** — All discovered wallets that support the configured chain. ```ts - for (const w of client.wallet.wallets) { + const { wallets } = client.wallet.getState(); + for (const w of wallets) { console.log(w.name, w.icon); } ``` -- `client.wallet.connected` — The active connection (wallet, account, and signer), or `null` when disconnected. +- **`getState().connected`** — The active connection (wallet, account, and signer), or `null` when disconnected. ```ts - const { wallet, account, signer } = client.wallet.connected ?? {}; - console.log(account?.address); + const { connected } = client.wallet.getState(); + console.log(connected?.account.address); ``` -- `client.wallet.status` — The current connection status: `'pending'`, `'disconnected'`, `'connecting'`, `'connected'`, `'disconnecting'`, or `'reconnecting'`. +- **`getState().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. +- **`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. +- **`disconnect()`** — Disconnect the active wallet. -- `client.wallet.selectAccount(account)` — Switch to a different account within an already-authorized wallet without reconnecting. +- **`selectAccount(account)`** — Switch to a different account within an already-authorized wallet without reconnecting. ```ts - client.wallet.selectAccount(selectedWallet); + client.wallet.selectAccount(otherAccount); ``` -- `client.wallet.signMessage(message)` — Sign a raw message with the connected account. +- **`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. +- **`signIn(input?)`** / **`signIn(wallet, input?)`** — Sign In With Solana (SIWS). The two-argument form connects the wallet implicitly. ```ts // Sign in with the already-connected wallet @@ -102,9 +123,9 @@ await client.sendTransaction( const output = await client.wallet.signIn(selectedWallet, { domain: window.location.host }); ``` -### Framework integration +## Framework integration -The plugin exposes `subscribe` and `getSnapshot` for binding wallet state to any UI framework. +The plugin exposes `subscribe` and `getState` for binding wallet state to any UI framework. **React** — use `useSyncExternalStore` for concurrent-mode-safe rendering: @@ -112,7 +133,7 @@ The plugin exposes `subscribe` and `getSnapshot` for binding wallet state to any import { useSyncExternalStore } from 'react'; function useWalletState() { - return useSyncExternalStore(client.wallet.subscribe, client.wallet.getSnapshot); + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); } function App() { @@ -138,10 +159,10 @@ function App() { import { onMounted, onUnmounted, shallowRef } from 'vue'; function useWalletState() { - const state = shallowRef(client.wallet.getSnapshot()); + const state = shallowRef(client.wallet.getState()); onMounted(() => { const unsub = client.wallet.subscribe(() => { - state.value = client.wallet.getSnapshot(); + state.value = client.wallet.getState(); }); onUnmounted(unsub); }); @@ -154,52 +175,41 @@ function useWalletState() { ```ts import { readable } from 'svelte/store'; -export const walletState = readable(client.wallet.getSnapshot(), set => { - return client.wallet.subscribe(() => set(client.wallet.getSnapshot())); +export const walletState = readable(client.wallet.getState(), set => { + return client.wallet.subscribe(() => set(client.wallet.getState())); }); ``` -### 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): +## Configuration ```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 + chain: 'solana:mainnet', // required + storage: sessionStorage, // default: localStorage (null to disable) + storageKey: 'my-app:wallet', // default: 'kit-wallet' + autoConnect: false, // default: true (disable silent reconnect) + filter: w => w.features.includes('solana:signAndSendTransaction'), // optional }); ``` -### SSR / server-side rendering +## 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). -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. +## SSR / server-side rendering + +Both `wallet` and `walletAsPayer` are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw `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' })); + .use(walletAsPayer({ chain: 'solana:mainnet' })); -// Server: client.wallet.status === 'pending', client.payer === serverKeypair +// Server: status === 'pending', client.payer === undefined // 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 +## Cleanup The plugin implements `[Symbol.dispose]`, so it integrates with the `using` declaration or explicit disposal: diff --git a/packages/kit-plugin-wallet/src/index.ts b/packages/kit-plugin-wallet/src/index.ts index 67824d6..cf3ebe1 100644 --- a/packages/kit-plugin-wallet/src/index.ts +++ b/packages/kit-plugin-wallet/src/index.ts @@ -1,440 +1,2 @@ -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'; - -/** - * 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 ----------------------------------------------------------- - -/** - * 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'; - -/** - * A snapshot of the wallet plugin state at a point in time. - * - * Returned by {@link WalletNamespace.getState}. 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.getState} - */ -export type WalletState = { - /** - * The active connection, or `null` when disconnected. - * - * `signer` is `null` for read-only / watch-only wallets that do not - * support any signing feature. - */ - readonly connected: { - readonly account: UiWalletAccount; - /** The signer for the active account, or `null` for read-only wallets. */ - readonly signer: WalletSigner | null; - 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} and {@link walletAsPayer} plugins. - */ -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; -}; - -/** - * The `wallet` namespace exposed on the client as `client.wallet`. - * - * 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 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 - * 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; - - /** Disconnect the active wallet. Calls `standard:disconnect` if supported. */ - disconnect: () => Promise; - - // -- State -- - /** - * Get the current wallet state. Referentially stable — a new object is - * only created when a field actually changes, so React's - * `useSyncExternalStore` skips re-renders when nothing meaningful changed. - * - * @see {@link WalletState} - */ - getState: () => WalletState; - - /** - * 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; - - /** - * Subscribe to any wallet state change. Compatible with React's - * `useSyncExternalStore` and similar framework primitives. - * - * @returns An unsubscribe function. - * - * @example - * ```ts - * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); - * ``` - */ - subscribe: (listener: () => void) => () => void; -}; - -/** - * Properties added to the client by the {@link wallet} plugin. - * - * All wallet state and actions are namespaced under `client.wallet`. - * `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} - */ -// TODO: would be moved to kit plugin-interfaces -export type ClientWithWallet = { - /** The wallet namespace — state, actions, and framework integration. */ - 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 ------------------------------------------------------------------ - -/** - * 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 WalletStore = WalletNamespace & { - [Symbol.dispose]: () => void; -}; - -// -- Store ------------------------------------------------------------------ - -function createWalletStore(_config: WalletPluginConfig): WalletStore { - throw new Error('not implemented'); -} - -// -- Plugin ----------------------------------------------------------------- - -/** - * A framework-agnostic Kit plugin that manages wallet discovery, connection - * lifecycle, and signer creation using wallet-standard. - * - * 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.** 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(planAndSendTransactions()); - * - * // client.payer is always backendKeypair (wallet plugin does not touch it) - * // client.wallet.getState().connected?.signer for manual use - * ``` - * - * @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 - * await client.wallet.connect(uiWallet); - * - * // Subscribe to state changes (React) - * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); - * ``` - * - * @see {@link walletAsPayer} - * @see {@link WalletPluginConfig} - * @see {@link ClientWithWallet} - */ -export function wallet(config: WalletPluginConfig) { - return (client: T): ClientWithWallet & Disposable & Omit => { - const store = createWalletStore(config); - - return withCleanup( - extendClient(client, { - wallet: store, - }), - () => 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 = withCleanup( - extendClient(client, { - wallet: store, - }), - () => store[Symbol.dispose](), - ); - - Object.defineProperty(obj, 'payer', { - configurable: true, - enumerable: true, - get() { - return obj.wallet.getState().connected?.signer ?? undefined; - }, - }); - - return obj as unknown as ClientWithWalletAsPayer & Disposable & Omit; - }; -} +export * from './types'; +export * from './wallet'; diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts new file mode 100644 index 0000000..958912d --- /dev/null +++ b/packages/kit-plugin-wallet/src/store.ts @@ -0,0 +1,14 @@ +import type { WalletNamespace, WalletPluginConfig } from './types'; + +// -- Internal types --------------------------------------------------------- + +export type WalletStore = WalletNamespace & { + [Symbol.dispose]: () => void; +}; + +// -- Store ------------------------------------------------------------------ + +/** @internal */ +export function createWalletStore(_config: WalletPluginConfig): WalletStore { + throw new Error('not implemented'); +} diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts new file mode 100644 index 0000000..e040074 --- /dev/null +++ b/packages/kit-plugin-wallet/src/types.ts @@ -0,0 +1,297 @@ +import type { 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 ----------------------------------------------------------- + +/** + * 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'; + +/** + * A snapshot of the wallet plugin state at a point in time. + * + * Returned by {@link WalletNamespace.getState}. 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.getState} + */ +export type WalletState = { + /** + * The active connection, or `null` when disconnected. + * + * `signer` is `null` for read-only / watch-only wallets that do not + * support any signing feature. + */ + readonly connected: { + readonly account: UiWalletAccount; + /** The signer for the active account, or `null` for read-only wallets. */ + readonly signer: WalletSigner | null; + 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} and {@link walletAsPayer} plugins. + */ +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; +}; + +/** + * The `wallet` namespace exposed on the client as `client.wallet`. + * + * 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 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 + * 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; + + /** Disconnect the active wallet. Calls `standard:disconnect` if supported. */ + disconnect: () => Promise; + + // -- State -- + /** + * Get the current wallet state. Referentially stable — a new object is + * only created when a field actually changes, so React's + * `useSyncExternalStore` skips re-renders when nothing meaningful changed. + * + * @see {@link WalletState} + */ + getState: () => WalletState; + + /** + * 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; + + /** + * Subscribe to any wallet state change. Compatible with React's + * `useSyncExternalStore` and similar framework primitives. + * + * @returns An unsubscribe function. + * + * @example + * ```ts + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); + * ``` + */ + subscribe: (listener: () => void) => () => void; +}; + +/** + * Properties added to the client by the {@link wallet} plugin. + * + * All wallet state and actions are namespaced under `client.wallet`. + * `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} + */ +// TODO: would be moved to kit plugin-interfaces +export type ClientWithWallet = { + /** The wallet namespace — state, actions, and framework integration. */ + 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 ------------------------------------------------------------------ + +/** + * 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 +// https://github.com/anza-xyz/kit/pull/1526 +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; + } +} diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts new file mode 100644 index 0000000..ebe0a91 --- /dev/null +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -0,0 +1,134 @@ +import { extendClient, withCleanup } from '@solana/kit'; + +import { createWalletStore } from './store'; +import type { ClientWithWallet, ClientWithWalletAsPayer, WalletPluginConfig } from './types'; + +/** + * A framework-agnostic Kit plugin that manages wallet discovery, connection + * lifecycle, and signer creation using wallet-standard. + * + * 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.** 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(planAndSendTransactions()); + * + * // client.payer is always backendKeypair (wallet plugin does not touch it) + * // client.wallet.getState().connected?.signer for manual use + * ``` + * + * @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 + * await client.wallet.connect(uiWallet); + * + * // Subscribe to state changes (React) + * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); + * ``` + * + * @see {@link walletAsPayer} + * @see {@link WalletPluginConfig} + * @see {@link ClientWithWallet} + */ +export function wallet(config: WalletPluginConfig) { + return (client: T): ClientWithWallet & Disposable & Omit => { + const store = createWalletStore(config); + + return withCleanup( + extendClient(client, { + wallet: store, + }), + () => 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 = withCleanup( + extendClient(client, { + wallet: store, + }), + () => store[Symbol.dispose](), + ); + + Object.defineProperty(obj, 'payer', { + configurable: true, + enumerable: true, + get() { + return obj.wallet.getState().connected?.signer ?? undefined; + }, + }); + + return obj as unknown as ClientWithWalletAsPayer & Disposable & Omit; + }; +} From a27fde0aecf810dd32bef6c96d11e9b9cea8ab0b Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 10 Apr 2026 09:32:45 +0000 Subject: [PATCH 06/16] Minor tweaks --- packages/kit-plugin-wallet/README.md | 2 +- packages/kit-plugin-wallet/package.json | 1 + packages/kit-plugin-wallet/src/types.ts | 2 +- packages/kit-plugin-wallet/src/wallet.ts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 1a0d752..6ab0f6d 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -104,7 +104,7 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref - **`selectAccount(account)`** — Switch to a different account within an already-authorized wallet without reconnecting. ```ts - client.wallet.selectAccount(otherAccount); + client.wallet.selectAccount(accounts[0]); ``` - **`signMessage(message)`** — Sign a raw message with the connected account. diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index 03be17e..b79963b 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -34,6 +34,7 @@ "kit", "plugin", "wallet", + "wallet-adapter", "wallet-standard", "signer" ], diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index e040074..34225a3 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -146,7 +146,7 @@ export type WalletPluginConfig = { /** * The `wallet` namespace exposed on the client as `client.wallet`. * - * All wallet state is accessed via {@link getSnapshot}. Use {@link subscribe} + * All wallet state is accessed via {@link getState}. Use {@link subscribe} * to be notified of changes and integrate with framework primitives such as * React's `useSyncExternalStore`. * diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index ebe0a91..581d939 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -125,6 +125,7 @@ export function walletAsPayer(config: WalletPluginConfig) { configurable: true, enumerable: true, get() { + // map null signer -> undefined payer, to match `client.payer` type return obj.wallet.getState().connected?.signer ?? undefined; }, }); From 77caaec4ba13baeecc97597efce20201d74d09ac Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 10 Apr 2026 12:44:24 +0000 Subject: [PATCH 07/16] Update spec to address issues found when implementing --- packages/kit-plugin-wallet/src/types.ts | 33 ++- packages/kit-plugin-wallet/src/wallet.ts | 20 +- wallet-plugin-spec.md | 266 +++++++++++------------ 3 files changed, 148 insertions(+), 171 deletions(-) diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 34225a3..07790df 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -187,34 +187,31 @@ export type WalletNamespace = { selectAccount: (account: UiWalletAccount) => void; /** - * Sign In With Solana. + * Sign In With Solana (SIWS-as-connect). * - * **Overload 1** — sign in with the already-connected wallet: - * ```ts - * const output = await client.wallet.signIn({ domain: window.location.host }); - * ``` + * Connects the wallet, calls `solana:signIn`, sets the returned account as + * active, and creates a signer. After completion, the client is in the same + * state as if {@link connect} had been called. * - * **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 }); - * ``` + * All fields on `SolanaSignInInput` are optional — pass `{}` if no sign-in + * customization is needed. + * + * To sign in with the already-connected wallet, pass + * `getState().connected.wallet`. * - * @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; - }; + signIn: (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). + * Calls the wallet's `solana:signMessage` feature directly (does not go + * through the cached signer), so message signing works even for wallets + * that don't support transaction signing. + * + * @throws {@link WalletNotConnectedError} if no wallet is connected. * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signMessage`. */ diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 581d939..dce49cb 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -114,22 +114,24 @@ export function walletAsPayer(config: WalletPluginConfig) { return (client: T): ClientWithWalletAsPayer & Disposable & Omit => { const store = createWalletStore(config); - const obj = withCleanup( - extendClient(client, { - wallet: store, - }), - () => store[Symbol.dispose](), - ); + // Build an additions object with a dynamic payer getter. The getter + // must be part of the additions passed to extendClient (not defined + // after the fact) because extendClient freezes the result. + const additions = { + wallet: store, + }; - Object.defineProperty(obj, 'payer', { + Object.defineProperty(additions, 'payer', { configurable: true, enumerable: true, get() { // map null signer -> undefined payer, to match `client.payer` type - return obj.wallet.getState().connected?.signer ?? undefined; + return store.getState().connected?.signer ?? undefined; }, }); - return obj as unknown as ClientWithWalletAsPayer & Disposable & Omit; + return withCleanup(extendClient(client, additions), () => + store[Symbol.dispose](), + ) as unknown as ClientWithWalletAsPayer & Disposable & Omit; }; } diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index 07db052..de76273 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -7,7 +7,7 @@ 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. +- **Plugin lifecycle utilities** (`extendClient`, `withCleanup`) in `@solana/kit` (re-exported from `@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 @@ -20,6 +20,7 @@ This spec builds on two changes that must land first: "dependencies": { "@solana/wallet-account-signer": "^1.x", "@wallet-standard/app": "^1.x", + "@wallet-standard/features": "^1.x", "@wallet-standard/ui": "^1.x", "@wallet-standard/ui-features": "^1.x", "@wallet-standard/ui-registry": "^1.x" @@ -186,24 +187,25 @@ type ClientWithWallet = { * 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. + * Calls the wallet's solana:signMessage feature directly + * (does not go through the cached signer). */ signMessage: (message: Uint8Array) => Promise; /** - * Sign In With Solana. + * Sign In With Solana (SIWS-as-connect). * - * 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 + * Connects the wallet, calls solana:signIn, sets the returned + * account as active, and creates a signer. After completion, the * client is in the same state as if connect() had been called. + * + * All fields on SolanaSignInInput are optional — pass {} if no + * sign-in customization is needed. + * + * To sign in with the already-connected wallet, pass + * getState().connected.wallet. */ - signIn(input?: SolanaSignInInput): Promise; - signIn(wallet: UiWallet, input?: SolanaSignInInput): Promise; + signIn(wallet: UiWallet, input: SolanaSignInInput): Promise; }; }; @@ -305,7 +307,6 @@ 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 { @@ -331,20 +332,23 @@ export function walletAsPayer(config: WalletPluginConfig) { return (client: T) => { const store = createWalletStore(config); - const obj = extendClient(client, { + // Build an additions object with a dynamic payer getter. + // The getter must be part of the additions passed to extendClient + // (not defined after the fact) because extendClient freezes the result. + const additions = { wallet: store, ...withCleanup(client, () => store.destroy()), - }); + }; - Object.defineProperty(obj, 'payer', { + Object.defineProperty(additions, 'payer', { get() { - return obj.wallet.getState().connected?.signer; + return store.getState().connected?.signer ?? undefined; }, enumerable: true, configurable: true, }); - return obj; + return extendClient(client, additions); }; } ``` @@ -374,7 +378,8 @@ type WalletStoreState = { ```typescript function createWalletStore(config: WalletPluginConfig) { - const isBrowser = typeof window !== 'undefined'; + // __BROWSER__ is a compile-time constant replaced by the build system. + // Tree-shaking removes the server/browser branches from each build target. let state: WalletStoreState = { wallets: [], @@ -393,12 +398,12 @@ function createWalletStore(config: WalletPluginConfig) { // 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 + // Resolve storage: default to localStorage in browser, null to disable. + // On the server (__BROWSER__ === false), this code is unreachable — + // the SSR guard returns early before we get here. + const storage = config.storage === null ? null - : config.storage === null - ? null - : config.storage ?? localStorage; + : config.storage ?? localStorage; const storageKey = config.storageKey ?? 'kit-wallet'; // -- State management -- @@ -438,9 +443,9 @@ function createWalletStore(config: WalletPluginConfig) { }); } - // -- SSR: skip all browser-only initialization -- + // -- SSR guard: on the server, return an inert stub -- - if (!isBrowser) { + if (!__BROWSER__) { return { subscribe: (listener: () => void) => { listeners.add(listener); @@ -451,7 +456,7 @@ function createWalletStore(config: WalletPluginConfig) { 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' }); }, + signIn: () => { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); }, // wallet arg ignored on server destroy: () => {}, }; } @@ -538,10 +543,11 @@ function createWalletStore(config: WalletPluginConfig) { 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; + // Refresh UiWallet to get updated accounts after connect. + // UiWallet handles are immutable snapshots — the pre-connect + // handle won't reflect newly authorized accounts. + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; if (allAccounts.length === 0) { setState({ status: 'disconnected' }); @@ -558,16 +564,16 @@ function createWalletStore(config: WalletPluginConfig) { const signer = tryCreateSigner(activeAccount); walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(uiWallet); + walletEventsCleanup = subscribeToWalletEvents(refreshedWallet); setState({ - connectedWallet: uiWallet, + connectedWallet: refreshedWallet, account: activeAccount, signer, status: 'connected', }); - persistAccount(activeAccount); + persistAccount(activeAccount, refreshedWallet); return allAccounts; } catch (error) { setState({ status: 'disconnected' }); @@ -576,6 +582,8 @@ function createWalletStore(config: WalletPluginConfig) { } async function disconnect(): Promise { + if (!state.connectedWallet) return; + const currentWallet = state.connectedWallet; setState({ status: 'disconnecting' }); @@ -634,82 +642,68 @@ function createWalletStore(config: WalletPluginConfig) { userHasSelected = true; const signer = tryCreateSigner(account); setState({ account, signer }); - persistAccount(account); + persistAccount(account, state.connectedWallet!); } // -- Message signing -- async function signMessage(message: Uint8Array): Promise { - const { signer, connectedWallet } = state; - if (!signer || !connectedWallet) { + const { connectedWallet, account } = state; + if (!connectedWallet || !account) { 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]; + // Use the wallet feature directly rather than going through the cached + // signer. This decouples message signing from transaction signing — + // a wallet that supports solana:signMessage but not transaction signing + // still works. getWalletFeature throws WalletStandardError if the + // feature is not supported. + const signMessageFeature = getWalletFeature(connectedWallet, 'solana:signMessage') as + SolanaSignMessageFeature['solana:signMessage']; + const [output] = await signMessageFeature.signMessage({ account, message }); + return output.signature; } - // -- Sign In With Solana -- + // -- Sign In With Solana (SIWS-as-connect) -- - 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', - }); - } + async function signIn(wallet: UiWallet, input: SolanaSignInInput): Promise { + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; - const signInFeature = getWalletFeature(targetWallet, 'solana:signIn') as + const signInFeature = getWalletFeature(wallet, 'solana:signIn') as SolanaSignInFeature['solana:signIn']; - const [result] = await signInFeature.signIn(input ? [input] : [{}]); + const [result] = await signInFeature.signIn(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); + // Set up full connection state using the account from the sign-in response. + const account = result.account; + const signer = tryCreateSigner(account); - setState({ - connectedWallet: targetWallet, - account, - signer, - status: 'connected', - }); + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(wallet); - persistAccount(account); - } + setState({ + connectedWallet: wallet, + account, + signer, + status: 'connected', + }); + persistAccount(account, wallet); return result; } // -- Wallet-initiated events -- + // UiWallet handles are immutable snapshots. After a connect or change + // event the handle may be stale. Refresh by round-tripping through the + // underlying raw wallet to get the latest UiWallet. + function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { + const rawWallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(staleUiWallet); + return getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(rawWallet); + } + function subscribeToWalletEvents(uiWallet: UiWallet): () => void { if (!uiWallet.features.includes('standard:events')) { return () => {}; @@ -732,7 +726,8 @@ function createWalletStore(config: WalletPluginConfig) { } function handleAccountsChanged(uiWallet: UiWallet): void { - const newAccounts = uiWallet.accounts; + const refreshed = refreshUiWallet(uiWallet); + const newAccounts = refreshed.accounts; if (newAccounts.length === 0) { disconnectLocally(); @@ -746,12 +741,14 @@ function createWalletStore(config: WalletPluginConfig) { const activeAccount = stillPresent ?? newAccounts[0]; const signer = tryCreateSigner(activeAccount); - setState({ account: activeAccount, signer }); - persistAccount(activeAccount); + setState({ account: activeAccount, connectedWallet: refreshed, signer }); + persistAccount(activeAccount, refreshed); } function handleChainsChanged(uiWallet: UiWallet): void { - if (!uiWallet.chains.includes(config.chain)) { + const refreshed = refreshUiWallet(uiWallet); + + if (!refreshed.chains.includes(config.chain)) { disconnectLocally(); return; } @@ -759,23 +756,27 @@ function createWalletStore(config: WalletPluginConfig) { // signer in case chain-related capabilities changed. if (state.account) { const signer = tryCreateSigner(state.account); - setState({ signer }); + setState({ connectedWallet: refreshed, signer }); } } function handleFeaturesChanged(uiWallet: UiWallet): void { + const refreshed = refreshUiWallet(uiWallet); + // Re-run the filter — if the wallet no longer passes, disconnect. - if (config.filter && !config.filter(uiWallet)) { + if (config.filter && !config.filter(refreshed)) { 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. + // to pick up new capabilities or drop removed ones. if (state.account) { const signer = tryCreateSigner(state.account); - setState({ signer }); + setState({ connectedWallet: refreshed, signer }); } + + // Rebuild wallet list so other wallets reflect feature changes too. + setState({ wallets: buildWalletList() }); } // -- Auto-connect -- @@ -863,7 +864,12 @@ function createWalletStore(config: WalletPluginConfig) { unsubRegisterForReconnect(); }; } - })(); + })().catch(() => { + // Storage read failed — fall back to disconnected. + if (!userHasSelected) { + setState({ status: 'disconnected' }); + } + }); } else { // No auto-connect: immediately transition from 'pending' to 'disconnected' setState({ status: 'disconnected' }); @@ -880,8 +886,8 @@ function createWalletStore(config: WalletPluginConfig) { StandardConnectFeature['standard:connect']; await connectFeature.connect({ silent: true }); - // Read accounts from uiWallet.accounts after connect. - const allAccounts = uiWallet.accounts; + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; if (allAccounts.length === 0) { setState({ status: 'disconnected' }); @@ -900,10 +906,10 @@ function createWalletStore(config: WalletPluginConfig) { const signer = tryCreateSigner(activeAccount); walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(uiWallet); + walletEventsCleanup = subscribeToWalletEvents(refreshedWallet); setState({ - connectedWallet: uiWallet, + connectedWallet: refreshedWallet, account: activeAccount, signer, status: 'connected', @@ -916,8 +922,8 @@ function createWalletStore(config: WalletPluginConfig) { // -- Persistence -- - function persistAccount(account: UiWalletAccount): void { - storage?.setItem(storageKey, `${state.connectedWallet!.name}:${account.address}`); + function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { + storage?.setItem(storageKey, `${wallet.name}:${account.address}`); } // -- Public API (exposed as client.wallet) -- @@ -973,22 +979,24 @@ The bridge function (`createSignerFromWalletAccount`) inspects the wallet's feat - `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. +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 uses the wallet's `solana:signMessage` feature directly rather than going through the cached signer, so message signing works even for wallets that don't support transaction signing. ## Payer Integration Detail ### How the getter works -`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: +`walletAsPayer` defines a dynamic getter on the additions object passed to `extendClient`. The getter returns the current wallet signer, or `undefined` when disconnected or read-only. It must be defined on the additions object (not on the result) because `extendClient` freezes the returned client: ```typescript -Object.defineProperty(obj, 'payer', { +Object.defineProperty(additions, 'payer', { get() { - return obj.wallet.getState().connected?.signer; + return store.getState().connected?.signer ?? undefined; }, enumerable: true, configurable: true, }); + +return extendClient(client, additions); ``` This getter is preserved through subsequent `.use()` calls because: @@ -1160,18 +1168,16 @@ type WalletPluginConfig = { ### Error codes -Two new error codes added to `@solana/errors`: +One new error code 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" ``` +Feature-not-supported errors are delegated to `getWalletFeature` from `@wallet-standard/ui-features`, which throws a `WalletStandardError` when the requested feature is not present on the wallet. This avoids duplicating error handling that wallet-standard already provides. + Wallet-originated errors (e.g. user rejecting a connection prompt) are propagated unchanged. ### Error behavior @@ -1188,9 +1194,8 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate | `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' }` | +| `signMessage` on wallet without feature | `getWalletFeature` throws `WalletStandardError` | +| `signIn` on wallet without feature | `getWalletFeature` throws `WalletStandardError` | `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. @@ -1218,7 +1223,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **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. +**SIWS-as-connect.** `signIn` always takes a `UiWallet` argument and establishes full connection state using the account returned in the sign-in response — one step instead of `connect()` then separate auth. To sign in with the already-connected wallet, callers pass `getState().connected.wallet`. This avoids overload discrimination logic and keeps the API surface simple. **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. @@ -1226,34 +1231,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **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`. +**`userHasSelected` flag.** Tracks whether the user has made an explicit choice (via `connect`, `selectAccount`, or `signIn`). 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. `connected.signer` in the state is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` is `undefined`. ---- - -## 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 121ff62fa031cf9998a3d98c6e8a198b40d70e24 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 07:52:26 +0000 Subject: [PATCH 08/16] Use WalletNotConnectedError from Kit --- packages/kit-plugin-wallet/README.md | 2 +- packages/kit-plugin-wallet/src/types.ts | 34 ++---------------------- packages/kit-plugin-wallet/src/wallet.ts | 2 +- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 6ab0f6d..67ea27b 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -198,7 +198,7 @@ By default the plugin uses `localStorage` to remember the last connected wallet ## SSR / server-side rendering -Both `wallet` and `walletAsPayer` are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw `WalletNotConnectedError`, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. +Both `wallet` and `walletAsPayer` are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw a `SolanaError` with code `SOLANA_ERROR__WALLET__NOT_CONNECTED`, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. ```ts const client = createEmptyClient() diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 07790df..340c74a 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -182,7 +182,7 @@ export type WalletNamespace = { * 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. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is connected. */ selectAccount: (account: UiWalletAccount) => void; @@ -211,7 +211,7 @@ export type WalletNamespace = { * through the cached signer), so message signing works even for wallets * that don't support transaction signing. * - * @throws {@link WalletNotConnectedError} if no wallet is connected. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is connected. * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signMessage`. */ @@ -262,33 +262,3 @@ export type ClientWithWalletAsPayer = ClientWithWallet & { /** The connected wallet signer, or `undefined` when disconnected / read-only. */ readonly payer: TransactionSigner | undefined; }; - -// -- 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 -// https://github.com/anza-xyz/kit/pull/1526 -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; - } -} diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index dce49cb..43575ea 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -15,7 +15,7 @@ import type { ClientWithWallet, ClientWithWalletAsPayer, WalletPluginConfig } fr * * **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 + * `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)`, and no registry listeners or storage reads * are made. * * ```ts From 934c3b0afd4e133012c07eb588c87a43507e5637 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 09:56:14 +0000 Subject: [PATCH 09/16] Refactor so `walletAsPayer` returns a `ClientWithPayer` and throws if payer is not present --- packages/kit-plugin-wallet/src/types.ts | 16 ------- packages/kit-plugin-wallet/src/wallet.ts | 10 ++-- wallet-plugin-spec.md | 59 +++++++++++++++--------- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 340c74a..32a45d9 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -246,19 +246,3 @@ export type ClientWithWallet = { /** The wallet namespace — state, actions, and framework integration. */ 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; -}; diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 43575ea..71c9965 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -1,7 +1,7 @@ -import { extendClient, withCleanup } from '@solana/kit'; +import { type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; import { createWalletStore } from './store'; -import type { ClientWithWallet, ClientWithWalletAsPayer, WalletPluginConfig } from './types'; +import type { ClientWithWallet, WalletPluginConfig } from './types'; /** * A framework-agnostic Kit plugin that manages wallet discovery, connection @@ -108,10 +108,10 @@ export function wallet(config: WalletPluginConfig) { * * @see {@link wallet} * @see {@link WalletPluginConfig} - * @see {@link ClientWithWalletAsPayer} + * @see {@link ClientWithWallet & ClientWithPayer} */ export function walletAsPayer(config: WalletPluginConfig) { - return (client: T): ClientWithWalletAsPayer & Disposable & Omit => { + return (client: T): ClientWithWallet & ClientWithPayer & Disposable & Omit => { const store = createWalletStore(config); // Build an additions object with a dynamic payer getter. The getter @@ -132,6 +132,6 @@ export function walletAsPayer(config: WalletPluginConfig) { return withCleanup(extendClient(client, additions), () => store[Symbol.dispose](), - ) as unknown as ClientWithWalletAsPayer & Disposable & Omit; + ) as unknown as ClientWithWallet & ClientWithPayer & Disposable & Omit; }; } diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index de76273..0d31030 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -32,7 +32,7 @@ The bridge function (`createSignerFromWalletAccount`) is consumed via `@solana/w ## 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. +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, throwing when no signing 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.** 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: @@ -45,7 +45,7 @@ const client = createEmptyClient() .use(systemProgram()) .use(planAndSendTransactions()); -// Server: status === 'pending', client.payer === undefined +// Server: status === 'pending', client.payer throws // Browser: auto-connect fires, client.payer becomes wallet signer ``` @@ -97,7 +97,7 @@ The package exports two plugin functions that differ in how they interact with ` **`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`. +**`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, throws `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED`. When connected but read-only, throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`. ```typescript import { walletAsPayer } from '@solana/kit-plugin-wallet'; @@ -108,9 +108,9 @@ const client = createEmptyClient() .use(walletAsPayer({ chain: 'solana:mainnet' })) .use(planAndSendTransactions()); -// No wallet connected -> client.payer is undefined +// No wallet connected -> client.payer throws SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED // Wallet connected -> client.payer returns wallet signer -// Wallet disconnects -> client.payer is undefined +// Wallet disconnects -> client.payer throws SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED ``` `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. @@ -129,7 +129,7 @@ const client = createEmptyClient() .use(rpc('https://api.mainnet-beta.solana.com')) .use(walletAsPayer({ chain: 'solana:mainnet' })) .use(planAndSendTransactions()); -// client.payer is TransactionSigner | undefined +// client.payer is TransactionSigner (throws if not connected) // Wallet alongside a static payer const client = createEmptyClient() @@ -210,19 +210,17 @@ type ClientWithWallet = { }; /** - * Extends ClientWithWallet with a dynamic payer getter. - * client.payer returns the wallet signer when connected, - * or undefined when disconnected / read-only. + * walletAsPayer returns ClientWithWallet & ClientWithPayer. + * The payer getter is dynamic — returns the wallet signer when connected, + * throws SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED when disconnected, + * or SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE when 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; + (client: T) => T & ClientWithWallet & ClientWithPayer; type WalletStatus = | 'pending' // not yet initialized (SSR, or browser before first storage/registry check) @@ -307,6 +305,8 @@ import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; import { SolanaError, SOLANA_ERROR__WALLET__NOT_CONNECTED, + SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, + SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE, } from '@solana/errors'; import { getWallets } from '@wallet-standard/app'; import { @@ -956,7 +956,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 `getState().connected.signer` is `null`. When using `walletAsPayer`, `client.payer` is `undefined` (no signer available). +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 `getState().connected.signer` is `null`. When using `walletAsPayer`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`. This ensures referential stability, which matters for React's dependency arrays and avoids redundant codec/wrapper creation. @@ -985,12 +985,19 @@ All variants satisfy `TransactionSigner`, which is what `client.payer` expects. ### How the getter works -`walletAsPayer` defines a dynamic getter on the additions object passed to `extendClient`. The getter returns the current wallet signer, or `undefined` when disconnected or read-only. It must be defined on the additions object (not on the result) because `extendClient` freezes the returned client: +`walletAsPayer` defines a dynamic getter on the additions object passed to `extendClient`. The getter returns the current wallet signer, or throws when disconnected or read-only. It must be defined on the additions object (not on the result) because `extendClient` freezes the returned client: ```typescript Object.defineProperty(additions, 'payer', { get() { - return store.getState().connected?.signer ?? undefined; + const state = store.getState(); + if (!state.connected) { + throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); + } + if (!state.connected.signer) { + throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); + } + return state.connected.signer; }, enumerable: true, configurable: true, @@ -1013,7 +1020,7 @@ The `planAndSendTransactions` plugin accesses `client.payer` at transaction time - 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 +- User disconnects → `client.payer` throws `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED` No client reconstruction is needed. The client is a long-lived object. @@ -1168,14 +1175,24 @@ type WalletPluginConfig = { ### Error codes -One new error code added to `@solana/errors`: +Three error codes from `@solana/errors`: ```typescript SOLANA_ERROR__WALLET__NOT_CONNECTED // context: { operation: string } // message: "Cannot $operation: no wallet connected" + +SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED +// context: { status: string } +// message: "No signing wallet connected (status: $status)" + +SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE +// context: {} +// message: "Connected wallet does not support signing" ``` +`NOT_CONNECTED` is thrown by wallet actions (signMessage, selectAccount, etc.) when no wallet is connected. `NO_SIGNER_CONNECTED` is thrown by the `payer` getter when no wallet is connected. `SIGNER_NOT_AVAILABLE` is thrown by the `payer` getter when a wallet is connected but is read-only (no signing support). + Feature-not-supported errors are delegated to `getWalletFeature` from `@wallet-standard/ui-features`, which throws a `WalletStandardError` when the requested feature is not present on the wallet. This avoids duplicating error handling that wallet-standard already provides. Wallet-originated errors (e.g. user rejecting a connection prompt) are propagated unchanged. @@ -1186,7 +1203,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate |----------|----------| | 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 support signing | Connection succeeds, `connected.signer` is `null`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`, 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 | @@ -1207,7 +1224,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **Single wallet connection.** One active wallet at a time. dApps needing multiple can access `getState().wallets` and manage additional connections via wallet-standard APIs. -**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. +**SSR-safe.** Both `wallet` and `walletAsPayer` gracefully degrade on the server — status stays `'pending'`, wallet list is empty, `client.payer` throws `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED` (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. @@ -1233,5 +1250,5 @@ 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`). 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. `connected.signer` in the state is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` is `undefined`. +**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 state is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`. From e3c5be07a2364dbd99386dc6592e697e814a1399 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 10:18:49 +0000 Subject: [PATCH 10/16] Add walletIdentity and walletSigner --- packages/kit-plugin-wallet/src/wallet.ts | 220 ++++++++++++++--------- wallet-plugin-spec.md | 175 +++++++++--------- 2 files changed, 221 insertions(+), 174 deletions(-) diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 71c9965..05f7075 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -1,137 +1,179 @@ -import { type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; +import { type ClientWithIdentity, type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; import { createWalletStore } from './store'; import type { ClientWithWallet, WalletPluginConfig } from './types'; +// -- Internal helpers --------------------------------------------------------- + +function defineSignerGetter( + additions: Record, + property: string, + store: ReturnType, +): void { + Object.defineProperty(additions, property, { + configurable: true, + enumerable: true, + get() { + const state = store.getState(); + if (!state.connected) { + // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); + throw new Error(`No signing wallet connected (status: ${state.status})`); + } + if (!state.connected.signer) { + // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); + throw new Error('Connected wallet does not support signing'); + } + return state.connected.signer; + }, + }); +} + +function createPlugin(config: WalletPluginConfig, signerProperties: string[]) { + return ( + client: T, + ): Disposable & Omit & TAdditions => { + if ('wallet' in client) { + throw new Error( + 'Only one wallet plugin can be used per client. ' + + 'Use walletSigner, walletPayer, walletIdentity, or walletWithoutSigner — not multiple.', + ); + } + + const store = createWalletStore(config); + + const additions: Record = { wallet: store }; + for (const prop of signerProperties) { + defineSignerGetter(additions, prop, store); + } + + return withCleanup(extendClient(client, additions), () => store[Symbol.dispose]()) as unknown as Disposable & + Omit & + TAdditions; + }; +} + +// -- Public API --------------------------------------------------------------- + /** * A framework-agnostic Kit plugin that manages wallet discovery, connection - * lifecycle, and signer creation using wallet-standard. + * lifecycle, and signer creation using wallet-standard — and syncs the + * connected wallet's signer to both `client.payer` and `client.identity`. * - * 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. + * This is the most common entrypoint for dApps. When a signing-capable + * wallet is connected, `client.payer` and `client.identity` both return the + * wallet signer. When disconnected or read-only, accessing either throws. * * **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 - * `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)`, and no registry listeners or storage reads - * are made. + * server and browser. * * ```ts - * const client = createEmptyClient() + * const client = createClient() * .use(rpc('https://api.mainnet-beta.solana.com')) - * .use(payer(backendKeypair)) - * .use(wallet({ chain: 'solana:mainnet' })) + * .use(walletSigner({ chain: 'solana:mainnet' })) * .use(planAndSendTransactions()); - * - * // client.payer is always backendKeypair (wallet plugin does not touch it) - * // client.wallet.getState().connected?.signer for manual use * ``` * * @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'; + * @see {@link walletPayer} + * @see {@link walletIdentity} + * @see {@link walletWithoutSigner} + * @see {@link WalletPluginConfig} + */ +export function walletSigner(config: WalletPluginConfig) { + return createPlugin(config, ['payer', 'identity']); +} + +/** + * 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.identity`. * - * const client = createEmptyClient() - * .use(rpc('https://api.mainnet-beta.solana.com')) - * .use(wallet({ chain: 'solana:mainnet' })); + * Use this when `client.payer` is controlled by a separate `payer()` plugin + * (e.g. a backend relayer pays fees, but the user's wallet is the identity). * - * // Connect a wallet - * await client.wallet.connect(uiWallet); + * **SSR-safe.** Can be included in a shared client chain that runs on both + * server and browser. * - * // Subscribe to state changes (React) - * const state = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); + * ```ts + * const client = createClient() + * .use(rpc('https://api.mainnet-beta.solana.com')) + * .use(payer(relayerKeypair)) + * .use(walletIdentity({ chain: 'solana:mainnet' })) + * .use(planAndSendTransactions()); * ``` * - * @see {@link walletAsPayer} + * @param config - Plugin configuration. + * + * @see {@link walletSigner} + * @see {@link walletPayer} + * @see {@link walletWithoutSigner} * @see {@link WalletPluginConfig} - * @see {@link ClientWithWallet} */ -export function wallet(config: WalletPluginConfig) { - return (client: T): ClientWithWallet & Disposable & Omit => { - const store = createWalletStore(config); - - return withCleanup( - extendClient(client, { - wallet: store, - }), - () => store[Symbol.dispose](), - ) as ClientWithWallet & Disposable & Omit; - }; +export function walletIdentity(config: WalletPluginConfig) { + return createPlugin(config, ['identity']); } /** * 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. + * connected wallet's signer to `client.payer`. * - * 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. + * Use this when you need the wallet as the fee payer but don't need + * `client.identity`. For most dApps, prefer {@link walletSigner} which + * sets both. * * **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. + * server and browser. * * ```ts - * const client = createEmptyClient() + * const client = createClient() * .use(rpc('https://api.mainnet-beta.solana.com')) - * .use(walletAsPayer({ chain: 'solana:mainnet' })) + * .use(walletPayer({ 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'; + * @see {@link walletSigner} + * @see {@link walletIdentity} + * @see {@link walletWithoutSigner} + * @see {@link WalletPluginConfig} + */ +export function walletPayer(config: WalletPluginConfig) { + return createPlugin(config, ['payer']); +} + +/** + * A framework-agnostic Kit plugin that manages wallet discovery, connection + * lifecycle, and signer creation using wallet-standard. * - * const client = createEmptyClient() + * Adds the `wallet` namespace to the client without setting `client.payer` + * or `client.identity`. Use this alongside separate `payer()` and/or + * `identity()` plugins, or when the wallet's signer is used explicitly in + * instructions. For most dApps, prefer {@link walletSigner} instead. + * + * **SSR-safe.** Can be included in a shared client chain that runs on both + * server and browser. + * + * ```ts + * const client = createClient() * .use(rpc('https://api.mainnet-beta.solana.com')) - * .use(walletAsPayer({ chain: 'solana:mainnet' })); + * .use(payer(backendKeypair)) + * .use(walletWithoutSigner({ chain: 'solana:mainnet' })) + * .use(planAndSendTransactions()); * - * // Connect a wallet - * await client.wallet.connect(uiWallet); - * // client.payer now returns the wallet signer + * // client.payer is always backendKeypair + * // client.wallet.getState().connected?.signer for manual use * ``` * - * @see {@link wallet} + * @param config - Plugin configuration. + * + * @see {@link walletSigner} + * @see {@link walletPayer} + * @see {@link walletIdentity} * @see {@link WalletPluginConfig} - * @see {@link ClientWithWallet & ClientWithPayer} */ -export function walletAsPayer(config: WalletPluginConfig) { - return (client: T): ClientWithWallet & ClientWithPayer & Disposable & Omit => { - const store = createWalletStore(config); - - // Build an additions object with a dynamic payer getter. The getter - // must be part of the additions passed to extendClient (not defined - // after the fact) because extendClient freezes the result. - const additions = { - wallet: store, - }; - - Object.defineProperty(additions, 'payer', { - configurable: true, - enumerable: true, - get() { - // map null signer -> undefined payer, to match `client.payer` type - return store.getState().connected?.signer ?? undefined; - }, - }); - - return withCleanup(extendClient(client, additions), () => - store[Symbol.dispose](), - ) as unknown as ClientWithWallet & ClientWithPayer & Disposable & Omit; - }; +export function walletWithoutSigner(config: WalletPluginConfig) { + return createPlugin(config, []); } diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index 0d31030..b279e71 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -32,21 +32,21 @@ The bridge function (`createSignerFromWalletAccount`) is consumed via `@solana/w ## 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, throwing when no signing 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. +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` and/or `identity` slots via dynamic getters, throwing when no signing 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.** 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: +**SSR-safe.** All four plugin functions (`walletSigner`, `walletIdentity`, `walletPayer`, `walletWithoutSigner`) can be included in a shared client chain on both server and browser. On the server (`__BROWSER__ === false`), the plugin gracefully degrades — status stays `'pending'`, wallet list is empty, signer getters throw, 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'; +import { walletSigner } from '@solana/kit-plugin-wallet'; -const client = createEmptyClient() +const client = createClient() .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(walletSigner({ chain: 'solana:mainnet' })) .use(systemProgram()) .use(planAndSendTransactions()); -// Server: status === 'pending', client.payer throws -// Browser: auto-connect fires, client.payer becomes wallet signer +// Server: status === 'pending', client.payer / client.identity throw +// Browser: auto-connect fires, client.payer / client.identity become wallet signer ``` ## Motivation @@ -91,51 +91,69 @@ This plugin fills that gap. It sits between wallet-standard's raw discovery API 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.getState` — 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` +### Relationship to `payer` and `identity` -The package exports two plugin functions that differ in how they interact with `client.payer`: +The package exports four plugin functions that differ in which signer properties they set on the client: -**`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. +**`walletSigner()`** — adds `client.wallet`, `client.payer`, and `client.identity`. The most common entrypoint for dApps. -**`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, throws `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED`. When connected but read-only, throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`. +**`walletIdentity()`** — adds `client.wallet` and `client.identity`. Use when `client.payer` is controlled by a separate `payer()` plugin (e.g. a backend relayer pays fees, but the user's wallet is the identity). + +**`walletPayer()`** — adds `client.wallet` and `client.payer`. Use when you need the wallet as the fee payer but don't need `client.identity`. + +**`walletWithoutSigner()`** — adds `client.wallet` only. Use alongside separate `payer()` and/or `identity()` plugins, or when the wallet's signer is used explicitly in instructions. + +All signer getters (`payer`, `identity`) are dynamic — they return the wallet signer when connected, throw `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED` when disconnected, or throw `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE` when the wallet is read-only. ```typescript -import { walletAsPayer } from '@solana/kit-plugin-wallet'; +import { walletSigner } from '@solana/kit-plugin-wallet'; import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan'; -const client = createEmptyClient() +const client = createClient() .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(walletSigner({ chain: 'solana:mainnet' })) .use(planAndSendTransactions()); -// No wallet connected -> client.payer throws SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED -// Wallet connected -> client.payer returns wallet signer -// Wallet disconnects -> client.payer throws SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED +// No wallet connected -> client.payer / client.identity throw +// Wallet connected -> client.payer / client.identity return wallet signer +// Wallet disconnects -> client.payer / client.identity throw ``` -`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. +The dynamic getters use `Object.defineProperty`. `addUse` preserves getter descriptors through subsequent `.use()` calls, and downstream plugins that use `extendClient` also preserve them. -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()`. +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 `walletSigner()`. ## API Surface ### Plugin creation ```typescript -import { wallet, walletAsPayer } from '@solana/kit-plugin-wallet'; +import { walletSigner, walletIdentity, walletPayer, walletWithoutSigner } from '@solana/kit-plugin-wallet'; + +// Wallet as payer and identity — most dApps +const client = createClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); -// Wallet as payer — most dApps -const client = createEmptyClient() +// Wallet as identity only — relayer pays fees +const client = createClient() .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })) + .use(payer(relayerKeypair)) + .use(walletIdentity({ chain: 'solana:mainnet' })) .use(planAndSendTransactions()); -// client.payer is TransactionSigner (throws if not connected) -// Wallet alongside a static payer -const client = createEmptyClient() +// Wallet as payer only +const client = createClient() + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletPayer({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); + +// Wallet without signer — manual signer use +const client = createClient() .use(rpc('https://api.mainnet-beta.solana.com')) .use(payer(backendKeypair)) - .use(wallet({ chain: 'solana:mainnet' })) + .use(walletWithoutSigner({ chain: 'solana:mainnet' })) .use(planAndSendTransactions()); // client.payer is TransactionSigner (from payer plugin, untouched) // client.wallet.getState().connected?.signer for manual use @@ -216,12 +234,18 @@ type ClientWithWallet = { * or SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE when read-only. */ -export function wallet(config: WalletPluginConfig): - (client: T) => T & ClientWithWallet; +export function walletSigner(config: WalletPluginConfig): + (client: T) => T & ClientWithWallet & ClientWithPayer & ClientWithIdentity; -export function walletAsPayer(config: WalletPluginConfig): +export function walletIdentity(config: WalletPluginConfig): + (client: T) => T & ClientWithWallet & ClientWithIdentity; + +export function walletPayer(config: WalletPluginConfig): (client: T) => T & ClientWithWallet & ClientWithPayer; +export function walletWithoutSigner(config: WalletPluginConfig): + (client: T) => T & ClientWithWallet; + type WalletStatus = | 'pending' // not yet initialized (SSR, or browser before first storage/registry check) | 'disconnected' // initialized, no wallet connected @@ -317,40 +341,40 @@ 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); - - return extendClient(client, { - wallet: store, - ...withCleanup(client, () => store.destroy()), - }); - }; +// Internal helper — defines a throwing signer getter on the additions object. +function defineSignerGetter(additions, property, store) { + Object.defineProperty(additions, property, { + get() { + const state = store.getState(); + if (!state.connected) { + throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); + } + if (!state.connected.signer) { + throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); + } + return state.connected.signer; + }, + enumerable: true, + configurable: true, + }); } -export function walletAsPayer(config: WalletPluginConfig) { - return (client: T) => { +// Internal helper — creates a wallet plugin with the given signer properties. +function createPlugin(config, signerProperties) { + return (client) => { const store = createWalletStore(config); - - // Build an additions object with a dynamic payer getter. - // The getter must be part of the additions passed to extendClient - // (not defined after the fact) because extendClient freezes the result. - const additions = { - wallet: store, - ...withCleanup(client, () => store.destroy()), - }; - - Object.defineProperty(additions, 'payer', { - get() { - return store.getState().connected?.signer ?? undefined; - }, - enumerable: true, - configurable: true, - }); - - return extendClient(client, additions); + const additions = { wallet: store }; + for (const prop of signerProperties) { + defineSignerGetter(additions, prop, store); + } + return withCleanup(extendClient(client, additions), () => store.destroy()); }; } + +export function walletSigner(config) { return createPlugin(config, ['payer', 'identity']); } +export function walletIdentity(config) { return createPlugin(config, ['identity']); } +export function walletPayer(config) { return createPlugin(config, ['payer']); } +export function walletWithoutSigner(config) { return createPlugin(config, []); } ``` ### Internal store @@ -981,38 +1005,19 @@ The bridge function (`createSignerFromWalletAccount`) inspects the wallet's feat 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 uses the wallet's `solana:signMessage` feature directly rather than going through the cached signer, so message signing works even for wallets that don't support transaction signing. -## Payer Integration Detail - -### How the getter works +## Signer Getter Detail -`walletAsPayer` defines a dynamic getter on the additions object passed to `extendClient`. The getter returns the current wallet signer, or throws when disconnected or read-only. It must be defined on the additions object (not on the result) because `extendClient` freezes the returned client: +### How the getters work -```typescript -Object.defineProperty(additions, 'payer', { - get() { - const state = store.getState(); - if (!state.connected) { - throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); - } - if (!state.connected.signer) { - throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); - } - return state.connected.signer; - }, - enumerable: true, - configurable: true, -}); - -return extendClient(client, additions); -``` +The `walletSigner`, `walletPayer`, and `walletIdentity` plugins define dynamic getters for `payer` and/or `identity` on the additions object passed to `extendClient`. Each getter returns the current wallet signer when connected, or throws when disconnected or read-only. Getters must be defined on the additions object (not on the result) because `extendClient` freezes the returned client. The `defineSignerGetter` helper is shared by all plugin functions. -This getter is preserved through subsequent `.use()` calls because: +These getters are 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. +Note: downstream plugins should use `extendClient` rather than spread to ensure signer getters are preserved. ### Interaction with planAndSendTransactions plugin @@ -1224,7 +1229,7 @@ Wallet-originated errors (e.g. user rejecting a connection prompt) are propagate **Single wallet connection.** One active wallet at a time. dApps needing multiple can access `getState().wallets` and manage additional connections via wallet-standard APIs. -**SSR-safe.** Both `wallet` and `walletAsPayer` gracefully degrade on the server — status stays `'pending'`, wallet list is empty, `client.payer` throws `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED` (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. +**SSR-safe.** All four plugin functions gracefully degrade on the server — status stays `'pending'`, wallet list is empty, signer getters throw `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED`, 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. @@ -1232,7 +1237,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`). -**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. +**Four plugin entrypoints.** The signer property decision is expressed at the type level, not via a config flag. `walletSigner()` sets both `payer` and `identity`, `walletIdentity()` sets only `identity`, `walletPayer()` sets only `payer`, and `walletWithoutSigner()` sets neither. 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. From 627f26d591e02ce4a21da8a6aa78bda875c7e6d3 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 15:25:17 +0000 Subject: [PATCH 11/16] Update readme for scaffold --- packages/kit-plugin-wallet/README.md | 124 ++++++++++++++++++--------- 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 67ea27b..535d3a4 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -7,12 +7,16 @@ [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. +This package provides plugins that add browser wallet support to your Kit clients using [wallet-standard](https://github.com/wallet-standard/wallet-standard). They handle wallet discovery, connection lifecycle, account selection, and signer creation. -Two plugin functions are exported: +Four plugin functions are exported — each adds a `client.wallet` namespace, but they differ in how the connected wallet's signer is exposed on the client: -- **`wallet()`** — adds a `client.wallet` namespace with state and actions, without touching `client.payer`. -- **`walletAsPayer()`** — same as `wallet()`, but also syncs the connected wallet's signer to `client.payer` via a dynamic getter. +| Plugin | `client.payer` | `client.identity` | Use case | +| --------------------- | -------------- | ----------------- | ------------------------------------------------------------- | +| `walletSigner` | wallet signer | wallet signer | Most dApps — wallet pays fees and signs as the user identity. | +| `walletPayer` | wallet signer | — | Wallet pays fees; identity is managed separately. | +| `walletIdentity` | — | wallet signer | Backend relayer pays fees; wallet provides user identity. | +| `walletWithoutSigner` | — | — | Wallet state only; payer and identity are managed separately. | ## Installation @@ -23,13 +27,15 @@ pnpm install @solana/kit-plugin-wallet ## Quick start ```ts -import { createEmptyClient } from '@solana/kit'; -import { rpc } from '@solana/kit-plugin-rpc'; -import { walletAsPayer } from '@solana/kit-plugin-wallet'; +import { createClient } from '@solana/kit'; +import { solanaRpc } from '@solana/kit-plugin-rpc'; +import { walletSigner } 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(walletAsPayer({ chain: 'solana:mainnet' })); +const client = createClient() + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(planAndSendTransactions()); // Read discovered wallets from state const { wallets } = client.wallet.getState(); @@ -37,38 +43,70 @@ const { wallets } = client.wallet.getState(); // Connect a wallet await client.wallet.connect(wallets[0]); -// client.payer is now the connected wallet's signer +// client.payer and client.identity are now the connected wallet's signer +await client.sendTransaction([myInstruction]); ``` -## `wallet` plugin +## `walletSigner` plugin -Adds `client.wallet` without touching `client.payer`. Use this alongside the `payer()` plugin for backend signers, or when the wallet's signer is used explicitly in instructions. +Syncs the connected wallet's signer to both `client.payer` and `client.identity`. This is the most common choice for dApps where the user's wallet pays fees and signs as the transaction identity. ```ts -import { wallet } from '@solana/kit-plugin-wallet'; +import { walletSigner } from '@solana/kit-plugin-wallet'; -const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(payer(backendKeypair)) - .use(wallet({ chain: 'solana:mainnet' })); +const client = createClient() + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(planAndSendTransactions()); +``` -// client.payer is always backendKeypair -// client.wallet.getState().connected?.signer for manual use +## `walletPayer` plugin + +Syncs the connected wallet's signer to `client.payer` only. Use this when you need the wallet as the fee payer but don't need `client.identity`. For most dApps, prefer `walletSigner` which sets both. + +```ts +import { walletPayer } from '@solana/kit-plugin-wallet'; + +const client = createClient() + .use(walletPayer({ chain: 'solana:mainnet' })) + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(planAndSendTransactions()); +``` + +## `walletIdentity` plugin + +Syncs the connected wallet's signer to `client.identity` only. Use this when a separate payer (e.g. a backend relayer) pays transaction fees, but the user's wallet is needed as the identity signer. + +```ts +import { payer } from '@solana/kit-plugin-signer'; +import { walletIdentity } from '@solana/kit-plugin-wallet'; + +const client = createClient() + .use(payer(relayerKeypair)) + .use(walletIdentity({ chain: 'solana:mainnet' })) + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(planAndSendTransactions()); + +// client.payer is always relayerKeypair +// client.identity is the connected wallet's signer ``` -## `walletAsPayer` plugin +## `walletWithoutSigner` plugin -Adds `client.wallet` and overrides `client.payer` with a dynamic getter. When a signing-capable wallet is connected, `client.payer` returns the wallet signer. When disconnected or read-only, `client.payer` is `undefined`. +Adds `client.wallet` without setting `client.payer` or `client.identity`. Use this alongside separate `payer()` and/or `identity()` plugins, or when the wallet's signer is used explicitly in instructions. ```ts -import { walletAsPayer } from '@solana/kit-plugin-wallet'; +import { payer } from '@solana/kit-plugin-signer'; +import { walletWithoutSigner } from '@solana/kit-plugin-wallet'; -const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })); +const client = createClient() + .use(payer(backendKeypair)) + .use(walletWithoutSigner({ chain: 'solana:mainnet' })) + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(planAndSendTransactions()); -await client.wallet.connect(selectedWallet); -// client.payer === client.wallet.getState().connected?.signer +// client.payer is always backendKeypair +// client.wallet.getState().connected?.signer for manual use ``` ## State and actions @@ -113,13 +151,9 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref const signature = await client.wallet.signMessage(new TextEncoder().encode('Hello')); ``` -- **`signIn(input?)`** / **`signIn(wallet, input?)`** — Sign In With Solana (SIWS). The two-argument form connects the wallet implicitly. +- **`signIn(wallet, input)`** — Sign In With Solana (SIWS-as-connect). Connects the wallet, calls `solana:signIn`, and sets up full connection state. Pass `{}` for `input` if no sign-in customization is needed. To sign in with the already-connected wallet, pass `getState().connected.wallet`. ```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 }); ``` @@ -183,7 +217,7 @@ export const walletState = readable(client.wallet.getState(), set => { ## Configuration ```ts -wallet({ +walletSigner({ chain: 'solana:mainnet', // required storage: sessionStorage, // default: localStorage (null to disable) storageKey: 'my-app:wallet', // default: 'kit-wallet' @@ -198,14 +232,15 @@ By default the plugin uses `localStorage` to remember the last connected wallet ## SSR / server-side rendering -Both `wallet` and `walletAsPayer` are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw a `SolanaError` with code `SOLANA_ERROR__WALLET__NOT_CONNECTED`, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. +All four wallet plugins are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. ```ts -const client = createEmptyClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletAsPayer({ chain: 'solana:mainnet' })); +const client = createClient() + .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); -// Server: status === 'pending', client.payer === undefined +// Server: status === 'pending', client.payer throws // Browser: auto-connects, client.payer becomes the wallet signer ``` @@ -215,7 +250,16 @@ The plugin implements `[Symbol.dispose]`, so it integrates with the `using` decl ```ts { - using client = createEmptyClient().use(wallet({ chain: 'solana:mainnet' })); + using client = createClient().use(walletSigner({ chain: 'solana:mainnet' })); // registry listeners and storage subscriptions are cleaned up on scope exit } ``` + +Or call `[Symbol.dispose]()` explicitly when you're done with the client: + +```ts +const client = createClient().use(walletSigner({ chain: 'solana:mainnet' })); + +// ... later, when the client is no longer needed +client[Symbol.dispose](); +``` From 901c55128570e10832ad1bdc69a82583bb7c7992 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 15 Apr 2026 18:09:33 +0000 Subject: [PATCH 12/16] Fix types of wallet plugins + add typetests --- .../src/__typetests__/wallet-typetest.ts | 95 +++++++++++++++++++ packages/kit-plugin-wallet/src/types.ts | 9 +- packages/kit-plugin-wallet/src/wallet.ts | 6 +- 3 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts diff --git a/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts b/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts new file mode 100644 index 0000000..6f7c60d --- /dev/null +++ b/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts @@ -0,0 +1,95 @@ +import { type ClientWithIdentity, type ClientWithPayer, createClient, TransactionSigner } from '@solana/kit'; + +import { ClientWithWallet } from '../types'; +import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../wallet'; + +const config = { chain: 'solana:mainnet' as const }; + +const signer = null as unknown as TransactionSigner; + +// [DESCRIBE] walletSigner +{ + // It sets payer, identity, and wallet on the client. + { + const client = createClient().use(walletSigner(config)); + client.payer satisfies ClientWithPayer['payer']; + client.identity satisfies ClientWithIdentity['identity']; + client.wallet satisfies ClientWithWallet['wallet']; + } +} + +// [DESCRIBE] walletPayer +{ + // It sets payer and wallet on the client. + { + const client = createClient().use(walletPayer(config)); + client.payer satisfies ClientWithPayer['payer']; + client.wallet satisfies ClientWithWallet['wallet']; + } + // It does not strip a previously-set identity. + { + const base = { identity: signer } as unknown as ClientWithIdentity; + const result = walletPayer(config)(base); + result.identity satisfies TransactionSigner; + } +} + +// [DESCRIBE] walletIdentity +{ + // It sets identity and wallet on the client. + { + const client = createClient().use(walletIdentity(config)); + client.identity satisfies ClientWithIdentity['identity']; + client.wallet satisfies ClientWithWallet['wallet']; + } + // It does not strip a previously-set payer. + { + const base = { payer: signer } as unknown as ClientWithPayer; + const result = walletIdentity(config)(base); + result.payer satisfies TransactionSigner; + } +} + +// [DESCRIBE] walletWithoutSigner +{ + // It sets wallet on the client. + { + const client = createClient().use(walletWithoutSigner(config)); + client.wallet satisfies ClientWithWallet['wallet']; + } + // It does not strip a previously-set payer. + { + const base = { payer: signer } as unknown as ClientWithPayer; + const result = walletWithoutSigner(config)(base); + result.payer satisfies TransactionSigner; + } + // It does not strip a previously-set identity. + { + const base = { identity: signer } as unknown as ClientWithIdentity; + const result = walletWithoutSigner(config)(base); + result.identity satisfies TransactionSigner; + } + // It does not strip a previously-set payer and identity. + { + const base = { identity: signer, payer: signer } as unknown as ClientWithIdentity & ClientWithPayer; + const result = walletWithoutSigner(config)(base); + result.payer satisfies TransactionSigner; + result.identity satisfies TransactionSigner; + } +} + +// [DESCRIBE] Only one wallet plugin allowed +{ + // It fails to typecheck when a wallet plugin is used on a client that already has wallet. + { + const client = createClient().use(walletSigner(config)); + // @ts-expect-error Cannot use a second wallet plugin. + walletSigner(config)(client); + // @ts-expect-error Cannot use a second wallet plugin. + walletPayer(config)(client); + // @ts-expect-error Cannot use a second wallet plugin. + walletIdentity(config)(client); + // @ts-expect-error Cannot use a second wallet plugin. + walletWithoutSigner(config)(client); + } +} diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 32a45d9..7de7bd5 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -86,7 +86,8 @@ export type WalletStorage = { }; /** - * Configuration for the {@link wallet} and {@link walletAsPayer} plugins. + * Configuration for the wallet plugins ({@link walletSigner}, + * {@link walletPayer}, {@link walletIdentity}, {@link walletWithoutSigner}). */ export type WalletPluginConfig = { /** @@ -232,13 +233,11 @@ export type WalletNamespace = { }; /** - * Properties added to the client by the {@link wallet} plugin. + * Properties added to the client by the wallet plugins. * * All wallet state and actions are namespaced under `client.wallet`. - * `client.payer` is not affected — use the {@link walletAsPayer} plugin to - * set the payer dynamically from the connected wallet. * - * @see {@link wallet} + * @see {@link walletSigner} * @see {@link WalletNamespace} */ // TODO: would be moved to kit plugin-interfaces diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 05f7075..817b254 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -29,9 +29,7 @@ function defineSignerGetter( } function createPlugin(config: WalletPluginConfig, signerProperties: string[]) { - return ( - client: T, - ): Disposable & Omit & TAdditions => { + return (client: T): Disposable & Omit & TAdditions => { if ('wallet' in client) { throw new Error( 'Only one wallet plugin can be used per client. ' + @@ -47,7 +45,7 @@ function createPlugin(config: WalletPluginC } return withCleanup(extendClient(client, additions), () => store[Symbol.dispose]()) as unknown as Disposable & - Omit & + Omit & TAdditions; }; } From dd70b788ca5fbd4c918f7f1e06bcee862c8cf29c Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 17 Apr 2026 10:33:00 +0000 Subject: [PATCH 13/16] Loosen the type of `chain` to allow non-solana chains This better matches wallet standard and provides an escape hatch --- packages/kit-plugin-wallet/package.json | 1 + packages/kit-plugin-wallet/src/types.ts | 14 +++++++-- pnpm-lock.yaml | 3 ++ wallet-plugin-spec.md | 41 +++++++++++++++++++------ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index b79963b..1dbf358 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -55,6 +55,7 @@ "@solana/wallet-standard-chains": "^1.1.1", "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/app": "^1.1.0", + "@wallet-standard/base": "^1.1.0", "@wallet-standard/errors": "^0.1.1", "@wallet-standard/features": "^1.1.0", "@wallet-standard/ui": "^1.0.1", diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 7de7bd5..f5e8431 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -1,6 +1,7 @@ import type { 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 { IdentifierString } from '@wallet-standard/base'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; /** @@ -101,12 +102,21 @@ export type WalletPluginConfig = { autoConnect?: boolean; /** - * The Solana chain this client targets (e.g. `'solana:mainnet'`). + * The chain this client targets (e.g. `'solana:mainnet'`). + * + * Accepts any {@link SolanaChain} (with literal autocomplete) and, as an + * escape hatch, any wallet-standard {@link IdentifierString} shape + * (`${string}:${string}`) for custom chains or non-Solana L2s. The plugin's + * runtime behavior is chain-agnostic — it passes the identifier to + * wallet-standard discovery (`uiWallet.chains.includes(chain)`) and to + * `createSignerFromWalletAccount`. Wallets that don't advertise the chain + * are filtered out, and accounts that can't produce a signer for the chain + * resolve to `signer: null` (matching the read-only-wallet contract). * * One client = one chain. To switch networks, create a separate client * with a different chain and RPC endpoint. */ - chain: SolanaChain; + chain: SolanaChain | (IdentifierString & {}); /** * Optional filter function for wallet discovery. Called for each wallet diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2b10f..71a6949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: '@wallet-standard/app': specifier: ^1.1.0 version: 1.1.0 + '@wallet-standard/base': + specifier: ^1.1.0 + version: 1.1.0 '@wallet-standard/errors': specifier: ^0.1.1 version: 0.1.1 diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index b279e71..f4a17ab 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -20,6 +20,7 @@ This spec builds on two changes that must land first: "dependencies": { "@solana/wallet-account-signer": "^1.x", "@wallet-standard/app": "^1.x", + "@wallet-standard/base": "^1.x", "@wallet-standard/features": "^1.x", "@wallet-standard/ui": "^1.x", "@wallet-standard/ui-features": "^1.x", @@ -339,6 +340,7 @@ import { import { getWalletFeature } from '@wallet-standard/ui-features'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import type { IdentifierString } from '@wallet-standard/base'; import type { TransactionSigner, MessageSigner, SolanaChain } from '@solana/kit'; // Internal helper — defines a throwing signer getter on the additions object. @@ -487,17 +489,22 @@ function createWalletStore(config: WalletPluginConfig) { // -- Browser-only initialization below this point -- - // -- Signer creation (resilient to read-only wallets) -- + // -- Signer creation (resilient to read-only wallets and custom chains) -- function tryCreateSigner( account: UiWalletAccount, ): TransactionSigner | (MessageSigner & TransactionSigner) | null { try { - return createSignerFromWalletAccount(account, config.chain); + // `config.chain` widens to `SolanaChain | (IdentifierString & {})` for + // the custom-chain escape hatch. `createSignerFromWalletAccount` types + // only `SolanaChain`, but at runtime it throws when the account doesn't + // support the chain — which we catch below. Non-Solana chains therefore + // degrade to `signer: null`, matching the read-only-wallet contract. + return createSignerFromWalletAccount(account, config.chain as SolanaChain); } catch { - // Wallet doesn't support signing (e.g. read-only / watch wallet). - // Connection proceeds without a signer — account is still usable - // for discovery, display, and persistence. + // Wallet doesn't support signing (read-only / watch wallet, or a + // non-Solana chain). Connection proceeds without a signer — the account + // is still usable for discovery, display, and persistence. return null; } } @@ -1127,11 +1134,25 @@ wallet({ chain: 'solana:mainnet', storage: null }) ```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. + * The chain this client targets. + * + * Accepts any `SolanaChain` (with literal autocomplete for + * 'solana:mainnet' / 'solana:devnet' / 'solana:testnet') and, as an escape + * hatch, any wallet-standard `IdentifierString` (`${string}:${string}`) for + * custom chains or non-Solana L2s. The `& {}` preserves literal autocomplete + * on the Solana members — without it TS would collapse the union into the + * wider template literal type. + * + * The plugin's runtime is chain-agnostic: `uiWallet.chains.includes(chain)` + * for discovery is a plain string check, and `createSignerFromWalletAccount` + * throws for chains it doesn't understand — which `tryCreateSigner` already + * catches, degrading to `signer: null` (matching the read-only-wallet + * contract). + * + * One client = one chain. To switch networks, create a separate client with + * a different chain and RPC endpoint. */ - chain: SolanaChain; + chain: SolanaChain | (IdentifierString & {}); /** * Optional filter function for wallet discovery. @@ -1227,6 +1248,8 @@ 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. +**Chain type includes a custom-chain escape hatch.** `WalletPluginConfig.chain` types as `SolanaChain | (IdentifierString & {})` rather than just `SolanaChain`. `SolanaChain` covers the 99% case with literal autocomplete; the `IdentifierString` escape hatch matches wallet-standard's native chain type (`UiWallet.chains: IdentifierString[]`) and unblocks custom chains or non-Solana L2s without requiring consumers to cast. The runtime is already chain-agnostic — the chain is a plain string passed to wallet-standard discovery and to `createSignerFromWalletAccount` (which throws for unknown chains, caught by `tryCreateSigner` → `signer: null`, same contract as read-only wallets). + **Single wallet connection.** One active wallet at a time. dApps needing multiple can access `getState().wallets` and manage additional connections via wallet-standard APIs. **SSR-safe.** All four plugin functions gracefully degrade on the server — status stays `'pending'`, wallet list is empty, signer getters throw `SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED`, 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. From d484b0aae6014288798ac7e56063825fb53cc005 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 23 Apr 2026 09:58:18 +0000 Subject: [PATCH 14/16] Add abort signals to async wallet operations --- packages/kit-plugin-wallet/src/types.ts | 53 ++++++++++++++++++++++--- wallet-plugin-spec.md | 53 +++++++++++++++++++++---- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index f5e8431..290e98d 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -57,6 +57,34 @@ export type WalletState = { readonly wallets: readonly UiWallet[]; }; +/** + * Options accepted by each async wallet action. + * + * Currently only carries an `abortSignal`, but is kept as an object for + * consistency with the rest of the Kit ecosystem and to allow future + * additions without breaking the call-site shape. + * + * @see {@link WalletNamespace.connect} + * @see {@link WalletNamespace.disconnect} + * @see {@link WalletNamespace.signMessage} + * @see {@link WalletNamespace.signIn} + */ +export type WalletActionOptions = { + /** + * An optional `AbortSignal` used to cancel the operation. + * + * Cancellation is pre-call only: the plugin calls + * `abortSignal.throwIfAborted()` at the start of each action and bails + * out before invoking the wallet. Once the underlying wallet-standard + * call has been dispatched, its result is returned even if the signal + * is aborted mid-flight — the wallet's side effect (an approved + * signature, a live connection, a broadcast transaction) is the source + * of truth, and throwing here would discard real user work without + * undoing what the wallet already did. + */ + abortSignal?: AbortSignal; +}; + /** * A pluggable storage adapter for persisting the selected wallet account. * @@ -173,11 +201,20 @@ export type WalletNamespace = { * * @returns All accounts from the wallet after connection. * @throws The wallet's rejection error if the user declines the prompt. + * @throws `options.abortSignal.reason` if the signal is already aborted + * when the action is called. Aborts after the wallet call has been + * dispatched do not take effect. */ - connect: (wallet: UiWallet) => Promise; + connect: (wallet: UiWallet, options?: WalletActionOptions) => Promise; - /** Disconnect the active wallet. Calls `standard:disconnect` if supported. */ - disconnect: () => Promise; + /** + * Disconnect the active wallet. Calls `standard:disconnect` if supported. + * + * @throws `options.abortSignal.reason` if the signal is already aborted + * when the action is called. Aborts after the wallet call has been + * dispatched do not take effect. + */ + disconnect: (options?: WalletActionOptions) => Promise; // -- State -- /** @@ -212,8 +249,11 @@ export type WalletNamespace = { * * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signIn`. + * @throws `options.abortSignal.reason` if the signal is already aborted + * when the action is called. Aborts after the wallet call has been + * dispatched do not take effect. */ - signIn: (wallet: UiWallet, input: SolanaSignInInput) => Promise; + signIn: (wallet: UiWallet, input: SolanaSignInInput, options?: WalletActionOptions) => Promise; /** * Sign an arbitrary message with the connected account. @@ -225,8 +265,11 @@ export type WalletNamespace = { * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is connected. * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signMessage`. + * @throws `options.abortSignal.reason` if the signal is already aborted + * when the action is called. Aborts after the wallet call has been + * dispatched do not take effect. */ - signMessage: (message: Uint8Array) => Promise; + signMessage: (message: Uint8Array, options?: WalletActionOptions) => Promise; /** * Subscribe to any wallet state change. Compatible with React's diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index f4a17ab..76ce266 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -191,10 +191,10 @@ type ClientWithWallet = { * if reconnecting). Creates and caches a signer for the active account. * Returns all accounts from the wallet after connection. */ - connect: (wallet: UiWallet) => Promise; + connect: (wallet: UiWallet, options?: WalletActionOptions) => Promise; /** Disconnect the active wallet. Calls standard:disconnect if supported. */ - disconnect: () => Promise; + disconnect: (options?: WalletActionOptions) => Promise; /** * Switch to a different account within the connected wallet. @@ -209,7 +209,7 @@ type ClientWithWallet = { * Calls the wallet's solana:signMessage feature directly * (does not go through the cached signer). */ - signMessage: (message: Uint8Array) => Promise; + signMessage: (message: Uint8Array, options?: WalletActionOptions) => Promise; /** * Sign In With Solana (SIWS-as-connect). @@ -224,10 +224,33 @@ type ClientWithWallet = { * To sign in with the already-connected wallet, pass * getState().connected.wallet. */ - signIn(wallet: UiWallet, input: SolanaSignInInput): Promise; + signIn(wallet: UiWallet, input: SolanaSignInInput, options?: WalletActionOptions): Promise; }; }; +/** + * Options accepted by each async wallet action. + * + * Currently only carries an `abortSignal`, but is kept as an object for + * consistency with the rest of the Kit ecosystem and to allow future + * additions without breaking the call-site shape. + */ +type WalletActionOptions = { + /** + * An optional AbortSignal used to cancel the operation. + * + * Cancellation is pre-call only: the plugin checks + * `abortSignal.throwIfAborted()` at the start of each action and bails + * out before invoking the wallet. Once the underlying wallet-standard + * call has been dispatched, its result is returned even if the signal + * is aborted mid-flight — the wallet's side effect (an approved + * signature, a live connection, a broadcast transaction) is the source + * of truth, and throwing here would discard real user work without + * undoing what the wallet already did. + */ + abortSignal?: AbortSignal; +}; + /** * walletAsPayer returns ClientWithWallet & ClientWithPayer. * The payer getter is dynamic — returns the wallet signer when connected, @@ -558,7 +581,11 @@ function createWalletStore(config: WalletPluginConfig) { // -- Connection lifecycle -- - async function connect(uiWallet: UiWallet): Promise { + async function connect( + uiWallet: UiWallet, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); userHasSelected = true; reconnectCleanup?.(); reconnectCleanup = null; @@ -612,7 +639,8 @@ function createWalletStore(config: WalletPluginConfig) { } } - async function disconnect(): Promise { + async function disconnect(options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); if (!state.connectedWallet) return; const currentWallet = state.connectedWallet; @@ -678,7 +706,11 @@ function createWalletStore(config: WalletPluginConfig) { // -- Message signing -- - async function signMessage(message: Uint8Array): Promise { + async function signMessage( + message: Uint8Array, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); const { connectedWallet, account } = state; if (!connectedWallet || !account) { throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { @@ -698,7 +730,12 @@ function createWalletStore(config: WalletPluginConfig) { // -- Sign In With Solana (SIWS-as-connect) -- - async function signIn(wallet: UiWallet, input: SolanaSignInInput): Promise { + async function signIn( + wallet: UiWallet, + input: SolanaSignInInput, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); userHasSelected = true; reconnectCleanup?.(); reconnectCleanup = null; From dd572e9884cfa3852f3a0310023ad3aa76cd003c Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 8 Jun 2026 12:36:23 +0000 Subject: [PATCH 15/16] Add missing rootDir to tsconfig declarations --- packages/kit-plugin-wallet/tsconfig.declarations.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit-plugin-wallet/tsconfig.declarations.json b/packages/kit-plugin-wallet/tsconfig.declarations.json index dc2d27b..39f8d32 100644 --- a/packages/kit-plugin-wallet/tsconfig.declarations.json +++ b/packages/kit-plugin-wallet/tsconfig.declarations.json @@ -3,7 +3,8 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "./dist/types" + "outDir": "./dist/types", + "rootDir": "./src" }, "extends": "./tsconfig.json", "include": ["src/index.ts", "src/types"] From a59b81dfe7d4510c1a70858c33e478b658fc6af3 Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 8 Jun 2026 13:12:40 +0000 Subject: [PATCH 16/16] Fix lint --- packages/kit-plugin-wallet/package.json | 68 +- packages/kit-plugin-wallet/vitest.config.mts | 1 + wallet-plugin-spec.md | 1655 +++++++++--------- 3 files changed, 864 insertions(+), 860 deletions(-) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index 1dbf358..843e9a8 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -2,6 +2,38 @@ "name": "@solana/kit-plugin-wallet", "version": "0.1.0", "description": "Wallet connection plugin for Kit clients", + "keywords": [ + "kit", + "plugin", + "signer", + "solana", + "wallet", + "wallet-adapter", + "wallet-standard" + ], + "bugs": { + "url": "http://github.com/anza-xyz/kit-plugins/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anza-xyz/kit-plugins" + }, + "files": [ + "./dist/types", + "./dist/index.*", + "./src/" + ], + "type": "commonjs", + "sideEffects": false, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs" + }, + "types": "./dist/types/index.d.ts", + "react-native": "./dist/index.react-native.mjs", "exports": { "types": "./dist/types/index.d.ts", "react-native": "./dist/index.react-native.mjs", @@ -14,30 +46,6 @@ "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-adapter", - "wallet-standard", - "signer" - ], "scripts": { "build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json", "dev": "vitest --project node", @@ -47,9 +55,6 @@ "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", @@ -62,13 +67,8 @@ "@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" + "peerDependencies": { + "@solana/kit": "^6.6.0" }, "browserslist": [ "supports bigint and not dead", diff --git a/packages/kit-plugin-wallet/vitest.config.mts b/packages/kit-plugin-wallet/vitest.config.mts index 8fd0137..1839df6 100644 --- a/packages/kit-plugin-wallet/vitest.config.mts +++ b/packages/kit-plugin-wallet/vitest.config.mts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; + import { getVitestConfig } from '../../vitest.config.base.mjs'; export default defineConfig({ diff --git a/wallet-plugin-spec.md b/wallet-plugin-spec.md index 76ce266..dd3d98b 100644 --- a/wallet-plugin-spec.md +++ b/wallet-plugin-spec.md @@ -14,18 +14,18 @@ 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/base": "^1.x", - "@wallet-standard/features": "^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/base": "^1.x", + "@wallet-standard/features": "^1.x", + "@wallet-standard/ui": "^1.x", + "@wallet-standard/ui-features": "^1.x", + "@wallet-standard/ui-registry": "^1.x" + } } ``` @@ -41,10 +41,10 @@ A framework-agnostic Kit plugin that manages wallet discovery, connection lifecy import { walletSigner } from '@solana/kit-plugin-wallet'; const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletSigner({ chain: 'solana:mainnet' })) - .use(systemProgram()) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(systemProgram()) + .use(planAndSendTransactions()); // Server: status === 'pending', client.payer / client.identity throw // Browser: auto-connect fires, client.payer / client.identity become wallet signer @@ -111,9 +111,9 @@ import { walletSigner } from '@solana/kit-plugin-wallet'; import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan'; const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletSigner({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // No wallet connected -> client.payer / client.identity throw // Wallet connected -> client.payer / client.identity return wallet signer @@ -133,29 +133,29 @@ import { walletSigner, walletIdentity, walletPayer, walletWithoutSigner } from ' // Wallet as payer and identity — most dApps const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletSigner({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // Wallet as identity only — relayer pays fees const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(payer(relayerKeypair)) - .use(walletIdentity({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(relayerKeypair)) + .use(walletIdentity({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // Wallet as payer only const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(walletPayer({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(walletPayer({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // Wallet without signer — manual signer use const client = createClient() - .use(rpc('https://api.mainnet-beta.solana.com')) - .use(payer(backendKeypair)) - .use(walletWithoutSigner({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + .use(rpc('https://api.mainnet-beta.solana.com')) + .use(payer(backendKeypair)) + .use(walletWithoutSigner({ chain: 'solana:mainnet' })) + .use(planAndSendTransactions()); // client.payer is TransactionSigner (from payer plugin, untouched) // client.wallet.getState().connected?.signer for manual use ``` @@ -166,66 +166,66 @@ Both `wallet` and `walletAsPayer` add the `wallet` namespace to the client. `wal ```typescript 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 the current wallet state. Referentially stable - * when unchanged — a new object is only created when a - * state field actually changes. - */ - getState: () => WalletState; - - // -- 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, options?: WalletActionOptions) => Promise; - - /** Disconnect the active wallet. Calls standard:disconnect if supported. */ - disconnect: (options?: WalletActionOptions) => 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. - * Calls the wallet's solana:signMessage feature directly - * (does not go through the cached signer). - */ - signMessage: (message: Uint8Array, options?: WalletActionOptions) => Promise; - - /** - * Sign In With Solana (SIWS-as-connect). - * - * Connects the wallet, calls solana:signIn, sets the returned - * account as active, and creates a signer. After completion, the - * client is in the same state as if connect() had been called. - * - * All fields on SolanaSignInInput are optional — pass {} if no - * sign-in customization is needed. - * - * To sign in with the already-connected wallet, pass - * getState().connected.wallet. - */ - signIn(wallet: UiWallet, input: SolanaSignInInput, options?: WalletActionOptions): Promise; - }; + 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 the current wallet state. Referentially stable + * when unchanged — a new object is only created when a + * state field actually changes. + */ + getState: () => WalletState; + + // -- 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, options?: WalletActionOptions) => Promise; + + /** Disconnect the active wallet. Calls standard:disconnect if supported. */ + disconnect: (options?: WalletActionOptions) => 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. + * Calls the wallet's solana:signMessage feature directly + * (does not go through the cached signer). + */ + signMessage: (message: Uint8Array, options?: WalletActionOptions) => Promise; + + /** + * Sign In With Solana (SIWS-as-connect). + * + * Connects the wallet, calls solana:signIn, sets the returned + * account as active, and creates a signer. After completion, the + * client is in the same state as if connect() had been called. + * + * All fields on SolanaSignInInput are optional — pass {} if no + * sign-in customization is needed. + * + * To sign in with the already-connected wallet, pass + * getState().connected.wallet. + */ + signIn(wallet: UiWallet, input: SolanaSignInInput, options?: WalletActionOptions): Promise; + }; }; /** @@ -236,19 +236,19 @@ type ClientWithWallet = { * additions without breaking the call-site shape. */ type WalletActionOptions = { - /** - * An optional AbortSignal used to cancel the operation. - * - * Cancellation is pre-call only: the plugin checks - * `abortSignal.throwIfAborted()` at the start of each action and bails - * out before invoking the wallet. Once the underlying wallet-standard - * call has been dispatched, its result is returned even if the signal - * is aborted mid-flight — the wallet's side effect (an approved - * signature, a live connection, a broadcast transaction) is the source - * of truth, and throwing here would discard real user work without - * undoing what the wallet already did. - */ - abortSignal?: AbortSignal; + /** + * An optional AbortSignal used to cancel the operation. + * + * Cancellation is pre-call only: the plugin checks + * `abortSignal.throwIfAborted()` at the start of each action and bails + * out before invoking the wallet. Once the underlying wallet-standard + * call has been dispatched, its result is returned even if the signal + * is aborted mid-flight — the wallet's side effect (an approved + * signature, a live connection, a broadcast transaction) is the source + * of truth, and throwing here would discard real user work without + * undoing what the wallet already did. + */ + abortSignal?: AbortSignal; }; /** @@ -258,45 +258,44 @@ type WalletActionOptions = { * or SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE when read-only. */ -export function walletSigner(config: WalletPluginConfig): - (client: T) => T & ClientWithWallet & ClientWithPayer & ClientWithIdentity; +export function walletSigner( + config: WalletPluginConfig, +): (client: T) => T & ClientWithWallet & ClientWithPayer & ClientWithIdentity; -export function walletIdentity(config: WalletPluginConfig): - (client: T) => T & ClientWithWallet & ClientWithIdentity; +export function walletIdentity( + config: WalletPluginConfig, +): (client: T) => T & ClientWithWallet & ClientWithIdentity; -export function walletPayer(config: WalletPluginConfig): - (client: T) => T & ClientWithWallet & ClientWithPayer; +export function walletPayer( + config: WalletPluginConfig, +): (client: T) => T & ClientWithWallet & ClientWithPayer; -export function walletWithoutSigner(config: WalletPluginConfig): - (client: T) => T & ClientWithWallet; +export function walletWithoutSigner(config: WalletPluginConfig): (client: T) => T & ClientWithWallet; 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 WalletState = { - 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; + 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; }; ``` All wallet state is accessed via `getState()`. The returned object is frozen and 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, wallets } = useSyncExternalStore( - client.wallet.subscribe, - client.wallet.getState, -); +const { connected, status, wallets } = useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); if (status === 'pending') return null; if (!connected) return ; @@ -325,19 +324,19 @@ client[Symbol.dispose](); // With using syntax (TypeScript 5.2+) { - using client = createEmptyClient() - .use(rpc('https://...')) - .use(wallet({ chain: 'solana:mainnet' })) - .use(planAndSendTransactions()); + 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](); }, []); ``` @@ -351,15 +350,13 @@ 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__NO_SIGNER_CONNECTED, - SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE, + SolanaError, + SOLANA_ERROR__WALLET__NOT_CONNECTED, + SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, + SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE, } 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'; @@ -368,38 +365,46 @@ import type { TransactionSigner, MessageSigner, SolanaChain } from '@solana/kit' // Internal helper — defines a throwing signer getter on the additions object. function defineSignerGetter(additions, property, store) { - Object.defineProperty(additions, property, { - get() { - const state = store.getState(); - if (!state.connected) { - throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); - } - if (!state.connected.signer) { - throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); - } - return state.connected.signer; - }, - enumerable: true, - configurable: true, - }); + Object.defineProperty(additions, property, { + get() { + const state = store.getState(); + if (!state.connected) { + throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); + } + if (!state.connected.signer) { + throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); + } + return state.connected.signer; + }, + enumerable: true, + configurable: true, + }); } // Internal helper — creates a wallet plugin with the given signer properties. function createPlugin(config, signerProperties) { - return (client) => { - const store = createWalletStore(config); - const additions = { wallet: store }; - for (const prop of signerProperties) { - defineSignerGetter(additions, prop, store); - } - return withCleanup(extendClient(client, additions), () => store.destroy()); - }; + return client => { + const store = createWalletStore(config); + const additions = { wallet: store }; + for (const prop of signerProperties) { + defineSignerGetter(additions, prop, store); + } + return withCleanup(extendClient(client, additions), () => store.destroy()); + }; } -export function walletSigner(config) { return createPlugin(config, ['payer', 'identity']); } -export function walletIdentity(config) { return createPlugin(config, ['identity']); } -export function walletPayer(config) { return createPlugin(config, ['payer']); } -export function walletWithoutSigner(config) { return createPlugin(config, []); } +export function walletSigner(config) { + return createPlugin(config, ['payer', 'identity']); +} +export function walletIdentity(config) { + return createPlugin(config, ['identity']); +} +export function walletPayer(config) { + return createPlugin(config, ['payer']); +} +export function walletWithoutSigner(config) { + return createPlugin(config, []); +} ``` ### Internal store @@ -410,16 +415,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; }; ``` @@ -427,596 +432,589 @@ type WalletStoreState = { ```typescript function createWalletStore(config: WalletPluginConfig) { - // __BROWSER__ is a compile-time constant replaced by the build system. - // Tree-shaking removes the server/browser branches from each build target. - - let state: WalletStoreState = { - wallets: [], - connectedWallet: null, - account: null, - signer: null, - status: 'pending', - }; - - let snapshot: WalletState = 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: default to localStorage in browser, null to disable. - // On the server (__BROWSER__ === false), this code is unreachable — - // the SSR guard returns early before we get here. - const storage = 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()); - } - - function deriveSnapshot(s: WalletStoreState): WalletState { - 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, - }); - } - - // -- SSR guard: on the server, return an inert stub -- + // __BROWSER__ is a compile-time constant replaced by the build system. + // Tree-shaking removes the server/browser branches from each build target. - if (!__BROWSER__) { - return { - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - getState: () => snapshot, - 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' }); }, // wallet arg ignored on server - destroy: () => {}, + let state: WalletStoreState = { + wallets: [], + connectedWallet: null, + account: null, + signer: null, + status: 'pending', }; - } - - // -- Browser-only initialization below this point -- - - // -- Signer creation (resilient to read-only wallets and custom chains) -- - - function tryCreateSigner( - account: UiWalletAccount, - ): TransactionSigner | (MessageSigner & TransactionSigner) | null { - try { - // `config.chain` widens to `SolanaChain | (IdentifierString & {})` for - // the custom-chain escape hatch. `createSignerFromWalletAccount` types - // only `SolanaChain`, but at runtime it throws when the account doesn't - // support the chain — which we catch below. Non-Solana chains therefore - // degrade to `signer: null`, matching the read-only-wallet contract. - return createSignerFromWalletAccount(account, config.chain as SolanaChain); - } catch { - // Wallet doesn't support signing (read-only / watch wallet, or a - // non-Solana chain). Connection proceeds without a signer — the 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), - ); - } + let snapshot: WalletState = 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: default to localStorage in browser, null to disable. + // On the server (__BROWSER__ === false), this code is unreachable — + // the SSR guard returns early before we get here. + const storage = 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()); + } - setState({ wallets: buildWalletList() }); + function deriveSnapshot(s: WalletStoreState): WalletState { + 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, + }); + } - 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); + // -- SSR guard: on the server, return an inert stub -- + + if (!__BROWSER__) { + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: () => snapshot, + 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' }); + }, // wallet arg ignored on server + destroy: () => {}, + }; } - setState(updates); - }); + // -- Browser-only initialization below this point -- + + // -- Signer creation (resilient to read-only wallets and custom chains) -- + + function tryCreateSigner(account: UiWalletAccount): TransactionSigner | (MessageSigner & TransactionSigner) | null { + try { + // `config.chain` widens to `SolanaChain | (IdentifierString & {})` for + // the custom-chain escape hatch. `createSignerFromWalletAccount` types + // only `SolanaChain`, but at runtime it throws when the account doesn't + // support the chain — which we catch below. Non-Solana chains therefore + // degrade to `signer: null`, matching the read-only-wallet contract. + return createSignerFromWalletAccount(account, config.chain as SolanaChain); + } catch { + // Wallet doesn't support signing (read-only / watch wallet, or a + // non-Solana chain). Connection proceeds without a signer — the account + // is still usable for discovery, display, and persistence. + return null; + } + } - // -- Connection lifecycle -- + // -- Wallet discovery -- - async function connect( - uiWallet: UiWallet, - options?: WalletActionOptions, - ): Promise { - options?.abortSignal?.throwIfAborted(); - userHasSelected = true; - reconnectCleanup?.(); - reconnectCleanup = null; - setState({ status: 'connecting' }); + const registry = getWallets(); - try { - const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as - StandardConnectFeature['standard:connect']; + 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; + } - // Snapshot existing accounts before connect — the wallet may - // already have some accounts visible. - const existingAccounts = [...uiWallet.accounts]; + function buildWalletList(): readonly UiWallet[] { + return Object.freeze( + registry + .get() + .filter(filterWallet) + .map(getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED), + ); + } - await connectFeature.connect(); + setState({ wallets: buildWalletList() }); - // Refresh UiWallet to get updated accounts after connect. - // UiWallet handles are immutable snapshots — the pre-connect - // handle won't reflect newly authorized accounts. - const refreshedWallet = refreshUiWallet(uiWallet); - const allAccounts = refreshedWallet.accounts; + 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); + }); - 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(refreshedWallet); - - setState({ - connectedWallet: refreshedWallet, - account: activeAccount, - signer, - status: 'connected', - }); - - persistAccount(activeAccount, refreshedWallet); - return allAccounts; - } catch (error) { - setState({ status: 'disconnected' }); - throw error; + // -- Connection lifecycle -- + + async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); + 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(); + + // Refresh UiWallet to get updated accounts after connect. + // UiWallet handles are immutable snapshots — the pre-connect + // handle won't reflect newly authorized accounts. + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.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(refreshedWallet); + + setState({ + connectedWallet: refreshedWallet, + account: activeAccount, + signer, + status: 'connected', + }); + + persistAccount(activeAccount, refreshedWallet); + return allAccounts; + } catch (error) { + setState({ status: 'disconnected' }); + throw error; + } } - } - - async function disconnect(options?: WalletActionOptions): Promise { - options?.abortSignal?.throwIfAborted(); - if (!state.connectedWallet) return; - - 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(options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); + if (!state.connectedWallet) return; + + 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, state.connectedWallet!); - } - - // -- Message signing -- - - async function signMessage( - message: Uint8Array, - options?: WalletActionOptions, - ): Promise { - options?.abortSignal?.throwIfAborted(); - const { connectedWallet, account } = state; - if (!connectedWallet || !account) { - throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { - operation: 'signMessage', - }); + storage?.removeItem(storageKey); } - // Use the wallet feature directly rather than going through the cached - // signer. This decouples message signing from transaction signing — - // a wallet that supports solana:signMessage but not transaction signing - // still works. getWalletFeature throws WalletStandardError if the - // feature is not supported. - const signMessageFeature = getWalletFeature(connectedWallet, 'solana:signMessage') as - SolanaSignMessageFeature['solana:signMessage']; - const [output] = await signMessageFeature.signMessage({ account, message }); - return output.signature; - } - - // -- Sign In With Solana (SIWS-as-connect) -- - - async function signIn( - wallet: UiWallet, - input: SolanaSignInInput, - options?: WalletActionOptions, - ): Promise { - options?.abortSignal?.throwIfAborted(); - userHasSelected = true; - reconnectCleanup?.(); - reconnectCleanup = null; - - const signInFeature = getWalletFeature(wallet, 'solana:signIn') as - SolanaSignInFeature['solana:signIn']; - const [result] = await signInFeature.signIn(input); - - // Set up full connection state using the account from the sign-in response. - const account = result.account; - const signer = tryCreateSigner(account); - - walletEventsCleanup?.(); - walletEventsCleanup = subscribeToWalletEvents(wallet); - - setState({ - connectedWallet: wallet, - account, - signer, - status: 'connected', - }); - persistAccount(account, wallet); - return result; - } + 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, state.connectedWallet!); + } - // -- Wallet-initiated events -- + // -- Message signing -- + + async function signMessage(message: Uint8Array, options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); + const { connectedWallet, account } = state; + if (!connectedWallet || !account) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { + operation: 'signMessage', + }); + } + // Use the wallet feature directly rather than going through the cached + // signer. This decouples message signing from transaction signing — + // a wallet that supports solana:signMessage but not transaction signing + // still works. getWalletFeature throws WalletStandardError if the + // feature is not supported. + const signMessageFeature = getWalletFeature( + connectedWallet, + 'solana:signMessage', + ) as SolanaSignMessageFeature['solana:signMessage']; + const [output] = await signMessageFeature.signMessage({ account, message }); + return output.signature; + } - // UiWallet handles are immutable snapshots. After a connect or change - // event the handle may be stale. Refresh by round-tripping through the - // underlying raw wallet to get the latest UiWallet. - function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { - const rawWallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(staleUiWallet); - return getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(rawWallet); - } + // -- Sign In With Solana (SIWS-as-connect) -- + + async function signIn( + wallet: UiWallet, + input: SolanaSignInInput, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); + userHasSelected = true; + reconnectCleanup?.(); + reconnectCleanup = null; + + const signInFeature = getWalletFeature(wallet, 'solana:signIn') as SolanaSignInFeature['solana:signIn']; + const [result] = await signInFeature.signIn(input); + + // Set up full connection state using the account from the sign-in response. + const account = result.account; + const signer = tryCreateSigner(account); + + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(wallet); + + setState({ + connectedWallet: wallet, + account, + signer, + status: 'connected', + }); - function subscribeToWalletEvents(uiWallet: UiWallet): () => void { - if (!uiWallet.features.includes('standard:events')) { - return () => {}; + persistAccount(account, wallet); + return result; } - 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); - } - }); - } + // -- Wallet-initiated events -- - function handleAccountsChanged(uiWallet: UiWallet): void { - const refreshed = refreshUiWallet(uiWallet); - const newAccounts = refreshed.accounts; + // UiWallet handles are immutable snapshots. After a connect or change + // event the handle may be stale. Refresh by round-tripping through the + // underlying raw wallet to get the latest UiWallet. + function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { + const rawWallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(staleUiWallet); + return getOrCreateUiWalletForStandardWallet_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(rawWallet); + } - if (newAccounts.length === 0) { - disconnectLocally(); - return; + 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 currentAddress = state.account?.address; - const stillPresent = currentAddress - ? newAccounts.find((a) => a.address === currentAddress) - : null; - const activeAccount = stillPresent ?? newAccounts[0]; + function handleAccountsChanged(uiWallet: UiWallet): void { + const refreshed = refreshUiWallet(uiWallet); + const newAccounts = refreshed.accounts; - const signer = tryCreateSigner(activeAccount); - setState({ account: activeAccount, connectedWallet: refreshed, signer }); - persistAccount(activeAccount, refreshed); - } + if (newAccounts.length === 0) { + disconnectLocally(); + return; + } - function handleChainsChanged(uiWallet: UiWallet): void { - const refreshed = refreshUiWallet(uiWallet); + const currentAddress = state.account?.address; + const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; + const activeAccount = stillPresent ?? newAccounts[0]; - if (!refreshed.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({ connectedWallet: refreshed, signer }); + const signer = tryCreateSigner(activeAccount); + setState({ account: activeAccount, connectedWallet: refreshed, signer }); + persistAccount(activeAccount, refreshed); } - } - function handleFeaturesChanged(uiWallet: UiWallet): void { - const refreshed = refreshUiWallet(uiWallet); + function handleChainsChanged(uiWallet: UiWallet): void { + const refreshed = refreshUiWallet(uiWallet); - // Re-run the filter — if the wallet no longer passes, disconnect. - if (config.filter && !config.filter(refreshed)) { - disconnectLocally(); - return; - } - // Features changed but wallet is still valid — recreate signer - // to pick up new capabilities or drop removed ones. - if (state.account) { - const signer = tryCreateSigner(state.account); - setState({ connectedWallet: refreshed, signer }); + if (!refreshed.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({ connectedWallet: refreshed, signer }); + } } - // Rebuild wallet list so other wallets reflect feature changes too. - setState({ wallets: buildWalletList() }); - } - - // -- Auto-connect -- + function handleFeaturesChanged(uiWallet: UiWallet): void { + const refreshed = refreshUiWallet(uiWallet); - 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; + // Re-run the filter — if the wallet no longer passes, disconnect. + if (config.filter && !config.filter(refreshed)) { + disconnectLocally(); + return; + } + // Features changed but wallet is still valid — recreate signer + // to pick up new capabilities or drop removed ones. + if (state.account) { + const signer = tryCreateSigner(state.account); + setState({ connectedWallet: refreshed, signer }); + } + + // Rebuild wallet list so other wallets reflect feature changes too. + setState({ wallets: buildWalletList() }); + } - 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(); + }; + } + })().catch(() => { + // Storage read failed — fall back to disconnected. + if (!userHasSelected) { + setState({ status: 'disconnected' }); + } + }); + } else { + // No auto-connect: immediately transition from 'pending' to 'disconnected' setState({ status: 'disconnected' }); - 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 }); + + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.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(refreshedWallet); + + setState({ + connectedWallet: refreshedWallet, + account: activeAccount, + signer, + status: 'connected', + }); + } catch { setState({ status: 'disconnected' }); - } - }); - - reconnectCleanup = () => { - clearTimeout(statusTimeout); - unsubRegisterForReconnect(); - }; - } - })().catch(() => { - // Storage read failed — fall back to disconnected. - if (!userHasSelected) { - setState({ status: 'disconnected' }); - } - }); - } else { - // No auto-connect: immediately transition from 'pending' to 'disconnected' - setState({ status: 'disconnected' }); - } - - async function attemptSilentReconnect( - savedAccountKey: string, - uiWallet: UiWallet, - ): Promise { - setState({ status: 'reconnecting' }); - - try { - const connectFeature = getWalletFeature(uiWallet, 'standard:connect') as - StandardConnectFeature['standard:connect']; - await connectFeature.connect({ silent: true }); - - const refreshedWallet = refreshUiWallet(uiWallet); - const allAccounts = refreshedWallet.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(refreshedWallet); - - setState({ - connectedWallet: refreshedWallet, - account: activeAccount, - signer, - status: 'connected', - }); - } catch { - setState({ status: 'disconnected' }); - storage?.removeItem(storageKey); + storage?.removeItem(storageKey); + } } - } - // -- Persistence -- + // -- Persistence -- - function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { - storage?.setItem(storageKey, `${wallet.name}:${account.address}`); - } + function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { + storage?.setItem(storageKey, `${wallet.name}:${account.address}`); + } - // -- Public API (exposed as client.wallet) -- + // -- Public API (exposed as client.wallet) -- - return { - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - getState: () => snapshot, - connect, - disconnect, - selectAccount, - signMessage, - signIn, - destroy: () => { - unsubRegister(); - unsubUnregister(); - walletEventsCleanup?.(); - walletEventsCleanup = null; - reconnectCleanup?.(); - reconnectCleanup = null; - listeners.clear(); - }, - }; + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: () => snapshot, + connect, + disconnect, + selectAccount, + signMessage, + signIn, + destroy: () => { + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + reconnectCleanup?.(); + reconnectCleanup = null; + listeners.clear(); + }, + }; } ``` @@ -1084,36 +1082,42 @@ All wallet state is accessed via `client.wallet.getState()`. There are no indivi ### Framework adapter examples **React:** + ```tsx function useWalletState(client) { - return useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); } ``` **Vue:** + ```typescript function useWalletState(client) { - const state = shallowRef(client.wallet.getState()); - onMounted(() => { - const unsub = client.wallet.subscribe(() => { state.value = client.wallet.getState(); }); - onUnmounted(unsub); - }); - return state; + const state = shallowRef(client.wallet.getState()); + onMounted(() => { + const unsub = client.wallet.subscribe(() => { + state.value = client.wallet.getState(); + }); + onUnmounted(unsub); + }); + return state; } ``` **Svelte:** + ```typescript -const walletState = readable(client.wallet.getState(), (set) => { - return client.wallet.subscribe(() => set(client.wallet.getState())); +const walletState = readable(client.wallet.getState(), set => { + return client.wallet.subscribe(() => set(client.wallet.getState())); }); ``` **Solid:** + ```typescript const [walletState, setWalletState] = createSignal(client.wallet.getState()); onMount(() => { - onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getState()))); + onCleanup(client.wallet.subscribe(() => setWalletState(client.wallet.getState()))); }); ``` @@ -1125,9 +1129,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; }; ``` @@ -1147,90 +1151,90 @@ 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 chain this client targets. - * - * Accepts any `SolanaChain` (with literal autocomplete for - * 'solana:mainnet' / 'solana:devnet' / 'solana:testnet') and, as an escape - * hatch, any wallet-standard `IdentifierString` (`${string}:${string}`) for - * custom chains or non-Solana L2s. The `& {}` preserves literal autocomplete - * on the Solana members — without it TS would collapse the union into the - * wider template literal type. - * - * The plugin's runtime is chain-agnostic: `uiWallet.chains.includes(chain)` - * for discovery is a plain string check, and `createSignerFromWalletAccount` - * throws for chains it doesn't understand — which `tryCreateSigner` already - * catches, degrading to `signer: null` (matching the read-only-wallet - * contract). - * - * One client = one chain. To switch networks, create a separate client with - * a different chain and RPC endpoint. - */ - chain: SolanaChain | (IdentifierString & {}); - - /** - * 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; + /** + * The chain this client targets. + * + * Accepts any `SolanaChain` (with literal autocomplete for + * 'solana:mainnet' / 'solana:devnet' / 'solana:testnet') and, as an escape + * hatch, any wallet-standard `IdentifierString` (`${string}:${string}`) for + * custom chains or non-Solana L2s. The `& {}` preserves literal autocomplete + * on the Solana members — without it TS would collapse the union into the + * wider template literal type. + * + * The plugin's runtime is chain-agnostic: `uiWallet.chains.includes(chain)` + * for discovery is a plain string check, and `createSignerFromWalletAccount` + * throws for chains it doesn't understand — which `tryCreateSigner` already + * catches, degrading to `signer: null` (matching the read-only-wallet + * contract). + * + * One client = one chain. To switch networks, create a separate client with + * a different chain and RPC endpoint. + */ + chain: SolanaChain | (IdentifierString & {}); + + /** + * 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; }; ``` @@ -1241,15 +1245,15 @@ type WalletPluginConfig = { Three error codes from `@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__NO_SIGNER_CONNECTED +SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED; // context: { status: string } // message: "No signing wallet connected (status: $status)" -SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE +SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE; // context: {} // message: "Connected wallet does not support signing" ``` @@ -1262,20 +1266,20 @@ 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, `connected.signer` is `null`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`, 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 | `getWalletFeature` throws `WalletStandardError` | -| `signIn` on wallet without feature | `getWalletFeature` throws `WalletStandardError` | +| 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`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`, 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 | `getWalletFeature` throws `WalletStandardError` | +| `signIn` on wallet without feature | `getWalletFeature` throws `WalletStandardError` | `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. @@ -1316,4 +1320,3 @@ 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`). 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. `connected.signer` in the state is `null`, letting UI distinguish connected-with-signer from connected-without-signer. When using `walletAsPayer`, `client.payer` throws `SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE`. -