Conversation
|
d88512c to
2435b0e
Compare
amilz
left a comment
There was a problem hiding this comment.
Awesome. Great work w/ this!
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
This PR scaffolds the @solana/kit-plugin-wallet package — a framework-agnostic wallet plugin that manages wallet discovery, connection lifecycle, signer creation, and payer integration via wallet-standard. The implementation is stub-only (createWalletStore throws not implemented), so this is purely the type system, API surface, configuration design, and package scaffolding.
The design is very well thought out. The API surface is clean, the subscribe/getSnapshot pattern for framework integration is the right call, and the SSR story with pending status is elegant. The spec included in wallet-plugin-spec.md provides excellent context for the implementation that will follow.
Key observations
Things that look great
hasSignerin snapshot vssignerinWalletConnection— smart separation to avoid unnecessary re-renders while still exposing the actual signer for instruction building.pendingvsdisconnecteddistinction — avoids the UI flash problem on SSR/auto-reconnect. This is a common pain point in wallet adapters.usePayerwith fallback capture — the dynamic getter approach with fallback to the previousclient.payeris clean. Plugin ordering (payerbeforewallet) is well-documented.WalletStorageinterface — supporting async backends while being compatible withlocalStorage/sessionStoragedirectly is practical.signInoverloads — the two-argument form (SIWS-as-connect) is a nice ergonomic touch.
Things to watch during implementation
- Race conditions in
connect/disconnect— concurrent calls (user spam-clicks connect, or connects during auto-reconnect) need careful state machine transitions. The spec mentions this but it'll be the trickiest part ofcreateWalletStore. - Signer caching invalidation — when a wallet's accounts change externally (via
standard:events), cached signers for removed accounts need cleanup. TheWalletStoreStatehas a singlesignerfield, so this should be straightforward, but worth verifying during implementation. fallbackPayercapture timing — the spec shows capturingclient.payerat plugin install time. If the prior payer is itself a dynamic getter (from another plugin), you'll want to capture the descriptor, not the value, to preserve the getter chain. Or at least document that the fallback is evaluated once.
Minor items
See inline comments below.
Notes for subsequent reviewers
- The
wallet-plugin-spec.md(1299 lines) is context-only and not intended for permanent inclusion in the repo — author has noted this. - The
createWalletStorefunction is the heart of this plugin and is entirely stubbed. The real review work comes when that's implemented. @solana/kitis listed as both apeerDependencyand resolved as a regular dependency in the lockfile — worth checking if this is intentional or if it should only be a peer dep.- The
extendClientimport from@solana/kitis present but unused in the current scaffold (the plugin function implementation is truncated in the diff, but the store is a stub anyway).
9ff1b02 to
ca6a002
Compare
|
I've updated this PR with a new commit: ca6a002 This is a large update and addresses a lot of what we've talked about:
I've also updated the description, and the demos - which already mostly used the reactive subscribe/snapshot APIs so have minimal changes. Also dependabot bumped us to Kit 6.6.0, so I've updated that dependency here and used |
|
I've made another update:
|
36c665b to
1aeb4ed
Compare
|
To flag, I've realised that For now I think it'll be best to just leave these types in the plugin itself, as the current version of this PR does. |
b6c41e3 to
963206a
Compare
|
I've split the code into three different files (types/store/wallet). This is annoying for the diff, but better to do it here than in the implementation. Note that types and wallet are exported from the package, store is not. |
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
This PR scaffolds the @solana/kit-plugin-wallet package, establishing the type system, plugin entry points (wallet and walletAsPayer), and a stub store — without yet implementing the wallet discovery/connection logic. The design is well thought out: clean separation between types, store, and plugin wiring; a subscribe/getState API that maps directly onto framework primitives (useSyncExternalStore, Svelte readable, Vue shallowRef); SSR safety via the pending state; and a pluggable storage adapter for persistence.
Key observations
Design looks solid. The split into wallet() vs walletAsPayer() is clean and gives users the right level of control. The WalletSigner type alias is a nice addition. The Object.defineProperty approach for the dynamic payer getter in walletAsPayer is the right call.
Types are thorough and well-documented. The JSDoc throughout types.ts is excellent — each type, field, and method has clear documentation with examples where helpful.
Things to watch for in subsequent reviews / during implementation
- The
WalletNamespaceJSDoc references{@link getSnapshot}(line 137 of types.ts) but the method is calledgetState. Should be{@link getState}. - The
payergetter inwalletAsPayercoercesnullsigner toundefinedvia?? undefined. This is correct for the type (TransactionSigner | undefined) but worth a comment explaining why —connected?.signercan benullfor read-only wallets, while thepayerslot expectsundefinedfor "no payer". @solana/kitappears as apeerDependencybut also gets resolved as a regular dependency in the lockfile (thepackages/kit-plugin-walletsection inpnpm-lock.yamllists it underdependencies). This seems to be a pnpm workspace resolution artifact, but worth double-checking it doesn't cause duplicate bundling.- The spec file (
wallet-plugin-spec.md) is at the repo root. The PR description mentions it'll be removed later — might be worth adding a TODO comment or tracking issue so it doesn't get forgotten. tsconfig.jsonuseslib: []which is consistent with the existingkit-pluginspackage. The implementation will need DOM types (forlocalStorage,windowchecks, etc.) — this will presumably be handled via the__BROWSER__global declarations and conditional compilation through tsup, matching the existing pattern.
2acdb76 to
548685c
Compare
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Added another commit: cb6f652 Addresses issues found in the spec/scaffold while implementing the plugin:
|
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review — scaffold looks good
This is a re-review after changes since my last pass. The previous issues I raised (stale getSnapshot JSDoc link, missing comment on the ?? undefined coercion) have both been addressed. The code is in good shape for a scaffold PR.
What changed
- File structure split into
types.ts,wallet.ts,store.ts— much cleaner than the single-file version. - The
wallet.tsplugin functions now properly usewithCleanupwrappingextendClientrather than spread. store.tsis a clean stub with the right shape (WalletStore = WalletNamespace & { [Symbol.dispose] }).
New observations
- README
signIndocs vs types mismatch: The README shows two forms —signIn(input?)(connected wallet) andsignIn(wallet, input?)(connect-and-sign-in). ButWalletNamespace.signInintypes.tsonly has the two-argument form(wallet: UiWallet, input: SolanaSignInInput). The README implies an overload that doesn't exist in the types. The spec also only has the two-argument form and explains the design choice. I'd update the README to match. store.tsnot exported fromindex.ts— intentional and correct (marked@internal), just confirming I noticed.
Previously raised — still relevant
- Error class resilience:
WalletNotConnectedErrorstill relies onthis.name = 'WalletNotConnectedError'which can break under minification if someone doeserror.name === 'WalletNotConnectedError'. A staticcodeproperty (like Kit'sSolanaError) would make this robust. The TODO comment about moving this to Kit proper addresses this long-term. @solana/kitin lockfile: Still appears as a resolved regular dep underpackages/kit-plugin-walletin the lockfile despite being listed only as apeerDependencyinpackage.json. Likely a pnpm workspace artifact, but worth verifying it doesn't cause duplicate bundling.
For subsequent reviewers
- The spec file at the repo root (
wallet-plugin-spec.md) is intended to be temporary — the PR description mentions removing it later. - The
tsconfig.jsonuseslib: []— this is consistent with the repo pattern; DOM types come in via the__BROWSER__compile-time constants and tsup's multi-target build. - The store implementation is entirely stubbed — the interesting review will be when
createWalletStoregets its real implementation.
| get() { | ||
| return store.getState().connected?.signer ?? undefined; | ||
| }, |
There was a problem hiding this comment.
I don't think a plugin that adds Partial<ClientWithPayer> to the client is going to be very useful because none of the plugin requiring ClientWithPayer will accept it.
I think it's best to return a ClientWithPayer directly and, when the wallet isn't connected, throw an error when trying to access client.payer with a message showing the wallet status.
There was a problem hiding this comment.
I'm happy with doing it that way too. I didn't for the same concern you mentioned above - claiming to implement the type but then throwing a runtime error.
I'm not sure if this changes in your open PRs but I based this decision on the fact that plugins like rpcTransactionPlanner were using Partial<ClientWithPayer> as their input client type, so it would be accepted for that.
I don't feel strongly either way though, especially if that has changed/will change.
There was a problem hiding this comment.
That rpcTransactionPlanner design is more of an exception than the rule and to be honest, now that we're fully leaning into the plugin design (i.e. not using client factories), I should totally change that plugin requirement so it requests ClientWithPayer instead of throwing a runtime error when both are not provided.
Just about every other plugin (including the program plugins) will end up requesting ClientWithPayer so Partial<ClientWithPayer> isn't gonna take us very far.
I think if the goal is making sure SSR hydration works but really you're designing the client plugin, then I think we'll make the developer experience more enjoyable with ClientWithPayer.
There was a problem hiding this comment.
Makes sense, I'm happy to update to set ClientWithPayer (and also ClientWithIdentity when we add that)
There was a problem hiding this comment.
Made a quick PR for the Partial<ClientWithPayer> removal: #192.
There was a problem hiding this comment.
Updated this, it now returns ClientWithPayer. I'll open a PR on Kit with the errors proposed for the case where it isn't set.
| // Wallet as payer — most dApps | ||
| const client = createEmptyClient() | ||
| .use(rpc('https://api.mainnet-beta.solana.com')) | ||
| .use(walletAsPayer({ chain: 'solana:mainnet' })) | ||
| .use(planAndSendTransactions()); | ||
| // client.payer is TransactionSigner | undefined | ||
|
|
||
| // Wallet alongside a static payer | ||
| const client = createEmptyClient() | ||
| .use(rpc('https://api.mainnet-beta.solana.com')) | ||
| .use(payer(backendKeypair)) | ||
| .use(wallet({ chain: 'solana:mainnet' })) | ||
| .use(planAndSendTransactions()); | ||
| // client.payer is TransactionSigner (from payer plugin, untouched) | ||
| // client.wallet.getState().connected?.signer for manual use | ||
| ``` |
There was a problem hiding this comment.
As discussed offline, the new @solana/kit-plugin-signer package provides a family of helpers for each kind of signer strategy. For instance, generatedPayer/generatedIdentity/generatedSigner all add a new generated signer to the client such that the first one sets client.payer, the second sets client.identity and the last sets both using the same signer object (the one most users will end up using).
As such, we should aim to unify these APIs since the wallet plugin is able to set signers on the client too.
The main difference is the wallet plugin also sets client.wallet which acts as a "control centre" for managing connected wallets. Therefore we could go with something like:
walletSigner: Setsclient.wallet,client.payerandclient.identity(the most popular entrypoint).walletIdentity: Setsclient.wallet,client.identity(the second most popular entrypoint for dApps that have a different payer signer).walletPayer: Setsclient.wallet,client.payer.walletWithoutSigner: Setsclient.walletonly (more verbose to discourage users to default to using it).
One other thing to consider is: could we technically select two different wallets as the client.identity and client.payer signers? If so, we should consider if it'd make sense for client.identity and client.payer to own their own client.wallet stores internally (or a subset of it).
There was a problem hiding this comment.
Agree that if we add all these entrypoints then we should make the walletWithoutSigner more verbose.
I'd lean against exposing different client.wallet stores for identity and payer though. Technically they could be independent signers, both implemented by a wallet. But that doesn't require different client.wallet stores, the Signer contains everything they need, the fact they internally call to a wallet is implementation detail.
While the client.wallet store only stores one selected wallet/account/signer in client.wallet.connected, you have access to all connected accounts for all wallets in client.wallet.getState().wallets. And you can use createSignerFromWalletAccount from Kit to get a signer for any of those.
So I think in the unusual case that you want to have signers for multiple wallet accounts and set them as payer/identity, you have everything you need with just one client.wallet store, and your own custom identity/payer logic.
The one thing that would be arguably useful is to allow them to store the equivalent of client.wallet.connected, ie the wallet/account/signer. But I'm not sure how we could structure payer/identity to handle that without complicating all the non-wallet use cases, so I think app specific logic is probably the right way to deal with that.
There was a problem hiding this comment.
I agree that having a single client.wallet store makes sense even if you have multiple selected wallets as client.payer and client.identity. It's just the connected state I'm unsure of (if we end up supporting multiple wallet selected). Theoretically there's more information you could store inside a client.payer and client.identity so that could move there. I'm just not sure what the high level API looks like for connecting two different wallets. Is that two plugins: walletPayer and walletIdentity and the second call doesn't override the existing client.wallet? How do you then select a payer wallet versus an identity wallet?
Maybe this is out-of-scope for now though and, if this is needed in the future, it would materialise as a separate plugin. I just wanted to make sure we were not missing a design piece by not analysing this use-case.
There was a problem hiding this comment.
In practice an app juggling multiple connected wallets would probably want to just use wallet-standard directly:
const connectFeature = getWalletFeature(
uiWallet,
StandardConnect,
) as StandardConnectFeature[typeof StandardConnect];
// Snapshot existing accounts before connect.
const existingAccounts = [...uiWallet.accounts];
const updatedAccounts = await connectFeature.connect();
// persist connected accounts / convert to signer if neededSo you'd bypass client.wallet.getState().connected, and just be using the wallet plugin for tracking registered wallets/all their connected accounts/event handlers etc. and handle mapping them to signers yourself.
There is a missing piece there for setting those signers to identity/payer though, but I think that being out of scope is ok. If you wanted to do it yourself you'd probably create a custom plugin that exposes a setter for each of them, and then in your app logic where you handle connecting wallets you'd call those setters.
There was a problem hiding this comment.
Updated to the suggestion at the top of this thread:
- walletSigner: Sets client.wallet, client.payer and client.identity (the most popular entrypoint).
- walletIdentity: Sets client.wallet, client.identity (the second most popular entrypoint for dApps that have a different payer signer).
- walletPayer: Sets client.wallet, client.payer.
- walletWithoutSigner: Sets client.wallet only (more verbose to discourage users to default to using it).
|
|
||
| **Descriptor-preserving composition.** The plugin uses `extendClient` from plugin-core to build the client, preserving getters and symbol-keyed properties from previous plugins. The updated `addUse` also preserves descriptors through `.use()` calls. Downstream plugins should use `extendClient` rather than spread to avoid flattening the dynamic payer getter. | ||
|
|
||
| **Single chain per client.** Signers are bound to a specific chain at creation time. Switching chains requires a different RPC endpoint too. One client = one network. |
There was a problem hiding this comment.
See for me if we have the design principle "1 client = 1 network", then I don't think it's unexpected to also have the design principle "1 client = 1 environment" (i.e. client vs server).
There was a problem hiding this comment.
This would be the same as #173 (comment) - treating SSR as an environment where you should be able to use the same client as the browser for developer convenience.
cb6f652 to
20a2e6b
Compare
f41ae68 to
4e36bfb
Compare
aae592e to
2274788
Compare
4e36bfb to
67b7f09
Compare
4362565 to
536a1d3
Compare
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review — Scaffold/design for the wallet plugin
What changed since last review
Big design evolution: the two plugins (wallet, walletAsPayer) are now four (walletSigner, walletPayer, walletIdentity, walletWithoutSigner). This is a much cleaner design — the 4-way matrix covering payer × identity gives users exactly the right level of control without needing to understand the internals.
The createPlugin + defineSignerGetter pattern in wallet.ts is elegant — one generic factory with the signer properties parameterized. The two distinct error cases (no wallet connected vs. read-only wallet) in the getter are a nice touch.
Issues
🐛 Type bug: Omit<T, 'identity' | 'payer' | 'wallet'> is over-broad — see inline comment. This will strip payer/identity from the input type even for plugins that don't replace them.
📝 Stale JSDoc references — a couple of {@link wallet} / {@link walletAsPayer} references survived the rename. See inline comments.
Still relevant from prior reviews
@solana/kitappearing as a resolved regular dependency in the lockfile despite being declared aspeerDependenciesonly inpackage.json. Thepackages/kit-plugin-walletsection inpnpm-lock.yamllists it underdependencies. This is likely a pnpm workspace resolution artifact, but worth verifying it doesn't cause duplicate bundling for consumers.
| client: T, | ||
| ): Disposable & Omit<T, 'identity' | 'payer' | 'wallet'> & TAdditions => { | ||
| if ('wallet' in client) { | ||
| throw new Error( |
There was a problem hiding this comment.
The Omit<T, 'identity' | 'payer' | 'wallet'> always strips both identity and payer from the incoming type, even for plugins that don't set them. This means:
const client = createClient()
.use(identity(myKeypair)) // sets client.identity
.use(walletWithoutSigner({ ... })); // should only add wallet
client.identity; // ← type error, but the value is still there at runtimeSame issue with walletPayer stripping identity and walletIdentity stripping payer from the preceding chain.
The Omit should only strip properties that the plugin actually re-adds. One approach:
function createPlugin<TAdditions extends ClientWithWallet>(
config: WalletPluginConfig,
signerProperties: string[],
) {
return <T extends object & { wallet?: never }>(
client: T,
): Disposable & Omit<T, keyof TAdditions> & TAdditions => { ... };
}That way walletWithoutSigner only omits wallet, walletPayer omits wallet + payer, etc.
There was a problem hiding this comment.
Fixed + added typetests to verify the behaviour of types of these plugins (🤞 these won't summon copilot spam)
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review — Scaffold/design for the wallet plugin
Both issues from my last review have been addressed:
Omittype bug — Fixed.createPluginnow correctly usesOmit<T, keyof TAdditions>, so each variant only strips the properties it actually adds. ✅- Stale JSDoc references — Fixed.
WalletPluginConfigdocs now correctly referencewalletSigner,walletPayer,walletIdentity,walletWithoutSigner. ✅
Type tests
Great addition. The new wallet-typetest.ts covers all four plugin variants and validates:
- Each plugin adds the expected properties (
payer,identity,wallet) - Plugins don't strip previously-set properties they don't own (the
Omitfix) - Only one wallet plugin can be used per client (via
{ wallet?: never }constraint)
The scaffold looks good to me — no new issues found.
Still noting (non-blocking)
@solana/kitappearing as a resolved regular dependency inpnpm-lock.yamldespite being declared peer-only inpackage.json. Likely a pnpm workspace artifact, but worth a quick check that consumers don't get a duplicate bundle.
lorisleiva
left a comment
There was a problem hiding this comment.
Nice, thanks for making the walletIdentity/walletPayer/walletSigner changes to be consistent with @solana/kit-plugin-signers.
|
Pushed a small change to widen the type of |
c316df1 to
bd0ca1c
Compare
- All state is now exclusively stored on the snapshot, including the signer - Fallback payer is no longer used - Split into 2 plugins, `wallet` which does not touch payer, and `walletAsPayer` which replaces `client.payer` with the selected wallet
… payer is not present
This better matches wallet standard and provides an escape hatch
bd0ca1c to
c3c284e
Compare

This PR adds the scaffolding and design of the proposed wallet plugin, without yet implementing it
The plugin is designed to expose the interface expected by reactive frameworks, eg React's useSyncExternalStore.
Example (Vanilla JS)
Key functionality
standard:connectfeature, as well as an optionalfilterthat can be passed to the plugin. ReturnsUiWallet[]connect(wallet: UiWallet), we derive aSignerfor the connected wallet account. This will include all available signer features of modify + send tx, sign + send tx, modify + sign message. See Kit function: https://github.com/anza-xyz/kit/blob/f055201c2dd3a4a69b9894d66b622ae81c13b8cd/packages/wallet-account-signer/src/wallet-account-signer.ts#L68. If the wallet account is read-only, we don't set the signer but do set wallet + accountwalletSigner,walletIdentity,walletPayer,walletWithoutSigner. All provide an identicalclient.walletinterface. The only difference is which ofclient.identityandclient.payerthey control.storage: null, and is always disabled on the server.autoConnectcan be independently disabled while using storage too.getState()andsubscribe()APIs.getStatereturns a referentially stable object describing the state, andsubscribeallows registering listeners that are notified whenever state changes. Together these enable a reactive store like React'suseSyncExternalStore, Svelte'sreadableand Vue's reactive state.pendingstate, and all functions will throw. But consumers can safely include it in their SSR pass for simplicity. Thependingstate is intentionally distinct fromdisconnectedto allow eg not rendering/rendering a skeleton and avoid a UI flash from disconnected -> connectedImplementation notes/TODOs
We haven't published Kit since mergingwithCleanup, so for now this plugin implements[Symbol.Dispose]directly.~~I think we will need one new error added to Kit. For now this is just included as an error class in the plugin. I think it's justifiable in Kit because it'd be useful to any similar wallet functionality. ~~
We'd also want to add(I noticed we can't do this because of wallet-standard types, soClientWithWalletto kit plugin-interfaces later, and move some of the types thereClientWithWalletwill be exported from the plugin)More examples
I've had Claude generate a simple example app for React, Svelte, Vue and Vanilla JS: https://github.com/anza-xyz/kit-plugins/tree/wallet-plugin-demos-1/examples/wallet-demo/src . These are intended to show how different frameworks can bind to the state in this plugin and expose it to apps. They are not intended to be representative of how actual apps would be written with kit-plugins, but hopefully show how thin the framework-specific layer for wallets can be. One of my design goals is to never again upset someone who doesn't want to use React!
For full transparency, this PR includes a spec file. This is long and the result of lots of back and forth with an LLM, I don't expect anyone to review it and will later remove it from the repo. But wanted to include it here to give more context about how this will all work!