@usebutr/core 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +163 -575
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +196 -348
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
package/dist/index.js
CHANGED
|
@@ -3,11 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
//#region src/types/account.ts
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* must build accounts through this helper (or keep the format
|
|
10
|
-
* byte-identical).
|
|
6
|
+
* The reducer compares accounts by the composite `<chain>:<address>`
|
|
7
|
+
* id, so every adapter must build accounts here or reproduce that
|
|
8
|
+
* format byte for byte.
|
|
11
9
|
*/
|
|
12
10
|
const buildAccount = (address, chain) => ({
|
|
13
11
|
chain,
|
|
@@ -18,30 +16,9 @@ const buildAccount = (address, chain) => ({
|
|
|
18
16
|
//#endregion
|
|
19
17
|
//#region src/types/chains-by-platform.ts
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* Use this in apps that target one or two chain platforms; importing
|
|
25
|
-
* only those packages keeps unused chain registries out of the bundle.
|
|
26
|
-
* Apps that want every chain reach for `CHAINS_BY_PLATFORM` from
|
|
27
|
-
* `@usebutr/wallets` instead.
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* // EVM-only app: Solana/Sui/Bitcoin tables never enter the bundle
|
|
31
|
-
* import { EVM_CHAINS_LIST } from "@usebutr/evm";
|
|
32
|
-
* import { buildChainsByPlatform } from "@usebutr/core";
|
|
33
|
-
*
|
|
34
|
-
* const chains = buildChainsByPlatform({ evm: EVM_CHAINS_LIST });
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* // Multi-chain app: pull from each package the app actually uses
|
|
38
|
-
* import { EVM_CHAINS_LIST } from "@usebutr/evm";
|
|
39
|
-
* import { SVM_CHAINS_LIST } from "@usebutr/svm";
|
|
40
|
-
*
|
|
41
|
-
* const chains = buildChainsByPlatform({
|
|
42
|
-
* evm: EVM_CHAINS_LIST,
|
|
43
|
-
* svm: SVM_CHAINS_LIST,
|
|
44
|
-
* });
|
|
19
|
+
* Naming only the platforms an app targets keeps the other packages'
|
|
20
|
+
* chain registries out of its bundle. Apps that want all of them import
|
|
21
|
+
* `CHAINS_BY_PLATFORM` from `@usebutr/wallets`.
|
|
45
22
|
*/
|
|
46
23
|
const buildChainsByPlatform = (partial) => ({
|
|
47
24
|
bitcoin: partial.bitcoin ?? [],
|
|
@@ -54,17 +31,9 @@ const buildChainsByPlatform = (partial) => ({
|
|
|
54
31
|
//#endregion
|
|
55
32
|
//#region src/types/errors.ts
|
|
56
33
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* - butr's own `Error("Connection timeout")` (from the 90s connect timeout)
|
|
61
|
-
* - butr's own `Error("Failed to get account")` (from the connect flow)
|
|
62
|
-
* - EIP-1193 numeric `code` properties (`4001` → UserRejected,
|
|
63
|
-
* `-32002` → RequestPending, `4100`/`4900`/`4901` → NotConnected:
|
|
64
|
-
* unauthorized / disconnected from all-or-one chains)
|
|
65
|
-
* - common message substrings: "user rejected" / "user denied",
|
|
66
|
-
* "locked", "chain", etc.
|
|
67
|
-
* - anything else → `Unknown` with `cause` set to the original value.
|
|
34
|
+
* EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
|
|
35
|
+
* unauthorized or disconnected. Message-substring matching is the last
|
|
36
|
+
* resort for SDKs that ship no codes at all.
|
|
68
37
|
*/
|
|
69
38
|
const mapConnectionError = (raw) => {
|
|
70
39
|
if (raw instanceof Error) {
|
|
@@ -123,11 +92,9 @@ const mapConnectionError = (raw) => {
|
|
|
123
92
|
//#endregion
|
|
124
93
|
//#region src/types/platform.ts
|
|
125
94
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* never drift (a missing platform here was why Polkadot connections failed
|
|
130
|
-
* to persist).
|
|
95
|
+
* Single runtime source of truth: `ChainPlatform` and the storage
|
|
96
|
+
* validators' allowlists both derive from it. Omitting a platform here
|
|
97
|
+
* silently stops its connections from persisting.
|
|
131
98
|
*/
|
|
132
99
|
const CHAIN_PLATFORMS = [
|
|
133
100
|
"evm",
|
|
@@ -140,32 +107,17 @@ const CHAIN_PLATFORMS = [
|
|
|
140
107
|
//#endregion
|
|
141
108
|
//#region src/wallet-source.ts
|
|
142
109
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* so an EVM-only app can do
|
|
146
|
-
* `createWalletSource(discoverEvmAdapters)` without importing anything
|
|
147
|
-
* protocol-bearing beyond `@usebutr/evm`.
|
|
110
|
+
* Takes the exact shape of `discoverEvmAdapters` and friends, so a
|
|
111
|
+
* single-platform app keeps `@usebutr/wallets` out of its bundle.
|
|
148
112
|
*/
|
|
149
113
|
const createWalletSource = (subscribe) => ({ subscribe });
|
|
150
114
|
|
|
151
115
|
//#endregion
|
|
152
116
|
//#region src/store/shadow-adapter.ts
|
|
153
117
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* snapshot). Shadow adapters carry the identity and account data of a
|
|
158
|
-
* previously-connected wallet, but the live wallet extension hasn't
|
|
159
|
-
* been verified yet; the silent reconnect happens asynchronously
|
|
160
|
-
* after mount.
|
|
161
|
-
*
|
|
162
|
-
* UI code that gates affordances on `wallet.connector.capabilities.*`
|
|
163
|
-
* never reaches a shadow method (capabilities are all `false`). Code
|
|
164
|
-
* that calls through anyway hits this typed error, which is the
|
|
165
|
-
* correct loud failure: the consumer ignored the capability gate.
|
|
166
|
-
*
|
|
167
|
-
* Consumers wanting to wait out the reconnecting window should branch
|
|
168
|
-
* on whether `connectorId` is in `state.reconnectingIds`.
|
|
118
|
+
* Loud failure for consumers that called a wallet method without gating
|
|
119
|
+
* on `capabilities.*` (all `false` here) or `reconnectingIds`, before
|
|
120
|
+
* silent reconnect swapped in the live adapter.
|
|
169
121
|
*/
|
|
170
122
|
var ShadowConnectorError = class extends Error {
|
|
171
123
|
code = "BUTR_RECONNECTING";
|
|
@@ -191,26 +143,9 @@ const ALL_FALSE_CAPABILITIES = Object.freeze({
|
|
|
191
143
|
switchChain: false
|
|
192
144
|
});
|
|
193
145
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* is provided: each stored entry becomes a `ConnectedWallet` whose
|
|
198
|
-
* `connector` is one of these. The store flips `isHydrated` true
|
|
199
|
-
* synchronously and exposes the data through the usual hooks
|
|
200
|
-
* (`useActiveWallet`, `useConnectedWallets`, …), but the connector
|
|
201
|
-
* can't actually talk to a wallet yet, so its capabilities are all
|
|
202
|
-
* `false` and its methods throw `ShadowConnectorError` if called.
|
|
203
|
-
*
|
|
204
|
-
* The hydration coordinator's silent-reconnect pass upgrades each
|
|
205
|
-
* shadow to a live `WalletAdapter` by calling `createConnector(id)`
|
|
206
|
-
* (which only succeeds once the live adapter has been announced via
|
|
207
|
-
* discovery or registered explicitly) and replacing the pool entry.
|
|
208
|
-
* On success the connector id is removed from `reconnectingIds`; on
|
|
209
|
-
* failure the entry is dropped from the pool and storage.
|
|
210
|
-
*
|
|
211
|
-
* The entry's `name` is required (the storage validator rejects
|
|
212
|
-
* entries without it); `icon` is optional only because some live
|
|
213
|
-
* adapters genuinely have no icon to begin with.
|
|
146
|
+
* Lets `createWalletStore` flip `isHydrated` synchronously from
|
|
147
|
+
* `initialState`; the hydration coordinator later swaps each shadow for
|
|
148
|
+
* a live adapter, or drops the entry from pool and storage.
|
|
214
149
|
*/
|
|
215
150
|
const createShadowAdapter = (entry) => {
|
|
216
151
|
const id = entry.connectorId;
|
|
@@ -260,17 +195,9 @@ const createShadowAdapter = (entry) => {
|
|
|
260
195
|
}
|
|
261
196
|
};
|
|
262
197
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* for consumers writing wagmi-style "is this connection verified yet"
|
|
267
|
-
* checks without subscribing to `reconnectingIds` directly.
|
|
268
|
-
*
|
|
269
|
-
* Detection is structural: a shadow has all capabilities set to false.
|
|
270
|
-
* Live adapters always advertise at least one capability (every wallet
|
|
271
|
-
* surface includes `getBalance`, `signMessage`, `switchChain` as
|
|
272
|
-
* required methods, and adapter constructors set their flags
|
|
273
|
-
* accordingly).
|
|
198
|
+
* Structural detection: relies on every live adapter advertising at
|
|
199
|
+
* least one capability, since `getBalance`, `signMessage` and
|
|
200
|
+
* `switchChain` are required on all wallet surfaces.
|
|
274
201
|
*/
|
|
275
202
|
const isShadowAdapter = (adapter) => {
|
|
276
203
|
return Object.values(adapter.capabilities).every((flag) => !flag);
|
|
@@ -369,6 +296,64 @@ const parseStoredPoolEntry = (key, value) => {
|
|
|
369
296
|
if (!parsed.success || parsed.data.connectorId !== key) return null;
|
|
370
297
|
return parsed.data;
|
|
371
298
|
};
|
|
299
|
+
const DEFAULT_KEY_PREFIX = "butr";
|
|
300
|
+
/**
|
|
301
|
+
* `WalletStorage` writes these and `readWalletSnapshot` reads them from
|
|
302
|
+
* a cookie jar; any divergence desyncs the SSR-seeded render from the
|
|
303
|
+
* client that rehydrates it, the failure ADR 0003 exists to prevent.
|
|
304
|
+
*/
|
|
305
|
+
const storageKeys = (keyPrefix) => {
|
|
306
|
+
const prefix = keyPrefix === void 0 || keyPrefix === "" ? DEFAULT_KEY_PREFIX : keyPrefix;
|
|
307
|
+
return {
|
|
308
|
+
active: `${prefix}-active`,
|
|
309
|
+
pool: `${prefix}-pool`,
|
|
310
|
+
selection: `${prefix}-selection`,
|
|
311
|
+
userDisconnected: `${prefix}-user-disconnected`
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Never repairs or throws: one corrupt entry must not take down a whole
|
|
316
|
+
* session, and `readWalletSnapshot` may run on a server with no cookie
|
|
317
|
+
* jar to write the eviction to.
|
|
318
|
+
*/
|
|
319
|
+
const decodePool = (raw, label = "[butr]") => {
|
|
320
|
+
if (raw === null || raw === void 0 || raw === "") return {};
|
|
321
|
+
let value;
|
|
322
|
+
try {
|
|
323
|
+
value = JSON.parse(raw);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
logWarn(`${label} failed to parse pool from storage:`, error);
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
const parsed = recordSchema.safeParse(value);
|
|
329
|
+
if (!parsed.success) return {};
|
|
330
|
+
const result = {};
|
|
331
|
+
for (const [key, entryValue] of Object.entries(parsed.data)) {
|
|
332
|
+
const entry = parseStoredPoolEntry(key, entryValue);
|
|
333
|
+
if (entry === null) logWarn(`${label} dropping invalid pool entry for ${key}`);
|
|
334
|
+
else result[key] = entry;
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
};
|
|
338
|
+
/** Decode a persisted selection payload. Same contract as `decodePool`. */
|
|
339
|
+
const decodeSelection = (raw, label = "[butr]") => {
|
|
340
|
+
if (raw === null || raw === void 0 || raw === "") return {};
|
|
341
|
+
let value;
|
|
342
|
+
try {
|
|
343
|
+
value = JSON.parse(raw);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
logWarn(`${label} failed to parse selection from storage:`, error);
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
const parsed = recordSchema.safeParse(value);
|
|
349
|
+
if (!parsed.success) return {};
|
|
350
|
+
const result = {};
|
|
351
|
+
for (const [key, selectionValue] of Object.entries(parsed.data)) {
|
|
352
|
+
const platform = chainPlatformSchema.safeParse(key);
|
|
353
|
+
if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
};
|
|
372
357
|
|
|
373
358
|
//#endregion
|
|
374
359
|
//#region src/storage/wallet-storage.ts
|
|
@@ -380,19 +365,17 @@ var WalletStorage = class {
|
|
|
380
365
|
persistent;
|
|
381
366
|
session;
|
|
382
367
|
/**
|
|
383
|
-
* Serializes pool-
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* state, each merge their own entries, and whichever finishes last
|
|
387
|
-
* overwrites the other's additions. Reads (`getPool`) don't enter
|
|
388
|
-
* the queue; they observe whatever's currently in the driver.
|
|
368
|
+
* Serializes pool read-modify-writes; without it concurrent `setPool`
|
|
369
|
+
* calls drop each other's entries. Not reentrant, so a queued mutation
|
|
370
|
+
* must read via `readPool`: `getPool` re-enters and deadlocks it.
|
|
389
371
|
*/
|
|
390
372
|
poolMutationQueue = Promise.resolve();
|
|
391
373
|
constructor(config) {
|
|
392
|
-
|
|
393
|
-
this.
|
|
394
|
-
this.
|
|
395
|
-
this.
|
|
374
|
+
const keys = storageKeys(config.keyPrefix);
|
|
375
|
+
this.poolKey = keys.pool;
|
|
376
|
+
this.selectionKey = keys.selection;
|
|
377
|
+
this.activeKey = keys.active;
|
|
378
|
+
this.userDisconnectedKey = keys.userDisconnected;
|
|
396
379
|
if (config.persistent && config.session) {
|
|
397
380
|
this.persistent = config.persistent;
|
|
398
381
|
this.session = config.session;
|
|
@@ -418,43 +401,48 @@ var WalletStorage = class {
|
|
|
418
401
|
resolve();
|
|
419
402
|
}
|
|
420
403
|
}
|
|
421
|
-
|
|
404
|
+
/** Read and decode the pool without touching the mutation queue. Safe to
|
|
405
|
+
* call from inside a queued mutation, unlike `getPool`. */
|
|
406
|
+
async readPool() {
|
|
407
|
+
let stored;
|
|
422
408
|
try {
|
|
423
|
-
|
|
424
|
-
if (stored === null || stored === "") return {};
|
|
425
|
-
const value = JSON.parse(stored);
|
|
426
|
-
const parsed = recordSchema.safeParse(value);
|
|
427
|
-
if (!parsed.success) {
|
|
428
|
-
await this.clearPool();
|
|
429
|
-
return {};
|
|
430
|
-
}
|
|
431
|
-
const result = {};
|
|
432
|
-
for (const [key, entryValue] of Object.entries(parsed.data)) {
|
|
433
|
-
const entry = parseStoredPoolEntry(key, entryValue);
|
|
434
|
-
if (entry === null) logWarn(`[butr] dropping invalid pool entry for ${key}`);
|
|
435
|
-
else result[key] = entry;
|
|
436
|
-
}
|
|
437
|
-
return result;
|
|
409
|
+
stored = await this.persistent.getItem(this.poolKey);
|
|
438
410
|
} catch (error) {
|
|
439
|
-
logWarn("[butr] failed to
|
|
440
|
-
|
|
441
|
-
|
|
411
|
+
logWarn("[butr] failed to read pool from storage:", error);
|
|
412
|
+
return {
|
|
413
|
+
corrupt: false,
|
|
414
|
+
decoded: {}
|
|
415
|
+
};
|
|
442
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;
|
|
443
435
|
}
|
|
444
436
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* now", not "the complete list of remembered connections"; a
|
|
449
|
-
* silent reconnect that fails on reload leaves the entry out of
|
|
450
|
-
* the pool but the saved entry stays so the next load can retry.
|
|
451
|
-
* Use `removePoolEntry` for explicit eviction (the user clicked
|
|
452
|
-
* Disconnect) and `clearAll` for a full wipe (reset).
|
|
437
|
+
* Additive on purpose: a failed silent reconnect drops the entry from
|
|
438
|
+
* the live pool, and it must survive to be retried next load.
|
|
439
|
+
* Eviction goes through `removePoolEntry` or `clearAll`.
|
|
453
440
|
*/
|
|
454
441
|
async setPool(pool) {
|
|
455
442
|
await this.serializePoolMutation(async () => {
|
|
456
443
|
try {
|
|
457
|
-
const
|
|
444
|
+
const { decoded: existing } = await this.readPool();
|
|
445
|
+
const serializable = { ...existing };
|
|
458
446
|
for (const [connectorId, wallet] of pool) {
|
|
459
447
|
const entry = {
|
|
460
448
|
account: wallet.account,
|
|
@@ -476,7 +464,7 @@ var WalletStorage = class {
|
|
|
476
464
|
async removePoolEntry(connectorId) {
|
|
477
465
|
await this.serializePoolMutation(async () => {
|
|
478
466
|
try {
|
|
479
|
-
const stored = await this.
|
|
467
|
+
const { decoded: stored } = await this.readPool();
|
|
480
468
|
if (stored[connectorId]) {
|
|
481
469
|
const { [connectorId]: _, ...remaining } = stored;
|
|
482
470
|
await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
|
|
@@ -493,19 +481,9 @@ var WalletStorage = class {
|
|
|
493
481
|
}
|
|
494
482
|
async getSelection() {
|
|
495
483
|
try {
|
|
496
|
-
|
|
497
|
-
if (stored === null || stored === "") return {};
|
|
498
|
-
const value = JSON.parse(stored);
|
|
499
|
-
const parsed = recordSchema.safeParse(value);
|
|
500
|
-
if (!parsed.success) return {};
|
|
501
|
-
const result = {};
|
|
502
|
-
for (const [key, selectionValue] of Object.entries(parsed.data)) {
|
|
503
|
-
const platform = chainPlatformSchema.safeParse(key);
|
|
504
|
-
if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
|
|
505
|
-
}
|
|
506
|
-
return result;
|
|
484
|
+
return decodeSelection(await this.persistent.getItem(this.selectionKey));
|
|
507
485
|
} catch (error) {
|
|
508
|
-
logWarn("[butr] failed to
|
|
486
|
+
logWarn("[butr] failed to read selection from storage:", error);
|
|
509
487
|
return {};
|
|
510
488
|
}
|
|
511
489
|
}
|
|
@@ -541,12 +519,9 @@ var WalletStorage = class {
|
|
|
541
519
|
]);
|
|
542
520
|
}
|
|
543
521
|
/**
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
* but clears when the session ends (unlike the persistent driver).
|
|
548
|
-
* Prevents auto-connect from firing immediately after a manual disconnect,
|
|
549
|
-
* while still allowing auto-connect on fresh sessions.
|
|
522
|
+
* Kept in the session driver so it survives remounts (unlike a ref)
|
|
523
|
+
* yet clears at session end (unlike the persistent driver): a manual
|
|
524
|
+
* disconnect must suppress auto-connect now, not forever.
|
|
550
525
|
*/
|
|
551
526
|
async isUserDisconnected() {
|
|
552
527
|
try {
|
|
@@ -730,6 +705,7 @@ const reducer = (state, event) => {
|
|
|
730
705
|
case "HYDRATED": {
|
|
731
706
|
const pool = new Map(state.pool);
|
|
732
707
|
for (const [id, entry] of event.pool) pool.set(id, entry);
|
|
708
|
+
for (const id of event.dropped) pool.delete(id);
|
|
733
709
|
const selection = new Map(state.selection);
|
|
734
710
|
for (const [platform, id] of event.selection) selection.set(platform, id);
|
|
735
711
|
let activeConnectorId = null;
|
|
@@ -737,11 +713,18 @@ const reducer = (state, event) => {
|
|
|
737
713
|
else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
|
|
738
714
|
else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
|
|
739
715
|
let nextReconnecting = state.reconnectingIds;
|
|
740
|
-
if (state.reconnectingIds.size > 0
|
|
716
|
+
if (state.reconnectingIds.size > 0) {
|
|
741
717
|
const next = new Set(state.reconnectingIds);
|
|
742
718
|
for (const id of event.pool.keys()) next.delete(id);
|
|
719
|
+
for (const id of event.dropped) next.delete(id);
|
|
743
720
|
if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
|
|
744
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
|
+
}
|
|
745
728
|
return {
|
|
746
729
|
...state,
|
|
747
730
|
activeConnectorId,
|
|
@@ -749,7 +732,7 @@ const reducer = (state, event) => {
|
|
|
749
732
|
isUserDisconnected: event.isUserDisconnected,
|
|
750
733
|
pool,
|
|
751
734
|
reconnectingIds: nextReconnecting,
|
|
752
|
-
selection
|
|
735
|
+
selection: selectionAfterDrop
|
|
753
736
|
};
|
|
754
737
|
}
|
|
755
738
|
case "USER_DISCONNECTED_SET": return {
|
|
@@ -770,6 +753,7 @@ const reducer = (state, event) => {
|
|
|
770
753
|
...state,
|
|
771
754
|
activeConnectorId: connectorId,
|
|
772
755
|
connectingConnectorId: null,
|
|
756
|
+
connectionError: null,
|
|
773
757
|
connectionStatus: "success",
|
|
774
758
|
pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
|
|
775
759
|
reconnectingIds: nextReconnecting
|
|
@@ -780,18 +764,34 @@ const reducer = (state, event) => {
|
|
|
780
764
|
...state,
|
|
781
765
|
activeConnectorId: connectorId,
|
|
782
766
|
connectingConnectorId: null,
|
|
767
|
+
connectionError: null,
|
|
783
768
|
connectionStatus: "success",
|
|
784
769
|
pool: newPool,
|
|
785
770
|
reconnectingIds: nextReconnecting,
|
|
786
771
|
selection: newSelection
|
|
787
772
|
};
|
|
788
773
|
}
|
|
789
|
-
case "
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
774
|
+
case "ENTRY_RESTORED": {
|
|
775
|
+
const { connectorId, entry } = event;
|
|
776
|
+
const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
|
|
777
|
+
const platform = entry.connector.chainPlatform;
|
|
778
|
+
const selection = state.selection.has(platform) ? state.selection : new Map([...state.selection, [platform, connectorId]]);
|
|
779
|
+
return {
|
|
780
|
+
...state,
|
|
781
|
+
activeConnectorId: state.activeConnectorId ?? connectorId,
|
|
782
|
+
pool: new Map([...state.pool, [connectorId, entry]]),
|
|
783
|
+
reconnectingIds: nextReconnecting,
|
|
784
|
+
selection
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
case "CONNECT_FAILED":
|
|
788
|
+
if (state.connectingConnectorId !== event.connectorId) return state;
|
|
789
|
+
return {
|
|
790
|
+
...state,
|
|
791
|
+
connectingConnectorId: null,
|
|
792
|
+
connectionError: event.error,
|
|
793
|
+
connectionStatus: "error"
|
|
794
|
+
};
|
|
795
795
|
case "DISCONNECTED": {
|
|
796
796
|
const wallet = state.pool.get(event.connectorId);
|
|
797
797
|
if (!wallet) return state;
|
|
@@ -894,13 +894,9 @@ const run = async (fn, onError) => {
|
|
|
894
894
|
const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
|
|
895
895
|
const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
|
|
896
896
|
/**
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
* consumers can branch on "is this connection verified" without
|
|
901
|
-
* waiting for the async silent reconnect. `isHydrated` flips true
|
|
902
|
-
* synchronously: the consumer's first render sees the persisted
|
|
903
|
-
* state in the live store, not undefined.
|
|
897
|
+
* `isHydrated` is true from the first render on this path, so
|
|
898
|
+
* `reconnectingIds` (not `isHydrated`) is the "is this connection
|
|
899
|
+
* verified" signal until silent reconnect lands.
|
|
904
900
|
*/
|
|
905
901
|
const seedStateFromSnapshot = (snapshot) => {
|
|
906
902
|
const pool = /* @__PURE__ */ new Map();
|
|
@@ -931,8 +927,7 @@ const CONNECT_TIMEOUT_MS = 9e4;
|
|
|
931
927
|
const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
|
|
932
928
|
const normaliseAddress = (addr) => addr.toLowerCase();
|
|
933
929
|
const createWalletStore = (config) => {
|
|
934
|
-
const
|
|
935
|
-
const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
|
|
930
|
+
const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
|
|
936
931
|
const reportStorageError = (context) => (error) => {
|
|
937
932
|
if (config.onStorageError) {
|
|
938
933
|
try {
|
|
@@ -966,6 +961,8 @@ const createWalletStore = (config) => {
|
|
|
966
961
|
refreshPoolEntry(connectorId, [...accounts], active);
|
|
967
962
|
},
|
|
968
963
|
onDisconnected: (connectorId, chainPlatform) => {
|
|
964
|
+
const wallet = get().pool.get(connectorId);
|
|
965
|
+
if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
|
|
969
966
|
dispatch({
|
|
970
967
|
connectorId,
|
|
971
968
|
type: "DISCONNECTED"
|
|
@@ -1026,6 +1023,7 @@ const createWalletStore = (config) => {
|
|
|
1026
1023
|
} catch (error) {
|
|
1027
1024
|
const normalised = mapConnectionError(error);
|
|
1028
1025
|
dispatch({
|
|
1026
|
+
connectorId,
|
|
1029
1027
|
error: normalised,
|
|
1030
1028
|
type: "CONNECT_FAILED"
|
|
1031
1029
|
});
|
|
@@ -1072,6 +1070,7 @@ const createWalletStore = (config) => {
|
|
|
1072
1070
|
const result = await hydration.hydrate();
|
|
1073
1071
|
dispatch({
|
|
1074
1072
|
activeConnectorId: result.activeConnectorId,
|
|
1073
|
+
dropped: result.dropped.map((d) => d.connectorId),
|
|
1075
1074
|
isUserDisconnected: result.isUserDisconnected,
|
|
1076
1075
|
pool: result.pool,
|
|
1077
1076
|
selection: result.selection,
|
|
@@ -1161,7 +1160,7 @@ const createWalletStore = (config) => {
|
|
|
1161
1160
|
dispatch({
|
|
1162
1161
|
connectorId,
|
|
1163
1162
|
entry: outcome.entry,
|
|
1164
|
-
type: "
|
|
1163
|
+
type: "ENTRY_RESTORED"
|
|
1165
1164
|
});
|
|
1166
1165
|
lifecycle.attach(connectorId, outcome.entry.connector);
|
|
1167
1166
|
await Promise.all([
|
|
@@ -1228,28 +1227,9 @@ const toCookieMap$1 = (input) => {
|
|
|
1228
1227
|
return new Map(Object.entries(input));
|
|
1229
1228
|
};
|
|
1230
1229
|
/**
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* **When to use this:** SSR apps that need to know who's connected
|
|
1235
|
-
* during the server render (so they can stream the connected-wallet
|
|
1236
|
-
* UI without a client-side hydration flicker). Pass `initialCookies`
|
|
1237
|
-
* from a server-side cookie source (e.g. `cookies()` in Next.js'
|
|
1238
|
-
* `next/headers`, or a parsed `req.headers.cookie`) and the same
|
|
1239
|
-
* driver will serve those values during the server render and switch
|
|
1240
|
-
* to `document.cookie` once it mounts in the browser.
|
|
1241
|
-
*
|
|
1242
|
-
* **Trade-offs vs `localStorage`:** cookies travel with every
|
|
1243
|
-
* request, so they cost bytes on the wire. Keep the storage key
|
|
1244
|
-
* prefix short, and prefer this driver for the `persistent` slot
|
|
1245
|
-
* only: the `session` slot can stay in `sessionStorage` (which
|
|
1246
|
-
* cookies can't natively model anyway).
|
|
1247
|
-
*
|
|
1248
|
-
* **Server-side writes are no-ops.** Emitting `Set-Cookie` requires
|
|
1249
|
-
* access to the framework's response object, which a storage driver
|
|
1250
|
-
* shouldn't reach into. The store doesn't mutate persisted state
|
|
1251
|
-
* during the SSR pass anyway; writes only fire after client mount,
|
|
1252
|
-
* once `document.cookie` is reachable.
|
|
1230
|
+
* Server-side writes are no-ops: `Set-Cookie` needs the framework's
|
|
1231
|
+
* response object. Cookies ride every request, so use this driver for
|
|
1232
|
+
* the `persistent` slot only and leave `session` on `sessionStorage`.
|
|
1253
1233
|
*/
|
|
1254
1234
|
const createCookieStorageDriver = (options = {}) => {
|
|
1255
1235
|
const seeded = toCookieMap$1(options.initialCookies);
|
|
@@ -1302,74 +1282,18 @@ const toCookieMap = (input) => {
|
|
|
1302
1282
|
}
|
|
1303
1283
|
return out;
|
|
1304
1284
|
};
|
|
1305
|
-
const
|
|
1306
|
-
if (raw === void 0 || raw === "") return {};
|
|
1307
|
-
try {
|
|
1308
|
-
const value = JSON.parse(raw);
|
|
1309
|
-
const parsed = recordSchema.safeParse(value);
|
|
1310
|
-
if (!parsed.success) return {};
|
|
1311
|
-
const result = {};
|
|
1312
|
-
for (const [key, entryValue] of Object.entries(parsed.data)) {
|
|
1313
|
-
const entry = parseStoredPoolEntry(key, entryValue);
|
|
1314
|
-
if (entry === null) logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
|
|
1315
|
-
else result[key] = entry;
|
|
1316
|
-
}
|
|
1317
|
-
return result;
|
|
1318
|
-
} catch (error) {
|
|
1319
|
-
logWarn("[butr] readWalletSnapshot: failed to parse pool cookie:", error);
|
|
1320
|
-
return {};
|
|
1321
|
-
}
|
|
1322
|
-
};
|
|
1323
|
-
const parseSelection = (raw) => {
|
|
1324
|
-
if (raw === void 0 || raw === "") return {};
|
|
1325
|
-
try {
|
|
1326
|
-
const value = JSON.parse(raw);
|
|
1327
|
-
const parsed = recordSchema.safeParse(value);
|
|
1328
|
-
if (!parsed.success) return {};
|
|
1329
|
-
const result = {};
|
|
1330
|
-
for (const [key, selectionValue] of Object.entries(parsed.data)) {
|
|
1331
|
-
const platform = chainPlatformSchema.safeParse(key);
|
|
1332
|
-
if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
|
|
1333
|
-
}
|
|
1334
|
-
return result;
|
|
1335
|
-
} catch (error) {
|
|
1336
|
-
logWarn("[butr] readWalletSnapshot: failed to parse selection cookie:", error);
|
|
1337
|
-
return {};
|
|
1338
|
-
}
|
|
1339
|
-
};
|
|
1285
|
+
const SNAPSHOT_LABEL = "[butr] readWalletSnapshot:";
|
|
1340
1286
|
/**
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
* (Server Component, route handler, edge middleware, even client
|
|
1345
|
-
* code). Pair with `createCookieStorageDriver({ initialCookies })`
|
|
1346
|
-
* and `<WalletManagerProvider initialSnapshot={…} />` to render a
|
|
1347
|
-
* connected shell server-side without a hydration flash.
|
|
1348
|
-
*
|
|
1349
|
-
* **Stale-snapshot semantics.** The snapshot reflects whatever the
|
|
1350
|
-
* browser most recently persisted. If the user has since uninstalled
|
|
1351
|
-
* the wallet, switched accounts, or disconnected in another tab, the
|
|
1352
|
-
* client-side hydration will reconcile reality and the live store
|
|
1353
|
-
* will diverge from the snapshot. Treat the snapshot as an
|
|
1354
|
-
* *optimistic* shell; accurate enough to avoid a paint flicker,
|
|
1355
|
-
* authoritative only after `useIsHydrated()` is true.
|
|
1356
|
-
*
|
|
1357
|
-
* **Inputs.** Accepts the three shapes Next.js / Express / Hono /
|
|
1358
|
-
* generic-Node cookie code naturally produces:
|
|
1359
|
-
* - A plain object: `{ "butr-pool": "{...}", … }`
|
|
1360
|
-
* - An array of `{ name, value }` (Next.js' `cookies().getAll()`)
|
|
1361
|
-
* - An iterable of `[name, value]` tuples
|
|
1362
|
-
*
|
|
1363
|
-
* Malformed entries are dropped with a `logWarn` (same policy as
|
|
1364
|
-
* `WalletStorage.getPool`); a cross-tab corruption shouldn't crash
|
|
1365
|
-
* the server render.
|
|
1287
|
+
* Optimistic: only what the browser last persisted, so an uninstall or
|
|
1288
|
+
* other-tab disconnect makes it stale. Authoritative once the entry
|
|
1289
|
+
* leaves `reconnectingIds`; `isHydrated` is true from render one.
|
|
1366
1290
|
*/
|
|
1367
1291
|
const readWalletSnapshot = (source, options = {}) => {
|
|
1368
|
-
const
|
|
1292
|
+
const keys = storageKeys(options.keyPrefix);
|
|
1369
1293
|
const cookies = toCookieMap(source);
|
|
1370
|
-
const pool =
|
|
1371
|
-
const selection =
|
|
1372
|
-
const rawActive = cookies.get(
|
|
1294
|
+
const pool = decodePool(cookies.get(keys.pool), SNAPSHOT_LABEL);
|
|
1295
|
+
const selection = decodeSelection(cookies.get(keys.selection), SNAPSHOT_LABEL);
|
|
1296
|
+
const rawActive = cookies.get(keys.active);
|
|
1373
1297
|
let activeConnectorId = null;
|
|
1374
1298
|
if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
|
|
1375
1299
|
else {
|
|
@@ -1386,26 +1310,9 @@ const readWalletSnapshot = (source, options = {}) => {
|
|
|
1386
1310
|
//#endregion
|
|
1387
1311
|
//#region src/group-by-platform.ts
|
|
1388
1312
|
/**
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
* discovery hands back a flat list where a single brand appears several
|
|
1393
|
-
* times. Any app targeting more than one chain has to bucket that list
|
|
1394
|
-
* before rendering, and hand-rolled versions drift: butr's own demos
|
|
1395
|
-
* carried a copy that reimplemented `CHAIN_PLATFORMS` as a local
|
|
1396
|
-
* ordering constant.
|
|
1397
|
-
*
|
|
1398
|
-
* Keys are inserted in `CHAIN_PLATFORMS` order and platforms with no
|
|
1399
|
-
* members are omitted, so the result is directly iterable for rendering
|
|
1400
|
-
* (`[...groups]`) as well as addressable for lookup (`groups.get("evm")`)
|
|
1401
|
-
* without a further emptiness filter.
|
|
1402
|
-
*
|
|
1403
|
-
* `getPlatform` exists because the discriminant sits at a different depth
|
|
1404
|
-
* depending on the input: a discovered `WalletAdapter` carries
|
|
1405
|
-
* `chainPlatform` at the top level, a pool entry nests it under
|
|
1406
|
-
* `connector`. React consumers should reach for
|
|
1407
|
-
* `useDiscoveredWalletsByPlatform` / `useConnectedWalletsByPlatform`
|
|
1408
|
-
* instead, which bind the accessor for them.
|
|
1313
|
+
* A multi-chain wallet announces one adapter per platform, so brands
|
|
1314
|
+
* repeat in the flat list. Keys follow `CHAIN_PLATFORMS` order and empty
|
|
1315
|
+
* platforms are omitted, so `[...groups]` needs no emptiness filter.
|
|
1409
1316
|
*/
|
|
1410
1317
|
const groupByPlatform = (items, getPlatform) => {
|
|
1411
1318
|
const buckets = /* @__PURE__ */ new Map();
|
|
@@ -1426,20 +1333,9 @@ const groupByPlatform = (items, getPlatform) => {
|
|
|
1426
1333
|
//#endregion
|
|
1427
1334
|
//#region src/encoding/bytes.ts
|
|
1428
1335
|
/**
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
* chain. They used to be hand-reimplemented in ~10 connector files; a
|
|
1433
|
-
* single tested module removes the drift surface (a `padStart` omission
|
|
1434
|
-
* or base64 variant mismatch corrupts signatures/addresses for one chain
|
|
1435
|
-
* only, and the divergence is invisible because the copies look "the
|
|
1436
|
-
* same").
|
|
1437
|
-
*
|
|
1438
|
-
* Hex prefixing diverges across chains, so the module exposes **explicit
|
|
1439
|
-
* variants** rather than one function:
|
|
1440
|
-
* - {@link bytesToHex} returns bare hex (Bitcoin, Ledger).
|
|
1441
|
-
* - {@link bytesToHexPrefixed} returns `0x`-prefixed hex (EVM, Polkadot).
|
|
1442
|
-
* - {@link hexToBytes} tolerantly strips an optional `0x` (all callers).
|
|
1336
|
+
* Hex prefixing diverges by chain (bare for Bitcoin and Ledger, `0x` for
|
|
1337
|
+
* EVM and Polkadot), so the variants stay explicit: these sit on the
|
|
1338
|
+
* signing path, where a silent mismatch corrupts one chain's signatures.
|
|
1443
1339
|
*/
|
|
1444
1340
|
/** Bitcoin/Solana alphabet; omits the visually ambiguous `0`, `O`, `I`, `l`. */
|
|
1445
1341
|
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
@@ -1543,30 +1439,9 @@ var SignInUnsupportedError = class extends Error {
|
|
|
1543
1439
|
};
|
|
1544
1440
|
const defaultBuildMessage = ({ account, nonce }) => `${account.walletAddress} signs in.\nNonce: ${nonce}`;
|
|
1545
1441
|
/**
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
* Every wallet-auth app writes this same sequence, and the parts that
|
|
1550
|
-
* are easy to get wrong are the ones that aren't about the app: which
|
|
1551
|
-
* capability flag gates the attempt, whether a Solana wallet should take
|
|
1552
|
-
* the SIWS path instead, and which bytes to base64 for the server (the
|
|
1553
|
-
* wallet's `signedMessage`, not the input). This owns those; you own the
|
|
1554
|
-
* nonce endpoint, the message format, and the verifier.
|
|
1555
|
-
*
|
|
1556
|
-
* ```ts
|
|
1557
|
-
* const { signIn } = createSignInFlow({
|
|
1558
|
-
* getNonce: () => fetch("/api/nonce").then((r) => r.text()),
|
|
1559
|
-
* verify: (result) =>
|
|
1560
|
-
* fetch("/api/verify", { body: JSON.stringify(result), method: "POST" }).then(() => {}),
|
|
1561
|
-
* });
|
|
1562
|
-
*
|
|
1563
|
-
* await signIn(wallet);
|
|
1564
|
-
* ```
|
|
1565
|
-
*
|
|
1566
|
-
* Solana wallets advertising `solana:signIn` take the SIWS path
|
|
1567
|
-
* automatically, so `result.signedMessage` holds the wallet-composed
|
|
1568
|
-
* SIWS statement and `result.message` is absent. Set
|
|
1569
|
-
* `preferSignMessage` to opt out.
|
|
1442
|
+
* Solana wallets advertising `solana:signIn` take the SIWS path, so
|
|
1443
|
+
* `result.signedMessage` holds the wallet-composed statement and
|
|
1444
|
+
* `result.message` is absent. `preferSignMessage` opts out.
|
|
1570
1445
|
*/
|
|
1571
1446
|
const createSignInFlow = (options) => {
|
|
1572
1447
|
const buildMessage = options.buildMessage ?? defaultBuildMessage;
|
|
@@ -1617,21 +1492,9 @@ const createSignInFlow = (options) => {
|
|
|
1617
1492
|
//#endregion
|
|
1618
1493
|
//#region src/wallet-equal.ts
|
|
1619
1494
|
/**
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
* (active-wallet, selected-wallet, useWalletEntry) to suppress spurious
|
|
1624
|
-
* re-renders when the underlying Map churns but the resolved entry
|
|
1625
|
-
* hasn't changed.
|
|
1626
|
-
*
|
|
1627
|
-
* The adapter is compared by reference, not by `connector.id`: hydration
|
|
1628
|
-
* replaces a shadow adapter with the live one under an unchanged id and
|
|
1629
|
-
* address, so an id comparison reports "equal" and leaves consumers
|
|
1630
|
-
* holding a placeholder whose every method throws ShadowConnectorError.
|
|
1631
|
-
*
|
|
1632
|
-
* Hoisted to its own module so the equivalence rule lives in one place;
|
|
1633
|
-
* if we ever extend the snapshot (e.g. to consider `accounts.length`),
|
|
1634
|
-
* every selector hook picks up the new rule for free.
|
|
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.
|
|
1635
1498
|
*/
|
|
1636
1499
|
const walletEqual = (a, b) => {
|
|
1637
1500
|
if (a === b) return true;
|
|
@@ -1642,24 +1505,9 @@ const walletEqual = (a, b) => {
|
|
|
1642
1505
|
//#endregion
|
|
1643
1506
|
//#region src/sanitize-icon.ts
|
|
1644
1507
|
/**
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1648
|
-
* `providerInfo.icon`, Wallet Standard `wallet.icon`. That value is
|
|
1649
|
-
* not under butr's control, and some wallets ship data-URI icons with
|
|
1650
|
-
* surrounding whitespace (a newline left over from a pretty-printed
|
|
1651
|
-
* manifest). Strict consumers reject it: Next.js's `<Image>` throws
|
|
1652
|
-
* because `src` must not start with a control character.
|
|
1653
|
-
*
|
|
1654
|
-
* Trims surrounding whitespace and treats an all-whitespace (or empty)
|
|
1655
|
-
* icon as absent, so consumers get either a usable string or
|
|
1656
|
-
* `undefined`: never a blank or malformed one. `undefined` passes
|
|
1657
|
-
* through untouched.
|
|
1658
|
-
*
|
|
1659
|
-
* **Discovery already applies this.** Every adapter butr discovers has
|
|
1660
|
-
* had its icon sanitized at construction, so `Connector.icon` is safe to
|
|
1661
|
-
* render as-is. Reach for this helper only when building adapter or
|
|
1662
|
-
* `ConnectorMeta` metadata yourself from a source butr didn't produce.
|
|
1508
|
+
* Some wallets announce data-URI icons wrapped in whitespace, which
|
|
1509
|
+
* makes Next.js `<Image>` throw on a leading control character.
|
|
1510
|
+
* Discovery already applies this; only hand-built metadata needs it.
|
|
1663
1511
|
*/
|
|
1664
1512
|
const sanitizeIcon = (icon) => {
|
|
1665
1513
|
if (icon === void 0) return;
|