@usebutr/core 1.1.0 → 2.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/CHANGELOG.md +213 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.d.ts +201 -591
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +211 -358
- package/dist/index.js.map +1 -1
- package/package.json +20 -5
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 ?? [],
|
|
@@ -53,18 +30,12 @@ const buildChainsByPlatform = (partial) => ({
|
|
|
53
30
|
|
|
54
31
|
//#endregion
|
|
55
32
|
//#region src/types/errors.ts
|
|
33
|
+
const isCodedError = (error) => "code" in error;
|
|
34
|
+
const toErrorCause = (value) => value;
|
|
56
35
|
/**
|
|
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.
|
|
36
|
+
* EIP-1193 codes: `4001` rejected, `-32002` pending, `4100`/`4900`/`4901`
|
|
37
|
+
* unauthorized or disconnected. Message-substring matching is the last
|
|
38
|
+
* resort for SDKs that ship no codes at all.
|
|
68
39
|
*/
|
|
69
40
|
const mapConnectionError = (raw) => {
|
|
70
41
|
if (raw instanceof Error) {
|
|
@@ -78,7 +49,7 @@ const mapConnectionError = (raw) => {
|
|
|
78
49
|
kind: "NotConnected",
|
|
79
50
|
message
|
|
80
51
|
};
|
|
81
|
-
const code =
|
|
52
|
+
const code = isCodedError(raw) ? raw.code : void 0;
|
|
82
53
|
if (code === 4001) return {
|
|
83
54
|
kind: "UserRejected",
|
|
84
55
|
message
|
|
@@ -123,11 +94,9 @@ const mapConnectionError = (raw) => {
|
|
|
123
94
|
//#endregion
|
|
124
95
|
//#region src/types/platform.ts
|
|
125
96
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* never drift (a missing platform here was why Polkadot connections failed
|
|
130
|
-
* to persist).
|
|
97
|
+
* Single runtime source of truth: `ChainPlatform` and the storage
|
|
98
|
+
* validators' allowlists both derive from it. Omitting a platform here
|
|
99
|
+
* silently stops its connections from persisting.
|
|
131
100
|
*/
|
|
132
101
|
const CHAIN_PLATFORMS = [
|
|
133
102
|
"evm",
|
|
@@ -140,32 +109,17 @@ const CHAIN_PLATFORMS = [
|
|
|
140
109
|
//#endregion
|
|
141
110
|
//#region src/wallet-source.ts
|
|
142
111
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* so an EVM-only app can do
|
|
146
|
-
* `createWalletSource(discoverEvmAdapters)` without importing anything
|
|
147
|
-
* protocol-bearing beyond `@usebutr/evm`.
|
|
112
|
+
* Takes the exact shape of `discoverEvmAdapters` and friends, so a
|
|
113
|
+
* single-platform app keeps `@usebutr/wallets` out of its bundle.
|
|
148
114
|
*/
|
|
149
115
|
const createWalletSource = (subscribe) => ({ subscribe });
|
|
150
116
|
|
|
151
117
|
//#endregion
|
|
152
118
|
//#region src/store/shadow-adapter.ts
|
|
153
119
|
/**
|
|
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`.
|
|
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.
|
|
169
123
|
*/
|
|
170
124
|
var ShadowConnectorError = class extends Error {
|
|
171
125
|
code = "BUTR_RECONNECTING";
|
|
@@ -191,26 +145,9 @@ const ALL_FALSE_CAPABILITIES = Object.freeze({
|
|
|
191
145
|
switchChain: false
|
|
192
146
|
});
|
|
193
147
|
/**
|
|
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.
|
|
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.
|
|
214
151
|
*/
|
|
215
152
|
const createShadowAdapter = (entry) => {
|
|
216
153
|
const id = entry.connectorId;
|
|
@@ -256,21 +193,13 @@ const createShadowAdapter = (entry) => {
|
|
|
256
193
|
};
|
|
257
194
|
default:
|
|
258
195
|
entry.chainPlatform;
|
|
259
|
-
throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
|
|
196
|
+
throw new Error(`[butr] unknown chainPlatform: ${String(entry.chainPlatform)}`);
|
|
260
197
|
}
|
|
261
198
|
};
|
|
262
199
|
/**
|
|
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).
|
|
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.
|
|
274
203
|
*/
|
|
275
204
|
const isShadowAdapter = (adapter) => {
|
|
276
205
|
return Object.values(adapter.capabilities).every((flag) => !flag);
|
|
@@ -289,7 +218,7 @@ const logError = (...args) => {
|
|
|
289
218
|
//#region src/storage/browser-storage-driver.ts
|
|
290
219
|
const hasWebStorage = (kind) => {
|
|
291
220
|
try {
|
|
292
|
-
return
|
|
221
|
+
return kind === "localStorage" ? globalThis.localStorage !== void 0 : globalThis.sessionStorage !== void 0;
|
|
293
222
|
} catch {
|
|
294
223
|
return false;
|
|
295
224
|
}
|
|
@@ -369,6 +298,65 @@ const parseStoredPoolEntry = (key, value) => {
|
|
|
369
298
|
if (!parsed.success || parsed.data.connectorId !== key) return null;
|
|
370
299
|
return parsed.data;
|
|
371
300
|
};
|
|
301
|
+
const DEFAULT_KEY_PREFIX = "butr";
|
|
302
|
+
/**
|
|
303
|
+
* `WalletStorage` writes these and `readWalletSnapshot` reads them from
|
|
304
|
+
* a cookie jar; any divergence desyncs the SSR-seeded render from the
|
|
305
|
+
* client that rehydrates it, the failure ADR 0003 exists to prevent.
|
|
306
|
+
*/
|
|
307
|
+
const storageKeys = (keyPrefix) => {
|
|
308
|
+
const prefix = keyPrefix === void 0 || keyPrefix === "" ? DEFAULT_KEY_PREFIX : keyPrefix;
|
|
309
|
+
return {
|
|
310
|
+
active: `${prefix}-active`,
|
|
311
|
+
pool: `${prefix}-pool`,
|
|
312
|
+
selection: `${prefix}-selection`,
|
|
313
|
+
userDisconnected: `${prefix}-user-disconnected`
|
|
314
|
+
};
|
|
315
|
+
};
|
|
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]") => {
|
|
322
|
+
if (raw === null || raw === void 0 || raw === "") return {};
|
|
323
|
+
let value;
|
|
324
|
+
try {
|
|
325
|
+
value = JSON.parse(raw);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
logWarn(`${label} failed to parse pool from storage:`, error);
|
|
328
|
+
return {};
|
|
329
|
+
}
|
|
330
|
+
const parsed = recordSchema.safeParse(value);
|
|
331
|
+
if (!parsed.success) return {};
|
|
332
|
+
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;
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
};
|
|
341
|
+
/** Decode a persisted selection payload. Same contract as `decodePool`. */
|
|
342
|
+
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
|
+
const result = {};
|
|
354
|
+
for (const [key, selectionValue] of Object.entries(parsed.data)) {
|
|
355
|
+
const platform = chainPlatformSchema.safeParse(key);
|
|
356
|
+
if (platform.success && typeof selectionValue === "string" && selectionValue.length > 0) result[platform.data] = selectionValue;
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
};
|
|
372
360
|
|
|
373
361
|
//#endregion
|
|
374
362
|
//#region src/storage/wallet-storage.ts
|
|
@@ -380,19 +368,17 @@ var WalletStorage = class {
|
|
|
380
368
|
persistent;
|
|
381
369
|
session;
|
|
382
370
|
/**
|
|
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.
|
|
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.
|
|
389
374
|
*/
|
|
390
375
|
poolMutationQueue = Promise.resolve();
|
|
391
376
|
constructor(config) {
|
|
392
|
-
|
|
393
|
-
this.
|
|
394
|
-
this.
|
|
395
|
-
this.
|
|
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;
|
|
396
382
|
if (config.persistent && config.session) {
|
|
397
383
|
this.persistent = config.persistent;
|
|
398
384
|
this.session = config.session;
|
|
@@ -418,43 +404,48 @@ var WalletStorage = class {
|
|
|
418
404
|
resolve();
|
|
419
405
|
}
|
|
420
406
|
}
|
|
421
|
-
|
|
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;
|
|
422
411
|
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;
|
|
412
|
+
stored = await this.persistent.getItem(this.poolKey);
|
|
438
413
|
} catch (error) {
|
|
439
|
-
logWarn("[butr] failed to
|
|
440
|
-
|
|
441
|
-
|
|
414
|
+
logWarn("[butr] failed to read pool from storage:", error);
|
|
415
|
+
return {
|
|
416
|
+
corrupt: false,
|
|
417
|
+
decoded: {}
|
|
418
|
+
};
|
|
442
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;
|
|
443
438
|
}
|
|
444
439
|
/**
|
|
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).
|
|
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`.
|
|
453
443
|
*/
|
|
454
444
|
async setPool(pool) {
|
|
455
445
|
await this.serializePoolMutation(async () => {
|
|
456
446
|
try {
|
|
457
|
-
const
|
|
447
|
+
const { decoded: existing } = await this.readPool();
|
|
448
|
+
const serializable = { ...existing };
|
|
458
449
|
for (const [connectorId, wallet] of pool) {
|
|
459
450
|
const entry = {
|
|
460
451
|
account: wallet.account,
|
|
@@ -476,7 +467,7 @@ var WalletStorage = class {
|
|
|
476
467
|
async removePoolEntry(connectorId) {
|
|
477
468
|
await this.serializePoolMutation(async () => {
|
|
478
469
|
try {
|
|
479
|
-
const stored = await this.
|
|
470
|
+
const { decoded: stored } = await this.readPool();
|
|
480
471
|
if (stored[connectorId]) {
|
|
481
472
|
const { [connectorId]: _, ...remaining } = stored;
|
|
482
473
|
await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
|
|
@@ -493,19 +484,9 @@ var WalletStorage = class {
|
|
|
493
484
|
}
|
|
494
485
|
async getSelection() {
|
|
495
486
|
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;
|
|
487
|
+
return decodeSelection(await this.persistent.getItem(this.selectionKey));
|
|
507
488
|
} catch (error) {
|
|
508
|
-
logWarn("[butr] failed to
|
|
489
|
+
logWarn("[butr] failed to read selection from storage:", error);
|
|
509
490
|
return {};
|
|
510
491
|
}
|
|
511
492
|
}
|
|
@@ -541,12 +522,9 @@ var WalletStorage = class {
|
|
|
541
522
|
]);
|
|
542
523
|
}
|
|
543
524
|
/**
|
|
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.
|
|
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.
|
|
550
528
|
*/
|
|
551
529
|
async isUserDisconnected() {
|
|
552
530
|
try {
|
|
@@ -636,7 +614,7 @@ const restoreOneEntry = async (connectorId, entry, connector) => {
|
|
|
636
614
|
} catch (error) {
|
|
637
615
|
return {
|
|
638
616
|
connectorId,
|
|
639
|
-
error,
|
|
617
|
+
error: toErrorCause(error instanceof Error ? error : String(error)),
|
|
640
618
|
kind: "fail"
|
|
641
619
|
};
|
|
642
620
|
}
|
|
@@ -730,6 +708,7 @@ const reducer = (state, event) => {
|
|
|
730
708
|
case "HYDRATED": {
|
|
731
709
|
const pool = new Map(state.pool);
|
|
732
710
|
for (const [id, entry] of event.pool) pool.set(id, entry);
|
|
711
|
+
for (const id of event.dropped) pool.delete(id);
|
|
733
712
|
const selection = new Map(state.selection);
|
|
734
713
|
for (const [platform, id] of event.selection) selection.set(platform, id);
|
|
735
714
|
let activeConnectorId = null;
|
|
@@ -737,11 +716,18 @@ const reducer = (state, event) => {
|
|
|
737
716
|
else if (state.activeConnectorId !== null && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
|
|
738
717
|
else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
|
|
739
718
|
let nextReconnecting = state.reconnectingIds;
|
|
740
|
-
if (state.reconnectingIds.size > 0
|
|
719
|
+
if (state.reconnectingIds.size > 0) {
|
|
741
720
|
const next = new Set(state.reconnectingIds);
|
|
742
721
|
for (const id of event.pool.keys()) next.delete(id);
|
|
722
|
+
for (const id of event.dropped) next.delete(id);
|
|
743
723
|
if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
|
|
744
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
|
+
}
|
|
745
731
|
return {
|
|
746
732
|
...state,
|
|
747
733
|
activeConnectorId,
|
|
@@ -749,7 +735,7 @@ const reducer = (state, event) => {
|
|
|
749
735
|
isUserDisconnected: event.isUserDisconnected,
|
|
750
736
|
pool,
|
|
751
737
|
reconnectingIds: nextReconnecting,
|
|
752
|
-
selection
|
|
738
|
+
selection: selectionAfterDrop
|
|
753
739
|
};
|
|
754
740
|
}
|
|
755
741
|
case "USER_DISCONNECTED_SET": return {
|
|
@@ -770,6 +756,7 @@ const reducer = (state, event) => {
|
|
|
770
756
|
...state,
|
|
771
757
|
activeConnectorId: connectorId,
|
|
772
758
|
connectingConnectorId: null,
|
|
759
|
+
connectionError: null,
|
|
773
760
|
connectionStatus: "success",
|
|
774
761
|
pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
|
|
775
762
|
reconnectingIds: nextReconnecting
|
|
@@ -780,18 +767,34 @@ const reducer = (state, event) => {
|
|
|
780
767
|
...state,
|
|
781
768
|
activeConnectorId: connectorId,
|
|
782
769
|
connectingConnectorId: null,
|
|
770
|
+
connectionError: null,
|
|
783
771
|
connectionStatus: "success",
|
|
784
772
|
pool: newPool,
|
|
785
773
|
reconnectingIds: nextReconnecting,
|
|
786
774
|
selection: newSelection
|
|
787
775
|
};
|
|
788
776
|
}
|
|
789
|
-
case "
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
case "CONNECT_FAILED":
|
|
791
|
+
if (state.connectingConnectorId !== event.connectorId) return state;
|
|
792
|
+
return {
|
|
793
|
+
...state,
|
|
794
|
+
connectingConnectorId: null,
|
|
795
|
+
connectionError: event.error,
|
|
796
|
+
connectionStatus: "error"
|
|
797
|
+
};
|
|
795
798
|
case "DISCONNECTED": {
|
|
796
799
|
const wallet = state.pool.get(event.connectorId);
|
|
797
800
|
if (!wallet) return state;
|
|
@@ -822,8 +825,8 @@ const reducer = (state, event) => {
|
|
|
822
825
|
if (event.active) nextAccount = event.active;
|
|
823
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;
|
|
824
827
|
const sameAccount = nextAccount.walletAddress === wallet.account.walletAddress && nextAccount.chain.id === wallet.account.chain.id;
|
|
825
|
-
const
|
|
826
|
-
if (sameAccount &&
|
|
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;
|
|
827
830
|
const updated = {
|
|
828
831
|
...wallet,
|
|
829
832
|
account: nextAccount,
|
|
@@ -885,7 +888,7 @@ const run = async (fn, onError) => {
|
|
|
885
888
|
try {
|
|
886
889
|
await fn();
|
|
887
890
|
} catch (error) {
|
|
888
|
-
onError(error);
|
|
891
|
+
onError(toErrorCause(error instanceof Error ? error : String(error)));
|
|
889
892
|
}
|
|
890
893
|
};
|
|
891
894
|
|
|
@@ -894,13 +897,9 @@ const run = async (fn, onError) => {
|
|
|
894
897
|
const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
|
|
895
898
|
const isChainPlatform = (value) => VALID_CHAIN_PLATFORMS.has(value);
|
|
896
899
|
/**
|
|
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.
|
|
900
|
+
* `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.
|
|
904
903
|
*/
|
|
905
904
|
const seedStateFromSnapshot = (snapshot) => {
|
|
906
905
|
const pool = /* @__PURE__ */ new Map();
|
|
@@ -931,8 +930,7 @@ const CONNECT_TIMEOUT_MS = 9e4;
|
|
|
931
930
|
const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
|
|
932
931
|
const normaliseAddress = (addr) => addr.toLowerCase();
|
|
933
932
|
const createWalletStore = (config) => {
|
|
934
|
-
const
|
|
935
|
-
const storage = config.storage ?? new WalletStorage({ keyPrefix: storageKeyPrefix });
|
|
933
|
+
const storage = config.storage ?? new WalletStorage({ keyPrefix: config.storageKeyPrefix });
|
|
936
934
|
const reportStorageError = (context) => (error) => {
|
|
937
935
|
if (config.onStorageError) {
|
|
938
936
|
try {
|
|
@@ -966,6 +964,8 @@ const createWalletStore = (config) => {
|
|
|
966
964
|
refreshPoolEntry(connectorId, [...accounts], active);
|
|
967
965
|
},
|
|
968
966
|
onDisconnected: (connectorId, chainPlatform) => {
|
|
967
|
+
const wallet = get().pool.get(connectorId);
|
|
968
|
+
if (wallet) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
|
|
969
969
|
dispatch({
|
|
970
970
|
connectorId,
|
|
971
971
|
type: "DISCONNECTED"
|
|
@@ -1024,8 +1024,9 @@ const createWalletStore = (config) => {
|
|
|
1024
1024
|
config.onConnect?.(entry);
|
|
1025
1025
|
onSuccess?.(entry);
|
|
1026
1026
|
} catch (error) {
|
|
1027
|
-
const normalised = mapConnectionError(error);
|
|
1027
|
+
const normalised = mapConnectionError(error instanceof Error ? error : String(error));
|
|
1028
1028
|
dispatch({
|
|
1029
|
+
connectorId,
|
|
1029
1030
|
error: normalised,
|
|
1030
1031
|
type: "CONNECT_FAILED"
|
|
1031
1032
|
});
|
|
@@ -1072,6 +1073,7 @@ const createWalletStore = (config) => {
|
|
|
1072
1073
|
const result = await hydration.hydrate();
|
|
1073
1074
|
dispatch({
|
|
1074
1075
|
activeConnectorId: result.activeConnectorId,
|
|
1076
|
+
dropped: result.dropped.map((d) => d.connectorId),
|
|
1075
1077
|
isUserDisconnected: result.isUserDisconnected,
|
|
1076
1078
|
pool: result.pool,
|
|
1077
1079
|
selection: result.selection,
|
|
@@ -1161,7 +1163,7 @@ const createWalletStore = (config) => {
|
|
|
1161
1163
|
dispatch({
|
|
1162
1164
|
connectorId,
|
|
1163
1165
|
entry: outcome.entry,
|
|
1164
|
-
type: "
|
|
1166
|
+
type: "ENTRY_RESTORED"
|
|
1165
1167
|
});
|
|
1166
1168
|
lifecycle.attach(connectorId, outcome.entry.connector);
|
|
1167
1169
|
await Promise.all([
|
|
@@ -1228,28 +1230,9 @@ const toCookieMap$1 = (input) => {
|
|
|
1228
1230
|
return new Map(Object.entries(input));
|
|
1229
1231
|
};
|
|
1230
1232
|
/**
|
|
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.
|
|
1233
|
+
* Server-side writes are no-ops: `Set-Cookie` needs the framework's
|
|
1234
|
+
* response object. Cookies ride every request, so use this driver for
|
|
1235
|
+
* the `persistent` slot only and leave `session` on `sessionStorage`.
|
|
1253
1236
|
*/
|
|
1254
1237
|
const createCookieStorageDriver = (options = {}) => {
|
|
1255
1238
|
const seeded = toCookieMap$1(options.initialCookies);
|
|
@@ -1284,10 +1267,12 @@ const createCookieStorageDriver = (options = {}) => {
|
|
|
1284
1267
|
|
|
1285
1268
|
//#endregion
|
|
1286
1269
|
//#region src/storage/snapshot.ts
|
|
1270
|
+
const EMPTY_POOL = Object.freeze({});
|
|
1271
|
+
const EMPTY_SELECTION = Object.freeze({});
|
|
1287
1272
|
const EMPTY_SNAPSHOT = Object.freeze({
|
|
1288
1273
|
activeConnectorId: null,
|
|
1289
|
-
pool:
|
|
1290
|
-
selection:
|
|
1274
|
+
pool: EMPTY_POOL,
|
|
1275
|
+
selection: EMPTY_SELECTION
|
|
1291
1276
|
});
|
|
1292
1277
|
const toCookieMap = (input) => {
|
|
1293
1278
|
if (!(Symbol.iterator in input)) return new Map(Object.entries(input));
|
|
@@ -1302,74 +1287,18 @@ const toCookieMap = (input) => {
|
|
|
1302
1287
|
}
|
|
1303
1288
|
return out;
|
|
1304
1289
|
};
|
|
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
|
-
};
|
|
1290
|
+
const SNAPSHOT_LABEL = "[butr] readWalletSnapshot:";
|
|
1340
1291
|
/**
|
|
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.
|
|
1292
|
+
* Optimistic: only what the browser last persisted, so an uninstall or
|
|
1293
|
+
* other-tab disconnect makes it stale. Authoritative once the entry
|
|
1294
|
+
* leaves `reconnectingIds`; `isHydrated` is true from render one.
|
|
1366
1295
|
*/
|
|
1367
1296
|
const readWalletSnapshot = (source, options = {}) => {
|
|
1368
|
-
const
|
|
1297
|
+
const keys = storageKeys(options.keyPrefix);
|
|
1369
1298
|
const cookies = toCookieMap(source);
|
|
1370
|
-
const pool =
|
|
1371
|
-
const selection =
|
|
1372
|
-
const rawActive = cookies.get(
|
|
1299
|
+
const pool = decodePool(cookies.get(keys.pool), SNAPSHOT_LABEL);
|
|
1300
|
+
const selection = decodeSelection(cookies.get(keys.selection), SNAPSHOT_LABEL);
|
|
1301
|
+
const rawActive = cookies.get(keys.active);
|
|
1373
1302
|
let activeConnectorId = null;
|
|
1374
1303
|
if (rawActive !== void 0 && rawActive.length > 0 && pool[rawActive] !== void 0) activeConnectorId = rawActive;
|
|
1375
1304
|
else {
|
|
@@ -1386,26 +1315,9 @@ const readWalletSnapshot = (source, options = {}) => {
|
|
|
1386
1315
|
//#endregion
|
|
1387
1316
|
//#region src/group-by-platform.ts
|
|
1388
1317
|
/**
|
|
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.
|
|
1318
|
+
* A multi-chain wallet announces one adapter per platform, so brands
|
|
1319
|
+
* repeat in the flat list. Keys follow `CHAIN_PLATFORMS` order and empty
|
|
1320
|
+
* platforms are omitted, so `[...groups]` needs no emptiness filter.
|
|
1409
1321
|
*/
|
|
1410
1322
|
const groupByPlatform = (items, getPlatform) => {
|
|
1411
1323
|
const buckets = /* @__PURE__ */ new Map();
|
|
@@ -1426,20 +1338,9 @@ const groupByPlatform = (items, getPlatform) => {
|
|
|
1426
1338
|
//#endregion
|
|
1427
1339
|
//#region src/encoding/bytes.ts
|
|
1428
1340
|
/**
|
|
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).
|
|
1341
|
+
* Hex prefixing diverges by chain (bare for Bitcoin and Ledger, `0x` for
|
|
1342
|
+
* EVM and Polkadot), so the variants stay explicit: these sit on the
|
|
1343
|
+
* signing path, where a silent mismatch corrupts one chain's signatures.
|
|
1443
1344
|
*/
|
|
1444
1345
|
/** Bitcoin/Solana alphabet; omits the visually ambiguous `0`, `O`, `I`, `l`. */
|
|
1445
1346
|
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
@@ -1543,30 +1444,9 @@ var SignInUnsupportedError = class extends Error {
|
|
|
1543
1444
|
};
|
|
1544
1445
|
const defaultBuildMessage = ({ account, nonce }) => `${account.walletAddress} signs in.\nNonce: ${nonce}`;
|
|
1545
1446
|
/**
|
|
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.
|
|
1447
|
+
* Solana wallets advertising `solana:signIn` take the SIWS path, so
|
|
1448
|
+
* `result.signedMessage` holds the wallet-composed statement and
|
|
1449
|
+
* `result.message` is absent. `preferSignMessage` opts out.
|
|
1570
1450
|
*/
|
|
1571
1451
|
const createSignInFlow = (options) => {
|
|
1572
1452
|
const buildMessage = options.buildMessage ?? defaultBuildMessage;
|
|
@@ -1617,21 +1497,9 @@ const createSignInFlow = (options) => {
|
|
|
1617
1497
|
//#endregion
|
|
1618
1498
|
//#region src/wallet-equal.ts
|
|
1619
1499
|
/**
|
|
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.
|
|
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.
|
|
1635
1503
|
*/
|
|
1636
1504
|
const walletEqual = (a, b) => {
|
|
1637
1505
|
if (a === b) return true;
|
|
@@ -1642,24 +1510,9 @@ const walletEqual = (a, b) => {
|
|
|
1642
1510
|
//#endregion
|
|
1643
1511
|
//#region src/sanitize-icon.ts
|
|
1644
1512
|
/**
|
|
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.
|
|
1513
|
+
* Some wallets announce data-URI icons wrapped in whitespace, which
|
|
1514
|
+
* makes Next.js `<Image>` throw on a leading control character.
|
|
1515
|
+
* Discovery already applies this; only hand-built metadata needs it.
|
|
1663
1516
|
*/
|
|
1664
1517
|
const sanitizeIcon = (icon) => {
|
|
1665
1518
|
if (icon === void 0) return;
|