@usebutr/core 2.0.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,81 +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
33
- const isCodedError = (error) => "code" in error;
34
- const toErrorCause = (value) => value;
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;
35
46
  /**
36
47
  * EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
37
48
  * unauthorized or disconnected. Message-substring matching is the last
38
49
  * resort for SDKs that ship no codes at all.
39
50
  */
40
- const mapConnectionError = (raw) => {
41
- if (raw instanceof Error) {
42
- const message = raw.message;
43
- const lower = message.toLowerCase();
44
- if (message === "Connection timeout") return {
45
- kind: "Timeout",
46
- message
47
- };
48
- if (message === "Failed to get account" || lower.includes("not connected")) return {
49
- kind: "NotConnected",
50
- message
51
- };
52
- const code = isCodedError(raw) ? raw.code : void 0;
53
- if (code === 4001) return {
54
- kind: "UserRejected",
55
- message
56
- };
57
- if (code === -32002) return {
58
- kind: "RequestPending",
59
- message
60
- };
61
- if (code === 4100 || code === 4900 || code === 4901) return {
62
- kind: "NotConnected",
63
- message
64
- };
65
- if (lower.includes("user rejected") || lower.includes("user denied")) return {
66
- kind: "UserRejected",
67
- message
68
- };
69
- if (lower.includes("locked")) return {
70
- kind: "WalletLocked",
71
- message
72
- };
73
- if (lower.includes("chain") && (lower.includes("mismatch") || lower.includes("unsupported"))) return {
74
- kind: "ChainMismatch",
75
- message
76
- };
77
- return {
78
- cause: raw,
79
- kind: "Unknown",
80
- message
81
- };
82
- }
83
- if (typeof raw === "string") return {
84
- kind: "Unknown",
85
- message: raw
86
- };
87
- return {
88
- cause: raw,
89
- kind: "Unknown",
90
- message: "Connection failed"
91
- };
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 });
92
68
  };
93
69
 
94
70
  //#endregion
@@ -105,120 +81,252 @@ const CHAIN_PLATFORMS = [
105
81
  "bitcoin",
106
82
  "polkadot"
107
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
+ };
108
262
 
109
263
  //#endregion
110
264
  //#region src/wallet-source.ts
265
+ const isIterable = (value) => Symbol.iterator in value;
111
266
  /**
112
- * Takes the exact shape of `discoverEvmAdapters` and friends, so a
113
- * 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.
114
270
  */
115
- 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
+ };
116
288
 
117
289
  //#endregion
118
290
  //#region src/store/shadow-adapter.ts
119
291
  /**
120
- * Loud failure for consumers that called a wallet method without gating
121
- * on `capabilities.*` (all `false` here) or `reconnectingIds`, before
122
- * silent reconnect swapped in the live adapter.
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.
123
295
  */
124
296
  var ShadowConnectorError = class extends Error {
125
- code = "BUTR_RECONNECTING";
126
297
  connectorId;
127
298
  method;
128
299
  constructor(method, connectorId) {
129
- 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.`);
130
301
  this.name = "ShadowConnectorError";
131
302
  this.connectorId = connectorId;
132
303
  this.method = method;
133
304
  }
134
305
  };
135
- const ALL_FALSE_CAPABILITIES = Object.freeze({
136
- getBalance: false,
137
- getTransactionReceipt: false,
138
- requestAccounts: false,
139
- sendTransaction: false,
140
- signIn: false,
141
- signMessage: false,
142
- signTransaction: false,
143
- subscribe: false,
144
- switchAccount: false,
145
- switchChain: false
146
- });
147
306
  /**
148
- * Lets `createWalletStore` flip `isHydrated` synchronously from
149
- * `initialState`; the hydration coordinator later swaps each shadow for
150
- * a live adapter, or drops the entry from pool and storage.
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.
151
310
  */
152
311
  const createShadowAdapter = (entry) => {
153
- const id = entry.connectorId;
154
- const name = entry.name;
155
- const icon = entry.icon;
156
- const reject = (method) => Promise.reject(new ShadowConnectorError(method, id));
157
- const base = {
158
- capabilities: ALL_FALSE_CAPABILITIES,
159
- connect: () => reject("connect"),
160
- disconnect: () => reject("disconnect"),
161
- getAccount: () => reject("getAccount"),
162
- getBalance: () => reject("getBalance"),
163
- getSigner: () => reject("getSigner"),
164
- getTransactionReceipt: () => reject("getTransactionReceipt"),
165
- icon,
166
- id,
167
- name,
168
- sendTx: () => reject("sendTx"),
169
- sendTxToChain: () => reject("sendTxToChain"),
170
- signMessage: () => reject("signMessage"),
171
- 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
172
321
  };
173
- switch (entry.chainPlatform) {
174
- case "bitcoin": return {
175
- ...base,
176
- chainPlatform: "bitcoin"
177
- };
178
- case "evm": return {
179
- ...base,
180
- chainPlatform: "evm"
181
- };
182
- case "sui": return {
183
- ...base,
184
- chainPlatform: "sui"
185
- };
186
- case "svm": return {
187
- ...base,
188
- chainPlatform: "svm"
189
- };
190
- case "polkadot": return {
191
- ...base,
192
- chainPlatform: "polkadot"
193
- };
194
- default:
195
- entry.chainPlatform;
196
- throw new Error(`[butr] unknown chainPlatform: ${String(entry.chainPlatform)}`);
197
- }
198
- };
199
- /**
200
- * Structural detection: relies on every live adapter advertising at
201
- * least one capability, since `getBalance`, `signMessage` and
202
- * `switchChain` are required on all wallet surfaces.
203
- */
204
- const isShadowAdapter = (adapter) => {
205
- return Object.values(adapter.capabilities).every((flag) => !flag);
206
- };
207
-
208
- //#endregion
209
- //#region src/logger.ts
210
- const logWarn = (...args) => {
211
- console.warn(...args);
212
- };
213
- const logError = (...args) => {
214
- console.error(...args);
215
322
  };
216
323
 
217
324
  //#endregion
218
325
  //#region src/storage/browser-storage-driver.ts
326
+ const webStorageGlobals = globalThis;
219
327
  const hasWebStorage = (kind) => {
220
328
  try {
221
- return kind === "localStorage" ? globalThis.localStorage !== void 0 : globalThis.sessionStorage !== void 0;
329
+ return kind === "localStorage" ? webStorageGlobals.localStorage !== void 0 : webStorageGlobals.sessionStorage !== void 0;
222
330
  } catch {
223
331
  return false;
224
332
  }
@@ -293,14 +401,9 @@ const storedPoolEntrySchema = z.looseObject({
293
401
  name: z.string().min(1)
294
402
  });
295
403
  const recordSchema = z.record(z.string(), z.unknown());
296
- const parseStoredPoolEntry = (key, value) => {
297
- const parsed = storedPoolEntrySchema.safeParse(value);
298
- if (!parsed.success || parsed.data.connectorId !== key) return null;
299
- return parsed.data;
300
- };
301
404
  const DEFAULT_KEY_PREFIX = "butr";
302
405
  /**
303
- * `WalletStorage` writes these and `readWalletSnapshot` reads them from
406
+ * `createWalletStorage` writes these and `readWalletSnapshot` reads them from
304
407
  * a cookie jar; any divergence desyncs the SSR-seeded render from the
305
408
  * client that rehydrates it, the failure ADR 0003 exists to prevent.
306
409
  */
@@ -313,478 +416,308 @@ const storageKeys = (keyPrefix) => {
313
416
  userDisconnected: `${prefix}-user-disconnected`
314
417
  };
315
418
  };
316
- /**
317
- * Never repairs or throws: one corrupt entry must not take down a whole
318
- * session, and `readWalletSnapshot` may run on a server with no cookie
319
- * jar to write the eviction to.
320
- */
321
- const decodePool = (raw, label = "[butr]") => {
419
+ const parseRecord = (raw, label, what) => {
322
420
  if (raw === null || raw === void 0 || raw === "") return {};
323
- let value;
324
421
  try {
325
- value = JSON.parse(raw);
422
+ const parsed = recordSchema.safeParse(JSON.parse(raw));
423
+ return parsed.success ? parsed.data : {};
326
424
  } catch (error) {
327
- logWarn(`${label} failed to parse pool from storage:`, error);
425
+ logWarn(`${label} failed to parse ${what} from storage:`, error);
328
426
  return {};
329
427
  }
330
- const parsed = recordSchema.safeParse(value);
331
- 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]") => {
332
435
  const result = {};
333
- for (const [key, entryValue] of Object.entries(parsed.data)) {
334
- const candidate = storedPoolEntrySchema.safeParse(entryValue);
335
- const entry = candidate.success ? parseStoredPoolEntry(key, candidate.data) : null;
336
- if (entry === null) logWarn(`${label} dropping invalid pool entry for ${key}`);
337
- else result[key] = entry;
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}`);
338
440
  }
