diff --git a/.changeset/clever-boats-jam.md b/.changeset/clever-boats-jam.md new file mode 100644 index 0000000..6b6d71e --- /dev/null +++ b/.changeset/clever-boats-jam.md @@ -0,0 +1,6 @@ +--- +'@solana/kit-plugin-signer': minor +'@solana/kit-plugin-wallet': minor +--- + +Add `ClientWithSubscribeToPayer` and `ClientWithSubscribeToIdentity` to `@solana/kit-plugin-signer`. These are a framework-agnostic convention for plugins that mutate `client.payer` / `client.identity` reactively — they install a sibling `subscribeToPayer` / `subscribeToIdentity` function so consumers can observe changes without naming the specific plugin. diff --git a/packages/kit-plugin-signer/README.md b/packages/kit-plugin-signer/README.md index 8d86d59..7d02fb9 100644 --- a/packages/kit-plugin-signer/README.md +++ b/packages/kit-plugin-signer/README.md @@ -32,6 +32,30 @@ In many cases both roles are filled by the same keypair. Every plugin in this pa For instance, `signer(mySigner)` is a shorthand for using both `payer(mySigner)` and `identity(mySigner)`. +## Reactive `payer` / `identity` convention + +Some plugins set `client.payer` or `client.identity` reactively — the connected wallet may change, an account may be swapped, or a signer may be cleared on disconnect. Plugins that participate in this pattern advertise it by installing a sibling `subscribeTo` function on the client: + +| Type | Sibling function | Advertises that… | +| ------------------------------- | --------------------- | -------------------------------------- | +| `ClientWithSubscribeToPayer` | `subscribeToPayer` | `client.payer` may change over time | +| `ClientWithSubscribeToIdentity` | `subscribeToIdentity` | `client.identity` may change over time | + +Reactive consumers (framework hooks, stores, effects) can then observe changes without having to know which plugin installed the capability — they duck-type on the subscribe function: + +```ts +import type { ClientWithPayer } from '@solana/kit'; +import type { ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; + +function observePayer(client: ClientWithPayer & ClientWithSubscribeToPayer) { + return client.subscribeToPayer(() => { + console.log('payer is now', client.payer); + }); +} +``` + +The static plugins in this package (`signer`, `payer`, `identity`, and their `generated*` / `*FromFile` variants) all leave the signer fixed for the lifetime of the client, so they do **not** install these hooks — there is nothing to subscribe to. The convention is meant for plugins like `@solana/kit-plugin-wallet`, which reassigns `client.payer` / `client.identity` as the user connects, switches accounts, or disconnects. + ## `signer` / `payer` / `identity` plugins These plugins accept an existing `TransactionSigner` and install it on the client. diff --git a/packages/kit-plugin-signer/src/__typetests__/subscribe-to-typetest.ts b/packages/kit-plugin-signer/src/__typetests__/subscribe-to-typetest.ts new file mode 100644 index 0000000..8ccf547 --- /dev/null +++ b/packages/kit-plugin-signer/src/__typetests__/subscribe-to-typetest.ts @@ -0,0 +1,29 @@ +import type { ClientWithSubscribeToIdentity, ClientWithSubscribeToPayer, SubscribeToCapability } from '../subscribe-to'; + +// [DESCRIBE] SubscribeToCapability +{ + // It takes a listener and returns an unsubscribe function. + { + const subscribe = null as unknown as SubscribeToCapability; + const unsubscribe = subscribe(() => {}); + unsubscribe satisfies () => void; + } +} + +// [DESCRIBE] ClientWithSubscribeToPayer +{ + // It exposes a readonly `subscribeToPayer` of type `SubscribeToCapability`. + { + const client = null as unknown as ClientWithSubscribeToPayer; + client.subscribeToPayer satisfies SubscribeToCapability; + } +} + +// [DESCRIBE] ClientWithSubscribeToIdentity +{ + // It exposes a readonly `subscribeToIdentity` of type `SubscribeToCapability`. + { + const client = null as unknown as ClientWithSubscribeToIdentity; + client.subscribeToIdentity satisfies SubscribeToCapability; + } +} diff --git a/packages/kit-plugin-signer/src/index.ts b/packages/kit-plugin-signer/src/index.ts index d9155ef..f1ce97e 100644 --- a/packages/kit-plugin-signer/src/index.ts +++ b/packages/kit-plugin-signer/src/index.ts @@ -3,3 +3,4 @@ export * from './generated-signer'; export * from './generated-signer-with-sol'; export * from './signer'; export * from './signer-from-file'; +export * from './subscribe-to'; diff --git a/packages/kit-plugin-signer/src/subscribe-to.ts b/packages/kit-plugin-signer/src/subscribe-to.ts new file mode 100644 index 0000000..eb1d0a7 --- /dev/null +++ b/packages/kit-plugin-signer/src/subscribe-to.ts @@ -0,0 +1,100 @@ +/** + * Convention for advertising that a client capability is reactive. + * + * A plugin whose capability can change over time (for example, a wallet + * plugin whose `client.payer` is reassigned as the user connects, switches + * accounts, or disconnects) installs a sibling + * `subscribeTo(listener): () => void` function alongside the + * capability itself. Reactive consumers (framework hooks, stores, effects) + * can then observe changes without having to name the specific plugin that + * installed the capability — they duck-type on the subscribe hook. + * + * The listener is invoked whenever the observable value of the capability + * may have changed; consumers should re-read the capability to get the + * current value. Over-notification is acceptable — consumers that bail on + * reference-equal snapshots (such as React's `useSyncExternalStore`) will + * filter redundant notifications out for free. + * + * Plugins whose capability is fixed for the lifetime of the client (e.g. a + * static `payer(signer)` plugin) do not need to install this function. + * Consumers that care about reactivity should fall back to a no-op subscribe + * and read the capability once. + * + * This module defines the convention for `client.payer` and `client.identity`. + * See {@link ClientWithSubscribeToPayer} and {@link ClientWithSubscribeToIdentity}. + */ + +/** + * Registers a listener for changes to a reactive client capability. Returns + * an unsubscribe function. + * + * Calling the returned unsubscribe more than once is safe — it must be + * idempotent. + */ +export type SubscribeToCapability = (listener: () => void) => () => void; + +/** + * Advertises that `client.payer` is reactive. + * + * A plugin that can mutate `client.payer` over time installs this sibling + * function so that reactive consumers can re-read the capability without + * having to know which plugin installed it. + * + * The listener is invoked whenever the observable value of `client.payer` + * may have changed; consumers should re-read `client.payer` to get the + * current value. + * + * @example + * ```ts + * import { type ClientWithPayer } from '@solana/kit'; + * import { type ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; + * + * function observePayer(client: ClientWithPayer & ClientWithSubscribeToPayer) { + * return client.subscribeToPayer(() => { + * console.log('payer is now', client.payer); + * }); + * } + * ``` + * + * @see {@link ClientWithSubscribeToIdentity} + */ +export type ClientWithSubscribeToPayer = { + /** + * Registers a listener to be called whenever `client.payer` may have + * changed. Returns an unsubscribe function. + */ + readonly subscribeToPayer: SubscribeToCapability; +}; + +/** + * Advertises that `client.identity` is reactive. + * + * A plugin that can mutate `client.identity` over time installs this sibling + * function so that reactive consumers can re-read the capability without + * having to know which plugin installed it. + * + * The listener is invoked whenever the observable value of `client.identity` + * may have changed; consumers should re-read `client.identity` to get the + * current value. + * + * @example + * ```ts + * import { type ClientWithIdentity } from '@solana/kit'; + * import { type ClientWithSubscribeToIdentity } from '@solana/kit-plugin-signer'; + * + * function observeIdentity(client: ClientWithIdentity & ClientWithSubscribeToIdentity) { + * return client.subscribeToIdentity(() => { + * console.log('identity is now', client.identity); + * }); + * } + * ``` + * + * @see {@link ClientWithSubscribeToPayer} + */ +export type ClientWithSubscribeToIdentity = { + /** + * Registers a listener to be called whenever `client.identity` may have + * changed. Returns an unsubscribe function. + */ + readonly subscribeToIdentity: SubscribeToCapability; +}; diff --git a/packages/kit-plugin-wallet/README.md b/packages/kit-plugin-wallet/README.md index 535d3a4..0f173bb 100644 --- a/packages/kit-plugin-wallet/README.md +++ b/packages/kit-plugin-wallet/README.md @@ -161,6 +161,25 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref The plugin exposes `subscribe` and `getState` for binding wallet state to any UI framework. +### Observing reactive `payer` / `identity` + +Because the connected wallet's signer is synced to `client.payer` and/or `client.identity` (depending on the variant), these capabilities change over time. To let reactive consumers observe those changes without naming this plugin directly, the wallet plugins follow the [reactive signer convention](../kit-plugin-signer/README.md#reactive-payer--identity-convention) from `@solana/kit-plugin-signer`: + +| Plugin | `subscribeToPayer` | `subscribeToIdentity` | +| --------------------- | ------------------ | --------------------- | +| `walletSigner` | ✅ | ✅ | +| `walletPayer` | ✅ | — | +| `walletIdentity` | — | ✅ | +| `walletWithoutSigner` | — | — | + +Each is a `(listener: () => void) => () => void` that fires when the underlying signer identity changes (connect, disconnect, switch account). Unrelated wallet state changes such as wallet discovery do **not** trigger the listener — the plugin filters at the source. + +```ts +const unsubscribe = client.subscribeToPayer(() => { + console.log('payer is now', client.payer); +}); +``` + **React** — use `useSyncExternalStore` for concurrent-mode-safe rendering: ```tsx diff --git a/packages/kit-plugin-wallet/package.json b/packages/kit-plugin-wallet/package.json index 1dbf358..833cd42 100644 --- a/packages/kit-plugin-wallet/package.json +++ b/packages/kit-plugin-wallet/package.json @@ -51,6 +51,7 @@ "@solana/kit": "^6.6.0" }, "dependencies": { + "@solana/kit-plugin-signer": "workspace:*", "@solana/wallet-account-signer": "^6.6.0", "@solana/wallet-standard-chains": "^1.1.1", "@solana/wallet-standard-features": "^1.3.0", diff --git a/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts b/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts index 6f7c60d..d9430f9 100644 --- a/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts +++ b/packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts @@ -1,4 +1,5 @@ import { type ClientWithIdentity, type ClientWithPayer, createClient, TransactionSigner } from '@solana/kit'; +import type { ClientWithSubscribeToIdentity, ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; import { ClientWithWallet } from '../types'; import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../wallet'; @@ -16,6 +17,12 @@ const signer = null as unknown as TransactionSigner; client.identity satisfies ClientWithIdentity['identity']; client.wallet satisfies ClientWithWallet['wallet']; } + // It advertises both `subscribeToPayer` and `subscribeToIdentity`. + { + const client = createClient().use(walletSigner(config)); + client.subscribeToPayer satisfies ClientWithSubscribeToPayer['subscribeToPayer']; + client.subscribeToIdentity satisfies ClientWithSubscribeToIdentity['subscribeToIdentity']; + } } // [DESCRIBE] walletPayer @@ -26,6 +33,13 @@ const signer = null as unknown as TransactionSigner; client.payer satisfies ClientWithPayer['payer']; client.wallet satisfies ClientWithWallet['wallet']; } + // It advertises `subscribeToPayer` but not `subscribeToIdentity`. + { + const client = createClient().use(walletPayer(config)); + client.subscribeToPayer satisfies ClientWithSubscribeToPayer['subscribeToPayer']; + // @ts-expect-error walletPayer does not install subscribeToIdentity. + void client.subscribeToIdentity; + } // It does not strip a previously-set identity. { const base = { identity: signer } as unknown as ClientWithIdentity; @@ -42,6 +56,13 @@ const signer = null as unknown as TransactionSigner; client.identity satisfies ClientWithIdentity['identity']; client.wallet satisfies ClientWithWallet['wallet']; } + // It advertises `subscribeToIdentity` but not `subscribeToPayer`. + { + const client = createClient().use(walletIdentity(config)); + client.subscribeToIdentity satisfies ClientWithSubscribeToIdentity['subscribeToIdentity']; + // @ts-expect-error walletIdentity does not install subscribeToPayer. + void client.subscribeToPayer; + } // It does not strip a previously-set payer. { const base = { payer: signer } as unknown as ClientWithPayer; @@ -57,6 +78,14 @@ const signer = null as unknown as TransactionSigner; const client = createClient().use(walletWithoutSigner(config)); client.wallet satisfies ClientWithWallet['wallet']; } + // It installs neither `subscribeToPayer` nor `subscribeToIdentity`. + { + const client = createClient().use(walletWithoutSigner(config)); + // @ts-expect-error walletWithoutSigner does not install subscribeToPayer. + void client.subscribeToPayer; + // @ts-expect-error walletWithoutSigner does not install subscribeToIdentity. + void client.subscribeToIdentity; + } // It does not strip a previously-set payer. { const base = { payer: signer } as unknown as ClientWithPayer; diff --git a/packages/kit-plugin-wallet/src/wallet.ts b/packages/kit-plugin-wallet/src/wallet.ts index 817b254..def5b57 100644 --- a/packages/kit-plugin-wallet/src/wallet.ts +++ b/packages/kit-plugin-wallet/src/wallet.ts @@ -1,4 +1,5 @@ import { type ClientWithIdentity, type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; +import type { ClientWithSubscribeToIdentity, ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; import { createWalletStore } from './store'; import type { ClientWithWallet, WalletPluginConfig } from './types'; @@ -42,6 +43,26 @@ function createPlugin(config: WalletPluginC const additions: Record = { wallet: store }; for (const prop of signerProperties) { defineSignerGetter(additions, prop, store); + // Install the matching `subscribeTo` hook so reactive + // consumers can observe changes without naming this plugin + // directly. Filter at the source on signer identity so listeners + // only fire when `client.payer` / `client.identity` would actually + // resolve to a different value — unrelated wallet state changes + // (e.g. discovery) are silently absorbed. + const subscribeProp = + prop === 'payer' ? 'subscribeToPayer' : prop === 'identity' ? 'subscribeToIdentity' : null; + if (subscribeProp) { + additions[subscribeProp] = (listener: () => void) => { + let last = store.getState().connected?.signer ?? null; + return store.subscribe(() => { + const curr = store.getState().connected?.signer ?? null; + if (curr !== last) { + last = curr; + listener(); + } + }); + }; + } } return withCleanup(extendClient(client, additions), () => store[Symbol.dispose]()) as unknown as Disposable & @@ -79,7 +100,13 @@ function createPlugin(config: WalletPluginC * @see {@link WalletPluginConfig} */ export function walletSigner(config: WalletPluginConfig) { - return createPlugin(config, ['payer', 'identity']); + return createPlugin< + ClientWithIdentity & + ClientWithPayer & + ClientWithSubscribeToIdentity & + ClientWithSubscribeToPayer & + ClientWithWallet + >(config, ['payer', 'identity']); } /** @@ -109,7 +136,7 @@ export function walletSigner(config: WalletPluginConfig) { * @see {@link WalletPluginConfig} */ export function walletIdentity(config: WalletPluginConfig) { - return createPlugin(config, ['identity']); + return createPlugin(config, ['identity']); } /** @@ -139,7 +166,7 @@ export function walletIdentity(config: WalletPluginConfig) { * @see {@link WalletPluginConfig} */ export function walletPayer(config: WalletPluginConfig) { - return createPlugin(config, ['payer']); + return createPlugin(config, ['payer']); } /** diff --git a/packages/kit-plugin-wallet/test/wallet.test.ts b/packages/kit-plugin-wallet/test/wallet.test.ts index df8063f..daa2432 100644 --- a/packages/kit-plugin-wallet/test/wallet.test.ts +++ b/packages/kit-plugin-wallet/test/wallet.test.ts @@ -1,8 +1,8 @@ import { createClient, extendClient } from '@solana/kit'; import { describe, expect, it, vi } from 'vitest'; -import { walletPayer, walletSigner, walletWithoutSigner } from '../src'; -import { createMockAccount, createMockUiWallet, mockSigner, registerWallet } from './_setup'; +import { walletIdentity, walletPayer, walletSigner, walletWithoutSigner } from '../src'; +import { createMockAccount, createMockUiWallet, createSignerMock, mockSigner, registerWallet } from './_setup'; describe.skipIf(!__BROWSER__)('walletWithoutSigner plugin (browser)', () => { it('adds wallet namespace to client', () => { @@ -115,6 +115,128 @@ describe.skipIf(!__BROWSER__)('walletSigner plugin (browser)', () => { }); }); +describe.skipIf(!__BROWSER__)('subscribeToPayer / subscribeToIdentity filtering', () => { + it('subscribeToPayer fires only when the signer identity changes', 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 })); + const listener = vi.fn(); + client.subscribeToPayer(listener); + + // Wallet discovery (registering a second wallet) must not trigger. + registerWallet(createMockUiWallet({ name: 'OtherWallet' })); + expect(listener).not.toHaveBeenCalled(); + + // Connect flips signer from null → mockSigner. + await client.wallet.connect(mockWallet); + expect(listener).toHaveBeenCalledTimes(1); + + // Disconnect flips signer from mockSigner → null. + await client.wallet.disconnect(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('subscribeToIdentity fires only when the signer identity changes', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:disconnect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletIdentity({ chain: 'solana:mainnet', storage: null })); + const listener = vi.fn(); + client.subscribeToIdentity(listener); + + registerWallet(createMockUiWallet({ name: 'OtherWallet' })); + expect(listener).not.toHaveBeenCalled(); + + await client.wallet.connect(mockWallet); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('walletSigner fires both subscriptions on signer change', async () => { + const account = createMockAccount(); + const mockWallet = createMockUiWallet({ + accounts: [account], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + const payerListener = vi.fn(); + const identityListener = vi.fn(); + client.subscribeToPayer(payerListener); + client.subscribeToIdentity(identityListener); + + await client.wallet.connect(mockWallet); + + expect(payerListener).toHaveBeenCalledTimes(1); + expect(identityListener).toHaveBeenCalledTimes(1); + }); + + it('walletSigner fires both subscriptions on selectAccount', async () => { + const account1 = createMockAccount(); + const account2 = createMockAccount('Dv1XzYJkvnB7knw4E3E1HXyKVEoiacnZN35u1UgCbUkQ'); + const mockWallet = createMockUiWallet({ + accounts: [account1, account2], + features: ['standard:connect', 'standard:events'], + name: 'TestWallet', + }); + registerWallet(mockWallet); + + // Return a distinct signer reference per account so the subscription + // filter (which compares signer identity) observes a real change. + const signer1 = { ...mockSigner, address: account1.address }; + const signer2 = { ...mockSigner, address: account2.address }; + createSignerMock.mockImplementation((account: unknown) => + (account as { address: string }).address === account2.address ? signer2 : signer1, + ); + + const client = createClient().use(walletSigner({ chain: 'solana:mainnet', storage: null })); + await client.wallet.connect(mockWallet); + + const payerListener = vi.fn(); + const identityListener = vi.fn(); + client.subscribeToPayer(payerListener); + client.subscribeToIdentity(identityListener); + + client.wallet.selectAccount(account2); + + expect(payerListener).toHaveBeenCalledTimes(1); + expect(identityListener).toHaveBeenCalledTimes(1); + }); + + it('unsubscribe stops further notifications', 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 })); + const listener = vi.fn(); + const unsubscribe = client.subscribeToPayer(listener); + + await client.wallet.connect(mockWallet); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + await client.wallet.disconnect(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + describe.skipIf(!__BROWSER__)('wallet plugin duplicate guard', () => { it('throws when using two wallet plugins on the same client', () => { expect(() => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae36ab..169d5b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@solana/kit': specifier: ^6.8.0 version: 6.8.0(typescript@5.9.3) + '@solana/kit-plugin-signer': + specifier: workspace:* + version: link:../kit-plugin-signer '@solana/wallet-account-signer': specifier: ^6.6.0 version: 6.6.0(typescript@5.9.3)