@usebutr/core 0.4.1 → 0.5.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
@@ -1,24 +1,25 @@
1
1
  import { createStore } from "zustand/vanilla";
2
+ import { z } from "zod";
2
3
 
3
4
  //#region src/types/chains-by-platform.ts
4
5
  /**
5
6
  * Build a fully-populated `ChainsByPlatform` from a partial. Platforms
6
7
  * the consumer doesn't specify default to an empty list.
7
8
  *
8
- * Use this in apps that target one or two chain platforms — importing
9
+ * Use this in apps that target one or two chain platforms; importing
9
10
  * only those packages keeps unused chain registries out of the bundle.
10
11
  * Apps that want every chain reach for `CHAINS_BY_PLATFORM` from
11
12
  * `@usebutr/wallets` instead.
12
13
  *
13
14
  * @example
14
- * // EVM-only app — Solana/Sui/Bitcoin tables never enter the bundle
15
+ * // EVM-only app: Solana/Sui/Bitcoin tables never enter the bundle
15
16
  * import { EVM_CHAINS_LIST } from "@usebutr/evm";
16
17
  * import { buildChainsByPlatform } from "@usebutr/core";
17
18
  *
18
19
  * const chains = buildChainsByPlatform({ evm: EVM_CHAINS_LIST });
19
20
  *
20
21
  * @example
21
- * // Multi-chain app — pull from each package the app actually uses
22
+ * // Multi-chain app: pull from each package the app actually uses
22
23
  * import { EVM_CHAINS_LIST } from "@usebutr/evm";
23
24
  * import { SVM_CHAINS_LIST } from "@usebutr/svm";
24
25
  *
@@ -44,7 +45,7 @@ const buildChainsByPlatform = (partial) => ({
44
45
  * - butr's own `Error("Connection timeout")` (from the 90s connect timeout)
45
46
  * - butr's own `Error("Failed to get account")` (from the connect flow)
46
47
  * - EIP-1193 numeric `code` properties (`4001` → UserRejected,
47
- * `-32002` → RequestPending, `4100`/`4900`/`4901` → NotConnected —
48
+ * `-32002` → RequestPending, `4100`/`4900`/`4901` → NotConnected:
48
49
  * unauthorized / disconnected from all-or-one chains)
49
50
  * - common message substrings: "user rejected" / "user denied",
50
51
  * "locked", "chain", etc.
@@ -62,7 +63,7 @@ const mapConnectionError = (raw) => {
62
63
  kind: "NotConnected",
63
64
  message
64
65
  };
65
- const code = raw.code;
66
+ const code = "code" in raw ? raw.code : void 0;
66
67
  if (code === 4001) return {
67
68
  kind: "UserRejected",
68
69
  message
@@ -120,6 +121,18 @@ const CHAIN_PLATFORMS = [
120
121
  "bitcoin",
121
122
  "polkadot"
122
123
  ];
124
+ /**
125
+ * Build butr's `Account` shape from a wallet address and a resolved
126
+ * `ChainBase`. The composite id (`<chain>:<address>`) is what the
127
+ * reducer uses to compare accounts across refreshes, so every adapter
128
+ * must build accounts through this helper (or keep the format
129
+ * byte-identical).
130
+ */
131
+ const buildAccount = (address, chain) => ({
132
+ chain,
133
+ id: `${chain.id}:${address}`,
134
+ walletAddress: address
135
+ });
123
136
 
124
137
  //#endregion
125
138
  //#region src/wallet-source.ts
