@kheopskit/core 5.3.0 → 6.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/MIGRATING_TO_V6.md +837 -0
  3. package/README.md +628 -12
  4. package/dist/ethereum.d.mts +12 -6
  5. package/dist/ethereum.d.ts +12 -6
  6. package/dist/ethereum.js +13 -10
  7. package/dist/ethereum.js.map +1 -1
  8. package/dist/ethereum.mjs +7 -4
  9. package/dist/ethereum.mjs.map +1 -1
  10. package/dist/{iconCache-pP2ES4pJ.mjs → iconCache-C-U3IEbP.mjs} +21 -40
  11. package/dist/iconCache-C-U3IEbP.mjs.map +1 -0
  12. package/dist/{iconCache-DUtkJlaX.js → iconCache-FR4iy6go.js} +21 -40
  13. package/dist/iconCache-FR4iy6go.js.map +1 -0
  14. package/dist/index.d.mts +17 -63
  15. package/dist/index.d.ts +17 -63
  16. package/dist/index.js +387 -373
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +384 -369
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/injectedWallets-48L-AL1I.mjs +173 -0
  21. package/dist/injectedWallets-48L-AL1I.mjs.map +1 -0
  22. package/dist/injectedWallets-Dy8YGKL-.js +178 -0
  23. package/dist/injectedWallets-Dy8YGKL-.js.map +1 -0
  24. package/dist/internal.d.mts +3 -27
  25. package/dist/internal.d.ts +3 -27
  26. package/dist/internal.js +2 -2
  27. package/dist/internal.mjs +2 -2
  28. package/dist/pageLoad-BZ7o8p5R.mjs +24 -0
  29. package/dist/pageLoad-BZ7o8p5R.mjs.map +1 -0
  30. package/dist/pageLoad-D1wLeuFG.js +28 -0
  31. package/dist/pageLoad-D1wLeuFG.js.map +1 -0
  32. package/dist/platformAccounts-BjIeRcFN.js +321 -0
  33. package/dist/platformAccounts-BjIeRcFN.js.map +1 -0
  34. package/dist/platformAccounts-yyRUrnel.mjs +256 -0
  35. package/dist/platformAccounts-yyRUrnel.mjs.map +1 -0
  36. package/dist/polkadot.d.mts +127 -11
  37. package/dist/polkadot.d.ts +127 -11
  38. package/dist/polkadot.js +170 -106
  39. package/dist/polkadot.js.map +1 -1
  40. package/dist/polkadot.mjs +163 -99
  41. package/dist/polkadot.mjs.map +1 -1
  42. package/dist/solana.d.mts +11 -5
  43. package/dist/solana.d.ts +11 -5
  44. package/dist/solana.js +18 -15
  45. package/dist/solana.js.map +1 -1
  46. package/dist/solana.mjs +7 -4
  47. package/dist/solana.mjs.map +1 -1
  48. package/dist/storage-D3AwNgqc.d.mts +27 -0
  49. package/dist/storage-D3AwNgqc.d.ts +27 -0
  50. package/dist/testing.d.mts +71 -0
  51. package/dist/testing.d.ts +71 -0
  52. package/dist/testing.js +136 -0
  53. package/dist/testing.js.map +1 -0
  54. package/dist/testing.mjs +131 -0
  55. package/dist/testing.mjs.map +1 -0
  56. package/dist/{types-DKU-SNio.js → types-BWHvNFlN.js} +149 -2
  57. package/dist/types-BWHvNFlN.js.map +1 -0
  58. package/dist/types-Dkyt9bf4.d.mts +683 -0
  59. package/dist/types-Dkyt9bf4.d.ts +683 -0
  60. package/dist/types-Yor672md.mjs +215 -0
  61. package/dist/types-Yor672md.mjs.map +1 -0
  62. package/dist/walletconnect.d.mts +41 -0
  63. package/dist/walletconnect.d.ts +41 -0
  64. package/dist/walletconnect.js +219 -0
  65. package/dist/walletconnect.js.map +1 -0
  66. package/dist/walletconnect.mjs +216 -0
  67. package/dist/walletconnect.mjs.map +1 -0
  68. package/package.json +30 -6
  69. package/dist/errors-BLFP1dQI.mjs +0 -181
  70. package/dist/errors-BLFP1dQI.mjs.map +0 -1
  71. package/dist/errors-DYWVPHrR.js +0 -222
  72. package/dist/errors-DYWVPHrR.js.map +0 -1
  73. package/dist/iconCache-DUtkJlaX.js.map +0 -1
  74. package/dist/iconCache-pP2ES4pJ.mjs.map +0 -1
  75. package/dist/injectedWallets-BqR-_aAy.js +0 -148
  76. package/dist/injectedWallets-BqR-_aAy.js.map +0 -1
  77. package/dist/injectedWallets-Uz4hPuoW.mjs +0 -119
  78. package/dist/injectedWallets-Uz4hPuoW.mjs.map +0 -1
  79. package/dist/types-AxUDtkR-.d.mts +0 -338
  80. package/dist/types-AxUDtkR-.d.ts +0 -338
  81. package/dist/types-DKU-SNio.js.map +0 -1
  82. package/dist/types-jnifAAA0.mjs +0 -104
  83. package/dist/types-jnifAAA0.mjs.map +0 -1
package/README.md CHANGED
@@ -1,20 +1,28 @@
1
1
  # @kheopskit/core
2
2
 
3
- Framework-agnostic core for [Kheopskit](https://github.com/kheopskit/kheopskit) —
4
- list wallets and accounts across Polkadot, Ethereum and Solana, with injected
5
- wallets and WalletConnect (Reown AppKit).
3
+ Framework-agnostic core for
4
+ [Kheopskit](https://github.com/kheopskit/kheopskit). It lists wallets and
5
+ accounts across Polkadot, Ethereum and Solana, with injected wallets and
6
+ WalletConnect (Reown AppKit).
6
7
 
7
- For React, use [`@kheopskit/react`](https://www.npmjs.com/package/@kheopskit/react).
8
- Full docs and the interactive playground: https://github.com/kheopskit/kheopskit
8
+ For React, use
9
+ [`@kheopskit/react`](https://www.npmjs.com/package/@kheopskit/react). Full
10
+ docs and the interactive playground are at
11
+ https://github.com/kheopskit/kheopskit.
9
12
 
10
13
  > **Upgrading from v3?** v4 moves platforms to plugins and makes platform SDKs
11
14
  > (and WalletConnect) optional peer dependencies. See
12
15
  > [MIGRATING_TO_V4.md](./MIGRATING_TO_V4.md).
13
16
 
17
+ > **Upgrading from v5?** v6 makes `connect()` shared, idempotent and
18
+ > abortable, types its rejections, adds `status`, `isDiscovering`,
19
+ > `isPlaceholder` and `signRaw`, and moves WalletConnect to a plugin. See
20
+ > [MIGRATING_TO_V6.md](./MIGRATING_TO_V6.md).
21
+
14
22
  ## Install
15
23
 
16
- `rxjs` is always required; every platform SDK (and WalletConnect) is an optional
17
- peer dependency — install only what you use:
24
+ `rxjs` is always required. Every platform SDK (and WalletConnect) is an
25
+ optional peer dependency, so install only what you use:
18
26
 
19
27
  ```bash
20
28
  pnpm add @kheopskit/core rxjs
@@ -28,6 +36,34 @@ pnpm add @reown/appkit # WalletConnect
28
36
  A Polkadot-only dapp that doesn't use WalletConnect installs none of the
29
37
  Ethereum/Solana SDKs nor `@reown/appkit`, and none of their code is bundled.
30
38
 
39
+ WalletConnect is a plugin from its own entry point, so only a dapp that imports
40
+ `@kheopskit/core/walletconnect` bundles AppKit:
41
+
42
+ ```ts
43
+ import { getKheopskit$ } from "@kheopskit/core";
44
+ import { polkadot } from "@kheopskit/core/polkadot";
45
+ import { walletConnect } from "@kheopskit/core/walletconnect";
46
+ import { defineChain } from "@reown/appkit/networks";
47
+
48
+ const polkadotNetwork = defineChain({
49
+ id: "91b171bb158e2d3848fa23a9f1c25182",
50
+ name: "Polkadot",
51
+ nativeCurrency: { name: "Polkadot", symbol: "DOT", decimals: 10 },
52
+ rpcUrls: { default: { http: ["https://rpc.polkadot.io"] } },
53
+ chainNamespace: "polkadot",
54
+ caipNetworkId: "polkadot:91b171bb158e2d3848fa23a9f1c25182",
55
+ });
56
+
57
+ const kheopskit$ = getKheopskit$({
58
+ platforms: [polkadot()],
59
+ walletConnect: walletConnect({
60
+ projectId: "<your Reown project id>",
61
+ metadata: { name: "My Dapp", description: "", url: "https://my.dapp", icons: [] },
62
+ networks: [polkadotNetwork],
63
+ }),
64
+ });
65
+ ```
66
+
31
67
  ## Usage
32
68
 
33
69
  Platforms are enabled by passing **plugins** (imported from their entry points)
@@ -45,23 +81,603 @@ const kheopskit$ = getKheopskit$({
45
81
  ethereum(),
46
82
  solana({ chain: "solana:mainnet" }),
47
83
  ],
48
- appName: "My Dapp", // shown by Polkadot extensions; defaults to walletConnect.metadata.name, then the hostname
84
+ appName: "My Dapp", // shown by Polkadot extensions; defaults to the WalletConnect metadata name, then the hostname
49
85
  autoReconnect: true,
50
86
  });
51
87
 
52
- kheopskit$.subscribe(({ wallets, accounts, isHydrating }) => {
88
+ kheopskit$.subscribe(({ wallets, accounts, isHydrating, isDiscovering }) => {
53
89
  // ...
54
90
  });
55
91
  ```
56
92
 
93
+ [Wallet lifecycle](#wallet-lifecycle) explains what each field means over a
94
+ page load, with every default and delay.
95
+
57
96
  Each platform entry point also exports its SDK-precise types
58
97
  (`PolkadotAccount`, `EthereumAccount`, `SolanaAccount`, …). The root export
59
98
  carries the SDK-free `KheopskitConfig`, `KheopskitState`, `BaseWallet` and
60
99
  `BaseWalletAccount`.
61
100
 
62
- > While `state.isHydrating` is `true`, wallets/accounts are cached placeholders
63
- > carrying only the base fields — SDK fields (`signer`, `client`,
64
- > provider/extension handles) are absent until hydration completes.
101
+ ## Wallet lifecycle
102
+
103
+ A page load runs these steps in order. Kheopskit discovers the installed
104
+ wallets, paints the cached state as placeholders, reconnects the wallets the
105
+ user connected last time, ends hydration, and writes the new state to the
106
+ cache. A user's `connect()` or `disconnect()` can happen at any point. Each
107
+ subsection below gives the rules and the numbers, and links the source that
108
+ implements them.
109
+
110
+ ### Discovery
111
+
112
+ Source: [`api/polkadot/wallets.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/polkadot/wallets.ts),
113
+ [`utils/pageLoad.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/utils/pageLoad.ts),
114
+ [`api/kheopskit.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/kheopskit.ts).
115
+
116
+ Polkadot extensions register themselves on `window.injectedWeb3` and send no
117
+ event, so kheopskit reads that registry on a fixed schedule:
118
+
119
+ - At subscribe, then 100, 300, 600 and 1100 ms after subscribe.
120
+ - When `window` fires `load`, and again 100 ms after `load`. A subscription
121
+ that starts after `load` skips these two reads.
122
+
123
+ Nothing reads the registry after the last of these reads. A wallet that
124
+ injects after `load` is listed only when a read still runs after it, the one
125
+ 100 ms after `load` or a subscribe read still pending, so its discovery is not
126
+ guaranteed. Wallets inject `injectedWeb3` while the page loads, so a wallet
127
+ that injects after the last read is not listed until the next subscription,
128
+ usually the next page load. Kheopskit has no
129
+ `refreshWallets()` and does not read again on focus or on connect.
130
+
131
+ Ethereum (EIP-6963, through `mipd`) and Solana (Wallet Standard) wallets
132
+ announce themselves with events, so those lists update whenever a wallet
133
+ announces or unregisters.
134
+
135
+ `state.isDiscovering` tells you whether the list may still grow:
136
+
137
+ - `true` when the subscription started before `document.readyState` was
138
+ `"complete"`.
139
+ - It turns `false` in the first emission after `load` in which the registry was
140
+ read again. That emission already lists every extension injected by `load`.
141
+ Wait for it before you conclude that a wallet is missing.
142
+ - `false` from the first emission when you subscribe after `load`.
143
+ - Always `true` on the server.
144
+
145
+ It says nothing about wallets that inject after `load`. In the end-to-end test
146
+ `e2e/tests/discovery-at-load.spec.ts`, an extension injected 1500 ms after the
147
+ first read and before `load` showed in the wallet list 16.2 ms after `load` on
148
+ vite-react, and 15.8 to 18.3 ms across the three example apps (the test's
149
+ limit is 300 ms).
150
+
151
+ ### Hydration
152
+
153
+ Source: [`api/kheopskit.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/kheopskit.ts),
154
+ [`api/hydration.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/hydration.ts),
155
+ [`utils/createHydrationBuffer.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/utils/createHydrationBuffer.ts).
156
+
157
+ On a reload, the first emission shows the wallets and accounts of the previous
158
+ visit from the cache, so the list does not flash empty while the extensions
159
+ inject and the wallets reconnect. `state.isHydrating` is `true` while it does.
160
+ With nothing cached, or with `hydrationGracePeriod: 0`, `isHydrating` is
161
+ `false` from the first emission.
162
+
163
+ Items restored from the cache are placeholders, flagged `isPlaceholder: true`.
164
+ Each platform type is a union on that flag. A placeholder has no SDK handle
165
+ (`txCreator`, `polkadotSigner`, `signRaw`, `client`, `signer`, `getSigner`,
166
+ `provider`, `extension`, `wallet`, `appKit`, `sourceId`), so the compiler makes
167
+ you narrow before you sign:
168
+
169
+ ```ts
170
+ if (account.isPlaceholder) return;
171
+ await account.txCreator.signBytes(bytes);
172
+ ```
173
+
174
+ Reading a handle without narrowing gives `undefined` in the type, so the
175
+ compiler rejects a call on it. The plain fields of the cache (Ethereum
176
+ `chainId`, Polkadot key `type`) are restored on placeholders. Solana `chains`
177
+ is not cached, so a placeholder Solana account has none.
178
+
179
+ A placeholder wallet is never connected. Its `connect()` waits for kheopskit to
180
+ discover the live wallet with the same id and connects that one. It rejects
181
+ with `WALLET_PENDING` if hydration ends first, and with `ABORTED` if its
182
+ `signal` fires or the last subscriber unsubscribes while it waits. Its
183
+ `disconnect()` resolves at once. A cached wallet that kheopskit has already
184
+ discovered is no longer a placeholder. It carries its live `connect()` and
185
+ `disconnect()` and shows `status: "connecting"` while its reconnect runs, and
186
+ its `connect()` joins that reconnect. A Polkadot wallet's `extension` stays
187
+ `undefined` until that reconnect succeeds. No item is a placeholder once
188
+ `isHydrating` is `false`.
189
+
190
+ `isHydrating` turns `false` once all three hold:
191
+
192
+ 1. `hydrationGracePeriod` has elapsed (default 500 ms).
193
+ 2. Discovery has ended (`isDiscovering` is `false`), so a page with a cache
194
+ hydrates until `load` or `hydrationTimeout`, whichever comes first.
195
+ 3. Every wallet that hydrated as `connecting` has settled. A wallet has
196
+ settled when it is absent from the discovered wallets, connected once its
197
+ account list has been read (an empty list counts) or connected with no
198
+ cached accounts, disconnected with `error` set, or disconnected and not
199
+ remembered when hydration began.
200
+
201
+ At `hydrationTimeout` (default 3000 ms) hydration ends whatever is pending, and
202
+ a wallet whose reconnect is still pending stays `connecting`. The timeout wins
203
+ over a longer grace period, so an app that raises `hydrationGracePeriod` sets
204
+ `hydrationTimeout` as well.
205
+
206
+ - A wallet that reconnects without sharing any account with the site settles
207
+ as soon as it answers. Its cached accounts leave the list when hydration
208
+ ends.
209
+ - A failed reconnect ends that wallet's wait, even when the reconnect policy
210
+ still permits a retry.
211
+ - A remembered wallet the user disconnects during or right after its reconnect
212
+ holds hydration, and shows its cached accounts, until `hydrationTimeout`.
213
+ - A prompt the extension opens for a revoked site holds hydration until the
214
+ user answers or `hydrationTimeout` elapses.
215
+ - "Its account list has been read" relies on a signal that the built-in
216
+ plugins send. A custom `KheopskitPlatform` plugin, or a wrapper that pipes a
217
+ built-in plugin's `getAccounts$`, does not send it. For such a plugin, a
218
+ reconnected wallet with cached accounts that now lists none holds hydration
219
+ until `hydrationTimeout`.
220
+
221
+ Measured with Talisman 3.11.0 on vite-react, with the extension disabled and a
222
+ filled cache, hydration ended 633 ms after page start (subscribe at about
223
+ 130 ms plus the 500 ms grace period), where v5 took 3 s. With the extension
224
+ present, the reloaded row read `Connecting…` at 176 ms and `Disconnect` at
225
+ 282 ms. An extension re-enabled cold answered at 2667 ms, close to the 3000 ms
226
+ default, so raise `hydrationTimeout` if your users' wallets start slower.
227
+
228
+ The cache is written after hydration ends, 1 second after the state stops
229
+ changing (a debounce). The first write after hydration always happens; later
230
+ writes happen only when a persisted field or a wallet's `status` changed. A
231
+ wallet still `connecting` at that moment keeps its previous cached entry and
232
+ accounts, so a slow reconnect never writes an empty cache. Every other wallet is
233
+ written as it is, with the accounts of connected wallets only. See
234
+ [Persistence](#persistence) for the format.
235
+
236
+ ### Reconnect
237
+
238
+ Source: [`api/wallets.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/wallets.ts),
239
+ [`api/reconnectPolicy.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/reconnectPolicy.ts),
240
+ [`api/injectedWallets.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/injectedWallets.ts).
241
+
242
+ Kheopskit adds an injected wallet to the store's `remembered` list on every
243
+ successful `connect()` and removes it when `disconnect()` starts on a
244
+ connected wallet, whatever `autoReconnect` says, so an app can turn reconnect
245
+ on later. On the next load it reconnects the remembered injected wallets that
246
+ `autoReconnect` accepts:
247
+
248
+ - `true` (the default) reconnects every remembered injected wallet, and
249
+ `false` none.
250
+ - A function chooses per wallet. It is called for each cached connected
251
+ injected wallet on the first frame, then for each remembered wallet with its
252
+ live object before the attempt. It must be synchronous and pure. It runs on
253
+ the server and on the client for the same input, and both must agree, or the
254
+ markup diverges. It receives the `BaseWallet` fields only, so key on `id`.
255
+
256
+ ```ts
257
+ getKheopskit$({
258
+ platforms: [polkadot()],
259
+ autoReconnect: (wallet) => wallet.id === "polkadot:talisman",
260
+ });
261
+ ```
262
+
263
+ A cached connected wallet the function accepts shows `status: "connecting"`
264
+ from the first frame until its attempt settles. A wallet it rejects shows
265
+ `disconnected` from the first frame, without its cached accounts, so nothing
266
+ flashes from connected to disconnected. The cache records a connect 1 s after
267
+ the state settles. After a quicker reload, the first frame shows the wallet as
268
+ the previous cache had it, until its reconnect starts.
269
+
270
+ A reconnect opens no connect prompt while the site stays authorised in the
271
+ extension. When the authorisation was revoked, the extension prompts once. A
272
+ refusal (`USER_REJECTED`) forgets the wallet, so the next load stays silent.
273
+ Any other failure sets `wallet.error` and keeps the wallet remembered.
274
+ Kheopskit retries it on the next change to the wallet list (a wallet discovered
275
+ or removed, or any wallet's status changing), 3 attempts in total. If the list
276
+ never changes again, no retry runs. A manual `connect()` or `disconnect()` on a
277
+ remembered wallet ends its automatic reconnect for the session. A
278
+ `disconnect()` while the automatic reconnect still waits for the extension
279
+ aborts it without forgetting the wallet, so the next load reconnects it.
280
+ Automatic reconnect also ends when the wallet disappears mid-attempt, and the
281
+ wallet stays remembered for the next load.
282
+
283
+ Every wallet carries `status` (`disconnected`, `connecting` or `connected`) and
284
+ `error`. `isConnected` is `status === "connected"`. `error` is the last
285
+ `connect()` or reconnect that the platform refused (`USER_REJECTED`) or failed
286
+ (`CONNECT_FAILED`, or `FEATURE_NOT_SUPPORTED` for a Wallet Standard wallet
287
+ without `standard:connect`). The next attempt clears it, and an aborted
288
+ attempt sets nothing. With `debug: true`, a failed reconnect is logged with
289
+ `console.debug`.
290
+
291
+ The WalletConnect connector is not governed by `autoReconnect`. AppKit restores
292
+ its own session on load, and the connector hydrates `connecting` while it does.
293
+
294
+ ### Connect and disconnect
295
+
296
+ Source: [`api/injectedWallets.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/injectedWallets.ts),
297
+ [`api/kheopskit.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/kheopskit.ts),
298
+ [`api/errors.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/errors.ts).
299
+
300
+ `wallet.connect()` and `wallet.disconnect()` are safe to call straight from UI
301
+ code:
302
+
303
+ - **Shared.** Concurrent `connect()` calls on one wallet share one platform
304
+ attempt, so the extension is asked once. Each call gets its own promise.
305
+ - **Idempotent.** `connect()` on a connected wallet and `disconnect()` on a
306
+ disconnected wallet resolve at once without asking the platform. A
307
+ `connect()` made while a `disconnect()` still waits for the platform waits
308
+ for that disconnect to settle, then connects if the wallet ended up
309
+ disconnected.
310
+ - **Abortable.** `connect({ signal })` rejects that caller with `ABORTED` when
311
+ the signal fires before the platform answers. Other callers keep waiting.
312
+ The attempt itself is abandoned once every caller that passed a signal has
313
+ aborted and no caller without one joined, on `disconnect()`, or when the
314
+ last `kheopskit$` subscriber unsubscribes. An abort after the platform
315
+ granted the connect changes nothing, and a Polkadot extension counts as
316
+ granted only once its account list has been read. Aborting cannot close the
317
+ extension's own prompt.
318
+ - **No default timeout.** An extension prompt pends only while the user has
319
+ not answered it, so kheopskit sets no timeout. Pass `AbortSignal.timeout(ms)`
320
+ to cap the wait.
321
+ - **Settles with the state.** `connect()` resolves once `kheopskit$` has
322
+ emitted a state in which the wallet has `isConnected: true`, so the state
323
+ your UI reads already shows the connect. That costs one microtask after the
324
+ platform's grant (1.9 ms wall clock, measured once during K3 in a
325
+ fake-timer unit run). There are two exceptions. A `disconnect()` that
326
+ completes while `connect()` waits for that state rejects it with `ABORTED`.
327
+ When the wallet leaves the list (an extension removed) after the platform
328
+ granted the connect, `connect()` resolves even if no state showed the wallet
329
+ connected, and the wallet stays remembered. Removed before the grant, it
330
+ rejects with `ABORTED`. A `disconnect()` whose platform call is still
331
+ pending or refused does not count as completed, so the `connect()` still
332
+ resolves once the state shows the wallet connected (today only Solana has a
333
+ platform disconnect).
334
+ - **Disconnect.** `disconnect()` forgets the wallet first, then waits for the
335
+ platform's disconnect, if the platform has one, and rejects when that call
336
+ fails (the wallet is then remembered again and stays connected). It then
337
+ resolves once `kheopskit$` has emitted the wallet with `isConnected: false`,
338
+ or once a later `connect()`, the wallet leaving the list or the end of the
339
+ subscription makes that emission impossible. A `disconnect()` that still
340
+ waits for the platform settles when the platform answers, even after the
341
+ subscription ended. During a pending `connect()` that the platform has not
342
+ granted, `disconnect()` aborts it and resolves without waiting for the
343
+ prompt.
344
+ - **After the subscription.** Once the last `kheopskit$` subscriber has
345
+ unsubscribed, `connect()` and `disconnect()` on an injected wallet object
346
+ from that subscription reject with `ABORTED` without asking the platform. A
347
+ placeholder's `connect()` rejects the same way, and its `disconnect()` still
348
+ resolves at once. The live WalletConnect connector is not wrapped, so its
349
+ `connect()` still opens the AppKit modal and its `disconnect()` still ends a
350
+ live session.
351
+
352
+ Every rejection of `connect()` is a `KheopskitError` with a stable `code` and
353
+ the `walletId`, and so is an `ABORTED` `disconnect()`. A `disconnect()` whose
354
+ platform call fails (Solana, the WalletConnect connector) rejects with the
355
+ platform's own error.
356
+
357
+ | Code | When |
358
+ |------|------|
359
+ | `USER_REJECTED` | The user refused the prompt. `cause` holds the platform error. |
360
+ | `CONNECT_FAILED` | Any other platform failure, including a Polkadot extension whose `enable()` result lacks `accounts.get`, `accounts.subscribe`, `signer.signPayload` or `signer.signRaw`, or whose `accounts.get()` rejects. `cause` holds the platform error. |
361
+ | `ABORTED` | The call was abandoned, as listed above. The message reads `connect of wallet <id> was aborted` or `disconnect of wallet <id> was aborted`. |
362
+ | `FEATURE_NOT_SUPPORTED` | A Solana Wallet Standard wallet that lacks `standard:connect`. |
363
+ | `WALLET_PENDING` | `connect()` on a placeholder whose live wallet was not discovered before hydration ended, and always on a `getHydratedSnapshot` placeholder. |
364
+
365
+ A Polkadot extension sends a plain `Error` on refusal, so kheopskit maps its
366
+ message: Talisman's `Site <url> has not been authorised for Talisman yet`
367
+ (observed live), polkadot.js's `Connection request was rejected by the user.`,
368
+ SubWallet's `Rejected` or `Cancelled`. On Ethereum, an EIP-1193 error with code
369
+ `4001` maps to `USER_REJECTED`. Branch on `error.code`, not on the message. A
370
+ rejected `accounts.get()` during a Polkadot connect whose message matches one
371
+ of these refusals also maps to `USER_REJECTED`. Refused live in Talisman, a
372
+ connect rejects with the message `[kheopskit] the user rejected the connect
373
+ request of wallet polkadot:talisman` and the Talisman error as `cause`.
374
+
375
+ The WalletConnect connector differs. Its `connect()` opens the AppKit modal
376
+ and resolves once it is open, `isConnected` follows when the user approves a
377
+ session, and `signal` is accepted for signature compatibility only.
378
+
379
+ #### Accounts
380
+
381
+ In `kheopskit$`, each wallet connection has its own account stream. It starts
382
+ empty when the wallet connects and ends when it disconnects. No emission shows a
383
+ disconnected wallet still listing live accounts, or a connected wallet listing
384
+ the accounts of a previous connection, and another wallet's pending request
385
+ (Ethereum `eth_accounts`) never delays a wallet's accounts. A Polkadot
386
+ wallet's `connect()` reads `accounts.get()` before it resolves, so the
387
+ emission that first shows it connected already lists its accounts. On
388
+ platforms that list accounts asynchronously (Ethereum `eth_accounts`), the
389
+ accounts may arrive in a later emission. While `isHydrating` is `true`, a
390
+ wallet's cached placeholder accounts stay listed, whatever its status, until
391
+ live accounts replace them or hydration ends. The guarantee has one more
392
+ exception. The first value of a platform plugin's `getAccounts$`, called
393
+ directly with wallets that are already connected, waits until each of them has
394
+ answered. `kheopskit$` subscribes before any wallet is connected, so it never
395
+ waits.
396
+
397
+ `state.accounts` is grouped by platform (Polkadot, Ethereum, Solana), then by
398
+ wallet (Talisman first, then by wallet name). Inside a wallet, accounts keep
399
+ the order the extension lists them in. `state.wallets` follows the same
400
+ platform order, then the WalletConnect connector, with Talisman first, then by
401
+ name.
402
+
403
+ ### Persistence
404
+
405
+ Source: [`api/store.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/store.ts),
406
+ [`api/compactStore.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/compactStore.ts),
407
+ [`utils/storage.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/utils/storage.ts).
408
+
409
+ The data lives under the key `kheopskit` (change it with the `storageKey`
410
+ config option, for example to run two dapps on one origin). In the browser it
411
+ goes to `localStorage` as JSON:
412
+
413
+ ```json
414
+ {
415
+ "remembered": ["polkadot:talisman"],
416
+ "cachedWallets": [
417
+ { "id": "polkadot:talisman", "platform": "polkadot", "type": "injected", "name": "Talisman", "isConnected": true }
418
+ ],
419
+ "cachedAccounts": [
420
+ { "id": "polkadot:talisman::5Grw…", "platform": "polkadot", "address": "5Grw…", "name": "Alice", "polkadotAccountType": "sr25519", "walletId": "polkadot:talisman", "walletName": "Talisman" }
421
+ ]
422
+ }
423
+ ```
424
+
425
+ - `remembered` lists the injected wallets the user connected. It is written as
426
+ soon as a connect succeeds and cleared as soon as `disconnect()` starts on a
427
+ connected wallet. A `disconnect()` that aborts a pending connect leaves it
428
+ unchanged.
429
+ - `cachedWallets` and `cachedAccounts` are written by the hydration rule above:
430
+ after hydration ends, 1 second after the state stops changing.
431
+ - Wallet icons go to a separate `localStorage` key, `kheopskit-icons`, to keep
432
+ the main entry small.
433
+
434
+ With `ssrCookies`, the same data goes to a `kheopskit` cookie in a compact
435
+ form, to stay under the cookie size limit:
436
+
437
+ ```json
438
+ { "v": 1, "r": ["polkadot:talisman"], "w": [["polkadot:talisman", "Talisman", 1, 0]], "a": [["polkadot:talisman", "5Grw…", "Alice", null, 0, 0]] }
439
+ ```
440
+
441
+ `r` is `remembered`, `w` holds `[id, name, isConnected, type]` per wallet and
442
+ `a` holds `[walletId, address, name, chainId, polkadotType, platform]` per
443
+ account. The cookie is URI-encoded, lasts one year, and is set with `path=/`,
444
+ `SameSite=Lax`, and `Secure` on HTTPS. Kheopskit warns in the console when the
445
+ encoded value exceeds 3072 bytes.
446
+
447
+ Data written before v6 stored `remembered` as `autoReconnect`. The store reads
448
+ that field as `remembered` and drops it on the next write. A storage write that
449
+ throws (`QuotaExceededError`, a denied write) keeps the in-memory state while
450
+ the store is observed, and
451
+ neither `connect()` nor `disconnect()` rejects because of it.
452
+
453
+ #### Custom storage
454
+
455
+ `createKheopskitStore` accepts a `storage` option, which takes precedence over
456
+ `ssrCookies` and `localStorage`. Pass the store to `getKheopskit$`:
457
+
458
+ ```ts
459
+ import {
460
+ createKheopskitStore,
461
+ getKheopskit$,
462
+ type SyncableStorage,
463
+ } from "@kheopskit/core";
464
+
465
+ const entries = new Map<string, string>();
466
+ const storage: SyncableStorage = {
467
+ getItem: (key) => entries.get(key) ?? null,
468
+ setItem: (key, value) => entries.set(key, value),
469
+ removeItem: (key) => entries.delete(key),
470
+ };
471
+
472
+ const store = createKheopskitStore({ storage });
473
+ const kheopskit$ = getKheopskit$(config, { store });
474
+
475
+ // When done with the store
476
+ store.destroy();
477
+ ```
478
+
479
+ `subscribe` is optional. Implement it to receive changes made by other tabs.
480
+ The store holds that subscription only while `store.observable` has
481
+ subscribers. `store.destroy()` completes `store.observable`, which ends it.
482
+ `store.rememberWallet(walletId)` and `store.forgetWallet(walletId)` edit the
483
+ remembered list directly.
484
+
485
+ ### Signing
486
+
487
+ Source: [`api/polkadot/accounts.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/polkadot/accounts.ts),
488
+ [`api/polkadot/types.ts`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/api/polkadot/types.ts).
489
+
490
+ A live Polkadot account signs transactions through the signer polkadot-api
491
+ expects. The field follows the installed polkadot-api version, detected at
492
+ runtime. It is `txCreator` (a `TxCreator`) on v3 and later, and
493
+ `polkadotSigner` (a `PolkadotSigner`) on v2. With neither factory available,
494
+ kheopskit throws a `KheopskitError` with code `UNSUPPORTED_VERSION`. On v3 and
495
+ later, the `txCreator` of an account whose `type` is `ethereum` is built with
496
+ the `ecdsa` signature type, so fee estimation and dry runs use a 65-byte mock
497
+ signature. On v2, `getPolkadotSignerFromPjs` ignores the signature type.
498
+ Ethereum accounts expose `client` (viem), and Solana accounts `signer` and
499
+ `getSigner(chain)`.
500
+
501
+ `account.signRaw(data, options?)` signs arbitrary data with the wallet's raw
502
+ signing call (`signer.signRaw` for an extension, `polkadot_signMessage` over
503
+ WalletConnect) and resolves the hex signature:
504
+
505
+ - A string is sent as text with `type: "payload"`, and a `Uint8Array` as `0x`
506
+ hex with `type: "bytes"`. An explicit `options.type` is sent as given for
507
+ either input. Over WalletConnect, `polkadot_signMessage` has no `type`, so
508
+ none is sent.
509
+ - A hex-looking string is sent as text, so pass a `Uint8Array` for bytes.
510
+ Talisman, polkadot.js, SubWallet and Nova decode a `0x`-shaped string as
511
+ bytes (Talisman observed live, the others read from their sources).
512
+ - Pass a string for a Sign-In With Substrate (SIWS) message. Talisman opens its
513
+ Sign In screen when the text parses as SIWS and its generic Sign Request
514
+ screen for anything else, hex included. The SIWS `Version` line must read
515
+ `1.0.0`. Talisman rejects `1` and falls back to the generic screen. The
516
+ screen follows `data`, not `type`.
517
+ - Talisman, polkadot.js, SubWallet and Nova ignore `type` (read from their
518
+ sources). Enkrypt reads it as the encoding of `data`, so it needs the value
519
+ the derived default sends.
520
+
521
+ What Talisman 3.11.0 showed for one SIWS message (`Version: 1.0.0`), observed
522
+ live:
523
+
524
+ | Call | `data` sent | `type` sent | Screen |
525
+ |------|-------------|-------------|--------|
526
+ | `signRaw(message)` | text | `"payload"` | Sign In ("wants you to sign in with Substrate") |
527
+ | `signRaw(message, { type: "bytes" })` | text | `"bytes"` | Sign In |
528
+ | `signRaw(bytes)` with a `Uint8Array` | hex | `"bytes"` | Sign Request |
529
+ | `txCreator.signBytes(bytes)` | hex | `"bytes"` | Sign Request |
530
+ | `signRaw` of the same text with `Version: 1` | text | either | Sign Request |
531
+
532
+ Every signature taken verified. An `ethereum` account signs a 65-byte
533
+ signature that recovers to its address over keccak256 of the `<Bytes>`-wrapped
534
+ message.
535
+
536
+ On a connected Polkadot wallet, `wallet.extension` is the raw object
537
+ `enable(appName)` returned, typed `PolkadotInjected`. Use it for extension calls
538
+ kheopskit does not wrap. It is `undefined` until the wallet is connected, so
539
+ also while it connects, and a placeholder has none. Talisman adds `readonly`
540
+ and `partOfPortfolio` to the accounts it shares with its own origins. Both are
541
+ typed on `PolkadotAccount`.
542
+
543
+ ### Testing
544
+
545
+ Source: [`testing/`](https://github.com/kheopskit/kheopskit/blob/main/packages/core/src/testing).
546
+
547
+ `@kheopskit/core/testing` runs kheopskit in a test without a browser
548
+ extension. It has no test-runner dependency, so it works with vitest and jest.
549
+
550
+ - `createFakePolkadotExtension(options?)` installs a fake extension on
551
+ `window.injectedWeb3[name]`. `options.name` defaults to `"fake-extension"`.
552
+ `options.accounts` is the account list. `options.enable` sets how `enable()`
553
+ answers: `"resolve"` (the default) at once, `"reject"` at once with
554
+ `new Error("Rejected")`, which `connect()` reports as `USER_REJECTED`, or
555
+ `"hold"` until the test calls `resolveEnable()` or `rejectEnable(error?)`.
556
+ - The fake returns `{ name, walletId, setAccounts, resolveEnable, rejectEnable,
557
+ enableCalls, signRawCalls, uninstall }`. `setAccounts(accounts)` pushes the
558
+ list to every `accounts.subscribe` listener. `enableCalls()` lists the app
559
+ name of each `enable()` call. `signRaw` and `signPayload` resolve
560
+ `FAKE_SIGNATURE`. `signRawCalls()` lists what `signRaw` received;
561
+ `signPayload` calls are not recorded.
562
+ - `createMemoryStorage()` returns a `SyncableStorage` held in memory, for
563
+ `createKheopskitStore({ storage })`. Stores created on the same instance see
564
+ each other's writes.
565
+ - `cachedWalletOf(walletId, name)` and `cachedAccountOf(wallet, address,
566
+ name?)` build the entries `store.setCachedState(wallets, accounts)` takes, so
567
+ a test seeds a previous session without writing the persisted shape.
568
+
569
+ A vitest test that seeds a remembered wallet and checks that it reconnects:
570
+
571
+ ```ts
572
+ import {
573
+ createKheopskitStore,
574
+ getKheopskit$,
575
+ type KheopskitState,
576
+ type KheopskitStore,
577
+ } from "@kheopskit/core";
578
+ import { polkadot } from "@kheopskit/core/polkadot";
579
+ import {
580
+ cachedAccountOf,
581
+ cachedWalletOf,
582
+ createFakePolkadotExtension,
583
+ createMemoryStorage,
584
+ type FakePolkadotExtension,
585
+ } from "@kheopskit/core/testing";
586
+ import type { Subscription } from "rxjs";
587
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
588
+
589
+ const alice = {
590
+ address: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
591
+ name: "Alice",
592
+ type: "sr25519" as const,
593
+ };
594
+
595
+ let talisman: FakePolkadotExtension;
596
+ let store: KheopskitStore;
597
+ let subscription: Subscription;
598
+
599
+ beforeEach(() => {
600
+ vi.useFakeTimers();
601
+ talisman = createFakePolkadotExtension({ name: "talisman", accounts: [alice] });
602
+ store = createKheopskitStore({ storage: createMemoryStorage() });
603
+ });
604
+
605
+ afterEach(() => {
606
+ talisman.uninstall();
607
+ subscription.unsubscribe();
608
+ store.destroy();
609
+ vi.useRealTimers();
610
+ });
611
+
612
+ it("reconnects the wallet the user connected last time", async () => {
613
+ const wallet = cachedWalletOf(talisman.walletId, "Talisman");
614
+ store.rememberWallet(talisman.walletId);
615
+ store.setCachedState([wallet], [cachedAccountOf(wallet, alice.address, alice.name)]);
616
+
617
+ let state: KheopskitState | undefined;
618
+ subscription = getKheopskit$({ platforms: [polkadot()] }, { store }).subscribe(
619
+ (next) => {
620
+ state = next;
621
+ },
622
+ );
623
+ await vi.advanceTimersByTimeAsync(1000);
624
+
625
+ expect(talisman.enableCalls()).toHaveLength(1);
626
+ expect(state?.wallets.map((w) => [w.id, w.isConnected])).toEqual([
627
+ ["polkadot:talisman", true],
628
+ ]);
629
+ expect(state?.accounts.map((a) => a.name)).toEqual(["Alice"]);
630
+ });
631
+ ```
632
+
633
+ Tear down with three calls:
634
+
635
+ ```ts
636
+ talisman.uninstall();
637
+ subscription.unsubscribe();
638
+ store.destroy();
639
+ ```
640
+
641
+ Unsubscribing from `getKheopskit$` stops every timer it started, among them
642
+ the discovery reads, the hydration timers and the 1 second persistence
643
+ debounce. Without it, a persist can write to storage after the test ended.
644
+ `store.destroy()` ends the store's storage subscription. `uninstall()` removes
645
+ the fake from `window.injectedWeb3`, and removes `window.injectedWeb3` once it
646
+ is empty.
647
+
648
+ With fake timers, advance time with `vi.advanceTimersByTimeAsync` (jest:
649
+ `jest.advanceTimersByTimeAsync`). Kheopskit settles connects and reconnects in
650
+ promises, and the async variants run those microtasks between timers. The
651
+ synchronous variants leave them pending.
652
+
653
+ When the code under test calls `getKheopskit$` without a `store`, it uses
654
+ `localStorage`. Seed it through a store on the same storage, then destroy that
655
+ store:
656
+
657
+ ```ts
658
+ const seed = createKheopskitStore();
659
+ seed.rememberWallet("polkadot:talisman");
660
+ seed.destroy();
661
+ ```
662
+
663
+ The kit fakes Polkadot extensions only. It has no hook to make `disconnect()`
664
+ fail.
665
+
666
+ ### Design choices
667
+
668
+ - Kheopskit does not look for wallets that inject after `load`, and has no
669
+ `refreshWallets()`. Wallets inject at page load, and a rescan would add
670
+ timers and states for a case no supported wallet produces.
671
+ - No `walletsReady` promise. It would be a second surface for the one fact
672
+ `isDiscovering` carries.
673
+ - No longer fixed poll. Any duration is still a guess about how long a page
674
+ takes to boot, and wallets inject before `load`.
675
+ - Discovery does not end at the last scheduled read (1100 ms). On a warm load
676
+ that is slower than `load`, for no gain.
677
+ - `remembered` is read once at subscribe. A live read would make a connect in
678
+ one tab connect every open tab, with no matching disconnect.
679
+ - No `persist: false` option. The `autoReconnect` predicate covers "do not
680
+ reconnect", and `storageKey` keeps two dapps apart.
65
681
 
66
682
  ## License
67
683