@usebutr/core 1.0.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
+ };
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);
442
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 {
@@ -589,8 +564,6 @@ const createConnectorLifecycle = (handlers) => {
589
564
  case "disconnected":
590
565
  detach(connectorId);
591
566
  handlers.onDisconnected(connectorId, connector.chainPlatform);
592
- break;
593
- default:
594
567
  }
595
568
  });
596
569
  unsubscribers.set(connectorId, unsub);
@@ -732,6 +705,7 @@ const reducer = (state, event) => {
732
705
  case "HYDRATED": {
733
706
  const pool = new Map(state.pool);
734
707
  for (const [id, entry] of event.pool) pool.set(id, entry);
708
+ for (const id of event.dropped) pool.delete(id);
735
709
  const selection = new Map(state.selection);
736
710
  for (const [platform, id] of event.selection) selection.set(platform, id);
737
711
  let activeConnectorId = null;
@@ -739,11 +713,18 @@ const reducer = (state, event) => {
739
713
  else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
740
714
  else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
741
715
  let nextReconnecting = state.reconnectingIds;
742
- if (state.reconnectingIds.size > 0 && event.pool.size > 0) {
716
+ if (state.reconnectingIds.size > 0) {
743
717
  const next = new Set(state.reconnectingIds);
744
718
  for (const id of event.pool.keys()) next.delete(id);
719
+ for (const id of event.dropped) next.delete(id);
745
720
  if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
746
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
+ }
747
728
  return {
748
729
  ...state,
749
730
  activeConnectorId,
@@ -751,7 +732,7 @@ const reducer = (state, event) => {
751
732
  isUserDisconnected: event.isUserDisconnected,
752
733
  pool,
753
734
  reconnectingIds: nextReconnecting,
754
- selection
735
+ selection: selectionAfterDrop
755
736
  };
756
737
  }
757
738
  case "USER_DISCONNECTED_SET": return {
@@ -772,6 +753,7 @@ const reducer = (state, event) => {
772
753
  ...state,
773
754
  activeConnectorId: connectorId,
774
755
  connectingConnectorId: null,
756
+ connectionError: null,
775
757
  connectionStatus: "success",
776
758
  pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
777
759
  reconnectingIds: nextReconnecting
@@ -782,18 +764,34 @@ const reducer = (state, event) => {
782
764
  ...state,
783
765
  activeConnectorId: connectorId,
784
766
  connectingConnectorId: null,
767
+ connectionError: null,
785
768
  connectionStatus: "success",
786
769
  pool: newPool,
787
770
  reconnectingIds: nextReconnecting,
788
771
  selection: newSelection
789
772
  };
790
773
  }
791
- case "CONNECT_FAILED": return {
792
- ...state,
793
- connectingConnectorId: null,
794
- connectionError: event.error,
795
- connectionStatus: "error"
796
- };
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
+ };
797
795
  case "DISCONNECTED": {
798
796
  const wallet = state.pool.get(event.connectorId);
799
797
  if (!wallet) return state;
@@ -896,13 +894,9 @@ const run = async (fn, onError) => {
896
894
  const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
897
895
  const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
898
896
  /**
899
- * Build a synchronously-populated `State` from `config.initialState`.
900
- * Each pool entry becomes a `ConnectedWallet` whose `connector` is a
901
- * shadow adapter; every connector id enters `reconnectingIds` so
902
- * consumers can branch on "is this connection verified" without
903
- * waiting for the async silent reconnect. `isHydrated` flips true
904
- * synchronously: the consumer's first render sees the persisted
905
- * 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.
906
900
  */
907
901
  const seedStateFromSnapshot = (snapshot) => {
908
902
  const pool = /* @__PURE__ */ new Map();
@@ -933,8 +927,7 @@ const CONNECT_TIMEOUT_MS = 9e4;
933
927
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
934
928
  const normaliseAddress = (addr) => addr.toLowerCase();
935
929
  const createWalletStore = (config) => {
936
- const storageKeyPrefix = config.storageKeyPrefix === void 0 || config.storageKeyPrefix === "" ? "butr" : config.storageKeyPrefix;
937
- const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
930
+ const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
938
931
  const reportStorageError = (context) => (error) => {
939
932
  if (config.onStorageError) {
940
933
  try {
@@ -968,6 +961,8 @@ const createWalletStore = (config) => {
968
961
  refreshPoolEntry(connectorId, [...accounts], active);
969
962
  },
970
963
  onDisconnected: (connectorId, chainPlatform) => {
964
+ const wallet = get().pool.get(connectorId);
965
+ if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
971
966
  dispatch({
972
967
  connectorId,
973
968
  type: "DISCONNECTED"
@@ -1028,6 +1023,7 @@ const createWalletStore = (config) => {
1028
1023
  } catch (error) {
1029
1024
  const normalised = mapConnectionError(error);
1030
1025
  dispatch({
1026
+ connectorId,
1031
1027
  error: normalised,
1032
1028
  type: "CONNECT_FAILED"
1033
1029
  });
@@ -1074,6 +1070,7 @@ const createWalletStore = (config) => {
1074
1070
  const result = await hydration.hydrate();
1075
1071
  dispatch({
1076
1072
  activeConnectorId: result.activeConnectorId,
1073
+ dropped: result.dropped.map((d) => d.connectorId),
1077
1074
  isUserDisconnected: result.isUserDisconnected,
1078
1075
  pool: result.pool,
1079
1076
  selection: result.selection,
@@ -1163,7 +1160,7 @@ const createWalletStore = (config) => {
1163
1160
  dispatch({
1164
1161
  connectorId,
1165
1162
  entry: outcome.entry,
1166
- type: "CONNECT_SUCCEEDED"
1163
+ type: "ENTRY_RESTORED"
1167
1164
  });
1168
1165
  lifecycle.attach(connectorId, outcome.entry.connector);
1169
1166
  await Promise.all([
@@ -1191,7 +1188,7 @@ const createWalletStore = (config) => {
1191
1188
 
1192
1189
  //#endregion
1193
1190
  //#region src/storage/cookie-storage-driver.ts
1194
- const DEFAULT_MAX_AGE_SECONDS = 3600 * 24 * 30;
1191
+ const DEFAULT_MAX_AGE_SECONDS = 2592e3;
1195
1192
  const encode = (value) => encodeURIComponent(value);
1196
1193
  const decode = (value) => decodeURIComponent(value);
1197
1194
  const readCookies = () => {
@@ -1230,28 +1227,9 @@ const toCookieMap$1 = (input) => {
1230
1227
  return new Map(Object.entries(input));
1231
1228
  };
1232
1229
  /**
1233
- * Cookie-backed storage driver. Reads/writes `document.cookie`;
1234
- * server-readable, survives reloads, scoped per `domain`/`path`.
1235
- *
1236
- * **When to use this:** SSR apps that need to know who's connected
1237
- * during the server render (so they can stream the connected-wallet
1238
- * UI without a client-side hydration flicker). Pass `initialCookies`
1239
- * from a server-side cookie source (e.g. `cookies()` in Next.js'
1240
- * `next/headers`, or a parsed `req.headers.cookie`) and the same
1241
- * driver will serve those values during the server render and switch
1242
- * to `document.cookie` once it mounts in the browser.
1243
- *
1244
- * **Trade-offs vs `localStorage`:** cookies travel with every
1245
- * request, so they cost bytes on the wire. Keep the storage key
1246
- * prefix short, and prefer this driver for the `persistent` slot
1247
- * only: the `session` slot can stay in `sessionStorage` (which
1248
- * cookies can't natively model anyway).
1249
- *
1250
- * **Server-side writes are no-ops.** Emitting `Set-Cookie` requires
1251
- * access to the framework's response object, which a storage driver
1252
- * shouldn't reach into. The store doesn't mutate persisted state
1253
- * during the SSR pass anyway; writes only fire after client mount,
1254
- * 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`.
1255
1233
  */
1256
1234
  const createCookieStorageDriver = (options = {}) => {
1257
1235
  const seeded = toCookieMap$1(options.initialCookies);
@@ -1304,74 +1282,18 @@ const toCookieMap = (input) => {
1304
1282
  }
1305
1283
  return out;
1306
1284
  };
1307
- const parsePool = (raw) => {
1308
- if (raw === void 0 || raw === "") return {};
1309
- try {
1310
- const value = JSON.parse(raw);
1311
- const parsed = recordSchema.safeParse(value);
1312
- if (!parsed.success) return {};
1313
- const result = {};
1314
- for (const [key, entryValue] of Object.entries(parsed.data)) {
1315
- const entry = parseStoredPoolEntry(key, entryValue);
1316
- if (entry === null) logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
1317
- else result[key] = entry;
1318
- }
1319
- return result;
1320
- } catch (error) {
1321
- logWarn("[butr] readWalletSnapshot: failed to parse pool cookie:", error);
1322
- return {};
1323
- }
1324
- };
1325
- const parseSelection = (raw) => {
1326
- if (raw === void 0 || raw === "") return {};
1327
- try {
1328
- const value = JSON.parse(raw);
1329
- const parsed = recordSchema.safeParse(value);
1330
- if (!parsed.success) return {};
1331
- const result = {};
1332
- for (const [key, selectionValue] of Object.entries(parsed.data)) {
1333
- const platform = chainPlatformSchema.safeParse(key);
1334
- if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
1335
- }
1336
- return result;
1337
- } catch (error) {
1338
- logWarn("[butr] readWalletSnapshot: failed to parse selection cookie:", error);
1339
- return {};
1340
- }
1341
- };
1285
+ const SNAPSHOT_LABEL = "[butr] readWalletSnapshot:";
1342
1286
  /**
1343
- * Parse a cookie source into a server-safe `WalletSnapshot`.
1344
- *
1345
- * Pure, sync, no `document`, no React; runnable in any environment
1346
- * (Server Component, route handler, edge middleware, even client
1347
- * code). Pair with `createCookieStorageDriver({ initialCookies })`
1348
- * and `<WalletManagerProvider initialSnapshot={…} />` to render a
1349
- * connected shell server-side without a hydration flash.
1350
- *
1351
- * **Stale-snapshot semantics.** The snapshot reflects whatever the
1352
- * browser most recently persisted. If the user has since uninstalled
1353
- * the wallet, switched accounts, or disconnected in another tab, the
1354
- * client-side hydration will reconcile reality and the live store
1355
- * will diverge from the snapshot. Treat the snapshot as an
1356
- * *optimistic* shell; accurate enough to avoid a paint flicker,
1357
- * authoritative only after `useIsHydrated()` is true.
1358
- *
1359
- * **Inputs.** Accepts the three shapes Next.js / Express / Hono /
1360
- * generic-Node cookie code naturally produces:
1361
- * - A plain object: `{ "butr-pool": "{...}", … }`
1362
- * - An array of `{ name, value }` (Next.js' `cookies().getAll()`)
1363
- * - An iterable of `[name, value]` tuples
1364
- *
1365
- * Malformed entries are dropped with a `logWarn` (same policy as
1366
- * `WalletStorage.getPool`); a cross-tab corruption shouldn't crash
1367
- * 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.
1368
1290
  */
1369
1291
  const readWalletSnapshot = (source, options = {}) => {
1370
- const keyPrefix = options.keyPrefix === void 0 || options.keyPrefix === "" ? "butr" : options.keyPrefix;
1292
+ const keys = storageKeys(options.keyPrefix);
1371
1293
  const cookies = toCookieMap(source);
1372
- const pool = parsePool(cookies.get(`${keyPrefix}-pool`));
1373
- const selection = parseSelection(cookies.get(`${keyPrefix}-selection`));
1374
- 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);
1375
1297
  let activeConnectorId = null;
1376
1298
  if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1377
1299
  else {
@@ -1386,70 +1308,34 @@ const readWalletSnapshot = (source, options = {}) => {
1386
1308
  };
1387
1309
 
1388
1310
  //#endregion
1389
- //#region src/wallet-equal.ts
1311
+ //#region src/group-by-platform.ts
1390
1312
  /**
1391
- * Two wallet snapshots are equivalent for selector purposes iff they
1392
- * share the same adapter instance, active account address, and active
1393
- * account chain id. Used by `useStoreWithEqualityFn` consumers
1394
- * (active-wallet, selected-wallet, useWalletEntry) to suppress spurious
1395
- * re-renders when the underlying Map churns but the resolved entry
1396
- * hasn't changed.
1397
- *
1398
- * The adapter is compared by reference, not by `connector.id`: hydration
1399
- * replaces a shadow adapter with the live one under an unchanged id and
1400
- * address, so an id comparison reports "equal" and leaves consumers
1401
- * holding a placeholder whose every method throws ShadowConnectorError.
1402
- *
1403
- * Hoisted to its own module so the equivalence rule lives in one place;
1404
- * if we ever extend the snapshot (e.g. to consider `accounts.length`),
1405
- * every selector hook picks up the new rule for free.
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.
1406
1316
  */
1407
- const walletEqual = (a, b) => {
1408
- if (a === b) return true;
1409
- if (!a || !b) return false;
1410
- return a.connector === b.connector && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
1411
- };
1412
-
1413
- //#endregion
1414
- //#region src/sanitize-icon.ts
1415
- /**
1416
- * Normalize a wallet-announced icon string.
1417
- *
1418
- * Wallets announce their icon through external metadata; EIP-6963
1419
- * `providerInfo.icon`, Wallet Standard `wallet.icon`. That value is
1420
- * not under butr's control, and some wallets ship data-URI icons with
1421
- * surrounding whitespace (a newline left over from a pretty-printed
1422
- * manifest). Strict consumers reject it: Next.js's `<Image>` throws
1423
- * because `src` must not start with a control character.
1424
- *
1425
- * Trims surrounding whitespace and treats an all-whitespace (or empty)
1426
- * icon as absent, so consumers get either a usable string or
1427
- * `undefined`: never a blank or malformed one. `undefined` passes
1428
- * through untouched.
1429
- */
1430
- const sanitizeIcon = (icon) => {
1431
- if (icon === void 0) return;
1432
- const trimmed = icon.trim();
1433
- return trimmed.length > 0 ? trimmed : void 0;
1317
+ const groupByPlatform = (items, getPlatform) => {
1318
+ const buckets = /* @__PURE__ */ new Map();
1319
+ for (const item of items) {
1320
+ const platform = getPlatform(item);
1321
+ const bucket = buckets.get(platform);
1322
+ if (bucket === void 0) buckets.set(platform, [item]);
1323
+ else bucket.push(item);
1324
+ }
1325
+ const ordered = /* @__PURE__ */ new Map();
1326
+ for (const platform of CHAIN_PLATFORMS) {
1327
+ const bucket = buckets.get(platform);
1328
+ if (bucket !== void 0) ordered.set(platform, bucket);
1329
+ }
1330
+ return ordered;
1434
1331
  };
1435
1332
 
1436
1333
  //#endregion
1437
1334
  //#region src/encoding/bytes.ts
1438
1335
  /**
1439
- * Shared byte-encoding helpers for the connector packages.
1440
- *
1441
- * These functions sit directly on the signing/address path of every
1442
- * chain. They used to be hand-reimplemented in ~10 connector files; a
1443
- * single tested module removes the drift surface (a `padStart` omission
1444
- * or base64 variant mismatch corrupts signatures/addresses for one chain
1445
- * only, and the divergence is invisible because the copies look "the
1446
- * same").
1447
- *
1448
- * Hex prefixing diverges across chains, so the module exposes **explicit
1449
- * variants** rather than one function:
1450
- * - {@link bytesToHex} returns bare hex (Bitcoin, Ledger).
1451
- * - {@link bytesToHexPrefixed} returns `0x`-prefixed hex (EVM, Polkadot).
1452
- * - {@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.
1453
1339
  */
1454
1340
  /** Bitcoin/Solana alphabet; omits the visually ambiguous `0`, `O`, `I`, `l`. */
1455
1341
  const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
@@ -1539,5 +1425,96 @@ const base58ToBytes = (input) => {
1539
1425
  };
1540
1426
 
1541
1427
  //#endregion
1542
- export { CHAIN_PLATFORMS, EMPTY_SNAPSHOT, ShadowConnectorError, WalletStorage, base58ToBytes, base64ToBytes, buildAccount, buildChainsByPlatform, bytesToBase58, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createWalletSource, createWalletStore, hexToBytes, isShadowAdapter, logError, logWarn, mapConnectionError, readWalletSnapshot, sanitizeIcon, walletEqual };
1428
+ //#region src/sign-in/sign-in-flow.ts
1429
+ /** Thrown before any wallet interaction when the wallet can't sign at
1430
+ * all. Distinct from a rejection: nothing was asked of the user, so UI
1431
+ * should say "this wallet can't sign in" rather than "you declined". */
1432
+ var SignInUnsupportedError = class extends Error {
1433
+ connectorId;
1434
+ constructor(connectorId) {
1435
+ super(`Wallet "${connectorId}" reports capabilities.signMessage === false, so it cannot sign in.`);
1436
+ this.connectorId = connectorId;
1437
+ this.name = "SignInUnsupportedError";
1438
+ }
1439
+ };
1440
+ const defaultBuildMessage = ({ account, nonce }) => `${account.walletAddress} signs in.\nNonce: ${nonce}`;
1441
+ /**
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.
1445
+ */
1446
+ const createSignInFlow = (options) => {
1447
+ const buildMessage = options.buildMessage ?? defaultBuildMessage;
1448
+ const sign = async (wallet, account) => {
1449
+ const { connector } = wallet;
1450
+ const signingAccount = account ?? wallet.account;
1451
+ if (!connector.capabilities.signMessage) throw new SignInUnsupportedError(connector.id);
1452
+ const nonce = await options.getNonce({
1453
+ account: signingAccount,
1454
+ wallet
1455
+ });
1456
+ if (options.preferSignMessage !== true && connector.chainPlatform === "svm" && connector.capabilities.signIn && connector.signIn !== void 0) {
1457
+ const output = await connector.signIn({ nonce });
1458
+ return {
1459
+ account: output.account,
1460
+ nonce,
1461
+ signature: output.signature,
1462
+ signatureBase64: bytesToBase64(output.signature),
1463
+ signedMessage: output.signedMessage,
1464
+ signedMessageBase64: bytesToBase64(output.signedMessage),
1465
+ wallet
1466
+ };
1467
+ }
1468
+ const message = buildMessage({
1469
+ account: signingAccount,
1470
+ nonce,
1471
+ wallet
1472
+ });
1473
+ const { signature, signedMessage } = await connector.signMessage(new TextEncoder().encode(message), signingAccount);
1474
+ return {
1475
+ account: signingAccount,
1476
+ message,
1477
+ nonce,
1478
+ signature,
1479
+ signatureBase64: bytesToBase64(signature),
1480
+ signedMessage,
1481
+ signedMessageBase64: bytesToBase64(signedMessage),
1482
+ wallet
1483
+ };
1484
+ };
1485
+ return { signIn: async (wallet, account) => {
1486
+ const result = await sign(wallet, account);
1487
+ await options.verify(result);
1488
+ return result;
1489
+ } };
1490
+ };
1491
+
1492
+ //#endregion
1493
+ //#region src/wallet-equal.ts
1494
+ /**
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.
1498
+ */
1499
+ const walletEqual = (a, b) => {
1500
+ if (a === b) return true;
1501
+ if (!a || !b) return false;
1502
+ return a.connector === b.connector && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
1503
+ };
1504
+
1505
+ //#endregion
1506
+ //#region src/sanitize-icon.ts
1507
+ /**
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.
1511
+ */
1512
+ const sanitizeIcon = (icon) => {
1513
+ if (icon === void 0) return;
1514
+ const trimmed = icon.trim();
1515
+ return trimmed.length > 0 ? trimmed : void 0;
1516
+ };
1517
+
1518
+ //#endregion
1519
+ export { CHAIN_PLATFORMS, EMPTY_SNAPSHOT, ShadowConnectorError, SignInUnsupportedError, WalletStorage, base58ToBytes, base64ToBytes, buildAccount, buildChainsByPlatform, bytesToBase58, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createSignInFlow, createWalletSource, createWalletStore, groupByPlatform, hexToBytes, isShadowAdapter, logError, logWarn, mapConnectionError, readWalletSnapshot, sanitizeIcon, walletEqual };
1543
1520
  //# sourceMappingURL=index.js.map