@usebutr/core 2.0.0 → 3.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
@@ -14,79 +14,57 @@ const buildAccount = (address, chain) => ({
14
14
  });
15
15
 
16
16
  //#endregion
17
- //#region src/types/chains-by-platform.ts
17
+ //#region src/types/chain.ts
18
18
  /**
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`.
19
+ * Wallets report bare CAIP-2 ids, so the display name comes from a chain
20
+ * registry. A chain outside `known` keeps its id as its name rather than
21
+ * borrowing the wallet's.
22
22
  */
23
- const buildChainsByPlatform = (partial) => ({
24
- bitcoin: partial.bitcoin ?? [],
25
- evm: partial.evm ?? [],
26
- polkadot: partial.polkadot ?? [],
27
- sui: partial.sui ?? [],
28
- svm: partial.svm ?? []
29
- });
23
+ const resolveChain = (id, known = []) => {
24
+ const match = known.find((chain) => chain.id === id);
25
+ if (match !== void 0) return match;
26
+ const [namespace = "", reference = ""] = id.split(":");
27
+ return {
28
+ id,
29
+ name: id,
30
+ namespace,
31
+ reference
32
+ };
33
+ };
30
34
 
31
35
  //#endregion
32
36
  //#region src/types/errors.ts
37
+ var ConnectionError = class extends Error {
38
+ kind;
39
+ constructor(kind, message, options) {
40
+ super(message, options);
41
+ this.name = "ConnectionError";
42
+ this.kind = kind;
43
+ }
44
+ };
45
+ const readCode = (error) => "code" in error && (typeof error.code === "number" || typeof error.code === "string") ? error.code : void 0;
33
46
  /**
34
47
  * EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
35
48
  * unauthorized or disconnected. Message-substring matching is the last
36
49
  * resort for SDKs that ship no codes at all.
37
50
  */
38
- const mapConnectionError = (raw) => {
39
- if (raw instanceof Error) {
40
- const message = raw.message;
41
- const lower = message.toLowerCase();
42
- if (message === "Connection timeout") return {
43
- kind: "Timeout",
44
- message
45
- };
46
- if (message === "Failed to get account" || lower.includes("not connected")) return {
47
- kind: "NotConnected",
48
- message
49
- };
50
- const code = "code" in raw ? raw.code : void 0;
51
- if (code === 4001) return {
52
- kind: "UserRejected",
53
- message
54
- };
55
- if (code === -32002) return {
56
- kind: "RequestPending",
57
- message
58
- };
59
- if (code === 4100 || code === 4900 || code === 4901) return {
60
- kind: "NotConnected",
61
- message
62
- };
63
- if (lower.includes("user rejected") || lower.includes("user denied")) return {
64
- kind: "UserRejected",
65
- message
66
- };
67
- if (lower.includes("locked")) return {
68
- kind: "WalletLocked",
69
- message
70
- };
71
- if (lower.includes("chain") && (lower.includes("mismatch") || lower.includes("unsupported"))) return {
72
- kind: "ChainMismatch",
73
- message
74
- };
75
- return {
76
- cause: raw,
77
- kind: "Unknown",
78
- message
79
- };
80
- }
81
- if (typeof raw === "string") return {
82
- kind: "Unknown",
83
- message: raw
84
- };
85
- return {
86
- cause: raw,
87
- kind: "Unknown",
88
- message: "Connection failed"
89
- };
51
+ const classify = (error) => {
52
+ const code = readCode(error);
53
+ if (code === 4001) return "UserRejected";
54
+ if (code === -32002) return "RequestPending";
55
+ if (code === 4100 || code === 4900 || code === 4901) return "NotConnected";
56
+ const lower = error.message.toLowerCase();
57
+ if (lower.includes("user rejected") || lower.includes("user denied")) return "UserRejected";
58
+ if (lower.includes("not connected")) return "NotConnected";
59
+ if (lower.includes("locked")) return "WalletLocked";
60
+ if (lower.includes("chain") && (lower.includes("mismatch") || lower.includes("unsupported"))) return "ChainMismatch";
61
+ return "Unknown";
62
+ };
63
+ /** The one boundary where a thrown value of unknown shape becomes typed. */
64
+ const toConnectionError = (cause) => {
65
+ if (cause instanceof ConnectionError) return cause;
66
+ if (cause instanceof Error) return new ConnectionError(classify(cause), cause.message, { cause });
67
+ return new ConnectionError("Unknown", typeof cause === "string" && cause !== "" ? cause : "Connection failed", { cause });
90
68
  };
91
69
 
92
70
  //#endregion
@@ -103,120 +81,252 @@ const CHAIN_PLATFORMS = [
103
81
  "bitcoin",
104
82
  "polkadot"
105
83
  ];
84
+ const PLATFORMS = new Set(CHAIN_PLATFORMS);
85
+ const isChainPlatform = (value) => PLATFORMS.has(value);
86
+
87
+ //#endregion
88
+ //#region src/types/wallet.ts
89
+ /** Narrows a pool entry to one platform, e.g. before calling its `sendTx`. */
90
+ const isPlatformWallet = (wallet, platform) => wallet.connector.chainPlatform === platform;
91
+
92
+ //#endregion
93
+ //#region src/chains.ts
94
+ /** Common EVM chains. `switchChain` accepts any `ChainBase`, so a chain
95
+ * missing here still works; it is just named by its CAIP-2 id. */
96
+ const EVM_CHAINS = {
97
+ arbitrum: {
98
+ id: "eip155:42161",
99
+ name: "Arbitrum One",
100
+ namespace: "eip155",
101
+ reference: "42161"
102
+ },
103
+ base: {
104
+ id: "eip155:8453",
105
+ name: "Base",
106
+ namespace: "eip155",
107
+ reference: "8453"
108
+ },
109
+ bsc: {
110
+ id: "eip155:56",
111
+ name: "BNB Smart Chain",
112
+ namespace: "eip155",
113
+ reference: "56"
114
+ },
115
+ ethereum: {
116
+ id: "eip155:1",
117
+ name: "Ethereum",
118
+ namespace: "eip155",
119
+ reference: "1"
120
+ },
121
+ optimism: {
122
+ id: "eip155:10",
123
+ name: "Optimism",
124
+ namespace: "eip155",
125
+ reference: "10"
126
+ },
127
+ polygon: {
128
+ id: "eip155:137",
129
+ name: "Polygon",
130
+ namespace: "eip155",
131
+ reference: "137"
132
+ },
133
+ sepolia: {
134
+ id: "eip155:11155111",
135
+ name: "Sepolia",
136
+ namespace: "eip155",
137
+ reference: "11155111"
138
+ }
139
+ };
140
+ const SVM_CHAINS = {
141
+ devnet: {
142
+ id: "solana:devnet",
143
+ name: "Solana Devnet",
144
+ namespace: "solana",
145
+ reference: "devnet"
146
+ },
147
+ mainnet: {
148
+ id: "solana:mainnet",
149
+ name: "Solana Mainnet",
150
+ namespace: "solana",
151
+ reference: "mainnet"
152
+ },
153
+ testnet: {
154
+ id: "solana:testnet",
155
+ name: "Solana Testnet",
156
+ namespace: "solana",
157
+ reference: "testnet"
158
+ }
159
+ };
160
+ const SUI_CHAINS = {
161
+ devnet: {
162
+ id: "sui:devnet",
163
+ name: "Sui Devnet",
164
+ namespace: "sui",
165
+ reference: "devnet"
166
+ },
167
+ localnet: {
168
+ id: "sui:localnet",
169
+ name: "Sui Localnet",
170
+ namespace: "sui",
171
+ reference: "localnet"
172
+ },
173
+ mainnet: {
174
+ id: "sui:mainnet",
175
+ name: "Sui Mainnet",
176
+ namespace: "sui",
177
+ reference: "mainnet"
178
+ },
179
+ testnet: {
180
+ id: "sui:testnet",
181
+ name: "Sui Testnet",
182
+ namespace: "sui",
183
+ reference: "testnet"
184
+ }
185
+ };
186
+ /** CAIP-2 Bitcoin references are the first 16 bytes of each network's
187
+ * genesis block hash, which is what wallets advertise in `wallet.chains`. */
188
+ const BITCOIN_CHAINS = {
189
+ mainnet: {
190
+ id: "bip122:000000000019d6689c085ae165831e93",
191
+ name: "Bitcoin",
192
+ namespace: "bip122",
193
+ reference: "000000000019d6689c085ae165831e93"
194
+ },
195
+ signet: {
196
+ id: "bip122:00000008819873e925422c1ff0f99f7c",
197
+ name: "Bitcoin Signet",
198
+ namespace: "bip122",
199
+ reference: "00000008819873e925422c1ff0f99f7c"
200
+ },
201
+ testnet: {
202
+ id: "bip122:000000000933ea01ad0ee984209779ba",
203
+ name: "Bitcoin Testnet",
204
+ namespace: "bip122",
205
+ reference: "000000000933ea01ad0ee984209779ba"
206
+ },
207
+ testnet4: {
208
+ id: "bip122:00000000da84f2bafbbc53dee25a72ae",
209
+ name: "Bitcoin Testnet4",
210
+ namespace: "bip122",
211
+ reference: "00000000da84f2bafbbc53dee25a72ae"
212
+ }
213
+ };
214
+ const POLKADOT_CHAINS = {
215
+ kusama: {
216
+ id: "polkadot:b0a8d493285c2df73290dfb7e61f870f",
217
+ name: "Kusama",
218
+ namespace: "polkadot",
219
+ reference: "b0a8d493285c2df73290dfb7e61f870f"
220
+ },
221
+ paseo: {
222
+ id: "polkadot:77afd6190f1554ad45fd0d31aee62aac",
223
+ name: "Paseo",
224
+ namespace: "polkadot",
225
+ reference: "77afd6190f1554ad45fd0d31aee62aac"
226
+ },
227
+ polkadot: {
228
+ id: "polkadot:91b171bb158e2d3848fa23a9f1c25182",
229
+ name: "Polkadot",
230
+ namespace: "polkadot",
231
+ reference: "91b171bb158e2d3848fa23a9f1c25182"
232
+ },
233
+ westend: {
234
+ id: "polkadot:e143f23803ac50e8f6f8e62695d1ce9e",
235
+ name: "Westend",
236
+ namespace: "polkadot",
237
+ reference: "e143f23803ac50e8f6f8e62695d1ce9e"
238
+ }
239
+ };
240
+ const EVM_CHAINS_LIST = Object.values(EVM_CHAINS);
241
+ const SVM_CHAINS_LIST = Object.values(SVM_CHAINS);
242
+ const SUI_CHAINS_LIST = Object.values(SUI_CHAINS);
243
+ const BITCOIN_CHAINS_LIST = Object.values(BITCOIN_CHAINS);
244
+ const POLKADOT_CHAINS_LIST = Object.values(POLKADOT_CHAINS);
245
+ /** Every registry as a list, keyed by platform: the shape chain pickers want. */
246
+ const CHAINS_BY_PLATFORM = {
247
+ bitcoin: BITCOIN_CHAINS_LIST,
248
+ evm: EVM_CHAINS_LIST,
249
+ polkadot: POLKADOT_CHAINS_LIST,
250
+ sui: SUI_CHAINS_LIST,
251
+ svm: SVM_CHAINS_LIST
252
+ };
253
+
254
+ //#endregion
255
+ //#region src/logger.ts
256
+ const logWarn = (...args) => {
257
+ console.warn(...args);
258
+ };
259
+ const logError = (...args) => {
260
+ console.error(...args);
261
+ };
106
262
 
107
263
  //#endregion
108
264
  //#region src/wallet-source.ts
265
+ const isIterable = (value) => Symbol.iterator in value;
109
266
  /**
110
- * Takes the exact shape of `discoverEvmAdapters` and friends, so a
111
- * single-platform app keeps `@usebutr/wallets` out of its bundle.
267
+ * For adapters that are built rather than discovered: WalletConnect, Ledger,
268
+ * hand-rolled ones. Takes one adapter or several, or a promise of either; a
269
+ * rejected promise is logged and contributes nothing.
112
270
  */
113
- const createWalletSource = (subscribe) => ({ subscribe });
271
+ const fromAdapters = (adapters) => (onAdapter) => {
272
+ let active = true;
273
+ (async () => {
274
+ try {
275
+ const resolved = await adapters;
276
+ for (const adapter of isIterable(resolved) ? resolved : [resolved]) {
277
+ if (!active) return;
278
+ onAdapter(adapter);
279
+ }
280
+ } catch (error) {
281
+ logWarn("[butr] fromAdapters: adapters failed to load:", error);
282
+ }
283
+ })();
284
+ return () => {
285
+ active = false;
286
+ };
287
+ };
114
288
 
115
289
  //#endregion
116
290
  //#region src/store/shadow-adapter.ts
117
291
  /**
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.
292
+ * Thrown by the three required methods of a shadow adapter. Every optional
293
+ * method is simply absent, so only code that skipped the `reconnectingIds`
294
+ * check and called `connect`, `getAccounts` or `getSigner` sees this.
121
295
  */
122
296
  var ShadowConnectorError = class extends Error {
123
- code = "BUTR_RECONNECTING";
124
297
  connectorId;
125
298
  method;
126
299
  constructor(method, connectorId) {
127
- super(`[butr] ${method} called on shadow connector "${connectorId}". Wait for silent reconnect to complete (check reconnectingIds.has(id) before calling).`);
300
+ super(`[butr] ${method} called on "${connectorId}" while it is still reconnecting. Check useIsReconnecting(id) before calling it.`);
128
301
  this.name = "ShadowConnectorError";
129
302
  this.connectorId = connectorId;
130
303
  this.method = method;
131
304
  }
132
305
  };
133
- const ALL_FALSE_CAPABILITIES = Object.freeze({
134
- getBalance: false,
135
- getTransactionReceipt: false,
136
- requestAccounts: false,
137
- sendTransaction: false,
138
- signIn: false,
139
- signMessage: false,
140
- signTransaction: false,
141
- subscribe: false,
142
- switchAccount: false,
143
- switchChain: false
144
- });
145
306
  /**
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.
307
+ * Stands in for a connection seeded from `initialState` so its identity
308
+ * renders before discovery runs. It exposes no optional methods; the manager
309
+ * swaps it for the live adapter, or evicts it, once silent reconnect settles.
149
310
  */
150
311
  const createShadowAdapter = (entry) => {
151
- const id = entry.connectorId;
152
- const name = entry.name;
153
- const icon = entry.icon;
154
- const reject = (method) => Promise.reject(new ShadowConnectorError(method, id));
155
- const base = {
156
- capabilities: ALL_FALSE_CAPABILITIES,
157
- connect: () => reject("connect"),
158
- disconnect: () => reject("disconnect"),
159
- getAccount: () => reject("getAccount"),
160
- getBalance: () => reject("getBalance"),
161
- getSigner: () => reject("getSigner"),
162
- getTransactionReceipt: () => reject("getTransactionReceipt"),
163
- icon,
164
- id,
165
- name,
166
- sendTx: () => reject("sendTx"),
167
- sendTxToChain: () => reject("sendTxToChain"),
168
- signMessage: () => reject("signMessage"),
169
- switchChain: () => reject("switchChain")
312
+ const reject = (method) => () => Promise.reject(new ShadowConnectorError(method, entry.connectorId));
313
+ return {
314
+ chainPlatform: entry.chainPlatform,
315
+ connect: reject("connect"),
316
+ getAccounts: reject("getAccounts"),
317
+ getSigner: reject("getSigner"),
318
+ icon: entry.icon,
319
+ id: entry.connectorId,
320
+ name: entry.name
170
321
  };
171
- switch (entry.chainPlatform) {
172
- case "bitcoin": return {
173
- ...base,
174
- chainPlatform: "bitcoin"
175
- };
176
- case "evm": return {
177
- ...base,
178
- chainPlatform: "evm"
179
- };
180
- case "sui": return {
181
- ...base,
182
- chainPlatform: "sui"
183
- };
184
- case "svm": return {
185
- ...base,
186
- chainPlatform: "svm"
187
- };
188
- case "polkadot": return {
189
- ...base,
190
- chainPlatform: "polkadot"
191
- };
192
- default:
193
- entry.chainPlatform;
194
- throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
195
- }
196
- };
197
- /**
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.
201
- */
202
- const isShadowAdapter = (adapter) => {
203
- return Object.values(adapter.capabilities).every((flag) => !flag);
204
- };
205
-
206
- //#endregion
207
- //#region src/logger.ts
208
- const logWarn = (...args) => {
209
- console.warn(...args);
210
- };
211
- const logError = (...args) => {
212
- console.error(...args);
213
322
  };
214
323
 
215
324
  //#endregion
216
325
  //#region src/storage/browser-storage-driver.ts
326
+ const webStorageGlobals = globalThis;
217
327
  const hasWebStorage = (kind) => {
218
328
  try {
219
- return typeof globalThis !== "undefined" && globalThis[kind] !== void 0;
329
+ return kind === "localStorage" ? webStorageGlobals.localStorage !== void 0 : webStorageGlobals.sessionStorage !== void 0;
220
330
  } catch {
221
331
  return false;
222
332
  }
@@ -291,14 +401,9 @@ const storedPoolEntrySchema = z.looseObject({
291
401
  name: z.string().min(1)
292
402
  });
293
403
  const recordSchema = z.record(z.string(), z.unknown());
294
- const parseStoredPoolEntry = (key, value) => {
295
- const parsed = storedPoolEntrySchema.safeParse(value);
296
- if (!parsed.success || parsed.data.connectorId !== key) return null;
297
- return parsed.data;
298
- };
299
404
  const DEFAULT_KEY_PREFIX = "butr";
300
405
  /**
301
- * `WalletStorage` writes these and `readWalletSnapshot` reads them from
406
+ * `createWalletStorage` writes these and `readWalletSnapshot` reads them from
302
407
  * a cookie jar; any divergence desyncs the SSR-seeded render from the
303
408
  * client that rehydrates it, the failure ADR 0003 exists to prevent.
304
409
  */
@@ -311,477 +416,308 @@ const storageKeys = (keyPrefix) => {
311
416
  userDisconnected: `${prefix}-user-disconnected`
312
417
  };
313
418
  };
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]") => {
419
+ const parseRecord = (raw, label, what) => {
320
420
  if (raw === null || raw === void 0 || raw === "") return {};
321
- let value;
322
421
  try {
323
- value = JSON.parse(raw);
422
+ const parsed = recordSchema.safeParse(JSON.parse(raw));
423
+ return parsed.success ? parsed.data : {};
324
424
  } catch (error) {
325
- logWarn(`${label} failed to parse pool from storage:`, error);
425
+ logWarn(`${label} failed to parse ${what} from storage:`, error);
326
426
  return {};
327
427
  }
328
- const parsed = recordSchema.safeParse(value);
329
- if (!parsed.success) return {};
428
+ };
429
+ /**
430
+ * Never repairs or throws: one corrupt entry must not take down a whole
431
+ * session, and `readWalletSnapshot` may run on a server with no cookie
432
+ * jar to write the eviction to. The next save overwrites the bad value.
433
+ */
434
+ const decodePool = (raw, label = "[butr]") => {
330
435
  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;
436
+ for (const [key, value] of Object.entries(parseRecord(raw, label, "pool"))) {
437
+ const entry = storedPoolEntrySchema.safeParse(value);
438
+ if (entry.success && entry.data.connectorId === key) result[key] = entry.data;
439
+ else logWarn(`${label} dropping invalid pool entry for ${key}`);
335
440
  }
336
441
  return result;
337
442
  };
338
443
  /** Decode a persisted selection payload. Same contract as `decodePool`. */
339
444
  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
445
  const result = {};
351
- for (const [key, selectionValue] of Object.entries(parsed.data)) {
446
+ for (const [key, value] of Object.entries(parseRecord(raw, label, "selection"))) {
352
447
  const platform = chainPlatformSchema.safeParse(key);
353
- if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
448
+ if (platform.success && typeof value === "string" && value.length > 0) result[platform.data] = value;
354
449
  }
355
450
  return result;
356
451
  };
357
452
 
358
453
  //#endregion
359
454
  //#region src/storage/wallet-storage.ts
360
- var WalletStorage = class {
361
- poolKey;
362
- selectionKey;
363
- activeKey;
364
- userDisconnectedKey;
365
- persistent;
366
- session;
367
- /**
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.
371
- */
372
- poolMutationQueue = Promise.resolve();
373
- constructor(config) {
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;
379
- if (config.persistent && config.session) {
380
- this.persistent = config.persistent;
381
- this.session = config.session;
382
- } else {
383
- const defaults = createBrowserStorageDriver();
384
- this.persistent = config.persistent ?? defaults.persistent;
385
- this.session = config.session ?? defaults.session;
386
- }
387
- }
388
- /** Chain `fn` after the in-flight pool mutation. */
389
- async serializePoolMutation(fn) {
390
- const previous = this.poolMutationQueue;
391
- let resolve = () => {};
392
- this.poolMutationQueue = new Promise((r) => {
393
- resolve = r;
394
- });
395
- try {
396
- await previous;
397
- } catch {}
398
- try {
399
- return await fn();
400
- } finally {
401
- resolve();
402
- }
403
- }
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;
408
- try {
409
- stored = await this.persistent.getItem(this.poolKey);
410
- } catch (error) {
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);
433
- }
434
- return decoded;
435
- }
436
- /**
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`.
440
- */
441
- async setPool(pool) {
442
- await this.serializePoolMutation(async () => {
443
- try {
444
- const { decoded: existing } = await this.readPool();
445
- const serializable = { ...existing };
446
- for (const [connectorId, wallet] of pool) {
447
- const entry = {
448
- account: wallet.account,
449
- accounts: wallet.accounts,
450
- chainPlatform: wallet.connector.chainPlatform,
451
- connectorId,
452
- icon: wallet.connector.icon,
453
- name: wallet.connector.name
454
- };
455
- if (parseStoredPoolEntry(connectorId, entry) === null) throw new Error(`[butr] refusing to persist invalid pool entry for ${connectorId}`);
456
- serializable[connectorId] = entry;
457
- }
458
- await this.persistent.setItem(this.poolKey, JSON.stringify(serializable));
459
- } catch (error) {
460
- logWarn("[butr] failed to persist pool:", error);
461
- }
462
- });
463
- }
464
- async removePoolEntry(connectorId) {
465
- await this.serializePoolMutation(async () => {
466
- try {
467
- const { decoded: stored } = await this.readPool();
468
- if (stored[connectorId]) {
469
- const { [connectorId]: _, ...remaining } = stored;
470
- await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
471
- }
472
- } catch (error) {
473
- logWarn(`[butr] failed to remove pool entry ${connectorId}:`, error);
474
- }
475
- });
476
- }
477
- async clearPool() {
478
- await this.serializePoolMutation(async () => {
479
- await this.persistent.removeItem(this.poolKey);
480
- });
481
- }
482
- async getSelection() {
483
- try {
484
- return decodeSelection(await this.persistent.getItem(this.selectionKey));
485
- } catch (error) {
486
- logWarn("[butr] failed to read selection from storage:", error);
487
- return {};
488
- }
489
- }
490
- async setSelection(selection) {
455
+ const isEmpty = (record) => Object.keys(record).length === 0;
456
+ /**
457
+ * Pool, selection and active id go to the persistent driver, the disconnect
458
+ * intent to the session driver. Unchanged keys are not rewritten, so a
459
+ * cookie-backed driver does not re-send every cookie on each save.
460
+ */
461
+ const createWalletStorage = (options = {}) => {
462
+ const keys = storageKeys(options.keyPrefix);
463
+ const browser = createBrowserStorageDriver();
464
+ const persistent = options.persistent ?? browser.persistent;
465
+ const session = options.session ?? browser.session;
466
+ const written = /* @__PURE__ */ new Map();
467
+ const read = async (driver, key) => {
491
468
  try {
492
- const serializable = {};
493
- for (const [platform, connectorId] of selection) serializable[platform] = connectorId;
494
- await this.persistent.setItem(this.selectionKey, JSON.stringify(serializable));
469
+ const value = await driver.getItem(key);
470
+ written.set(key, value);
471
+ return value;
495
472
  } catch (error) {
496
- logWarn("[butr] failed to persist selection:", error);
497
- }
498
- }
499
- async getActiveConnectorId() {
500
- try {
501
- const value = await this.persistent.getItem(this.activeKey);
502
- return value !== null && value.length > 0 ? value : null;
503
- } catch {
473
+ logWarn(`[butr] failed to read ${key} from storage:`, error);
504
474
  return null;
505
475
  }
506
- }
507
- async setActiveConnectorId(connectorId) {
508
- try {
509
- await (connectorId === null ? this.persistent.removeItem(this.activeKey) : this.persistent.setItem(this.activeKey, connectorId));
510
- } catch (error) {
511
- logWarn("[butr] failed to persist active connector id:", error);
512
- }
513
- }
514
- async clearAll() {
515
- await Promise.all([
516
- this.clearPool(),
517
- this.persistent.removeItem(this.selectionKey),
518
- this.persistent.removeItem(this.activeKey)
519
- ]);
520
- }
521
- /**
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.
525
- */
526
- async isUserDisconnected() {
527
- try {
528
- return await this.session.getItem(this.userDisconnectedKey) === "true";
529
- } catch {
530
- return false;
476
+ };
477
+ const write = async (driver, key, value) => {
478
+ if (written.get(key) === value) return;
479
+ await (value === null ? driver.removeItem(key) : driver.setItem(key, value));
480
+ written.set(key, value);
481
+ };
482
+ return {
483
+ load: async () => {
484
+ const [pool, selection, active, userDisconnected] = await Promise.all([
485
+ read(persistent, keys.pool),
486
+ read(persistent, keys.selection),
487
+ read(persistent, keys.active),
488
+ read(session, keys.userDisconnected)
489
+ ]);
490
+ return {
491
+ activeConnectorId: active === null || active === "" ? null : active,
492
+ isUserDisconnected: userDisconnected === "true",
493
+ pool: decodePool(pool),
494
+ selection: decodeSelection(selection)
495
+ };
496
+ },
497
+ save: async (state) => {
498
+ const failure = (await Promise.allSettled([
499
+ write(persistent, keys.pool, isEmpty(state.pool) ? null : JSON.stringify(state.pool)),
500
+ write(persistent, keys.selection, isEmpty(state.selection) ? null : JSON.stringify(state.selection)),
501
+ write(persistent, keys.active, state.activeConnectorId),
502
+ write(session, keys.userDisconnected, state.isUserDisconnected ? "true" : null)
503
+ ])).find((result) => result.status === "rejected");
504
+ if (failure !== void 0) throw failure.reason;
531
505
  }
532
- }
533
- async markUserDisconnected(value) {
534
- try {
535
- await (value ? this.session.setItem(this.userDisconnectedKey, "true") : this.session.removeItem(this.userDisconnectedKey));
536
- } catch {}
537
- }
506
+ };
538
507
  };
539
508
 
540
509
  //#endregion
541
510
  //#region src/store/connector-lifecycle.ts
542
511
  const createConnectorLifecycle = (handlers) => {
543
- const unsubscribers = /* @__PURE__ */ new Map();
512
+ const live = /* @__PURE__ */ new Map();
544
513
  const detach = (connectorId) => {
545
- const unsub = unsubscribers.get(connectorId);
546
- if (!unsub) return;
514
+ const subscription = live.get(connectorId);
515
+ if (subscription === void 0) return;
516
+ live.delete(connectorId);
547
517
  try {
548
- unsub();
518
+ subscription.unsubscribe();
549
519
  } catch (error) {
550
520
  logWarn("[butr] unsubscribe threw:", error);
551
521
  }
552
- unsubscribers.delete(connectorId);
553
522
  };
554
- return {
555
- attach: (connectorId, connector) => {
556
- if (!connector.subscribe) return;
523
+ const attach = (connectorId, connector) => {
524
+ if (connector.subscribe === void 0) return;
525
+ const disconnected = () => {
557
526
  detach(connectorId);
558
- try {
559
- const unsub = connector.subscribe((event) => {
560
- switch (event.type) {
561
- case "accountChanged":
562
- handlers.onAccountChanged(connectorId, event.accounts, event.account);
563
- break;
564
- case "disconnected":
565
- detach(connectorId);
566
- handlers.onDisconnected(connectorId, connector.chainPlatform);
567
- }
568
- });
569
- unsubscribers.set(connectorId, unsub);
570
- } catch (error) {
571
- logWarn(`[butr] subscribe failed for ${connectorId}:`, error);
572
- }
573
- },
574
- detach,
575
- detachAll: () => {
576
- const ids = [];
577
- for (const id of unsubscribers.keys()) ids.push(id);
578
- for (const id of ids) detach(id);
527
+ handlers.onDisconnected(connectorId);
528
+ };
529
+ try {
530
+ const unsubscribe = connector.subscribe((event) => {
531
+ if (live.get(connectorId)?.connector !== connector) return;
532
+ if (event.type === "disconnected" || event.accounts.length === 0) {
533
+ disconnected();
534
+ return;
535
+ }
536
+ handlers.onAccountsChanged(connectorId, event.accounts);
537
+ });
538
+ live.set(connectorId, {
539
+ connector,
540
+ unsubscribe
541
+ });
542
+ } catch (error) {
543
+ logWarn(`[butr] subscribe failed for ${connectorId}:`, error);
579
544
  }
580
545
  };
581
- };
582
-
583
- //#endregion
584
- //#region src/store/hydration.ts
585
- const VALID_CHAIN_PLATFORMS$1 = new Set(CHAIN_PLATFORMS);
586
- const isChainPlatform$1 = (value) => VALID_CHAIN_PLATFORMS$1.has(value);
587
- /** Pick the right `accounts` list: fresh from the connector if it
588
- * exposes `getAccounts`, otherwise the persisted list, otherwise just
589
- * the active account. */
590
- const resolveHydratedAccounts = async (connector, storedAccounts, fallbackAccount) => {
591
- if (connector.getAccounts) {
592
- const fresh = await connector.getAccounts().catch(() => null);
593
- if (fresh && fresh.length > 0) return fresh;
594
- }
595
- if (storedAccounts.length > 0) return [...storedAccounts];
596
- return [fallbackAccount];
597
- };
598
- const restoreOneEntry = async (connectorId, entry, connector) => {
599
- try {
600
- await connector.connect({ silent: true });
601
- const accountToUse = await connector.getAccount() ?? entry.account;
602
- return {
603
- connectorId,
604
- entry: {
605
- account: accountToUse,
606
- accounts: await resolveHydratedAccounts(connector, entry.accounts, accountToUse),
607
- connector
608
- },
609
- kind: "ok"
610
- };
611
- } catch (error) {
612
- return {
613
- connectorId,
614
- error,
615
- kind: "fail"
616
- };
617
- }
618
- };
619
- const createHydrationCoordinator = (storage, createConnector) => {
620
- const pending = /* @__PURE__ */ new Map();
621
546
  return {
622
- drainPending: async (connectorId) => {
623
- const entry = pending.get(connectorId);
624
- if (!entry) return null;
625
- const connector = createConnector(connectorId);
626
- if (!connector) return null;
627
- const outcome = await restoreOneEntry(connectorId, entry, connector);
628
- pending.delete(connectorId);
629
- return outcome;
630
- },
631
- hydrate: async () => {
632
- const [storedPool, storedSelection, storedActive, userDisconnected] = await Promise.all([
633
- storage.getPool(),
634
- storage.getSelection(),
635
- storage.getActiveConnectorId(),
636
- storage.isUserDisconnected()
637
- ]);
638
- const pool = /* @__PURE__ */ new Map();
639
- const dropped = [];
640
- pending.clear();
641
- const tasks = [];
642
- for (const [connectorId, entry] of Object.entries(storedPool)) {
643
- if (!entry) continue;
644
- const connector = createConnector(connectorId);
645
- if (!connector) {
646
- pending.set(connectorId, entry);
647
- continue;
648
- }
649
- tasks.push(restoreOneEntry(connectorId, entry, connector));
650
- }
651
- const outcomes = await Promise.all(tasks);
652
- for (const outcome of outcomes) {
653
- if (outcome.kind === "ok") {
654
- pool.set(outcome.connectorId, outcome.entry);
655
- continue;
656
- }
657
- dropped.push({
658
- connectorId: outcome.connectorId,
659
- reason: outcome.error
660
- });
661
- }
662
- const selection = /* @__PURE__ */ new Map();
663
- for (const [platform, connectorId] of Object.entries(storedSelection)) if (isChainPlatform$1(platform) && connectorId !== void 0 && pool.has(connectorId)) selection.set(platform, connectorId);
664
- for (const [connectorId, wallet] of pool) {
665
- const platform = wallet.connector.chainPlatform;
666
- if (!selection.has(platform)) selection.set(platform, connectorId);
667
- }
668
- let activeConnectorId = null;
669
- if (storedActive !== null && pool.has(storedActive)) activeConnectorId = storedActive;
670
- else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
671
- return {
672
- activeConnectorId,
673
- dropped,
674
- isUserDisconnected: userDisconnected,
675
- pendingIds: [...pending.keys()],
676
- pool,
677
- selection
678
- };
547
+ detachAll: () => {
548
+ for (const connectorId of live.keys()) detach(connectorId);
679
549
  },
680
- pendingIds: () => [...pending.keys()]
550
+ sync: (pool) => {
551
+ for (const [connectorId, subscription] of live) if (pool.get(connectorId)?.connector !== subscription.connector) detach(connectorId);
552
+ for (const [connectorId, wallet] of pool) if (!live.has(connectorId)) attach(connectorId, wallet.connector);
553
+ }
681
554
  };
682
555
  };
683
556
 
557
+ //#endregion
558
+ //#region src/wallet-equal.ts
559
+ /** Account ids are canonical (`buildAccount`), so identity is the id. */
560
+ const accountsEqual = (a, b) => a === b || a.length === b.length && a.every((account, i) => account.id === b[i]?.id);
561
+ /**
562
+ * The adapter is compared by reference, not `connector.id`: hydration
563
+ * swaps a shadow adapter for the live one under an unchanged id, and an
564
+ * id check would strand consumers on the placeholder.
565
+ */
566
+ const walletEqual = (a, b) => a === b || a !== void 0 && b !== void 0 && a.connector === b.connector && a.account.id === b.account.id && accountsEqual(a.accounts, b.accounts);
567
+
684
568
  //#endregion
685
569
  //#region src/store/reducer.ts
686
570
  const initialState = {
687
571
  activeConnectorId: null,
572
+ adapters: [],
688
573
  connectingConnectorId: null,
689
574
  connectionError: null,
690
575
  connectionStatus: "idle",
576
+ dormant: /* @__PURE__ */ new Map(),
691
577
  isHydrated: false,
692
578
  isUserDisconnected: false,
693
579
  pool: /* @__PURE__ */ new Map(),
694
580
  reconnectingIds: /* @__PURE__ */ new Set(),
695
581
  selection: /* @__PURE__ */ new Map()
696
582
  };
697
- /** Find any connector in the pool serving a given platform. */
698
- const findConnectorForPlatform = (pool, chainPlatform) => {
699
- for (const [id, wallet] of pool) if (wallet.connector.chainPlatform === chainPlatform) return id;
583
+ const toStoredEntry = (connectorId, wallet) => ({
584
+ account: wallet.account,
585
+ accounts: wallet.accounts,
586
+ chainPlatform: wallet.connector.chainPlatform,
587
+ connectorId,
588
+ icon: wallet.connector.icon,
589
+ name: wallet.connector.name
590
+ });
591
+ const withEntry = (map, key, value) => new Map(map).set(key, value);
592
+ const withoutKey = (map, key) => {
593
+ if (!map.has(key)) return map;
594
+ const next = new Map(map);
595
+ next.delete(key);
596
+ return next;
700
597
  };
701
- /** Pure reducer: every state transition is expressed as `(state, event) => state`.
702
- * No I/O, no async, no side effects. Side effects live in the runtime. */
703
- const reducer = (state, event) => {
598
+ const withoutId = (set, id) => {
599
+ if (!set.has(id)) return set;
600
+ const next = new Set(set);
601
+ next.delete(id);
602
+ return next;
603
+ };
604
+ const keepEntries = (map, keep) => {
605
+ const kept = [...map].filter(([key, value]) => keep(key, value));
606
+ return kept.length === map.size ? map : new Map(kept);
607
+ };
608
+ const keepIds = (set, keep) => {
609
+ const kept = [...set].filter(keep);
610
+ return kept.length === set.size ? set : new Set(kept);
611
+ };
612
+ const sameEntries = (a, b) => a.size === b.size && [...a].every(([key, value]) => b.get(key) === value);
613
+ const reconcileSelection = (selection, pool) => {
614
+ const next = /* @__PURE__ */ new Map();
615
+ for (const [platform, id] of selection) if (pool.get(id)?.connector.chainPlatform === platform) next.set(platform, id);
616
+ for (const [id, wallet] of pool) if (!next.has(wallet.connector.chainPlatform)) next.set(wallet.connector.chainPlatform, id);
617
+ return sameEntries(next, selection) ? selection : next;
618
+ };
619
+ /**
620
+ * The invariants every event relies on, enforced once after every event so
621
+ * no case re-derives a fallback. Returns `state` itself when it already
622
+ * holds, which keeps zustand from notifying.
623
+ */
624
+ const reconcile = (state) => {
625
+ const { pool } = state;
626
+ const reconnectingIds = keepIds(state.reconnectingIds, (id) => pool.has(id));
627
+ const dormant = keepEntries(state.dormant, (id) => !pool.has(id) || reconnectingIds.has(id));
628
+ const selection = reconcileSelection(state.selection, pool);
629
+ const activeConnectorId = state.activeConnectorId !== null && pool.has(state.activeConnectorId) ? state.activeConnectorId : pool.keys().next().value ?? null;
630
+ if (reconnectingIds === state.reconnectingIds && dormant === state.dormant && selection === state.selection && activeConnectorId === state.activeConnectorId) return state;
631
+ return {
632
+ ...state,
633
+ activeConnectorId,
634
+ dormant,
635
+ reconnectingIds,
636
+ selection
637
+ };
638
+ };
639
+ const applyStoredSelection = (selection, stored, pool) => {
640
+ let next = selection;
641
+ for (const [platform, id] of Object.entries(stored)) if (isChainPlatform(platform) && pool.get(id)?.connector.chainPlatform === platform) next = withEntry(next, platform, id);
642
+ return next;
643
+ };
644
+ const changeAccounts = (state, event) => {
645
+ const wallet = state.pool.get(event.connectorId);
646
+ const first = event.accounts.at(0);
647
+ if (wallet === void 0 || first === void 0) return state;
648
+ const account = event.active ?? event.accounts.find((a) => a.id === wallet.account.id) ?? first;
649
+ if (account.id === wallet.account.id && accountsEqual(event.accounts, wallet.accounts)) return state;
650
+ const updated = {
651
+ ...wallet,
652
+ account,
653
+ accounts: event.accounts
654
+ };
655
+ return {
656
+ ...state,
657
+ pool: withEntry(state.pool, event.connectorId, updated)
658
+ };
659
+ };
660
+ const apply = (state, event) => {
704
661
  switch (event.type) {
662
+ case "ADAPTER_REGISTERED":
663
+ if (state.adapters.some((adapter) => adapter.id === event.adapter.id)) return state;
664
+ return {
665
+ ...state,
666
+ adapters: [...state.adapters, event.adapter]
667
+ };
668
+ case "STORAGE_LOADED": {
669
+ const dormant = new Map(state.dormant);
670
+ for (const [id, entry] of Object.entries(event.entries)) if (entry !== void 0) dormant.set(id, entry);
671
+ return {
672
+ ...state,
673
+ dormant,
674
+ isUserDisconnected: event.isUserDisconnected
675
+ };
676
+ }
677
+ case "ENTRY_RESTORED":
678
+ if (!state.dormant.has(event.connectorId)) return state;
679
+ return {
680
+ ...state,
681
+ pool: withEntry(state.pool, event.connectorId, event.entry),
682
+ reconnectingIds: withoutId(state.reconnectingIds, event.connectorId)
683
+ };
684
+ case "RESTORE_FAILED":
685
+ if (!state.reconnectingIds.has(event.connectorId)) return state;
686
+ return {
687
+ ...state,
688
+ pool: withoutKey(state.pool, event.connectorId),
689
+ reconnectingIds: withoutId(state.reconnectingIds, event.connectorId)
690
+ };
705
691
  case "HYDRATED": {
706
- const pool = new Map(state.pool);
707
- for (const [id, entry] of event.pool) pool.set(id, entry);
708
- for (const id of event.dropped) pool.delete(id);
709
- const selection = new Map(state.selection);
710
- for (const [platform, id] of event.selection) selection.set(platform, id);
711
- let activeConnectorId = null;
712
- if (event.activeConnectorId !== null && pool.has(event.activeConnectorId)) activeConnectorId = event.activeConnectorId;
713
- else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
714
- else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
715
- let nextReconnecting = state.reconnectingIds;
716
- if (state.reconnectingIds.size > 0) {
717
- const next = new Set(state.reconnectingIds);
718
- for (const id of event.pool.keys()) next.delete(id);
719
- for (const id of event.dropped) next.delete(id);
720
- if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
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
- }
692
+ const preferred = event.activeConnectorId;
728
693
  return {
729
694
  ...state,
730
- activeConnectorId,
695
+ activeConnectorId: preferred !== null && state.pool.has(preferred) ? preferred : state.activeConnectorId,
731
696
  isHydrated: true,
732
- isUserDisconnected: event.isUserDisconnected,
733
- pool,
734
- reconnectingIds: nextReconnecting,
735
- selection: selectionAfterDrop
697
+ selection: applyStoredSelection(state.selection, event.selection, state.pool)
736
698
  };
737
699
  }
738
- case "USER_DISCONNECTED_SET": return {
739
- ...state,
740
- isUserDisconnected: event.value
741
- };
742
700
  case "CONNECT_STARTED": return {
743
701
  ...state,
744
702
  connectingConnectorId: event.connectorId,
745
703
  connectionError: null,
746
- connectionStatus: "connecting"
704
+ connectionStatus: "connecting",
705
+ isUserDisconnected: false
747
706
  };
748
707
  case "CONNECT_SUCCEEDED": {
749
708
  const { connectorId, entry } = event;
750
- const existing = state.pool.get(connectorId);
751
- const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
752
- if (existing && existing.account.walletAddress.toLowerCase() === entry.account.walletAddress.toLowerCase()) return {
753
- ...state,
754
- activeConnectorId: connectorId,
755
- connectingConnectorId: null,
756
- connectionError: null,
757
- connectionStatus: "success",
758
- pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
759
- reconnectingIds: nextReconnecting
760
- };
761
- const newPool = new Map([...state.pool, [connectorId, entry]]);
762
- const newSelection = new Map([...state.selection, [entry.connector.chainPlatform, connectorId]]);
709
+ const isCurrentAttempt = state.connectingConnectorId === connectorId;
763
710
  return {
764
711
  ...state,
765
712
  activeConnectorId: connectorId,
766
- connectingConnectorId: null,
767
- connectionError: null,
768
- connectionStatus: "success",
769
- pool: newPool,
770
- reconnectingIds: nextReconnecting,
771
- selection: newSelection
772
- };
773
- }
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
713
+ ...isCurrentAttempt && {
714
+ connectingConnectorId: null,
715
+ connectionError: null,
716
+ connectionStatus: "success"
717
+ },
718
+ pool: withEntry(state.pool, connectorId, entry),
719
+ reconnectingIds: withoutId(state.reconnectingIds, connectorId),
720
+ selection: state.pool.has(connectorId) ? state.selection : withEntry(state.selection, entry.connector.chainPlatform, connectorId)
785
721
  };
786
722
  }
787
723
  case "CONNECT_FAILED":
@@ -792,398 +728,424 @@ const reducer = (state, event) => {
792
728
  connectionError: event.error,
793
729
  connectionStatus: "error"
794
730
  };
795
- case "DISCONNECTED": {
796
- const wallet = state.pool.get(event.connectorId);
797
- if (!wallet) return state;
798
- const newPool = new Map(state.pool);
799
- newPool.delete(event.connectorId);
800
- const newSelection = new Map(state.selection);
801
- const platform = wallet.connector.chainPlatform;
802
- if (newSelection.get(platform) === event.connectorId) {
803
- newSelection.delete(platform);
804
- const fallback = findConnectorForPlatform(newPool, platform);
805
- if (fallback !== void 0) newSelection.set(platform, fallback);
806
- }
807
- const firstId = newPool.keys().next();
808
- const activeConnectorId = state.activeConnectorId === event.connectorId ? firstId.value ?? null : state.activeConnectorId;
809
- const nextReconnecting = state.reconnectingIds.has(event.connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== event.connectorId)) : state.reconnectingIds;
731
+ case "STATUS_RESET":
732
+ if (state.connectionStatus === "idle") return state;
810
733
  return {
811
734
  ...state,
812
- activeConnectorId,
813
- pool: newPool,
814
- reconnectingIds: nextReconnecting,
815
- selection: newSelection
735
+ connectingConnectorId: null,
736
+ connectionError: null,
737
+ connectionStatus: "idle"
816
738
  };
817
- }
818
- case "ACCOUNTS_REFRESHED": {
739
+ case "ACCOUNTS_CHANGED": return changeAccounts(state, event);
740
+ case "DISCONNECTED": {
741
+ const pool = withoutKey(state.pool, event.connectorId);
742
+ if (event.byUser) {
743
+ const dormant = pool.size === 0 ? keepEntries(state.dormant, () => false) : withoutKey(state.dormant, event.connectorId);
744
+ if (pool === state.pool && dormant === state.dormant && state.isUserDisconnected) return state;
745
+ return {
746
+ ...state,
747
+ dormant,
748
+ isUserDisconnected: true,
749
+ pool
750
+ };
751
+ }
819
752
  const wallet = state.pool.get(event.connectorId);
820
- if (!wallet) return state;
821
- let nextAccount;
822
- if (event.active) nextAccount = event.active;
823
- 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
- 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;
827
- const updated = {
828
- ...wallet,
829
- account: nextAccount,
830
- accounts: [...event.accounts]
831
- };
832
- const newPool = new Map([...state.pool, [event.connectorId, updated]]);
753
+ if (wallet === void 0) return state;
754
+ const stored = toStoredEntry(event.connectorId, wallet);
833
755
  return {
834
756
  ...state,
835
- pool: newPool
757
+ dormant: withEntry(state.dormant, event.connectorId, stored),
758
+ pool
836
759
  };
837
760
  }
838
- case "WALLET_REFRESHED": {
839
- const wallet = state.pool.get(event.connectorId);
840
- if (!wallet) return state;
841
- const newPool = new Map([...state.pool, [event.connectorId, { ...wallet }]]);
761
+ case "RESET": return {
762
+ ...initialState,
763
+ adapters: state.adapters,
764
+ isHydrated: state.isHydrated,
765
+ isUserDisconnected: true
766
+ };
767
+ case "ACTIVE_CHANGED":
768
+ if (state.activeConnectorId === event.connectorId || !state.pool.has(event.connectorId)) return state;
842
769
  return {
843
770
  ...state,
844
- pool: newPool
771
+ activeConnectorId: event.connectorId
845
772
  };
846
- }
847
- case "ACTIVE_CHANGED": return {
848
- ...state,
849
- activeConnectorId: event.connectorId
850
- };
851
- case "SELECTION_CHANGED": {
852
- const newSelection = new Map(state.selection);
853
- if (event.connectorId === null) newSelection.delete(event.chainPlatform);
854
- else newSelection.set(event.chainPlatform, event.connectorId);
773
+ case "SELECTION_CHANGED":
774
+ if (state.selection.get(event.chainPlatform) === event.connectorId || state.pool.get(event.connectorId)?.connector.chainPlatform !== event.chainPlatform) return state;
855
775
  return {
856
776
  ...state,
857
- selection: newSelection
777
+ selection: withEntry(state.selection, event.chainPlatform, event.connectorId)
858
778
  };
859
- }
860
- case "STATUS_RESET": return {
861
- ...state,
862
- connectingConnectorId: null,
863
- connectionError: null,
864
- connectionStatus: "idle"
865
- };
866
- case "ERROR_SET": return {
867
- ...state,
868
- connectionError: event.error,
869
- connectionStatus: event.error ? "error" : "idle"
870
- };
871
- case "RESET": return {
872
- ...initialState,
873
- isHydrated: state.isHydrated,
874
- isUserDisconnected: true,
875
- reconnectingIds: /* @__PURE__ */ new Set()
876
- };
877
779
  default: return state;
878
780
  }
879
781
  };
880
-
881
- //#endregion
882
- //#region src/store/wallet-store-helpers.ts
883
- /** Run an async operation fire-and-forget, calling onError if it throws. */
884
- const run = async (fn, onError) => {
885
- try {
886
- await fn();
887
- } catch (error) {
888
- onError(error);
889
- }
782
+ /** Pure: `(state, event) => state`. Side effects live in the manager. */
783
+ const reducer = (state, event) => {
784
+ const next = apply(state, event);
785
+ return next === state ? state : reconcile(next);
890
786
  };
891
-
892
- //#endregion
893
- //#region src/store/wallet-store.ts
894
- const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
895
- const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
896
787
  /**
897
788
  * `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.
789
+ * `reconnectingIds` says whether a connection is verified. Seeded entries
790
+ * are also dormant: persisted, not yet live.
900
791
  */
901
- const seedStateFromSnapshot = (snapshot) => {
792
+ const stateFromSnapshot = (snapshot) => {
902
793
  const pool = /* @__PURE__ */ new Map();
903
- const reconnectingIds = /* @__PURE__ */ new Set();
904
- for (const [connectorId, entry] of Object.entries(snapshot.pool)) {
905
- if (!entry) continue;
906
- const connector = createShadowAdapter(entry);
794
+ const dormant = /* @__PURE__ */ new Map();
795
+ for (const [connectorId, entry] of Object.entries(snapshot.pool)) if (entry !== void 0) {
907
796
  pool.set(connectorId, {
908
797
  account: entry.account,
909
- accounts: [...entry.accounts],
910
- connector
798
+ accounts: entry.accounts,
799
+ connector: createShadowAdapter(entry)
911
800
  });
912
- reconnectingIds.add(connectorId);
801
+ dormant.set(connectorId, entry);
913
802
  }
914
- const selection = /* @__PURE__ */ new Map();
915
- for (const [platform, connectorId] of Object.entries(snapshot.selection)) if (isChainPlatform(platform) && connectorId !== void 0 && pool.has(connectorId)) selection.set(platform, connectorId);
916
- const activeConnectorId = snapshot.activeConnectorId !== null && pool.has(snapshot.activeConnectorId) ? snapshot.activeConnectorId : pool.keys().next().value ?? null;
917
- return {
803
+ return reconcile({
918
804
  ...initialState,
919
- activeConnectorId,
805
+ activeConnectorId: snapshot.activeConnectorId,
806
+ dormant,
920
807
  isHydrated: true,
921
808
  pool,
922
- reconnectingIds,
923
- selection
924
- };
809
+ reconnectingIds: new Set(pool.keys()),
810
+ selection: applyStoredSelection(/* @__PURE__ */ new Map(), snapshot.selection, pool)
811
+ });
925
812
  };
813
+
814
+ //#endregion
815
+ //#region src/store/wallet-manager.ts
926
816
  const CONNECT_TIMEOUT_MS = 9e4;
817
+ const RESTORE_TIMEOUT_MS = 15e3;
927
818
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
928
- const normaliseAddress = (addr) => addr.toLowerCase();
929
- const createWalletStore = (config) => {
930
- const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
931
- const reportStorageError = (context) => (error) => {
932
- if (config.onStorageError) {
819
+ const EMPTY_PERSISTED = {
820
+ activeConnectorId: null,
821
+ isUserDisconnected: false,
822
+ pool: {},
823
+ selection: {}
824
+ };
825
+ const toPersistedState = (state) => {
826
+ const pool = Object.fromEntries(state.dormant);
827
+ for (const [connectorId, wallet] of state.pool) pool[connectorId] = toStoredEntry(connectorId, wallet);
828
+ return {
829
+ activeConnectorId: state.activeConnectorId,
830
+ isUserDisconnected: state.isUserDisconnected,
831
+ pool,
832
+ selection: Object.fromEntries(state.selection)
833
+ };
834
+ };
835
+ const persistedSliceChanged = (state, prev) => state.pool !== prev.pool || state.dormant !== prev.dormant || state.selection !== prev.selection || state.activeConnectorId !== prev.activeConnectorId || state.isUserDisconnected !== prev.isUserDisconnected;
836
+ const toError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
837
+ /** A consumer callback that throws must not break the manager. */
838
+ const safely = (name, callback, ...args) => {
839
+ try {
840
+ callback?.(...args);
841
+ } catch (error) {
842
+ logWarn(`[butr] ${name} threw:`, error);
843
+ }
844
+ };
845
+ const withTimeout = async (promise, ms) => {
846
+ promise.catch(() => {});
847
+ let timer;
848
+ try {
849
+ return await Promise.race([promise, new Promise((_, reject) => {
850
+ timer = setTimeout(() => {
851
+ reject(new ConnectionError("Timeout", `Connection timed out after ${ms / 1e3}s`));
852
+ }, ms);
853
+ })]);
854
+ } finally {
855
+ clearTimeout(timer);
856
+ }
857
+ };
858
+ const teardown = async (connector) => {
859
+ try {
860
+ await connector.disconnect?.();
861
+ } catch (error) {
862
+ logWarn(`[butr] disconnect of ${connector.id} failed:`, error);
863
+ }
864
+ };
865
+ /**
866
+ * Framework-free: React binds it through `WalletManagerProvider`, anything
867
+ * else calls `start()` itself. Creating one has no side effects, so it is
868
+ * safe during a server render.
869
+ */
870
+ const createWalletManager = (config = {}, options = {}) => {
871
+ const storage = config.storage ?? createWalletStorage({ keyPrefix: config.storageKeyPrefix });
872
+ const store = createStore()(() => options.initialState ? stateFromSnapshot(options.initialState) : initialState);
873
+ const { getState } = store;
874
+ const dispatch = (event) => {
875
+ store.setState((state) => reducer(state, event), true);
876
+ };
877
+ const findAdapter = (connectorId) => getState().adapters.find((adapter) => adapter.id === connectorId);
878
+ const reportStorageError = (cause) => {
879
+ if (config.onStorageError) safely("onStorageError", config.onStorageError, toError(cause));
880
+ else logWarn("[butr] wallet persistence failed:", cause);
881
+ };
882
+ let persistEnabled = false;
883
+ let flushing = false;
884
+ let dirty = false;
885
+ const persist = async () => {
886
+ dirty = true;
887
+ if (flushing) return;
888
+ flushing = true;
889
+ while (dirty) {
890
+ dirty = false;
933
891
  try {
934
- config.onStorageError(error, context);
935
- } catch (cbError) {
936
- logWarn("[butr] onStorageError threw:", cbError);
892
+ await storage.save(toPersistedState(getState()));
893
+ } catch (error) {
894
+ reportStorageError(error);
937
895
  }
938
- return;
939
896
  }
940
- logWarn(`[butr] ${context}:`, error);
897
+ flushing = false;
941
898
  };
942
- const hydration = createHydrationCoordinator(storage, config.createConnector);
943
- return createStore()((set, get) => {
944
- const dispatch = (event) => {
945
- set((prev) => reducer(prev, event), false);
946
- };
947
- const persistPool = () => run(() => storage.setPool(get().pool), reportStorageError("failed to persist pool"));
948
- const persistSelection = () => run(() => storage.setSelection(get().selection), reportStorageError("failed to persist selection"));
949
- const persistActive = () => run(() => storage.setActiveConnectorId(get().activeConnectorId), reportStorageError("failed to persist active connector"));
950
- const refreshPoolEntry = (connectorId, accounts, active) => {
899
+ store.subscribe((state, prev) => {
900
+ if (persistEnabled && persistedSliceChanged(state, prev)) persist();
901
+ });
902
+ const lifecycle = createConnectorLifecycle({
903
+ onAccountsChanged: (connectorId, accounts) => {
951
904
  dispatch({
952
905
  accounts,
953
- active,
906
+ active: accounts[0],
954
907
  connectorId,
955
- type: "ACCOUNTS_REFRESHED"
908
+ type: "ACCOUNTS_CHANGED"
956
909
  });
957
- persistPool();
958
- };
959
- const lifecycle = createConnectorLifecycle({
960
- onAccountChanged: (connectorId, accounts, active) => {
961
- refreshPoolEntry(connectorId, [...accounts], active);
962
- },
963
- onDisconnected: (connectorId, chainPlatform) => {
964
- const wallet = get().pool.get(connectorId);
965
- if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
966
- dispatch({
967
- connectorId,
968
- type: "DISCONNECTED"
969
- });
970
- config.onDisconnect?.(chainPlatform);
910
+ },
911
+ onDisconnected: (connectorId) => {
912
+ const wallet = getState().pool.get(connectorId);
913
+ if (wallet === void 0) return;
914
+ teardown(wallet.connector);
915
+ dispatch({
916
+ byUser: false,
917
+ connectorId,
918
+ type: "DISCONNECTED"
919
+ });
920
+ safely("onDisconnect", config.onDisconnect, wallet, { byUser: false });
921
+ }
922
+ });
923
+ const attempted = /* @__PURE__ */ new Set();
924
+ let loaded = false;
925
+ let hydration = null;
926
+ let userChose = false;
927
+ const choose = (event) => {
928
+ const before = getState();
929
+ dispatch(event);
930
+ if (getState() !== before) userChose = true;
931
+ };
932
+ const restore = async (connectorId) => {
933
+ const adapter = findAdapter(connectorId);
934
+ if (adapter === void 0 || attempted.has(connectorId) || !getState().dormant.has(connectorId)) return null;
935
+ attempted.add(connectorId);
936
+ try {
937
+ await withTimeout(adapter.connect({ silent: true }), RESTORE_TIMEOUT_MS);
938
+ const accounts = await adapter.getAccounts();
939
+ const account = accounts.at(0);
940
+ if (account === void 0) throw new ConnectionError("NotConnected", `${adapter.name} exposed no accounts`);
941
+ const wallet = {
942
+ account,
943
+ accounts,
944
+ connector: adapter
945
+ };
946
+ const before = getState();
947
+ dispatch({
948
+ connectorId,
949
+ entry: wallet,
950
+ type: "ENTRY_RESTORED"
951
+ });
952
+ if (getState() === before) {
953
+ if (!before.pool.has(connectorId)) await teardown(adapter);
954
+ return null;
971
955
  }
956
+ safely("onConnect", config.onConnect, wallet, { reconnected: true });
957
+ return {
958
+ connectorId,
959
+ ok: true
960
+ };
961
+ } catch (error) {
962
+ dispatch({
963
+ connectorId,
964
+ type: "RESTORE_FAILED"
965
+ });
966
+ return {
967
+ connectorId,
968
+ error: toConnectionError(error),
969
+ ok: false
970
+ };
971
+ }
972
+ };
973
+ const restoreLate = async (connectorId) => {
974
+ const result = await restore(connectorId);
975
+ if (result?.ok === false) logWarn(`[butr] silent reconnect failed for ${connectorId}:`, result.error);
976
+ };
977
+ const hydrate = async () => {
978
+ let persisted = EMPTY_PERSISTED;
979
+ try {
980
+ persisted = await storage.load();
981
+ } catch (error) {
982
+ reportStorageError(error);
983
+ }
984
+ dispatch({
985
+ entries: persisted.pool,
986
+ isUserDisconnected: persisted.isUserDisconnected,
987
+ type: "STORAGE_LOADED"
972
988
  });
973
- return {
974
- ...config.initialState ? seedStateFromSnapshot(config.initialState) : initialState,
975
- _config: config,
976
- _storage: storage,
977
- connectWallet: async (connectorId, onSuccess, onError) => {
978
- get().setUserDisconnected(false);
979
- const connector = config.createConnector(connectorId);
980
- if (!connector) throw new Error(`Failed to create connector for ${connectorId}`);
981
- dispatch({
989
+ loaded = true;
990
+ const ids = [...getState().dormant.keys()];
991
+ const restores = ids.map(restore);
992
+ const pendingIds = ids.filter((id) => !attempted.has(id));
993
+ const results = (await Promise.all(restores)).filter((result) => result !== null);
994
+ dispatch({
995
+ activeConnectorId: userChose ? null : persisted.activeConnectorId,
996
+ selection: userChose ? {} : persisted.selection,
997
+ type: "HYDRATED"
998
+ });
999
+ persistEnabled = true;
1000
+ persist();
1001
+ const outcome = {
1002
+ dropped: results.flatMap((result) => result.ok ? [] : [{
1003
+ connectorId: result.connectorId,
1004
+ reason: result.error
1005
+ }]),
1006
+ pendingIds,
1007
+ restoredIds: results.flatMap((result) => result.ok ? [result.connectorId] : [])
1008
+ };
1009
+ if (config.onHydrated) safely("onHydrated", config.onHydrated, outcome);
1010
+ else for (const { connectorId, reason } of outcome.dropped) logWarn(`[butr] silent reconnect failed for ${connectorId}:`, reason);
1011
+ };
1012
+ const register = (adapter) => {
1013
+ const before = getState();
1014
+ dispatch({
1015
+ adapter,
1016
+ type: "ADAPTER_REGISTERED"
1017
+ });
1018
+ if (loaded && getState() !== before) restoreLate(adapter.id);
1019
+ };
1020
+ const subscribeSource = (source) => {
1021
+ try {
1022
+ return source(register);
1023
+ } catch (error) {
1024
+ logError("[butr] a wallet source threw on subscribe:", error);
1025
+ return () => {};
1026
+ }
1027
+ };
1028
+ return {
1029
+ clearConnectionError: () => {
1030
+ dispatch({ type: "STATUS_RESET" });
1031
+ },
1032
+ connect: async (connectorId) => {
1033
+ dispatch({
1034
+ connectorId,
1035
+ type: "CONNECT_STARTED"
1036
+ });
1037
+ const slowTimer = config.onSlowConnect ? setTimeout(() => {
1038
+ safely("onSlowConnect", config.onSlowConnect, connectorId);
1039
+ }, config.slowConnectThresholdMs ?? DEFAULT_SLOW_CONNECT_THRESHOLD_MS) : void 0;
1040
+ const connector = findAdapter(connectorId);
1041
+ try {
1042
+ if (connector === void 0) throw new ConnectionError("WalletNotFound", `No wallet adapter is registered as "${connectorId}"`);
1043
+ await withTimeout(connector.connect(), CONNECT_TIMEOUT_MS);
1044
+ const accounts = await connector.getAccounts();
1045
+ const account = accounts.at(0);
1046
+ if (account === void 0) throw new ConnectionError("NotConnected", `${connector.name} exposed no accounts`);
1047
+ const wallet = {
1048
+ account,
1049
+ accounts,
1050
+ connector
1051
+ };
1052
+ choose({
982
1053
  connectorId,
983
- type: "CONNECT_STARTED"
1054
+ entry: wallet,
1055
+ type: "CONNECT_SUCCEEDED"
984
1056
  });
985
- const slowThreshold = config.slowConnectThresholdMs ?? DEFAULT_SLOW_CONNECT_THRESHOLD_MS;
986
- const slowTimer = config.onSlowConnect ? setTimeout(() => {
987
- try {
988
- config.onSlowConnect?.(connectorId);
989
- } catch (cbError) {
990
- logWarn("[butr] onSlowConnect threw:", cbError);
991
- }
992
- }, slowThreshold) : null;
993
- let connectTimeoutId;
994
- try {
995
- const connectPromise = connector.connect();
996
- connectPromise.catch(() => {});
997
- await Promise.race([connectPromise, new Promise((_, reject) => {
998
- connectTimeoutId = setTimeout(() => {
999
- reject(/* @__PURE__ */ new Error("Connection timeout"));
1000
- }, CONNECT_TIMEOUT_MS);
1001
- })]);
1002
- const account = await connector.getAccount();
1003
- if (!account) throw new Error("Failed to get account");
1004
- const fetchedAccounts = connector.getAccounts ? await connector.getAccounts().catch(() => null) : null;
1005
- const entry = {
1006
- account,
1007
- accounts: fetchedAccounts && fetchedAccounts.length > 0 ? fetchedAccounts : [account],
1008
- connector
1009
- };
1010
- dispatch({
1011
- connectorId,
1012
- entry,
1013
- type: "CONNECT_SUCCEEDED"
1014
- });
1015
- lifecycle.attach(connectorId, connector);
1016
- await Promise.all([
1017
- persistPool(),
1018
- persistSelection(),
1019
- persistActive()
1020
- ]);
1021
- config.onConnect?.(entry);
1022
- onSuccess?.(entry);
1023
- } catch (error) {
1024
- const normalised = mapConnectionError(error);
1025
- dispatch({
1026
- connectorId,
1027
- error: normalised,
1028
- type: "CONNECT_FAILED"
1029
- });
1030
- try {
1031
- await connector.disconnect?.();
1032
- } catch (disconnectError) {
1033
- logWarn("[butr] disconnect during error recovery failed:", disconnectError);
1034
- }
1035
- try {
1036
- config.onConnectError?.(normalised, connectorId);
1037
- } catch (cbError) {
1038
- logWarn("[butr] onConnectError threw:", cbError);
1039
- }
1040
- onError?.(error instanceof Error ? error : new Error(String(error)));
1041
- throw error;
1042
- } finally {
1043
- clearTimeout(connectTimeoutId);
1044
- if (slowTimer) clearTimeout(slowTimer);
1045
- }
1046
- },
1047
- disconnectWallet: (connectorId) => {
1048
- get().setUserDisconnected(true);
1049
- const state = get();
1050
- if (!state.isHydrated) return;
1051
- const wallet = state.pool.get(connectorId);
1052
- if (!wallet) return;
1053
- lifecycle.detach(connectorId);
1054
- run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
1055
- const platform = wallet.connector.chainPlatform;
1057
+ safely("onConnect", config.onConnect, wallet, { reconnected: false });
1058
+ return wallet;
1059
+ } catch (error) {
1060
+ const failure = toConnectionError(error);
1056
1061
  dispatch({
1057
1062
  connectorId,
1058
- type: "DISCONNECTED"
1063
+ error: failure,
1064
+ type: "CONNECT_FAILED"
1059
1065
  });
1060
- if (get().pool.size === 0) run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
1061
- else {
1062
- run(() => storage.removePoolEntry(connectorId), reportStorageError("failed to remove pool entry"));
1063
- persistSelection();
1064
- persistActive();
1065
- }
1066
- config.onDisconnect?.(platform);
1067
- },
1068
- getConnectorInstance: (id) => config.createConnector(id),
1069
- hydrateWallets: async () => {
1070
- const result = await hydration.hydrate();
1071
- dispatch({
1072
- activeConnectorId: result.activeConnectorId,
1073
- dropped: result.dropped.map((d) => d.connectorId),
1074
- isUserDisconnected: result.isUserDisconnected,
1075
- pool: result.pool,
1076
- selection: result.selection,
1077
- type: "HYDRATED"
1078
- });
1079
- for (const [connectorId, wallet] of result.pool) lifecycle.attach(connectorId, wallet.connector);
1080
- await Promise.all([persistSelection(), persistActive()]);
1081
- for (const id of hydration.pendingIds()) run(() => get().tryRestoreFromPending(id), (e) => {
1082
- logWarn(`[butr] late restore rejected for ${id}:`, e);
1083
- });
1084
- if (config.onHydrated) try {
1085
- config.onHydrated({
1086
- dropped: result.dropped,
1087
- pendingIds: [...result.pendingIds],
1088
- restoredIds: [...result.pool.keys()]
1089
- });
1090
- } catch (cbError) {
1091
- logWarn("[butr] onHydrated threw:", cbError);
1066
+ if (connector !== void 0 && getState().pool.get(connectorId)?.connector !== connector) await teardown(connector);
1067
+ safely("onConnectError", config.onConnectError, failure, connectorId);
1068
+ throw failure;
1069
+ } finally {
1070
+ clearTimeout(slowTimer);
1071
+ }
1072
+ },
1073
+ disconnect: (connectorId) => {
1074
+ const wallet = getState().pool.get(connectorId);
1075
+ dispatch({
1076
+ byUser: true,
1077
+ connectorId,
1078
+ type: "DISCONNECTED"
1079
+ });
1080
+ if (wallet !== void 0) {
1081
+ teardown(wallet.connector);
1082
+ safely("onDisconnect", config.onDisconnect, wallet, { byUser: true });
1083
+ }
1084
+ },
1085
+ disconnectAll: () => {
1086
+ const wallets = [...getState().pool.values()];
1087
+ dispatch({ type: "RESET" });
1088
+ for (const wallet of wallets) {
1089
+ teardown(wallet.connector);
1090
+ safely("onDisconnect", config.onDisconnect, wallet, { byUser: true });
1091
+ }
1092
+ },
1093
+ getInitialState: store.getInitialState,
1094
+ getState,
1095
+ requestAccounts: async (connectorId) => {
1096
+ const connector = getState().pool.get(connectorId)?.connector;
1097
+ if (connector?.requestAccounts === void 0) return;
1098
+ await connector.requestAccounts();
1099
+ const accounts = await connector.getAccounts();
1100
+ dispatch({
1101
+ accounts,
1102
+ connectorId,
1103
+ type: "ACCOUNTS_CHANGED"
1104
+ });
1105
+ },
1106
+ setAccount: (connectorId, account) => {
1107
+ const wallet = getState().pool.get(connectorId);
1108
+ const active = wallet?.accounts.find((candidate) => candidate.id === account.id);
1109
+ if (wallet === void 0 || active === void 0) return;
1110
+ dispatch({
1111
+ accounts: wallet.accounts,
1112
+ active,
1113
+ connectorId,
1114
+ type: "ACCOUNTS_CHANGED"
1115
+ });
1116
+ },
1117
+ setActive: (connectorId) => {
1118
+ choose({
1119
+ connectorId,
1120
+ type: "ACTIVE_CHANGED"
1121
+ });
1122
+ },
1123
+ setSelection: (chainPlatform, connectorId) => {
1124
+ choose({
1125
+ chainPlatform,
1126
+ connectorId,
1127
+ type: "SELECTION_CHANGED"
1128
+ });
1129
+ },
1130
+ start: () => {
1131
+ const unsubscribes = (config.sources ?? []).map(subscribeSource);
1132
+ lifecycle.sync(getState().pool);
1133
+ const unsubscribePool = store.subscribe((state, prev) => {
1134
+ if (state.pool !== prev.pool) lifecycle.sync(state.pool);
1135
+ });
1136
+ hydration ??= hydrate();
1137
+ return () => {
1138
+ for (const unsubscribe of unsubscribes) try {
1139
+ unsubscribe();
1140
+ } catch (error) {
1141
+ logWarn("[butr] a wallet source threw on unsubscribe:", error);
1092
1142
  }
1093
- else if (result.dropped.length > 0) for (const { connectorId, reason } of result.dropped) logWarn(`[butr] failed to restore connector ${connectorId}:`, reason);
1094
- },
1095
- refreshWallet: (connectorId) => {
1096
- dispatch({
1097
- connectorId,
1098
- type: "WALLET_REFRESHED"
1099
- });
1100
- },
1101
- requestAccounts: async (connectorId) => {
1102
- const entry = get().pool.get(connectorId);
1103
- if (!entry) return;
1104
- await entry.connector.requestAccounts?.();
1105
- const fresh = entry.connector.getAccounts ? await entry.connector.getAccounts().catch(() => null) : null;
1106
- if (!fresh || fresh.length === 0) return;
1107
- refreshPoolEntry(connectorId, fresh);
1108
- },
1109
- reset: () => {
1110
- get().setUserDisconnected(true);
1111
- const state = get();
1112
- if (!state.isHydrated) return;
1143
+ unsubscribePool();
1113
1144
  lifecycle.detachAll();
1114
- for (const wallet of state.pool.values()) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
1115
- run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
1116
- if (config.onReset) {
1117
- const onReset = config.onReset;
1118
- run(() => Promise.resolve(onReset()), logError);
1119
- }
1120
- dispatch({ type: "RESET" });
1121
- },
1122
- resetConnectionStatus: () => {
1123
- dispatch({ type: "STATUS_RESET" });
1124
- },
1125
- setActiveConnector: (connectorId) => {
1126
- dispatch({
1127
- connectorId,
1128
- type: "ACTIVE_CHANGED"
1129
- });
1130
- persistActive();
1131
- },
1132
- setConnectionError: (error) => {
1133
- dispatch({
1134
- error,
1135
- type: "ERROR_SET"
1136
- });
1137
- },
1138
- setSelection: (chainPlatform, connectorId) => {
1139
- dispatch({
1140
- chainPlatform,
1141
- connectorId,
1142
- type: "SELECTION_CHANGED"
1143
- });
1144
- persistSelection();
1145
- },
1146
- setUserDisconnected: (value) => {
1147
- dispatch({
1148
- type: "USER_DISCONNECTED_SET",
1149
- value
1150
- });
1151
- run(() => storage.markUserDisconnected(value), reportStorageError("failed to persist disconnect intent"));
1152
- },
1153
- tryRestoreFromPending: async (connectorId) => {
1154
- const outcome = await hydration.drainPending(connectorId);
1155
- if (!outcome) return;
1156
- if (outcome.kind === "fail") {
1157
- logWarn(`[butr] late restore failed for ${connectorId}:`, outcome.error);
1158
- return;
1159
- }
1160
- dispatch({
1161
- connectorId,
1162
- entry: outcome.entry,
1163
- type: "ENTRY_RESTORED"
1164
- });
1165
- lifecycle.attach(connectorId, outcome.entry.connector);
1166
- await Promise.all([
1167
- persistPool(),
1168
- persistSelection(),
1169
- persistActive()
1170
- ]);
1171
- config.onConnect?.(outcome.entry);
1172
- },
1173
- updateWalletAccount: (connectorId, account) => {
1174
- const entry = get().pool.get(connectorId);
1175
- if (!entry) return;
1176
- const newChain = account.chain;
1177
- const remapped = entry.accounts.map((a) => a.chain.id === newChain.id ? a : {
1178
- chain: newChain,
1179
- id: `${newChain.id}:${normaliseAddress(a.walletAddress)}`,
1180
- walletAddress: a.walletAddress
1181
- });
1182
- const accounts = remapped.some((a) => normaliseAddress(a.walletAddress) === normaliseAddress(account.walletAddress)) ? remapped : [...remapped, account];
1183
- refreshPoolEntry(connectorId, accounts, account);
1184
- }
1185
- };
1186
- });
1145
+ };
1146
+ },
1147
+ subscribe: store.subscribe
1148
+ };
1187
1149
  };
1188
1150
 
1189
1151
  //#endregion
@@ -1264,10 +1226,12 @@ const createCookieStorageDriver = (options = {}) => {
1264
1226
 
1265
1227
  //#endregion
1266
1228
  //#region src/storage/snapshot.ts
1229
+ const EMPTY_POOL = Object.freeze({});
1230
+ const EMPTY_SELECTION = Object.freeze({});
1267
1231
  const EMPTY_SNAPSHOT = Object.freeze({
1268
1232
  activeConnectorId: null,
1269
- pool: Object.freeze({}),
1270
- selection: Object.freeze({})
1233
+ pool: EMPTY_POOL,
1234
+ selection: EMPTY_SELECTION
1271
1235
  });
1272
1236
  const toCookieMap = (input) => {
1273
1237
  if (!(Symbol.iterator in input)) return new Map(Object.entries(input));
@@ -1298,7 +1262,7 @@ const readWalletSnapshot = (source, options = {}) => {
1298
1262
  if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1299
1263
  else {
1300
1264
  const firstKey = Object.keys(pool)[0];
1301
- if (firstKey) activeConnectorId = firstKey;
1265
+ if (firstKey !== void 0 && firstKey !== "") activeConnectorId = firstKey;
1302
1266
  }
1303
1267
  return {
1304
1268
  activeConnectorId,
@@ -1432,7 +1396,7 @@ const base58ToBytes = (input) => {
1432
1396
  var SignInUnsupportedError = class extends Error {
1433
1397
  connectorId;
1434
1398
  constructor(connectorId) {
1435
- super(`Wallet "${connectorId}" reports capabilities.signMessage === false, so it cannot sign in.`);
1399
+ super(`Wallet "${connectorId}" cannot sign messages, so it cannot sign in.`);
1436
1400
  this.connectorId = connectorId;
1437
1401
  this.name = "SignInUnsupportedError";
1438
1402
  }
@@ -1448,12 +1412,11 @@ const createSignInFlow = (options) => {
1448
1412
  const sign = async (wallet, account) => {
1449
1413
  const { connector } = wallet;
1450
1414
  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) {
1415
+ if (options.preferSignMessage !== true && connector.chainPlatform === "svm" && connector.signIn !== void 0) {
1416
+ const nonce = await options.getNonce({
1417
+ account: signingAccount,
1418
+ wallet
1419
+ });
1457
1420
  const output = await connector.signIn({ nonce });
1458
1421
  return {
1459
1422
  account: output.account,
@@ -1465,12 +1428,17 @@ const createSignInFlow = (options) => {
1465
1428
  wallet
1466
1429
  };
1467
1430
  }
1431
+ if (connector.signMessage === void 0) throw new SignInUnsupportedError(connector.id);
1432
+ const nonce = await options.getNonce({
1433
+ account: signingAccount,
1434
+ wallet
1435
+ });
1468
1436
  const message = buildMessage({
1469
1437
  account: signingAccount,
1470
1438
  nonce,
1471
1439
  wallet
1472
1440
  });
1473
- const { signature, signedMessage } = await connector.signMessage(new TextEncoder().encode(message), signingAccount);
1441
+ const { signature, signedMessage } = await connector.signMessage(new TextEncoder().encode(message), { account: signingAccount });
1474
1442
  return {
1475
1443
  account: signingAccount,
1476
1444
  message,
@@ -1489,19 +1457,6 @@ const createSignInFlow = (options) => {
1489
1457
  } };
1490
1458
  };
1491
1459
 
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
1460
  //#endregion
1506
1461
  //#region src/sanitize-icon.ts
1507
1462
  /**
@@ -1516,5 +1471,5 @@ const sanitizeIcon = (icon) => {
1516
1471
  };
1517
1472
 
1518
1473
  //#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 };
1474
+ export { BITCOIN_CHAINS, BITCOIN_CHAINS_LIST, CHAINS_BY_PLATFORM, CHAIN_PLATFORMS, ConnectionError, EMPTY_SNAPSHOT, EVM_CHAINS, EVM_CHAINS_LIST, POLKADOT_CHAINS, POLKADOT_CHAINS_LIST, SUI_CHAINS, SUI_CHAINS_LIST, SVM_CHAINS, SVM_CHAINS_LIST, ShadowConnectorError, SignInUnsupportedError, accountsEqual, base58ToBytes, base64ToBytes, buildAccount, bytesToBase58, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createSignInFlow, createWalletManager, createWalletStorage, fromAdapters, groupByPlatform, hexToBytes, isChainPlatform, isPlatformWallet, logError, logWarn, readWalletSnapshot, resolveChain, sanitizeIcon, toConnectionError, walletEqual };
1520
1475
  //# sourceMappingURL=index.js.map