@@ -132,6 +145,134 @@ const CHAIN_PLATFORMS = [
132
145
  */
133
146
  const createWalletSource = (subscribe) => ({ subscribe });
134
147
 
148
+ //#endregion
149
+ //#region src/store/shadow-adapter.ts
150
+ /**
151
+ * Error thrown when a method is called on a shadow adapter; the
152
+ * placeholder `WalletAdapter` that the store seeds into the pool when
153
+ * an `initialState` is provided (e.g. from a server-rendered cookie
154
+ * snapshot). Shadow adapters carry the identity and account data of a
155
+ * previously-connected wallet, but the live wallet extension hasn't
156
+ * been verified yet; the silent reconnect happens asynchronously
157
+ * after mount.
158
+ *
159
+ * UI code that gates affordances on `wallet.connector.capabilities.*`
160
+ * never reaches a shadow method (capabilities are all `false`). Code
161
+ * that calls through anyway hits this typed error, which is the
162
+ * correct loud failure: the consumer ignored the capability gate.
163
+ *
164
+ * Consumers wanting to wait out the reconnecting window should branch
165
+ * on whether `connectorId` is in `state.reconnectingIds`.
166
+ */
167
+ var ShadowConnectorError = class extends Error {
168
+ code = "BUTR_RECONNECTING";
169
+ connectorId;
170
+ method;
171
+ constructor(method, connectorId) {
172
+ super(`[butr] ${method} called on shadow connector "${connectorId}". Wait for silent reconnect to complete (check reconnectingIds.has(id) before calling).`);
173
+ this.name = "ShadowConnectorError";
174
+ this.connectorId = connectorId;
175
+ this.method = method;
176
+ }
177
+ };
178
+ const ALL_FALSE_CAPABILITIES = Object.freeze({
179
+ getBalance: false,
180
+ getTransactionReceipt: false,
181
+ requestAccounts: false,
182
+ sendTransaction: false,
183
+ signIn: false,
184
+ signMessage: false,
185
+ signTransaction: false,
186
+ subscribe: false,
187
+ switchAccount: false,
188
+ switchChain: false
189
+ });
190
+ /**
191
+ * Builds a placeholder `WalletAdapter` from a persisted pool entry.
192
+ *
193
+ * Used by `createWalletStore` when `WalletManagerConfig.initialState`
194
+ * is provided: each stored entry becomes a `ConnectedWallet` whose
195
+ * `connector` is one of these. The store flips `isHydrated` true
196
+ * synchronously and exposes the data through the usual hooks
197
+ * (`useActiveWallet`, `useConnectedWallets`, …), but the connector
198
+ * can't actually talk to a wallet yet, so its capabilities are all
199
+ * `false` and its methods throw `ShadowConnectorError` if called.
200
+ *
201
+ * The hydration coordinator's silent-reconnect pass upgrades each
202
+ * shadow to a live `WalletAdapter` by calling `createConnector(id)`
203
+ * (which only succeeds once the live adapter has been announced via
204
+ * discovery or registered explicitly) and replacing the pool entry.
205
+ * On success the connector id is removed from `reconnectingIds`; on
206
+ * failure the entry is dropped from the pool and storage.
207
+ *
208
+ * The entry's `name` is required (the storage validator rejects
209
+ * entries without it); `icon` is optional only because some live
210
+ * adapters genuinely have no icon to begin with.
211
+ */
212
+ const createShadowAdapter = (entry) => {
213
+ const id = entry.connectorId;
214
+ const name = entry.name;
215
+ const icon = entry.icon;
216
+ const reject = (method) => Promise.reject(new ShadowConnectorError(method, id));
217
+ const base = {
218
+ capabilities: ALL_FALSE_CAPABILITIES,
219
+ connect: () => reject("connect"),
220
+ disconnect: () => reject("disconnect"),
221
+ getAccount: () => reject("getAccount"),
222
+ getBalance: () => reject("getBalance"),
223
+ getSigner: () => reject("getSigner"),
224
+ getTransactionReceipt: () => reject("getTransactionReceipt"),
225
+ icon,
226
+ id,
227
+ name,
228
+ sendTx: () => reject("sendTx"),
229
+ sendTxToChain: () => reject("sendTxToChain"),
230
+ signMessage: () => reject("signMessage"),
231
+ switchChain: () => reject("switchChain")
232
+ };
233
+ switch (entry.chainPlatform) {
234
+ case "bitcoin": return {
235
+ ...base,
236
+ chainPlatform: "bitcoin"
237
+ };
238
+ case "evm": return {
239
+ ...base,
240
+ chainPlatform: "evm"
241
+ };
242
+ case "sui": return {
243
+ ...base,
244
+ chainPlatform: "sui"
245
+ };
246
+ case "svm": return {
247
+ ...base,
248
+ chainPlatform: "svm"
249
+ };
250
+ case "polkadot": return {
251
+ ...base,
252
+ chainPlatform: "polkadot"
253
+ };
254
+ default:
255
+ entry.chainPlatform;
256
+ throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
257
+ }
258
+ };
259
+ /**
260
+ * Type guard. Returns true when an adapter is a placeholder created
261
+ * by `createShadowAdapter`. Useful for the hydration coordinator
262
+ * (which needs to know which pool entries still need upgrading) and
263
+ * for consumers writing wagmi-style "is this connection verified yet"
264
+ * checks without subscribing to `reconnectingIds` directly.
265
+ *
266
+ * Detection is structural: a shadow has all capabilities set to false.
267
+ * Live adapters always advertise at least one capability (every wallet
268
+ * surface includes `getBalance`, `signMessage`, `switchChain` as
269
+ * required methods, and adapter constructors set their flags
270
+ * accordingly).
271
+ */
272
+ const isShadowAdapter = (adapter) => {
273
+ return Object.values(adapter.capabilities).every((flag) => !flag);
274
+ };
275
+
135
276
  //#endregion
136
277
  //#region src/logger.ts
137
278
  const logWarn = (...args) => {
@@ -166,7 +307,7 @@ const createMemoryStorageDriver = () => {
166
307
  };
167
308
  const createWebStorageDriver = (kind) => {
168
309
  if (!hasWebStorage(kind)) return createMemoryStorageDriver();
169
- const storage = globalThis[kind];
310
+ const storage = kind === "localStorage" ? globalThis.localStorage : globalThis.sessionStorage;
170
311
  return {
171
312
  getItem(key) {
172
313
  try {
@@ -198,41 +339,36 @@ const createBrowserStorageDriver = () => ({
198
339
  });
199
340
 
200
341
  //#endregion
201
- //#region src/storage/wallet-storage.ts
202
- const VALID_CHAIN_PLATFORMS$1 = new Set(CHAIN_PLATFORMS);
203
- const isValidAccount$1 = (value) => {
204
- if (!value || typeof value !== "object") return false;
205
- const account = value;
206
- if (typeof account.walletAddress !== "string" || typeof account.id !== "string") return false;
207
- if (!account.chain || typeof account.chain !== "object") return false;
208
- return true;
209
- };
210
- /**
211
- * Structural validator for a serialized pool entry.
212
- *
213
- * Used in two directions:
214
- * - On read (`getPool`): malformed entries from storage are warned
215
- * and dropped — legacy / cross-tab corruption shouldn't crash the
216
- * consumer.
217
- * - On write (`setPool`): the runtime's reducer state is the source
218
- * of truth, and a malformed write would indicate a programming
219
- * error inside butr. Throw rather than silently corrupt storage.
220
- *
221
- * Same validator either way — keeping the two paths symmetric means
222
- * "what's storable" is a single fact.
223
- */
224
- const isValidPoolEntry$1 = (key, value) => {
225
- if (!value || typeof value !== "object") return false;
226
- const entry = value;
227
- if (typeof entry.connectorId !== "string" || entry.connectorId !== key) return false;
228
- if (typeof entry.chainPlatform !== "string") return false;
229
- if (!VALID_CHAIN_PLATFORMS$1.has(entry.chainPlatform)) return false;
230
- if (typeof entry.name !== "string" || entry.name.length === 0) return false;
231
- if (entry.icon !== void 0 && typeof entry.icon !== "string") return false;
232
- if (!isValidAccount$1(entry.account)) return false;
233
- if (!Array.isArray(entry.accounts) || !entry.accounts.every(isValidAccount$1)) return false;
234
- return true;
342
+ //#region src/storage/validation.ts
343
+ const chainPlatformSchema = z.enum(CHAIN_PLATFORMS);
344
+ const chainSchema = z.looseObject({
345
+ id: z.string(),
346
+ name: z.string(),
347
+ namespace: z.string(),
348
+ reference: z.string()
349
+ });
350
+ const accountSchema = z.looseObject({
351
+ chain: chainSchema,
352
+ id: z.string(),
353
+ walletAddress: z.string()
354
+ });
355
+ const storedPoolEntrySchema = z.looseObject({
356
+ account: accountSchema,
357
+ accounts: z.array(accountSchema),
358
+ chainPlatform: chainPlatformSchema,
359
+ connectorId: z.string(),
360
+ icon: z.string().optional(),
361
+ name: z.string().min(1)
362
+ });
363
+ const recordSchema = z.record(z.string(), z.unknown());
364
+ const parseStoredPoolEntry = (key, value) => {
365
+ const parsed = storedPoolEntrySchema.safeParse(value);
366
+ if (!parsed.success || parsed.data.connectorId !== key) return null;
367
+ return parsed.data;
235
368
  };
369
+
370
+ //#endregion
371
+ //#region src/storage/wallet-storage.ts
236
372
  var WalletStorage = class {
237
373
  poolKey;
238
374
  selectionKey;
@@ -246,7 +382,7 @@ var WalletStorage = class {
246
382
  * this, two simultaneous `setPool` calls both read the pre-write
247
383
  * state, each merge their own entries, and whichever finishes last
248
384
  * overwrites the other's additions. Reads (`getPool`) don't enter
249
- * the queue — they observe whatever's currently in the driver.
385
+ * the queue; they observe whatever's currently in the driver.
250
386
  */
251
387
  poolMutationQueue = Promise.resolve();
252
388
  constructor(config) {
@@ -282,15 +418,19 @@ var WalletStorage = class {
282
418
  async getPool() {
283
419
  try {
284
420
  const stored = await this.persistent.getItem(this.poolKey);
285
- if (!stored) return {};
286
- const parsed = JSON.parse(stored);
287
- if (!parsed || typeof parsed !== "object") {
421
+ if (stored === null || stored === "") return {};
422
+ const value = JSON.parse(stored);
423
+ const parsed = recordSchema.safeParse(value);
424
+ if (!parsed.success) {
288
425
  await this.clearPool();
289
426
  return {};
290
427
  }
291
428
  const result = {};
292
- for (const [key, value] of Object.entries(parsed)) if (isValidPoolEntry$1(key, value)) result[key] = value;
293
- else logWarn(`[butr] dropping invalid pool entry for ${key}`);
429
+ for (const [key, entryValue] of Object.entries(parsed.data)) {
430
+ const entry = parseStoredPoolEntry(key, entryValue);
431
+ if (entry === null) logWarn(`[butr] dropping invalid pool entry for ${key}`);
432
+ else result[key] = entry;
433
+ }
294
434
  return result;
295
435
  } catch (error) {
296
436
  logWarn("[butr] failed to parse pool from storage:", error);
@@ -302,14 +442,14 @@ var WalletStorage = class {
302
442
  * Upsert the in-memory pool into storage. Additive: entries in
303
443
  * `pool` are written; entries already in storage that aren't in
304
444
  * `pool` are kept. The in-memory pool reflects "what's live right
305
- * now", not "the complete list of remembered connections" — a
445
+ * now", not "the complete list of remembered connections"; a
306
446
  * silent reconnect that fails on reload leaves the entry out of
307
447
  * the pool but the saved entry stays so the next load can retry.
308
448
  * Use `removePoolEntry` for explicit eviction (the user clicked
309
449
  * Disconnect) and `clearAll` for a full wipe (reset).
310
450
  */
311
- setPool(pool) {
312
- return this.serializePoolMutation(async () => {
451
+ async setPool(pool) {
452
+ await this.serializePoolMutation(async () => {
313
453
  try {
314
454
  const serializable = { ...await this.getPool() };
315
455
  for (const [connectorId, wallet] of pool) {
@@ -321,7 +461,7 @@ var WalletStorage = class {
321
461
  icon: wallet.connector.icon,
322
462
  name: wallet.connector.name
323
463
  };
324
- if (!isValidPoolEntry$1(connectorId, entry)) throw new Error(`[butr] refusing to persist invalid pool entry for ${connectorId}`);
464
+ if (parseStoredPoolEntry(connectorId, entry) === null) throw new Error(`[butr] refusing to persist invalid pool entry for ${connectorId}`);
325
465
  serializable[connectorId] = entry;
326
466
  }
327
467
  await this.persistent.setItem(this.poolKey, JSON.stringify(serializable));
@@ -330,8 +470,8 @@ var WalletStorage = class {
330
470
  }
331
471
  });
332
472
  }
333
- removePoolEntry(connectorId) {
334
- return this.serializePoolMutation(async () => {
473
+ async removePoolEntry(connectorId) {
474
+ await this.serializePoolMutation(async () => {
335
475
  try {
336
476
  const stored = await this.getPool();
337
477
  if (stored[connectorId]) {
@@ -343,19 +483,23 @@ var WalletStorage = class {
343
483
  }
344
484
  });
345
485
  }
346
- clearPool() {
347
- return this.serializePoolMutation(async () => {
486
+ async clearPool() {
487
+ await this.serializePoolMutation(async () => {
348
488
  await this.persistent.removeItem(this.poolKey);
349
489
  });
350
490
  }
351
491
  async getSelection() {
352
492
  try {
353
493
  const stored = await this.persistent.getItem(this.selectionKey);
354
- if (!stored) return {};
355
- const parsed = JSON.parse(stored);
356
- if (!parsed || typeof parsed !== "object") return {};
494
+ if (stored === null || stored === "") return {};
495
+ const value = JSON.parse(stored);
496
+ const parsed = recordSchema.safeParse(value);
497
+ if (!parsed.success) return {};
357
498
  const result = {};
358
- for (const [key, value] of Object.entries(parsed)) if (VALID_CHAIN_PLATFORMS$1.has(key) && typeof value === "string" && value.length > 0) result[key] = value;
499
+ for (const [key, selectionValue] of Object.entries(parsed.data)) {
500
+ const platform = chainPlatformSchema.safeParse(key);
501
+ if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
502
+ }
359
503
  return result;
360
504
  } catch (error) {
361
505
  logWarn("[butr] failed to parse selection from storage:", error);
@@ -374,7 +518,7 @@ var WalletStorage = class {
374
518
  async getActiveConnectorId() {
375
519
  try {
376
520
  const value = await this.persistent.getItem(this.activeKey);
377
- return value && value.length > 0 ? value : null;
521
+ return value !== null && value.length > 0 ? value : null;
378
522
  } catch {
379
523
  return null;
380
524
  }
@@ -462,6 +606,8 @@ const createConnectorLifecycle = (handlers) => {
462
606
 
463
607
  //#endregion
464
608
  //#region src/store/hydration.ts
609
+ const VALID_CHAIN_PLATFORMS$1 = new Set(CHAIN_PLATFORMS);
610
+ const isChainPlatform$1 = (value) => VALID_CHAIN_PLATFORMS$1.has(value);
465
611
  /** Pick the right `accounts` list: fresh from the connector if it
466
612
  * exposes `getAccounts`, otherwise the persisted list, otherwise just
467
613
  * the active account. */
@@ -476,7 +622,7 @@ const resolveHydratedAccounts = async (connector, storedAccounts, fallbackAccoun
476
622
  const restoreOneEntry = async (connectorId, entry, connector) => {
477
623
  try {
478
624
  await connector.connect({ silent: true });
479
- const accountToUse = await connector.getAccount() || entry.account;
625
+ const accountToUse = await connector.getAccount() ?? entry.account;
480
626
  return {
481
627
  connectorId,
482
628
  entry: {
@@ -538,13 +684,13 @@ const createHydrationCoordinator = (storage, createConnector) => {
538
684
  });
539
685
  }
540
686
  const selection = /* @__PURE__ */ new Map();
541
- for (const [platform, connectorId] of Object.entries(storedSelection)) if (connectorId && pool.has(connectorId)) selection.set(platform, connectorId);
687
+ for (const [platform, connectorId] of Object.entries(storedSelection)) if (isChainPlatform$1(platform) && connectorId !== void 0 && pool.has(connectorId)) selection.set(platform, connectorId);
542
688
  for (const [connectorId, wallet] of pool) {
543
689
  const platform = wallet.connector.chainPlatform;
544
690
  if (!selection.has(platform)) selection.set(platform, connectorId);
545
691
  }
546
692
  let activeConnectorId = null;
547
- if (storedActive && pool.has(storedActive)) activeConnectorId = storedActive;
693
+ if (storedActive !== null && pool.has(storedActive)) activeConnectorId = storedActive;
548
694
  else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
549
695
  return {
550
696
  activeConnectorId,
@@ -586,8 +732,8 @@ const reducer = (state, event) => {
586
732
  const selection = new Map(state.selection);
587
733
  for (const [platform, id] of event.selection) selection.set(platform, id);
588
734
  let activeConnectorId = null;
589
- if (event.activeConnectorId && pool.has(event.activeConnectorId)) activeConnectorId = event.activeConnectorId;
590
- else if (state.activeConnectorId && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
735
+ if (event.activeConnectorId !== null && pool.has(event.activeConnectorId)) activeConnectorId = event.activeConnectorId;
736
+ else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
591
737
  else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
592
738
  let nextReconnecting = state.reconnectingIds;
593
739
  if (state.reconnectingIds.size > 0 && event.pool.size > 0) {
@@ -655,7 +801,7 @@ const reducer = (state, event) => {
655
801
  if (newSelection.get(platform) === event.connectorId) {
656
802
  newSelection.delete(platform);
657
803
  const fallback = findConnectorForPlatform(newPool, platform);
658
- if (fallback) newSelection.set(platform, fallback);
804
+ if (fallback !== void 0) newSelection.set(platform, fallback);
659
805
  }
660
806
  const firstId = newPool.keys().next();
661
807
  const activeConnectorId = state.activeConnectorId === event.connectorId ? firstId.value ?? null : state.activeConnectorId;
@@ -731,118 +877,6 @@ const reducer = (state, event) => {
731
877
  }
732
878
  };
733
879
 
734
- //#endregion
735
- //#region src/store/shadow-adapter.ts
736
- /**
737
- * Error thrown when a method is called on a shadow adapter — the
738
- * placeholder `WalletAdapter` that the store seeds into the pool when
739
- * an `initialState` is provided (e.g. from a server-rendered cookie
740
- * snapshot). Shadow adapters carry the identity and account data of a
741
- * previously-connected wallet, but the live wallet extension hasn't
742
- * been verified yet — the silent reconnect happens asynchronously
743
- * after mount.
744
- *
745
- * UI code that gates affordances on `wallet.connector.capabilities.*`
746
- * never reaches a shadow method (capabilities are all `false`). Code
747
- * that calls through anyway hits this typed error, which is the
748
- * correct loud failure: the consumer ignored the capability gate.
749
- *
750
- * Consumers wanting to wait out the reconnecting window should branch
751
- * on whether `connectorId` is in `state.reconnectingIds`.
752
- */
753
- var ShadowConnectorError = class extends Error {
754
- code = "BUTR_RECONNECTING";
755
- connectorId;
756
- method;
757
- constructor(method, connectorId) {
758
- super(`[butr] ${method} called on shadow connector "${connectorId}" — wait for silent reconnect to complete (check reconnectingIds.has(id) before calling).`);
759
- this.name = "ShadowConnectorError";
760
- this.connectorId = connectorId;
761
- this.method = method;
762
- }
763
- };
764
- const ALL_FALSE_CAPABILITIES = Object.freeze({
765
- getBalance: false,
766
- getTransactionReceipt: false,
767
- requestAccounts: false,
768
- sendTransaction: false,
769
- signIn: false,
770
- signMessage: false,
771
- signTransaction: false,
772
- subscribe: false,
773
- switchAccount: false,
774
- switchChain: false
775
- });
776
- /**
777
- * Builds a placeholder `WalletAdapter` from a persisted pool entry.
778
- *
779
- * Used by `createWalletStore` when `WalletManagerConfig.initialState`
780
- * is provided: each stored entry becomes a `ConnectedWallet` whose
781
- * `connector` is one of these. The store flips `isHydrated` true
782
- * synchronously and exposes the data through the usual hooks
783
- * (`useActiveWallet`, `useConnectedWallets`, …) — but the connector
784
- * can't actually talk to a wallet yet, so its capabilities are all
785
- * `false` and its methods throw `ShadowConnectorError` if called.
786
- *
787
- * The hydration coordinator's silent-reconnect pass upgrades each
788
- * shadow to a live `WalletAdapter` by calling `createConnector(id)`
789
- * (which only succeeds once the live adapter has been announced via
790
- * discovery or registered explicitly) and replacing the pool entry.
791
- * On success the connector id is removed from `reconnectingIds`; on
792
- * failure the entry is dropped from the pool and storage.
793
- *
794
- * The entry's `name` is required (the storage validator rejects
795
- * entries without it); `icon` is optional only because some live
796
- * adapters genuinely have no icon to begin with.
797
- */
798
- const createShadowAdapter = (entry) => {
799
- const id = entry.connectorId;
800
- const name = entry.name;
801
- const icon = entry.icon;
802
- const reject = (method) => Promise.reject(new ShadowConnectorError(method, id));
803
- const base = {
804
- capabilities: ALL_FALSE_CAPABILITIES,
805
- connect: () => reject("connect"),
806
- disconnect: () => reject("disconnect"),
807
- getAccount: () => reject("getAccount"),
808
- getBalance: () => reject("getBalance"),
809
- getSigner: () => reject("getSigner"),
810
- getTransactionReceipt: () => reject("getTransactionReceipt"),
811
- icon,
812
- id,
813
- name,
814
- sendTx: () => reject("sendTx"),
815
- sendTxToChain: () => reject("sendTxToChain"),
816
- signMessage: () => reject("signMessage"),
817
- switchChain: () => reject("switchChain")
818
- };
819
- switch (entry.chainPlatform) {
820
- case "bitcoin": return {
821
- ...base,
822
- chainPlatform: "bitcoin"
823
- };
824
- case "evm": return {
825
- ...base,
826
- chainPlatform: "evm"
827
- };
828
- case "sui": return {
829
- ...base,
830
- chainPlatform: "sui"
831
- };
832
- case "svm": return {
833
- ...base,
834
- chainPlatform: "svm"
835
- };
836
- case "polkadot": return {
837
- ...base,
838
- chainPlatform: "polkadot"
839
- };
840
- default:
841
- entry.chainPlatform;
842
- throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
843
- }
844
- };
845
-
846
880
  //#endregion
847
881
  //#region src/store/wallet-store-helpers.ts
848
882
  /** Run an async operation fire-and-forget, calling onError if it throws. */
@@ -856,13 +890,15 @@ const run = async (fn, onError) => {
856
890
 
857
891
  //#endregion
858
892
  //#region src/store/wallet-store.ts
893
+ const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
894
+ const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
859
895
  /**
860
896
  * Build a synchronously-populated `State` from `config.initialState`.
861
897
  * Each pool entry becomes a `ConnectedWallet` whose `connector` is a
862
898
  * shadow adapter; every connector id enters `reconnectingIds` so
863
899
  * consumers can branch on "is this connection verified" without
864
900
  * waiting for the async silent reconnect. `isHydrated` flips true
865
- * synchronously — the consumer's first render sees the persisted
901
+ * synchronously: the consumer's first render sees the persisted
866
902
  * state in the live store, not undefined.
867
903
  */
868
904
  const seedStateFromSnapshot = (snapshot) => {
@@ -879,8 +915,8 @@ const seedStateFromSnapshot = (snapshot) => {
879
915
  reconnectingIds.add(connectorId);
880
916
  }
881
917
  const selection = /* @__PURE__ */ new Map();
882
- for (const [platform, connectorId] of Object.entries(snapshot.selection)) if (connectorId && pool.has(connectorId)) selection.set(platform, connectorId);
883
- const activeConnectorId = snapshot.activeConnectorId && pool.has(snapshot.activeConnectorId) ? snapshot.activeConnectorId : pool.keys().next().value ?? null;
918
+ for (const [platform, connectorId] of Object.entries(snapshot.selection)) if (isChainPlatform(platform) && connectorId !== void 0 && pool.has(connectorId)) selection.set(platform, connectorId);
919
+ const activeConnectorId = snapshot.activeConnectorId !== null && pool.has(snapshot.activeConnectorId) ? snapshot.activeConnectorId : pool.keys().next().value ?? null;
884
920
  return {
885
921
  ...initialState,
886
922
  activeConnectorId,
@@ -894,8 +930,8 @@ const CONNECT_TIMEOUT_MS = 9e4;
894
930
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
895
931
  const normaliseAddress = (addr) => addr.toLowerCase();
896
932
  const createWalletStore = (config) => {
897
- const storageKeyPrefix = config.storageKeyPrefix || "butr";
898
- const storage = config.storage || new WalletStorage({ keyPrefix: storageKeyPrefix });
933
+ const storageKeyPrefix = config.storageKeyPrefix === void 0 || config.storageKeyPrefix === "" ? "butr" : config.storageKeyPrefix;
934
+ const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
899
935
  const reportStorageError = (context) => (error) => {
900
936
  if (config.onStorageError) {
901
937
  try {
@@ -956,7 +992,7 @@ const createWalletStore = (config) => {
956
992
  logWarn("[butr] onSlowConnect threw:", cbError);
957
993
  }
958
994
  }, slowThreshold) : null;
959
- let connectTimeoutId = null;
995
+ let connectTimeoutId;
960
996
  try {
961
997
  const connectPromise = connector.connect();
962
998
  connectPromise.catch(() => {});
@@ -1002,10 +1038,10 @@ const createWalletStore = (config) => {
1002
1038
  } catch (cbError) {
1003
1039
  logWarn("[butr] onConnectError threw:", cbError);
1004
1040
  }
1005
- onError?.(error);
1041
+ onError?.(error instanceof Error ? error : new Error(String(error)));
1006
1042
  throw error;
1007
1043
  } finally {
1008
- if (connectTimeoutId) clearTimeout(connectTimeoutId);
1044
+ clearTimeout(connectTimeoutId);
1009
1045
  if (slowTimer) clearTimeout(slowTimer);
1010
1046
  }
1011
1047
  },
@@ -1042,7 +1078,9 @@ const createWalletStore = (config) => {
1042
1078
  });
1043
1079
  for (const [connectorId, wallet] of result.pool) lifecycle.attach(connectorId, wallet.connector);
1044
1080
  await Promise.all([persistSelection(), persistActive()]);
1045
- for (const id of hydration.pendingIds()) run(() => get().tryRestoreFromPending(id), (e) => logWarn(`[butr] late restore rejected for ${id}:`, e));
1081
+ for (const id of hydration.pendingIds()) run(() => get().tryRestoreFromPending(id), (e) => {
1082
+ logWarn(`[butr] late restore rejected for ${id}:`, e);
1083
+ });
1046
1084
  if (config.onHydrated) try {
1047
1085
  config.onHydrated({
1048
1086
  dropped: result.dropped,
@@ -1173,7 +1211,7 @@ const readCookies = () => {
1173
1211
  };
1174
1212
  const buildCookieString = (name, value, options, expired = false) => {
1175
1213
  const parts = [`${name}=${encode(value)}`, `Path=${options.path ?? "/"}`];
1176
- if (options.domain) parts.push(`Domain=${options.domain}`);
1214
+ if (options.domain !== void 0 && options.domain !== "") parts.push(`Domain=${options.domain}`);
1177
1215
  if (expired) parts.push("Max-Age=0");
1178
1216
  else {
1179
1217
  const maxAge = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
@@ -1185,11 +1223,11 @@ const buildCookieString = (name, value, options, expired = false) => {
1185
1223
  };
1186
1224
  const toCookieMap$1 = (input) => {
1187
1225
  if (!input) return null;
1188
- if (typeof input[Symbol.iterator] === "function") return new Map(input);
1226
+ if (Symbol.iterator in input) return new Map(input);
1189
1227
  return new Map(Object.entries(input));
1190
1228
  };
1191
1229
  /**
1192
- * Cookie-backed storage driver. Reads/writes `document.cookie` —
1230
+ * Cookie-backed storage driver. Reads/writes `document.cookie`;
1193
1231
  * server-readable, survives reloads, scoped per `domain`/`path`.
1194
1232
  *
1195
1233
  * **When to use this:** SSR apps that need to know who's connected
@@ -1203,13 +1241,13 @@ const toCookieMap$1 = (input) => {
1203
1241
  * **Trade-offs vs `localStorage`:** cookies travel with every
1204
1242
  * request, so they cost bytes on the wire. Keep the storage key
1205
1243
  * prefix short, and prefer this driver for the `persistent` slot
1206
- * only — the `session` slot can stay in `sessionStorage` (which
1244
+ * only: the `session` slot can stay in `sessionStorage` (which
1207
1245
  * cookies can't natively model anyway).
1208
1246
  *
1209
1247
  * **Server-side writes are no-ops.** Emitting `Set-Cookie` requires
1210
1248
  * access to the framework's response object, which a storage driver
1211
1249
  * shouldn't reach into. The store doesn't mutate persisted state
1212
- * during the SSR pass anyway — writes only fire after client mount,
1250
+ * during the SSR pass anyway; writes only fire after client mount,
1213
1251
  * once `document.cookie` is reachable.
1214
1252
  */
1215
1253
  const createCookieStorageDriver = (options = {}) => {
@@ -1245,14 +1283,13 @@ const createCookieStorageDriver = (options = {}) => {
1245
1283
 
1246
1284
  //#endregion
1247
1285
  //#region src/storage/snapshot.ts
1248
- const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
1249
1286
  const EMPTY_SNAPSHOT = Object.freeze({
1250
1287
  activeConnectorId: null,
1251
1288
  pool: Object.freeze({}),
1252
1289
  selection: Object.freeze({})
1253
1290
  });
1254
1291
  const toCookieMap = (input) => {
1255
- if (typeof input[Symbol.iterator] !== "function") return new Map(Object.entries(input));
1292
+ if (!(Symbol.iterator in input)) return new Map(Object.entries(input));
1256
1293
  const out = /* @__PURE__ */ new Map();
1257
1294
  for (const entry of input) {
1258
1295
  if (Array.isArray(entry)) {
@@ -1260,37 +1297,22 @@ const toCookieMap = (input) => {
1260
1297
  if (typeof name === "string" && typeof value === "string") out.set(name, value);
1261
1298
  continue;
1262
1299
  }
1263
- if (entry && typeof entry === "object" && "name" in entry && "value" in entry && typeof entry.name === "string" && typeof entry.value === "string") out.set(entry.name, entry.value);
1300
+ if (typeof entry.name === "string" && typeof entry.value === "string") out.set(entry.name, entry.value);
1264
1301
  }
1265
1302
  return out;
1266
1303
  };
1267
- const isValidAccount = (value) => {
1268
- if (!value || typeof value !== "object") return false;
1269
- const account = value;
1270
- if (typeof account.walletAddress !== "string" || typeof account.id !== "string") return false;
1271
- if (!account.chain || typeof account.chain !== "object") return false;
1272
- return true;
1273
- };
1274
- const isValidPoolEntry = (key, value) => {
1275
- if (!value || typeof value !== "object") return false;
1276
- const entry = value;
1277
- if (typeof entry.connectorId !== "string" || entry.connectorId !== key) return false;
1278
- if (typeof entry.chainPlatform !== "string") return false;
1279
- if (!VALID_CHAIN_PLATFORMS.has(entry.chainPlatform)) return false;
1280
- if (typeof entry.name !== "string" || entry.name.length === 0) return false;
1281
- if (entry.icon !== void 0 && typeof entry.icon !== "string") return false;
1282
- if (!isValidAccount(entry.account)) return false;
1283
- if (!Array.isArray(entry.accounts) || !entry.accounts.every(isValidAccount)) return false;
1284
- return true;
1285
- };
1286
1304
  const parsePool = (raw) => {
1287
- if (!raw) return {};
1305
+ if (raw === void 0 || raw === "") return {};
1288
1306
  try {
1289
- const parsed = JSON.parse(raw);
1290
- if (!parsed || typeof parsed !== "object") return {};
1307
+ const value = JSON.parse(raw);
1308
+ const parsed = recordSchema.safeParse(value);
1309
+ if (!parsed.success) return {};
1291
1310
  const result = {};
1292
- for (const [key, value] of Object.entries(parsed)) if (isValidPoolEntry(key, value)) result[key] = value;
1293
- else logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
1311
+ for (const [key, entryValue] of Object.entries(parsed.data)) {
1312
+ const entry = parseStoredPoolEntry(key, entryValue);
1313
+ if (entry === null) logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
1314
+ else result[key] = entry;
1315
+ }
1294
1316
  return result;
1295
1317
  } catch (error) {
1296
1318
  logWarn("[butr] readWalletSnapshot: failed to parse pool cookie:", error);
@@ -1298,12 +1320,16 @@ const parsePool = (raw) => {
1298
1320
  }
1299
1321
  };
1300
1322
  const parseSelection = (raw) => {
1301
- if (!raw) return {};
1323
+ if (raw === void 0 || raw === "") return {};
1302
1324
  try {
1303
- const parsed = JSON.parse(raw);
1304
- if (!parsed || typeof parsed !== "object") return {};
1325
+ const value = JSON.parse(raw);
1326
+ const parsed = recordSchema.safeParse(value);
1327
+ if (!parsed.success) return {};
1305
1328
  const result = {};
1306
- for (const [key, value] of Object.entries(parsed)) if (VALID_CHAIN_PLATFORMS.has(key) && typeof value === "string" && value.length > 0) result[key] = value;
1329
+ for (const [key, selectionValue] of Object.entries(parsed.data)) {
1330
+ const platform = chainPlatformSchema.safeParse(key);
1331
+ if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
1332
+ }
1307
1333
  return result;
1308
1334
  } catch (error) {
1309
1335
  logWarn("[butr] readWalletSnapshot: failed to parse selection cookie:", error);
@@ -1313,7 +1339,7 @@ const parseSelection = (raw) => {
1313
1339
  /**
1314
1340
  * Parse a cookie source into a server-safe `WalletSnapshot`.
1315
1341
  *
1316
- * Pure, sync, no `document`, no React — runnable in any environment
1342
+ * Pure, sync, no `document`, no React; runnable in any environment
1317
1343
  * (Server Component, route handler, edge middleware, even client
1318
1344
  * code). Pair with `createCookieStorageDriver({ initialCookies })`
1319
1345
  * and `<WalletManagerProvider initialSnapshot={…} />` to render a
@@ -1324,7 +1350,7 @@ const parseSelection = (raw) => {
1324
1350
  * the wallet, switched accounts, or disconnected in another tab, the
1325
1351
  * client-side hydration will reconcile reality and the live store
1326
1352
  * will diverge from the snapshot. Treat the snapshot as an
1327
- * *optimistic* shell — accurate enough to avoid a paint flicker,
1353
+ * *optimistic* shell; accurate enough to avoid a paint flicker,
1328
1354
  * authoritative only after `useIsHydrated()` is true.
1329
1355
  *
1330
1356
  * **Inputs.** Accepts the three shapes Next.js / Express / Hono /
@@ -1334,17 +1360,17 @@ const parseSelection = (raw) => {
1334
1360
  * - An iterable of `[name, value]` tuples
1335
1361
  *
1336
1362
  * Malformed entries are dropped with a `logWarn` (same policy as
1337
- * `WalletStorage.getPool`) — a cross-tab corruption shouldn't crash
1363
+ * `WalletStorage.getPool`); a cross-tab corruption shouldn't crash
1338
1364
  * the server render.
1339
1365
  */
1340
1366
  const readWalletSnapshot = (source, options = {}) => {
1341
- const keyPrefix = options.keyPrefix || "butr";
1367
+ const keyPrefix = options.keyPrefix === void 0 || options.keyPrefix === "" ? "butr" : options.keyPrefix;
1342
1368
  const cookies = toCookieMap(source);
1343
1369
  const pool = parsePool(cookies.get(`${keyPrefix}-pool`));
1344
1370
  const selection = parseSelection(cookies.get(`${keyPrefix}-selection`));
1345
1371
  const rawActive = cookies.get(`${keyPrefix}-active`);
1346
1372
  let activeConnectorId = null;
1347
- if (rawActive && rawActive.length > 0 && pool[rawActive]) activeConnectorId = rawActive;
1373
+ if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1348
1374
  else {
1349
1375
  const firstKey = Object.keys(pool)[0];
1350
1376
  if (firstKey) activeConnectorId = firstKey;
@@ -1360,19 +1386,25 @@ const readWalletSnapshot = (source, options = {}) => {
1360
1386
  //#region src/wallet-equal.ts
1361
1387
  /**
1362
1388
  * Two wallet snapshots are equivalent for selector purposes iff they
1363
- * share connectorId, active account address, and active account chain
1364
- * id. Used by `useStoreWithEqualityFn` consumers (active-wallet,
1365
- * selected-wallet, useWalletEntry) to suppress spurious re-renders
1366
- * when the underlying Map churns but the resolved entry hasn't changed.
1389
+ * share the same adapter instance, active account address, and active
1390
+ * account chain id. Used by `useStoreWithEqualityFn` consumers
1391
+ * (active-wallet, selected-wallet, useWalletEntry) to suppress spurious
1392
+ * re-renders when the underlying Map churns but the resolved entry
1393
+ * hasn't changed.
1394
+ *
1395
+ * The adapter is compared by reference, not by `connector.id`: hydration
1396
+ * replaces a shadow adapter with the live one under an unchanged id and
1397
+ * address, so an id comparison reports "equal" and leaves consumers
1398
+ * holding a placeholder whose every method throws ShadowConnectorError.
1367
1399
  *
1368
- * Hoisted to its own module so the equivalence rule lives in one place
1369
- * — if we ever extend the snapshot (e.g. to consider `accounts.length`),
1400
+ * Hoisted to its own module so the equivalence rule lives in one place;
1401
+ * if we ever extend the snapshot (e.g. to consider `accounts.length`),
1370
1402
  * every selector hook picks up the new rule for free.
1371
1403
  */
1372
1404
  const walletEqual = (a, b) => {
1373
1405
  if (a === b) return true;
1374
1406
  if (!a || !b) return false;
1375
- return a.connector.id === b.connector.id && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
1407
+ return a.connector === b.connector && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
1376
1408
  };
1377
1409
 
1378
1410
  //#endregion
@@ -1380,7 +1412,7 @@ const walletEqual = (a, b) => {
1380
1412
  /**
1381
1413
  * Normalize a wallet-announced icon string.
1382
1414
  *
1383
- * Wallets announce their icon through external metadata — EIP-6963
1415
+ * Wallets announce their icon through external metadata; EIP-6963
1384
1416
  * `providerInfo.icon`, Wallet Standard `wallet.icon`. That value is
1385
1417
  * not under butr's control, and some wallets ship data-URI icons with
1386
1418
  * surrounding whitespace (a newline left over from a pretty-printed
@@ -1389,7 +1421,7 @@ const walletEqual = (a, b) => {
1389
1421
  *
1390
1422
  * Trims surrounding whitespace and treats an all-whitespace (or empty)
1391
1423
  * icon as absent, so consumers get either a usable string or
1392
- * `undefined` — never a blank or malformed one. `undefined` passes
1424
+ * `undefined`: never a blank or malformed one. `undefined` passes
1393
1425
  * through untouched.
1394
1426
  */
1395
1427
  const sanitizeIcon = (icon) => {
@@ -1416,7 +1448,7 @@ const sanitizeIcon = (icon) => {
1416
1448
  * - {@link bytesToHexPrefixed} returns `0x`-prefixed hex (EVM, Polkadot).
1417
1449
  * - {@link hexToBytes} tolerantly strips an optional `0x` (all callers).
1418
1450
  */
1419
- /** Bare lowercase hex — no `0x` prefix (Bitcoin, Ledger). */
1451
+ /** Bare lowercase hex, no `0x` prefix (Bitcoin, Ledger). */
1420
1452
  const bytesToHex = (bytes) => {
1421
1453
  let hex = "";
1422
1454
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
@@ -1458,5 +1490,5 @@ const bytesToBase64 = (bytes) => {
1458
1490
  };
1459
1491
 
1460
1492
  //#endregion
1461
- export { CHAIN_PLATFORMS, EMPTY_SNAPSHOT, WalletStorage, base64ToBytes, buildChainsByPlatform, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createWalletSource, createWalletStore, hexToBytes, logError, logWarn, mapConnectionError, readWalletSnapshot, sanitizeIcon, walletEqual };
1493
+ export { CHAIN_PLATFORMS, EMPTY_SNAPSHOT, ShadowConnectorError, WalletStorage, base64ToBytes, buildAccount, buildChainsByPlatform, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createWalletSource, createWalletStore, hexToBytes, isShadowAdapter, logError, logWarn, mapConnectionError, readWalletSnapshot, sanitizeIcon, walletEqual };
1462
1494
  //# sourceMappingURL=index.js.map