Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/clever-boats-jam.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/kit-plugin-signer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Capability>` 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions packages/kit-plugin-signer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
100 changes: 100 additions & 0 deletions packages/kit-plugin-signer/src/subscribe-to.ts
Original file line number Diff line number Diff line change
@@ -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<Capability>(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.
*/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docblock guarantees idempotent unsubscribe ("Calling the returned unsubscribe more than once is safe"). The current wallet-plugin implementation delegates to store.subscribe, whose unsubscribe is a listeners.delete(listener) — naturally idempotent. Worth noting that any future plugin adopting this convention needs to uphold that contract; consider whether you want a type-level or test-level marker to make that obvious. Not blocking.

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;
};
19 changes: 19 additions & 0 deletions packages/kit-plugin-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/kit-plugin-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions packages/kit-plugin-wallet/src/__typetests__/wallet-typetest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
33 changes: 30 additions & 3 deletions packages/kit-plugin-wallet/src/wallet.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,6 +43,26 @@ function createPlugin<TAdditions extends ClientWithWallet>(config: WalletPluginC
const additions: Record<string, unknown> = { wallet: store };
for (const prop of signerProperties) {
defineSignerGetter(additions, prop, store);
// Install the matching `subscribeTo<Capability>` 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();
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small observation, no change requested: the subscribe factory is allocated per-property inside the loop. With only two capabilities that's fine, but if the signerProperties list ever grows or if you add more subscribeTo* capabilities, it might be worth hoisting this into a named helper (e.g. createSignerChangeSubscriber(store)) so the branching and closure allocation live in one place. Purely stylistic.

};
}
}

return withCleanup(extendClient(client, additions), () => store[Symbol.dispose]()) as unknown as Disposable &
Expand Down Expand Up @@ -79,7 +100,13 @@ function createPlugin<TAdditions extends ClientWithWallet>(config: WalletPluginC
* @see {@link WalletPluginConfig}
*/
export function walletSigner(config: WalletPluginConfig) {
return createPlugin<ClientWithIdentity & ClientWithPayer & ClientWithWallet>(config, ['payer', 'identity']);
return createPlugin<
ClientWithIdentity &
ClientWithPayer &
ClientWithSubscribeToIdentity &
ClientWithSubscribeToPayer &
ClientWithWallet
>(config, ['payer', 'identity']);
}

/**
Expand Down Expand Up @@ -109,7 +136,7 @@ export function walletSigner(config: WalletPluginConfig) {
* @see {@link WalletPluginConfig}
*/
export function walletIdentity(config: WalletPluginConfig) {
return createPlugin<ClientWithIdentity & ClientWithWallet>(config, ['identity']);
return createPlugin<ClientWithIdentity & ClientWithSubscribeToIdentity & ClientWithWallet>(config, ['identity']);
}

/**
Expand Down Expand Up @@ -139,7 +166,7 @@ export function walletIdentity(config: WalletPluginConfig) {
* @see {@link WalletPluginConfig}
*/
export function walletPayer(config: WalletPluginConfig) {
return createPlugin<ClientWithPayer & ClientWithWallet>(config, ['payer']);
return createPlugin<ClientWithPayer & ClientWithSubscribeToPayer & ClientWithWallet>(config, ['payer']);
}

/**
Expand Down
Loading
Loading