Skip to content

Commit d2a5cf2

Browse files
authored
Scaffold/design for the wallet plugin (#173)
* Scaffold/design for the wallet plugin * Update wallet plugin scaffold - All state is now exclusively stored on the snapshot, including the signer - Fallback payer is no longer used - Split into 2 plugins, `wallet` which does not touch payer, and `walletAsPayer` which replaces `client.payer` with the selected wallet * Refactor to use withCleanup for plugin cleanup * Update wallet plugin scaffold design * Split code into store/types/wallet * Minor tweaks * Update spec to address issues found when implementing * Use WalletNotConnectedError from Kit * Refactor so `walletAsPayer` returns a `ClientWithPayer` and throws if payer is not present * Add walletIdentity and walletSigner * Update readme for scaffold * Fix types of wallet plugins + add typetests * Loosen the type of `chain` to allow non-solana chains This better matches wallet standard and provides an escape hatch * Add abort signals to async wallet operations * Add missing rootDir to tsconfig declarations * Fix lint
1 parent 04eae45 commit d2a5cf2

17 files changed

Lines changed: 2806 additions & 0 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dist/
2+
test-ledger/
3+
target/
4+
CHANGELOG.md

packages/kit-plugin-wallet/LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Anza
4+
5+
Permission is hereby granted, free of charge, to any person obtaining
6+
a copy of this software and associated documentation files (the
7+
"Software"), to deal in the Software without restriction, including
8+
without limitation the rights to use, copy, modify, merge, publish,
9+
distribute, sublicense, and/or sell copies of the Software, and to
10+
permit persons to whom the Software is furnished to do so, subject to
11+
the following conditions:
12+
13+
The above copyright notice and this permission notice shall be
14+
included in all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# Kit Plugins ➤ Wallet
2+
3+
[![npm][npm-image]][npm-url]
4+
[![npm-downloads][npm-downloads-image]][npm-url]
5+
6+
[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/kit-plugin-wallet.svg?style=flat
7+
[npm-image]: https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat&label=%40solana%2Fkit-plugin-wallet
8+
[npm-url]: https://www.npmjs.com/package/@solana/kit-plugin-wallet
9+
10+
This package provides plugins that add browser wallet support to your Kit clients using [wallet-standard](https://github.com/wallet-standard/wallet-standard). They handle wallet discovery, connection lifecycle, account selection, and signer creation.
11+
12+
Four plugin functions are exported — each adds a `client.wallet` namespace, but they differ in how the connected wallet's signer is exposed on the client:
13+
14+
| Plugin | `client.payer` | `client.identity` | Use case |
15+
| --------------------- | -------------- | ----------------- | ------------------------------------------------------------- |
16+
| `walletSigner` | wallet signer | wallet signer | Most dApps — wallet pays fees and signs as the user identity. |
17+
| `walletPayer` | wallet signer || Wallet pays fees; identity is managed separately. |
18+
| `walletIdentity` || wallet signer | Backend relayer pays fees; wallet provides user identity. |
19+
| `walletWithoutSigner` ||| Wallet state only; payer and identity are managed separately. |
20+
21+
## Installation
22+
23+
```sh
24+
pnpm install @solana/kit-plugin-wallet
25+
```
26+
27+
## Quick start
28+
29+
```ts
30+
import { createClient } from '@solana/kit';
31+
import { solanaRpc } from '@solana/kit-plugin-rpc';
32+
import { walletSigner } from '@solana/kit-plugin-wallet';
33+
import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan';
34+
35+
const client = createClient()
36+
.use(walletSigner({ chain: 'solana:mainnet' }))
37+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
38+
.use(planAndSendTransactions());
39+
40+
// Read discovered wallets from state
41+
const { wallets } = client.wallet.getState();
42+
43+
// Connect a wallet
44+
await client.wallet.connect(wallets[0]);
45+
46+
// client.payer and client.identity are now the connected wallet's signer
47+
await client.sendTransaction([myInstruction]);
48+
```
49+
50+
## `walletSigner` plugin
51+
52+
Syncs the connected wallet's signer to both `client.payer` and `client.identity`. This is the most common choice for dApps where the user's wallet pays fees and signs as the transaction identity.
53+
54+
```ts
55+
import { walletSigner } from '@solana/kit-plugin-wallet';
56+
57+
const client = createClient()
58+
.use(walletSigner({ chain: 'solana:mainnet' }))
59+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
60+
.use(planAndSendTransactions());
61+
```
62+
63+
## `walletPayer` plugin
64+
65+
Syncs the connected wallet's signer to `client.payer` only. Use this when you need the wallet as the fee payer but don't need `client.identity`. For most dApps, prefer `walletSigner` which sets both.
66+
67+
```ts
68+
import { walletPayer } from '@solana/kit-plugin-wallet';
69+
70+
const client = createClient()
71+
.use(walletPayer({ chain: 'solana:mainnet' }))
72+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
73+
.use(planAndSendTransactions());
74+
```
75+
76+
## `walletIdentity` plugin
77+
78+
Syncs the connected wallet's signer to `client.identity` only. Use this when a separate payer (e.g. a backend relayer) pays transaction fees, but the user's wallet is needed as the identity signer.
79+
80+
```ts
81+
import { payer } from '@solana/kit-plugin-signer';
82+
import { walletIdentity } from '@solana/kit-plugin-wallet';
83+
84+
const client = createClient()
85+
.use(payer(relayerKeypair))
86+
.use(walletIdentity({ chain: 'solana:mainnet' }))
87+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
88+
.use(planAndSendTransactions());
89+
90+
// client.payer is always relayerKeypair
91+
// client.identity is the connected wallet's signer
92+
```
93+
94+
## `walletWithoutSigner` plugin
95+
96+
Adds `client.wallet` without setting `client.payer` or `client.identity`. Use this alongside separate `payer()` and/or `identity()` plugins, or when the wallet's signer is used explicitly in instructions.
97+
98+
```ts
99+
import { payer } from '@solana/kit-plugin-signer';
100+
import { walletWithoutSigner } from '@solana/kit-plugin-wallet';
101+
102+
const client = createClient()
103+
.use(payer(backendKeypair))
104+
.use(walletWithoutSigner({ chain: 'solana:mainnet' }))
105+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
106+
.use(planAndSendTransactions());
107+
108+
// client.payer is always backendKeypair
109+
// client.wallet.getState().connected?.signer for manual use
110+
```
111+
112+
## State and actions
113+
114+
All wallet state is accessed via `client.wallet.getState()`, which returns a referentially stable `WalletState` object (new reference only when something changes).
115+
116+
- **`getState().wallets`** — All discovered wallets that support the configured chain.
117+
118+
```ts
119+
const { wallets } = client.wallet.getState();
120+
for (const w of wallets) {
121+
console.log(w.name, w.icon);
122+
}
123+
```
124+
125+
- **`getState().connected`**The active connection (wallet, account, and signer), or `null` when disconnected.
126+
127+
```ts
128+
const { connected } = client.wallet.getState();
129+
console.log(connected?.account.address);
130+
```
131+
132+
- **`getState().status`**The current connection status: `'pending'`, `'disconnected'`, `'connecting'`, `'connected'`, `'disconnecting'`, or `'reconnecting'`.
133+
134+
- **`connect(wallet)`**Connect to a wallet and select the first newly authorized account.
135+
136+
```ts
137+
const accounts = await client.wallet.connect(selectedWallet);
138+
```
139+
140+
- **`disconnect()`**Disconnect the active wallet.
141+
142+
- **`selectAccount(account)`**Switch to a different account within an already-authorized wallet without reconnecting.
143+
144+
```ts
145+
client.wallet.selectAccount(accounts[0]);
146+
```
147+
148+
- **`signMessage(message)`**Sign a raw message with the connected account.
149+
150+
```ts
151+
const signature = await client.wallet.signMessage(new TextEncoder().encode('Hello'));
152+
```
153+
154+
- **`signIn(wallet, input)`**Sign In With Solana (SIWS-as-connect). Connects the wallet, calls `solana:signIn`, and sets up full connection state. Pass `{}` for `input` if no sign-in customization is needed. To sign in with the already-connected wallet, pass `getState().connected.wallet`.
155+
156+
```ts
157+
const output = await client.wallet.signIn(selectedWallet, { domain: window.location.host });
158+
```
159+
160+
## Framework integration
161+
162+
The plugin exposes `subscribe` and `getState` for binding wallet state to any UI framework.
163+
164+
**React**use `useSyncExternalStore` for concurrent-mode-safe rendering:
165+
166+
```tsx
167+
import { useSyncExternalStore } from 'react';
168+
169+
function useWalletState() {
170+
return useSyncExternalStore(client.wallet.subscribe, client.wallet.getState);
171+
}
172+
173+
function App() {
174+
const { wallets, connected, status } = useWalletState();
175+
176+
if (status === 'pending') return null; // avoid flashing a connect button before auto-reconnect
177+
178+
if (!connected) {
179+
return wallets.map(w => (
180+
<button key={w.name} onClick={() => client.wallet.connect(w)}>
181+
{w.name}
182+
</button>
183+
));
184+
}
185+
186+
return <p>Connected: {connected.account.address}</p>;
187+
}
188+
```
189+
190+
**Vue**use a `shallowRef` composable:
191+
192+
```ts
193+
import { onMounted, onUnmounted, shallowRef } from 'vue';
194+
195+
function useWalletState() {
196+
const state = shallowRef(client.wallet.getState());
197+
onMounted(() => {
198+
const unsub = client.wallet.subscribe(() => {
199+
state.value = client.wallet.getState();
200+
});
201+
onUnmounted(unsub);
202+
});
203+
return state;
204+
}
205+
```
206+
207+
**Svelte**wrap in a `readable` store:
208+
209+
```ts
210+
import { readable } from 'svelte/store';
211+
212+
export const walletState = readable(client.wallet.getState(), set => {
213+
return client.wallet.subscribe(() => set(client.wallet.getState()));
214+
});
215+
```
216+
217+
## Configuration
218+
219+
```ts
220+
walletSigner({
221+
chain: 'solana:mainnet', // required
222+
storage: sessionStorage, // default: localStorage (null to disable)
223+
storageKey: 'my-app:wallet', // default: 'kit-wallet'
224+
autoConnect: false, // default: true (disable silent reconnect)
225+
filter: w => w.features.includes('solana:signAndSendTransaction'), // optional
226+
});
227+
```
228+
229+
## Persistence
230+
231+
By default the plugin uses `localStorage` to remember the last connected wallet and auto-reconnects on the next page load. Pass `storage: null` to disable, or provide a custom adapter (e.g. `sessionStorage` or an IndexedDB wrapper).
232+
233+
## SSR / server-side rendering
234+
235+
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.
236+
237+
```ts
238+
const client = createClient()
239+
.use(solanaRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }))
240+
.use(walletSigner({ chain: 'solana:mainnet' }))
241+
.use(planAndSendTransactions());
242+
243+
// Server: status === 'pending', client.payer throws
244+
// Browser: auto-connects, client.payer becomes the wallet signer
245+
```
246+
247+
## Cleanup
248+
249+
The plugin implements `[Symbol.dispose]`, so it integrates with the `using` declaration or explicit disposal:
250+
251+
```ts
252+
{
253+
using client = createClient().use(walletSigner({ chain: 'solana:mainnet' }));
254+
// registry listeners and storage subscriptions are cleaned up on scope exit
255+
}
256+
```
257+
258+
Or call `[Symbol.dispose]()` explicitly when you're done with the client:
259+
260+
```ts
261+
const client = createClient().use(walletSigner({ chain: 'solana:mainnet' }));
262+
263+
// ... later, when the client is no longer needed
264+
client[Symbol.dispose]();
265+
```
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"name": "@solana/kit-plugin-wallet",
3+
"version": "0.1.0",
4+
"description": "Wallet connection plugin for Kit clients",
5+
"keywords": [
6+
"kit",
7+
"plugin",
8+
"signer",
9+
"solana",
10+
"wallet",
11+
"wallet-adapter",
12+
"wallet-standard"
13+
],
14+
"bugs": {
15+
"url": "http://github.com/anza-xyz/kit-plugins/issues"
16+
},
17+
"license": "MIT",
18+
"repository": {
19+
"type": "git",
20+
"url": "https://github.com/anza-xyz/kit-plugins"
21+
},
22+
"files": [
23+
"./dist/types",
24+
"./dist/index.*",
25+
"./src/"
26+
],
27+
"type": "commonjs",
28+
"sideEffects": false,
29+
"main": "./dist/index.node.cjs",
30+
"module": "./dist/index.node.mjs",
31+
"browser": {
32+
"./dist/index.node.cjs": "./dist/index.browser.cjs",
33+
"./dist/index.node.mjs": "./dist/index.browser.mjs"
34+
},
35+
"types": "./dist/types/index.d.ts",
36+
"react-native": "./dist/index.react-native.mjs",
37+
"exports": {
38+
"types": "./dist/types/index.d.ts",
39+
"react-native": "./dist/index.react-native.mjs",
40+
"browser": {
41+
"import": "./dist/index.browser.mjs",
42+
"require": "./dist/index.browser.cjs"
43+
},
44+
"node": {
45+
"import": "./dist/index.node.mjs",
46+
"require": "./dist/index.node.cjs"
47+
}
48+
},
49+
"scripts": {
50+
"build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json",
51+
"dev": "vitest --project node",
52+
"lint": "eslint . && prettier --check .",
53+
"lint:fix": "eslint --fix . && prettier --write .",
54+
"test": "pnpm test:types && pnpm test:treeshakability",
55+
"test:treeshakability": "for file in dist/index.*.mjs; do agadoo $file; done",
56+
"test:types": "tsc --noEmit"
57+
},
58+
"dependencies": {
59+
"@solana/wallet-account-signer": "^6.6.0",
60+
"@solana/wallet-standard-chains": "^1.1.1",
61+
"@solana/wallet-standard-features": "^1.3.0",
62+
"@wallet-standard/app": "^1.1.0",
63+
"@wallet-standard/base": "^1.1.0",
64+
"@wallet-standard/errors": "^0.1.1",
65+
"@wallet-standard/features": "^1.1.0",
66+
"@wallet-standard/ui": "^1.0.1",
67+
"@wallet-standard/ui-features": "^1.0.1",
68+
"@wallet-standard/ui-registry": "^1.0.1"
69+
},
70+
"peerDependencies": {
71+
"@solana/kit": "^6.6.0"
72+
},
73+
"browserslist": [
74+
"supports bigint and not dead",
75+
"maintained node versions"
76+
]
77+
}

0 commit comments

Comments
 (0)