339
441
  return result;
340
442
  };
341
443
  /** Decode a persisted selection payload. Same contract as `decodePool`. */
342
444
  const decodeSelection = (raw, label = "[butr]") => {
343
- if (raw === null || raw === void 0 || raw === "") return {};
344
- let value;
345
- try {
346
- value = JSON.parse(raw);
347
- } catch (error) {
348
- logWarn(`${label} failed to parse selection from storage:`, error);
349
- return {};
350
- }
351
- const parsed = recordSchema.safeParse(value);
352
- if (!parsed.success) return {};
353
445
  const result = {};
354
- for (const [key, selectionValue] of Object.entries(parsed.data)) {
446
+ for (const [key, value] of Object.entries(parseRecord(raw, label, "selection"))) {
355
447
  const platform = chainPlatformSchema.safeParse(key);
356
- 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;
357
449
  }
358
450
  return result;
359
451
  };
360
452
 
361
453
  //#endregion
362
454
  //#region src/storage/wallet-storage.ts
363
- var WalletStorage = class {
364
- poolKey;
365
- selectionKey;
366
- activeKey;
367
- userDisconnectedKey;
368
- persistent;
369
- session;
370
- /**
371
- * Serializes pool read-modify-writes; without it concurrent `setPool`
372
- * calls drop each other's entries. Not reentrant, so a queued mutation
373
- * must read via `readPool`: `getPool` re-enters and deadlocks it.
374
- */
375
- poolMutationQueue = Promise.resolve();
376
- constructor(config) {
377
- const keys = storageKeys(config.keyPrefix);
378
- this.poolKey = keys.pool;
379
- this.selectionKey = keys.selection;
380
- this.activeKey = keys.active;
381
- this.userDisconnectedKey = keys.userDisconnected;
382
- if (config.persistent && config.session) {
383
- this.persistent = config.persistent;
384
- this.session = config.session;
385
- } else {
386
- const defaults = createBrowserStorageDriver();
387
- this.persistent = config.persistent ?? defaults.persistent;
388
- this.session = config.session ?? defaults.session;
389
- }
390
- }
391
- /** Chain `fn` after the in-flight pool mutation. */
392
- async serializePoolMutation(fn) {
393
- const previous = this.poolMutationQueue;
394
- let resolve = () => {};
395
- this.poolMutationQueue = new Promise((r) => {
396
- resolve = r;
397
- });
398
- try {
399
- await previous;
400
- } catch {}
401
- try {
402
- return await fn();
403
- } finally {
404
- resolve();
405
- }
406
- }
407
- /** Read and decode the pool without touching the mutation queue. Safe to
408
- * call from inside a queued mutation, unlike `getPool`. */
409
- async readPool() {
410
- let stored;
411
- try {
412
- stored = await this.persistent.getItem(this.poolKey);
413
- } catch (error) {
414
- logWarn("[butr] failed to read pool from storage:", error);
415
- return {
416
- corrupt: false,
417
- decoded: {}
418
- };
419
- }
420
- if (stored === null || stored === "") return {
421
- corrupt: false,
422
- decoded: {}
423
- };
424
- const decoded = decodePool(stored);
425
- return {
426
- corrupt: Object.keys(decoded).length === 0,
427
- decoded
428
- };
429
- }
430
- async getPool() {
431
- const { corrupt, decoded } = await this.readPool();
432
- if (corrupt) try {
433
- await this.persistent.removeItem(this.poolKey);
434
- } catch (error) {
435
- logWarn("[butr] failed to clear corrupt pool:", error);
436
- }
437
- return decoded;
438
- }
439
- /**
440
- * Additive on purpose: a failed silent reconnect drops the entry from
441
- * the live pool, and it must survive to be retried next load.
442
- * Eviction goes through `removePoolEntry` or `clearAll`.
443
- */
444
- async setPool(pool) {
445
- await this.serializePoolMutation(async () => {
446
- try {
447
- const { decoded: existing } = await this.readPool();
448
- const serializable = { ...existing };
449
- for (const [connectorId, wallet] of pool) {
450
- const entry = {
451
- account: wallet.account,
452
- accounts: wallet.accounts,
453
- chainPlatform: wallet.connector.chainPlatform,
454
- connectorId,
455
- icon: wallet.connector.icon,
456
- name: wallet.connector.name
457
- };
458
- if (parseStoredPoolEntry(connectorId, entry) === null) throw new Error(`[butr] refusing to persist invalid pool entry for ${connectorId}`);
459
- serializable[connectorId] = entry;
460
- }
461
- await this.persistent.setItem(this.poolKey, JSON.stringify(serializable));
462
- } catch (error) {
463
- logWarn("[butr] failed to persist pool:", error);
464
- }
465
- });
466
- }
467
- async removePoolEntry(connectorId) {
468
- await this.serializePoolMutation(async () => {
469
- try {
470
- const { decoded: stored } = await this.readPool();
471
- if (stored[connectorId]) {
472
- const { [connectorId]: _, ...remaining } = stored;
473
- await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
474
- }
475
- } catch (error) {
476
- logWarn(`[butr] failed to remove pool entry ${connectorId}:`, error);
477
- }
478
- });
479
- }
480
- async clearPool() {
481
- await this.serializePoolMutation(async () => {
482
- await this.persistent.removeItem(this.poolKey);
483
- });
484
- }
485
- async getSelection() {
486
- try {
487
- return decodeSelection(await this.persistent.getItem(this.selectionKey));
488
- } catch (error) {
489
- logWarn("[butr] failed to read selection from storage:", error);
490
- return {};
491
- }
492
- }
493
- 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) => {
494
468
  try {
495
- const serializable = {};
496
- for (const [platform, connectorId] of selection) serializable[platform] = connectorId;
497
- await this.persistent.setItem(this.selectionKey, JSON.stringify(serializable));
469
+ const value = await driver.getItem(key);
470
+ written.set(key, value);
471
+ return value;
498
472
  } catch (error) {
499
- logWarn("[butr] failed to persist selection:", error);
500
- }
501
- }
502
- async getActiveConnectorId() {
503
- try {
504
- const value = await this.persistent.getItem(this.activeKey);
505
- return value !== null && value.length > 0 ? value : null;
506
- } catch {
473
+ logWarn(`[butr] failed to read ${key} from storage:`, error);
507
474
  return null;
508
475
  }
509
- }
510
- async setActiveConnectorId(connectorId) {
511
- try {
512
- await (connectorId === null ? this.persistent.removeItem(this.activeKey) : this.persistent.setItem(this.activeKey, connectorId));
513
- } catch (error) {
514
- logWarn("[butr] failed to persist active connector id:", error);
515
- }
516
- }
517
- async clearAll() {
518
- await Promise.all([
519
- this.clearPool(),
520
- this.persistent.removeItem(this.selectionKey),
521
- this.persistent.removeItem(this.activeKey)
522
- ]);
523
- }
524
- /**
525
- * Kept in the session driver so it survives remounts (unlike a ref)
526
- * yet clears at session end (unlike the persistent driver): a manual
527
- * disconnect must suppress auto-connect now, not forever.
528
- */
529
- async isUserDisconnected() {
530
- try {
531
- return await this.session.getItem(this.userDisconnectedKey) === "true";
532
- } catch {
533
- 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;
534
505
  }
535
- }
536
- async markUserDisconnected(value) {
537
- try {
538
- await (value ? this.session.setItem(this.userDisconnectedKey, "true") : this.session.removeItem(this.userDisconnectedKey));
539
- } catch {}
540
- }
506
+ };
541
507
  };
542
508
 
543
509
  //#endregion
544
510
  //#region src/store/connector-lifecycle.ts
545
511
  const createConnectorLifecycle = (handlers) => {
546
- const unsubscribers = /* @__PURE__ */ new Map();
512
+ const live = /* @__PURE__ */ new Map();
547
513
  const detach = (connectorId) => {
548
- const unsub = unsubscribers.get(connectorId);
549
- if (!unsub) return;
514
+ const subscription = live.get(connectorId);
515
+ if (subscription === void 0) return;
516
+ live.delete(connectorId);
550
517
  try {
551
- unsub();
518
+ subscription.unsubscribe();
552
519
  } catch (error) {
553
520
  logWarn("[butr] unsubscribe threw:", error);
554
521
  }
555
- unsubscribers.delete(connectorId);
556
522
  };
557
- return {
558
- attach: (connectorId, connector) => {
559
- if (!connector.subscribe) return;
523
+ const attach = (connectorId, connector) => {
524
+ if (connector.subscribe === void 0) return;
525
+ const disconnected = () => {
560
526
  detach(connectorId);
561
- try {
562
- const unsub = connector.subscribe((event) => {
563
- switch (event.type) {
564
- case "accountChanged":
565
- handlers.onAccountChanged(connectorId, event.accounts, event.account);
566
- break;
567
- case "disconnected":
568
- detach(connectorId);
569
- handlers.onDisconnected(connectorId, connector.chainPlatform);
570
- }
571
- });
572
- unsubscribers.set(connectorId, unsub);
573
- } catch (error) {
574
- logWarn(`[butr] subscribe failed for ${connectorId}:`, error);
575
- }
576
- },
577
- detach,
578
- detachAll: () => {
579
- const ids = [];
580
- for (const id of unsubscribers.keys()) ids.push(id);
581
- 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);
582
544
  }
583
545
  };
584
- };
585
-
586
- //#endregion
587
- //#region src/store/hydration.ts
588
- const VALID_CHAIN_PLATFORMS$1 = new Set(CHAIN_PLATFORMS);
589
- const isChainPlatform$1 = (value) => VALID_CHAIN_PLATFORMS$1.has(value);
590
- /** Pick the right `accounts` list: fresh from the connector if it
591
- * exposes `getAccounts`, otherwise the persisted list, otherwise just
592
- * the active account. */
593
- const resolveHydratedAccounts = async (connector, storedAccounts, fallbackAccount) => {
594
- if (connector.getAccounts) {
595
- const fresh = await connector.getAccounts().catch(() => null);
596
- if (fresh && fresh.length > 0) return fresh;
597
- }
598
- if (storedAccounts.length > 0) return [...storedAccounts];
599
- return [fallbackAccount];
600
- };
601
- const restoreOneEntry = async (connectorId, entry, connector) => {
602
- try {
603
- await connector.connect({ silent: true });
604
- const accountToUse = await connector.getAccount() ?? entry.account;
605
- return {
606
- connectorId,
607
- entry: {
608
- account: accountToUse,
609
- accounts: await resolveHydratedAccounts(connector, entry.accounts, accountToUse),
610
- connector
611
- },
612
- kind: "ok"
613
- };
614
- } catch (error) {
615
- return {
616
- connectorId,
617
- error: toErrorCause(error instanceof Error ? error : String(error)),
618
- kind: "fail"
619
- };
620
- }
621
- };
622
- const createHydrationCoordinator = (storage, createConnector) => {
623
- const pending = /* @__PURE__ */ new Map();
624
546
  return {
625
- drainPending: async (connectorId) => {
626
- const entry = pending.get(connectorId);
627
- if (!entry) return null;
628
- const connector = createConnector(connectorId);
629
- if (!connector) return null;
630
- const outcome = await restoreOneEntry(connectorId, entry, connector);
631
- pending.delete(connectorId);
632
- return outcome;
633
- },
634
- hydrate: async () => {
635
- const [storedPool, storedSelection, storedActive, userDisconnected] = await Promise.all([
636
- storage.getPool(),
637
- storage.getSelection(),
638
- storage.getActiveConnectorId(),
639
- storage.isUserDisconnected()
640
- ]);
641
- const pool = /* @__PURE__ */ new Map();
642
- const dropped = [];
643
- pending.clear();
644
- const tasks = [];
645
- for (const [connectorId, entry] of Object.entries(storedPool)) {
646
- if (!entry) continue;
647
- const connector = createConnector(connectorId);
648
- if (!connector) {
649
- pending.set(connectorId, entry);
650
- continue;
651
- }
652
- tasks.push(restoreOneEntry(connectorId, entry, connector));
653
- }
654
- const outcomes = await Promise.all(tasks);
655
- for (const outcome of outcomes) {
656
- if (outcome.kind === "ok") {
657
- pool.set(outcome.connectorId, outcome.entry);
658
- continue;
659
- }
660
- dropped.push({
661
- connectorId: outcome.connectorId,
662
- reason: outcome.error
663
- });
664
- }
665
- const selection = /* @__PURE__ */ new Map();
666
- for (const [platform, connectorId] of Object.entries(storedSelection)) if (isChainPlatform$1(platform) && connectorId !== void 0 && pool.has(connectorId)) selection.set(platform, connectorId);
667
- for (const [connectorId, wallet] of pool) {
668
- const platform = wallet.connector.chainPlatform;
669
- if (!selection.has(platform)) selection.set(platform, connectorId);
670
- }
671
- let activeConnectorId = null;
672
- if (storedActive !== null && pool.has(storedActive)) activeConnectorId = storedActive;
673
- else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
674
- return {
675
- activeConnectorId,
676
- dropped,
677
- isUserDisconnected: userDisconnected,
678
- pendingIds: [...pending.keys()],
679
- pool,
680
- selection
681
- };
547
+ detachAll: () => {
548
+ for (const connectorId of live.keys()) detach(connectorId);
682
549
  },
683
- 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
+ }
684
554
  };
685
555
  };
686
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
+
687
568
  //#endregion
688
569
  //#region src/store/reducer.ts
689
570
  const initialState = {
690
571
  activeConnectorId: null,
572
+ adapters: [],
691
573
  connectingConnectorId: null,
692
574
  connectionError: null,
693
575
  connectionStatus: "idle",
576
+ dormant: /* @__PURE__ */ new Map(),
694
577
  isHydrated: false,
695
578
  isUserDisconnected: false,
696
579
  pool: /* @__PURE__ */ new Map(),
697
580
  reconnectingIds: /* @__PURE__ */ new Set(),
698
581
  selection: /* @__PURE__ */ new Map()
699
582
  };
700
- /** Find any connector in the pool serving a given platform. */
701
- const findConnectorForPlatform = (pool, chainPlatform) => {
702
- 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;
703
597
  };
704
- /** Pure reducer: every state transition is expressed as `(state, event) => state`.
705
- * No I/O, no async, no side effects. Side effects live in the runtime. */
706
- 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) => {
707
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
+ };
708
691
  case "HYDRATED": {
709
- const pool = new Map(state.pool);
710
- for (const [id, entry] of event.pool) pool.set(id, entry);
711
- for (const id of event.dropped) pool.delete(id);
712
- const selection = new Map(state.selection);
713
- for (const [platform, id] of event.selection) selection.set(platform, id);
714
- let activeConnectorId = null;
715
- if (event.activeConnectorId !== null && pool.has(event.activeConnectorId)) activeConnectorId = event.activeConnectorId;
716
- else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
717
- else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
718
- let nextReconnecting = state.reconnectingIds;
719
- if (state.reconnectingIds.size > 0) {
720
- const next = new Set(state.reconnectingIds);
721
- for (const id of event.pool.keys()) next.delete(id);
722
- for (const id of event.dropped) next.delete(id);
723
- if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
724
- }
725
- const selectionAfterDrop = new Map(selection);
726
- for (const [platform, id] of selectionAfterDrop) if (!pool.has(id)) {
727
- selectionAfterDrop.delete(platform);
728
- const fallback = findConnectorForPlatform(pool, platform);
729
- if (fallback !== void 0) selectionAfterDrop.set(platform, fallback);
730
- }
692
+ const preferred = event.activeConnectorId;
731
693
  return {
732
694
  ...state,
733
- activeConnectorId,
695
+ activeConnectorId: preferred !== null && state.pool.has(preferred) ? preferred : state.activeConnectorId,
734
696
  isHydrated: true,
735
- isUserDisconnected: event.isUserDisconnected,
736
- pool,
737
- reconnectingIds: nextReconnecting,
738
- selection: selectionAfterDrop
697
+ selection: applyStoredSelection(state.selection, event.selection, state.pool)
739
698
  };
740
699
  }
