@usebutr/core 1.1.0 → 2.0.1

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 ?? [],
@@ -53,18 +30,12 @@ const buildChainsByPlatform = (partial) => ({
53
30
 
54
31
  //#endregion
55
32
  //#region src/types/errors.ts
33
+ const isCodedError = (error) => "code" in error;
34
+ const toErrorCause = (value) => value;
56
35
  /**
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.
36
+ * EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
37
+ * unauthorized or disconnected. Message-substring matching is the last
38
+ * resort for SDKs that ship no codes at all.
68
39
  */
69
40
  const mapConnectionError = (raw) => {
70
41
  if (raw instanceof Error) {
@@ -78,7 +49,7 @@ const mapConnectionError = (raw) => {
78
49
  kind: "NotConnected",
79
50
  message
80
51
  };
81
- const code = "code" in raw ? raw.code : void 0;
52
+ const code = isCodedError(raw) ? raw.code : void 0;
82
53
  if (code === 4001) return {
83
54
  kind: "UserRejected",
84
55
  message
@@ -123,11 +94,9 @@ const mapConnectionError = (raw) => {
123
94
  //#endregion
124
95
  //#region src/types/platform.ts
125
96
  /**
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).
97
+ * Single runtime source of truth: `ChainPlatform` and the storage
98
+ * validators' allowlists both derive from it. Omitting a platform here
99
+ * silently stops its connections from persisting.
131
100
  */
132
101
  const CHAIN_PLATFORMS = [
133
102
  "evm",
@@ -140,32 +109,17 @@ const CHAIN_PLATFORMS = [
140
109
  //#endregion
141
110
  //#region src/wallet-source.ts
142
111
  /**
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`.
112
+ * Takes the exact shape of `discoverEvmAdapters` and friends, so a
113
+ * single-platform app keeps `@usebutr/wallets` out of its bundle.
148
114
  */
149
115
  const createWalletSource = (subscribe) => ({ subscribe });
150
116
 
151
117
  //#endregion
152
118
  //#region src/store/shadow-adapter.ts
153
119
  /**
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`.
120
+ * Loud failure for consumers that called a wallet method without gating
121
+ * on `capabilities.*` (all `false` here) or `reconnectingIds`, before
122
+ * silent reconnect swapped in the live adapter.
169
123
  */
170
124
  var ShadowConnectorError = class extends Error {
171
125
  code = "BUTR_RECONNECTING";
@@ -191,26 +145,9 @@ const ALL_FALSE_CAPABILITIES = Object.freeze({
191
145
  switchChain: false
192
146
  });
193
147
  /**
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.
148
+ * Lets `createWalletStore` flip `isHydrated` synchronously from
149
+ * `initialState`; the hydration coordinator later swaps each shadow for
150
+ * a live adapter, or drops the entry from pool and storage.
214
151
  */
215
152
  const createShadowAdapter = (entry) => {
216
153
  const id = entry.connectorId;
@@ -256,21 +193,13 @@ const createShadowAdapter = (entry) => {
256
193
  };
257
194
  default:
258
195
  entry.chainPlatform;
259
- throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
196
+ throw new Error(`[butr] unknown chainPlatform: ${String(entry.chainPlatform)}`);
260
197
  }
261
198
  };
262
199
  /**
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).
200
+ * Structural detection: relies on every live adapter advertising at
201
+ * least one capability, since `getBalance`, `signMessage` and
202
+ * `switchChain` are required on all wallet surfaces.
274
203
  */
275
204
  const isShadowAdapter = (adapter) => {
276
205
  return Object.values(adapter.capabilities).every((flag) => !flag);
@@ -289,7 +218,7 @@ const logError = (...args) => {
289
218
  //#region src/storage/browser-storage-driver.ts
290
219
  const hasWebStorage = (kind) => {
291
220
  try {
292
- return typeof globalThis !== "undefined" && globalThis[kind] !== void 0;
221
+ return kind === "localStorage" ? globalThis.localStorage !== void 0 : globalThis.sessionStorage !== void 0;
293
222
  } catch {
294
223
  return false;
295
224
  }
@@ -369,6 +298,65 @@ const parseStoredPoolEntry = (key, value) => {
369
298
  if (!parsed.success || parsed.data.connectorId !== key) return null;
370
299
  return parsed.data;
371
300
  };
301
+ const DEFAULT_KEY_PREFIX = "butr";
302
+ /**
303
+ * `WalletStorage` writes these and `readWalletSnapshot` reads them from
304
+ * a cookie jar; any divergence desyncs the SSR-seeded render from the
305
+ * client that rehydrates it, the failure ADR 0003 exists to prevent.
306
+ */
307
+ const storageKeys = (keyPrefix) => {
308
+ const prefix = keyPrefix === void 0 || keyPrefix === "" ? DEFAULT_KEY_PREFIX : keyPrefix;
309
+ return {
310
+ active: `${prefix}-active`,
311
+ pool: `${prefix}-pool`,
312
+ selection: `${prefix}-selection`,
313
+ userDisconnected: `${prefix}-user-disconnected`
314
+ };
315
+ };
316
+ /**
317
+ * Never repairs or throws: one corrupt entry must not take down a whole
318
+ * session, and `readWalletSnapshot` may run on a server with no cookie
319
+ * jar to write the eviction to.
320
+ */
321
+ const decodePool = (raw, label = "[butr]") => {
322
+ if (raw === null || raw === void 0 || raw === "") return {};
323
+ let value;
324
+ try {
325
+ value = JSON.parse(raw);
326
+ } catch (error) {
327
+ logWarn(`${label} failed to parse pool from storage:`, error);
328
+ return {};
329
+ }
330
+ const parsed = recordSchema.safeParse(value);
331
+ if (!parsed.success) return {};
332
+ const result = {};
333
+ for (const [key, entryValue] of Object.entries(parsed.data)) {
334
+ const candidate = storedPoolEntrySchema.safeParse(entryValue);
335
+ const entry = candidate.success ? parseStoredPoolEntry(key, candidate.data) : null;
336
+ if (entry === null) logWarn(`${label} dropping invalid pool entry for ${key}`);
337
+ else result[key] = entry;
338
+ }
339
+ return result;
340
+ };
341
+ /** Decode a persisted selection payload. Same contract as `decodePool`. */
342
+ const decodeSelection = (raw, label = "[butr]") => {
343
+ if (raw === null || raw === void 0 || raw === "") return {};
344
+ let value;
345
+ try {
346
+ value = JSON.parse(raw);
347
+ } catch (error) {
348
+ logWarn(`${label} failed to parse selection from storage:`, error);
349
+ return {};
350
+ }
351
+ const parsed = recordSchema.safeParse(value);
352
+ if (!parsed.success) return {};
353
+ const result = {};
354
+ for (const [key, selectionValue] of Object.entries(parsed.data)) {
355
+ const platform = chainPlatformSchema.safeParse(key);
356
+ if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
357
+ }
358
+ return result;
359
+ };
372
360
 
373
361
  //#endregion
374
362
  //#region src/storage/wallet-storage.ts
@@ -380,19 +368,17 @@ var WalletStorage = class {
380
368
  persistent;
381
369
  session;
382
370
  /**
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.
371
+ * Serializes pool read-modify-writes; without it concurrent `setPool`
372
+ * calls drop each other's entries. Not reentrant, so a queued mutation
373
+ * must read via `readPool`: `getPool` re-enters and deadlocks it.
389
374
  */
390
375
  poolMutationQueue = Promise.resolve();
391
376
  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`;
377
+ const keys = storageKeys(config.keyPrefix);
378
+ this.poolKey = keys.pool;
379
+ this.selectionKey = keys.selection;
380
+ this.activeKey = keys.active;
381
+ this.userDisconnectedKey = keys.userDisconnected;
396
382
  if (config.persistent && config.session) {
397
383
  this.persistent = config.persistent;
398
384
  this.session = config.session;
@@ -418,43 +404,48 @@ var WalletStorage = class {
418
404
  resolve();
419
405
  }
420
406
  }
421
- async getPool() {
407
+ /** Read and decode the pool without touching the mutation queue. Safe to
408
+ * call from inside a queued mutation, unlike `getPool`. */
409
+ async readPool() {
410
+ let stored;
422
411
  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;
412
+ stored = await this.persistent.getItem(this.poolKey);
438
413
  } catch (error) {
439
- logWarn("[butr] failed to parse pool from storage:", error);
440
- await this.clearPool();
441
- return {};
414
+ logWarn("[butr] failed to read pool from storage:", error);
415
+ return {
416
+ corrupt: false,
417
+ decoded: {}
418
+ };
442
419
  }
420
+ if (stored === null || stored === "") return {
421
+ corrupt: false,
422
+ decoded: {}
423
+ };
424
+ const decoded = decodePool(stored);
425
+ return {
426
+ corrupt: Object.keys(decoded).length === 0,
427
+ decoded
428
+ };
429
+ }
430
+ async getPool() {
431
+ const { corrupt, decoded } = await this.readPool();
432
+ if (corrupt) try {
433
+ await this.persistent.removeItem(this.poolKey);
434
+ } catch (error) {
435
+ logWarn("[butr] failed to clear corrupt pool:", error);
436
+ }
437
+ return decoded;
443
438
  }
444
439
  /**
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).
440
+ * Additive on purpose: a failed silent reconnect drops the entry from
441
+ * the live pool, and it must survive to be retried next load.
442
+ * Eviction goes through `removePoolEntry` or `clearAll`.
453
443
  */
454
444
  async setPool(pool) {
455
445
  await this.serializePoolMutation(async () => {
456
446
  try {
457
- const serializable = { ...await this.getPool() };
447
+ const { decoded: existing } = await this.readPool();
448
+ const serializable = { ...existing };
458
449
  for (const [connectorId, wallet] of pool) {
459
450
  const entry = {
460
451
  account: wallet.account,
@@ -476,7 +467,7 @@ var WalletStorage = class {
476
467
  async removePoolEntry(connectorId) {
477
468
  await this.serializePoolMutation(async () => {
478
469
  try {
479
- const stored = await this.getPool();
470
+ const { decoded: stored } = await this.readPool();
480
471
  if (stored[connectorId]) {
481
472
  const { [connectorId]: _, ...remaining } = stored;
482
473
  await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
@@ -493,19 +484,9 @@ var WalletStorage = class {
493
484
  }
494
485
  async getSelection() {
495
486
  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;
487
+ return decodeSelection(await this.persistent.getItem(this.selectionKey));
507
488
  } catch (error) {
508
- logWarn("[butr] failed to parse selection from storage:", error);
489
+ logWarn("[butr] failed to read selection from storage:", error);
509
490
  return {};
510
491
  }
511
492
  }
@@ -541,12 +522,9 @@ var WalletStorage = class {
541
522
  ]);
542
523
  }
543
524
  /**
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.
525
+ * Kept in the session driver so it survives remounts (unlike a ref)
526
+ * yet clears at session end (unlike the persistent driver): a manual
527
+ * disconnect must suppress auto-connect now, not forever.
550
528
  */
551
529
  async isUserDisconnected() {
552
530
  try {
@@ -636,7 +614,7 @@ const restoreOneEntry = async (connectorId, entry, connector) => {
636
614
  } catch (error) {
637
615
  return {
638
616
  connectorId,
639
- error,
617
+ error: toErrorCause(error instanceof Error ? error : String(error)),
640
618
  kind: "fail"
641
619
  };
642
620
  }
@@ -730,6 +708,7 @@ const reducer = (state, event) => {
730
708
  case "HYDRATED": {
731
709
  const pool = new Map(state.pool);
732
710
  for (const [id, entry] of event.pool) pool.set(id, entry);
711
+ for (const id of event.dropped) pool.delete(id);
733
712
  const selection = new Map(state.selection);
734
713
  for (const [platform, id] of event.selection) selection.set(platform, id);
735
714
  let activeConnectorId = null;
@@ -737,11 +716,18 @@ const reducer = (state, event) => {
737
716
  else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
738
717
  else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
739
718
  let nextReconnecting = state.reconnectingIds;
740
- if (state.reconnectingIds.size > 0 && event.pool.size > 0) {
719
+ if (state.reconnectingIds.size > 0) {
741
720
  const next = new Set(state.reconnectingIds);
742
721
  for (const id of event.pool.keys()) next.delete(id);
722
+ for (const id of event.dropped) next.delete(id);
743
723
  if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
744
724
  }
725
+ const selectionAfterDrop = new Map(selection);
726
+ for (const [platform, id] of selectionAfterDrop) if (!pool.has(id)) {
727
+ selectionAfterDrop.delete(platform);
728
+ const fallback = findConnectorForPlatform(pool, platform);
729
+ if (fallback !== void 0) selectionAfterDrop.set(platform, fallback);
730
+ }
745
731
  return {
746
732
  ...state,
747
733
  activeConnectorId,
@@ -749,7 +735,7 @@ const reducer = (state, event) => {
749
735
  isUserDisconnected: event.isUserDisconnected,
750
736
  pool,
751
737
  reconnectingIds: nextReconnecting,
752
- selection
738
+ selection: selectionAfterDrop
753
739
  };
754
740
  }
755
741
  case "USER_DISCONNECTED_SET": return {
@@ -770,6 +756,7 @@ const reducer = (state, event) => {
770
756
  ...state,
771
757
  activeConnectorId: connectorId,
772
758
  connectingConnectorId: null,
759
+ connectionError: null,
773
760
  connectionStatus: "success",
774
761
  pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
775
762
  reconnectingIds: nextReconnecting
@@ -780,18 +767,34 @@ const reducer = (state, event) => {
780
767
  ...state,
781
768
  activeConnectorId: connectorId,
782
769
  connectingConnectorId: null,
770
+ connectionError: null,
783
771
  connectionStatus: "success",
784
772
  pool: newPool,
785
773
  reconnectingIds: nextReconnecting,
786
774
  selection: newSelection
787
775
  };
788
776
  }
789
- case "CONNECT_FAILED": return {
790
- ...state,
791
- connectingConnectorId: null,
792
- connectionError: event.error,
793
- connectionStatus: "error"
794
- };
777
+ case "ENTRY_RESTORED": {
778
+ const { connectorId, entry } = event;
779
+ const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
780
+ const platform = entry.connector.chainPlatform;
781
+ const selection = state.selection.has(platform) ? state.selection : new Map([...state.selection, [platform, connectorId]]);
782
+ return {
783
+ ...state,
784
+ activeConnectorId: state.activeConnectorId ?? connectorId,
785
+ pool: new Map([...state.pool, [connectorId, entry]]),
786
+ reconnectingIds: nextReconnecting,
787
+ selection
788
+ };
789
+ }
790
+ case "CONNECT_FAILED":
791
+ if (state.connectingConnectorId !== event.connectorId) return state;
792
+ return {
793
+ ...state,
794
+ connectingConnectorId: null,
795
+ connectionError: event.error,
796
+ connectionStatus: "error"
797
+ };
795
798
  case "DISCONNECTED": {
796
799
  const wallet = state.pool.get(event.connectorId);
797
800
  if (!wallet) return state;
@@ -822,8 +825,8 @@ const reducer = (state, event) => {
822
825
  if (event.active) nextAccount = event.active;
823
826
  else nextAccount = event.accounts.some((a) => a.walletAddress === wallet.account.walletAddress && a.chain.id === wallet.account.chain.id) ? wallet.account : event.accounts[0] ?? wallet.account;
824
827
  const sameAccount = nextAccount.walletAddress === wallet.account.walletAddress && nextAccount.chain.id === wallet.account.chain.id;
825
- const sameAccountsShape = event.accounts.length === wallet.accounts.length && event.accounts.every((a, i) => a.walletAddress === wallet.accounts[i]?.walletAddress);
826
- if (sameAccount && sameAccountsShape) return state;
828
+ const hasSameAccounts = event.accounts.length === wallet.accounts.length && event.accounts.every((a, i) => a.walletAddress === wallet.accounts[i]?.walletAddress);
829
+ if (sameAccount && hasSameAccounts) return state;
827
830
  const updated = {
828
831
  ...wallet,
829
832
  account: nextAccount,
@@ -885,7 +888,7 @@ const run = async (fn, onError) => {
885
888
  try {
886
889
  await fn();
887
890
  } catch (error) {
888
- onError(error);
891
+ onError(toErrorCause(error instanceof Error ? error : String(error)));
889
892
  }
890
893
  };
891
894
 
@@ -894,13 +897,9 @@ const run = async (fn, onError) => {
894
897
  const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
895
898
  const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
896
899
  /**
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.
900
+ * `isHydrated` is true from the first render on this path, so
901
+ * `reconnectingIds` (not `isHydrated`) is the "is this connection
902
+ * verified" signal until silent reconnect lands.
904
903
  */
905
904
  const seedStateFromSnapshot = (snapshot) => {
906
905
  const pool = /* @__PURE__ */ new Map();
@@ -931,8 +930,7 @@ const CONNECT_TIMEOUT_MS = 9e4;
931
930
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
932
931
  const normaliseAddress = (addr) => addr.toLowerCase();
933
932
  const createWalletStore = (config) => {
934
- const storageKeyPrefix = config.storageKeyPrefix === void 0 || config.storageKeyPrefix === "" ? "butr" : config.storageKeyPrefix;
935
- const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
933
+ const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
936
934
  const reportStorageError = (context) => (error) => {
937
935
  if (config.onStorageError) {
938
936
  try {
@@ -966,6 +964,8 @@ const createWalletStore = (config) => {
966
964
  refreshPoolEntry(connectorId, [...accounts], active);
967
965
  },
968
966
  onDisconnected: (connectorId, chainPlatform) => {
967
+ const wallet = get().pool.get(connectorId);
968
+ if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
969
969
  dispatch({
970
970
  connectorId,
971
971
  type: "DISCONNECTED"
@@ -1024,8 +1024,9 @@ const createWalletStore = (config) => {
1024
1024
  config.onConnect?.(entry);
1025
1025
  onSuccess?.(entry);
1026
1026
  } catch (error) {
1027
- const normalised = mapConnectionError(error);
1027
+ const normalised = mapConnectionError(error instanceof Error ? error : String(error));
1028
1028
  dispatch({
1029
+ connectorId,
1029
1030
  error: normalised,
1030
1031
  type: "CONNECT_FAILED"
1031
1032
  });
@@ -1072,6 +1073,7 @@ const createWalletStore = (config) => {
1072
1073
  const result = await hydration.hydrate();
1073
1074
  dispatch({
1074
1075
  activeConnectorId: result.activeConnectorId,
1076
+ dropped: result.dropped.map((d) => d.connectorId),
1075
1077
  isUserDisconnected: result.isUserDisconnected,
1076
1078
  pool: result.pool,
1077
1079
  selection: result.selection,
@@ -1161,7 +1163,7 @@ const createWalletStore = (config) => {
1161
1163
  dispatch({
1162
1164
  connectorId,
1163
1165
  entry: outcome.entry,
1164
- type: "CONNECT_SUCCEEDED"
1166
+ type: "ENTRY_RESTORED"
1165
1167
  });
1166
1168
  lifecycle.attach(connectorId, outcome.entry.connector);
1167
1169
  await Promise.all([
@@ -1228,28 +1230,9 @@ const toCookieMap$1 = (input) => {
1228
1230
  return new Map(Object.entries(input));
1229
1231
  };
1230
1232
  /**
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.
1233
+ * Server-side writes are no-ops: `Set-Cookie` needs the framework's
1234
+ * response object. Cookies ride every request, so use this driver for
1235
+ * the `persistent` slot only and leave `session` on `sessionStorage`.
1253
1236
  */
1254
1237
  const createCookieStorageDriver = (options = {}) => {
1255
1238
  const seeded = toCookieMap$1(options.initialCookies);
@@ -1284,10 +1267,12 @@ const createCookieStorageDriver = (options = {}) => {
1284
1267
 
1285
1268
  //#endregion
1286
1269
  //#region src/storage/snapshot.ts
1270
+ const EMPTY_POOL = Object.freeze({});
1271
+ const EMPTY_SELECTION = Object.freeze({});
1287
1272
  const EMPTY_SNAPSHOT = Object.freeze({
1288
1273
  activeConnectorId: null,
1289
- pool: Object.freeze({}),
1290
- selection: Object.freeze({})
1274
+ pool: EMPTY_POOL,
1275
+ selection: EMPTY_SELECTION
1291
1276
  });
1292
1277
  const toCookieMap = (input) => {
1293
1278
  if (!(Symbol.iterator in input)) return new Map(Object.entries(input));
@@ -1302,74 +1287,18 @@ const toCookieMap = (input) => {
1302
1287
  }
1303
1288
  return out;
1304
1289
  };
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
- };
1290
+ const SNAPSHOT_LABEL = "[butr] readWalletSnapshot:";
1340
1291
  /**
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.
1292
+ * Optimistic: only what the browser last persisted, so an uninstall or
1293
+ * other-tab disconnect makes it stale. Authoritative once the entry
1294
+ * leaves `reconnectingIds`; `isHydrated` is true from render one.
1366
1295
  */
1367
1296
  const readWalletSnapshot = (source, options = {}) => {
1368
- const keyPrefix = options.keyPrefix === void 0 || options.keyPrefix === "" ? "butr" : options.keyPrefix;
1297
+ const keys = storageKeys(options.keyPrefix);
1369
1298
  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`);
1299
+ const pool = decodePool(cookies.get(keys.pool), SNAPSHOT_LABEL);
1300
+ const selection = decodeSelection(cookies.get(keys.selection), SNAPSHOT_LABEL);
1301
+ const rawActive = cookies.get(keys.active);
1373
1302
  let activeConnectorId = null;
1374
1303
  if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1375
1304
  else {
@@ -1386,26 +1315,9 @@ const readWalletSnapshot = (source, options = {}) => {
1386
1315
  //#endregion
1387
1316
  //#region src/group-by-platform.ts
1388
1317
  /**
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.
1318
+ * A multi-chain wallet announces one adapter per platform, so brands
1319
+ * repeat in the flat list. Keys follow `CHAIN_PLATFORMS` order and empty
1320
+ * platforms are omitted, so `[...groups]` needs no emptiness filter.
1409
1321
  */
1410
1322
  const groupByPlatform = (items, getPlatform) => {
1411
1323
  const buckets = /* @__PURE__ */ new Map();
@@ -1426,20 +1338,9 @@ const groupByPlatform = (items, getPlatform) => {
1426
1338
  //#endregion
1427
1339
  //#region src/encoding/bytes.ts
1428
1340
  /**
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).
1341
+ * Hex prefixing diverges by chain (bare for Bitcoin and Ledger, `0x` for
1342
+ * EVM and Polkadot), so the variants stay explicit: these sit on the
1343
+ * signing path, where a silent mismatch corrupts one chain's signatures.
1443
1344
  */
1444
1345
  /** Bitcoin/Solana alphabet; omits the visually ambiguous `0`, `O`, `I`, `l`. */
1445
1346
  const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
@@ -1543,30 +1444,9 @@ var SignInUnsupportedError = class extends Error {
1543
1444
  };
1544
1445
  const defaultBuildMessage = ({ account, nonce }) => `${account.walletAddress} signs in.\nNonce: ${nonce}`;
1545
1446
  /**
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.
1447
+ * Solana wallets advertising `solana:signIn` take the SIWS path, so
1448
+ * `result.signedMessage` holds the wallet-composed statement and
1449
+ * `result.message` is absent. `preferSignMessage` opts out.
1570
1450
  */
1571
1451
  const createSignInFlow = (options) => {
1572
1452
  const buildMessage = options.buildMessage ?? defaultBuildMessage;
@@ -1617,21 +1497,9 @@ const createSignInFlow = (options) => {
1617
1497
  //#endregion
1618
1498
  //#region src/wallet-equal.ts
1619
1499
  /**
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.
1500
+ * The adapter is compared by reference, not `connector.id`: hydration
1501
+ * swaps a shadow adapter for the live one under an unchanged id, and an
1502
+ * id check would strand consumers on the throwing placeholder.
1635
1503
  */
1636
1504
  const walletEqual = (a, b) => {
1637
1505
  if (a === b) return true;
@@ -1642,24 +1510,9 @@ const walletEqual = (a, b) => {
1642
1510
  //#endregion
1643
1511
  //#region src/sanitize-icon.ts
1644
1512
  /**
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.
1513
+ * Some wallets announce data-URI icons wrapped in whitespace, which
1514
+ * makes Next.js `<Image>` throw on a leading control character.
1515
+ * Discovery already applies this; only hand-built metadata needs it.
1663
1516
  */
1664
1517
  const sanitizeIcon = (icon) => {
1665
1518
  if (icon === void 0) return;