@qntx/wallet-engine 0.0.0-stage → 0.4.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.mjs ADDED
@@ -0,0 +1,3420 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
3
+ import { nostr } from "@qntx/wallet/nostr";
4
+ import { spark } from "@qntx/wallet/spark";
5
+ import { parseBackupHeader } from "@qntx/wallet";
6
+ import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
7
+ import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
8
+ import { makeZapRequest } from "@qntx/nostr/nips/nip57";
9
+ import { base64 } from "@scure/base";
10
+ //#region src/controller.ts
11
+ /**
12
+ * Controller base: a zustand vanilla store plus lifecycle. `update` applies a recipe that must
13
+ * return a NEW state object; returning the same reference is a no-op (no set, no notification).
14
+ * Persistence and migrations layer on later — this package carries only what its first user needs.
15
+ */
16
+ var Controller = class {
17
+ name;
18
+ store;
19
+ constructor(options) {
20
+ this.name = options.name;
21
+ this.store = createStore(options.defaultState);
22
+ }
23
+ getState() {
24
+ return this.store.getState();
25
+ }
26
+ update(recipe) {
27
+ this.store.setState((state) => recipe(state));
28
+ }
29
+ };
30
+ //#endregion
31
+ //#region src/events.ts
32
+ function createEventBus(logger) {
33
+ const listeners = /* @__PURE__ */ new Map();
34
+ return {
35
+ emit(type, payload) {
36
+ const set = listeners.get(type);
37
+ if (set === void 0) return;
38
+ for (const listener of set) try {
39
+ listener(payload);
40
+ } catch (error) {
41
+ logger.warn("event listener threw", error);
42
+ }
43
+ },
44
+ on(type, listener) {
45
+ let set = listeners.get(type);
46
+ if (set === void 0) {
47
+ set = /* @__PURE__ */ new Set();
48
+ listeners.set(type, set);
49
+ }
50
+ const stored = listener;
51
+ set.add(stored);
52
+ return () => {
53
+ set.delete(stored);
54
+ };
55
+ }
56
+ };
57
+ }
58
+ //#endregion
59
+ //#region src/errors.ts
60
+ /** Typed engine errors: store codes are `closed` and `integrity` (decrypt or bind failure). */
61
+ var EngineStoreError = class extends Error {
62
+ code;
63
+ constructor(code, message) {
64
+ super(message);
65
+ this.name = "EngineStoreError";
66
+ this.code = code;
67
+ }
68
+ };
69
+ //#endregion
70
+ //#region src/store/guard.ts
71
+ /** NUL is the composite-key/AAD separator — a name or key containing it collides. */
72
+ function checkName(kind, value) {
73
+ if (value.includes("\0")) throw new EngineStoreError("invalid-input", `${kind} must not contain NUL`);
74
+ }
75
+ //#endregion
76
+ //#region src/store/memory.ts
77
+ /** In-memory EngineStore: the Node/test implementation — same contract, no durability. */
78
+ function createMemoryEngineStore(backing) {
79
+ const collections = backing?.collections ?? /* @__PURE__ */ new Map();
80
+ let closed = false;
81
+ const guard = () => {
82
+ if (closed) throw new EngineStoreError("closed", "engine store is closed");
83
+ };
84
+ return {
85
+ collection(name) {
86
+ checkName("collection", name);
87
+ let table = collections.get(name);
88
+ if (table === void 0) {
89
+ table = /* @__PURE__ */ new Map();
90
+ collections.set(name, table);
91
+ }
92
+ return {
93
+ get: async (key) => {
94
+ guard();
95
+ checkName("key", key);
96
+ const value = table.get(key);
97
+ return value === void 0 ? void 0 : new Uint8Array(value);
98
+ },
99
+ put: async (key, bytes) => {
100
+ guard();
101
+ checkName("key", key);
102
+ table.set(key, new Uint8Array(bytes));
103
+ },
104
+ delete: async (key) => {
105
+ guard();
106
+ checkName("key", key);
107
+ table.delete(key);
108
+ },
109
+ list: async () => {
110
+ guard();
111
+ return [...table.keys()];
112
+ }
113
+ };
114
+ },
115
+ close: async () => {
116
+ closed = true;
117
+ collections.clear();
118
+ }
119
+ };
120
+ }
121
+ //#endregion
122
+ //#region src/accounts/errors.ts
123
+ var AccountTreeError = class extends Error {
124
+ category;
125
+ constructor(category, message, cause) {
126
+ super(message, { cause });
127
+ this.name = "AccountTreeError";
128
+ this.category = category;
129
+ }
130
+ };
131
+ //#endregion
132
+ //#region src/accounts/controller.ts
133
+ const EMPTY_TREE = {
134
+ wallets: {},
135
+ groups: {},
136
+ selected: null,
137
+ totals: {}
138
+ };
139
+ const TOTAL_KEY = (walletId, index) => `${walletId}:${index}`;
140
+ const withoutBinding = (g) => {
141
+ const { boundPubkey: _removed, ...rest } = g;
142
+ return rest;
143
+ };
144
+ const NAME_MAX$1 = 24;
145
+ const TOTALS_DEBOUNCE_MS = 1e3;
146
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
147
+ const own = (o, k) => Object.hasOwn(o, k);
148
+ const isNonNegativeInt$1 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
149
+ function validateName(v) {
150
+ if (v === void 0) return;
151
+ if (typeof v !== "string") return;
152
+ const name = v.trim();
153
+ return name.length > 0 && name.length <= NAME_MAX$1 ? name : void 0;
154
+ }
155
+ const KNOWN_GROUP_KEYS$1 = /* @__PURE__ */ new Set([
156
+ "walletId",
157
+ "index",
158
+ "name",
159
+ "hidden",
160
+ "boundPubkey",
161
+ "updatedAt"
162
+ ]);
163
+ const KNOWN_TOP_KEYS = /* @__PURE__ */ new Set([
164
+ "wallets",
165
+ "groups",
166
+ "selected",
167
+ "totals"
168
+ ]);
169
+ const KNOWN_WALLET_KEYS$1 = /* @__PURE__ */ new Set(["name", "updatedAt"]);
170
+ const KNOWN_SELECTED_KEYS = /* @__PURE__ */ new Set(["walletId", "groupIndex"]);
171
+ const PUBKEY_RE$1 = /^[0-9a-f]{64}$/;
172
+ /** Strict decode — any malformed shape or unknown key returns `undefined`. */
173
+ function decodeTree(bytes) {
174
+ try {
175
+ const text = new TextDecoder().decode(bytes);
176
+ const raw = JSON.parse(text);
177
+ if (!isPlainObject(raw)) return;
178
+ for (const k of Object.keys(raw)) if (!KNOWN_TOP_KEYS.has(k)) return;
179
+ const rawWallets = raw["wallets"];
180
+ const rawGroups = raw["groups"];
181
+ const rawSelected = raw["selected"];
182
+ const rawTotals = raw["totals"];
183
+ if (!isPlainObject(rawWallets) || !isPlainObject(rawGroups) || !isPlainObject(rawTotals)) return;
184
+ const wallets = {};
185
+ for (const [walletId, w] of Object.entries(rawWallets)) {
186
+ if (!isPlainObject(w) || walletId.length === 0) return;
187
+ for (const k of Object.keys(w)) if (!KNOWN_WALLET_KEYS$1.has(k)) return;
188
+ const name = validateName(w["name"]);
189
+ if (w["name"] !== void 0 && name === void 0) return;
190
+ const { updatedAt } = w;
191
+ if (!isNonNegativeInt$1(updatedAt)) return;
192
+ wallets[walletId] = {
193
+ updatedAt,
194
+ ...name === void 0 ? {} : { name }
195
+ };
196
+ }
197
+ const groups = {};
198
+ for (const [walletId, arr] of Object.entries(rawGroups)) {
199
+ if (!Array.isArray(arr)) return;
200
+ const seen = /* @__PURE__ */ new Set();
201
+ const list = [];
202
+ for (const item of arr) {
203
+ if (!isPlainObject(item)) return;
204
+ for (const k of Object.keys(item)) if (!KNOWN_GROUP_KEYS$1.has(k)) return;
205
+ if (item["walletId"] !== walletId || !isNonNegativeInt$1(item["index"])) return;
206
+ const { index } = item;
207
+ if (seen.has(index) || item["hidden"] !== true && item["hidden"] !== false) return;
208
+ seen.add(index);
209
+ const name = validateName(item["name"]);
210
+ if (item["name"] !== void 0 && name === void 0) return;
211
+ const { updatedAt } = item;
212
+ if (!isNonNegativeInt$1(updatedAt)) return;
213
+ const { boundPubkey } = item;
214
+ if (boundPubkey !== void 0 && (typeof boundPubkey !== "string" || !PUBKEY_RE$1.test(boundPubkey))) return;
215
+ list.push({
216
+ walletId,
217
+ index,
218
+ hidden: item["hidden"],
219
+ updatedAt,
220
+ ...name === void 0 ? {} : { name },
221
+ ...boundPubkey === void 0 ? {} : { boundPubkey }
222
+ });
223
+ }
224
+ list.sort((a, b) => a.index - b.index);
225
+ groups[walletId] = list;
226
+ }
227
+ let selected = null;
228
+ if (rawSelected !== void 0 && rawSelected !== null) {
229
+ if (!isPlainObject(rawSelected)) return;
230
+ for (const k of Object.keys(rawSelected)) if (!KNOWN_SELECTED_KEYS.has(k)) return;
231
+ const { walletId } = rawSelected;
232
+ const { groupIndex } = rawSelected;
233
+ if (typeof walletId !== "string" || walletId.length === 0 || !isNonNegativeInt$1(groupIndex)) return;
234
+ selected = {
235
+ walletId,
236
+ groupIndex
237
+ };
238
+ }
239
+ const totals = {};
240
+ for (const [k, v] of Object.entries(rawTotals)) {
241
+ if (!own(rawTotals, k) || !isNonNegativeInt$1(v)) return;
242
+ totals[k] = v;
243
+ }
244
+ return {
245
+ wallets,
246
+ groups,
247
+ selected,
248
+ totals
249
+ };
250
+ } catch {
251
+ return;
252
+ }
253
+ }
254
+ function encodeTree(tree) {
255
+ return new TextEncoder().encode(JSON.stringify(tree));
256
+ }
257
+ /**
258
+ * Declarative account tree: wallets come from the Wallet snapshot (reconciled on unlock and on
259
+ * every snapshot change while unlocked), groups/names/hidden/selected/totals persist to the engine
260
+ * store under `accounts/tree`. The controller drives `spark.setAccounts` with the selected group
261
+ * only — an unselected group needs no live client (incoming transfers are claimed whenever it is
262
+ * selected again). The active account's `balanceSats` feeds `totals`, debounced 1 s before
263
+ * persist.
264
+ */
265
+ var AccountTreeController = class extends Controller {
266
+ options;
267
+ col;
268
+ sparkUnsubscribe;
269
+ totalsTimer;
270
+ persistChain = Promise.resolve();
271
+ constructor(options) {
272
+ super({
273
+ name: "accounts",
274
+ defaultState: () => ({
275
+ status: "locked",
276
+ wallets: [],
277
+ groups: {},
278
+ selected: null,
279
+ totals: {}
280
+ })
281
+ });
282
+ this.options = options;
283
+ }
284
+ setStore(store) {
285
+ this.col = store === void 0 ? void 0 : store.collection("accounts");
286
+ }
287
+ /** Load persisted state, reconcile with the snapshot, start driving Spark. */
288
+ async init() {
289
+ const persisted = await this.load();
290
+ this.update(() => ({
291
+ status: "ready",
292
+ ...persistedToState(persisted)
293
+ }));
294
+ this.reconcile();
295
+ this.driveSpark(this.getState().selected);
296
+ this.sparkUnsubscribe = this.options.spark.store.subscribe((state) => {
297
+ this.onSparkState(state.accounts);
298
+ });
299
+ }
300
+ async destroy() {
301
+ this.sparkUnsubscribe?.();
302
+ this.sparkUnsubscribe = void 0;
303
+ await this.lock();
304
+ }
305
+ /** Locked: stop every Spark client, flush pending persists, then drop to the empty state. */
306
+ async lock() {
307
+ this.sparkUnsubscribe?.();
308
+ this.sparkUnsubscribe = void 0;
309
+ this.options.spark.setAccounts([]);
310
+ await this.flushTotals();
311
+ this.col = void 0;
312
+ this.update(() => ({
313
+ status: "locked",
314
+ wallets: [],
315
+ groups: {},
316
+ selected: null,
317
+ totals: {}
318
+ }));
319
+ await Promise.resolve();
320
+ }
321
+ /** Reconcile groups/names/selected with `wallet.getSnapshot().wallets`; persists on change. */
322
+ reconcile() {
323
+ if (this.getState().status !== "ready") return;
324
+ const live = this.options.wallet.getSnapshot().wallets.map((w) => w.walletId);
325
+ const liveSet = new Set(live);
326
+ const state = this.getState();
327
+ const entriesById = new Map(state.wallets.map((w) => [w.walletId, w]));
328
+ const wallets = live.map((walletId) => {
329
+ return entriesById.get(walletId) ?? {
330
+ walletId,
331
+ updatedAt: 0
332
+ };
333
+ });
334
+ const groups = {};
335
+ for (const walletId of live) {
336
+ const existing = state.groups[walletId] ?? [];
337
+ groups[walletId] = existing.length > 0 ? existing : [{
338
+ walletId,
339
+ index: 0,
340
+ hidden: false,
341
+ updatedAt: 0
342
+ }];
343
+ }
344
+ const totals = {};
345
+ for (const [k, v] of Object.entries(state.totals)) {
346
+ const walletId = k.slice(0, Math.max(0, k.lastIndexOf(":")));
347
+ if (liveSet.has(walletId)) totals[k] = v;
348
+ }
349
+ const selected = this.validSelection(groups, live, state.selected);
350
+ if (JSON.stringify(wallets) !== JSON.stringify(state.wallets) || JSON.stringify(groups) !== JSON.stringify(state.groups) || JSON.stringify(totals) !== JSON.stringify(state.totals) || JSON.stringify(selected) !== JSON.stringify(state.selected)) {
351
+ this.update((s) => ({
352
+ ...s,
353
+ wallets,
354
+ groups,
355
+ totals,
356
+ selected
357
+ }));
358
+ this.persist();
359
+ this.driveSpark(selected);
360
+ }
361
+ }
362
+ addGroup(walletId) {
363
+ this.mustWallet(walletId);
364
+ const groups = this.getState().groups[walletId] ?? [];
365
+ let index = 0;
366
+ for (const g of groups) index = Math.max(index, g.index + 1);
367
+ const updatedAt = this.options.now();
368
+ this.mutateGroups(walletId, (list) => [...list, {
369
+ walletId,
370
+ index,
371
+ hidden: false,
372
+ updatedAt
373
+ }]);
374
+ return index;
375
+ }
376
+ addGroups(walletId, indices) {
377
+ this.mustWallet(walletId);
378
+ this.mutateGroups(walletId, (list) => {
379
+ const have = new Set(list.map((g) => g.index));
380
+ const added = [];
381
+ const updatedAt = this.options.now();
382
+ for (const i of indices) if (Number.isSafeInteger(i) && i >= 0 && !have.has(i)) {
383
+ have.add(i);
384
+ added.push({
385
+ walletId,
386
+ index: i,
387
+ hidden: false,
388
+ updatedAt
389
+ });
390
+ }
391
+ return [...list, ...added].sort((a, b) => a.index - b.index);
392
+ });
393
+ }
394
+ renameWallet(walletId, name) {
395
+ this.mustWallet(walletId);
396
+ const trimmed = this.checkedName(name);
397
+ const updatedAt = this.options.now();
398
+ this.update((s) => ({
399
+ ...s,
400
+ wallets: s.wallets.map((w) => w.walletId === walletId ? {
401
+ walletId,
402
+ name: trimmed,
403
+ updatedAt
404
+ } : w)
405
+ }));
406
+ this.persist();
407
+ }
408
+ renameGroup(walletId, index, name) {
409
+ this.mustGroup(walletId, index);
410
+ const trimmed = this.checkedName(name);
411
+ const updatedAt = this.options.now();
412
+ this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
413
+ ...g,
414
+ name: trimmed,
415
+ updatedAt
416
+ } : g));
417
+ }
418
+ setHidden(walletId, index, hidden) {
419
+ const state = this.getState();
420
+ this.mustGroup(walletId, index);
421
+ const visible = this.visibleGroups(state).filter((g) => !(g.walletId === walletId && g.index === index));
422
+ if (hidden && visible.length === 0) throw new AccountTreeError("rejected", "cannot hide every account group");
423
+ const updatedAt = this.options.now();
424
+ this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
425
+ ...g,
426
+ hidden,
427
+ updatedAt
428
+ } : g));
429
+ const sel = state.selected;
430
+ if (sel !== null && sel.walletId === walletId && sel.groupIndex === index && hidden) this.selectFallback();
431
+ }
432
+ select(walletId, index) {
433
+ const state = this.getState();
434
+ const group = (state.groups[walletId] ?? []).find((g) => g.index === index);
435
+ if (group === void 0) throw new AccountTreeError("not-found", "account group does not exist");
436
+ if (group.hidden) throw new AccountTreeError("rejected", "cannot select a hidden account group");
437
+ if (state.selected?.walletId === walletId && state.selected.groupIndex === index) return;
438
+ this.update((s) => ({
439
+ ...s,
440
+ selected: {
441
+ walletId,
442
+ groupIndex: index
443
+ }
444
+ }));
445
+ this.persist();
446
+ this.driveSpark(this.getState().selected);
447
+ }
448
+ /**
449
+ * Binds a social identity to one group. The identity is unique across the whole tree — every
450
+ * group in every wallet carrying it loses the binding (each entry re-stamped).
451
+ */
452
+ bind(walletId, index, pubkey) {
453
+ this.mustGroup(walletId, index);
454
+ if (!PUBKEY_RE$1.test(pubkey)) throw new AccountTreeError("invalid-input", "boundPubkey must be 64 lowercase hex");
455
+ const updatedAt = this.options.now();
456
+ this.update((s) => ({
457
+ ...s,
458
+ groups: Object.fromEntries(Object.entries(s.groups).map(([wid, list]) => [wid, list.map((g) => {
459
+ if (wid === walletId && g.index === index) return {
460
+ ...g,
461
+ boundPubkey: pubkey,
462
+ updatedAt
463
+ };
464
+ if (g.boundPubkey === pubkey) return {
465
+ ...withoutBinding(g),
466
+ updatedAt
467
+ };
468
+ return g;
469
+ })]))
470
+ }));
471
+ this.persist();
472
+ }
473
+ unbind(walletId, index) {
474
+ this.mustGroup(walletId, index);
475
+ const updatedAt = this.options.now();
476
+ this.mutateGroups(walletId, (list) => list.map((g) => g.index === index ? {
477
+ ...withoutBinding(g),
478
+ updatedAt
479
+ } : g));
480
+ }
481
+ /**
482
+ * Which group currently holds a pubkey — merges can leave the same identity on groups in two
483
+ * wallets; the newest `updatedAt` wins, ties resolve by snapshot wallet order then lower index.
484
+ */
485
+ bindingOf(pubkey) {
486
+ const s = this.getState();
487
+ let best;
488
+ let walletPos = -1;
489
+ for (const w of s.wallets) {
490
+ walletPos += 1;
491
+ for (const g of s.groups[w.walletId] ?? []) {
492
+ if (g.boundPubkey !== pubkey) continue;
493
+ if (best === void 0 || g.updatedAt > best.updatedAt || g.updatedAt === best.updatedAt && (walletPos < best.walletPos || walletPos === best.walletPos && g.index < best.groupIndex)) best = {
494
+ walletId: w.walletId,
495
+ groupIndex: g.index,
496
+ updatedAt: g.updatedAt,
497
+ walletPos
498
+ };
499
+ }
500
+ }
501
+ return best === void 0 ? void 0 : {
502
+ walletId: best.walletId,
503
+ groupIndex: best.groupIndex
504
+ };
505
+ }
506
+ /** The wallet's metadata document — every group, hidden included. */
507
+ exportMeta(walletId) {
508
+ this.mustWallet(walletId);
509
+ const s = this.getState();
510
+ const w = s.wallets.find((x) => x.walletId === walletId);
511
+ const wallet = {
512
+ updatedAt: w?.updatedAt ?? 0,
513
+ ...w?.name === void 0 ? {} : { name: w.name }
514
+ };
515
+ const groups = [];
516
+ for (const g of s.groups[walletId] ?? []) groups.push({
517
+ index: g.index,
518
+ hidden: g.hidden,
519
+ updatedAt: g.updatedAt,
520
+ ...g.name === void 0 ? {} : { name: g.name },
521
+ ...g.boundPubkey === void 0 ? {} : { boundPubkey: g.boundPubkey }
522
+ });
523
+ return {
524
+ v: 1,
525
+ wallet,
526
+ groups
527
+ };
528
+ }
529
+ /**
530
+ * Entry-level last-writer-wins merge: a newer remote `updatedAt` replaces the local entry, a tie
531
+ * keeps local; remote-only groups are added. `selected`/hidden rules are re-validated — if the
532
+ * merge hides the selected group it is re-selected, and a merge may never leave every group
533
+ * hidden (the selected one stays visible and is re-stamped so the fix propagates).
534
+ */
535
+ mergeMeta(walletId, doc) {
536
+ this.mustWallet(walletId);
537
+ let changed = false;
538
+ let localNewer = false;
539
+ const s = this.getState();
540
+ const localWallet = s.wallets.find((w) => w.walletId === walletId);
541
+ const walletUpdatedAt = localWallet?.updatedAt ?? 0;
542
+ let wallet = localWallet ?? {
543
+ walletId,
544
+ updatedAt: 0
545
+ };
546
+ if (doc.wallet.updatedAt > walletUpdatedAt) {
547
+ wallet = {
548
+ walletId,
549
+ updatedAt: doc.wallet.updatedAt,
550
+ ...doc.wallet.name === void 0 ? {} : { name: doc.wallet.name }
551
+ };
552
+ changed = true;
553
+ } else if (walletUpdatedAt > doc.wallet.updatedAt) localNewer = true;
554
+ const localByIndex = new Map((s.groups[walletId] ?? []).map((g) => [g.index, g]));
555
+ const groups = [...s.groups[walletId] ?? []];
556
+ for (const remote of doc.groups) {
557
+ const local = localByIndex.get(remote.index);
558
+ if (local === void 0) {
559
+ groups.push({
560
+ walletId,
561
+ index: remote.index,
562
+ hidden: remote.hidden,
563
+ updatedAt: remote.updatedAt,
564
+ ...remote.name === void 0 ? {} : { name: remote.name },
565
+ ...remote.boundPubkey === void 0 ? {} : { boundPubkey: remote.boundPubkey }
566
+ });
567
+ changed = true;
568
+ continue;
569
+ }
570
+ if (remote.updatedAt > local.updatedAt) {
571
+ const replaced = {
572
+ walletId,
573
+ index: remote.index,
574
+ hidden: remote.hidden,
575
+ updatedAt: remote.updatedAt,
576
+ ...remote.name === void 0 ? {} : { name: remote.name },
577
+ ...remote.boundPubkey === void 0 ? {} : { boundPubkey: remote.boundPubkey }
578
+ };
579
+ const at = groups.findIndex((g) => g.index === remote.index);
580
+ groups[at] = replaced;
581
+ changed = true;
582
+ } else if (local.updatedAt > remote.updatedAt) localNewer = true;
583
+ }
584
+ for (const g of groups) if (!doc.groups.some((r) => r.index === g.index) && g.updatedAt > 0) localNewer = true;
585
+ groups.sort((a, b) => a.index - b.index);
586
+ const walletOrder = s.wallets.map((w) => w.walletId);
587
+ let selected = this.validSelection({
588
+ ...s.groups,
589
+ [walletId]: groups
590
+ }, walletOrder, s.selected);
591
+ if (!groups.some((g) => !g.hidden) && groups.length > 0) {
592
+ const keep = selected ?? {
593
+ walletId,
594
+ groupIndex: groups[0]?.index ?? 0
595
+ };
596
+ const now = this.options.now();
597
+ for (const [i, g] of groups.entries()) if (g.index === keep.groupIndex) groups[i] = {
598
+ ...g,
599
+ hidden: false,
600
+ updatedAt: now
601
+ };
602
+ selected = keep;
603
+ changed = true;
604
+ localNewer = true;
605
+ }
606
+ if (selected?.walletId !== s.selected?.walletId || selected?.groupIndex !== s.selected?.groupIndex) changed = true;
607
+ if (changed) {
608
+ this.update((st) => ({
609
+ ...st,
610
+ wallets: st.wallets.map((w) => w.walletId === walletId ? wallet : w),
611
+ groups: {
612
+ ...st.groups,
613
+ [walletId]: groups
614
+ },
615
+ selected
616
+ }));
617
+ this.persist();
618
+ this.driveSpark(selected);
619
+ }
620
+ return {
621
+ changed,
622
+ localNewer
623
+ };
624
+ }
625
+ /** Sequential inspect from 0 upward; index 0 always included; stop after 2 consecutive unused. */
626
+ async discoverSpark(walletId) {
627
+ this.mustWallet(walletId);
628
+ const found = [0];
629
+ let index = 0;
630
+ let unused = 0;
631
+ while (found.length < 65) {
632
+ const ref = {
633
+ walletId,
634
+ groupIndex: index,
635
+ network: this.options.sparkNetwork
636
+ };
637
+ const { used } = await this.options.spark.inspect(ref);
638
+ if (used) {
639
+ if (index !== 0) found.push(index);
640
+ unused = 0;
641
+ } else {
642
+ unused += 1;
643
+ if (index !== 0 && unused >= 2) break;
644
+ }
645
+ index += 1;
646
+ }
647
+ return found;
648
+ }
649
+ /** Refresh `totals` for every other visible group; per-group failures are logged and skipped. */
650
+ async refreshTotals() {
651
+ const state = this.getState();
652
+ const sel = state.selected;
653
+ const targets = this.visibleGroups(state).filter((g) => !(sel !== null && g.walletId === sel.walletId && g.index === sel.groupIndex));
654
+ let cursor = 0;
655
+ const worker = async () => {
656
+ while (cursor < targets.length) {
657
+ const group = targets[cursor];
658
+ cursor += 1;
659
+ if (group === void 0) return;
660
+ try {
661
+ const ref = {
662
+ walletId: group.walletId,
663
+ groupIndex: group.index,
664
+ network: this.options.sparkNetwork
665
+ };
666
+ const { balanceSats } = await this.options.spark.inspect(ref);
667
+ this.setTotal(group.walletId, group.index, balanceSats);
668
+ } catch (error) {
669
+ this.options.logger.warn(`refreshTotals failed for ${group.walletId}:${group.index}`, error);
670
+ }
671
+ }
672
+ };
673
+ await Promise.all([worker(), worker()]);
674
+ }
675
+ onSparkState(accounts) {
676
+ const sel = this.getState().selected;
677
+ if (sel === null) return;
678
+ const balance = accounts[`${sel.walletId}:${sel.groupIndex}:${this.options.sparkNetwork}`]?.balanceSats;
679
+ if (balance !== void 0) this.setTotal(sel.walletId, sel.groupIndex, balance);
680
+ }
681
+ setTotal(walletId, index, balanceSats) {
682
+ const k = TOTAL_KEY(walletId, index);
683
+ if (this.getState().totals[k] === balanceSats) return;
684
+ this.update((s) => ({
685
+ ...s,
686
+ totals: {
687
+ ...s.totals,
688
+ [k]: balanceSats
689
+ }
690
+ }));
691
+ this.scheduleTotalsPersist();
692
+ }
693
+ driveSpark(selected) {
694
+ this.options.spark.setAccounts(selected === null ? [] : [{
695
+ ...selected,
696
+ network: this.options.sparkNetwork
697
+ }]);
698
+ }
699
+ visibleGroups(state) {
700
+ const out = [];
701
+ for (const w of state.wallets) for (const g of state.groups[w.walletId] ?? []) if (!g.hidden) out.push(g);
702
+ return out;
703
+ }
704
+ validSelection(groups, walletOrder, current) {
705
+ if (current !== null) {
706
+ const g = (groups[current.walletId] ?? []).find((x) => x.index === current.groupIndex);
707
+ if (g !== void 0 && !g.hidden) return current;
708
+ }
709
+ for (const walletId of walletOrder) for (const g of groups[walletId] ?? []) if (!g.hidden) return {
710
+ walletId,
711
+ groupIndex: g.index
712
+ };
713
+ return null;
714
+ }
715
+ selectFallback() {
716
+ const state = this.getState();
717
+ const next = this.validSelection(state.groups, state.wallets.map((w) => w.walletId), null);
718
+ this.update((s) => ({
719
+ ...s,
720
+ selected: next
721
+ }));
722
+ this.persist();
723
+ this.driveSpark(next);
724
+ }
725
+ mutateGroups(walletId, fn) {
726
+ this.update((s) => ({
727
+ ...s,
728
+ groups: {
729
+ ...s.groups,
730
+ [walletId]: fn(s.groups[walletId] ?? [])
731
+ }
732
+ }));
733
+ this.persist();
734
+ }
735
+ mustWallet(walletId) {
736
+ if (this.getState().status !== "ready") throw new AccountTreeError("locked", "account tree is locked");
737
+ if (!this.getState().wallets.some((w) => w.walletId === walletId)) throw new AccountTreeError("not-found", "wallet does not exist");
738
+ }
739
+ mustGroup(walletId, index) {
740
+ this.mustWallet(walletId);
741
+ if (!(this.getState().groups[walletId] ?? []).some((g) => g.index === index)) throw new AccountTreeError("not-found", "account group does not exist");
742
+ }
743
+ checkedName(name) {
744
+ const trimmed = name.trim();
745
+ if (trimmed.length === 0 || trimmed.length > NAME_MAX$1) throw new AccountTreeError("invalid-input", "name must be 1-24 characters");
746
+ return trimmed;
747
+ }
748
+ async load() {
749
+ if (this.col === void 0) return EMPTY_TREE;
750
+ const bytes = await this.col.get("tree");
751
+ if (bytes === void 0) return EMPTY_TREE;
752
+ const tree = decodeTree(bytes);
753
+ if (tree === void 0) {
754
+ this.options.logger.warn("invalid persisted account tree — starting empty");
755
+ return EMPTY_TREE;
756
+ }
757
+ return tree;
758
+ }
759
+ /** Queues a store write behind the previous one — the last queued write always lands last. */
760
+ persist() {
761
+ const { col } = this;
762
+ if (col === void 0) return;
763
+ const previous = this.persistChain;
764
+ this.persistChain = (async () => {
765
+ try {
766
+ await previous;
767
+ const s = this.getState();
768
+ const tree = {
769
+ wallets: Object.fromEntries(s.wallets.map((w) => [w.walletId, {
770
+ updatedAt: w.updatedAt,
771
+ ...w.name === void 0 ? {} : { name: w.name }
772
+ }])),
773
+ groups: s.groups,
774
+ selected: s.selected,
775
+ totals: s.totals
776
+ };
777
+ await col.put("tree", encodeTree(tree));
778
+ } catch (error) {
779
+ this.options.logger.warn("account tree persist failed", error);
780
+ }
781
+ })();
782
+ }
783
+ scheduleTotalsPersist() {
784
+ if (this.totalsTimer !== void 0) return;
785
+ this.totalsTimer = setTimeout(() => {
786
+ this.totalsTimer = void 0;
787
+ this.persist();
788
+ }, TOTALS_DEBOUNCE_MS);
789
+ }
790
+ async flushTotals() {
791
+ if (this.totalsTimer !== void 0) {
792
+ clearTimeout(this.totalsTimer);
793
+ this.totalsTimer = void 0;
794
+ this.persist();
795
+ }
796
+ await this.persistChain;
797
+ }
798
+ };
799
+ function persistedToState(tree) {
800
+ const wallets = [];
801
+ for (const [walletId, w] of Object.entries(tree.wallets)) if (w.name === void 0) wallets.push({
802
+ walletId,
803
+ updatedAt: w.updatedAt
804
+ });
805
+ else wallets.push({
806
+ walletId,
807
+ name: w.name,
808
+ updatedAt: w.updatedAt
809
+ });
810
+ return {
811
+ wallets,
812
+ groups: tree.groups,
813
+ selected: tree.selected,
814
+ totals: tree.totals
815
+ };
816
+ }
817
+ //#endregion
818
+ //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_u64.js
819
+ const fromNumH = (n) => n / 2 ** 32 | 0;
820
+ const fromNumL = (n) => n >>> 0;
821
+ function setU64FromNum(view, byteOffset, n, isLE) {
822
+ const h = fromNumH(n);
823
+ const l = fromNumL(n);
824
+ view.setUint32(byteOffset, isLE ? l : h, isLE);
825
+ view.setUint32(byteOffset + 4, isLE ? h : l, isLE);
826
+ }
827
+ //#endregion
828
+ //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/utils.js
829
+ /**
830
+ * Checks if something is Uint8Array. Be careful: nodejs Buffer will return true.
831
+ * @param a - value to test
832
+ * @returns `true` when the value is a Uint8Array-compatible view.
833
+ * @example
834
+ * Check whether a value is a Uint8Array-compatible view.
835
+ * ```ts
836
+ * isBytes(new Uint8Array([1, 2, 3]));
837
+ * ```
838
+ */
839
+ function isBytes(a) {
840
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
841
+ }
842
+ const atitle = (title) => title ? `"${title}" ` : "";
843
+ /**
844
+ * Asserts something is a non-negative integer.
845
+ * @param n - number to validate
846
+ * @param title - label included in thrown errors
847
+ * @returns The validated number.
848
+ * @throws On wrong argument types. {@link TypeError}
849
+ * @throws On wrong argument ranges or values. {@link RangeError}
850
+ * @example
851
+ * Validate a non-negative integer option.
852
+ * ```ts
853
+ * anumber(32, 'length');
854
+ * ```
855
+ */
856
+ function anumber(n, title = "") {
857
+ if (typeof n !== "number") throw new TypeError(atitle(title) + "expected number, got " + typeof n);
858
+ if (!Number.isSafeInteger(n) || n < 0) throw new RangeError(atitle(title) + "expected integer >= 0, got " + n);
859
+ return n;
860
+ }
861
+ /**
862
+ * Asserts something is Uint8Array.
863
+ * @param value - value to validate
864
+ * @param length - optional exact length constraint
865
+ * @param title - label included in thrown errors
866
+ * @returns The validated byte array.
867
+ * @throws On wrong argument types. {@link TypeError}
868
+ * @throws On wrong argument ranges or values. {@link RangeError}
869
+ * @example
870
+ * Validate that a value is a byte array.
871
+ * ```ts
872
+ * abytes(new Uint8Array([1, 2, 3]));
873
+ * ```
874
+ */
875
+ function abytes(value, length, title = "") {
876
+ if (isBytes(value) && (length === void 0 || value.length === length)) return value;
877
+ if (length !== void 0) anumber(length, "length");
878
+ const bytes = isBytes(value);
879
+ const ofLen = length !== void 0 ? ` of length ${length}` : "";
880
+ const got = bytes ? `length=${value.length}` : `type=${typeof value}`;
881
+ const message = atitle(title) + "expected Uint8Array" + ofLen + ", got " + got;
882
+ if (!bytes) throw new TypeError(message);
883
+ throw new RangeError(message);
884
+ }
885
+ const aobject = (value, label) => {
886
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError((label === "object" ? "" : `"${label}" `) + "expected object, got type=" + typeof value);
887
+ };
888
+ const aopts = (value, label) => {
889
+ aobject(value, label);
890
+ const proto = Object.getPrototypeOf(value);
891
+ if (proto !== Object.prototype && proto !== null) throw new TypeError(`"${label}" expected plain object`);
892
+ if (Object.hasOwn(value, "__proto__")) throw new TypeError(`"${label}.__proto__" is not allowed`);
893
+ };
894
+ /**
895
+ * Asserts a hash instance has not been destroyed or finished.
896
+ * @param instance - hash instance to validate
897
+ * @param checkFinished - whether to reject finalized instances
898
+ * @throws If the hash instance has already been destroyed or finalized. {@link Error}
899
+ * @example
900
+ * Validate that a hash instance is still usable.
901
+ * ```ts
902
+ * import { aexists } from '@noble/hashes/utils.js';
903
+ * import { sha256 } from '@noble/hashes/sha2.js';
904
+ * const hash = sha256.create();
905
+ * aexists(hash);
906
+ * ```
907
+ */
908
+ function aexists(instance, checkFinished = true) {
909
+ if (instance.destroyed) throw new Error("hash was destroyed");
910
+ if (checkFinished && instance.finished) throw new Error("digest() was already called");
911
+ }
912
+ /**
913
+ * Asserts output is a sufficiently-sized byte array.
914
+ * @param out - destination buffer
915
+ * @param instance - hash instance providing output length
916
+ * Oversized buffers are allowed; downstream code only promises to fill the first `outputLen` bytes.
917
+ * @throws On wrong argument types. {@link TypeError}
918
+ * @throws On wrong argument ranges or values. {@link RangeError}
919
+ * @example
920
+ * Validate a caller-provided digest buffer.
921
+ * ```ts
922
+ * import { aoutput } from '@noble/hashes/utils.js';
923
+ * import { sha256 } from '@noble/hashes/sha2.js';
924
+ * const hash = sha256.create();
925
+ * aoutput(new Uint8Array(hash.outputLen), hash);
926
+ * ```
927
+ */
928
+ function aoutput(out, instance) {
929
+ abytes(out, void 0, "output");
930
+ const min = instance.outputLen;
931
+ if (!(out.length >= min)) throw new RangeError("\"output\" expected length >= " + min);
932
+ }
933
+ /**
934
+ * Zeroizes typed arrays in place. Warning: JS provides no guarantees.
935
+ * @param arrays - arrays to overwrite with zeros
936
+ * @example
937
+ * Zeroize sensitive buffers in place.
938
+ * ```ts
939
+ * clean(new Uint8Array([1, 2, 3]));
940
+ * ```
941
+ */
942
+ function clean(...arrays) {
943
+ for (let i = 0; i < arrays.length; i++) arrays[i].fill(0);
944
+ }
945
+ /**
946
+ * Creates a DataView for byte-level manipulation.
947
+ * @param arr - source typed array
948
+ * @returns DataView over the same buffer region.
949
+ * @example
950
+ * Create a DataView over an existing buffer.
951
+ * ```ts
952
+ * createView(new Uint8Array(4));
953
+ * ```
954
+ */
955
+ function createView(arr) {
956
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
957
+ }
958
+ /**
959
+ * Rotate-right operation for uint32 values.
960
+ * @param word - source word
961
+ * @param shift - shift amount in bits
962
+ * @returns Rotated word.
963
+ * @example
964
+ * Rotate a 32-bit word to the right.
965
+ * ```ts
966
+ * rotr(0x12345678, 8);
967
+ * ```
968
+ */
969
+ function rotr(word, shift) {
970
+ return word << 32 - shift | word >>> shift;
971
+ }
972
+ const hasHexBuiltin = /* @__PURE__ */ (() => typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function")();
973
+ const hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
974
+ /**
975
+ * Convert byte array to hex string.
976
+ * Uses the built-in function when available and assumes it matches the tested
977
+ * fallback semantics.
978
+ * @param bytes - bytes to encode
979
+ * @returns Lowercase hexadecimal string.
980
+ * @throws On wrong argument types. {@link TypeError}
981
+ * @example
982
+ * Convert bytes to lowercase hexadecimal.
983
+ * ```ts
984
+ * bytesToHex(Uint8Array.from([0xca, 0xfe, 0x01, 0x23])); // 'cafe0123'
985
+ * ```
986
+ */
987
+ function bytesToHex(bytes) {
988
+ abytes(bytes);
989
+ if (hasHexBuiltin) return bytes.toHex();
990
+ let hex = "";
991
+ for (let i = 0; i < bytes.length; i++) hex += hexes[bytes[i]];
992
+ return hex;
993
+ }
994
+ /**
995
+ * Converts string to bytes using UTF8 encoding.
996
+ * Built-in doesn't validate input to be string: we do the check.
997
+ * Non-ASCII details are delegated to the platform `TextEncoder`.
998
+ * @param str - string to encode
999
+ * @returns UTF-8 encoded bytes.
1000
+ * @throws On wrong argument types. {@link TypeError}
1001
+ * @example
1002
+ * Encode a string as UTF-8 bytes.
1003
+ * ```ts
1004
+ * utf8ToBytes('abc'); // Uint8Array.from([97, 98, 99])
1005
+ * ```
1006
+ */
1007
+ function utf8ToBytes(str) {
1008
+ if (typeof str !== "string") throw new TypeError("string expected");
1009
+ const encoded = new TextEncoder().encode(str);
1010
+ try {
1011
+ return new Uint8Array(encoded);
1012
+ } finally {
1013
+ clean(encoded);
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Merges default options and passed options.
1018
+ * @param defaults - base option object
1019
+ * @param opts - user overrides
1020
+ * @param title - label included in thrown override errors
1021
+ * @returns Fresh merged option object with a null prototype.
1022
+ * @throws On wrong argument types. {@link TypeError}
1023
+ * @example
1024
+ * Merge user overrides onto default options.
1025
+ * ```ts
1026
+ * checkOpts({ dkLen: 32 }, { asyncTick: 10 });
1027
+ * ```
1028
+ */
1029
+ function checkOpts(defaults, opts, title = "opts") {
1030
+ aopts(defaults, "defaults");
1031
+ if (opts !== void 0) aopts(opts, title);
1032
+ return Object.assign(Object.create(null), defaults, opts);
1033
+ }
1034
+ /**
1035
+ * Creates a callable hash function from a stateful class constructor.
1036
+ * @param hashCons - hash constructor or factory
1037
+ * @param info - optional metadata such as DER OID
1038
+ * @returns Frozen callable hash wrapper with `.create()`.
1039
+ * Wrapper construction eagerly calls `hashCons(undefined)` once to read
1040
+ * `outputLen` / `blockLen`, so constructor side effects happen at module
1041
+ * init time.
1042
+ * @throws On wrong argument types. {@link TypeError}
1043
+ * @example
1044
+ * Wrap a stateful hash constructor into a callable helper.
1045
+ * ```ts
1046
+ * import { createHasher } from '@noble/hashes/utils.js';
1047
+ * import { sha256 } from '@noble/hashes/sha2.js';
1048
+ * const wrapped = createHasher(sha256.create, { oid: sha256.oid });
1049
+ * wrapped(new Uint8Array([1]));
1050
+ * ```
1051
+ */
1052
+ function createHasher(hashCons, info = {}) {
1053
+ if (typeof hashCons !== "function") throw new TypeError("\"hashCons\" expected function, got type=" + typeof hashCons);
1054
+ info = checkOpts({}, info, "info");
1055
+ const hashC = (msg, opts) => hashCons(opts).update(msg).digest();
1056
+ const tmp = hashCons(void 0);
1057
+ hashC.outputLen = tmp.outputLen;
1058
+ hashC.blockLen = tmp.blockLen;
1059
+ hashC.canXOF = tmp.canXOF;
1060
+ hashC.create = (opts) => hashCons(opts);
1061
+ Object.assign(hashC, info);
1062
+ return Object.freeze(hashC);
1063
+ }
1064
+ /**
1065
+ * Creates OID metadata for NIST hashes with prefix `06 09 60 86 48 01 65 03 04 02`.
1066
+ * @param suffix - final OID byte for the selected hash.
1067
+ * The helper accepts any byte even though only the documented NIST hash
1068
+ * suffixes are meaningful downstream.
1069
+ * @returns Object containing the DER-encoded OID.
1070
+ * @example
1071
+ * Build OID metadata for a NIST hash.
1072
+ * ```ts
1073
+ * oidNist(0x01);
1074
+ * ```
1075
+ */
1076
+ const oidNist = (suffix) => ({ oid: Uint8Array.from([
1077
+ 6,
1078
+ 9,
1079
+ 96,
1080
+ 134,
1081
+ 72,
1082
+ 1,
1083
+ 101,
1084
+ 3,
1085
+ 4,
1086
+ 2,
1087
+ suffix
1088
+ ]) });
1089
+ //#endregion
1090
+ //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/_md.js
1091
+ /**
1092
+ * Internal Merkle-Damgard hash utils.
1093
+ * @module
1094
+ */
1095
+ /**
1096
+ * Shared 32-bit conditional boolean primitive reused by SHA-256, SHA-1, and MD5 `F`.
1097
+ * Returns bits from `b` when `a` is set, otherwise from `c`.
1098
+ * The XOR form is equivalent to MD5's `F(X,Y,Z) = XY v not(X)Z` because the masked terms never
1099
+ * set the same bit.
1100
+ * @param a - selector word
1101
+ * @param b - word chosen when selector bit is set
1102
+ * @param c - word chosen when selector bit is clear
1103
+ * @returns Mixed 32-bit word.
1104
+ * @example
1105
+ * Combine three words with the shared 32-bit choice primitive.
1106
+ * ```ts
1107
+ * Chi(0xffffffff, 0x12345678, 0x87654321);
1108
+ * ```
1109
+ */
1110
+ function Chi(a, b, c) {
1111
+ return a & b ^ ~a & c;
1112
+ }
1113
+ /**
1114
+ * Shared 32-bit majority primitive reused by SHA-256 and SHA-1.
1115
+ * Returns bits shared by at least two inputs.
1116
+ * @param a - first input word
1117
+ * @param b - second input word
1118
+ * @param c - third input word
1119
+ * @returns Mixed 32-bit word.
1120
+ * @example
1121
+ * Combine three words with the shared 32-bit majority primitive.
1122
+ * ```ts
1123
+ * Maj(0xffffffff, 0x12345678, 0x87654321);
1124
+ * ```
1125
+ */
1126
+ function Maj(a, b, c) {
1127
+ return a & b ^ a & c ^ b & c;
1128
+ }
1129
+ /**
1130
+ * Merkle-Damgard hash construction base class.
1131
+ * Could be used to create MD5, RIPEMD, SHA1, SHA2.
1132
+ * Accepts only byte-aligned `Uint8Array` input, even when the underlying spec describes bit
1133
+ * strings with partial-byte tails.
1134
+ * @param blockLen - internal block size in bytes
1135
+ * @param outputLen - digest size in bytes
1136
+ * @param padOffset - trailing length field size in bytes
1137
+ * @param isLE - whether length and state words are encoded in little-endian
1138
+ * @example
1139
+ * Use a concrete subclass to get the shared Merkle-Damgard update/digest flow.
1140
+ * ```ts
1141
+ * import { _SHA1 } from '@noble/hashes/legacy.js';
1142
+ * const hash = new _SHA1();
1143
+ * hash.update(new Uint8Array([97, 98, 99]));
1144
+ * hash.digest();
1145
+ * ```
1146
+ */
1147
+ var HashMD = class {
1148
+ blockLen;
1149
+ outputLen;
1150
+ canXOF = false;
1151
+ padOffset;
1152
+ isLE;
1153
+ buffer;
1154
+ view;
1155
+ finished = false;
1156
+ length = 0;
1157
+ pos = 0;
1158
+ destroyed = false;
1159
+ constructor(blockLen, outputLen, padOffset, isLE) {
1160
+ this.blockLen = blockLen;
1161
+ this.outputLen = outputLen;
1162
+ this.padOffset = padOffset;
1163
+ this.isLE = isLE;
1164
+ this.buffer = new Uint8Array(blockLen);
1165
+ this.view = createView(this.buffer);
1166
+ }
1167
+ update(data) {
1168
+ aexists(this);
1169
+ abytes(data);
1170
+ const { view, buffer, blockLen } = this;
1171
+ const len = data.length;
1172
+ let processed = false;
1173
+ for (let pos = 0; pos < len;) {
1174
+ const take = Math.min(blockLen - this.pos, len - pos);
1175
+ if (take === blockLen) {
1176
+ const dataView = createView(data);
1177
+ for (; blockLen <= len - pos; pos += blockLen) this.process(dataView, pos);
1178
+ processed = true;
1179
+ continue;
1180
+ }
1181
+ buffer.set(pos === 0 && take === len ? data : data.subarray(pos, pos + take), this.pos);
1182
+ this.pos += take;
1183
+ pos += take;
1184
+ if (this.pos === blockLen) {
1185
+ this.process(view, 0);
1186
+ this.pos = 0;
1187
+ processed = true;
1188
+ }
1189
+ }
1190
+ this.length += data.length;
1191
+ if (processed) this.roundClean();
1192
+ return this;
1193
+ }
1194
+ digestInto(out) {
1195
+ aexists(this);
1196
+ aoutput(out, this);
1197
+ this.finished = true;
1198
+ const { buffer, view, blockLen, isLE } = this;
1199
+ let { pos } = this;
1200
+ buffer[pos++] = 128;
1201
+ buffer.fill(0, pos);
1202
+ if (this.padOffset > blockLen - pos) {
1203
+ this.process(view, 0);
1204
+ buffer.fill(0);
1205
+ }
1206
+ setU64FromNum(view, blockLen - 8, this.length * 8, isLE);
1207
+ this.process(view, 0);
1208
+ this.roundClean();
1209
+ const oview = out === buffer ? view : createView(out);
1210
+ const len = this.outputLen;
1211
+ const outLen = len / 4;
1212
+ const state = this.get();
1213
+ if (len % 4 || outLen > state.length) throw new Error("invalid outputLen");
1214
+ for (let i = 0; i < outLen; i++) oview.setUint32(4 * i, state[i], isLE);
1215
+ }
1216
+ digest() {
1217
+ const { buffer, outputLen } = this;
1218
+ this.digestInto(buffer);
1219
+ const res = buffer.slice(0, outputLen);
1220
+ this.destroy();
1221
+ return res;
1222
+ }
1223
+ _cloneIntoMeta(to) {
1224
+ const { buffer, length, finished, destroyed, pos } = this;
1225
+ to.destroyed = destroyed;
1226
+ to.finished = finished;
1227
+ to.length = length;
1228
+ to.pos = pos;
1229
+ if (pos) to.buffer.set(buffer);
1230
+ return to;
1231
+ }
1232
+ clone() {
1233
+ return this._cloneInto();
1234
+ }
1235
+ };
1236
+ /**
1237
+ * Initial SHA-2 state: fractional parts of square roots of first 16 primes 2..53.
1238
+ * Check out `test/misc/sha2-gen-iv.js` for recomputation guide.
1239
+ */
1240
+ /** Initial SHA256 state from RFC 6234 §6.1: the first 32 bits of the fractional parts of the
1241
+ * square roots of the first eight prime numbers. Exported as a shared table; callers must treat
1242
+ * it as read-only because constructors copy words from it by index. */
1243
+ const SHA256_IV = /* @__PURE__ */ Uint32Array.from([
1244
+ 1779033703,
1245
+ 3144134277,
1246
+ 1013904242,
1247
+ 2773480762,
1248
+ 1359893119,
1249
+ 2600822924,
1250
+ 528734635,
1251
+ 1541459225
1252
+ ]);
1253
+ //#endregion
1254
+ //#region ../../node_modules/.bun/@noble+hashes@2.4.0/node_modules/@noble/hashes/sha2.js
1255
+ /**
1256
+ * SHA2 hash function. A.k.a. sha256, sha384, sha512, sha512_224, sha512_256.
1257
+ * SHA256 is the fastest hash implementable in JS, even faster than Blake3.
1258
+ * Check out {@link https://www.rfc-editor.org/rfc/rfc4634 | RFC 4634} and
1259
+ * {@link https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf | FIPS 180-4}.
1260
+ * @module
1261
+ */
1262
+ /**
1263
+ * SHA-224 / SHA-256 round constants from RFC 6234 §5.1: the first 32 bits
1264
+ * of the cube roots of the first 64 primes (2..311).
1265
+ */
1266
+ const SHA256_K = /* @__PURE__ */ Uint32Array.from([
1267
+ 1116352408,
1268
+ 1899447441,
1269
+ 3049323471,
1270
+ 3921009573,
1271
+ 961987163,
1272
+ 1508970993,
1273
+ 2453635748,
1274
+ 2870763221,
1275
+ 3624381080,
1276
+ 310598401,
1277
+ 607225278,
1278
+ 1426881987,
1279
+ 1925078388,
1280
+ 2162078206,
1281
+ 2614888103,
1282
+ 3248222580,
1283
+ 3835390401,
1284
+ 4022224774,
1285
+ 264347078,
1286
+ 604807628,
1287
+ 770255983,
1288
+ 1249150122,
1289
+ 1555081692,
1290
+ 1996064986,
1291
+ 2554220882,
1292
+ 2821834349,
1293
+ 2952996808,
1294
+ 3210313671,
1295
+ 3336571891,
1296
+ 3584528711,
1297
+ 113926993,
1298
+ 338241895,
1299
+ 666307205,
1300
+ 773529912,
1301
+ 1294757372,
1302
+ 1396182291,
1303
+ 1695183700,
1304
+ 1986661051,
1305
+ 2177026350,
1306
+ 2456956037,
1307
+ 2730485921,
1308
+ 2820302411,
1309
+ 3259730800,
1310
+ 3345764771,
1311
+ 3516065817,
1312
+ 3600352804,
1313
+ 4094571909,
1314
+ 275423344,
1315
+ 430227734,
1316
+ 506948616,
1317
+ 659060556,
1318
+ 883997877,
1319
+ 958139571,
1320
+ 1322822218,
1321
+ 1537002063,
1322
+ 1747873779,
1323
+ 1955562222,
1324
+ 2024104815,
1325
+ 2227730452,
1326
+ 2361852424,
1327
+ 2428436474,
1328
+ 2756734187,
1329
+ 3204031479,
1330
+ 3329325298
1331
+ ]);
1332
+ /** Reusable SHA-224 / SHA-256 message schedule buffer `W_t` from RFC 6234 §6.2 step 1. */
1333
+ const SHA256_W = /* @__PURE__ */ new Uint32Array(64);
1334
+ /** Internal SHA-224 / SHA-256 compression engine from RFC 6234 §6.2. */
1335
+ var SHA2_32B = class extends HashMD {
1336
+ A = 0;
1337
+ B = 0;
1338
+ C = 0;
1339
+ D = 0;
1340
+ E = 0;
1341
+ F = 0;
1342
+ G = 0;
1343
+ H = 0;
1344
+ constructor(outputLen, IV) {
1345
+ super(64, outputLen, 8, false);
1346
+ this.A = IV[0] | 0;
1347
+ this.B = IV[1] | 0;
1348
+ this.C = IV[2] | 0;
1349
+ this.D = IV[3] | 0;
1350
+ this.E = IV[4] | 0;
1351
+ this.F = IV[5] | 0;
1352
+ this.G = IV[6] | 0;
1353
+ this.H = IV[7] | 0;
1354
+ }
1355
+ get() {
1356
+ const { A, B, C, D, E, F, G, H } = this;
1357
+ return [
1358
+ A,
1359
+ B,
1360
+ C,
1361
+ D,
1362
+ E,
1363
+ F,
1364
+ G,
1365
+ H
1366
+ ];
1367
+ }
1368
+ set(A, B, C, D, E, F, G, H) {
1369
+ this.A = A | 0;
1370
+ this.B = B | 0;
1371
+ this.C = C | 0;
1372
+ this.D = D | 0;
1373
+ this.E = E | 0;
1374
+ this.F = F | 0;
1375
+ this.G = G | 0;
1376
+ this.H = H | 0;
1377
+ }
1378
+ _cloneInto(to) {
1379
+ (to ||= new this.constructor()).set(...this.get());
1380
+ return this._cloneIntoMeta(to);
1381
+ }
1382
+ process(view, offset) {
1383
+ for (let i = 0; i < 16; i++, offset += 4) SHA256_W[i] = view.getUint32(offset, false);
1384
+ for (let i = 16; i < 64; i++) {
1385
+ const W15 = SHA256_W[i - 15];
1386
+ const W2 = SHA256_W[i - 2];
1387
+ const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3;
1388
+ const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10;
1389
+ SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0;
1390
+ }
1391
+ let { A, B, C, D, E, F, G, H } = this;
1392
+ for (let i = 0; i < 64; i++) {
1393
+ const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
1394
+ const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0;
1395
+ const T2 = (rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22)) + Maj(A, B, C) | 0;
1396
+ H = G;
1397
+ G = F;
1398
+ F = E;
1399
+ E = D + T1 | 0;
1400
+ D = C;
1401
+ C = B;
1402
+ B = A;
1403
+ A = T1 + T2 | 0;
1404
+ }
1405
+ A = A + this.A | 0;
1406
+ B = B + this.B | 0;
1407
+ C = C + this.C | 0;
1408
+ D = D + this.D | 0;
1409
+ E = E + this.E | 0;
1410
+ F = F + this.F | 0;
1411
+ G = G + this.G | 0;
1412
+ H = H + this.H | 0;
1413
+ this.set(A, B, C, D, E, F, G, H);
1414
+ }
1415
+ roundClean() {
1416
+ clean(SHA256_W);
1417
+ }
1418
+ destroy() {
1419
+ this.destroyed = true;
1420
+ this.set(0, 0, 0, 0, 0, 0, 0, 0);
1421
+ clean(this.buffer);
1422
+ }
1423
+ };
1424
+ /** Internal SHA-256 hash class grounded in RFC 6234 §6.2. */
1425
+ var _SHA256 = class extends SHA2_32B {
1426
+ constructor() {
1427
+ super(32, SHA256_IV);
1428
+ }
1429
+ };
1430
+ /**
1431
+ * SHA2-256 hash function from RFC 4634. In JS it's the fastest: even faster than Blake3. Some info:
1432
+ *
1433
+ * - Trying 2^128 hashes would get 50% chance of collision, using birthday attack.
1434
+ * - BTC network is doing 2^70 hashes/sec (2^95 hashes/year) as per 2025.
1435
+ * - Each sha256 hash is executing 2^18 bit operations.
1436
+ * - Good 2024 ASICs can do 200Th/sec with 3500 watts of power, corresponding to 2^36 hashes/joule.
1437
+ * @param msg - message bytes to hash
1438
+ * @param opts - Reserved hash options.
1439
+ * @returns Digest bytes.
1440
+ * @example
1441
+ * Hash a message with SHA2-256.
1442
+ * ```ts
1443
+ * sha256(new Uint8Array([97, 98, 99]));
1444
+ * ```
1445
+ */
1446
+ const sha256 = /* @__PURE__ */ createHasher(() => new _SHA256(), /* @__PURE__ */ oidNist(1));
1447
+ //#endregion
1448
+ //#region src/backup/errors.ts
1449
+ var BackupControllerError = class extends Error {
1450
+ category;
1451
+ constructor(category, message) {
1452
+ super(message);
1453
+ this.name = "BackupControllerError";
1454
+ this.category = category;
1455
+ }
1456
+ };
1457
+ //#endregion
1458
+ //#region src/backup/controller.ts
1459
+ /**
1460
+ * The backup reconcile loop (design/backup.mdx "同步"): keeps the cloud file
1461
+ * `qntx-wallet-backup-<id>.json` in step with the vault. Single-flight — a trigger during a run
1462
+ * schedules exactly one rerun; `lock()` bumps a generation so a stale run never writes state, store
1463
+ * or cloud after that point.
1464
+ *
1465
+ * Security shape: the engine only ever moves ciphertext. The BEK stays in the enclave; the local
1466
+ * record is a digest/coverage baseline, not a key.
1467
+ */
1468
+ const COLLECTION = "backup";
1469
+ const RECORD_KEY = "state";
1470
+ const FILE_PREFIX = "qntx-wallet-backup-";
1471
+ const FILE_SUFFIX = ".json";
1472
+ function fileName(id) {
1473
+ return `${FILE_PREFIX}${id}${FILE_SUFFIX}`;
1474
+ }
1475
+ function digestOf(text) {
1476
+ return bytesToHex(sha256(utf8ToBytes(text)));
1477
+ }
1478
+ function sorted(values) {
1479
+ return [...values].sort();
1480
+ }
1481
+ function sameSet(a, b) {
1482
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1483
+ }
1484
+ const isObject$2 = (v) => typeof v === "object" && v !== null;
1485
+ const isStringArray = (v) => Array.isArray(v) && v.every((e) => typeof e === "string");
1486
+ /** Strict record validation — anything malformed reads as absent (and is logged). */
1487
+ function parseRecord(bytes) {
1488
+ if (bytes === void 0) return;
1489
+ try {
1490
+ const value = JSON.parse(new TextDecoder().decode(bytes));
1491
+ if (!isObject$2(value)) return;
1492
+ const o = value;
1493
+ const { id } = o;
1494
+ const { digest } = o;
1495
+ const { uploadedAt } = o;
1496
+ const { walletIds } = o;
1497
+ const { nostrKeyIds } = o;
1498
+ if (typeof id !== "string" || !/^[0-9a-f]{32}$/.test(id)) return;
1499
+ if (digest !== void 0 && (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest))) return;
1500
+ if (uploadedAt !== void 0 && (typeof uploadedAt !== "number" || !Number.isSafeInteger(uploadedAt))) return;
1501
+ if (!isStringArray(walletIds) || !isStringArray(nostrKeyIds)) return;
1502
+ return {
1503
+ id,
1504
+ digest,
1505
+ uploadedAt,
1506
+ walletIds,
1507
+ nostrKeyIds
1508
+ };
1509
+ } catch {
1510
+ return;
1511
+ }
1512
+ }
1513
+ var BackupController = class extends Controller {
1514
+ wallet;
1515
+ cloud;
1516
+ now;
1517
+ logger;
1518
+ engineStore;
1519
+ generation = 0;
1520
+ current;
1521
+ rerun = false;
1522
+ /** Set by restore() before the store opens — written as a record on the first reconcile. */
1523
+ pendingAdoption;
1524
+ constructor(options) {
1525
+ super({
1526
+ name: "backup",
1527
+ defaultState: () => defaultState()
1528
+ });
1529
+ this.wallet = options.wallet;
1530
+ this.cloud = options.cloud;
1531
+ this.now = options.now;
1532
+ this.logger = options.logger;
1533
+ }
1534
+ async init() {
1535
+ await Promise.resolve();
1536
+ }
1537
+ async destroy() {
1538
+ this.generation += 1;
1539
+ await Promise.resolve();
1540
+ }
1541
+ /** The engine hands over the store on unlock and takes it back on lock. */
1542
+ setStore(store) {
1543
+ this.engineStore = store;
1544
+ }
1545
+ /** Bumps the generation: an in-flight reconcile writes nothing after this point. */
1546
+ lock() {
1547
+ this.generation += 1;
1548
+ this.engineStore = void 0;
1549
+ this.update(() => defaultState());
1550
+ }
1551
+ trigger() {
1552
+ this.triggerAsync();
1553
+ }
1554
+ async triggerAsync() {
1555
+ try {
1556
+ await this.reconcile();
1557
+ } catch (error) {
1558
+ this.logger.warn("backup reconcile trigger failed", error);
1559
+ }
1560
+ }
1561
+ /** Explicit sync — awaits the run so callers see its outcome. */
1562
+ async sync() {
1563
+ await this.reconcile();
1564
+ }
1565
+ async enable(options) {
1566
+ if (this.cloud === void 0 || !await this.cloud.available()) throw new BackupControllerError("unavailable", "the cloud store is unavailable");
1567
+ await this.wallet.backup.enable(options);
1568
+ await this.reconcileOrThrow();
1569
+ }
1570
+ async disable() {
1571
+ await this.wallet.backup.disable();
1572
+ await this.reconcileOrThrow();
1573
+ }
1574
+ /** Files under our prefix with a parseable header. */
1575
+ async list() {
1576
+ if (this.cloud === void 0) return [];
1577
+ const names = (await this.cloud.list()).filter((n) => n.startsWith(FILE_PREFIX) && n.endsWith(FILE_SUFFIX));
1578
+ const { cloud } = this;
1579
+ const texts = await Promise.all(names.map(async (name) => cloud.read(name)));
1580
+ const out = [];
1581
+ for (const [index, name] of names.entries()) {
1582
+ const text = texts[index];
1583
+ if (text === void 0) continue;
1584
+ try {
1585
+ out.push({
1586
+ name,
1587
+ header: parseBackupHeader(text)
1588
+ });
1589
+ } catch {
1590
+ this.logger.warn("backup file failed to parse", new Error(name));
1591
+ }
1592
+ }
1593
+ return out;
1594
+ }
1595
+ async restore(options) {
1596
+ if (this.cloud === void 0) throw new BackupControllerError("cloud", "no cloud store configured");
1597
+ const text = await this.cloud.read(options.name);
1598
+ if (text === void 0) throw new BackupControllerError("cloud", "backup file not found");
1599
+ const id = idFromName(options.name);
1600
+ if (id === void 0) throw new BackupControllerError("cloud", "unrecognized backup file name");
1601
+ const wasUninitialized = this.wallet.getSnapshot().vault === "uninitialized";
1602
+ if (wasUninitialized) this.pendingAdoption = {
1603
+ id,
1604
+ digest: digestOf(text)
1605
+ };
1606
+ let wallets;
1607
+ try {
1608
+ wallets = await this.wallet.backup.restore({
1609
+ envelope: text,
1610
+ backupFactor: options.backupFactor,
1611
+ ...options.factor === void 0 ? {} : { factor: options.factor }
1612
+ });
1613
+ } catch (error) {
1614
+ this.pendingAdoption = void 0;
1615
+ throw error;
1616
+ }
1617
+ const { backup } = this.wallet.getSnapshot();
1618
+ if (!wasUninitialized && backup !== null && backup.id === id) {
1619
+ const previous = await this.readRecord();
1620
+ await this.writeRecord({
1621
+ id,
1622
+ digest: digestOf(text),
1623
+ uploadedAt: previous?.uploadedAt,
1624
+ walletIds: [],
1625
+ nostrKeyIds: []
1626
+ });
1627
+ }
1628
+ try {
1629
+ await this.reconcile();
1630
+ } catch {}
1631
+ return wallets;
1632
+ }
1633
+ async reconcileOrThrow() {
1634
+ await this.reconcile();
1635
+ const { status, error } = this.getState();
1636
+ if (status === "error" || status === "conflict" || status === "unavailable") throw new BackupControllerError(error ?? "cloud", `backup sync failed: ${status}`);
1637
+ }
1638
+ async readRecord() {
1639
+ const bytes = await this.engineStore?.collection(COLLECTION).get(RECORD_KEY);
1640
+ const record = parseRecord(bytes);
1641
+ if (bytes !== void 0 && record === void 0) this.logger.warn("backup record failed to parse — treating as absent", /* @__PURE__ */ new Error(COLLECTION));
1642
+ return record;
1643
+ }
1644
+ async writeRecord(record) {
1645
+ await this.engineStore?.collection(COLLECTION).put(RECORD_KEY, new TextEncoder().encode(JSON.stringify(record)));
1646
+ }
1647
+ async deleteRecord() {
1648
+ await this.engineStore?.collection(COLLECTION).delete(RECORD_KEY);
1649
+ }
1650
+ snapshot() {
1651
+ return this.wallet.getSnapshot();
1652
+ }
1653
+ unprotected(snapshot, record) {
1654
+ const covered = snapshot.backup !== null && record !== void 0 && record.id === snapshot.backup.id && record.digest !== void 0;
1655
+ const wallets = snapshot.wallets.filter((w) => w.backup !== "mnemonic");
1656
+ const keys = snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone" && k.backedUp === false);
1657
+ const walletIds = wallets.map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id));
1658
+ const nostrKeyIds = keys.map((k) => k.publicKey).filter((id) => !covered || !record.nostrKeyIds.includes(id));
1659
+ return {
1660
+ walletIds: sorted(walletIds),
1661
+ nostrKeyIds: sorted(nostrKeyIds)
1662
+ };
1663
+ }
1664
+ coverage(snapshot) {
1665
+ return {
1666
+ walletIds: sorted(snapshot.wallets.map((w) => w.walletId)),
1667
+ nostrKeyIds: sorted(snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone").map((k) => k.publicKey))
1668
+ };
1669
+ }
1670
+ setStatus(generation, status, error, record) {
1671
+ if (generation !== this.generation) return;
1672
+ const snapshot = this.snapshot();
1673
+ this.update((state) => ({
1674
+ ...state,
1675
+ status,
1676
+ backup: snapshot.backup,
1677
+ uploadedAt: record?.uploadedAt,
1678
+ error,
1679
+ unprotected: this.unprotected(snapshot, record)
1680
+ }));
1681
+ }
1682
+ /**
1683
+ * Single-flight: a trigger during a run flags exactly one rerun inside the same promise, so
1684
+ * `sync()`/`enable()` await real convergence instead of a mid-run snapshot.
1685
+ */
1686
+ async reconcile() {
1687
+ if (this.current !== void 0) {
1688
+ this.rerun = true;
1689
+ return this.current;
1690
+ }
1691
+ this.current = (async () => {
1692
+ try {
1693
+ do {
1694
+ this.rerun = false;
1695
+ await this.pass(this.generation);
1696
+ } while (this.rerun);
1697
+ } finally {
1698
+ this.current = void 0;
1699
+ }
1700
+ })();
1701
+ return this.current;
1702
+ }
1703
+ async pass(generation) {
1704
+ const snapshot = this.snapshot();
1705
+ if (snapshot.vault !== "unlocked" || this.engineStore === void 0) return;
1706
+ let record = await this.readRecord();
1707
+ if (this.cloud === void 0 || !await this.cloud.available()) {
1708
+ this.setStatus(generation, "unavailable", void 0, record);
1709
+ return;
1710
+ }
1711
+ if (generation !== this.generation) return;
1712
+ const adoption = this.pendingAdoption;
1713
+ if (adoption !== void 0) {
1714
+ this.pendingAdoption = void 0;
1715
+ if (snapshot.backup?.id === adoption.id) {
1716
+ record = {
1717
+ id: adoption.id,
1718
+ digest: adoption.digest,
1719
+ uploadedAt: void 0,
1720
+ walletIds: [],
1721
+ nostrKeyIds: []
1722
+ };
1723
+ if (generation !== this.generation) return;
1724
+ await this.writeRecord(record);
1725
+ } else this.logger.warn("backup adoption dropped — the vault's backup key is not the file's", new Error(adoption.id));
1726
+ }
1727
+ const { backup } = snapshot;
1728
+ if (backup === null) {
1729
+ if (record !== void 0) try {
1730
+ if (generation !== this.generation) return;
1731
+ await this.cloud.delete(fileName(record.id));
1732
+ if (generation !== this.generation) return;
1733
+ await this.deleteRecord();
1734
+ } catch (error) {
1735
+ this.logger.warn("backup cloud delete failed — retrying next reconcile", error);
1736
+ this.setStatus(generation, "error", "cloud", record);
1737
+ return;
1738
+ }
1739
+ this.setStatus(generation, "off", void 0, void 0);
1740
+ return;
1741
+ }
1742
+ const baseline = record !== void 0 && record.id === backup.id ? record.digest : void 0;
1743
+ const cov = this.coverage(snapshot);
1744
+ if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds) && sameSet(cov.nostrKeyIds, record.nostrKeyIds)) {
1745
+ this.setStatus(generation, "synced", void 0, record);
1746
+ return;
1747
+ }
1748
+ this.setStatus(generation, "syncing", void 0, record);
1749
+ try {
1750
+ const envelope = await this.wallet.backup.update();
1751
+ if (generation !== this.generation) return;
1752
+ const name = fileName(backup.id);
1753
+ const remote = await this.cloud.read(name);
1754
+ const digest = digestOf(envelope);
1755
+ if (remote !== void 0 && digestOf(remote) !== baseline) {
1756
+ this.setStatus(generation, "conflict", "conflict", record);
1757
+ return;
1758
+ }
1759
+ if (generation !== this.generation) return;
1760
+ await this.cloud.write(name, envelope);
1761
+ if (await this.cloud.read(name) !== envelope) {
1762
+ this.setStatus(generation, "error", "cloud", record);
1763
+ return;
1764
+ }
1765
+ const next = {
1766
+ id: backup.id,
1767
+ digest,
1768
+ uploadedAt: this.now(),
1769
+ walletIds: cov.walletIds,
1770
+ nostrKeyIds: cov.nostrKeyIds
1771
+ };
1772
+ if (generation !== this.generation) return;
1773
+ await this.writeRecord(next);
1774
+ if (record !== void 0 && record.id !== backup.id) try {
1775
+ if (generation !== this.generation) return;
1776
+ await this.cloud.delete(fileName(record.id));
1777
+ } catch (error) {
1778
+ this.logger.warn("stale backup file delete failed", error);
1779
+ }
1780
+ this.setStatus(generation, "synced", void 0, next);
1781
+ } catch (error) {
1782
+ if (generation !== this.generation) return;
1783
+ this.logger.warn("backup reconcile failed", error);
1784
+ this.setStatus(generation, "error", "cloud", record);
1785
+ }
1786
+ }
1787
+ };
1788
+ function defaultState() {
1789
+ return {
1790
+ status: "locked",
1791
+ backup: null,
1792
+ uploadedAt: void 0,
1793
+ unprotected: {
1794
+ walletIds: [],
1795
+ nostrKeyIds: []
1796
+ },
1797
+ error: void 0
1798
+ };
1799
+ }
1800
+ function idFromName(name) {
1801
+ if (!name.startsWith(FILE_PREFIX) || !name.endsWith(FILE_SUFFIX)) return;
1802
+ const id = name.slice(19, name.length - 5);
1803
+ return /^[0-9a-f]{32}$/.test(id) ? id : void 0;
1804
+ }
1805
+ const NAME_MAX = 24;
1806
+ const PUBKEY_RE = /^[0-9a-f]{64}$/;
1807
+ const KNOWN_DOC_KEYS = /* @__PURE__ */ new Set([
1808
+ "v",
1809
+ "wallet",
1810
+ "groups"
1811
+ ]);
1812
+ const KNOWN_WALLET_KEYS = /* @__PURE__ */ new Set(["name", "updatedAt"]);
1813
+ const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
1814
+ "index",
1815
+ "name",
1816
+ "hidden",
1817
+ "boundPubkey",
1818
+ "updatedAt"
1819
+ ]);
1820
+ const isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1821
+ const isNonNegativeInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
1822
+ const validName = (v) => v === void 0 || typeof v === "string" && v.trim().length > 0 && v.trim().length <= NAME_MAX;
1823
+ const trimmedName = (v) => typeof v === "string" ? v.trim() : void 0;
1824
+ /** Strict decode — any malformed shape, unknown key or duplicate index returns `undefined`. */
1825
+ function decodeMetaDoc(value) {
1826
+ if (!isObject$1(value)) return;
1827
+ for (const k of Object.keys(value)) if (!KNOWN_DOC_KEYS.has(k)) return;
1828
+ if (value["v"] !== 1) return;
1829
+ const { wallet } = value;
1830
+ if (!isObject$1(wallet)) return;
1831
+ for (const k of Object.keys(wallet)) if (!KNOWN_WALLET_KEYS.has(k)) return;
1832
+ if (!validName(wallet["name"]) || !isNonNegativeInt(wallet["updatedAt"])) return;
1833
+ const { groups } = value;
1834
+ if (!Array.isArray(groups)) return;
1835
+ const seen = /* @__PURE__ */ new Set();
1836
+ const out = [];
1837
+ for (const item of groups) {
1838
+ if (!isObject$1(item)) return;
1839
+ for (const k of Object.keys(item)) if (!KNOWN_GROUP_KEYS.has(k)) return;
1840
+ const { index } = item;
1841
+ if (!isNonNegativeInt(index) || seen.has(index)) return;
1842
+ seen.add(index);
1843
+ if (!validName(item["name"]) || item["hidden"] !== true && item["hidden"] !== false) return;
1844
+ const { boundPubkey } = item;
1845
+ if (boundPubkey !== void 0 && (typeof boundPubkey !== "string" || !PUBKEY_RE.test(boundPubkey))) return;
1846
+ if (!isNonNegativeInt(item["updatedAt"])) return;
1847
+ const gName = trimmedName(item["name"]);
1848
+ out.push({
1849
+ index,
1850
+ hidden: item["hidden"],
1851
+ updatedAt: item["updatedAt"],
1852
+ ...gName === void 0 ? {} : { name: gName },
1853
+ ...boundPubkey === void 0 ? {} : { boundPubkey }
1854
+ });
1855
+ }
1856
+ out.sort((a, b) => a.index - b.index);
1857
+ const name = trimmedName(wallet["name"]);
1858
+ return {
1859
+ v: 1,
1860
+ wallet: {
1861
+ updatedAt: wallet["updatedAt"],
1862
+ ...name === void 0 ? {} : { name }
1863
+ },
1864
+ groups: out
1865
+ };
1866
+ }
1867
+ /** UTF-8 JSON — `undefined` when the document exceeds the 64 KB limit. */
1868
+ function encodeMetaDoc(doc) {
1869
+ const bytes = new TextEncoder().encode(JSON.stringify(doc));
1870
+ return bytes.byteLength <= 65536 ? bytes : void 0;
1871
+ }
1872
+ function decodeMetaDocBytes(bytes) {
1873
+ if (bytes.byteLength > 65536) return;
1874
+ try {
1875
+ return decodeMetaDoc(JSON.parse(new TextDecoder().decode(bytes)));
1876
+ } catch {
1877
+ return;
1878
+ }
1879
+ }
1880
+ //#endregion
1881
+ //#region src/meta/errors.ts
1882
+ var MetaSyncError = class extends Error {
1883
+ category;
1884
+ constructor(category, message, cause) {
1885
+ super(message, { cause });
1886
+ this.name = "MetaSyncError";
1887
+ this.category = category;
1888
+ }
1889
+ };
1890
+ //#endregion
1891
+ //#region src/meta/controller.ts
1892
+ const META_SYNC_KIND = 30078;
1893
+ const PUBLISH_DEBOUNCE_MS = 2e3;
1894
+ /**
1895
+ * Wallet-metadata sync over kind-30078 addressable events: one NIP-44-encrypted document per
1896
+ * wallet, authored by its group-0 derived key. Pull always precedes publish in an unlock session —
1897
+ * replaceable events would otherwise let a stale local tree overwrite a newer remote document.
1898
+ * Debounced publishes are per wallet; a generation counter cancels in-flight write-backs on lock.
1899
+ */
1900
+ var MetaSyncController = class extends Controller {
1901
+ options;
1902
+ generation = 0;
1903
+ pulled = /* @__PURE__ */ new Set();
1904
+ lastRemoteCreatedAt = /* @__PURE__ */ new Map();
1905
+ lastExported = /* @__PURE__ */ new Map();
1906
+ timers = /* @__PURE__ */ new Map();
1907
+ inflight = /* @__PURE__ */ new Set();
1908
+ accountsUnsubscribe;
1909
+ constructor(options) {
1910
+ super({
1911
+ name: "metaSync",
1912
+ defaultState: () => ({ wallets: {} })
1913
+ });
1914
+ this.options = options;
1915
+ }
1916
+ /** Unlocked: pull every wallet whose group-0 identity exists, then watch the tree. */
1917
+ async init() {
1918
+ const gen = ++this.generation;
1919
+ this.accountsUnsubscribe = this.options.accounts.store.subscribe(() => {
1920
+ this.onTreeChange();
1921
+ });
1922
+ await this.pullAll(gen);
1923
+ }
1924
+ async destroy() {
1925
+ await this.lock();
1926
+ }
1927
+ /** Locked: cancel debounces and in-flight write-backs, forget the pull/publish bookkeeping. */
1928
+ async lock() {
1929
+ this.generation += 1;
1930
+ this.accountsUnsubscribe?.();
1931
+ this.accountsUnsubscribe = void 0;
1932
+ for (const timer of this.timers.values()) clearTimeout(timer);
1933
+ this.timers.clear();
1934
+ this.pulled.clear();
1935
+ this.lastRemoteCreatedAt.clear();
1936
+ this.lastExported.clear();
1937
+ this.inflight.clear();
1938
+ this.update(() => ({ wallets: {} }));
1939
+ await Promise.resolve();
1940
+ }
1941
+ /** Snapshot changed while unlocked: pull newly-seen wallets, drop removed ones. */
1942
+ reconcile() {
1943
+ if (this.options.metaSync === void 0) return;
1944
+ const snap = this.options.wallet.getSnapshot();
1945
+ const live = new Set(snap.wallets.map((w) => w.walletId));
1946
+ const gen = this.generation;
1947
+ for (const w of snap.wallets) if (this.getState().wallets[w.walletId] === void 0) this.pullOne(w.walletId, gen);
1948
+ this.update((s) => {
1949
+ const wallets = Object.fromEntries(Object.entries(s.wallets).filter(([walletId]) => live.has(walletId)));
1950
+ return {
1951
+ ...s,
1952
+ wallets
1953
+ };
1954
+ });
1955
+ }
1956
+ /** Manual re-pull of every wallet that can sync. */
1957
+ async sync() {
1958
+ await this.pullAll(this.generation);
1959
+ }
1960
+ async pullAll(gen) {
1961
+ const snap = this.options.wallet.getSnapshot();
1962
+ await Promise.all(snap.wallets.map(async (w) => this.pullOne(w.walletId, gen)));
1963
+ }
1964
+ async pubkeyFor(signer) {
1965
+ return signer.getPublicKey();
1966
+ }
1967
+ signerFor(walletId) {
1968
+ const snap = this.options.wallet.getSnapshot();
1969
+ const ref = {
1970
+ kind: "derived",
1971
+ walletId,
1972
+ groupIndex: 0
1973
+ };
1974
+ if (!snap.nostr.keys.some((k) => k.ref.kind === "derived" && k.ref.walletId === walletId && k.ref.groupIndex === 0)) return;
1975
+ return this.options.nostr.signer(ref);
1976
+ }
1977
+ setStatus(walletId, status) {
1978
+ this.update((s) => ({
1979
+ ...s,
1980
+ wallets: {
1981
+ ...s.wallets,
1982
+ [walletId]: status
1983
+ }
1984
+ }));
1985
+ }
1986
+ async pullOne(walletId, gen) {
1987
+ const config = this.options.metaSync;
1988
+ if (config === void 0 || this.inflight.has(walletId)) return;
1989
+ const signer = this.signerFor(walletId);
1990
+ if (signer === void 0) {
1991
+ if (gen === this.generation) this.setStatus(walletId, { status: "unavailable" });
1992
+ return;
1993
+ }
1994
+ this.inflight.add(walletId);
1995
+ this.setStatus(walletId, { status: "syncing" });
1996
+ try {
1997
+ const pubkey = await this.pubkeyFor(signer);
1998
+ const events = await config.relays.fetch(this.filterFor(pubkey, config.tag));
1999
+ let remote;
2000
+ let remoteSkipped = 0;
2001
+ for (const e of events) {
2002
+ if (!this.acceptableEvent(e, pubkey, config.tag)) {
2003
+ remoteSkipped += 1;
2004
+ continue;
2005
+ }
2006
+ if (remote === void 0 || e.created_at > remote.created_at) remote = e;
2007
+ }
2008
+ if (remote === void 0) {
2009
+ const local = this.options.accounts.exportMeta(walletId);
2010
+ const hasEdits = local.wallet.updatedAt > 0 || local.groups.some((g) => g.updatedAt > 0);
2011
+ if (gen !== this.generation) return;
2012
+ this.pulled.add(walletId);
2013
+ this.rememberExport(walletId);
2014
+ this.setStatus(walletId, { status: "synced" });
2015
+ if (hasEdits) await this.publishNow(walletId, gen);
2016
+ } else {
2017
+ const decrypt = signer.nip44Decrypt;
2018
+ const plaintext = decrypt === void 0 ? void 0 : await decrypt.call(signer, pubkey, remote.content).catch(() => void 0);
2019
+ const doc = plaintext === void 0 ? void 0 : decodeMetaDocBytes(new TextEncoder().encode(plaintext));
2020
+ if (doc === void 0) {
2021
+ if (gen !== this.generation) return;
2022
+ const error = plaintext !== void 0 && isUnsupportedDoc(plaintext) ? "unsupported" : "corrupt";
2023
+ this.setStatus(walletId, {
2024
+ status: "error",
2025
+ error
2026
+ });
2027
+ this.options.logger.warn(`meta sync: unreadable remote doc (${error})`);
2028
+ return;
2029
+ }
2030
+ const merge = this.options.accounts.mergeMeta(walletId, doc);
2031
+ if (gen !== this.generation) return;
2032
+ this.lastRemoteCreatedAt.set(walletId, remote.created_at);
2033
+ this.pulled.add(walletId);
2034
+ this.rememberExport(walletId);
2035
+ this.setStatus(walletId, { status: "synced" });
2036
+ if (merge.localNewer) await this.publishNow(walletId, gen);
2037
+ }
2038
+ if (remoteSkipped > 0) this.options.logger.warn(`meta sync: ignored ${remoteSkipped} invalid remote events`);
2039
+ } catch (error) {
2040
+ if (gen === this.generation) {
2041
+ this.setStatus(walletId, {
2042
+ status: "error",
2043
+ error: categorizeSync(error)
2044
+ });
2045
+ this.options.logger.warn("meta sync pull failed", error);
2046
+ }
2047
+ } finally {
2048
+ this.inflight.delete(walletId);
2049
+ }
2050
+ }
2051
+ filterFor(pubkey, tag) {
2052
+ return {
2053
+ kinds: [META_SYNC_KIND],
2054
+ authors: [pubkey],
2055
+ "#d": [tag]
2056
+ };
2057
+ }
2058
+ acceptableEvent(e, pubkey, tag) {
2059
+ if (e.kind !== 30078 || e.pubkey !== pubkey || !verifyEvent(e)) return false;
2060
+ const dTags = e.tags.filter((t) => t[0] === "d");
2061
+ return dTags.length === 1 && dTags[0]?.[1] === tag;
2062
+ }
2063
+ /** Snapshot the wallet's exported doc — the debounce publishes only on a real change. */
2064
+ rememberExport(walletId) {
2065
+ try {
2066
+ const doc = this.options.accounts.exportMeta(walletId);
2067
+ this.lastExported.set(walletId, JSON.stringify(doc));
2068
+ } catch {}
2069
+ }
2070
+ onTreeChange() {
2071
+ if (this.options.metaSync === void 0) return;
2072
+ for (const walletId of this.pulled) {
2073
+ let current;
2074
+ try {
2075
+ current = JSON.stringify(this.options.accounts.exportMeta(walletId));
2076
+ } catch {
2077
+ continue;
2078
+ }
2079
+ if (current === this.lastExported.get(walletId)) continue;
2080
+ this.lastExported.set(walletId, current);
2081
+ const existing = this.timers.get(walletId);
2082
+ if (existing !== void 0) clearTimeout(existing);
2083
+ const gen = this.generation;
2084
+ this.timers.set(walletId, setTimeout(() => {
2085
+ this.timers.delete(walletId);
2086
+ this.publishNow(walletId, gen);
2087
+ }, PUBLISH_DEBOUNCE_MS));
2088
+ }
2089
+ }
2090
+ async publishNow(walletId, gen) {
2091
+ const config = this.options.metaSync;
2092
+ if (config === void 0 || !this.pulled.has(walletId)) return;
2093
+ const signer = this.signerFor(walletId);
2094
+ if (signer === void 0) {
2095
+ this.setStatus(walletId, { status: "unavailable" });
2096
+ return;
2097
+ }
2098
+ try {
2099
+ const bytes = encodeMetaDoc(this.options.accounts.exportMeta(walletId));
2100
+ if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
2101
+ const encrypt = signer.nip44Encrypt;
2102
+ if (encrypt === void 0) throw new MetaSyncError("internal", "signer lacks nip44 encryption");
2103
+ const pubkey = await this.pubkeyFor(signer);
2104
+ const content = await encrypt.call(signer, pubkey, new TextDecoder().decode(bytes));
2105
+ const lastRemote = this.lastRemoteCreatedAt.get(walletId) ?? 0;
2106
+ const created_at = Math.max(Math.floor(this.options.now() / 1e3), lastRemote + 1);
2107
+ const event = await signer.signEvent({
2108
+ pubkey,
2109
+ kind: META_SYNC_KIND,
2110
+ created_at,
2111
+ tags: [["d", config.tag]],
2112
+ content
2113
+ });
2114
+ if (gen !== this.generation) return;
2115
+ await config.relays.publish(event);
2116
+ } catch (error) {
2117
+ if (gen === this.generation) {
2118
+ this.setStatus(walletId, {
2119
+ status: "error",
2120
+ error: categorizeSync(error)
2121
+ });
2122
+ this.options.logger.warn("meta sync publish failed", error);
2123
+ }
2124
+ }
2125
+ }
2126
+ };
2127
+ function categorizeSync(error) {
2128
+ return error instanceof MetaSyncError ? error.category : "network";
2129
+ }
2130
+ /** A decrypted doc that parses as JSON with a numeric `v` above 1 is a newer schema, not garbage. */
2131
+ function isUnsupportedDoc(plaintext) {
2132
+ try {
2133
+ const doc = JSON.parse(plaintext);
2134
+ return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v > 1;
2135
+ } catch {
2136
+ return false;
2137
+ }
2138
+ }
2139
+ //#endregion
2140
+ //#region src/payments/parse.ts
2141
+ /** Invoices with less than this much life left are treated as already expired. */
2142
+ const BOLT11_EXPIRY_GRACE_MS = 6e4;
2143
+ const invalid = (reason) => ({
2144
+ kind: "invalid",
2145
+ reason
2146
+ });
2147
+ /** BTC decimal `amount` → sats. At most 8 decimal places; must fit a safe integer. */
2148
+ function btcAmountSats(text) {
2149
+ const match = /^(\d+)(?:\.(\d{1,8}))?$/.exec(text);
2150
+ if (match === null) return;
2151
+ const [, whole] = match;
2152
+ if (whole === void 0) return;
2153
+ const sats = BigInt(whole) * 100000000n + BigInt((match[2] ?? "").padEnd(8, "0") || "0");
2154
+ return sats <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(sats) : void 0;
2155
+ }
2156
+ /**
2157
+ * BOLT11 → fields, network- and expiry-checked. `undefined` = not a bolt11 string; `"network"` /
2158
+ * `"expired"` / `"malformed"` are the failure modes.
2159
+ */
2160
+ function bolt11Fields(text, options) {
2161
+ let decoded;
2162
+ try {
2163
+ decoded = decodeBolt11(text);
2164
+ } catch {
2165
+ return;
2166
+ }
2167
+ const network = decoded.network === "mainnet" ? "mainnet" : decoded.network === "regtest" ? "regtest" : void 0;
2168
+ if (network === void 0) return "network";
2169
+ if (network !== options.network) return "network";
2170
+ const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
2171
+ if (expiresAt <= options.now + BOLT11_EXPIRY_GRACE_MS) return "expired";
2172
+ return {
2173
+ ...decoded.amountMsat === void 0 ? {} : { amountSats: Math.ceil(Number(decoded.amountMsat) / 1e3) },
2174
+ paymentHash: bytesToHex(decoded.paymentHash),
2175
+ ...decoded.description === void 0 ? {} : { description: decoded.description },
2176
+ expiresAt
2177
+ };
2178
+ }
2179
+ /** Spark address or sats/token invoice → options. `null` means not a spark input. */
2180
+ function sparkOptions(text, options) {
2181
+ let decoded;
2182
+ try {
2183
+ decoded = decodeSparkAddress(text);
2184
+ } catch {
2185
+ return;
2186
+ }
2187
+ if (decoded.network !== options.network) return "network";
2188
+ if (decoded.invoice === void 0) return [{
2189
+ kind: "spark",
2190
+ address: text
2191
+ }];
2192
+ if (decoded.invoice.expiresAt !== void 0 && decoded.invoice.expiresAt <= options.now) return "expired";
2193
+ if (decoded.invoice.payment?.type === "tokens") return "token";
2194
+ let parsed;
2195
+ try {
2196
+ parsed = parseSparkInvoice(text, {
2197
+ network: options.network,
2198
+ now: options.now
2199
+ });
2200
+ } catch {
2201
+ return "malformed";
2202
+ }
2203
+ const { payment } = parsed.fields;
2204
+ return [{
2205
+ kind: "spark-invoice",
2206
+ invoice: text,
2207
+ ...payment?.type === "sats" && payment.amount !== void 0 ? { amountSats: payment.amount } : {},
2208
+ ...parsed.fields.memo === void 0 ? {} : { memo: parsed.fields.memo },
2209
+ ...parsed.fields.expiresAt === void 0 ? {} : { expiresAt: parsed.fields.expiresAt }
2210
+ }];
2211
+ }
2212
+ /** Does `address` decode under the other network? Used only to classify a rejection. */
2213
+ function decodesOnOtherNetwork(address, options) {
2214
+ try {
2215
+ bitcoinAddressScript(address, options.network === "mainnet" ? "regtest" : "mainnet");
2216
+ return true;
2217
+ } catch {
2218
+ return false;
2219
+ }
2220
+ }
2221
+ /** BIP-21 URI → request. Only the `spark=` and `lightning=` payment methods are supported. */
2222
+ function bip21Parse(text, options) {
2223
+ const q = text.indexOf("?");
2224
+ const address = text.slice(8, q === -1 ? void 0 : q);
2225
+ const params = /* @__PURE__ */ new Map();
2226
+ if (q !== -1) for (const pair of text.slice(q + 1).split("&")) {
2227
+ const eq = pair.indexOf("=");
2228
+ if (eq === -1) return invalid("malformed");
2229
+ let key;
2230
+ let value;
2231
+ try {
2232
+ key = decodeURIComponent(pair.slice(0, eq)).toLowerCase();
2233
+ value = decodeURIComponent(pair.slice(eq + 1));
2234
+ } catch {
2235
+ return invalid("malformed");
2236
+ }
2237
+ if (params.has(key)) return invalid("malformed");
2238
+ if (key.startsWith("req-") && !BIP21_KNOWN_REQ_PARAMS.has(key)) return invalid("malformed");
2239
+ params.set(key, value);
2240
+ }
2241
+ const options_ = [];
2242
+ let declaredSats;
2243
+ const spark = params.get("spark");
2244
+ if (spark !== void 0) {
2245
+ const parsed = sparkOptions(spark, options);
2246
+ if (parsed === void 0 || parsed === "malformed") return invalid("malformed");
2247
+ if (parsed === "token") return {
2248
+ kind: "unsupported",
2249
+ format: "spark-token-invoice"
2250
+ };
2251
+ if (parsed === "network" || parsed === "expired") return invalid(parsed);
2252
+ options_.push(...parsed);
2253
+ const sats = parsed.find((o) => o.kind === "spark-invoice")?.amountSats;
2254
+ if (sats !== void 0) {
2255
+ if (declaredSats !== void 0 && declaredSats !== sats) return invalid("amount-mismatch");
2256
+ declaredSats = sats;
2257
+ }
2258
+ }
2259
+ const lightning = params.get("lightning");
2260
+ if (lightning !== void 0) {
2261
+ const parsed = bolt11Fields(lightning, options);
2262
+ if (parsed === void 0 || parsed === "malformed") return invalid("malformed");
2263
+ if (parsed === "network" || parsed === "expired") return invalid(parsed);
2264
+ options_.push({
2265
+ kind: "lightning",
2266
+ invoice: lightning,
2267
+ ...parsed.amountSats === void 0 ? {} : { amountSats: parsed.amountSats },
2268
+ paymentHash: parsed.paymentHash,
2269
+ ...parsed.description === void 0 ? {} : { description: parsed.description },
2270
+ expiresAt: parsed.expiresAt
2271
+ });
2272
+ if (parsed.amountSats !== void 0) {
2273
+ if (declaredSats !== void 0 && declaredSats !== parsed.amountSats) return invalid("amount-mismatch");
2274
+ declaredSats = parsed.amountSats;
2275
+ }
2276
+ }
2277
+ if (address.length > 0) {
2278
+ try {
2279
+ bitcoinAddressScript(address, options.network);
2280
+ } catch {
2281
+ return invalid(decodesOnOtherNetwork(address, options) ? "network" : "malformed");
2282
+ }
2283
+ options_.push({
2284
+ kind: "bitcoin",
2285
+ address
2286
+ });
2287
+ }
2288
+ const declaredAmount = params.get("amount");
2289
+ if (declaredAmount !== void 0) {
2290
+ const sats = btcAmountSats(declaredAmount);
2291
+ if (sats === void 0) return invalid("malformed");
2292
+ if (declaredSats !== void 0 && declaredSats !== sats) return invalid("amount-mismatch");
2293
+ declaredSats = sats;
2294
+ }
2295
+ if (options_.length === 0) {
2296
+ if (params.has("lno")) return {
2297
+ kind: "unsupported",
2298
+ format: "bolt12"
2299
+ };
2300
+ if (params.has("sp")) return {
2301
+ kind: "unsupported",
2302
+ format: "silent-payment"
2303
+ };
2304
+ return invalid("malformed");
2305
+ }
2306
+ return {
2307
+ kind: "request",
2308
+ request: {
2309
+ options: options_,
2310
+ ...declaredSats === void 0 ? {} : { amountSats: declaredSats },
2311
+ ...params.get("label") === void 0 ? {} : { label: params.get("label") ?? "" },
2312
+ ...params.get("message") === void 0 ? {} : { message: params.get("message") ?? "" }
2313
+ }
2314
+ };
2315
+ }
2316
+ const BIP21_KNOWN_REQ_PARAMS = /* @__PURE__ */ new Set([
2317
+ "req-amount",
2318
+ "req-label",
2319
+ "req-message"
2320
+ ]);
2321
+ /** `input` → PaymentParse. Never throws: every failure is a structured result. */
2322
+ function parsePayment(input, options) {
2323
+ try {
2324
+ const text = input.trim();
2325
+ if (/^nostr\+walletconnect:/i.test(text)) return {
2326
+ kind: "unsupported",
2327
+ format: "nostr-wallet-connect"
2328
+ };
2329
+ const stripped = text.toLowerCase().startsWith("lightning:") ? text.slice(10) : text;
2330
+ if (/^bitcoin:/i.test(stripped)) return bip21Parse(`bitcoin:${stripped.slice(stripped.indexOf(":") + 1)}`, options);
2331
+ const bolt11 = bolt11Fields(stripped, options);
2332
+ if (bolt11 === "network" || bolt11 === "expired" || bolt11 === "malformed") return invalid(bolt11);
2333
+ if (bolt11 !== void 0) return {
2334
+ kind: "request",
2335
+ request: {
2336
+ options: [{
2337
+ kind: "lightning",
2338
+ invoice: stripped,
2339
+ ...bolt11.amountSats === void 0 ? {} : { amountSats: bolt11.amountSats },
2340
+ paymentHash: bolt11.paymentHash,
2341
+ ...bolt11.description === void 0 ? {} : { description: bolt11.description },
2342
+ expiresAt: bolt11.expiresAt
2343
+ }],
2344
+ ...bolt11.amountSats === void 0 ? {} : { amountSats: bolt11.amountSats }
2345
+ }
2346
+ };
2347
+ const spark = sparkOptions(stripped, options);
2348
+ if (spark === "token") return {
2349
+ kind: "unsupported",
2350
+ format: "spark-token-invoice"
2351
+ };
2352
+ if (spark === "network" || spark === "expired" || spark === "malformed") return invalid(spark);
2353
+ if (spark !== void 0) return {
2354
+ kind: "request",
2355
+ request: { options: spark }
2356
+ };
2357
+ if (/^(lnurlp|lnurlw):/i.test(stripped)) {
2358
+ const rest = stripped.slice(stripped.indexOf("://") + 3);
2359
+ const url = `${(rest.split(/[/?#]/, 1)[0] ?? "").toLowerCase().endsWith(".onion") ? "http" : "https"}://${rest}`;
2360
+ if (new URL(url).searchParams.get("tag") === "login") return {
2361
+ kind: "unsupported",
2362
+ format: "lnurl-auth"
2363
+ };
2364
+ return {
2365
+ kind: "lnurl",
2366
+ url
2367
+ };
2368
+ }
2369
+ if (/^keyauth:/i.test(stripped)) return {
2370
+ kind: "unsupported",
2371
+ format: "lnurl-auth"
2372
+ };
2373
+ if (!stripped.includes(":") && stripped.includes("@")) return {
2374
+ kind: "lnurl",
2375
+ url: lightningAddressUrl(stripped),
2376
+ address: stripped
2377
+ };
2378
+ if (/^lno1[02-9ac-hj-np-z]+$/i.test(stripped)) return {
2379
+ kind: "unsupported",
2380
+ format: "bolt12"
2381
+ };
2382
+ try {
2383
+ const url = decodeLnurl(stripped);
2384
+ if (new URL(url).searchParams.get("tag") === "login") return {
2385
+ kind: "unsupported",
2386
+ format: "lnurl-auth"
2387
+ };
2388
+ return {
2389
+ kind: "lnurl",
2390
+ url
2391
+ };
2392
+ } catch {}
2393
+ try {
2394
+ bitcoinAddressScript(stripped, options.network);
2395
+ return {
2396
+ kind: "request",
2397
+ request: { options: [{
2398
+ kind: "bitcoin",
2399
+ address: stripped
2400
+ }] }
2401
+ };
2402
+ } catch {
2403
+ if (decodesOnOtherNetwork(stripped, options)) return invalid("network");
2404
+ }
2405
+ return { kind: "unrecognized" };
2406
+ } catch {
2407
+ return invalid("malformed");
2408
+ }
2409
+ }
2410
+ //#endregion
2411
+ //#region src/spark/errors.ts
2412
+ var SparkControllerError = class extends Error {
2413
+ category;
2414
+ constructor(category, message, cause) {
2415
+ super(message, { cause });
2416
+ this.name = "SparkControllerError";
2417
+ this.category = category;
2418
+ }
2419
+ };
2420
+ /** Maps a thrown client/kernel error into a category without leaking payloads. */
2421
+ function categorize(error) {
2422
+ const name = error instanceof Error ? error.name : "";
2423
+ const message = error instanceof Error ? error.message : "";
2424
+ if (/approval|denied|rejected/i.test(message)) return /required/i.test(message) ? "approval-required" : "rejected";
2425
+ if (/insufficient|not enough|balance/i.test(message)) return "insufficient-funds";
2426
+ if (name === "SparkRequestError" || /fetch|network|timeout|unavailable|closed/i.test(message)) return "network";
2427
+ return "internal";
2428
+ }
2429
+ //#endregion
2430
+ //#region src/payments/errors.ts
2431
+ var PaymentError = class extends Error {
2432
+ category;
2433
+ constructor(category, message, cause) {
2434
+ super(message, { cause });
2435
+ this.name = "PaymentError";
2436
+ this.category = category;
2437
+ }
2438
+ };
2439
+ //#endregion
2440
+ //#region src/payments/pay.ts
2441
+ /**
2442
+ * Executes one parsed payment option. Every input validation happens before the approval round — a
2443
+ * `PaymentError` here never consumed a verification. `SparkControllerError` and `WalletError` pass
2444
+ * through untouched.
2445
+ */
2446
+ async function pay(context, params) {
2447
+ const { spark } = context;
2448
+ const ref = params.account;
2449
+ let result;
2450
+ switch (params.option.kind) {
2451
+ case "spark":
2452
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "spark payment: amountSats is required");
2453
+ result = {
2454
+ kind: "spark",
2455
+ transfer: await spark.send(ref, {
2456
+ receiver: params.option.address,
2457
+ amountSats: params.amountSats,
2458
+ verify: params.verify
2459
+ })
2460
+ };
2461
+ break;
2462
+ case "spark-invoice": {
2463
+ if (params.option.expiresAt !== void 0 && params.option.expiresAt <= context.now()) throw new PaymentError("rejected", "spark invoice expired");
2464
+ if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "spark invoice: amountSats conflicts with the invoice amount");
2465
+ const invoiceAmount = params.option.amountSats === void 0 ? params.amountSats : void 0;
2466
+ result = {
2467
+ kind: "spark",
2468
+ transfer: await spark.paySparkInvoice(ref, {
2469
+ invoice: params.option.invoice,
2470
+ ...invoiceAmount === void 0 ? {} : { amountSats: invoiceAmount },
2471
+ verify: params.verify
2472
+ })
2473
+ };
2474
+ break;
2475
+ }
2476
+ case "lightning":
2477
+ if (params.option.amountSats === void 0 && params.amountSats === void 0) throw new PaymentError("invalid-input", "amountless invoice: amountSats is required");
2478
+ if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lightning payment: maxFeeSats is required");
2479
+ if (params.option.expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "lightning invoice expired");
2480
+ if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "amountSats conflicts with the invoice amount");
2481
+ result = {
2482
+ kind: "lightning",
2483
+ payment: await spark.payInvoice(ref, {
2484
+ invoice: params.option.invoice,
2485
+ maxFeeSats: params.maxFeeSats,
2486
+ ...params.option.amountSats === void 0 && params.amountSats !== void 0 ? { amountSats: params.amountSats } : {},
2487
+ verify: params.verify
2488
+ })
2489
+ };
2490
+ break;
2491
+ case "lnurl-pay": {
2492
+ const { request } = params.option;
2493
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: amountSats is required");
2494
+ if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: maxFeeSats is required");
2495
+ const amountMsat = params.amountSats * 1e3;
2496
+ if (amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) throw new PaymentError("rejected", "lnurl-pay: amount outside the service's range");
2497
+ if (request.commentAllowed === 0 && params.comment !== void 0) throw new PaymentError("rejected", "lnurl-pay: comments not accepted by this service");
2498
+ if (params.comment !== void 0 && params.comment.length > request.commentAllowed) throw new PaymentError("rejected", "lnurl-pay: comment exceeds the service's limit");
2499
+ let requested;
2500
+ try {
2501
+ requested = await requestInvoice(request, {
2502
+ fetch: context.fetch,
2503
+ network: ref.network,
2504
+ amountMsat,
2505
+ ...params.comment === void 0 ? {} : { comment: params.comment }
2506
+ });
2507
+ } catch (error) {
2508
+ if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
2509
+ throw new PaymentError("network", "lnurl-pay: invoice request failed", error);
2510
+ }
2511
+ result = {
2512
+ kind: "lightning",
2513
+ payment: await spark.payInvoice(ref, {
2514
+ invoice: requested.invoice,
2515
+ maxFeeSats: params.maxFeeSats,
2516
+ verify: params.verify
2517
+ })
2518
+ };
2519
+ break;
2520
+ }
2521
+ case "lnurl-withdraw": {
2522
+ const { request } = params.option;
2523
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-withdraw: amountSats is required");
2524
+ const amountMsat = params.amountSats * 1e3;
2525
+ if (amountMsat < request.minWithdrawableMsat || amountMsat > request.maxWithdrawableMsat) throw new PaymentError("rejected", "lnurl-withdraw: amount outside the service's range");
2526
+ const invoice = await spark.createInvoice(ref, {
2527
+ amountSats: params.amountSats,
2528
+ memo: request.defaultDescription
2529
+ });
2530
+ try {
2531
+ await submitWithdraw(request, {
2532
+ fetch: context.fetch,
2533
+ invoice: invoice.invoice
2534
+ });
2535
+ } catch (error) {
2536
+ if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
2537
+ throw new PaymentError("network", "lnurl-withdraw: submission failed", error);
2538
+ }
2539
+ result = {
2540
+ kind: "lnurl-withdraw",
2541
+ invoice
2542
+ };
2543
+ break;
2544
+ }
2545
+ case "bitcoin":
2546
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "bitcoin payment: amountSats is required");
2547
+ result = {
2548
+ kind: "withdraw",
2549
+ exit: await spark.withdraw(ref, {
2550
+ address: params.option.address,
2551
+ speed: params.speed ?? "MEDIUM",
2552
+ verify: params.verify,
2553
+ amountSats: params.amountSats,
2554
+ ...params.maxFeeSats === void 0 ? {} : { maxFeeSats: params.maxFeeSats }
2555
+ })
2556
+ };
2557
+ }
2558
+ return result;
2559
+ }
2560
+ //#endregion
2561
+ //#region src/payments/resolve.ts
2562
+ /**
2563
+ * `parsePayment` plus LNURL dereferencing: an `lnurl` result is fetched and turned into a concrete
2564
+ * `lnurl-pay` / `lnurl-withdraw` request (`tag=login` endpoints report as unsupported). Network
2565
+ * failures throw `PaymentError("network")` — remote error bodies never enter the message.
2566
+ */
2567
+ async function resolvePayment(input, options) {
2568
+ const parsed = parsePayment(input, options);
2569
+ if (parsed.kind !== "lnurl") return parsed;
2570
+ let resolved;
2571
+ try {
2572
+ resolved = await resolveLnurl(parsed.url, { fetch: options.fetch });
2573
+ } catch {
2574
+ throw new PaymentError("network", "lnurl: resolution failed");
2575
+ }
2576
+ if (resolved.tag === "payRequest") return {
2577
+ kind: "request",
2578
+ request: { options: [{
2579
+ kind: "lnurl-pay",
2580
+ request: resolved,
2581
+ ...parsed.address === void 0 ? {} : { address: parsed.address }
2582
+ }] }
2583
+ };
2584
+ if (resolved.tag === "withdrawRequest") return {
2585
+ kind: "request",
2586
+ request: { options: [{
2587
+ kind: "lnurl-withdraw",
2588
+ request: resolved
2589
+ }] }
2590
+ };
2591
+ return {
2592
+ kind: "unsupported",
2593
+ format: "lnurl-auth"
2594
+ };
2595
+ }
2596
+ //#endregion
2597
+ //#region src/payments/zap.ts
2598
+ /** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
2599
+ async function prepareZap(params, context) {
2600
+ const amountMsat = params.amountSats * 1e3;
2601
+ if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0 || !Number.isSafeInteger(amountMsat)) throw new PaymentError("invalid-input", "zap amount is not a positive safe integer");
2602
+ try {
2603
+ const resolved = await resolveLnurl(params.recipient.lightning, { fetch: context.fetch });
2604
+ if (resolved.tag !== "payRequest") throw new PaymentError("rejected", "recipient is not an LNURL-pay endpoint");
2605
+ if (!resolved.allowsNostr || resolved.nostrPubkey === void 0) throw new PaymentError("rejected", "LNURL service does not accept nostr zaps");
2606
+ if (amountMsat < resolved.minSendableMsat || amountMsat > resolved.maxSendableMsat) throw new PaymentError("rejected", "zap amount outside the service's bounds");
2607
+ if (params.comment !== void 0) {
2608
+ if (resolved.commentAllowed === 0) throw new PaymentError("rejected", "the service does not accept comments");
2609
+ if (params.comment.length > resolved.commentAllowed) throw new PaymentError("rejected", "comment exceeds the service's limit");
2610
+ }
2611
+ const lnurl = params.recipient.lightning.toLowerCase().startsWith("lnurl1") ? params.recipient.lightning.toLowerCase() : encodeLnurl(lightningAddressUrl(params.recipient.lightning));
2612
+ const base = {
2613
+ amount: amountMsat,
2614
+ relays: [...params.relays],
2615
+ comment: params.comment,
2616
+ lnurl
2617
+ };
2618
+ const unsigned = {
2619
+ ..."event" in params.recipient ? makeZapRequest({
2620
+ event: params.recipient.event,
2621
+ ...base
2622
+ }) : makeZapRequest({
2623
+ pubkey: params.recipient.pubkey,
2624
+ ...base
2625
+ }),
2626
+ pubkey: await params.signer.getPublicKey()
2627
+ };
2628
+ const zapRequest = await params.signer.signEvent(unsigned);
2629
+ if (!verifyEvent(zapRequest) || !signedMatchesUnsigned(zapRequest, unsigned)) throw new PaymentError("rejected", "signer returned a zap request that does not match");
2630
+ const { invoice } = await requestInvoice(resolved, {
2631
+ fetch: context.fetch,
2632
+ network: params.network,
2633
+ amountMsat,
2634
+ ...params.comment === void 0 ? {} : { comment: params.comment },
2635
+ zapRequest: JSON.stringify(zapRequest)
2636
+ });
2637
+ const decoded = decodeBolt11(invoice);
2638
+ const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
2639
+ if (expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "zap invoice expires too soon");
2640
+ const eventId = "event" in params.recipient ? params.recipient.event.id : void 0;
2641
+ return {
2642
+ zapRequest,
2643
+ invoice,
2644
+ paymentHash: bytesToHex(decoded.paymentHash),
2645
+ expiresAt,
2646
+ amountSats: params.amountSats,
2647
+ receiptPubkey: bytesToHex(resolved.nostrPubkey),
2648
+ recipientPubkey: "event" in params.recipient ? params.recipient.event.pubkey : params.recipient.pubkey,
2649
+ ...eventId === void 0 ? {} : { eventId },
2650
+ ...params.comment === void 0 ? {} : { comment: params.comment },
2651
+ lightning: params.recipient.lightning
2652
+ };
2653
+ } catch (error) {
2654
+ if (error instanceof PaymentError) throw error;
2655
+ if (error instanceof SparkInputError || error instanceof Error && error.cause instanceof SparkInputError) throw new PaymentError("rejected", "zap failed invoice validation");
2656
+ throw new PaymentError(categorizePayment(error), "zap failed");
2657
+ }
2658
+ }
2659
+ /**
2660
+ * Pays a prepared zap through the controller's approve-first path. A nearly-expired invoice is
2661
+ * rejected before approval — the user may have sat on the confirm screen since `prepareZap`.
2662
+ */
2663
+ async function payZap(spark, account, prepared, options) {
2664
+ if (prepared.expiresAt <= options.now() + 6e4) throw new PaymentError("rejected", "zap invoice expired");
2665
+ return spark.payInvoice(account, {
2666
+ invoice: prepared.invoice,
2667
+ maxFeeSats: options.maxFeeSats,
2668
+ verify: options.verify,
2669
+ note: {
2670
+ kind: "zap",
2671
+ recipientPubkey: prepared.recipientPubkey,
2672
+ ...prepared.eventId === void 0 ? {} : { eventId: prepared.eventId },
2673
+ ...prepared.comment === void 0 ? {} : { comment: prepared.comment },
2674
+ lightning: prepared.lightning
2675
+ }
2676
+ });
2677
+ }
2678
+ function categorizePayment(error) {
2679
+ return categorize(error) === "network" ? "network" : "rejected";
2680
+ }
2681
+ //#endregion
2682
+ //#region src/spark/notes.ts
2683
+ const HEX64 = /^[0-9a-f]{64}$/;
2684
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2685
+ const NOTE_FIELDS = /* @__PURE__ */ new Set([
2686
+ "kind",
2687
+ "recipientPubkey",
2688
+ "eventId",
2689
+ "comment",
2690
+ "lightning"
2691
+ ]);
2692
+ /** Strict read-side validation: anything off-shape is `undefined`, never a throw. */
2693
+ function parsePaymentNote(value) {
2694
+ if (!isObject(value)) return;
2695
+ for (const field of Object.keys(value)) if (!NOTE_FIELDS.has(field)) return;
2696
+ const { kind, recipientPubkey, eventId, comment, lightning } = value;
2697
+ if (kind !== "zap") return;
2698
+ if (typeof recipientPubkey !== "string" || !HEX64.test(recipientPubkey)) return;
2699
+ if (eventId !== void 0 && (typeof eventId !== "string" || !HEX64.test(eventId))) return;
2700
+ if (comment !== void 0 && (typeof comment !== "string" || comment.length > 2e3)) return;
2701
+ if (typeof lightning !== "string" || lightning.length === 0) return;
2702
+ return {
2703
+ kind: "zap",
2704
+ recipientPubkey,
2705
+ ...eventId === void 0 ? {} : { eventId },
2706
+ ...comment === void 0 ? {} : { comment },
2707
+ lightning
2708
+ };
2709
+ }
2710
+ function encodePaymentNote(note) {
2711
+ return new TextEncoder().encode(JSON.stringify(note));
2712
+ }
2713
+ //#endregion
2714
+ //#region src/spark/exit-chain-codec.ts
2715
+ /**
2716
+ * SparkExitChain persistence codec: JSON with Uint8Arrays tagged `{ $b64: "…" }` — small and
2717
+ * explicit rather than schema-bound, so upstream field additions survive a round-trip.
2718
+ */
2719
+ function encodeValue(value) {
2720
+ if (value instanceof Uint8Array) return { $b64: base64.encode(value) };
2721
+ if (Array.isArray(value)) return value.map(encodeValue);
2722
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encodeValue(v)]));
2723
+ return value;
2724
+ }
2725
+ function decodeValue(value) {
2726
+ if (Array.isArray(value)) return value.map(decodeValue);
2727
+ if (typeof value === "object" && value !== null) {
2728
+ const entries = Object.entries(value);
2729
+ const b64 = entries.length === 1 && entries[0]?.[0] === "$b64" ? entries[0][1] : void 0;
2730
+ if (typeof b64 === "string") return base64.decode(b64);
2731
+ return Object.fromEntries(entries.map(([k, v]) => [k, decodeValue(v)]));
2732
+ }
2733
+ return value;
2734
+ }
2735
+ function encodeExitChain(chain) {
2736
+ return new TextEncoder().encode(JSON.stringify(encodeValue(chain)));
2737
+ }
2738
+ function decodeExitChain(bytes) {
2739
+ return decodeValue(JSON.parse(new TextDecoder().decode(bytes)));
2740
+ }
2741
+ //#endregion
2742
+ //#region src/spark/store.ts
2743
+ const ALL = "__all__";
2744
+ /**
2745
+ * SparkStore over one EngineCollection: chains keyed by leafId, plus an index record listing them
2746
+ * (`exitChains` is a full listing — the port has no secondary index).
2747
+ */
2748
+ function sparkStore(col) {
2749
+ const readIndex = async () => {
2750
+ const index = await col.get(ALL);
2751
+ if (index === void 0) return [];
2752
+ const parsed = JSON.parse(new TextDecoder().decode(index));
2753
+ if (!Array.isArray(parsed)) return [];
2754
+ return parsed.filter((v) => typeof v === "string");
2755
+ };
2756
+ const writeIndex = async (ids) => {
2757
+ await col.put(ALL, new TextEncoder().encode(JSON.stringify(ids)));
2758
+ };
2759
+ return {
2760
+ exitChains: async () => {
2761
+ const ids = await readIndex();
2762
+ const chains = [];
2763
+ for (const leafId of ids) {
2764
+ const record = await col.get(leafId);
2765
+ if (record !== void 0) chains.push(decodeExitChain(record));
2766
+ }
2767
+ return chains;
2768
+ },
2769
+ putExitChains: async (chains) => {
2770
+ const ids = await readIndex();
2771
+ const set = new Set(ids);
2772
+ for (const chain of chains) {
2773
+ await col.put(chain.leafId, encodeExitChain(chain));
2774
+ set.add(chain.leafId);
2775
+ }
2776
+ await writeIndex([...set]);
2777
+ },
2778
+ deleteExitChains: async (leafIds) => {
2779
+ const ids = await readIndex();
2780
+ const drop = new Set(leafIds);
2781
+ for (const leafId of leafIds) await col.delete(leafId);
2782
+ await writeIndex(ids.filter((leafId) => !drop.has(leafId)));
2783
+ }
2784
+ };
2785
+ }
2786
+ //#endregion
2787
+ //#region src/spark/controller.ts
2788
+ const key = (ref) => `${ref.walletId}:${ref.groupIndex}:${ref.network}`;
2789
+ /** TransferId: the client and SO expect a UUIDv7 — mint it from the injected clock+random. */
2790
+ const transferId = (random, now) => uuidv7(random, now);
2791
+ /** Catch-up sync for one pass: claim every pending transfer, then read balance and identity. */
2792
+ async function resync(client, log) {
2793
+ const { failures } = await client.transfers.claimPending();
2794
+ for (const failure of failures) log(failure.transferId, failure.error);
2795
+ const [balance, identity] = await Promise.all([client.balance(), client.identityPublicKey()]);
2796
+ return {
2797
+ balance,
2798
+ identity
2799
+ };
2800
+ }
2801
+ /** Abortable backoff — resolves early when the signal aborts; removes its listener. */
2802
+ async function eventsBackoff(failures, signal) {
2803
+ await new Promise((resolve) => {
2804
+ const timer = setTimeout(() => {
2805
+ signal.removeEventListener("abort", onAbort);
2806
+ resolve();
2807
+ }, Math.min(2 ** failures, 30) * 1e3);
2808
+ const onAbort = () => {
2809
+ clearTimeout(timer);
2810
+ resolve();
2811
+ };
2812
+ signal.addEventListener("abort", onAbort, { once: true });
2813
+ });
2814
+ }
2815
+ var SparkController = class extends Controller {
2816
+ options;
2817
+ engineStore;
2818
+ accounts = /* @__PURE__ */ new Map();
2819
+ clients = /* @__PURE__ */ new Map();
2820
+ recentSeq = /* @__PURE__ */ new Map();
2821
+ aborts = /* @__PURE__ */ new Map();
2822
+ constructor(options) {
2823
+ super({
2824
+ name: "spark",
2825
+ defaultState: () => ({ accounts: {} })
2826
+ });
2827
+ this.options = options;
2828
+ }
2829
+ /** Engine calls this with the open store on unlock; `undefined` on lock. */
2830
+ setStore(store) {
2831
+ this.engineStore = store;
2832
+ }
2833
+ /**
2834
+ * Declarative account set: refs not yet tracked are patched `locked` and started when the store
2835
+ * is open; tracked refs no longer listed are stopped and dropped; running refs stay untouched.
2836
+ */
2837
+ setAccounts(refs) {
2838
+ const wanted = new Map(refs.map((ref) => [key(ref), ref]));
2839
+ for (const k of this.accounts.keys()) if (!wanted.has(k)) {
2840
+ this.accounts.delete(k);
2841
+ this.stopAccount(k);
2842
+ }
2843
+ for (const [k, ref] of wanted) {
2844
+ if (!this.accounts.has(k)) {
2845
+ this.accounts.set(k, ref);
2846
+ this.patch(k, { status: "locked" });
2847
+ }
2848
+ if (this.engineStore !== void 0 && !this.clients.has(k)) this.startAccount(ref);
2849
+ }
2850
+ }
2851
+ async init() {
2852
+ for (const ref of this.accounts.values()) this.startAccount(ref);
2853
+ await Promise.resolve();
2854
+ }
2855
+ async destroy() {
2856
+ await this.lockAll();
2857
+ }
2858
+ stopAccount(k) {
2859
+ this.aborts.get(k)?.abort();
2860
+ this.aborts.delete(k);
2861
+ this.clients.get(k)?.close();
2862
+ this.clients.delete(k);
2863
+ this.update((s) => {
2864
+ if (!(k in s.accounts)) return s;
2865
+ const { [k]: _dropped, ...rest } = s.accounts;
2866
+ return {
2867
+ ...s,
2868
+ accounts: rest
2869
+ };
2870
+ });
2871
+ }
2872
+ /** Lock: abort every event loop, close every client, reset state. */
2873
+ async lockAll() {
2874
+ for (const [k, client] of this.clients) {
2875
+ this.aborts.get(k)?.abort();
2876
+ client.close();
2877
+ }
2878
+ this.aborts.clear();
2879
+ this.clients.clear();
2880
+ this.engineStore = void 0;
2881
+ await Promise.resolve();
2882
+ this.update((s) => Object.keys(s.accounts).length === 0 ? s : {
2883
+ ...s,
2884
+ accounts: Object.fromEntries(Object.keys(s.accounts).map((k) => [k, { status: "locked" }]))
2885
+ });
2886
+ }
2887
+ startAccount(ref) {
2888
+ const k = key(ref);
2889
+ if (this.engineStore === void 0 || this.clients.has(k)) return;
2890
+ this.patch(k, { status: "connecting" });
2891
+ try {
2892
+ const col = this.engineStore.collection(`spark-exit-chains:${k}`);
2893
+ const client = this.options.createClient({
2894
+ ref,
2895
+ store: sparkStore(col),
2896
+ signer: this.options.spark.signer(ref),
2897
+ fetch: this.options.fetch
2898
+ });
2899
+ this.clients.set(k, client);
2900
+ const abort = new AbortController();
2901
+ this.aborts.set(k, abort);
2902
+ this.runEvents(k, client, abort.signal, ref.network);
2903
+ } catch (error) {
2904
+ this.patch(k, {
2905
+ status: "error",
2906
+ errorCategory: categorize(error)
2907
+ });
2908
+ this.options.logger.warn("spark account failed to start", error);
2909
+ }
2910
+ }
2911
+ runEvents(k, client, signal, network) {
2912
+ const loop = async () => {
2913
+ let failures = 0;
2914
+ while (!signal.aborted) {
2915
+ try {
2916
+ const { balance, identity } = await resync(client, (transferId, error) => {
2917
+ this.options.logger.warn(`spark catch-up claim failed for ${transferId} (${categorize(error)})`);
2918
+ });
2919
+ if (signal.aborted) return;
2920
+ this.patch(k, {
2921
+ balanceSats: balance,
2922
+ status: "ready",
2923
+ identityPublicKey: [...identity].map((b) => b.toString(16).padStart(2, "0")).join(""),
2924
+ sparkAddress: encodeSparkAddress({
2925
+ network,
2926
+ identityPublicKey: identity
2927
+ })
2928
+ });
2929
+ this.refreshRecent(k, client);
2930
+ const iterator = client.events(signal)[Symbol.asyncIterator]();
2931
+ let open = true;
2932
+ while (open) {
2933
+ const item = await iterator.next();
2934
+ if (item.done === true) open = false;
2935
+ else {
2936
+ const event = item.value;
2937
+ if (event.type === "transfer") try {
2938
+ await this.handleTransfer(client, event, signal, k);
2939
+ failures = 0;
2940
+ } catch (error) {
2941
+ if (!(error instanceof SparkInputError)) throw error;
2942
+ this.options.logger.warn(`spark claim of ${event.transferId} rejected (${categorize(error)})`);
2943
+ failures = 0;
2944
+ }
2945
+ }
2946
+ }
2947
+ failures += 1;
2948
+ this.options.logger.warn("spark event stream ended — resubscribing");
2949
+ } catch (error) {
2950
+ if (signal.aborted) return;
2951
+ failures += 1;
2952
+ if (failures === 1) this.patch(k, {
2953
+ status: "error",
2954
+ errorCategory: categorize(error)
2955
+ });
2956
+ this.options.logger.warn("spark event stream failed — resubscribing", error);
2957
+ }
2958
+ await eventsBackoff(failures, signal);
2959
+ }
2960
+ };
2961
+ loop();
2962
+ }
2963
+ async handleTransfer(client, event, signal, k) {
2964
+ if (event.direction === "incoming") await client.transfers.claim(event.transferId, { signal });
2965
+ this.patch(k, { balanceSats: await client.balance() });
2966
+ this.refreshRecent(k, client);
2967
+ }
2968
+ notesCollection(k) {
2969
+ return this.engineStore?.collection(`spark-notes:${k}`);
2970
+ }
2971
+ /** Attach locally written notes to a history page; malformed records warn and drop. */
2972
+ async withNotes(k, payments) {
2973
+ const col = this.notesCollection(k);
2974
+ if (col === void 0) return payments;
2975
+ return Promise.all(payments.map(async (payment) => {
2976
+ const bytes = await col.get(payment.id);
2977
+ if (bytes === void 0) return payment;
2978
+ let note;
2979
+ try {
2980
+ note = parsePaymentNote(JSON.parse(new TextDecoder().decode(bytes)));
2981
+ } catch {
2982
+ note = void 0;
2983
+ }
2984
+ if (note === void 0) {
2985
+ this.options.logger.warn(`spark note record for ${payment.id} ignored: malformed`, /* @__PURE__ */ new Error("malformed payment note"));
2986
+ return payment;
2987
+ }
2988
+ return {
2989
+ ...payment,
2990
+ note
2991
+ };
2992
+ }));
2993
+ }
2994
+ /**
2995
+ * Refill `recent` from the first history page. A failure keeps the previous page — it never
2996
+ * touches `status`/`errorCategory`. A per-account sequence keeps a slower older refresh from
2997
+ * overwriting a newer one.
2998
+ */
2999
+ async refreshRecent(k, client) {
3000
+ const seq = (this.recentSeq.get(k) ?? 0) + 1;
3001
+ this.recentSeq.set(k, seq);
3002
+ try {
3003
+ const { payments } = await client.transfers.history({ limit: 20 });
3004
+ const items = await this.withNotes(k, payments);
3005
+ if (this.recentSeq.get(k) === seq && this.clients.get(k) === client) this.patch(k, { recent: items });
3006
+ } catch (error) {
3007
+ this.options.logger.warn(`spark recent refresh failed for ${k}`, error);
3008
+ }
3009
+ }
3010
+ patch(k, delta) {
3011
+ this.update((s) => ({
3012
+ ...s,
3013
+ accounts: {
3014
+ ...s.accounts,
3015
+ [k]: {
3016
+ status: "locked",
3017
+ ...s.accounts[k],
3018
+ ...delta
3019
+ }
3020
+ }
3021
+ }));
3022
+ }
3023
+ clientFor(ref) {
3024
+ const client = this.clients.get(key(ref));
3025
+ if (client === void 0) throw new SparkControllerError("internal", "account is not connected");
3026
+ return client;
3027
+ }
3028
+ async guarded(fn) {
3029
+ try {
3030
+ return await fn();
3031
+ } catch (error) {
3032
+ if (error instanceof SparkControllerError) throw error;
3033
+ throw new SparkControllerError(categorize(error), "spark action failed", error);
3034
+ }
3035
+ }
3036
+ /**
3037
+ * One-off balance + activity probe for an account that may not be running: builds a temporary
3038
+ * client through the same factory on the account's own exit-chain collection (nothing diverges —
3039
+ * `balance()`/`transfers.history` never write it), reads, and closes it in `finally`.
3040
+ */
3041
+ async inspect(ref) {
3042
+ return this.guarded(async () => {
3043
+ if (this.engineStore === void 0) throw new SparkControllerError("internal", "engine store is not open");
3044
+ const k = key(ref);
3045
+ const running = this.clients.get(k);
3046
+ if (running !== void 0) {
3047
+ const [balance, page] = await Promise.all([running.balance(), running.transfers.history({ limit: 1 })]);
3048
+ return {
3049
+ balanceSats: balance,
3050
+ used: balance > 0 || page.payments.length > 0
3051
+ };
3052
+ }
3053
+ const client = this.options.createClient({
3054
+ ref,
3055
+ store: sparkStore(this.engineStore.collection(`spark-exit-chains:${k}`)),
3056
+ signer: this.options.spark.signer(ref),
3057
+ fetch: this.options.fetch
3058
+ });
3059
+ try {
3060
+ const [balance, page] = await Promise.all([client.balance(), client.transfers.history({ limit: 1 })]);
3061
+ return {
3062
+ balanceSats: balance,
3063
+ used: balance > 0 || page.payments.length > 0
3064
+ };
3065
+ } finally {
3066
+ client.close();
3067
+ }
3068
+ });
3069
+ }
3070
+ /** History for one account: a client page merged with locally written notes by id. */
3071
+ async history(ref, options) {
3072
+ return this.guarded(async () => {
3073
+ const { payments, nextOffset } = await this.clientFor(ref).transfers.history({
3074
+ ...options?.limit === void 0 ? {} : { limit: options.limit },
3075
+ ...options?.offset === void 0 ? {} : { offset: options.offset }
3076
+ });
3077
+ return {
3078
+ items: await this.withNotes(key(ref), payments),
3079
+ nextOffset
3080
+ };
3081
+ });
3082
+ }
3083
+ async send(ref, params) {
3084
+ return this.guarded(async () => {
3085
+ const client = this.clientFor(ref);
3086
+ const receiver = decodeSparkAddress(params.receiver);
3087
+ if (receiver.network !== ref.network) throw new SparkControllerError("rejected", "receiver address is on a different network");
3088
+ const identity = receiver.identityPublicKey;
3089
+ await this.options.spark.approve({
3090
+ account: ref,
3091
+ intent: {
3092
+ kind: "transfer",
3093
+ receivers: [{
3094
+ identityPublicKey: identity,
3095
+ amountSats: params.amountSats
3096
+ }]
3097
+ },
3098
+ verify: params.verify
3099
+ });
3100
+ const transfer = await client.transfers.send({
3101
+ receiver: params.receiver,
3102
+ amountSats: params.amountSats,
3103
+ transferId: transferId(this.options.random, this.options.now)
3104
+ });
3105
+ this.refreshRecent(key(ref), client);
3106
+ return transfer;
3107
+ });
3108
+ }
3109
+ async registerLightningAddress(ref, params) {
3110
+ const signer = this.options.spark.signer(ref);
3111
+ try {
3112
+ return await registerLightningAddress({
3113
+ ...params,
3114
+ fetch: this.options.fetch,
3115
+ signer
3116
+ });
3117
+ } catch (error) {
3118
+ throw error instanceof SparkControllerError ? error : new SparkControllerError("internal", "registration failed");
3119
+ }
3120
+ }
3121
+ async unregisterLightningAddress(ref, params) {
3122
+ const signer = this.options.spark.signer(ref);
3123
+ try {
3124
+ await unregisterLightningAddress({
3125
+ ...params,
3126
+ fetch: this.options.fetch,
3127
+ signer
3128
+ });
3129
+ } catch (error) {
3130
+ throw error instanceof SparkControllerError ? error : new SparkControllerError("internal", "unregistration failed");
3131
+ }
3132
+ }
3133
+ async payInvoice(ref, params) {
3134
+ return this.guarded(async () => {
3135
+ const client = this.clientFor(ref);
3136
+ await this.options.spark.approve({
3137
+ account: ref,
3138
+ intent: {
3139
+ kind: "lightning",
3140
+ invoice: params.invoice,
3141
+ ...params.amountSats === void 0 ? {} : { amountSats: params.amountSats },
3142
+ maxFeeSats: params.maxFeeSats
3143
+ },
3144
+ verify: params.verify
3145
+ });
3146
+ const payment = await client.lightning.pay({
3147
+ invoice: params.invoice,
3148
+ maxFeeSats: params.maxFeeSats,
3149
+ ...params.amountSats === void 0 ? {} : { amountSats: params.amountSats },
3150
+ transferId: transferId(this.options.random, this.options.now)
3151
+ });
3152
+ if (params.note !== void 0) try {
3153
+ await this.notesCollection(key(ref))?.put(payment.transferId, encodePaymentNote(params.note));
3154
+ } catch (error) {
3155
+ this.options.logger.warn("spark note write failed", error);
3156
+ }
3157
+ this.refreshRecent(key(ref), client);
3158
+ return payment;
3159
+ });
3160
+ }
3161
+ /**
3162
+ * Pays a Spark invoice address. The approval intent mirrors `send`: receiver identity and amount
3163
+ * come from the parsed invoice (the supplied `amountSats` fills an open invoice). Then
3164
+ * `client.invoices.pay` executes it.
3165
+ */
3166
+ async paySparkInvoice(ref, params) {
3167
+ return this.guarded(async () => {
3168
+ const client = this.clientFor(ref);
3169
+ const parsed = parseSparkInvoice(params.invoice, {
3170
+ network: ref.network,
3171
+ now: this.options.now()
3172
+ });
3173
+ const invoicePayment = parsed.fields.payment;
3174
+ if (invoicePayment !== void 0 && invoicePayment.type !== "sats") throw new SparkControllerError("rejected", "spark invoice: only sats invoices are supported");
3175
+ if (invoicePayment?.type === "sats" && invoicePayment.amount !== void 0 && params.amountSats !== void 0 && params.amountSats !== invoicePayment.amount) throw new SparkControllerError("rejected", "spark invoice: amountSats conflicts with the invoice amount");
3176
+ const amountSats = invoicePayment?.type === "sats" && invoicePayment.amount !== void 0 ? invoicePayment.amount : params.amountSats;
3177
+ if (amountSats === void 0) throw new SparkControllerError("rejected", "spark invoice: amountless invoice requires amountSats");
3178
+ await this.options.spark.approve({
3179
+ account: ref,
3180
+ intent: {
3181
+ kind: "transfer",
3182
+ receivers: [{
3183
+ identityPublicKey: parsed.receiverIdentityPublicKey,
3184
+ amountSats
3185
+ }]
3186
+ },
3187
+ verify: params.verify
3188
+ });
3189
+ const result = await client.invoices.pay(params.invoice, {
3190
+ amount: amountSats,
3191
+ transferId: transferId(this.options.random, this.options.now)
3192
+ });
3193
+ this.refreshRecent(key(ref), client);
3194
+ if (typeof result === "string") throw new SparkControllerError("internal", "unexpected token invoice result");
3195
+ return result;
3196
+ });
3197
+ }
3198
+ async createInvoice(ref, params) {
3199
+ return this.guarded(async () => this.clientFor(ref).lightning.createInvoice({
3200
+ amountSats: params.amountSats,
3201
+ ...params.memo === void 0 ? {} : { memo: params.memo }
3202
+ }));
3203
+ }
3204
+ async receiveAddress(ref) {
3205
+ const state = this.getState().accounts[key(ref)];
3206
+ await Promise.resolve();
3207
+ return state?.sparkAddress;
3208
+ }
3209
+ /**
3210
+ * Cooperative exit to an L1 address. The client exits `amountSats` of leaves and the payout is
3211
+ * `amountSats - fee`, so the approval intent carries the PAYOUT: `amountSats: exit - fee` and
3212
+ * `maxFeeSats: fee` — the kernel matches the exit output against the intent, not the leaf total.
3213
+ */
3214
+ async withdraw(ref, params) {
3215
+ return this.guarded(async () => {
3216
+ const client = this.clientFor(ref);
3217
+ try {
3218
+ bitcoinAddressScript(params.address, ref.network);
3219
+ } catch {
3220
+ throw new SparkControllerError("rejected", "withdrawal address is not on this network");
3221
+ }
3222
+ const exitAmount = params.amountSats ?? await client.balance();
3223
+ const fee = (await client.withdrawals.quoteCooperativeExit({
3224
+ address: params.address,
3225
+ amountSats: exitAmount
3226
+ })).fees[params.speed === "FAST" ? "fast" : params.speed === "MEDIUM" ? "medium" : "slow"];
3227
+ if (params.maxFeeSats !== void 0 && params.maxFeeSats < fee) throw new SparkControllerError("rejected", "maxFeeSats below the quoted fee");
3228
+ if (fee >= exitAmount) throw new SparkControllerError("insufficient-funds", "exit fee meets or exceeds the amount");
3229
+ await this.options.spark.approve({
3230
+ account: ref,
3231
+ intent: {
3232
+ kind: "exit",
3233
+ address: params.address,
3234
+ amountSats: exitAmount - fee,
3235
+ maxFeeSats: fee
3236
+ },
3237
+ verify: params.verify
3238
+ });
3239
+ const exit = await client.withdrawals.cooperativeExit({
3240
+ address: params.address,
3241
+ speed: params.speed,
3242
+ maxFeeSats: fee,
3243
+ amountSats: exitAmount,
3244
+ transferId: transferId(this.options.random, this.options.now)
3245
+ });
3246
+ this.refreshRecent(key(ref), client);
3247
+ return exit;
3248
+ });
3249
+ }
3250
+ };
3251
+ //#endregion
3252
+ //#region src/engine.ts
3253
+ /**
3254
+ * Composition root: wires the Wallet's lock state to the SparkController's lifecycle. Unlocked →
3255
+ * open the EngineStore and start every declared account; locked → abort event loops, close every
3256
+ * client, close the store and reset account state. Transitions are serialized on one promise chain,
3257
+ * so an unlock→lock flip can never overlap a still-opening store.
3258
+ */
3259
+ function createWalletEngine(options) {
3260
+ const backup = new BackupController({
3261
+ wallet: options.wallet,
3262
+ cloud: options.cloud,
3263
+ now: options.now,
3264
+ logger: options.logger
3265
+ });
3266
+ const controller = new SparkController({
3267
+ spark: options.sparkApis ?? spark(options.wallet),
3268
+ fetch: options.fetch,
3269
+ random: options.random,
3270
+ now: options.now,
3271
+ logger: options.logger,
3272
+ createClient: ({ ref, store, signer, fetch }) => createSparkClient({
3273
+ network: ref.network,
3274
+ signer,
3275
+ store,
3276
+ fetch
3277
+ })
3278
+ });
3279
+ const accounts = new AccountTreeController({
3280
+ wallet: options.wallet,
3281
+ spark: controller,
3282
+ sparkNetwork: options.sparkNetwork,
3283
+ logger: options.logger,
3284
+ now: options.now
3285
+ });
3286
+ const metaSync = new MetaSyncController({
3287
+ wallet: options.wallet,
3288
+ accounts,
3289
+ nostr: { signer: (ref) => nostr(options.wallet).signer(ref) },
3290
+ metaSync: options.metaSync,
3291
+ logger: options.logger,
3292
+ now: options.now
3293
+ });
3294
+ let chain = Promise.resolve();
3295
+ let store;
3296
+ let started = false;
3297
+ const transition = async () => {
3298
+ const unlocked = options.wallet.getSnapshot().vault === "unlocked";
3299
+ if (unlocked && !started) {
3300
+ started = true;
3301
+ try {
3302
+ store = await options.openStore();
3303
+ } catch (error) {
3304
+ started = false;
3305
+ store = void 0;
3306
+ options.logger.warn("engine store failed to open", error);
3307
+ return;
3308
+ }
3309
+ controller.setStore(store);
3310
+ backup.setStore(store);
3311
+ accounts.setStore(store);
3312
+ backup.trigger();
3313
+ await accounts.init();
3314
+ await controller.init();
3315
+ await metaSync.init();
3316
+ } else if (!unlocked && started) {
3317
+ started = false;
3318
+ await metaSync.lock();
3319
+ await accounts.lock();
3320
+ await controller.lockAll();
3321
+ controller.setStore(void 0);
3322
+ const s = store;
3323
+ store = void 0;
3324
+ await s?.close();
3325
+ }
3326
+ };
3327
+ const snapshotListener = () => {
3328
+ if (options.wallet.getSnapshot().vault === "unlocked") {
3329
+ backup.trigger();
3330
+ accounts.reconcile();
3331
+ metaSync.reconcile();
3332
+ } else backup.lock();
3333
+ };
3334
+ const schedule = () => {
3335
+ const previous = chain;
3336
+ chain = (async () => {
3337
+ try {
3338
+ await previous;
3339
+ await transition();
3340
+ } catch (error) {
3341
+ options.logger.warn("engine lock transition failed", error);
3342
+ }
3343
+ })();
3344
+ };
3345
+ const unsubscribe = options.wallet.subscribe(() => {
3346
+ schedule();
3347
+ snapshotListener();
3348
+ });
3349
+ schedule();
3350
+ return {
3351
+ spark: controller,
3352
+ accounts,
3353
+ metaSync,
3354
+ backup,
3355
+ zaps: {
3356
+ prepare: async (params) => prepareZap(params, {
3357
+ fetch: options.fetch,
3358
+ now: options.now
3359
+ }),
3360
+ pay: async (account, prepared, payOptions) => payZap(controller, account, prepared, {
3361
+ ...payOptions,
3362
+ now: options.now
3363
+ })
3364
+ },
3365
+ payments: {
3366
+ parse: (input, network) => parsePayment(input, {
3367
+ network,
3368
+ now: options.now()
3369
+ }),
3370
+ resolve: async (input, network) => resolvePayment(input, {
3371
+ network,
3372
+ now: options.now(),
3373
+ fetch: options.fetch
3374
+ }),
3375
+ pay: async (params) => pay({
3376
+ spark: controller,
3377
+ fetch: options.fetch,
3378
+ now: options.now
3379
+ }, params)
3380
+ },
3381
+ unsubscribe
3382
+ };
3383
+ }
3384
+ //#endregion
3385
+ //#region src/backup/memory.ts
3386
+ const tick = Promise.resolve();
3387
+ /** In-memory CloudStore for tests and the playground; swap the Map for cross-engine sharing. */
3388
+ function createMemoryCloudStore(backing) {
3389
+ const files = backing ?? /* @__PURE__ */ new Map();
3390
+ let reachable = true;
3391
+ return {
3392
+ available: async () => {
3393
+ await tick;
3394
+ return reachable;
3395
+ },
3396
+ list: async () => {
3397
+ await tick;
3398
+ return [...files.keys()].sort();
3399
+ },
3400
+ read: async (name) => {
3401
+ await tick;
3402
+ return files.get(name);
3403
+ },
3404
+ write: async (name, text) => {
3405
+ await tick;
3406
+ files.set(name, text);
3407
+ },
3408
+ delete: async (name) => {
3409
+ await tick;
3410
+ files.delete(name);
3411
+ },
3412
+ setReachable: (v) => {
3413
+ reachable = v;
3414
+ }
3415
+ };
3416
+ }
3417
+ //#endregion
3418
+ export { AccountTreeController, AccountTreeError, BackupController, BackupControllerError, Controller, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, payZap, prepareZap, resolvePayment, sparkStore };
3419
+
3420
+ //# sourceMappingURL=index.mjs.map