741
- case "USER_DISCONNECTED_SET": return {
742
- ...state,
743
- isUserDisconnected: event.value
744
- };
745
700
  case "CONNECT_STARTED": return {
746
701
  ...state,
747
702
  connectingConnectorId: event.connectorId,
748
703
  connectionError: null,
749
- connectionStatus: "connecting"
704
+ connectionStatus: "connecting",
705
+ isUserDisconnected: false
750
706
  };
751
707
  case "CONNECT_SUCCEEDED": {
752
708
  const { connectorId, entry } = event;
753
- const existing = state.pool.get(connectorId);
754
- const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
755
- if (existing && existing.account.walletAddress.toLowerCase() === entry.account.walletAddress.toLowerCase()) return {
756
- ...state,
757
- activeConnectorId: connectorId,
758
- connectingConnectorId: null,
759
- connectionError: null,
760
- connectionStatus: "success",
761
- pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
762
- reconnectingIds: nextReconnecting
763
- };
764
- const newPool = new Map([...state.pool, [connectorId, entry]]);
765
- const newSelection = new Map([...state.selection, [entry.connector.chainPlatform, connectorId]]);
709
+ const isCurrentAttempt = state.connectingConnectorId === connectorId;
766
710
  return {
767
711
  ...state,
768
712
  activeConnectorId: connectorId,
769
- connectingConnectorId: null,
770
- connectionError: null,
771
- connectionStatus: "success",
772
- pool: newPool,
773
- reconnectingIds: nextReconnecting,
774
- selection: newSelection
775
- };
776
- }
777
- case "ENTRY_RESTORED": {
778
- const { connectorId, entry } = event;
779
- const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
780
- const platform = entry.connector.chainPlatform;
781
- const selection = state.selection.has(platform) ? state.selection : new Map([...state.selection, [platform, connectorId]]);
782
- return {
783
- ...state,
784
- activeConnectorId: state.activeConnectorId ?? connectorId,
785
- pool: new Map([...state.pool, [connectorId, entry]]),
786
- reconnectingIds: nextReconnecting,
787
- selection
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)
788
721
  };
789
722
  }
790
723
  case "CONNECT_FAILED":
@@ -795,398 +728,424 @@ const reducer = (state, event) => {
795
728
  connectionError: event.error,
796
729
  connectionStatus: "error"
797
730
  };
798
- case "DISCONNECTED": {
799
- const wallet = state.pool.get(event.connectorId);
800
- if (!wallet) return state;
801
- const newPool = new Map(state.pool);
802
- newPool.delete(event.connectorId);
803
- const newSelection = new Map(state.selection);
804
- const platform = wallet.connector.chainPlatform;
805
- if (newSelection.get(platform) === event.connectorId) {
806
- newSelection.delete(platform);
807
- const fallback = findConnectorForPlatform(newPool, platform);
808
- if (fallback !== void 0) newSelection.set(platform, fallback);
809
- }
810
- const firstId = newPool.keys().next();
811
- const activeConnectorId = state.activeConnectorId === event.connectorId ? firstId.value ?? null : state.activeConnectorId;
812
- 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;
813
733
  return {
814
734
  ...state,
815
- activeConnectorId,
816
- pool: newPool,
817
- reconnectingIds: nextReconnecting,
818
- selection: newSelection
735
+ connectingConnectorId: null,
736
+ connectionError: null,
737
+ connectionStatus: "idle"
819
738
  };
820
- }
821
- 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
+ }
822
752
  const wallet = state.pool.get(event.connectorId);
823
- if (!wallet) return state;
824
- let nextAccount;
825
- if (event.active) nextAccount = event.active;
826
- else nextAccount = event.accounts.some((a) => a.walletAddress === wallet.account.walletAddress && a.chain.id === wallet.account.chain.id) ? wallet.account : event.accounts[0] ?? wallet.account;
827
- const sameAccount = nextAccount.walletAddress === wallet.account.walletAddress && nextAccount.chain.id === wallet.account.chain.id;
828
- const hasSameAccounts = event.accounts.length === wallet.accounts.length && event.accounts.every((a, i) => a.walletAddress === wallet.accounts[i]?.walletAddress);
829
- if (sameAccount && hasSameAccounts) return state;
830
- const updated = {
831
- ...wallet,
832
- account: nextAccount,
833
- accounts: [...event.accounts]
834
- };
835
- const newPool = new Map([...state.pool, [event.connectorId, updated]]);
753
+ if (wallet === void 0) return state;
754
+ const stored = toStoredEntry(event.connectorId, wallet);
836
755
  return {
837
756
  ...state,
838
- pool: newPool
757
+ dormant: withEntry(state.dormant, event.connectorId, stored),
758
+ pool
839
759
  };
840
760
  }
841
- case "WALLET_REFRESHED": {
842
- const wallet = state.pool.get(event.connectorId);
843
- if (!wallet) return state;
844
- 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;
845
769
  return {
846
770
  ...state,
847
- pool: newPool
771
+ activeConnectorId: event.connectorId
848
772
  };
849
- }
850
- case "ACTIVE_CHANGED": return {
851
- ...state,
852
- activeConnectorId: event.connectorId
853
- };
854
- case "SELECTION_CHANGED": {
855
- const newSelection = new Map(state.selection);
856
- if (event.connectorId === null) newSelection.delete(event.chainPlatform);
857
- 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;
858
775
  return {
859
776
  ...state,
860
- selection: newSelection
777
+ selection: withEntry(state.selection, event.chainPlatform, event.connectorId)
861
778
  };
862
- }
863
- case "STATUS_RESET": return {
864
- ...state,
865
- connectingConnectorId: null,
866
- connectionError: null,
867
- connectionStatus: "idle"
868
- };
869
- case "ERROR_SET": return {
870
- ...state,
871
- connectionError: event.error,
872
- connectionStatus: event.error ? "error" : "idle"
873
- };
874
- case "RESET": return {
875
- ...initialState,
876
- isHydrated: state.isHydrated,
877
- isUserDisconnected: true,
878
- reconnectingIds: /* @__PURE__ */ new Set()
879
- };
880
779
  default: return state;
881
780
  }
882
781
  };
883
-
884
- //#endregion
885
- //#region src/store/wallet-store-helpers.ts
886
- /** Run an async operation fire-and-forget, calling onError if it throws. */
887
- const run = async (fn, onError) => {
888
- try {
889
- await fn();
890
- } catch (error) {
891
- onError(toErrorCause(error instanceof Error ? error : String(error)));
892
- }
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);
893
786
  };
894
-
895
- //#endregion
896
- //#region src/store/wallet-store.ts
897
- const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
898
- const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
899
787
  /**
900
788
  * `isHydrated` is true from the first render on this path, so
901
- * `reconnectingIds` (not `isHydrated`) is the "is this connection
902
- * verified" signal until silent reconnect lands.
789
+ * `reconnectingIds` says whether a connection is verified. Seeded entries
790
+ * are also dormant: persisted, not yet live.
903
791
  */
904
- const seedStateFromSnapshot = (snapshot) => {
792
+ const stateFromSnapshot = (snapshot) => {
905
793
  const pool = /* @__PURE__ */ new Map();
906
- const reconnectingIds = /* @__PURE__ */ new Set();
907
- for (const [connectorId, entry] of Object.entries(snapshot.pool)) {
908
- if (!entry) continue;
909
- const connector = createShadowAdapter(entry);
794
+ const dormant = /* @__PURE__ */ new Map();
795
+ for (const [connectorId, entry] of Object.entries(snapshot.pool)) if (entry !== void 0) {
910
796
  pool.set(connectorId, {
911
797
  account: entry.account,
912
- accounts: [...entry.accounts],
913
- connector
798
+ accounts: entry.accounts,
799
+ connector: createShadowAdapter(entry)
914
800
  });
915
- reconnectingIds.add(connectorId);
801
+ dormant.set(connectorId, entry);
916
802
  }
917
- const selection = /* @__PURE__ */ new Map();
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;
920
- return {
803
+ return reconcile({
921
804
  ...initialState,
922
- activeConnectorId,
805
+ activeConnectorId: snapshot.activeConnectorId,
806
+ dormant,
923
807
  isHydrated: true,
924
808
  pool,
925
- reconnectingIds,
926
- selection
927
- };
809
+ reconnectingIds: new Set(pool.keys()),
810
+ selection: applyStoredSelection(/* @__PURE__ */ new Map(), snapshot.selection, pool)
811
+ });
928
812
  };
813
+
814
+ //#endregion
815
+ //#region src/store/wallet-manager.ts
929
816
  const CONNECT_TIMEOUT_MS = 9e4;
817
+ const RESTORE_TIMEOUT_MS = 15e3;
930
818
  const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
931
- const normaliseAddress = (addr) => addr.toLowerCase();
932
- const createWalletStore = (config) => {
933
- const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
934
- const reportStorageError = (context) => (error) => {
935
- 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;
936
891
  try {
937
- config.onStorageError(error, context);
938
- } catch (cbError) {
939
- logWarn("[butr] onStorageError threw:", cbError);
892
+ await storage.save(toPersistedState(getState()));
893
+ } catch (error) {
894
+ reportStorageError(error);
940
895
  }
941
- return;
942
896
  }
943
- logWarn(`[butr] ${context}:`, error);
897
+ flushing = false;
944
898
  };
945
- const hydration = createHydrationCoordinator(storage, config.createConnector);
946
- return createStore()((set, get) => {
947
- const dispatch = (event) => {
948
- set((prev) => reducer(prev, event), false);
949
- };
950
- const persistPool = () => run(() => storage.setPool(get().pool), reportStorageError("failed to persist pool"));
951
- const persistSelection = () => run(() => storage.setSelection(get().selection), reportStorageError("failed to persist selection"));
952
- const persistActive = () => run(() => storage.setActiveConnectorId(get().activeConnectorId), reportStorageError("failed to persist active connector"));
953
- 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) => {
954
904
  dispatch({
955
905
  accounts,
956
- active,
906
+ active: accounts[0],
957
907
  connectorId,
958
- type: "ACCOUNTS_REFRESHED"
908
+ type: "ACCOUNTS_CHANGED"
959
909
  });
960
- persistPool();
961
- };
962
- const lifecycle = createConnectorLifecycle({
963
- onAccountChanged: (connectorId, accounts, active) => {
964
- refreshPoolEntry(connectorId, [...accounts], active);
965
- },
966
- onDisconnected: (connectorId, chainPlatform) => {
967
- const wallet = get().pool.get(connectorId);
968
- if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
969
- dispatch({
970
- connectorId,
971
- type: "DISCONNECTED"
972
- });
973
- 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;
974
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"
975
988
  });
976
- return {
977
- ...config.initialState ? seedStateFromSnapshot(config.initialState) : initialState,
978
- _config: config,
979
- _storage: storage,
980
- connectWallet: async (connectorId, onSuccess, onError) => {
981
- get().setUserDisconnected(false);
982
- const connector = config.createConnector(connectorId);
983
- if (!connector) throw new Error(`Failed to create connector for ${connectorId}`);
984
- 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({
985
1053
  connectorId,
986
- type: "CONNECT_STARTED"
1054
+ entry: wallet,
1055
+ type: "CONNECT_SUCCEEDED"
987
1056
  });
988
- const slowThreshold = config.slowConnectThresholdMs ?? DEFAULT_SLOW_CONNECT_THRESHOLD_MS;
989
- const slowTimer = config.onSlowConnect ? setTimeout(() => {
990
- try {
991
- config.onSlowConnect?.(connectorId);
992
- } catch (cbError) {
993
- logWarn("[butr] onSlowConnect threw:", cbError);
994
- }
995
- }, slowThreshold) : null;
996
- let connectTimeoutId;
997
- try {
998
- const connectPromise = connector.connect();
999
- connectPromise.catch(() => {});
1000
- await Promise.race([connectPromise, new Promise((_, reject) => {
1001
- connectTimeoutId = setTimeout(() => {
1002
- reject(/* @__PURE__ */ new Error("Connection timeout"));
1003
- }, CONNECT_TIMEOUT_MS);
1004
- })]);
1005
- const account = await connector.getAccount();
1006
- if (!account) throw new Error("Failed to get account");
1007
- const fetchedAccounts = connector.getAccounts ? await connector.getAccounts().catch(() => null) : null;
1008
- const entry = {
1009
- account,
1010
- accounts: fetchedAccounts && fetchedAccounts.length > 0 ? fetchedAccounts : [account],
1011
- connector
1012
- };
1013
- dispatch({
1014
- connectorId,
1015
- entry,
1016
- type: "CONNECT_SUCCEEDED"
1017
- });
1018
- lifecycle.attach(connectorId, connector);
1019
- await Promise.all([
1020
- persistPool(),
1021
- persistSelection(),
1022
- persistActive()
1023
- ]);
1024
- config.onConnect?.(entry);
1025
- onSuccess?.(entry);
1026
- } catch (error) {
1027
- const normalised = mapConnectionError(error instanceof Error ? error : String(error));
1028
- dispatch({
1029
- connectorId,
1030
- error: normalised,
1031
- type: "CONNECT_FAILED"
1032
- });
1033
- try {
1034
- await connector.disconnect?.();
1035
- } catch (disconnectError) {
1036
- logWarn("[butr] disconnect during error recovery failed:", disconnectError);
1037
- }
1038
- try {
1039
- config.onConnectError?.(normalised, connectorId);
1040
- } catch (cbError) {
1041
- logWarn("[butr] onConnectError threw:", cbError);
1042
- }
1043
- onError?.(error instanceof Error ? error : new Error(String(error)));
1044
- throw error;
1045
- } finally {
1046
- clearTimeout(connectTimeoutId);
1047
- if (slowTimer) clearTimeout(slowTimer);
1048
- }
1049
- },
1050
- disconnectWallet: (connectorId) => {
1051
- get().setUserDisconnected(true);
1052
- const state = get();
1053
- if (!state.isHydrated) return;
1054
- const wallet = state.pool.get(connectorId);
1055
- if (!wallet) return;
1056
- lifecycle.detach(connectorId);
1057
- run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
1058
- const platform = wallet.connector.chainPlatform;
1057
+ safely("onConnect", config.onConnect, wallet, { reconnected: false });
1058
+ return wallet;
1059
+ } catch (error) {
1060
+ const failure = toConnectionError(error);
1059
1061
  dispatch({
1060
1062
  connectorId,
1061
- type: "DISCONNECTED"
1063
+ error: failure,
1064
+ type: "CONNECT_FAILED"
1062
1065
  });
1063
- if (get().pool.size === 0) run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
1064
- else {
1065
- run(() => storage.removePoolEntry(connectorId), reportStorageError("failed to remove pool entry"));
1066
- persistSelection();
1067
- persistActive();
1068
- }
1069
- config.onDisconnect?.(platform);
1070
- },
1071
- getConnectorInstance: (id) => config.createConnector(id),
1072
- hydrateWallets: async () => {
1073
- const result = await hydration.hydrate();
1074
- dispatch({
1075
- activeConnectorId: result.activeConnectorId,
1076
- dropped: result.dropped.map((d) => d.connectorId),
1077
- isUserDisconnected: result.isUserDisconnected,
1078
- pool: result.pool,
1079
- selection: result.selection,
1080
- type: "HYDRATED"
1081
- });
1082
- for (const [connectorId, wallet] of result.pool) lifecycle.attach(connectorId, wallet.connector);
1083
- await Promise.all([persistSelection(), persistActive()]);
1084
- for (const id of hydration.pendingIds()) run(() => get().tryRestoreFromPending(id), (e) => {
1085
- logWarn(`[butr] late restore rejected for ${id}:`, e);
1086
- });
1087
- if (config.onHydrated) try {
1088
- config.onHydrated({
1089
- dropped: result.dropped,
1090
- pendingIds: [...result.pendingIds],
1091
- restoredIds: [...result.pool.keys()]
1092
- });
1093
- } catch (cbError) {
1094
- 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);
1095
1142
  }
1096
- else if (result.dropped.length > 0) for (const { connectorId, reason } of result.dropped) logWarn(`[butr] failed to restore connector ${connectorId}:`, reason);
1097
- },
1098
- refreshWallet: (connectorId) => {
1099
- dispatch({
1100
- connectorId,
1101
- type: "WALLET_REFRESHED"
1102
- });
1103
- },
1104
- requestAccounts: async (connectorId) => {
1105
- const entry = get().pool.get(connectorId);
1106
- if (!entry) return;
1107
- await entry.connector.requestAccounts?.();
1108
- const fresh = entry.connector.getAccounts ? await entry.connector.getAccounts().catch(() => null) : null;
1109
- if (!fresh || fresh.length === 0) return;
1110
- refreshPoolEntry(connectorId, fresh);
1111
- },
1112
- reset: () => {
1113
- get().setUserDisconnected(true);
1114
- const state = get();
1115
- if (!state.isHydrated) return;
1143
+ unsubscribePool();
1116
1144
  lifecycle.detachAll();
1117
- for (const wallet of state.pool.values()) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
1118
- run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
1119
- if (config.onReset) {
1120
- const onReset = config.onReset;
1121
- run(() => Promise.resolve(onReset()), logError);
1122
- }
1123
- dispatch({ type: "RESET" });
1124
- },
1125
- resetConnectionStatus: () => {
1126
- dispatch({ type: "STATUS_RESET" });
1127
- },
1128
- setActiveConnector: (connectorId) => {
1129
- dispatch({
1130
- connectorId,
1131
- type: "ACTIVE_CHANGED"
1132
- });
1133
- persistActive();
1134
- },
1135
- setConnectionError: (error) => {
1136
- dispatch({
1137
- error,
1138
- type: "ERROR_SET"
1139
- });
1140
- },
1141
- setSelection: (chainPlatform, connectorId) => {
1142
- dispatch({
1143
- chainPlatform,
1144
- connectorId,
1145
- type: "SELECTION_CHANGED"
1146
- });
1147
- persistSelection();
1148
- },
1149
- setUserDisconnected: (value) => {
1150
- dispatch({
1151
- type: "USER_DISCONNECTED_SET",
1152
- value
1153
- });
1154
- run(() => storage.markUserDisconnected(value), reportStorageError("failed to persist disconnect intent"));
1155
- },
1156
- tryRestoreFromPending: async (connectorId) => {
1157
- const outcome = await hydration.drainPending(connectorId);
1158
- if (!outcome) return;
1159
- if (outcome.kind === "fail") {
1160
- logWarn(`[butr] late restore failed for ${connectorId}:`, outcome.error);
1161
- return;
1162
- }
1163
- dispatch({
1164
- connectorId,
1165
- entry: outcome.entry,
1166
- type: "ENTRY_RESTORED"
1167
- });
1168
- lifecycle.attach(connectorId, outcome.entry.connector);
1169
- await Promise.all([
1170
- persistPool(),
1171
- persistSelection(),
1172
- persistActive()
1173
- ]);
1174
- config.onConnect?.(outcome.entry);
1175
- },
1176
- updateWalletAccount: (connectorId, account) => {
1177
- const entry = get().pool.get(connectorId);
1178
- if (!entry) return;
1179
- const newChain = account.chain;
1180
- const remapped = entry.accounts.map((a) => a.chain.id === newChain.id ? a : {
1181
- chain: newChain,
1182
- id: `${newChain.id}:${normaliseAddress(a.walletAddress)}`,
1183
- walletAddress: a.walletAddress
1184
- });
1185
- const accounts = remapped.some((a) => normaliseAddress(a.walletAddress) === normaliseAddress(account.walletAddress)) ? remapped : [...remapped, account];
1186
- refreshPoolEntry(connectorId, accounts, account);
1187
- }
1188
- };
1189
- });
1145
+ };
1146
+ },
1147
+ subscribe: store.subscribe
1148
+ };
1190
1149
  };
1191
1150
 
1192
1151
  //#endregion
@@ -1303,7 +1262,7 @@ const readWalletSnapshot = (source, options = {}) => {
1303
1262
  if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
1304
1263
  else {
1305
1264
  const firstKey = Object.keys(pool)[0];
1306
- if (firstKey) activeConnectorId = firstKey;
1265
+ if (firstKey !== void 0 && firstKey !== "") activeConnectorId = firstKey;
1307
1266
  }
1308
1267
  return {
1309
1268
  activeConnectorId,
@@ -1437,7 +1396,7 @@ const base58ToBytes = (input) => {
1437
1396
  var SignInUnsupportedError = class extends Error {
1438
1397
  connectorId;
1439
1398
  constructor(connectorId) {
1440
- super(`Wallet "${connectorId}" reports capabilities.signMessage === false, so it cannot sign in.`);
1399
+ super(`Wallet "${connectorId}" cannot sign messages, so it cannot sign in.`);
1441
1400
  this.connectorId = connectorId;
1442
1401
  this.name = "SignInUnsupportedError";
1443
1402
  }
@@ -1453,12 +1412,11 @@ const createSignInFlow = (options) => {
1453
1412
  const sign = async (wallet, account) => {
1454
1413
  const { connector } = wallet;
1455
1414
  const signingAccount = account ?? wallet.account;
1456
- if (!connector.capabilities.signMessage) throw new SignInUnsupportedError(connector.id);
1457
- const nonce = await options.getNonce({
1458
- account: signingAccount,
1459
- wallet
1460
- });
1461
- 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
+ });
1462
1420
  const output = await connector.signIn({ nonce });
1463
1421
  return {
1464
1422
  account: output.account,
@@ -1470,12 +1428,17 @@ const createSignInFlow = (options) => {
1470
1428
  wallet
1471
1429
  };
1472
1430
  }
1431
+ if (connector.signMessage === void 0) throw new SignInUnsupportedError(connector.id);
1432
+ const nonce = await options.getNonce({
1433
+ account: signingAccount,
1434
+ wallet
1435
+ });
1473
1436
  const message = buildMessage({
1474
1437
  account: signingAccount,
1475
1438
  nonce,
1476
1439
  wallet
1477
1440
  });
1478
- const { signature, signedMessage } = await connector.signMessage(new TextEncoder().encode(message), signingAccount);
1441
+ const { signature, signedMessage } = await connector.signMessage(new TextEncoder().encode(message), { account: signingAccount });
1479
1442
  return {
1480
1443
  account: signingAccount,
1481
1444
  message,
@@ -1494,19 +1457,6 @@ const createSignInFlow = (options) => {
1494
1457
  } };
1495
1458
  };
1496
1459
 
1497
- //#endregion
1498
- //#region src/wallet-equal.ts
1499
- /**
1500
- * The adapter is compared by reference, not `connector.id`: hydration
1501
- * swaps a shadow adapter for the live one under an unchanged id, and an
1502
- * id check would strand consumers on the throwing placeholder.
1503
- */
1504
- const walletEqual = (a, b) => {
1505
- if (a === b) return true;
1506
- if (!a || !b) return false;
1507
- return a.connector === b.connector && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
1508
- };
1509
-
1510
1460
  //#endregion
1511
1461
  //#region src/sanitize-icon.ts
1512
1462
  /**
@@ -1521,5 +1471,5 @@ const sanitizeIcon = (icon) => {
1521
1471
  };
1522
1472
 
1523
1473
  //#endregion
1524
- 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 };
1525
1475
  //# sourceMappingURL=index.js.map