diff --git a/.changeset/busy-clowns-stick.md b/.changeset/busy-clowns-stick.md new file mode 100644 index 0000000..ce0003b --- /dev/null +++ b/.changeset/busy-clowns-stick.md @@ -0,0 +1,5 @@ +--- +'@solana/kit-plugin-wallet': minor +--- + +Add `walletSigner`, `walletIdentity`, `walletPayer`, and `walletWithoutSigner` plugins for framework-agnostic wallet management using wallet-standard. Provides wallet discovery, connection lifecycle, signer creation, auto-connect persistence, subscribable state for UI frameworks, and dynamic payer/identity integration. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c6fa0..cf56f58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ This is a monorepo managed with [pnpm](https://pnpm.io/) and [Turborepo](https:/ | [`@solana/kit-plugin-signer`](./packages/kit-plugin-signer) | Signer, payer, and identity plugins. | | [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | LiteSVM support plugin. | | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | Transaction planning and execution plugins. | +| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | Browser wallet support plugins. | The repo also contains a deprecated umbrella package (`@solana/kit-plugins`) and several deprecated single-purpose packages (`@solana/kit-plugin-payer`, `@solana/kit-plugin-airdrop`, `@solana/kit-client-rpc`, `@solana/kit-client-litesvm`). They re-export from the active packages via `export *` for backward compatibility, but new code should import from the individual `kit-plugin-*` packages above directly. diff --git a/README.md b/README.md index cdf0ae8..b31fcce 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ This repo provides the following individual plugin packages. You can learn more | [`@solana/kit-plugin-signer`](./packages/kit-plugin-signer) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-signer.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-signer) | Signer, payer, and identity plugins | `signer`, `payer`, `identity`, `signerFromFile`, `payerFromFile`, `identityFromFile`, `generatedSigner`, `generatedPayer`, `generatedIdentity`, `generatedSignerWithSol`, `generatedPayerWithSol`, `generatedIdentityWithSol`, `airdropSigner`, `airdropPayer`, `airdropIdentity` | | [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-litesvm.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-litesvm) | LiteSVM support | `litesvm`, `litesvmConnection`, `litesvmAirdrop`, `litesvmGetMinimumBalance`, `litesvmTransactionPlanner`, `litesvmTransactionPlanExecutor` | | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-instruction-plan.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-instruction-plan) | Transaction planning and execution | `transactionPlanner`, `transactionPlanExecutor`, `planAndSendTransactions` | +| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-wallet) | Browser wallet support | `walletSigner`, `walletIdentity`, `walletPayer`, `walletWithoutSigner` | ## Community Plugins diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 535d3a4..5a895a4 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -137,6 +137,18 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref const accounts = await client.wallet.connect(selectedWallet); ``` + A successful call resolves only once the wallet is connected; any failure rejects. Connecting to a different wallet keeps the current one connected until the new connection succeeds — so if the attempt fails (the user declines the prompt, the wallet authorizes no accounts, or it becomes unavailable), the previous connection is left untouched rather than torn down. The same holds for `signIn`, which rejects rather than resolving with its sign-in output when the connection can't be established. While the attempt is in flight, `status` is `'connecting'` but `getState().connected` still describes the existing connection. + + If another `connect` or `signIn` starts before this one resolves (e.g. an accidental double-click), the newer request wins and owns the connection — the superseded call rejects with a `DOMException` whose `name` is `'AbortError'`. Ignore it the way you'd ignore any aborted operation: + + ```ts + try { + await client.wallet.connect(selectedWallet); + } catch (e) { + if ((e as Error).name !== 'AbortError') throw e; // a real failure + } + ``` + - **`disconnect()`** — Disconnect the active wallet. - **`selectAccount(account)`** — Switch to a different account within an already-authorized wallet without reconnecting. @@ -234,6 +246,8 @@ By default the plugin uses `localStorage` to remember the last connected wallet All four wallet plugins are safe to include in a shared client that runs on both server and browser. On the server, `status` stays `'pending'` permanently, all actions throw, and no registry listeners or storage reads are made. In the browser the plugin initializes normally. +React Native is treated the same way as the server: wallet-standard browser discovery is not available, so the plugin returns the same inert stub (`status` stays `'pending'`, actions throw). Native wallet integration would need a different discovery mechanism and is out of scope for this plugin. + ```ts const client = createClient() .use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index cc5b276..2e3cda4 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -49,11 +49,10 @@ "scripts": { "build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json", "dev": "vitest --project node", - "lint": "eslint . && prettier --check .", - "lint:fix": "eslint --fix . && prettier --write .", - "test": "pnpm test:types && pnpm test:treeshakability", + "test": "pnpm test:types && pnpm test:treeshakability && pnpm test:unit", "test:treeshakability": "for file in dist/index.*.mjs; do agadoo $file; done", - "test:types": "tsc --noEmit" + "test:types": "tsc --noEmit", + "test:unit": "vitest run" }, "dependencies": { "@solana/wallet-account-signer": "^6.9.0", @@ -61,14 +60,16 @@ "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/app": "^1.1.1", "@wallet-standard/base": "^1.1.1", - "@wallet-standard/errors": "^0.1.2", "@wallet-standard/features": "^1.1.1", "@wallet-standard/ui": "^1.0.3", "@wallet-standard/ui-features": "^1.0.3", "@wallet-standard/ui-registry": "^1.1.1" }, + "devDependencies": { + "@wallet-standard/errors": "^0.1.2" + }, "peerDependencies": { - "@solana/kit": "^6.6.0" + "@solana/kit": "^6.9.0" }, "browserslist": [ "supports bigint and not dead", diff --git a/packages/kit-plugin-wallet/src/store.ts b/packages/kit-plugin-wallet/src/store.ts index 958912d..c3318a0 100644 --- a/packages/kit-plugin-wallet/src/store.ts +++ b/packages/kit-plugin-wallet/src/store.ts @@ -1,14 +1,828 @@ -import type { WalletNamespace, WalletPluginConfig } from './types'; +import { type SignatureBytes, SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; +import { createSignerFromWalletAccount } from '@solana/wallet-account-signer'; +import { + SolanaSignIn, + type SolanaSignInFeature, + type SolanaSignInInput, + type SolanaSignInOutput, + SolanaSignMessage, + type SolanaSignMessageFeature, +} from '@solana/wallet-standard-features'; +import { getWallets } from '@wallet-standard/app'; +import { + StandardConnect, + type StandardConnectFeature, + StandardDisconnect, + type StandardDisconnectFeature, + StandardEvents, + type StandardEventsFeature, +} from '@wallet-standard/features'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { getWalletAccountFeature, getWalletFeature } from '@wallet-standard/ui-features'; +import { getOrCreateUiWalletForStandardWallet, getWalletForHandle } from '@wallet-standard/ui-registry'; + +import type { + WalletActionOptions, + WalletNamespace, + WalletPluginConfig, + WalletSigner, + WalletState, + WalletStatus, + WalletStorage, +} from './types'; // -- Internal types --------------------------------------------------------- +/** @internal */ export type WalletStore = WalletNamespace & { [Symbol.dispose]: () => void; }; +// Flat internal state for easier updates +type WalletStoreState = { + account: UiWalletAccount | null; + connectedWallet: UiWallet | null; + signer: WalletSigner | null; + status: WalletStatus; + wallets: readonly UiWallet[]; +}; + // -- Store ------------------------------------------------------------------ /** @internal */ -export function createWalletStore(_config: WalletPluginConfig): WalletStore { - throw new Error('not implemented'); +export function createWalletStore(config: WalletPluginConfig): WalletStore { + // -- SSR guard: on the server, return an inert stub --------------------- + + if (!__BROWSER__ || __REACTNATIVE__) { + const ssrSnapshot: WalletState = Object.freeze({ + connected: null, + status: 'pending' as const, + wallets: Object.freeze([]) as readonly UiWallet[], + }); + return { + connect: () => + Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' })), + disconnect: () => Promise.resolve(), + getState: () => ssrSnapshot, + selectAccount: () => { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); + }, + signIn: () => Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' })), + signMessage: () => + Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' })), + subscribe: () => () => {}, + [Symbol.dispose]: () => {}, + }; + } + + // -- Browser-only initialization below this point ---------------------- + + let state: WalletStoreState = { + account: null, + connectedWallet: null, + signer: null, + status: 'pending', + wallets: [], + }; + + const listeners = new Set<() => void>(); + let walletEventsCleanup: (() => void) | null = null; + let reconnectCleanup: (() => void) | null = null; + let userHasSelected = false; + let connectGeneration = 0; + let disposed = false; + // Wallet that is in the process of disconnecting, stored to guard against re-connecting to it if a future connect fails + let disconnectingWalletName: string | null = null; + + // Resolve storage: default to localStorage in browser, null to disable. + // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed + // iframes or when third-party storage is blocked, so the default resolution + // degrades to `null` rather than letting the whole plugin-install chain + // throw. This matches the best-effort persistence applied to reads/writes. + function resolveDefaultStorage(): WalletStorage | null { + try { + return localStorage; + } catch { + return null; + } + } + + const storage: WalletStorage | null = config.storage === null ? null : (config.storage ?? resolveDefaultStorage()); + const storageKey = config.storageKey ?? 'kit-wallet'; + + // -- State management -------------------------------------------------- + + function deriveSnapshot(s: WalletStoreState): WalletState { + return Object.freeze({ + connected: + s.connectedWallet && s.account + ? Object.freeze({ + account: s.account, + signer: s.signer, + wallet: s.connectedWallet, + }) + : null, + status: s.status, + wallets: s.wallets, + }); + } + + let snapshot: WalletState = deriveSnapshot(state); + + function updateState(updates: Partial): void { + const prev = state; + state = { ...state, ...updates }; + + // Only create a new snapshot and notify listeners when a + // snapshot-relevant field actually changed. This ensures + // referential stability for useSyncExternalStore and avoids + // unnecessary re-renders in non-React frameworks. + if ( + state.wallets !== prev.wallets || + state.connectedWallet !== prev.connectedWallet || + state.account !== prev.account || + state.status !== prev.status || + state.signer !== prev.signer + ) { + snapshot = deriveSnapshot(state); + listeners.forEach(l => l()); + } + } + + // -- Signer creation --------------------------------------------------- + + function tryCreateSigner(account: UiWalletAccount): WalletSigner | null { + try { + // `createSignerFromWalletAccount` throws for chains the wallet + // doesn't support — which we catch below. Non-Solana chains + // therefore degrade to `signer: null`, matching the read-only + // wallet contract. + return createSignerFromWalletAccount(account, config.chain); + } catch { + // Wallet doesn't support signing (read-only / watch wallet, or a + // non-Solana chain). + return null; + } + } + + // -- Wallet discovery -------------------------------------------------- + + // `getWallets()` is a wallet-standard function that returns the `Wallets` API + // This includes a `get()` function to get the current list of wallets, and + // an `on()` function to subscribe to wallet registration/unregistration events. + const registry = getWallets(); + + function filterWallet(uiWallet: UiWallet): boolean { + const supportsChain = uiWallet.chains.includes(config.chain); + const supportsConnect = uiWallet.features.includes(StandardConnect); + if (!supportsChain || !supportsConnect) return false; + return config.filter ? config.filter(uiWallet) : true; + } + + function buildWalletList(): readonly UiWallet[] { + return Object.freeze(registry.get().map(getOrCreateUiWalletForStandardWallet).filter(filterWallet)); + } + + // Rebuilds the filtered wallet list but returns the *existing* `state.wallets` + // reference when the contents are unchanged. `buildWalletList` always + // allocates a fresh frozen array, so without this a registry event for a + // wallet that gets filtered out (or any event that doesn't alter the visible + // set) would still produce a new snapshot and notify every listener — a + // spurious re-render. Wallets are compared by reference, in order: the + // wallet-ui registry hands back a stable UiWallet for a given underlying + // wallet until its accounts/features/chains change, so reference equality is + // a sound same-contents check here. + function reconcileWalletList(): readonly UiWallet[] { + const next = buildWalletList(); + const prev = state.wallets; + if (next.length === prev.length && next.every((w, i) => w === prev[i])) { + return prev; + } + return next; + } + + // True if a wallet matching `uiWallet` (by name) is currently registered and + // passes the filter. Used to re-check membership after an awaited connect / + // sign-in / silent reconnect resolves: the wallet may have unregistered (or + // dropped a required feature/chain) while its prompt was open, and the + // unregister handler can't tear down a connection that hasn't been + // established yet. Compared by name — the same identity the unregister + // handler uses — because the wallet-ui registry hands back a fresh UiWallet + // reference once connect adds accounts, so reference equality would not hold. + function isWalletStillAvailable(uiWallet: UiWallet): boolean { + return buildWalletList().some(w => w.name === uiWallet.name); + } + + updateState({ wallets: buildWalletList() }); + + // Listen for new wallets being registered + const unsubRegister = registry.on('register', () => { + updateState({ wallets: reconcileWalletList() }); + }); + + // Listen for wallets being unregistered — if the connected wallet is removed, disconnect + const unsubUnregister = registry.on('unregister', () => { + const newWallets = reconcileWalletList(); + const updates: Partial = { wallets: newWallets }; + + // If the connected wallet was unregistered, disconnect locally. + if (state.connectedWallet && !newWallets.some(w => w.name === state.connectedWallet!.name)) { + walletEventsCleanup?.(); + walletEventsCleanup = null; + updates.connectedWallet = null; + updates.account = null; + updates.signer = null; + updates.status = 'disconnected'; + clearPersistedAccount(); + } + + updateState(updates); + }); + + // -- Wallet-initiated events ------------------------------------------- + + function cancelReconnect(): void { + reconnectCleanup?.(); + reconnectCleanup = null; + } + + function resubscribeToWalletEvents(uiWallet: UiWallet): void { + walletEventsCleanup?.(); + walletEventsCleanup = subscribeToWalletEvents(uiWallet); + } + + function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void { + // The store may have been disposed while a connect / sign-in / silent + // reconnect awaited. The `connectGeneration` guard at each call site + // doesn't cover the auto-connect IIFE resuming from its storage read — + // it captures a fresh generation in `attemptSilentReconnect` after + // disposal. Bail here so a disposed store never re-subscribes to wallet + // events (a listener the already-run disposer can't clean up) or reports + // itself connected. + if (disposed) return; + const signer = tryCreateSigner(account); + resubscribeToWalletEvents(wallet); + disconnectingWalletName = null; + updateState({ account, connectedWallet: wallet, signer, status: 'connected' }); + if (options?.persist !== false) { + persistAccount(account, wallet); + } + } + + function refreshUiWallet(staleUiWallet: UiWallet): UiWallet { + const rawWallet = getWalletForHandle(staleUiWallet); + return getOrCreateUiWalletForStandardWallet(rawWallet); + } + + function subscribeToWalletEvents(uiWallet: UiWallet): () => void { + if (!uiWallet.features.includes(StandardEvents)) { + return () => {}; + } + + const eventsFeature = getWalletFeature( + uiWallet, + StandardEvents, + ) as StandardEventsFeature[typeof StandardEvents]; + + // The handler ignores the changed-property flags (`accounts`, `chains`, + // `features`) and instead reconciles the active account against the + // refreshed wallet. The signer is derived entirely from the account's + // features and chains, and the wallet-ui registry regenerates the + // account reference precisely when one of those changes — so the + // account reference is the single source of truth for whether the + // signer is affected, regardless of which flag the wallet sets. + return eventsFeature.on('change', () => { + const refreshed = refreshUiWallet(uiWallet); + + // If the wallet no longer passes the filter (e.g. it dropped the + // configured chain or a required feature), disconnect. + if (!filterWallet(refreshed)) { + disconnectLocally(); + return; + } + + const result = reconcileActiveAccount(refreshed); + if (result === null) { + disconnectLocally(); + return; + } + + // `connectedWallet` is always refreshed so consumers reading + // `getState().connected.wallet` see current metadata; the signer + // (in `result`) only churns when the active account changed, which + // keeps the snapshot referentially stable on no-op change events. + // `wallets` is reconciled too: the registry regenerates the wallet + // handle on a change, so without this the list entry would keep the + // stale handle (`wallets[i] !== connected.wallet`). `reconcileWalletList` + // returns the existing reference on a no-op, preserving that stability. + updateState({ connectedWallet: refreshed, wallets: reconcileWalletList(), ...result }); + + if (result.account) { + persistAccount(result.account, refreshed); + } + }); + } + + function reconcileActiveAccount(wallet: UiWallet): Partial | null { + const newAccounts = wallet.accounts; + + // No accounts left — the caller disconnects. + if (newAccounts.length === 0) return null; + + const currentAddress = state.account?.address; + const stillPresent = currentAddress ? newAccounts.find(a => a.address === currentAddress) : null; + const activeAccount = stillPresent ?? newAccounts[0]; + + // Same reference — nothing the signer depends on changed, so leave the + // signer untouched to preserve referential stability. + if (activeAccount === state.account) return {}; + + // The active account changed (switched, removed, or regenerated because + // its features/chains changed) — recreate the signer for it. + return { account: activeAccount, signer: tryCreateSigner(activeAccount) }; + } + + // -- Connection lifecycle ---------------------------------------------- + + async function connect(uiWallet: UiWallet, options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); + + // Resolve the feature before mutating any state. getWalletFeature throws + // WalletStandardError synchronously when the wallet doesn't support + // standard:connect — doing this first means an unsupported wallet is a + // no-op that never tears down an existing connection or cancels a + // pending reconnect. + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect, + ) as StandardConnectFeature[typeof StandardConnect]; + + userHasSelected = true; + cancelReconnect(); + const generation = ++connectGeneration; + updateState({ status: 'connecting' }); + + try { + // Snapshot existing accounts before connect. + const existingAccounts = [...uiWallet.accounts]; + + await connectFeature.connect(); + + // A newer connect/signIn was started while we were awaiting. Reject + // with an `AbortError` so this superseded call settles in the + // standard, ignorable way (matching the abort-signal contract) while + // the newer request owns the connection. The `catch` below leaves + // that newer connection untouched, since `generation` no longer + // matches `connectGeneration`. + if (generation !== connectGeneration) { + throw new DOMException('Wallet connect was superseded by a newer connect or sign-in', 'AbortError'); + } + + // The wallet may have unregistered (or dropped a required + // feature/chain) while its connect prompt was open. The unregister + // handler only disconnects an already-connected wallet, so without + // this re-check the store would be left connected to a wallet absent + // from `wallets`. Throw so the connection failure surfaces as a + // rejection; the `catch` below reverts to any prior connection. + if (!isWalletStillAvailable(uiWallet)) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); + } + + // Refresh UiWallet to get updated accounts after connect. + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; + + // No authorized accounts means there's nothing to connect to. Throw + // rather than resolving so a caller can't mistake an empty result for + // a successful connection; the `catch` reverts to any prior wallet. + if (allAccounts.length === 0) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }); + } + + // Prefer the first newly authorized account. + const newAccount = allAccounts.find(a => !existingAccounts.some(e => e.address === a.address)); + const activeAccount = newAccount ?? allAccounts[0]; + + setConnected(activeAccount, refreshedWallet); + return allAccounts; + } catch (error) { + // Only act if we're still the active connection attempt; a + // superseded attempt must leave the newer connection untouched. + if (generation === connectGeneration) { + revertToPreviousConnectionOrDisconnect(); + } + throw error; + } + } + + async function disconnect(options?: WalletActionOptions): Promise { + options?.abortSignal?.throwIfAborted(); + + // No connected wallet, but an auto-reconnect may still be in flight: + // during 'reconnecting' a silent reconnect or the late-registration + // listener can complete and connect, overriding this explicit + // disconnect. Record the user's intent so auto-connect won't re-arm, + // tear down the late-registration listener, and bump the generation so + // any in-flight connect/signIn/silent-reconnect bails at its guard + // instead of resuming. + if (!state.connectedWallet) { + userHasSelected = true; + cancelReconnect(); + connectGeneration++; + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } + + const currentWallet = state.connectedWallet; + // Bump the generation so this disconnect supersedes any connect/signIn + // that was already in flight (its prompt opened before the user chose to + // disconnect): that attempt captured an earlier generation, so it will + // bail at its guard rather than establishing a connection the user has + // since dismissed. Mirrors the not-connected branch above — newest action + // wins. A connect/signIn started *after* this disconnect bumps the + // generation again, so the `finally` guard skips `disconnectLocally` and + // leaves the newer connection intact. + const generation = ++connectGeneration; + // Mark this wallet as disconnecting + disconnectingWalletName = currentWallet.name; + updateState({ status: 'disconnecting' }); + + try { + if (currentWallet.features.includes(StandardDisconnect)) { + const disconnectFeature = getWalletFeature( + currentWallet, + StandardDisconnect, + ) as StandardDisconnectFeature[typeof StandardDisconnect]; + await disconnectFeature.disconnect(); + } + } finally { + // Only clean up if no new connect/signIn started while we were awaiting. + if (generation === connectGeneration) { + disconnectLocally(); + } + } + } + + function disconnectLocally(): void { + walletEventsCleanup?.(); + walletEventsCleanup = null; + disconnectingWalletName = null; + + updateState({ + account: null, + connectedWallet: null, + signer: null, + status: 'disconnected', + // Reconcile the list too: when this runs from the change handler + // because the connected wallet dropped the configured chain or + // failed the filter, that wallet must leave `wallets` immediately + // rather than linger until an unrelated register/unregister event. + // `reconcileWalletList` returns the existing reference when the + // visible set is unchanged (the common case for a plain disconnect), + // so this stays churn-free. + wallets: reconcileWalletList(), + }); + + clearPersistedAccount(); + } + + // A `connect`/`signIn` attempt failed to establish a new connection. While + // such an attempt is in flight only `status` is moved to 'connecting' — the + // existing connection's account, signer, event subscription, and persisted + // key are untouched until `setConnected` runs on success. So if a prior + // connection is still intact, revert to it (declining the new wallet must not + // log the user out of the one they had). Otherwise there's nothing to keep, + // so disconnect cleanly. The active-attempt (`generation`) guard at each call + // site ensures a superseded attempt never reverts the connection a newer one + // owns. + function revertToPreviousConnectionOrDisconnect(): void { + // Don't revive a wallet with disconnect in progress + if (state.connectedWallet && state.account && state.connectedWallet.name !== disconnectingWalletName) { + updateState({ status: 'connected' }); + } else { + disconnectLocally(); + } + } + + function selectAccount(account: UiWalletAccount): void { + if (!state.connectedWallet) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); + } + // Reject if this wallet is disconnecting + if (state.connectedWallet.name === disconnectingWalletName) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }); + } + const refreshed = refreshUiWallet(state.connectedWallet); + const selectedAccount = refreshed.accounts.find(a => a.address === account.address); + if (!selectedAccount) { + // Note: no Kit SolanaError for this case + throw new Error(`Account ${account.address} is not available in wallet "${state.connectedWallet.name}"`); + } + userHasSelected = true; + // Bump the generation so this selection supersedes any in-flight + // connect/signIn + ++connectGeneration; + // Change status back to `connected` if something in-flight changed it + // But skip recreating the signer if account is the same as before + if (selectedAccount === state.account && refreshed === state.connectedWallet) { + updateState({ status: 'connected' }); + return; + } + const signer = tryCreateSigner(selectedAccount); + updateState({ account: selectedAccount, connectedWallet: refreshed, signer, status: 'connected' }); + persistAccount(selectedAccount, refreshed); + } + + // -- 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. solana:signMessage is an account-scoped feature, so this + // resolves it against the active account: getWalletAccountFeature throws + // a WalletStandardError when either the wallet or the account does not + // support it. + const signMessageFeature = getWalletAccountFeature( + account, + SolanaSignMessage, + ) as SolanaSignMessageFeature[typeof SolanaSignMessage]; + const [output] = await signMessageFeature.signMessage({ account, message }); + return output.signature as SignatureBytes; + } + + // -- Sign In With Solana (SIWS-as-connect) ------------------------------ + + async function signIn( + uiWallet: UiWallet, + input: SolanaSignInInput, + options?: WalletActionOptions, + ): Promise { + options?.abortSignal?.throwIfAborted(); + + // Resolve the feature before mutating any state. getWalletFeature throws + // WalletStandardError synchronously when the wallet doesn't support + // solana:signIn — doing this first means an unsupported wallet is a + // no-op that never tears down an existing connection. + const signInFeature = getWalletFeature(uiWallet, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; + + userHasSelected = true; + cancelReconnect(); + const generation = ++connectGeneration; + updateState({ status: 'connecting' }); + + try { + const [result] = await signInFeature.signIn(input); + + // A newer connect/signIn was started while we were awaiting. Reject + // with an `AbortError` (matching the abort-signal contract) so the + // superseded call settles in the standard, ignorable way while the + // newer request owns the connection. + if (generation !== connectGeneration) { + throw new DOMException('Wallet sign-in was superseded by a newer connect or sign-in', 'AbortError'); + } + + // The wallet may have unregistered (or dropped a required + // feature/chain) while its sign-in prompt was open. Throw rather than + // resolving while the store is left connected to no wallet (or a + // different one); the `catch` below reverts to any prior connection. + if (!isWalletStillAvailable(uiWallet)) { + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); + } + + // Set up full connection state using the account from the sign-in response. + const refreshedWallet = refreshUiWallet(uiWallet); + const activeAccount = refreshedWallet.accounts.find(a => a.address === result.account.address); + + if (!activeAccount) { + // The wallet signed in with an account it doesn't expose — a + // protocol violation that can't be mapped to a connection. Throw + // so the failure surfaces as a rejection rather than resolving + // with a sign-in result the store can't act on; the `catch` + // reverts to any prior connection. + throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }); + } + + setConnected(activeAccount, refreshedWallet); + return result; + } catch (error) { + if (generation === connectGeneration) { + revertToPreviousConnectionOrDisconnect(); + } + throw error; + } + } + + // -- Persistence ------------------------------------------------------- + + function ignoreStorageFailure(operation: () => Promise | void): void { + void (async () => { + try { + await operation(); + } catch { + // Persistence is best-effort; wallet state is the source of truth. + } + })(); + } + + function persistAccount(account: UiWalletAccount, wallet: UiWallet): void { + if (!storage) return; + ignoreStorageFailure(() => storage.setItem(storageKey, `${wallet.name}:${account.address}`)); + } + + function clearPersistedAccount(): void { + if (!storage) return; + ignoreStorageFailure(() => storage.removeItem(storageKey)); + } + + // -- Auto-connect ------------------------------------------------------ + + if (config.autoConnect !== false && storage) { + const generation = connectGeneration; + (async () => { + const savedKey = await storage.getItem(storageKey); + // Don't auto-connect if the user has selected a wallet, or if the + // store was disposed while the storage read was in flight — the + // disposer has already torn down its listeners and bumped the + // generation, but this IIFE hasn't captured one yet, so without this + // check it would resume and silently reconnect into a disposed store. + if (userHasSelected || disposed) return; + + if (!savedKey) { + updateState({ status: 'disconnected' }); + return; + } + + const separatorIndex = savedKey.lastIndexOf(':'); + if (separatorIndex === -1) { + // Malformed saved key. + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } + + const walletName = savedKey.slice(0, separatorIndex); + const savedAddress = savedKey.slice(separatorIndex + 1); + const existing = state.wallets.find(w => w.name === walletName); + + if (existing) { + await attemptSilentReconnect(savedAddress, existing); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet(w); + return ui.name === walletName; + }) + ) { + // Wallet registered but doesn't pass the filter. + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + } else { + // Wallet not registered yet — wait for it to appear. + updateState({ status: 'reconnecting' }); + + // Change state to disconnected after 3s + // Note that the listener stays alive until reconnect is cancelled, so this is + // just affecting how long the UI shows reconnecting + const statusTimeout = setTimeout(() => { + if (!userHasSelected && state.status === 'reconnecting') { + updateState({ status: 'disconnected' }); + } + }, 3000); + + const unsubRegisterForReconnect = registry.on('register', () => { + const generation = connectGeneration; + void (async () => { + if (userHasSelected) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + return; + } + const found = buildWalletList().find(w => w.name === walletName); + if (found) { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + await attemptSilentReconnect(savedAddress, found); + } else if ( + registry.get().some(w => { + const ui = getOrCreateUiWalletForStandardWallet(w); + return ui.name === walletName; + }) + ) { + // Wallet registered but filtered out. + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + reconnectCleanup = null; + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + } + })().catch(() => { + // Reconnect failed — fall back to disconnected. + if (generation === connectGeneration && !userHasSelected) { + updateState({ status: 'disconnected' }); + } + }); + }); + + reconnectCleanup = () => { + clearTimeout(statusTimeout); + unsubRegisterForReconnect(); + }; + } + })().catch(() => { + // Storage read failed — fall back to disconnected. + if (generation === connectGeneration && !userHasSelected) { + updateState({ status: 'disconnected' }); + } + }); + } else { + // No auto-connect: immediately transition from 'pending' to 'disconnected'. + updateState({ status: 'disconnected' }); + } + + async function attemptSilentReconnect(savedAddress: string, uiWallet: UiWallet): Promise { + const generation = ++connectGeneration; + updateState({ status: 'reconnecting' }); + + try { + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect, + ) as StandardConnectFeature[typeof StandardConnect]; + await connectFeature.connect({ silent: true }); + + // A newer connect/signIn was started while we were awaiting — bail. + if (generation !== connectGeneration) return; + + // The wallet may have unregistered (or dropped a required + // feature/chain) while the silent reconnect was in flight. + if (!isWalletStillAvailable(uiWallet)) { + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } + + const refreshedWallet = refreshUiWallet(uiWallet); + const allAccounts = refreshedWallet.accounts; + + if (allAccounts.length === 0) { + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + return; + } + + // Restore the specific saved account, fall back to first from same wallet. + const activeAccount = allAccounts.find(a => a.address === savedAddress) ?? allAccounts[0]; + + // Persist only when we fell back to a different account + setConnected(activeAccount, refreshedWallet, { + persist: activeAccount.address !== savedAddress, + }); + } catch { + if (generation === connectGeneration) { + updateState({ status: 'disconnected' }); + clearPersistedAccount(); + } + } + } + + // -- Public API -------------------------------------------------------- + + return { + connect, + disconnect, + getState: () => snapshot, + selectAccount, + signIn, + signMessage, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + [Symbol.dispose]: () => { + // Invalidate any in-flight connect/signIn/silent-reconnect so they + // bail instead of resuming after disposal (which would re-subscribe + // to wallet events and re-arm cleanup that this disposer already + // ran). The generation bump covers attempts that already captured a + // generation; `disposed` covers the auto-connect IIFE, which resumes + // from its storage read and captures a fresh generation afterward. + disposed = true; + connectGeneration++; + unsubRegister(); + unsubUnregister(); + walletEventsCleanup?.(); + walletEventsCleanup = null; + cancelReconnect(); + listeners.clear(); + }, + }; } diff --git a/packages/kit-plugin-wallet/src/types.ts b/packages/kit-plugin-wallet/src/types.ts index 290e98d..87ac0cf 100644 --- a/packages/kit-plugin-wallet/src/types.ts +++ b/packages/kit-plugin-wallet/src/types.ts @@ -18,10 +18,15 @@ export type WalletSigner = TransactionSigner | (MessageSigner & TransactionSigne * The connection status of the wallet plugin. * * - `pending` — not yet initialized. Initial state on both server and browser. - * On the server this state is permanent. In the browser it resolves to - * `disconnected` or `reconnecting` once the storage check completes. + * On the server — and in React Native, which is treated the same way — this + * state is permanent. In the browser it resolves to `disconnected` or + * `reconnecting` once the storage check completes. * - `disconnected` — initialized, no wallet connected. - * - `connecting` — a user-initiated connection request is in progress. + * - `connecting` — a user-initiated connection request is in progress. When + * connecting to a different wallet while one is already connected, the + * existing connection is preserved until the new one succeeds — so + * {@link WalletState.connected} can be non-null during this state, and a + * failed attempt reverts to the previous connection. * - `connected` — a wallet is connected. * - `disconnecting` — a user-initiated disconnection request is in progress. * - `reconnecting` — auto-connect in progress (connecting to persisted wallet). @@ -44,6 +49,11 @@ export type WalletState = { * * `signer` is `null` for read-only / watch-only wallets that do not * support any signing feature. + * + * Independent of {@link status}: when {@link connect} or {@link signIn} is + * called for a different wallet while one is already connected, `connected` + * keeps describing the existing connection while `status` is `'connecting'`, + * and a failed attempt leaves it in place. */ readonly connected: { readonly account: UiWalletAccount; @@ -92,13 +102,20 @@ export type WalletActionOptions = { * `localStorage` and `sessionStorage` satisfy this interface directly. * Async backends (IndexedDB, encrypted storage) may return `Promise`s. * + * Writes are fire-and-forget: the plugin does not await `setItem`/`removeItem` + * and swallows any rejection (the live wallet connection is the source of + * truth, so a failed persist is non-fatal). A resolved action therefore does + * not guarantee the write has landed, and rapid successive writes are not + * ordered. This is intentional — persistence only records which account to + * silently reconnect to on the next load. + * * @example * ```ts * // Use sessionStorage - * wallet({ chain: 'solana:mainnet', storage: sessionStorage }); + * walletSigner({ chain: 'solana:mainnet', storage: sessionStorage }); * * // Custom async adapter - * wallet({ + * walletSigner({ * chain: 'solana:mainnet', * storage: { * getItem: (key) => myStore.get(key), @@ -115,8 +132,12 @@ export type WalletStorage = { }; /** - * Configuration for the wallet plugins ({@link walletSigner}, - * {@link walletPayer}, {@link walletIdentity}, {@link walletWithoutSigner}). + * Configuration for the wallet plugins. + * + * @see {@link walletSigner} + * @see {@link walletIdentity} + * @see {@link walletPayer} + * @see {@link walletWithoutSigner} */ export type WalletPluginConfig = { /** @@ -199,8 +220,23 @@ export type WalletNamespace = { * newly authorized account (or the first account if reconnecting). Creates * and caches a signer for the active account. * + * Resolving means the wallet is connected; any failure rejects. If a wallet + * is already connected, it stays connected until the new one is established: + * a failed attempt — a rejected prompt, no authorized accounts, or the + * wallet becoming unavailable — leaves the previous connection in place + * rather than disconnecting it. + * * @returns All accounts from the wallet after connection. * @throws The wallet's rejection error if the user declines the prompt. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if the wallet + * authorizes no accounts, or unregisters (or drops a required + * feature/chain) while its connect prompt is open. Any previously + * connected wallet is left in place. + * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or + * `signIn` is started before this call resolves. The newer request wins + * and owns the resulting connection; this superseded call rejects so it + * can be ignored like any other aborted operation (e.g. an accidental + * double-click still connects — only the orphaned first promise rejects). * @throws `options.abortSignal.reason` if the signal is already aborted * when the action is called. Aborts after the wallet call has been * dispatched do not take effect. @@ -231,6 +267,7 @@ export type WalletNamespace = { * caches a new signer for the selected account. * * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is connected. + * @throws Error if the specified account is not among the currently connected wallet's accounts. */ selectAccount: (account: UiWalletAccount) => void; @@ -238,8 +275,9 @@ export type WalletNamespace = { * Sign In With Solana (SIWS-as-connect). * * Connects the wallet, calls `solana:signIn`, sets the returned account as - * active, and creates a signer. After completion, the client is in the same - * state as if {@link connect} had been called. + * active, and creates a signer. Resolving means the client is in the same + * state as if {@link connect} had been called; any failure to connect + * rejects rather than resolving while disconnected. * * All fields on `SolanaSignInInput` are optional — pass `{}` if no sign-in * customization is needed. @@ -247,8 +285,20 @@ export type WalletNamespace = { * To sign in with the already-connected wallet, pass * `getState().connected.wallet`. * + * Like {@link connect}, a failed sign-in to a different wallet rejects and + * leaves any existing connection in place rather than disconnecting it. + * + * @returns The wallet's sign-in output, once the connection is established. * @throws `WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)` * if the wallet does not support `solana:signIn`. + * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if the wallet + * unregisters (or drops a required feature/chain) while its sign-in prompt + * is open, or signs in with an account it does not expose. Any previously + * connected wallet is left in place. + * @throws `DOMException` with `name: 'AbortError'` if a newer `connect` or + * `signIn` is started before this call resolves. The newer request wins + * and owns the resulting connection; this superseded call rejects so it + * can be ignored like any other aborted operation. * @throws `options.abortSignal.reason` if the signal is already aborted * when the action is called. Aborts after the wallet call has been * dispatched do not take effect. @@ -289,11 +339,17 @@ export type WalletNamespace = { * Properties added to the client by the wallet plugins. * * All wallet state and actions are namespaced under `client.wallet`. + * Depending on which plugin variant is used, `client.payer` and/or + * `client.identity` may also be set to the connected wallet's signer. + * This is not part of Kit plugin-interfaces, as it depends on wallet-standard types * * @see {@link walletSigner} + * @see {@link walletPayer} + * @see {@link walletIdentity} + * @see {@link walletWithoutSigner} * @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; diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 817b254..2169e56 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -1,4 +1,12 @@ -import { type ClientWithIdentity, type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; +import { + type ClientWithIdentity, + type ClientWithPayer, + extendClient, + SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, + SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE, + SolanaError, + withCleanup, +} from '@solana/kit'; import { createWalletStore } from './store'; import type { ClientWithWallet, WalletPluginConfig } from './types'; @@ -16,19 +24,22 @@ function defineSignerGetter( get() { const state = store.getState(); if (!state.connected) { - // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); - throw new Error(`No signing wallet connected (status: ${state.status})`); + throw new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: state.status }); } if (!state.connected.signer) { - // TODO: throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); - throw new Error('Connected wallet does not support signing'); + throw new SolanaError(SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE); } return state.connected.signer; }, }); } -function createPlugin(config: WalletPluginConfig, signerProperties: string[]) { +type SignerProperties = 'payer' | 'identity'; + +function createPlugin( + config: WalletPluginConfig, + signerProperties: SignerProperties[], +) { return (client: T): Disposable & Omit & TAdditions => { if ('wallet' in client) { throw new Error( diff --git a/packages/kit-plugin-wallet/test/_setup.ts b/packages/kit-plugin-wallet/test/_setup.ts new file mode 100644 index 0000000..e6d7339 --- /dev/null +++ b/packages/kit-plugin-wallet/test/_setup.ts @@ -0,0 +1,252 @@ +import { address } from '@solana/kit'; +import { + WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, + WalletStandardError, +} from '@wallet-standard/errors'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { afterEach, beforeEach, vi } from 'vitest'; + +import type { WalletStorage } from '../src/types'; + +// -- Mock helpers ----------------------------------------------------------- + +export function createMockAccount( + addr = '11111111111111111111111111111111', + features: readonly string[] = ['solana:signTransaction'], +): UiWalletAccount { + return { + address: address(addr), + chains: ['solana:mainnet'] as const, + features, + icon: 'data:image/png;base64,', + label: 'Account 1', + publicKey: new Uint8Array(32), + } as unknown as UiWalletAccount; +} + +type MockWalletOptions = { + accounts?: UiWalletAccount[]; + chains?: string[]; + features?: string[]; + name?: string; +}; + +export function createMockUiWallet(opts: MockWalletOptions = {}): UiWallet { + return { + accounts: opts.accounts ?? [], + chains: opts.chains ?? ['solana:mainnet'], + features: opts.features ?? ['standard:connect', 'standard:events'], + icon: 'data:image/png;base64,', + name: opts.name ?? 'MockWallet', + version: '1.0.0', + } as unknown as UiWallet; +} + +export function createMockStorage(initial?: Record): WalletStorage { + const data = new Map(initial ? Object.entries(initial) : []); + return { + getItem: (key: string) => data.get(key) ?? null, + removeItem: (key: string) => { + data.delete(key); + }, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + }; +} + +// -- Mocks ------------------------------------------------------------------ + +// Registry mock: tracks registered wallets and event handlers. +export const registeredWallets: ReturnType[] = []; +export const registryListeners: Record void>> = { + register: [], + unregister: [], +}; + +function createMockRawWallet(uiWallet: UiWallet) { + return { + accounts: [...uiWallet.accounts], + chains: [...uiWallet.chains], + features: Object.fromEntries(uiWallet.features.map(f => [f, { version: '1.0.0' }])), + icon: uiWallet.icon, + name: uiWallet.name, + version: uiWallet.version, + }; +} + +vi.mock('@wallet-standard/app', () => ({ + getWallets: () => ({ + get: () => registeredWallets, + on: (event: string, listener: (...args: unknown[]) => void) => { + registryListeners[event]?.push(listener); + // Return an unsubscribe function that removes this listener. + return () => { + const list = registryListeners[event]; + if (list) { + const idx = list.indexOf(listener); + if (idx >= 0) list.splice(idx, 1); + } + }; + }, + }), +})); + +// Map raw wallets to UiWallets. refreshUiWallet round-trips through this: +// getWalletForHandle returns the raw wallet, getOrCreateUiWallet maps it +// back to the latest UiWallet. Calling registerWallet with the same name +// updates the mapping, so refreshUiWallet returns the updated wallet. +const rawToUi = new Map(); +const nameToRaw = new Map(); + +vi.mock('@wallet-standard/ui-registry', () => ({ + getOrCreateUiWalletForStandardWallet: (raw: object) => { + const ui = rawToUi.get(raw); + if (!ui) throw new Error('No UiWallet registered for this raw wallet'); + return ui; + }, + getWalletForHandle: (ui: UiWallet) => { + // Return the latest raw wallet for this name, so refreshUiWallet + // picks up updated registrations. + const raw = nameToRaw.get(ui.name); + if (!raw) throw new Error(`No raw wallet registered for "${ui.name}"`); + return raw; + }, +})); + +// Track the connect/disconnect/events mocks per wallet. +export let connectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +export let disconnectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +export let signInMock: ReturnType = vi.fn().mockResolvedValue([{}]); +export let signMessageMock: ReturnType = vi.fn().mockResolvedValue([{ signature: new Uint8Array(64) }]); +export let eventListenerCleanup: ReturnType = vi.fn(); + +// Captured wallet event handler — tests call this to simulate wallet change events. +export let walletEventHandler: ((properties: Record) => void) | null = null; + +type IdentifierString = `${string}:${string}`; + +function resolveFeatureImpl(feature: IdentifierString): unknown { + if (feature === 'standard:connect') { + return { connect: connectMock, version: '1.0.0' }; + } + if (feature === 'standard:disconnect') { + return { disconnect: disconnectMock, version: '1.0.0' }; + } + if (feature === 'standard:events') { + return { + on: (_event: string, handler: (properties: Record) => void) => { + walletEventHandler = handler; + return eventListenerCleanup; + }, + version: '1.0.0', + }; + } + if (feature === 'solana:signIn') { + return { signIn: signInMock, version: '1.0.0' }; + } + if (feature === 'solana:signMessage') { + return { signMessage: signMessageMock, version: '1.0.0' }; + } + throw new Error(`Feature ${feature} not supported`); +} + +vi.mock('@wallet-standard/ui-features', () => ({ + // Mirrors the real `getWalletAccountFeature`: it first checks the account's + // own feature list, throwing a `WalletStandardError` when the account + // doesn't support the feature, then resolves the wallet-level + // implementation. + getWalletAccountFeature: (account: UiWalletAccount, feature: IdentifierString) => { + if (!account.features.includes(feature)) { + throw new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, { + address: account.address, + featureName: feature, + supportedChains: [...account.chains], + supportedFeatures: [...account.features], + }); + } + return resolveFeatureImpl(feature); + }, + getWalletFeature: (wallet: UiWallet, feature: IdentifierString) => { + if (!wallet.features.includes(feature)) { + throw new Error(`Wallet "${wallet.name}" does not support ${feature}`); + } + return resolveFeatureImpl(feature); + }, +})); + +// Signer mock. +export const mockSignerAddress = address('11111111111111111111111111111111'); +export const mockSigner: { address: typeof mockSignerAddress; modifyAndSignTransactions: ReturnType } = { + address: mockSignerAddress, + modifyAndSignTransactions: vi.fn(), +}; + +export let createSignerMock = vi.fn<(...args: unknown[]) => unknown>().mockReturnValue(mockSigner); + +vi.mock('@solana/wallet-account-signer', () => ({ + createSignerFromWalletAccount: (...args: unknown[]) => createSignerMock(...args), +})); + +// -- Helpers ---------------------------------------------------------------- + +export function registerWallet(uiWallet: UiWallet): void { + const raw = createMockRawWallet(uiWallet); + rawToUi.set(raw, uiWallet); + nameToRaw.set(uiWallet.name, raw); + registeredWallets.push(raw); +} + +/** Update an already-registered wallet's UiWallet mapping (for event handler tests). */ +export function updateRegisteredWallet(uiWallet: UiWallet): void { + const raw = nameToRaw.get(uiWallet.name); + if (raw) { + rawToUi.set(raw, uiWallet); + } +} + +/** Simulate a wallet registering after store creation (for late-registration tests). */ +export function lateRegisterWallet(uiWallet: UiWallet): void { + registerWallet(uiWallet); + registryListeners.register.forEach(l => l()); +} + +/** Simulate a wallet being unregistered (removed from registry). */ +export function unregisterWallet(uiWallet: UiWallet): void { + const raw = nameToRaw.get(uiWallet.name); + if (raw) { + const idx = registeredWallets.indexOf(raw as (typeof registeredWallets)[number]); + if (idx >= 0) registeredWallets.splice(idx, 1); + // Intentionally leave `rawToUi`/`nameToRaw` intact. The real + // `@wallet-standard/ui-registry` resolves handles by object identity, + // so `getWalletForHandle`/`getOrCreateUiWalletForStandardWallet` keep + // working for an unregistered wallet — only the registry's `get()` + // list drops it. + } + registryListeners.unregister.forEach(l => l()); +} + +function clearRegistry(): void { + registeredWallets.length = 0; + rawToUi.clear(); + nameToRaw.clear(); + registryListeners.register = []; + registryListeners.unregister = []; +} + +// -- Lifecycle -------------------------------------------------------------- + +beforeEach(() => { + clearRegistry(); + connectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + disconnectMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + signInMock = vi.fn().mockResolvedValue([{}]); + signMessageMock = vi.fn().mockResolvedValue([{ signature: new Uint8Array(64) }]); + createSignerMock = vi.fn<(...args: unknown[]) => unknown>().mockReturnValue(mockSigner); + eventListenerCleanup = vi.fn(); + walletEventHandler = null; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/packages/kit-plugin-wallet/test/store.test.ts b/packages/kit-plugin-wallet/test/store.test.ts new file mode 100644 index 0000000..8332e99 --- /dev/null +++ b/packages/kit-plugin-wallet/test/store.test.ts @@ -0,0 +1,2310 @@ +import { SOLANA_ERROR__WALLET__NOT_CONNECTED, SolanaError } from '@solana/kit'; +import { isWalletStandardError } from '@wallet-standard/errors'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createWalletStore } from '../src/store'; +import type { WalletStorage } from '../src/types'; +import { + connectMock, + createMockAccount, + createMockStorage, + createMockUiWallet, + createSignerMock, + disconnectMock, + lateRegisterWallet, + registerWallet, + registryListeners, + signInMock, + signMessageMock, + unregisterWallet, + updateRegisteredWallet, + walletEventHandler, +} from './_setup'; + +describe.skipIf(__BROWSER__)('store (SSR / non-browser)', () => { + it('returns pending status on the server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(store.getState().status).toBe('pending'); + expect(store.getState().wallets).toEqual([]); + expect(store.getState().connected).toBeNull(); + }); + + it('throws for connect on server', async () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const mockWallet = createMockUiWallet(); + await expect(() => store.connect(mockWallet)).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); + }); + + it('disconnect is a no-op on server', async () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + await expect(store.disconnect()).resolves.toBeUndefined(); + }); + + it('throws for signMessage on server', async () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + await expect(() => store.signMessage(new Uint8Array())).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }), + ); + }); + + it('throws for signIn on server', async () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const mockWallet = createMockUiWallet(); + await expect(() => store.signIn(mockWallet, {})).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); + }); + + it('subscribe returns unsubscribe function on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + const unsub = store.subscribe(() => {}); + expect(typeof unsub).toBe('function'); + }); + + it('dispose is a noop on server', () => { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(() => store[Symbol.dispose]()).not.toThrow(); + }); +}); + +describe.skipIf(!__BROWSER__)('store (browser)', () => { + it('starts in disconnected status when no storage/autoConnect', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().status).toBe('disconnected'); + }); + + it('discovers registered wallets', () => { + const mockWallet = createMockUiWallet({ name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().wallets.length).toBe(1); + }); + + it('filters wallets by chain', () => { + const wrongChain = createMockUiWallet({ chains: ['solana:devnet'], name: 'DevnetWallet' }); + registerWallet(wrongChain); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(store.getState().wallets.length).toBe(0); + }); + + it('applies custom filter', () => { + const w1 = createMockUiWallet({ name: 'Allowed' }); + const w2 = createMockUiWallet({ name: 'Blocked' }); + registerWallet(w1); + registerWallet(w2); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.name === 'Allowed', + storage: null, + }); + expect(store.getState().wallets.length).toBe(1); + expect(store.getState().wallets[0].name).toBe('Allowed'); + }); + + it('does not notify listeners when a register event leaves the filtered wallet list unchanged', () => { + const visible = createMockUiWallet({ name: 'Visible' }); + registerWallet(visible); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const before = store.getState(); + const listener = vi.fn(); + store.subscribe(listener); + + // A wallet for a different chain registers — it's filtered out, so the + // visible wallet list is unchanged. The snapshot must stay referentially + // stable and no listener should fire. + lateRegisterWallet(createMockUiWallet({ chains: ['solana:devnet'], name: 'OtherChain' })); + + expect(listener).not.toHaveBeenCalled(); + expect(store.getState()).toBe(before); + expect(store.getState().wallets).toBe(before.wallets); + }); + + it('notifies listeners when a register event adds a wallet to the filtered list', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const listener = vi.fn(); + store.subscribe(listener); + + lateRegisterWallet(createMockUiWallet({ name: 'Appeared' })); + + expect(listener).toHaveBeenCalledOnce(); + expect(store.getState().wallets.length).toBe(1); + expect(store.getState().wallets[0].name).toBe('Appeared'); + }); + + it('disconnects when connected wallet is unregistered', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + unregisterWallet(mockWallet); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + expect(state.wallets.length).toBe(0); + }); + + it('rejects when the wallet unregisters during an in-flight connect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Pause the wallet's connect() so we can unregister mid-flight. + const { promise, resolve } = Promise.withResolvers(); + connectMock.mockReturnValueOnce(promise); + + const connectPromise = store.connect(mockWallet); + + // The wallet unregisters while its connect prompt is open. state.connectedWallet + // is still null at this point, so the unregister handler doesn't disconnect — it + // only drops the wallet from the list. + unregisterWallet(mockWallet); + expect(store.getState().wallets.length).toBe(0); + + // The connect prompt now resolves. + resolve(); + // The connection could not be established, so the attempt rejects rather + // than resolving while the store is left disconnected. + await expect(connectPromise).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); + + // The store must not be left connected to a wallet absent from the list. + const state = store.getState(); + expect(state.connected).toBeNull(); + expect(state.status).toBe('disconnected'); + expect(state.wallets.length).toBe(0); + }); + + it('rejects when the wallet unregisters during an in-flight signIn', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Pause the wallet's signIn() so we can unregister mid-flight. + const { promise, resolve } = Promise.withResolvers(); + signInMock.mockReturnValueOnce(promise); + + const signInPromise = store.signIn(mockWallet, {}); + + unregisterWallet(mockWallet); + expect(store.getState().wallets.length).toBe(0); + + resolve([{ account: { address: account.address } }]); + // signIn cannot connect to a wallet absent from the list, so it rejects + // rather than resolving with the sign-in output while disconnected. + await expect(signInPromise).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); + + const state = store.getState(); + expect(state.connected).toBeNull(); + expect(state.status).toBe('disconnected'); + expect(state.wallets.length).toBe(0); + }); + + it('connects to a wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const accounts = await store.connect(mockWallet); + + expect(accounts.length).toBe(1); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected).not.toBeNull(); + expect(state.connected!.account.address).toBe(account.address); + expect(connectMock).toHaveBeenCalledOnce(); + }); + + it('prefers newly authorized account on connect', async () => { + const existingAccount = createMockAccount(); + const newAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + + // Wallet starts with one account visible before connect. + const mockWallet = createMockUiWallet({ + accounts: [existingAccount], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // After connect, the wallet gains a new account. + connectMock.mockImplementationOnce(() => { + updateRegisteredWallet( + createMockUiWallet({ + accounts: [existingAccount, newAccount], + name: 'TestWallet', + }), + ); + return Promise.resolve(); + }); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + // Should select the newly authorized account, not the pre-existing one. + expect(store.getState().connected!.account.address).toBe(newAccount.address); + }); + + it('transitions through connecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const { promise, resolve } = Promise.withResolvers(); + connectMock.mockReturnValueOnce(promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const connectPromise = store.connect(mockWallet); + + expect(store.getState().status).toBe('connecting'); + + resolve(); + await connectPromise; + + expect(store.getState().status).toBe('connected'); + }); + + it('connect throws when the wallet does not support standard:connect', async () => { + const mockWallet = createMockUiWallet({ features: ['standard:events'], name: 'NoConnect' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.connect(mockWallet)).rejects.toThrow(); + }); + + it('connect to an unsupported wallet leaves an existing connection intact', async () => { + const account = createMockAccount(); + const supported = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'Supported', + }); + // A wallet without standard:connect — connect cannot establish a session. + const unsupported = createMockUiWallet({ features: ['standard:events'], name: 'NoConnect' }); + registerWallet(supported); + registerWallet(unsupported); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(supported); + expect(store.getState().status).toBe('connected'); + + // The feature is resolved before any state is mutated, so connecting to + // an unsupported wallet must reject without publishing a 'connecting' + // status or otherwise disturbing the live connection. + const listener = vi.fn(); + store.subscribe(listener); + await expect(store.connect(unsupported)).rejects.toThrow(); + + expect(listener).not.toHaveBeenCalled(); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('disconnects from a wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + await store.disconnect(); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + expect(disconnectMock).toHaveBeenCalledOnce(); + }); + + it('transitions through disconnecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const { promise, resolve } = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const disconnectPromise = store.disconnect(); + + expect(store.getState().status).toBe('disconnecting'); + + resolve(); + await disconnectPromise; + + expect(store.getState().status).toBe('disconnected'); + }); + + it('disconnect is a no-op when not connected', async () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(disconnectMock).not.toHaveBeenCalled(); + }); + + it('notifies subscribers on state change', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const listener = vi.fn(); + store.subscribe(listener); + + // selectAccount is a single, synchronous state change. + store.selectAccount(account2); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('unsubscribe stops notifications', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const listener = vi.fn(); + const unsub = store.subscribe(listener); + unsub(); + + store.selectAccount(account2); + expect(listener).not.toHaveBeenCalled(); + }); + + it('selectAccount throws when not connected', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const account = createMockAccount(); + expect(() => store.selectAccount(account)).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }), + ); + }); + + it('does not notify listeners when nothing changed', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const before = store.getState(); + const listener = vi.fn(); + store.subscribe(listener); + // Clear calls from connect so we only observe selectAccount. + createSignerMock.mockClear(); + + // selectAccount with the same account — no state change. The signer is + // a fresh object on every creation, so recreating it here would churn + // the snapshot and notify listeners; the no-op guard must skip it. + store.selectAccount(account); + expect(listener).not.toHaveBeenCalled(); + expect(createSignerMock).not.toHaveBeenCalled(); + expect(store.getState()).toBe(before); + }); + + it('selectAccount switches accounts', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().connected!.account.address).toBe(account1.address); + + store.selectAccount(account2); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('selectAccount uses the wallet-owned account for the selected address', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const refreshedAccount2 = { + ...account2, + features: ['solana:signAndSendTransaction'] as const, + label: 'Refreshed Account 2', + publicKey: new Uint8Array(32).fill(2), + }; + const staleAccount2 = { + ...account2, + features: [] as const, + label: 'Stale Account 2', + publicKey: new Uint8Array(32).fill(9), + }; + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account1, refreshedAccount2], + name: 'TestWallet', + }), + ); + createSignerMock.mockClear(); + + store.selectAccount(staleAccount2); + + const state = store.getState(); + expect(state.connected!.account).toBe(refreshedAccount2); + expect(state.connected!.wallet.accounts[1]).toBe(refreshedAccount2); + expect(createSignerMock).toHaveBeenCalledWith(refreshedAccount2, 'solana:mainnet'); + }); + + it('selectAccount throws for account not in wallet', async () => { + const account = createMockAccount(); + const foreignAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + expect(() => store.selectAccount(foreignAccount)).toThrow('not available in wallet'); + }); + + it('signMessage throws when not connected', async () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' }), + ); + }); + + it('signMessage calls the account feature directly when connected', async () => { + const account = createMockAccount('11111111111111111111111111111111', [ + 'solana:signTransaction', + 'solana:signMessage', + ]); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const expectedSignature = new Uint8Array(64).fill(42); + signMessageMock.mockResolvedValueOnce([{ signature: expectedSignature }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const message = new Uint8Array([1, 2, 3]); + const result = await store.signMessage(message); + + expect(signMessageMock).toHaveBeenCalledExactlyOnceWith({ account, message }); + expect(result).toBe(expectedSignature); + }); + + it('signMessage throws when wallet does not support solana:signMessage', async () => { + const account = createMockAccount(); + // Wallet has standard:connect and standard:events but NOT solana:signMessage. + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'NoSignMessage', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toThrow(); + }); + + it('signMessage throws a WalletStandardError when the connected account does not support solana:signMessage', async () => { + // The wallet advertises solana:signMessage, but the active account does + // not carry it in its own feature list. Feature support must be checked + // at the account level, so this should surface a WalletStandardError + // rather than being passed through to the wallet. + const account = createMockAccount('11111111111111111111111111111111', ['solana:signTransaction']); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'AccountWithoutSignMessage', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + await expect(store.signMessage(new Uint8Array([1, 2, 3]))).rejects.toSatisfy(isWalletStandardError); + expect(signMessageMock).not.toHaveBeenCalled(); + }); + + it('signIn throws when wallet does not support solana:signIn', async () => { + const mockWallet = createMockUiWallet({ name: 'NoSignIn' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + // mockWallet does not include 'solana:signIn' in features, + // so getWalletFeature will throw. + await expect(store.signIn(mockWallet, {})).rejects.toThrow(); + }); + + it('signIn on an unsupported wallet leaves an existing connection intact', async () => { + const account = createMockAccount(); + // The connected wallet supports connect/events but NOT solana:signIn. + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + // signIn must reject because the feature is unsupported — and because + // the feature is resolved before any state is mutated, the live + // connection (and its status) must be untouched. + await expect(store.signIn(mockWallet, {})).rejects.toThrow(); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('signIn connects via SIWS and sets up connection state', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + signInMock.mockResolvedValueOnce([{ account: { address: account.address } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const result = await store.signIn(mockWallet, { domain: 'example.com' }); + + expect(signInMock).toHaveBeenCalledWith({ domain: 'example.com' }); + expect(result).toEqual({ account: { address: account.address } }); + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected).not.toBeNull(); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('signIn reverts to disconnected if rejected', async () => { + const mockWallet = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + signInMock.mockRejectedValueOnce(new Error('User rejected')); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.signIn(mockWallet, {})).rejects.toThrow('User rejected'); + expect(store.getState().status).toBe('disconnected'); + }); + + it('signIn transitions through connecting status', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const { promise, resolve } = Promise.withResolvers(); + signInMock.mockReturnValueOnce(promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const signInPromise = store.signIn(mockWallet, {}); + + expect(store.getState().status).toBe('connecting'); + + resolve([{ account: { address: account.address } }]); + await signInPromise; + + expect(store.getState().status).toBe('connected'); + }); + + it('signIn rejects when the signed-in account is not in the wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // Return an account address that doesn't match any wallet account. + signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + // The wallet signed in with an account it doesn't expose — a protocol + // violation that can't be mapped to a connection, so signIn rejects. + await expect(store.signIn(mockWallet, {})).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('reverts to the previous connection when signIn to another wallet is rejected', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + signInMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.signIn(wallet2, {})).rejects.toThrow('User rejected'); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('reverts to the previous connection when the signed-in account is not in the new wallet', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + // wallet2 reports an account that isn't among its accounts. + signInMock.mockResolvedValueOnce([{ account: { address: 'nonexistent' } }]); + await expect(store.signIn(wallet2, {})).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signIn' }), + ); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('reverts to disconnected if connect is rejected', async () => { + const mockWallet = createMockUiWallet({ name: 'TestWallet' }); + registerWallet(mockWallet); + connectMock.mockRejectedValueOnce(new Error('User rejected')); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await expect(store.connect(mockWallet)).rejects.toThrow('User rejected'); + expect(store.getState().status).toBe('disconnected'); + }); + + it('reverts to the previous connection when connect to another wallet fails', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + // Declining wallet2 should leave wallet1 connected, not log the user out. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('reverts to the previous connection when a new wallet returns zero accounts', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().status).toBe('connected'); + + // wallet2 authorized no accounts, so the connect attempt rejects. + await expect(store.connect(wallet2)).rejects.toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'connect' }), + ); + + // wallet2 never established a connection, so wallet1 stays connected. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account.address); + }); + + it('retains the persisted account when connect to another wallet fails', async () => { + const account = createMockAccount(); + const wallet1 = createMockUiWallet({ accounts: [account], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account.address}`); + + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + // The persisted key for wallet1 must survive the failed connect so the + // next page load can still silently reconnect to it. + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account.address}`); + }); + + it('stale connect rejection does not disconnect the active connection', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start connecting to wallet1. + const firstPromise = store.connect(wallet1); + + // User gives up on wallet1 and connects to wallet2 instead. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + // Wallet1 prompt is dismissed — rejection should not disconnect wallet2. + first.reject(new Error('User rejected')); + await expect(firstPromise).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('stale connect rejection does not interfere with in-flight connect', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Both connects are in flight. + const firstPromise = store.connect(wallet1); + const secondPromise = store.connect(wallet2); + + // Wallet1 is dismissed while wallet2 is still pending. + first.reject(new Error('User rejected')); + await expect(firstPromise).rejects.toThrow('User rejected'); + + // Store should still be connecting (wallet2 is in flight). + expect(store.getState().status).toBe('connecting'); + + // Wallet2 completes — should connect normally. + second.resolve(); + await secondPromise; + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('concurrent connect calls do not leak event subscriptions', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start connecting to wallet1, but don't await yet. + const firstPromise = store.connect(wallet1); + + // Start connecting to wallet2 — this should supersede wallet1. + const secondPromise = store.connect(wallet2); + + // Resolve the first connect — it was superseded, so it rejects with an + // AbortError rather than applying its result. + first.resolve(); + await expect(firstPromise).rejects.toThrow('superseded'); + await secondPromise; + + // wallet2 should be connected, not wallet1. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('rejects a superseded connect with an AbortError (double-click still connects)', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const first = Promise.withResolvers(); + connectMock.mockReturnValueOnce(first.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Two clicks on the same wallet before the first resolves. + const firstPromise = store.connect(mockWallet); + const secondPromise = store.connect(mockWallet); + + first.resolve(); + + // The orphaned first click rejects with a recognizable AbortError that + // consumers ignore by convention, ... + const error = await firstPromise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe('AbortError'); + + // ... while the second click establishes the connection. + await secondPromise; + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('concurrent signIn does not apply stale result', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ + accounts: [account2], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet2', + }); + registerWallet(wallet1); + registerWallet(wallet2); + + const first = Promise.withResolvers(); + signInMock + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([{ account: { address: account2.address } }]); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start signIn to wallet1, but don't await yet. + const firstPromise = store.signIn(wallet1, {}); + + // Start signIn to wallet2 — this should supersede wallet1. + const secondPromise = store.signIn(wallet2, {}); + + // Resolve the first signIn — it was superseded, so it rejects with an + // AbortError rather than applying its result. + first.resolve([{ account: { address: account1.address } }]); + await expect(firstPromise).rejects.toThrow('superseded'); + await secondPromise; + + // wallet2 should be connected, not wallet1. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('connect supersedes in-flight signIn', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const pendingSignIn = Promise.withResolvers(); + signInMock.mockReturnValueOnce(pendingSignIn.promise); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + + // Start signIn, then connect to a different wallet before it resolves. + const signInPromise = store.signIn(wallet1, {}); + await store.connect(wallet2); + + // signIn resolves stale — it was superseded, so it rejects with an + // AbortError and must not overwrite wallet2's connection. + pendingSignIn.resolve([{ account: { address: account1.address } }]); + await expect(signInPromise).rejects.toThrow('superseded'); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('explicit connect supersedes in-flight auto-reconnect', async () => { + vi.useFakeTimers(); + + const savedAccount = createMockAccount(); + const userAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const savedWallet = createMockUiWallet({ accounts: [savedAccount], name: 'SavedWallet' }); + const userWallet = createMockUiWallet({ accounts: [userAccount], name: 'UserWallet' }); + registerWallet(userWallet); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${savedAccount.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User explicitly connects while waiting for saved wallet. + await store.connect(userWallet); + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + // Saved wallet registers late — reconnect should be stale. + const reconnectConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(reconnectConnect.promise); + lateRegisterWallet(savedWallet); + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + + // User's explicit choice should not be overridden. + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + vi.useRealTimers(); + }); + + it('in-flight connect supersedes concurrent auto-reconnect via generation counter', async () => { + vi.useFakeTimers(); + + const savedAccount = createMockAccount(); + const userAccount = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const savedWallet = createMockUiWallet({ accounts: [savedAccount], name: 'SavedWallet' }); + const userWallet = createMockUiWallet({ accounts: [userAccount], name: 'UserWallet' }); + registerWallet(userWallet); + + // Hold user's connect pending so both are in flight simultaneously. + const userConnect = Promise.withResolvers(); + const reconnectConnect = Promise.withResolvers(); + connectMock + .mockReturnValueOnce(userConnect.promise) // user's connect + .mockReturnValueOnce(reconnectConnect.promise); // auto-reconnect's silent connect + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${savedAccount.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User starts connecting — still pending. + const userPromise = store.connect(userWallet); + expect(store.getState().status).toBe('connecting'); + + // Saved wallet registers — auto-reconnect fires while user connect is in flight. + lateRegisterWallet(savedWallet); + await vi.advanceTimersByTimeAsync(0); + + // Auto-reconnect resolves first — but stale, so user's connecting status holds. + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('connecting'); + + // User's connect resolves second. + userConnect.resolve(); + await userPromise; + + // User's connect should win — it has the later generation. + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(userAccount.address); + + vi.useRealTimers(); + }); + + it('disconnect does not clobber concurrent connect', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold disconnect pending. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // User connects to wallet2 while disconnect is in flight. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + // Disconnect resolves — should not wipe out wallet2's connection. + pendingDisconnect.resolve(); + await disconnectPromise; + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('disconnect supersedes an in-flight connect (newest action wins)', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold a connect to wallet2 pending, then disconnect while it's in flight. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + const disconnectPromise = store.disconnect(); + await disconnectPromise; + expect(store.getState().status).toBe('disconnected'); + + // The superseded connect resolves last — it must not establish wallet2, + // since disconnect was the user's later action. + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('selectAccount supersedes an in-flight connect (newest action wins)', async () => { + const account1a = createMockAccount(); + const account1b = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const account2 = createMockAccount('So11111111111111111111111111111111111111112'); + const wallet1 = createMockUiWallet({ + accounts: [account1a, account1b], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + expect(store.getState().connected!.account.address).toBe(account1a.address); + + // Hold a connect to wallet2 pending. wallet1 is still fully connected + // while wallet2's prompt is open. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + + // The user switches accounts on the still-connected wallet1 — a later + // action than the in-flight connect. + store.selectAccount(account1b); + expect(store.getState().connected!.account.address).toBe(account1b.address); + + // The superseded connect resolves last — it must not establish wallet2 + // and overwrite the user's just-made selection. + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + const state = store.getState(); + expect(state.connected!.wallet.name).toBe('Wallet1'); + expect(state.connected!.account.address).toBe(account1b.address); + // The superseded connect had moved status to 'connecting'; selectAccount + // must settle it back to 'connected' rather than leaving the store + // stranded mid-connect when wallet1 is in fact live. + expect(state.status).toBe('connected'); + }); + + it('selectAccount restores connected status even when re-selecting the active account', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // Hold a connect to wallet2 pending — status moves to 'connecting' while + // wallet1 stays live. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + expect(store.getState().status).toBe('connecting'); + + // Re-select the already-active account. This hits the no-op early return, + // but must still supersede the in-flight connect and settle the status. + store.selectAccount(account1); + expect(store.getState().status).toBe('connected'); + + pendingConnect.resolve(); + await expect(connectPromise).rejects.toThrow('superseded'); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account1.address); + }); + + it('selectAccount on a wallet mid-disconnect throws and does not revive it', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1, account2], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + registerWallet(wallet1); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account1.address}`); + + // Hold the wallet-side disconnect pending so it's in flight. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + expect(store.getState().status).toBe('disconnecting'); + + // Switching accounts on a wallet being torn down must reject rather than + // supersede the disconnect, re-persist, and leave the store half-connected. + expect(() => store.selectAccount(account2)).toThrow( + new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' }), + ); + + // The disconnect completes cleanly — the rejected selectAccount left it + // intact, so the store ends fully disconnected with persistence cleared. + pendingDisconnect.resolve(); + await disconnectPromise; + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + expect(await storage.getItem('kit-wallet')).toBeNull(); + }); + + it('a failed connect does not revive a wallet that was concurrently disconnected', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage(); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + await store.connect(wallet1); + expect(await storage.getItem('kit-wallet')).toBe(`Wallet1:${account1.address}`); + + // User disconnects wallet1; hold its wallet-side teardown pending. + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // While the disconnect is in flight the user starts connecting to + // wallet2; hold its prompt open. + const pendingConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(pendingConnect.promise); + const connectPromise = store.connect(wallet2); + + // Disconnect resolves — wallet1 is now disconnected at the wallet level. + // It supersedes the connect's `finally` guard, so local state is left + // intact on the bet that the newer connect will replace it. + pendingDisconnect.resolve(); + await disconnectPromise; + + // The user then declines wallet2. The revert must NOT restore wallet1 — + // the user's last completed action disconnected it. + pendingConnect.reject(new Error('User rejected')); + await expect(connectPromise).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + // Persistence for wallet1 must be cleared too, so the next load doesn't + // silently reconnect to a wallet the user disconnected. + expect(await storage.getItem('kit-wallet')).toBeNull(); + }); + + it('a failed connect that rejects before the concurrent disconnect resolves does not revive the wallet', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ + accounts: [account1], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'Wallet1', + }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + const pendingDisconnect = Promise.withResolvers(); + disconnectMock.mockReturnValueOnce(pendingDisconnect.promise); + const disconnectPromise = store.disconnect(); + + // The opposite ordering: wallet2 is declined while the disconnect is + // still in flight. The teardown marker is set when disconnect begins, so + // the revert still refuses to revive wallet1 even though the disconnect's + // `finally` hasn't run yet. + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + + pendingDisconnect.resolve(); + await disconnectPromise; + expect(store.getState().status).toBe('disconnected'); + }); + + it('reverts to the previous connection when no disconnect was in flight', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'Wallet1' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'Wallet2' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(wallet1); + + // No disconnect involved — declining wallet2 must still revert to the + // genuinely-live wallet1 connection (guards against the marker leaking + // across unrelated reverts). + connectMock.mockRejectedValueOnce(new Error('User rejected')); + await expect(store.connect(wallet2)).rejects.toThrow('User rejected'); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.wallet.name).toBe('Wallet1'); + }); + + it('getState returns referentially stable snapshots', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const snap1 = store.getState(); + const snap2 = store.getState(); + expect(snap1).toBe(snap2); + }); + + it('cleanup via Symbol.dispose removes registry listeners', () => { + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + expect(registryListeners.register.length).toBeGreaterThan(0); + + store[Symbol.dispose](); + expect(registryListeners.register.length).toBe(0); + expect(registryListeners.unregister.length).toBe(0); + }); +}); + +// -- Abort signal tests ------------------------------------------------------- + +describe.skipIf(!__BROWSER__)('store action abort signals (browser)', () => { + it('rejects connect with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // The pre-abort check is the first thing connect does, so it bails before + // moving to 'connecting' or invoking the wallet's connect feature. + await expect(store.connect(mockWallet, { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(connectMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('rejects disconnect with the signal reason and leaves the connection intact when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + expect(store.getState().status).toBe('connected'); + + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // disconnect bails before calling the wallet's disconnect feature, so the + // existing connection is left untouched. + await expect(store.disconnect({ abortSignal: controller.signal })).rejects.toThrow('aborted by caller'); + expect(disconnectMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('connected'); + }); + + it('rejects signMessage with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount('11111111111111111111111111111111', [ + 'solana:signTransaction', + 'solana:signMessage', + ]); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signMessage'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await store.connect(mockWallet); + + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + await expect(store.signMessage(new Uint8Array(), { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(signMessageMock).not.toHaveBeenCalled(); + }); + + it('rejects signIn with the signal reason and never calls the wallet when already aborted', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signIn'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(new Error('aborted by caller')); + + // The pre-abort check runs before signIn resolves the feature or moves to + // 'connecting', so the wallet is never prompted. + await expect(store.signIn(mockWallet, {}, { abortSignal: controller.signal })).rejects.toThrow( + 'aborted by caller', + ); + expect(signInMock).not.toHaveBeenCalled(); + expect(store.getState().status).toBe('disconnected'); + }); + + it('throws an AbortError by default when the signal is aborted without a reason', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + const controller = new AbortController(); + controller.abort(); + + // An abort with no explicit reason surfaces the default DOMException, so + // callers can detect the cancellation via the standard AbortError name. + const error = await store.connect(mockWallet, { abortSignal: controller.signal }).catch((e: unknown) => e); + expect((error as DOMException).name).toBe('AbortError'); + expect(connectMock).not.toHaveBeenCalled(); + }); +}); + +// -- Wallet event handler tests ----------------------------------------------- + +describe.skipIf(!__BROWSER__)('store wallet events (browser)', () => { + async function connectToWallet( + store: ReturnType, + wallet: ReturnType, + ) { + await store.connect(wallet); + expect(walletEventHandler).not.toBeNull(); + } + + it('disconnects when wallet no longer passes filter after feature change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signTransaction'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.features.includes('solana:signTransaction'), + storage: null, + }); + await connectToWallet(store, mockWallet); + + // Wallet drops signTransaction — no longer passes filter. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('disconnects when wallet drops configured chain', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Wallet drops mainnet support. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + chains: ['solana:devnet'], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ chains: true }); + + expect(store.getState().status).toBe('disconnected'); + }); + + it('recreates the signer when the active account is regenerated on a chains change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + chains: ['solana:mainnet', 'solana:devnet'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Clear calls from connect so we only see the event-triggered call. + createSignerMock.mockClear(); + + // The wallet narrows its chains and the registry regenerates the active + // account (new reference, same address) while still supporting mainnet. + const refreshedAccount = { ...account, chains: ['solana:mainnet'] as const }; + updateRegisteredWallet( + createMockUiWallet({ + accounts: [refreshedAccount], + chains: ['solana:mainnet'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ chains: true }); + + // Still connected, and the signer was recreated for the regenerated account. + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(createSignerMock).toHaveBeenCalledOnce(); + expect(createSignerMock.mock.calls[0][0]).toHaveProperty('address', account.address); + }); + + it('switches to first account when current account is removed', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + expect(store.getState().connected!.account.address).toBe(account1.address); + + // Wallet removes account1, only account2 remains. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account2], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account2.address); + }); + + it('skips update when current account reference is unchanged', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + const snapshotBefore = store.getState(); + + // Trigger accounts change but with same account reference. + walletEventHandler!({ accounts: true }); + + // Snapshot should be stable — no unnecessary state change. + expect(store.getState()).toBe(snapshotBefore); + }); + + it('disconnects when all accounts are removed', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Wallet removes all accounts. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + expect(state.connected).toBeNull(); + }); + + it('stays connected and has signer after features change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Trigger feature change — store should stay connected with a valid signer. + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.signer).toBeDefined(); + }); + + it('does not churn the snapshot or signer on a no-op change event', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + const before = store.getState(); + createSignerMock.mockClear(); + + // A change event fires but nothing the signer depends on changed — the + // registry returns the same account/wallet handles. The snapshot must + // stay referentially stable and the signer must not be recreated. + walletEventHandler!({ features: true }); + + expect(store.getState()).toBe(before); + expect(createSignerMock).not.toHaveBeenCalled(); + }); + + it('signer becomes null when the active account is regenerated without signing support', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + expect(store.getState().connected!.signer).not.toBeNull(); + + // The active account is regenerated (new reference, same address) + // without signing features, so the bridge function now throws. + const readOnlyAccount = { ...account, features: [] as const }; + createSignerMock.mockImplementation(() => { + throw new Error('No signing features'); + }); + updateRegisteredWallet( + createMockUiWallet({ + accounts: [readOnlyAccount], + name: 'TestWallet', + }), + ); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.signer).toBeNull(); + }); + it('uses new account signer when both accounts and features change', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // Clear calls from connect so we only see the event-triggered call. + createSignerMock.mockClear(); + + // Wallet removes account1 and changes features in the same event. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account2], + features: ['standard:connect', 'standard:events', 'solana:signAndSendTransaction'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true, features: true }); + + // Signer should be created for account2 (the new account), not account1. + expect(store.getState().connected!.account.address).toBe(account2.address); + expect(createSignerMock).toHaveBeenCalledOnce(); + expect(createSignerMock.mock.calls[0][0]).toHaveProperty('address', account2.address); + }); + + it('refreshes the connected wallet entry in the wallet list on a change event', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ chain: 'solana:mainnet', storage: null }); + await connectToWallet(store, mockWallet); + + // The registry regenerates the wallet handle (new reference, same name) + // — e.g. it authorized an additional account — while still passing the + // filter. + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account, account2], + name: 'TestWallet', + }), + ); + walletEventHandler!({ accounts: true }); + + const state = store.getState(); + expect(state.status).toBe('connected'); + // The wallet-list entry must be the same refreshed handle as + // connected.wallet, not the stale pre-change reference — otherwise UI + // rendering from the list shows pre-change accounts/features. + expect(state.wallets[0]).toBe(state.connected!.wallet); + }); + + it('drops the connected wallet from the wallet list when it no longer passes the filter', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events', 'solana:signTransaction'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.features.includes('solana:signTransaction'), + storage: null, + }); + await connectToWallet(store, mockWallet); + expect(store.getState().wallets.length).toBe(1); + + // Wallet drops signTransaction — it no longer passes the filter, so the + // store disconnects. + updateRegisteredWallet( + createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }), + ); + walletEventHandler!({ features: true }); + + const state = store.getState(); + expect(state.status).toBe('disconnected'); + // The now-ineligible wallet must be dropped immediately, not linger in + // the list until an unrelated register/unregister event. + expect(state.wallets.length).toBe(0); + }); +}); + +// -- Auto-connect tests ------------------------------------------------------- + +describe.skipIf(!__BROWSER__)('store auto-connect (browser)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('auto-connects when storage has a saved wallet', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + const state = store.getState(); + expect(state.status).toBe('connected'); + expect(state.connected!.account.address).toBe(account.address); + expect(connectMock).toHaveBeenCalledWith({ silent: true }); + }); + + it('transitions to disconnected when storage is empty', async () => { + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + }); + + it('clears storage and disconnects for malformed saved key', async () => { + const storage = createMockStorage({ 'kit-wallet': 'no-separator' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('does not auto-connect when autoConnect is false', () => { + const storage = createMockStorage({ 'kit-wallet': 'TestWallet:abc' }); + const store = createWalletStore({ autoConnect: false, chain: 'solana:mainnet', storage }); + + // Should immediately be disconnected, no async. + expect(store.getState().status).toBe('disconnected'); + }); + + it('userHasSelected prevents auto-connect from overriding', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const wallet1 = createMockUiWallet({ accounts: [account1], name: 'SavedWallet' }); + const wallet2 = createMockUiWallet({ accounts: [account2], name: 'UserWallet' }); + registerWallet(wallet1); + registerWallet(wallet2); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${account1.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // User connects before auto-connect resolves. + await store.connect(wallet2); + expect(store.getState().connected!.account.address).toBe(account2.address); + + await vi.advanceTimersByTimeAsync(0); + + // Auto-connect should not override the user's choice. + expect(store.getState().connected!.account.address).toBe(account2.address); + }); + + it('persists account on connect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + + expect(storage.getItem('kit-wallet')).toBe(`TestWallet:${account.address}`); + }); + + it('connects when persistence writes throw synchronously', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage: WalletStorage = { + getItem: () => null, + removeItem: () => {}, + setItem: () => { + throw new Error('storage write failed'); + }, + }; + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await expect(store.connect(mockWallet)).resolves.toHaveLength(1); + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('does not throw at install time when accessing localStorage throws', () => { + // Sandboxed iframes / blocked third-party storage make even *reading* + // the `localStorage` global throw a `SecurityError`. The default storage + // resolution must degrade to disabled persistence rather than throwing + // out of `createWalletStore` (and the `.use(walletSigner(...))` chain). + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + get() { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }, + }); + + try { + const store = createWalletStore({ chain: 'solana:mainnet' }); + expect(store.getState().status).toBe('disconnected'); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'localStorage', descriptor); + } else { + delete (globalThis as { localStorage?: unknown }).localStorage; + } + } + }); + + it('clears storage on disconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage(); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + expect(storage.getItem('kit-wallet')).not.toBeNull(); + + await store.disconnect(); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('disconnects when persistence removal throws synchronously', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage: WalletStorage = { + getItem: () => null, + removeItem: () => { + throw new Error('storage remove failed'); + }, + setItem: () => {}, + }; + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + await store.connect(mockWallet); + + await expect(store.disconnect()).resolves.toBeUndefined(); + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('falls back to disconnected when silent reconnect fails', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + connectMock.mockRejectedValueOnce(new Error('Silent connect failed')); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); + + it('persists the fallback account when the saved account is gone', async () => { + const account = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // The saved address no longer belongs to the wallet, so the reconnect + // falls back to the first available account. + const storage = createMockStorage({ 'kit-wallet': 'TestWallet:11111111111111111111111111111111' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().connected!.account.address).toBe(account.address); + // The stale address must be overwritten so future loads don't repeat the + // find-miss-fallback dance. + expect(storage.getItem('kit-wallet')).toBe(`TestWallet:${account.address}`); + }); + + it('does not rewrite storage on a normal reconnect to the saved account', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const setItem = vi.spyOn(storage, 'setItem'); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().connected!.account.address).toBe(account.address); + // The saved address matched, so the reconnect must not write to storage. + expect(setItem).not.toHaveBeenCalled(); + }); + + it('falls back to disconnected when storage read rejects', async () => { + const storage: WalletStorage = { + getItem: () => Promise.reject(new Error('storage unavailable')), + removeItem: () => {}, + setItem: () => {}, + }; + + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + expect(store.getState().status).toBe('pending'); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + }); + + it('does not reconnect when disposed mid silent reconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // Hold the silent connect open so we can dispose while it's in flight. + let resolveConnect!: () => void; + connectMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveConnect = resolve; + }), + ); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // Let auto-connect reach the awaited silent connect. + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // Dispose while the silent reconnect is still awaiting, then let it resolve. + store[Symbol.dispose](); + resolveConnect(); + await vi.advanceTimersByTimeAsync(0); + + // The resumed reconnect must not establish a connection or subscribe to + // wallet events after disposal. + expect(store.getState().status).not.toBe('connected'); + expect(store.getState().connected).toBeNull(); + expect(walletEventHandler).toBeNull(); + }); + + it('does not reconnect when disposed during the auto-connect storage read', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const storage = createMockStorage({ 'kit-wallet': `TestWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + // Dispose synchronously, before the auto-connect IIFE resumes from its + // awaited storage read — the window React StrictMode's + // mount → cleanup → remount hits. Disposal must invalidate the in-flight + // auto-connect even though it hasn't yet captured a connect generation. + store[Symbol.dispose](); + + // Let the storage read resolve and the silent reconnect (if any) run. + await vi.advanceTimersByTimeAsync(0); + + // The disposed store must not silently connect, leak a wallet-events + // subscription, or report itself connected. + expect(store.getState().status).not.toBe('connected'); + expect(store.getState().connected).toBeNull(); + expect(walletEventHandler).toBeNull(); + expect(connectMock).not.toHaveBeenCalled(); + }); +}); + +// -- Late wallet registration tests ------------------------------------------- + +describe.skipIf(!__BROWSER__)('store late wallet registration (browser)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('reconnects when saved wallet registers late', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + + // Wallet not registered yet — should be reconnecting. + expect(store.getState().status).toBe('reconnecting'); + + // Wallet registers late. + const lateWallet = createMockUiWallet({ + accounts: [account], + name: 'LateWallet', + }); + lateRegisterWallet(lateWallet); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('reverts to disconnected after timeout when wallet never registers', async () => { + const storage = createMockStorage({ 'kit-wallet': 'MissingWallet:abc' }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + await vi.advanceTimersByTimeAsync(3000); + expect(store.getState().status).toBe('disconnected'); + }); + + it('still reconnects if wallet appears after timeout', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + await vi.advanceTimersByTimeAsync(3000); + expect(store.getState().status).toBe('disconnected'); + + // Wallet appears after the timeout — should still silently reconnect. + const lateWallet = createMockUiWallet({ + accounts: [account], + name: 'LateWallet', + }); + lateRegisterWallet(lateWallet); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('connected'); + expect(store.getState().connected!.account.address).toBe(account.address); + }); + + it('disconnect while waiting for a late-registering wallet prevents it from connecting', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `LateWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User explicitly disconnects while still waiting for the saved wallet. + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + + // The saved wallet registers late — it must not override the disconnect. + const lateWallet = createMockUiWallet({ accounts: [account], name: 'LateWallet' }); + lateRegisterWallet(lateWallet); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + expect(connectMock).not.toHaveBeenCalled(); + }); + + it('disconnect during an in-flight silent reconnect prevents it from connecting', async () => { + const account = createMockAccount(); + const wallet = createMockUiWallet({ accounts: [account], name: 'SavedWallet' }); + registerWallet(wallet); + + // Hold the silent reconnect's connect pending so it's in flight when the + // user disconnects. + const reconnectConnect = Promise.withResolvers(); + connectMock.mockReturnValueOnce(reconnectConnect.promise); + + const storage = createMockStorage({ 'kit-wallet': `SavedWallet:${account.address}` }); + const store = createWalletStore({ chain: 'solana:mainnet', storage }); + + await vi.advanceTimersByTimeAsync(0); + expect(store.getState().status).toBe('reconnecting'); + + // User disconnects while the silent reconnect is still awaiting connect. + await store.disconnect(); + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + + // Silent reconnect resolves — but it's stale, so it must not connect. + reconnectConnect.resolve(); + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(store.getState().connected).toBeNull(); + }); + + it('clears storage when saved wallet is registered but filtered out', async () => { + const account = createMockAccount(); + const storage = createMockStorage({ 'kit-wallet': `FilteredWallet:${account.address}` }); + + // Wallet is registered but doesn't pass the custom filter. + const filteredWallet = createMockUiWallet({ + accounts: [account], + name: 'FilteredWallet', + }); + registerWallet(filteredWallet); + + const store = createWalletStore({ + chain: 'solana:mainnet', + filter: w => w.name !== 'FilteredWallet', + storage, + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(store.getState().status).toBe('disconnected'); + expect(storage.getItem('kit-wallet')).toBeNull(); + }); +}); diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts new file mode 100644 index 0000000..731a2e2 --- /dev/null +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -0,0 +1,208 @@ +import { createClient, extendClient, SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, SolanaError } from '@solana/kit'; +import { describe, expect, it, vi } from 'vitest'; + +import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../src'; +import { createMockAccount, createMockUiWallet, createSignerMock, mockSigner, registerWallet } from './_setup'; + +describe.skipIf(!__BROWSER__)('walletWithoutSigner plugin (browser)', () => { + it('adds wallet namespace to client', () => { + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); + expect(client.wallet).toBeDefined(); + expect(client.wallet.getState().status).toBe('disconnected'); + }); + + it('preserves existing client properties', () => { + const client = createClient() + .use(client => extendClient(client, { myField: 'hello' })) + .use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); + expect(client.myField).toBe('hello'); + }); + + it('cleanup via Symbol.dispose cleans up store', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + expect(client.wallet.getState().status).toBe('connected'); + + client[Symbol.dispose](); + + // Listener should fire without errors after dispose (no-op). + const listener = vi.fn(); + client.wallet.subscribe(listener); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe.skipIf(!__BROWSER__)('walletPayer plugin (browser)', () => { + it('payer throws when not connected', () => { + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow('No signing wallet connected'); + }); + + it('payer returns the signer when connected', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.payer).toBe(mockSigner); + }); + + it('payer throws after disconnect', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + expect(client.payer).toBe(mockSigner); + + await client.wallet.disconnect(); + expect(() => client.payer).toThrow('No signing wallet connected'); + }); + + it('preserves existing client properties', () => { + const client = createClient() + .use(client => extendClient(client, { myField: 'hello' })) + .use(walletPayer({ chain: 'solana:mainnet', storage: null })); + expect(client.myField).toBe('hello'); + }); + + it('cleanup via Symbol.dispose does not throw', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'TestWallet' }); + registerWallet(mockWallet); + + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(() => client[Symbol.dispose]()).not.toThrow(); + }); +}); + +describe.skipIf(!__BROWSER__)('walletSigner plugin (browser)', () => { + it('sets both payer and identity', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.payer).toBe(mockSigner); + expect(client.identity).toBe(mockSigner); + }); + + it('both payer and identity throw when not connected', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow('No signing wallet connected'); + expect(() => client.identity).toThrow('No signing wallet connected'); + }); +}); + +describe.skipIf(!__BROWSER__)('walletIdentity plugin (browser)', () => { + it('sets identity but not payer', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + expect(client.identity).toBe(mockSigner); + // walletIdentity must not define a payer getter. + expect('payer' in client).toBe(false); + }); + + it('identity throws when not connected', () => { + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + expect(() => client.identity).toThrow('No signing wallet connected'); + }); +}); + +describe.skipIf(!__BROWSER__)('wallet plugin read-only wallet (browser)', () => { + it('connects with a null signer and payer throws SIGNER_NOT_AVAILABLE', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ accounts: [account], name: 'ReadOnlyWallet' }); + registerWallet(mockWallet); + + // The bridge function throws — the wallet supports no signing features. + createSignerMock.mockImplementation(() => { + throw new Error('No signing features'); + }); + + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + // Connection succeeds, but with no signer. + const { connected } = client.wallet.getState(); + expect(connected).not.toBeNull(); + expect(connected!.signer).toBeNull(); + + // The dynamic payer getter surfaces the read-only state. + expect(() => client.payer).toThrow('Connected wallet does not support signing'); + }); +}); + +describe.skipIf(!__BROWSER__)('wallet plugin duplicate guard', () => { + it('throws when using two wallet plugins on the same client', () => { + expect(() => + createClient() + .use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })) + // @ts-expect-error — intentionally testing runtime guard for duplicate wallet plugins + .use(walletPayer({ chain: 'solana:mainnet', storage: null })), + ).toThrow('Only one wallet plugin can be used per client'); + }); +}); + +describe.skipIf(__BROWSER__)('wallet plugins (SSR / non-browser)', () => { + // On the server the store is an inert stub that reports 'pending' rather than + // 'disconnected', so the signer getters surface NO_SIGNER_CONNECTED with that + // status. This is the path a shared client chain hits during SSR. + const NO_SIGNER_ON_SERVER = new SolanaError(SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, { status: 'pending' }); + + it('adds the wallet namespace in pending status', () => { + const client = createClient().use(walletWithoutSigner({ chain: 'solana:mainnet', storage: null })); + expect(client.wallet).toBeDefined(); + expect(client.wallet.getState().status).toBe('pending'); + }); + + it('walletPayer payer throws NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletPayer({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('walletIdentity identity throws NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + expect(() => client.identity).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('walletSigner payer and identity both throw NO_SIGNER_CONNECTED', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client.payer).toThrow(NO_SIGNER_ON_SERVER); + expect(() => client.identity).toThrow(NO_SIGNER_ON_SERVER); + }); + + it('cleanup via Symbol.dispose does not throw', () => { + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + expect(() => client[Symbol.dispose]()).not.toThrow(); + }); +}); diff --git a/packages/kit-plugin-wallet/tsconfig.json b/packages/kit-plugin-wallet/tsconfig.json index 7a4f027..aac85ad 100644 --- a/packages/kit-plugin-wallet/tsconfig.json +++ b/packages/kit-plugin-wallet/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { "lib": [] }, + "compilerOptions": { "lib": ["ES2024.Promise"] }, "display": "@solana/kit-plugin-wallet", "extends": "../../tsconfig.json", "include": ["src", "test"] diff --git a/packages/kit-plugin-wallet/vitest.config.mts b/packages/kit-plugin-wallet/vitest.config.mts index 1839df6..3a25df5 100644 --- a/packages/kit-plugin-wallet/vitest.config.mts +++ b/packages/kit-plugin-wallet/vitest.config.mts @@ -1,9 +1,21 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; import { getVitestConfig } from '../../vitest.config.base.mjs'; +function withWalletMocks(config: ReturnType) { + return mergeConfig(config, { + test: { + setupFiles: ['./test/_setup.ts'], + }, + }); +} + export default defineConfig({ test: { - projects: [getVitestConfig('browser'), getVitestConfig('node'), getVitestConfig('react-native')], + projects: [ + withWalletMocks(getVitestConfig('browser')), + withWalletMocks(getVitestConfig('node')), + withWalletMocks(getVitestConfig('react-native')), + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e38299b..b97448c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,9 +199,6 @@ importers: '@wallet-standard/base': specifier: ^1.1.1 version: 1.1.1 - '@wallet-standard/errors': - specifier: ^0.1.2 - version: 0.1.2 '@wallet-standard/features': specifier: ^1.1.1 version: 1.1.1 @@ -214,6 +211,10 @@ importers: '@wallet-standard/ui-registry': specifier: ^1.1.1 version: 1.1.1 + devDependencies: + '@wallet-standard/errors': + specifier: ^0.1.2 + version: 0.1.2 packages/kit-plugins: dependencies: