@usebutr/core 1.1.0 → 2.0.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.
package/dist/index.js CHANGED
@@ -3,11 +3,9 @@ import { z } from "zod";
3
3
 
4
4
  //#region src/types/account.ts
5
5
  /**
6
- * Build butr's `Account` shape from a wallet address and a resolved
7
- * `ChainBase`. The composite id (`<chain>:<address>`) is what the
8
- * reducer uses to compare accounts across refreshes, so every adapter
9
- * must build accounts through this helper (or keep the format
10
- * byte-identical).
6
+ * The reducer compares accounts by the composite `<chain>:<address>`
7
+ * id, so every adapter must build accounts here or reproduce that
8
+ * format byte for byte.
11
9
  */
12
10
  const buildAccount = (address, chain) => ({
13
11
  chain,
@@ -18,30 +16,9 @@ const buildAccount = (address, chain) => ({
18
16
  //#endregion
19
17
  //#region src/types/chains-by-platform.ts
20
18
  /**
21
- * Build a fully-populated `ChainsByPlatform` from a partial. Platforms
22
- * the consumer doesn't specify default to an empty list.
23
- *
24
- * Use this in apps that target one or two chain platforms; importing
25
- * only those packages keeps unused chain registries out of the bundle.
26
- * Apps that want every chain reach for `CHAINS_BY_PLATFORM` from
27
- * `@usebutr/wallets` instead.
28
- *
29
- * @example
30
- * // EVM-only app: Solana/Sui/Bitcoin tables never enter the bundle
31
- * import { EVM_CHAINS_LIST } from "@usebutr/evm";
32
- * import { buildChainsByPlatform } from "@usebutr/core";
33
- *
34
- * const chains = buildChainsByPlatform({ evm: EVM_CHAINS_LIST });
35
- *
36
- * @example
37
- * // Multi-chain app: pull from each package the app actually uses
38
- * import { EVM_CHAINS_LIST } from "@usebutr/evm";
39
- * import { SVM_CHAINS_LIST } from "@usebutr/svm";
40
- *
41
- * const chains = buildChainsByPlatform({
42
- * evm: EVM_CHAINS_LIST,
43
- * svm: SVM_CHAINS_LIST,
44
- * });
19
+ * Naming only the platforms an app targets keeps the other packages'
20
+ * chain registries out of its bundle. Apps that want all of them import
21
+ * `CHAINS_BY_PLATFORM` from `@usebutr/wallets`.
45
22
  */
46
23
  const buildChainsByPlatform = (partial) => ({
47
24
  bitcoin: partial.bitcoin ?? [],
@@ -54,17 +31,9 @@ const buildChainsByPlatform = (partial) => ({
54
31
  //#endregion
55
32
  //#region src/types/errors.ts
56
33
  /**
57
- * Normalise a thrown value into a `ConnectionError`.
58
- *
59
- * Recognises:
60
- * - butr's own `Error("Connection timeout")` (from the 90s connect timeout)
61
- * - butr's own `Error("Failed to get account")` (from the connect flow)
62
- * - EIP-1193 numeric `code` properties (`4001` → UserRejected,
63
- * `-32002` → RequestPending, `4100`/`4900`/`4901` → NotConnected:
64
- * unauthorized / disconnected from all-or-one chains)
65
- * - common message substrings: "user rejected" / "user denied",
66
- * "locked", "chain", etc.
67
- * - anything else → `Unknown` with `cause` set to the original value.
34
+ * EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
35
+ * unauthorized or disconnected. Message-substring matching is the last
36
+ * resort for SDKs that ship no codes at all.
68
37
  */
69
38
  const mapConnectionError = (raw) => {
70
39
  if (raw instanceof Error) {
@@ -123,11 +92,9 @@ const mapConnectionError = (raw) => {
123
92
  //#endregion
124
93
  //#region src/types/platform.ts
125
94
  /**
126
- * Canonical list of supported chain platforms. The single runtime source
127
- * of truth: `ChainPlatform` is derived from it, and storage validators
128
- * build their allowlists from it, so the type and the runtime checks can
129
- * never drift (a missing platform here was why Polkadot connections failed
130
- * to persist).
95
+ * Single runtime source of truth: `ChainPlatform` and the storage
96
+ * validators' allowlists both derive from it. Omitting a platform here
97
+ * silently stops its connections from persisting.
131
98
  */
132
99
  const CHAIN_PLATFORMS = [
133
100
  "evm",
@@ -140,32 +107,17 @@ const CHAIN_PLATFORMS = [
140
107
  //#endregion
141
108
  //#region src/wallet-source.ts
142
109
  /**
143
- * Wrap a bare `subscribe` function (the exact shape of
144
- * `discoverEvmAdapters` / `discoverSvmAdapters`) into a `WalletSource`,
145
- * so an EVM-only app can do
146
- * `createWalletSource(discoverEvmAdapters)` without importing anything
147
- * protocol-bearing beyond `@usebutr/evm`.
110
+ * Takes the exact shape of `discoverEvmAdapters` and friends, so a
111
+ * single-platform app keeps `@usebutr/wallets` out of its bundle.
148
112
  */
149
113
  const createWalletSource = (subscribe) => ({ subscribe });
150
114
 
151
115
  //#endregion
152
116
  //#region src/store/shadow-adapter.ts
153
117
  /**
154
- * Error thrown when a method is called on a shadow adapter; the
155
- * placeholder `WalletAdapter` that the store seeds into the pool when
156
- * an `initialState` is provided (e.g. from a server-rendered cookie
157
- * snapshot). Shadow adapters carry the identity and account data of a
158
- * previously-connected wallet, but the live wallet extension hasn't
159
- * been verified yet; the silent reconnect happens asynchronously
160
- * after mount.
161
- *
162
- * UI code that gates affordances on `wallet.connector.capabilities.*`
163
- * never reaches a shadow method (capabilities are all `false`). Code
164
- * that calls through anyway hits this typed error, which is the
165
- * correct loud failure: the consumer ignored the capability gate.
166
- *
167
- * Consumers wanting to wait out the reconnecting window should branch
168
- * on whether `connectorId` is in `state.reconnectingIds`.
118
+ * Loud failure for consumers that called a wallet method without gating
119
+ * on `capabilities.*` (all `false` here) or `reconnectingIds`, before
120
+ * silent reconnect swapped in the live adapter.
169
121
  */
170
122
  var ShadowConnectorError = class extends Error {
171
123
  code = "BUTR_RECONNECTING";
@@ -191,26 +143,9 @@ const ALL_FALSE_CAPABILITIES = Object.freeze({
191
143
  switchChain: false
192
144
  });
193
145
  /**
194
- * Builds a placeholder `WalletAdapter` from a persisted pool entry.
195
- *
196
- * Used by `createWalletStore` when `WalletManagerConfig.initialState`
197
- * is provided: each stored entry becomes a `ConnectedWallet` whose
198
- * `connector` is one of these. The store flips `isHydrated` true
199
- * synchronously and exposes the data through the usual hooks
200
- * (`useActiveWallet`, `useConnectedWallets`, …), but the connector
201
- * can't actually talk to a wallet yet, so its capabilities are all
202
- * `false` and its methods throw `ShadowConnectorError` if called.
203
- *
204
- * The hydration coordinator's silent-reconnect pass upgrades each
205
- * shadow to a live `WalletAdapter` by calling `createConnector(id)`
206
- * (which only succeeds once the live adapter has been announced via
207
- * discovery or registered explicitly) and replacing the pool entry.
208
- * On success the connector id is removed from `reconnectingIds`; on
209
- * failure the entry is dropped from the pool and storage.
210
- *
211
- * The entry's `name` is required (the storage validator rejects
212
- * entries without it); `icon` is optional only because some live
213
- * adapters genuinely have no icon to begin with.
146
+ * Lets `createWalletStore` flip `isHydrated` synchronously from
147
+ * `initialState`; the hydration coordinator later swaps each shadow for
148
+ * a live adapter, or drops the entry from pool and storage.
214
149
  */
215
150
  const createShadowAdapter = (entry) => {
216
151
  const id = entry.connectorId;
@@ -260,17 +195,9 @@ const createShadowAdapter = (entry) => {
260
195
  }
261
196
  };
262
197
  /**
263
- * Type guard. Returns true when an adapter is a placeholder created
264
- * by `createShadowAdapter`. Useful for the hydration coordinator
265
- * (which needs to know which pool entries still need upgrading) and
266
- * for consumers writing wagmi-style "is this connection verified yet"
267
- * checks without subscribing to `reconnectingIds` directly.
268
- *
269
- * Detection is structural: a shadow has all capabilities set to false.
270
- * Live adapters always advertise at least one capability (every wallet
271
- * surface includes `getBalance`, `signMessage`, `switchChain` as
272
- * required methods, and adapter constructors set their flags
273
- * accordingly).
198
+ * Structural detection: relies on every live adapter advertising at
199
+ * least one capability, since `getBalance`, `signMessage` and
200
+ * `switchChain` are required on all wallet surfaces.
274
201
  */
275
202
  const isShadowAdapter = (adapter) => {
276
203
  return Object.values(adapter.capabilities).every((flag) => !flag);
@@ -369,6 +296,64 @@ const parseStoredPoolEntry = (key, value) => {
369
296
  if (!parsed.success || parsed.data.connectorId !== key) return null;
370
297
  return parsed.data;
371
298
  };
299
+ const DEFAULT_KEY_PREFIX = "butr";
300
+ /**
301
+ * `WalletStorage` writes these and `readWalletSnapshot` reads them from
302
+ * a cookie jar; any divergence desyncs the SSR-seeded render from the
303
+ * client that rehydrates it, the failure ADR 0003 exists to prevent.
304
+ */
305
+ const storageKeys = (keyPrefix) => {
306
+ const prefix = keyPrefix === void 0 || keyPrefix === "" ? DEFAULT_KEY_PREFIX : keyPrefix;
307
+ return {
308
+ active: `${prefix}-active`,
309
+ pool: `${prefix}-pool`,
310
+ selection: `${prefix}-selection`,
311
+ userDisconnected: `${prefix}-user-disconnected`
312
+ };
313
+ };
314
+ /**
315
+ * Never repairs or throws: one corrupt entry must not take down a whole
316
+ * session, and `readWalletSnapshot` may run on a server with no cookie
317
+ * jar to write the eviction to.
318
+ */
319
+ const decodePool = (raw, label = "[butr]") => {
320
+ if (raw === null || raw === void 0 || raw === "") return {};
321
+ let value;
322
+ try {
323
+ value = JSON.parse(raw);
324
+ } catch (error) {
325
+ logWarn(`${label} failed to parse pool from storage:`, error);
326
+ return {};
327
+ }
328
+ const parsed = recordSchema.safeParse(value);
329
+ if (!parsed.success) return {};
330
+ const result = {};
331
+ for (const [key, entryValue] of Object.entries(parsed.data)) {
332
+ const entry = parseStoredPoolEntry(key, entryValue);
333
+ if (entry === null) logWarn(`${label} dropping invalid pool entry for ${key}`);
334
+ else result[key] = entry;
335
+ }
336
+ return result;
337
+ };
338
+ /** Decode a persisted selection payload. Same contract as `decodePool`. */
339
+ const decodeSelection = (raw, label = "[butr]") => {
340
+ if (raw === null || raw === void 0 || raw === "") return {};
341
+ let value;
342
+ try {
343
+ value = JSON.parse(raw);
344
+ } catch (error) {
345
+ logWarn(`${label} failed to parse selection from storage:`, error);
346
+ return {};
347
+ }
348
+ const parsed = recordSchema.safeParse(value);
349
+ if (!parsed.success) return {};
350
+ const result = {};
351
+ for (const [key, selectionValue] of Object.entries(parsed.data)) {
352
+ const platform = chainPlatformSchema.safeParse(key);
353
+ if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
354
+ }
355
+ return result;
356
+ };
372
357
 
373
358
  //#endregion
374
359
  //#region src/storage/wallet-storage.ts
@@ -380,19 +365,17 @@ var WalletStorage = class {
380
365
  persistent;
381
366
  session;
382
367
  /**
383
- * Serializes pool-key mutations so concurrent fire-and-forget
384
- * writes can't interleave their read-modify-write phases. Without
385
- * this, two simultaneous `setPool` calls both read the pre-write
386
- * state, each merge their own entries, and whichever finishes last
387
- * overwrites the other's additions. Reads (`getPool`) don't enter
388
- * the queue; they observe whatever's currently in the driver.
368
+ * Serializes pool read-modify-writes; without it concurrent `setPool`
369
+ * calls drop each other's entries. Not reentrant, so a queued mutation
370
+ * must read via `readPool`: `getPool` re-enters and deadlocks it.
389
371
  */
390
372
  poolMutationQueue = Promise.resolve();
391
373
  constructor(config) {
392
- this.poolKey = `${config.keyPrefix}-pool`;
393
- this.selectionKey = `${config.keyPrefix}-selection`;
394
- this.activeKey = `${config.keyPrefix}-active`;
395
- this.userDisconnectedKey = `${config.keyPrefix}-user-disconnected`;
374
+ const keys = storageKeys(config.keyPrefix);
375
+ this.poolKey = keys.pool;
376
+ this.selectionKey = keys.selection;
377
+ this.activeKey = keys.active;
378
+ this.userDisconnectedKey = keys.userDisconnected;
396
379
  if (config.persistent && config.session) {
397
380
  this.persistent = config.persistent;
398
381
  this.session = config.session;
@@ -418,43 +401,48 @@ var WalletStorage = class {
418
401
  resolve();
419
402
  }
420
403
  }
421
- async getPool() {
404
+ /** Read and decode the pool without touching the mutation queue. Safe to
405
+ * call from inside a queued mutation, unlike `getPool`. */
406
+ async readPool() {
407
+ let stored;
422
408
  try {
423
- const stored = await this.persistent.getItem(this.poolKey);
424
- if (stored === null || stored === "") return {};
425
- const value = JSON.parse(stored);
426
- const parsed = recordSchema.safeParse(value);
427
- if (!parsed.success) {
428
- await this.clearPool();
429
- return {};
430
- }
431
- const result = {};
432
- for (const [key, entryValue] of Object.entries(parsed.data)) {
433
- const entry = parseStoredPoolEntry(key, entryValue);
434
- if (entry === null) logWarn(`[butr] dropping invalid pool entry for ${key}`);
435
- else result[key] = entry;
436
- }
437
- return result;
409
+ stored = await this.persistent.getItem(this.poolKey);
438
410
  } catch (error) {
439
- logWarn("[butr] failed to parse pool from storage:", error);
440
- await this.clearPool();
441
- return {};
411
+ logWarn("[butr] failed to read pool from storage:", error);
412
+ return {
413
+ corrupt: false,
414
+ decoded: {}
415
+ };
442
416
  }
417
+ if (stored === null || stored === "") return {
418
+ corrupt: false,
419
+ decoded: {}
420
+ };
421
+ const decoded = decodePool(stored);
422
+ return {
423
+ corrupt: Object.keys(decoded).length === 0,
424
+ decoded
425
+ };
426
+ }
427
+ async getPool() {
428
+ const { corrupt, decoded } = await this.readPool();
429
+ if (corrupt) try {
430
+ await this.persistent.removeItem(this.poolKey);
431
+ } catch (error) {
432
+ logWarn("[butr] failed to clear corrupt pool:", error);
433
+ }
434
+ return decoded;
443
435
  }
444
436
  /**
445
- * Upsert the in-memory pool into storage. Additive: entries in
446
- * `pool` are written; entries already in storage that aren't in
447
- * `pool` are kept. The in-memory pool reflects "what's live right
448
- * now", not "the complete list of remembered connections"; a
449
- * silent reconnect that fails on reload leaves the entry out of
450
- * the pool but the saved entry stays so the next load can retry.
451
- * Use `removePoolEntry` for explicit eviction (the user clicked
452
- * Disconnect) and `clearAll` for a full wipe (reset).
437
+ * Additive on purpose: a failed silent reconnect drops the entry from
438
+ * the live pool, and it must survive to be retried next load.
439
+ * Eviction goes through `removePoolEntry` or `clearAll`.
453
440
  */
454
441
  async setPool(pool) {
455
442
  await this.serializePoolMutation(async () => {
456
443
  try {
457
- const serializable = { ...await this.getPool() };
444
+ const { decoded: existing } = await this.readPool();
445
+ const serializable = { ...existing };
458
446
  for (const [connectorId, wallet] of pool) {
459
447
  const entry = {
460
448
  account: wallet.account,
@@ -476,7 +464,7 @@ var WalletStorage = class {
476
464
  async removePoolEntry(connectorId) {
477
465
  await this.serializePoolMutation(async () => {
478
466
  try {
479
- const stored = await this.getPool();
467
+ const { decoded: stored } = await this.readPool();
480
468
  if (stored[connectorId]) {
481
469
  const { [connectorId]: _, ...remaining } = stored;
482
470
  await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
@@ -493,19 +481,9 @@ var WalletStorage = class {
493
481
  }
494
482
  async getSelection() {
495
483
  try {
496
- const stored = await this.persistent.getItem(this.selectionKey);
497
- if (stored === null || stored === "") return {};
498
- const value = JSON.parse(stored);
499
- const parsed = recordSchema.safeParse(value);
500
- if (!parsed.success) return {};
501
- const result = {};
502
- for (const [key, selectionValue] of Object.entries(parsed.data)) {
503
- const platform = chainPlatformSchema.safeParse(key);
504
- if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
505
- }
506
- return result;
484
+ return decodeSelection(await this.persistent.getItem(this.selectionKey));
507
485
  } catch (error) {
508
- logWarn("[butr] failed to parse selection from storage:", error);
486
+ logWarn("[butr] failed to read selection from storage:", error);
509
487
  return {};
510
488
  }
511
489
  }
@@ -541,12 +519,9 @@ var WalletStorage = class {
541
519
  ]);
542
520
  }
543
521
  /**
544
- * Disconnect-intent tracking.
545
- *
546
- * Lives in the session driver: survives component remounts (unlike refs)
547
- * but clears when the session ends (unlike the persistent driver).
548
- * Prevents auto-connect from firing immediately after a manual disconnect,
549
- * while still allowing auto-connect on fresh sessions.
522
+ * Kept in the session driver so it survives remounts (unlike a ref)
523
+ * yet clears at session end (unlike the persistent driver): a manual
524
+ * disconnect must suppress auto-connect now, not forever.
550
525
  */
551
526
  async isUserDisconnected() {
552
527
  try {
@@ -730,6 +705,7 @@ const reducer = (state, event) => {
730
705
  case "HYDRATED": {
731
706
  const pool = new Map(state.pool);
732
707
  for (const [id, entry] of event.pool) pool.set(id, entry);
708
+ for (const id of event.dropped) pool.delete(id);
733
709
  const selection = new Map(state.selection);
734
710
  for (const [platform, id] of event.selection) selection.set(platform, id);
735
711
  let activeConnectorId = null;
@@ -737,11 +713,18 @@ const reducer = (state, event) => {
737
713
  else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
738
714
  else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
739
715
  let nextReconnecting = state.reconnectingIds;
740
- if (state.reconnectingIds.size > 0 && event.pool.size > 0) {
716
+ if (state.reconnectingIds.size > 0) {
741
717
  const next = new Set(state.reconnectingIds);
742
718
  for (const id of event.pool.keys()) next.delete(id);
719
+ for (const id of event.dropped) next.delete(id);
743
720
  if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
744
721
  }
722
+ const selectionAfterDrop = new Map(selection);
723
+ for (const [platform, id] of selectionAfterDrop) if (!pool.has(id)) {
724
+ selectionAfterDrop.delete(platform);
725
+ const fallback = findConnectorForPlatform(pool, platform);
726
+ if (fallback !== void 0) selectionAfterDrop.set(platform, fallback);
727
+ }
745
728
  return {
746
729
  ...state,
747
730
  activeConnectorId,
@@ -749,7 +732,7 @@ const reducer = (state, event) => {
749
732
  isUserDisconnected: event.isUserDisconnected,
750
733
  pool,
751
734
  reconnectingIds: nextReconnecting,
752
- selection
735
+ selection: selectionAfterDrop
753
736
  };
754
737
  }
755
738
  case "USER_DISCONNECTED_SET": return {
@@ -770,6 +753,7 @@ const reducer = (state, event) => {
770
753
  ...state,
771
754
  activeConnectorId: connectorId,
772
755
  connectingConnectorId: null,
756
+ connectionError: null,
773
757
  connectionStatus: "success",
774
758
  pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
775
759
  reconnectingIds: nextReconnecting
@@ -780,18 +764,34 @@ const reducer = (state, event) => {
780
764
  ...state,
781
765
  activeConnectorId: connectorId,
782
766
  connectingConnectorId: null,
767
+ connectionError: null,
783
768
  connectionStatus: "success",
784
769
  pool: newPool,
785
770
  reconnectingIds: nextReconnecting,
786
771
  selection: newSelection
787
772
  };
788
773
  }
789
- case "CONNECT_FAILED": return {
790
- ...state,
791
- connectingConnectorId: null,
792
- connectionError: event.error,
793
- connectionStatus: "error"
794
- };
774
+ case "ENTRY_RESTORED": {
775
+ const { connectorId, entry } = event;
776
+ const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
777
+ const platform = entry.connector.chainPlatform;
778
+ const selection = state.selection.has(platform) ? state.selection : new Map([...state.selection, [platform, connectorId]]);
779
+ return {
780
+ ...state,
781
+ activeConnectorId: state.activeConnectorId ?? connectorId,
782
+ pool: new Map([...state.pool, [connectorId, entry]]),
783
+ reconnectingIds: nextReconnecting,
784
+ selection
785
+ };
786
+ }
787
+ case "CONNECT_FAILED":
788
+ if (state.connectingConnectorId !== event.connectorId) return state;
789
+ return {
790
+ ...state,
791
+ connectingConnectorId: null,
792
+ connectionError: event.error,
793
+ connectionStatus: "error"
794
+ };
795
795
  case "DISCONNECTED": {
796
796
  const wallet = state.pool.get(event.connectorId);
797
797
  if (!wallet) return state;
@@ -894,13 +894,9 @@ const run = async (fn, onError) => {
894
894
  const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
895
895
  const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
896
896
  /**
897
- * Build a synchronously-populated `State` from `config.initialState`.
898
- * Each pool entry becomes a `ConnectedWallet` whose `connector` is a
899
- * shadow adapter; every connector id enters `reconnectingIds` so
900
- * consumers can branch on "is this connection verified" without
901
- * waiting for the async silent reconnect. `isHydrated` flips true
902
- * synchronously: the consumer's first render sees the persisted
903
- * state in the live store, not undefined.
897
+ * `isHydrated` is true from the first render on this path, so
898
+ * `reconnectingIds` (not `isHydrated`) is the "is this connection
899
+ * verified" signal until silent reconnect lands.
904
900
  */
905
901
  const seedStateFromSnapshot = (snapshot) => {
906
902
  const pool = /* @__PURE__ */ new Map();
@@ -931,8 +927,7 @@ const CONNECT_TIMEOUT_MS = 9e4;
931
927
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
932
928
  const normaliseAddress = (addr) => addr.toLowerCase();
933
929
  const createWalletStore = (config) => {
934
- const storageKeyPrefix = config.storageKeyPrefix === void 0 || config.storageKeyPrefix === "" ? "butr" : config.storageKeyPrefix;
935
- const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
930
+ const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
936
931
  const reportStorageError = (context) => (error) => {
937
932
  if (config.onStorageError) {
938
933
  try {
@@ -966,6 +961,8 @@ const createWalletStore = (config) => {
966
961
  refreshPoolEntry(connectorId, [...accounts], active);
967
962
  },
968
963
  onDisconnected: (connectorId, chainPlatform) => {
964
+ const wallet = get().pool.get(connectorId);
965
+ if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
969
966
  dispatch({
970
967
  connectorId,
971
968
  type: "DISCONNECTED"
@@ -1026,6 +1023,7 @@ const createWalletStore = (config) => {
1026
1023
  } catch (error) {
1027
1024
  const normalised = mapConnectionError(error);
1028
1025
  dispatch({
1026
+ connectorId,
1029
1027
  error: normalised,
1030
1028
  type: "CONNECT_FAILED"
1031
1029
  });
@@ -1072,6 +1070,7 @@ const createWalletStore = (config) => {
1072
1070
  const result = await hydration.hydrate();
1073
1071
  dispatch({
1074
1072
  activeConnectorId: result.activeConnectorId,
1073
+ dropped: result.dropped.map((d) => d.connectorId),
1075
1074
  isUserDisconnected: result.isUserDisconnected,
1076
1075
  pool: result.pool,
1077
1076
  selection: result.selection,
@@ -1161,7 +1160,7 @@ const createWalletStore = (config) => {
1161
1160
  dispatch({
1162
1161
  connectorId,
1163
1162
  entry: outcome.entry,
1164
- type: "CONNECT_SUCCEEDED"
1163
+ type: "ENTRY_RESTORED"
1165
1164
  });
1166
1165
  lifecycle.attach(connectorId, outcome.entry.connector);
1167
1166
  await Promise.all([
@@ -1228,28 +1227,9 @@ const toCookieMap$1 = (input) => {
1228
1227
  return new Map(Object.entries(input));
1229
1228
  };
1230
1229
  /**
1231
- * Cookie-backed storage driver. Reads/writes `document.cookie`;
1232
- * server-readable, survives reloads, scoped per `domain`/`path`.
1233
- *
1234
- * **When to use this:** SSR apps that need to know who's connected
1235
- * during the server render (so they can stream the connected-wallet
1236
- * UI without a client-side hydration flicker). Pass `initialCookies`
1237
- * from a server-side cookie source (e.g. `cookies()` in Next.js'
1238
- * `next/headers`, or a parsed `req.headers.cookie`) and the same
1239
- * driver will serve those values during the server render and switch
1240
- * to `document.cookie` once it mounts in the browser.
1241
- *
1242
- * **Trade-offs vs `localStorage`:** cookies travel with every
1243
- * request, so they cost bytes on the wire. Keep the storage key
1244
- * prefix short, and prefer this driver for the `persistent` slot
1245
- * only: the `session` slot can stay in `sessionStorage` (which
1246
- * cookies can't natively model anyway).
1247
- *
1248
- * **Server-side writes are no-ops.** Emitting `Set-Cookie` requires
1249
- * access to the framework's response object, which a storage driver
1250
- * shouldn't reach into. The store doesn't mutate persisted state
1251
- * during the SSR pass anyway; writes only fire after client mount,
1252
- * once `document.cookie` is reachable.
1230
+ * Server-side writes are no-ops: `Set-Cookie` needs the framework's
1231
+ * response object. Cookies ride every request, so use this driver for
1232
+ * the `persistent` slot only and leave `session` on `sessionStorage`.
1253
1233
  */
1254
1234
  const createCookieStorageDriver = (options = {}) => {
1255
1235
  const seeded = toCookieMap$1(options.initialCookies);
@@ -1302,74 +1282,18 @@ const toCookieMap = (input) => {
1302
1282
  }
1303
1283
  return out;
1304
1284
  };
1305
- const parsePool = (raw) => {
1306
- if (raw === void 0 || raw === "") return {};
1307
- try {
1308
- const value = JSON.parse(raw);
1309
- const parsed = recordSchema.safeParse(value);
1310
- if (!parsed.success) return {};
1311
- const result = {};
1312
- for (const [key, entryValue] of Object.entries(parsed.data)) {
1313
- const entry = parseStoredPoolEntry(key, entryValue);
1314
- if (entry === null) logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
1315
- else result[key] = entry;
1316
- }
1317
- return result;
1318
- } catch (error) {
1319
- logWarn("[butr] readWalletSnapshot: failed to parse pool cookie:", error);
1320
- return {};
1321
- }
1322
- };
1323
- const parseSelection = (raw) => {
1324
- if (raw === void 0 || raw === "") return {};
1325
- try {
1326
- const value = JSON.parse(raw);
1327
- const parsed = recordSchema.safeParse(value);
1328
- if (!parsed.success) return {};
1329
- const result = {};
1330
- for (const [key, selectionValue] of Object.entries(parsed.data)) {
1331
- const platform = chainPlatformSchema.safeParse(key);
1332
- if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
1333
- }
1334
- return result;
1335
- } catch (error) {
1336
- logWarn("[butr] readWalletSnapshot: failed to parse selection cookie:", error);
1337
- return {};
1338
- }
1339
- };
1285
+ const SNAPSHOT_LABEL = "[butr] readWalletSnapshot:";
1340
1286
  /**
1341
- * Parse a cookie source into a server-safe `WalletSnapshot`.
1342
- *
1343
- * Pure, sync, no `document`, no React; runnable in any environment
1344
- * (Server Component, route handler, edge middleware, even client
1345
- * code). Pair with `createCookieStorageDriver({ initialCookies })`
1346
- * and `<WalletManagerProvider initialSnapshot={…} />` to render a
1347
- * connected shell server-side without a hydration flash.
1348
- *
1349
- * **Stale-snapshot semantics.** The snapshot reflects whatever the
1350
- * browser most recently persisted. If the user has since uninstalled
1351
- * the wallet, switched accounts, or disconnected in another tab, the
1352
- * client-side hydration will reconcile reality and the live store
1353
- * will diverge from the snapshot. Treat the snapshot as an
1354
- * *optimistic* shell; accurate enough to avoid a paint flicker,
1355
- * authoritative only after `useIsHydrated()` is true.
1356
- *
1357
- * **Inputs.** Accepts the three shapes Next.js / Express / Hono /
1358
- * generic-Node cookie code naturally produces:
1359
- * - A plain object: `{ "butr-pool": "{...}", … }`
1360
- * - An array of `{ name, value }` (Next.js' `cookies().getAll()`)
1361
- * - An iterable of `[name, value]` tuples
1362
- *
1363
- * Malformed entries are dropped with a `logWarn` (same policy as
1364
- * `WalletStorage.getPool`); a cross-tab corruption shouldn't crash
1365
- * the server render.
1287
+ * Optimistic: only what the browser last persisted, so an uninstall or
1288
+ * other-tab disconnect makes it stale. Authoritative once the entry
1289
+ * leaves `reconnectingIds`; `isHydrated` is true from render one.
1366
1290
  */
1367
1291
  const readWalletSnapshot = (source, options = {}) => {
1368
- const keyPrefix = options.keyPrefix === void 0 || options.keyPrefix === "" ? "butr" : options.keyPrefix;
1292
+ const keys = storageKeys(options.keyPrefix);
1369
1293
  const cookies = toCookieMap(source);
1370
- const pool = parsePool(cookies.get(`${keyPrefix}-pool`));
1371
- const selection = parseSelection(cookies.get(`${keyPrefix}-selection`));
1372
- const rawActive = cookies.get(`${keyPrefix}-active`);
1294
+ const pool = decodePool(cookies.get(keys.pool), SNAPSHOT_LABEL);
1295
+ const selection = decodeSelection(cookies.get(keys.selection), SNAPSHOT_LABEL);
1296
+ const rawActive = cookies.get(keys.active);
1373
1297
  let activeConnectorId = null;
1374
1298
  if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1375
1299
  else {
@@ -1386,26 +1310,9 @@ const readWalletSnapshot = (source, options = {}) => {
1386
1310
  //#endregion
1387
1311
  //#region src/group-by-platform.ts
1388
1312
  /**
1389
- * Bucket a flat list into per-platform groups.
1390
- *
1391
- * A multi-chain wallet announces one adapter per platform it speaks, so
1392
- * discovery hands back a flat list where a single brand appears several
1393
- * times. Any app targeting more than one chain has to bucket that list
1394
- * before rendering, and hand-rolled versions drift: butr's own demos
1395
- * carried a copy that reimplemented `CHAIN_PLATFORMS` as a local
1396
- * ordering constant.
1397
- *
1398
- * Keys are inserted in `CHAIN_PLATFORMS` order and platforms with no
1399
- * members are omitted, so the result is directly iterable for rendering
1400
- * (`[...groups]`) as well as addressable for lookup (`groups.get("evm")`)
1401
- * without a further emptiness filter.
1402
- *
1403
- * `getPlatform` exists because the discriminant sits at a different depth
1404
- * depending on the input: a discovered `WalletAdapter` carries
1405
- * `chainPlatform` at the top level, a pool entry nests it under
1406
- * `connector`. React consumers should reach for
1407
- * `useDiscoveredWalletsByPlatform` / `useConnectedWalletsByPlatform`
1408
- * instead, which bind the accessor for them.
1313
+ * A multi-chain wallet announces one adapter per platform, so brands
1314
+ * repeat in the flat list. Keys follow `CHAIN_PLATFORMS` order and empty
1315
+ * platforms are omitted, so `[...groups]` needs no emptiness filter.
1409
1316
  */
1410
1317
  const groupByPlatform = (items, getPlatform) => {
1411
1318
  const buckets = /* @__PURE__ */ new Map();
@@ -1426,20 +1333,9 @@ const groupByPlatform = (items, getPlatform) => {
1426
1333
  //#endregion
1427
1334
  //#region src/encoding/bytes.ts
1428
1335
  /**
1429
- * Shared byte-encoding helpers for the connector packages.
1430
- *
1431
- * These functions sit directly on the signing/address path of every
1432
- * chain. They used to be hand-reimplemented in ~10 connector files; a
1433
- * single tested module removes the drift surface (a `padStart` omission
1434
- * or base64 variant mismatch corrupts signatures/addresses for one chain
1435
- * only, and the divergence is invisible because the copies look "the
1436
- * same").
1437
- *
1438
- * Hex prefixing diverges across chains, so the module exposes **explicit
1439
- * variants** rather than one function:
1440
- * - {@link bytesToHex} returns bare hex (Bitcoin, Ledger).
1441
- * - {@link bytesToHexPrefixed} returns `0x`-prefixed hex (EVM, Polkadot).
1442
- * - {@link hexToBytes} tolerantly strips an optional `0x` (all callers).
1336
+ * Hex prefixing diverges by chain (bare for Bitcoin and Ledger, `0x` for
1337
+ * EVM and Polkadot), so the variants stay explicit: these sit on the
1338
+ * signing path, where a silent mismatch corrupts one chain's signatures.
1443
1339
  */
1444
1340
  /** Bitcoin/Solana alphabet; omits the visually ambiguous `0`, `O`, `I`, `l`. */
1445
1341
  const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
@@ -1543,30 +1439,9 @@ var SignInUnsupportedError = class extends Error {
1543
1439
  };
1544
1440
  const defaultBuildMessage = ({ account, nonce }) => `${account.walletAddress} signs in.\nNonce: ${nonce}`;
1545
1441
  /**
1546
- * Build a reusable sign-in flow: nonce, capability gate, signature,
1547
- * encoding, verification.
1548
- *
1549
- * Every wallet-auth app writes this same sequence, and the parts that
1550
- * are easy to get wrong are the ones that aren't about the app: which
1551
- * capability flag gates the attempt, whether a Solana wallet should take
1552
- * the SIWS path instead, and which bytes to base64 for the server (the
1553
- * wallet's `signedMessage`, not the input). This owns those; you own the
1554
- * nonce endpoint, the message format, and the verifier.
1555
- *
1556
- * ```ts
1557
- * const { signIn } = createSignInFlow({
1558
- * getNonce: () => fetch("/api/nonce").then((r) => r.text()),
1559
- * verify: (result) =>
1560
- * fetch("/api/verify", { body: JSON.stringify(result), method: "POST" }).then(() => {}),
1561
- * });
1562
- *
1563
- * await signIn(wallet);
1564
- * ```
1565
- *
1566
- * Solana wallets advertising `solana:signIn` take the SIWS path
1567
- * automatically, so `result.signedMessage` holds the wallet-composed
1568
- * SIWS statement and `result.message` is absent. Set
1569
- * `preferSignMessage` to opt out.
1442
+ * Solana wallets advertising `solana:signIn` take the SIWS path, so
1443
+ * `result.signedMessage` holds the wallet-composed statement and
1444
+ * `result.message` is absent. `preferSignMessage` opts out.
1570
1445
  */
1571
1446
  const createSignInFlow = (options) => {
1572
1447
  const buildMessage = options.buildMessage ?? defaultBuildMessage;
@@ -1617,21 +1492,9 @@ const createSignInFlow = (options) => {
1617
1492
  //#endregion
1618
1493
  //#region src/wallet-equal.ts
1619
1494
  /**
1620
- * Two wallet snapshots are equivalent for selector purposes iff they
1621
- * share the same adapter instance, active account address, and active
1622
- * account chain id. Used by `useStoreWithEqualityFn` consumers
1623
- * (active-wallet, selected-wallet, useWalletEntry) to suppress spurious
1624
- * re-renders when the underlying Map churns but the resolved entry
1625
- * hasn't changed.
1626
- *
1627
- * The adapter is compared by reference, not by `connector.id`: hydration
1628
- * replaces a shadow adapter with the live one under an unchanged id and
1629
- * address, so an id comparison reports "equal" and leaves consumers
1630
- * holding a placeholder whose every method throws ShadowConnectorError.
1631
- *
1632
- * Hoisted to its own module so the equivalence rule lives in one place;
1633
- * if we ever extend the snapshot (e.g. to consider `accounts.length`),
1634
- * every selector hook picks up the new rule for free.
1495
+ * The adapter is compared by reference, not `connector.id`: hydration
1496
+ * swaps a shadow adapter for the live one under an unchanged id, and an
1497
+ * id check would strand consumers on the throwing placeholder.
1635
1498
  */
1636
1499
  const walletEqual = (a, b) => {
1637
1500
  if (a === b) return true;
@@ -1642,24 +1505,9 @@ const walletEqual = (a, b) => {
1642
1505
  //#endregion
1643
1506
  //#region src/sanitize-icon.ts
1644
1507
  /**
1645
- * Normalize a wallet-announced icon string.
1646
- *
1647
- * Wallets announce their icon through external metadata; EIP-6963
1648
- * `providerInfo.icon`, Wallet Standard `wallet.icon`. That value is
1649
- * not under butr's control, and some wallets ship data-URI icons with
1650
- * surrounding whitespace (a newline left over from a pretty-printed
1651
- * manifest). Strict consumers reject it: Next.js's `<Image>` throws
1652
- * because `src` must not start with a control character.
1653
- *
1654
- * Trims surrounding whitespace and treats an all-whitespace (or empty)
1655
- * icon as absent, so consumers get either a usable string or
1656
- * `undefined`: never a blank or malformed one. `undefined` passes
1657
- * through untouched.
1658
- *
1659
- * **Discovery already applies this.** Every adapter butr discovers has
1660
- * had its icon sanitized at construction, so `Connector.icon` is safe to
1661
- * render as-is. Reach for this helper only when building adapter or
1662
- * `ConnectorMeta` metadata yourself from a source butr didn't produce.
1508
+ * Some wallets announce data-URI icons wrapped in whitespace, which
1509
+ * makes Next.js `<Image>` throw on a leading control character.
1510
+ * Discovery already applies this; only hand-built metadata needs it.
1663
1511
  */
1664
1512
  const sanitizeIcon = (icon) => {
1665
1513
  if (icon === void 0) return;