@breeztech/breez-sdk-spark 0.22.2 → 0.23.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.
Files changed (44) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +65 -5
  3. package/bundler/breez_sdk_spark_wasm_bg.js +66 -27
  4. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  6. package/bundler/index.js +15 -2
  7. package/bundler/package.json +5 -0
  8. package/bundler/storage/index.js +6 -0
  9. package/bundler/tree-store/index.js +1518 -0
  10. package/bundler/tree-store/package.json +12 -0
  11. package/deno/breez_sdk_spark_wasm.d.ts +65 -5
  12. package/deno/breez_sdk_spark_wasm.js +66 -27
  13. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  15. package/nodejs/breez_sdk_spark_wasm.d.ts +65 -5
  16. package/nodejs/breez_sdk_spark_wasm.js +66 -27
  17. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  18. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  19. package/nodejs/index.js +11 -0
  20. package/nodejs/mysql-storage/index.cjs +9 -1
  21. package/nodejs/mysql-storage/migrations.cjs +6 -0
  22. package/nodejs/mysql-tree-store/index.cjs +296 -29
  23. package/nodejs/mysql-tree-store/migrations.cjs +198 -34
  24. package/nodejs/package.json +1 -0
  25. package/nodejs/postgres-storage/index.cjs +9 -1
  26. package/nodejs/postgres-storage/migrations.cjs +6 -0
  27. package/nodejs/postgres-tree-store/index.cjs +301 -40
  28. package/nodejs/postgres-tree-store/migrations.cjs +42 -0
  29. package/nodejs/storage/index.cjs +14 -3
  30. package/nodejs/storage/migrations.cjs +6 -0
  31. package/nodejs/tree-store/errors.cjs +13 -0
  32. package/nodejs/tree-store/index.cjs +1185 -0
  33. package/nodejs/tree-store/migrations.cjs +185 -0
  34. package/nodejs/tree-store/package.json +9 -0
  35. package/package.json +1 -1
  36. package/web/breez_sdk_spark_wasm.d.ts +74 -11
  37. package/web/breez_sdk_spark_wasm.js +66 -27
  38. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  39. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  40. package/web/index.js +15 -2
  41. package/web/package.json +5 -0
  42. package/web/storage/index.js +6 -0
  43. package/web/tree-store/index.js +1518 -0
  44. package/web/tree-store/package.json +12 -0
@@ -0,0 +1,1518 @@
1
+ /**
2
+ * ES module implementation of the durable tree store for browsers, backed by
3
+ * IndexedDB. Ports the two-table (leaves + ancestors) model and reservation /
4
+ * refresh / spent-guard semantics of the PostgreSQL tree store to a single-user
5
+ * IndexedDB database: no user_id scoping and no `brz_` table prefix, since each
6
+ * browser origin has its own isolated database.
7
+ *
8
+ * IndexedDB transaction lifetime: a transaction auto-commits once its request
9
+ * queue drains and control returns to the event loop, so awaiting any non-IDB
10
+ * promise mid-transaction closes it. Every logical operation therefore runs in
11
+ * ONE transaction: all reads are issued up front, and the compute + writes run
12
+ * synchronously inside the last read's success handler (never awaiting anything
13
+ * that isn't part of the transaction). Because IndexedDB serializes readwrite
14
+ * transactions with overlapping store scope, this also gives the mutating ops
15
+ * the same serialization the Postgres backend gets from its per-tenant lock.
16
+ */
17
+
18
+ /** Reservations older than this are stale and get released on the next refresh. */
19
+ const RESERVATION_TIMEOUT_MS = 300 * 1000; // 5 minutes
20
+
21
+ /** Spent-leaf markers older than this (relative to a refresh) are pruned. */
22
+ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
23
+
24
+ const DB_VERSION = 5;
25
+
26
+ const STORE_LEAVES = "leaves";
27
+ const STORE_ANCESTORS = "ancestors";
28
+ const STORE_RESERVATIONS = "reservations";
29
+ const STORE_SPENT = "spent";
30
+ const STORE_SWAP_STATUS = "swapStatus";
31
+
32
+ /** Singleton key of the swap-status row. */
33
+ const SWAP_STATUS_ID = 1;
34
+
35
+ class TreeStoreError extends Error {
36
+ constructor(message, cause = null) {
37
+ super(message);
38
+ this.name = "TreeStoreError";
39
+ this.cause = cause;
40
+ if (Error.captureStackTrace) {
41
+ Error.captureStackTrace(this, TreeStoreError);
42
+ }
43
+ }
44
+ }
45
+
46
+ function _resolveIndexedDB() {
47
+ if (typeof indexedDB !== "undefined") return indexedDB;
48
+ if (typeof globalThis !== "undefined" && globalThis.indexedDB) {
49
+ return globalThis.indexedDB;
50
+ }
51
+ if (typeof self !== "undefined" && self.indexedDB) return self.indexedDB;
52
+ if (typeof window !== "undefined" && window.indexedDB) return window.indexedDB;
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Pair a leaf with its ancestors (nearest first) by walking `parent_node_id`
58
+ * through `nodes`. Returns null if the leaf itself is absent; stops at a gap or
59
+ * cycle, returning a partial chain.
60
+ * @param {Map<string, object>} nodes
61
+ * @param {string} leafId
62
+ * @returns {{leaf: object, ancestors: Array<object>}|null}
63
+ */
64
+ function assembleExitChain(nodes, leafId) {
65
+ const leaf = nodes.get(leafId);
66
+ if (!leaf) return null;
67
+ const ancestors = [];
68
+ const visited = new Set([leafId]);
69
+ let current = leaf.parent_node_id;
70
+ while (current != null && !visited.has(current)) {
71
+ visited.add(current);
72
+ const node = nodes.get(current);
73
+ if (!node) break;
74
+ ancestors.push(node);
75
+ current = node.parent_node_id;
76
+ }
77
+ return { leaf, ancestors };
78
+ }
79
+
80
+ class WebTreeStore {
81
+ constructor(dbName = "BreezSdkSparkTree", logger = null) {
82
+ this.dbName = dbName;
83
+ this.db = null;
84
+ this.logger = logger;
85
+ }
86
+
87
+ async initialize() {
88
+ if (this.db) return this;
89
+
90
+ const idbFactory = _resolveIndexedDB();
91
+ if (!idbFactory) {
92
+ throw new TreeStoreError("IndexedDB is not available in this environment");
93
+ }
94
+
95
+ this.db = await new Promise((resolve, reject) => {
96
+ const request = idbFactory.open(this.dbName, DB_VERSION);
97
+
98
+ request.onupgradeneeded = (event) => {
99
+ const db = event.target.result;
100
+
101
+ // Spendable leaf pool. `data` is the full TreeNode; the other columns
102
+ // are projected out of it so queries avoid deserializing the blob.
103
+ if (!db.objectStoreNames.contains(STORE_LEAVES)) {
104
+ const leaves = db.createObjectStore(STORE_LEAVES, { keyPath: "id" });
105
+ leaves.createIndex("reservation_id", "reservation_id", {
106
+ unique: false,
107
+ });
108
+ // Stored 0/1 rather than as a boolean: IndexedDB rejects booleans as
109
+ // index keys, and indexing this is what keeps leavesMissingExitChains
110
+ // off a full scan of the store.
111
+ leaves.createIndex("chain_complete", "chain_complete", {
112
+ unique: false,
113
+ });
114
+ }
115
+
116
+ // Intermediate exit-chain nodes, kept separate from the leaf pool and
117
+ // carrying no pool metadata (no reservation / missing / added_at). Each
118
+ // row is owned by the leaf it belongs to (key `[leaf_id, id]`), so a
119
+ // node shared by several leaves' chains stores one row per leaf rather
120
+ // than one deduplicated row. Version 1 keyed this store by `id` alone;
121
+ // a keyPath cannot be changed in place, and there is no data worth
122
+ // migrating out of it, so it is dropped and recreated. Gated on
123
+ // `oldVersion` so a later, unrelated version bump does not wipe it again.
124
+ if (event.oldVersion < 2 && db.objectStoreNames.contains(STORE_ANCESTORS)) {
125
+ db.deleteObjectStore(STORE_ANCESTORS);
126
+ }
127
+ if (!db.objectStoreNames.contains(STORE_ANCESTORS)) {
128
+ const ancestors = db.createObjectStore(STORE_ANCESTORS, {
129
+ keyPath: ["leaf_id", "id"],
130
+ });
131
+ ancestors.createIndex("leaf_id", "leaf_id", { unique: false });
132
+ }
133
+
134
+ // An existing wallet gets the flag derived from the ancestor rows it
135
+ // already holds, so it does not refetch every chain. The index is
136
+ // created here and populated by the rewrite below, since createIndex
137
+ // cannot run from an async callback.
138
+ if (event.oldVersion > 0 && event.oldVersion < DB_VERSION) {
139
+ const tx = event.target.transaction;
140
+ const leaves = tx.objectStore(STORE_LEAVES);
141
+ if (!leaves.indexNames.contains("chain_complete")) {
142
+ leaves.createIndex("chain_complete", "chain_complete", {
143
+ unique: false,
144
+ });
145
+ }
146
+
147
+ const ancestors = tx.objectStore(STORE_ANCESTORS);
148
+ const linked = new Set();
149
+ ancestors.openCursor().onsuccess = (cursorEvent) => {
150
+ const cursor = cursorEvent.target.result;
151
+ if (cursor) {
152
+ linked.add(`${cursor.value.leaf_id}\u0000${cursor.value.id}`);
153
+ cursor.continue();
154
+ return;
155
+ }
156
+ leaves.openCursor().onsuccess = (leafEvent) => {
157
+ const leafCursor = leafEvent.target.result;
158
+ if (!leafCursor) return;
159
+ const row = leafCursor.value;
160
+ row.chain_complete =
161
+ row.parent_node_id == null ||
162
+ linked.has(`${row.id}\u0000${row.parent_node_id}`)
163
+ ? 1
164
+ : 0;
165
+ leafCursor.update(row);
166
+ leafCursor.continue();
167
+ };
168
+ };
169
+ }
170
+
171
+ if (!db.objectStoreNames.contains(STORE_RESERVATIONS)) {
172
+ db.createObjectStore(STORE_RESERVATIONS, { keyPath: "id" });
173
+ }
174
+
175
+ if (!db.objectStoreNames.contains(STORE_SPENT)) {
176
+ db.createObjectStore(STORE_SPENT, { keyPath: "id" });
177
+ }
178
+
179
+ if (!db.objectStoreNames.contains(STORE_SWAP_STATUS)) {
180
+ db.createObjectStore(STORE_SWAP_STATUS, { keyPath: "id" });
181
+ }
182
+ };
183
+
184
+ request.onsuccess = () => {
185
+ const db = request.result;
186
+ // Close on a version change requested by another connection so it is
187
+ // not blocked by this one.
188
+ db.onversionchange = () => {
189
+ db.close();
190
+ this.db = null;
191
+ };
192
+ resolve(db);
193
+ };
194
+
195
+ request.onerror = () =>
196
+ reject(
197
+ new TreeStoreError(
198
+ `Failed to open IndexedDB: ${request.error?.message || "Unknown error"}`,
199
+ request.error
200
+ )
201
+ );
202
+
203
+ request.onblocked = () => {
204
+ // Another open connection holds an older version. It will resolve once
205
+ // that connection closes (see onversionchange above).
206
+ };
207
+ });
208
+
209
+ return this;
210
+ }
211
+
212
+ close() {
213
+ if (this.db) {
214
+ this.db.close();
215
+ this.db = null;
216
+ }
217
+ }
218
+
219
+ // ===== Transaction runner =====
220
+
221
+ /**
222
+ * Runs one transaction. `reads` is a list of `{ name, store, key?, index?,
223
+ * all? }`: with a key it is a `get`, without it a `getAll`, and `all` with a
224
+ * key reads every record under that key; `index` reads through that
225
+ * index on `store` instead of the store directly. All reads are issued up
226
+ * front; once the last completes, `compute(results, tx)` runs synchronously
227
+ * in that read's success handler and may issue writes on `tx`. Its return
228
+ * value resolves the promise on `oncomplete`, so writes are durable before
229
+ * resolve. `compute` must never await a non-IDB promise (it would close `tx`).
230
+ */
231
+ _txRun(storeNames, mode, reads, compute) {
232
+ return new Promise((resolve, reject) => {
233
+ if (!this.db) {
234
+ reject(new TreeStoreError("Database not initialized"));
235
+ return;
236
+ }
237
+
238
+ let tx;
239
+ try {
240
+ tx = this.db.transaction(storeNames, mode);
241
+ } catch (e) {
242
+ reject(e);
243
+ return;
244
+ }
245
+
246
+ let outcome;
247
+ let settled = false;
248
+ let computeError = null;
249
+ const done = (fn) => {
250
+ if (!settled) {
251
+ settled = true;
252
+ fn();
253
+ }
254
+ };
255
+
256
+ tx.oncomplete = () => done(() => resolve(outcome));
257
+ tx.onabort = () =>
258
+ done(() =>
259
+ reject(
260
+ computeError ||
261
+ new TreeStoreError(
262
+ `transaction aborted: ${tx.error?.message || "unknown"}`,
263
+ tx.error
264
+ )
265
+ )
266
+ );
267
+ tx.onerror = () =>
268
+ done(() =>
269
+ reject(
270
+ computeError ||
271
+ new TreeStoreError(
272
+ `transaction error: ${tx.error?.message || "unknown"}`,
273
+ tx.error
274
+ )
275
+ )
276
+ );
277
+
278
+ const runCompute = (results) => {
279
+ try {
280
+ outcome = compute(results, tx);
281
+ } catch (e) {
282
+ computeError = e;
283
+ try {
284
+ tx.abort();
285
+ } catch (_) {
286
+ done(() => reject(e));
287
+ }
288
+ }
289
+ };
290
+
291
+ if (reads.length === 0) {
292
+ runCompute({});
293
+ return;
294
+ }
295
+
296
+ const results = {};
297
+ let pending = reads.length;
298
+ for (const r of reads) {
299
+ let req;
300
+ try {
301
+ const store = tx.objectStore(r.store);
302
+ const source = r.index ? store.index(r.index) : store;
303
+ if (r.keysOnly) {
304
+ // Primary keys of the matching records, without reading a value.
305
+ req = source.getAllKeys(r.key);
306
+ } else if (r.all) {
307
+ // Every record under `key`, rather than the single first match a
308
+ // bare `get` returns. An index key matches many records.
309
+ req = source.getAll(r.key);
310
+ } else {
311
+ req = "key" in r ? source.get(r.key) : source.getAll();
312
+ }
313
+ } catch (e) {
314
+ done(() => reject(e));
315
+ return;
316
+ }
317
+ req.onsuccess = () => {
318
+ results[r.name] = req.result;
319
+ pending -= 1;
320
+ if (pending === 0) runCompute(results);
321
+ };
322
+ // A failed request bubbles to the transaction and aborts it, surfacing
323
+ // via tx.onabort above.
324
+ }
325
+ });
326
+ }
327
+
328
+ // ===== Reads =====
329
+
330
+ /**
331
+ * Reconstruct the exit chains for many leaves in one transaction, each as
332
+ * { leaf, ancestors } with ancestors nearest first. A leaf absent from the store
333
+ * is skipped; a chain that hits a gap comes back partial.
334
+ * @param {Array<string>} leafIds
335
+ * @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
336
+ */
337
+ async getExitChains(leafIds) {
338
+ try {
339
+ if (!leafIds || leafIds.length === 0) return [];
340
+ return await new Promise((resolve, reject) => {
341
+ if (!this.db) {
342
+ reject(new TreeStoreError("Database not initialized"));
343
+ return;
344
+ }
345
+ // Each requested leaf's own ancestor rows (via the leaf_id index) plus
346
+ // the leaf itself, walked one chain at a time so a node id stored under
347
+ // another leaf can never cross-contaminate this one. All reads are
348
+ // issued up front; oncomplete only fires once every one of them (and
349
+ // any request queued afterwards) has settled.
350
+ const tx = this.db.transaction([STORE_LEAVES, STORE_ANCESTORS], "readonly");
351
+ const leavesStore = tx.objectStore(STORE_LEAVES);
352
+ const ancestorsIndex = tx.objectStore(STORE_ANCESTORS).index("leaf_id");
353
+
354
+ const leafById = new Map();
355
+ const ancestorsByLeaf = new Map();
356
+ let settled = false;
357
+ const fail = (msg) => {
358
+ if (!settled) {
359
+ settled = true;
360
+ reject(new TreeStoreError(msg));
361
+ }
362
+ };
363
+ tx.onabort = () =>
364
+ fail(`Failed to get exit chains: ${tx.error?.message || "aborted"}`);
365
+ tx.onerror = () =>
366
+ fail(`Failed to get exit chains: ${tx.error?.message || "error"}`);
367
+ tx.oncomplete = () => {
368
+ if (settled) return;
369
+ settled = true;
370
+ const result = [];
371
+ for (const id of leafIds) {
372
+ const leafRow = leafById.get(id);
373
+ if (!leafRow) continue;
374
+ const nodes = ancestorsByLeaf.get(id) || new Map();
375
+ nodes.set(leafRow.id, leafRow.data);
376
+ const pedigree = assembleExitChain(nodes, id);
377
+ if (pedigree) result.push(pedigree);
378
+ }
379
+ resolve(result);
380
+ };
381
+
382
+ for (const id of leafIds) {
383
+ const leafReq = leavesStore.get(id);
384
+ leafReq.onsuccess = () => {
385
+ if (leafReq.result) leafById.set(id, leafReq.result);
386
+ };
387
+ const ancestorsReq = ancestorsIndex.getAll(id);
388
+ ancestorsReq.onsuccess = () => {
389
+ const nodes = new Map();
390
+ for (const row of ancestorsReq.result || []) nodes.set(row.id, row.data);
391
+ ancestorsByLeaf.set(id, nodes);
392
+ };
393
+ }
394
+ });
395
+ } catch (error) {
396
+ if (error instanceof TreeStoreError) throw error;
397
+ throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Ids of the stored leaves whose chain cannot back an exit: the leaf has a
403
+ * parent, and no ancestor row of its own holds that parent.
404
+ * @returns {Promise<Array<string>>}
405
+ */
406
+ async leavesMissingExitChains() {
407
+ try {
408
+ // A leaf that is itself a root is always flagged complete, so the flag
409
+ // alone selects what needs a chain, and the index yields the leaf ids
410
+ // without deserializing a single stored node.
411
+ return await this._txRun(
412
+ [STORE_LEAVES],
413
+ "readonly",
414
+ [
415
+ {
416
+ name: "ids",
417
+ store: STORE_LEAVES,
418
+ index: "chain_complete",
419
+ key: 0,
420
+ keysOnly: true,
421
+ },
422
+ ],
423
+ (res) => res.ids
424
+ );
425
+ } catch (error) {
426
+ if (error instanceof TreeStoreError) throw error;
427
+ throw new TreeStoreError(
428
+ `Failed to get leaves missing exit chains: ${error.message}`,
429
+ error
430
+ );
431
+ }
432
+ }
433
+
434
+ async getLeaves() {
435
+ try {
436
+ return await this._txRun(
437
+ [STORE_LEAVES, STORE_RESERVATIONS],
438
+ "readonly",
439
+ [
440
+ { name: "leaves", store: STORE_LEAVES },
441
+ { name: "reservations", store: STORE_RESERVATIONS },
442
+ ],
443
+ (res) => {
444
+ const resMap = new Map(res.reservations.map((r) => [r.id, r]));
445
+ const available = [];
446
+ const notAvailable = [];
447
+ const availableMissingFromOperators = [];
448
+ const reservedForPayment = [];
449
+ const reservedForSwap = [];
450
+
451
+ for (const row of res.leaves) {
452
+ const node = row.data;
453
+ const purpose =
454
+ row.reservation_id != null
455
+ ? resMap.get(row.reservation_id)?.purpose
456
+ : undefined;
457
+
458
+ const spendable = node.status === "Available";
459
+ if (purpose) {
460
+ if (purpose === "Payment") reservedForPayment.push(node);
461
+ else if (purpose === "Swap") reservedForSwap.push(node);
462
+ } else if (!spendable) {
463
+ notAvailable.push(node);
464
+ } else if (row.is_missing_from_operators) {
465
+ availableMissingFromOperators.push(node);
466
+ } else {
467
+ available.push(node);
468
+ }
469
+ }
470
+
471
+ return {
472
+ available,
473
+ notAvailable,
474
+ availableMissingFromOperators,
475
+ reservedForPayment,
476
+ reservedForSwap,
477
+ };
478
+ }
479
+ );
480
+ } catch (error) {
481
+ if (error instanceof TreeStoreError) throw error;
482
+ throw new TreeStoreError(`Failed to get leaves: ${error.message}`, error);
483
+ }
484
+ }
485
+
486
+ async getAvailableBalance() {
487
+ try {
488
+ return await this._txRun(
489
+ [STORE_LEAVES, STORE_RESERVATIONS],
490
+ "readonly",
491
+ [
492
+ { name: "leaves", store: STORE_LEAVES },
493
+ { name: "reservations", store: STORE_RESERVATIONS },
494
+ ],
495
+ (res) => {
496
+ const resMap = new Map(res.reservations.map((r) => [r.id, r]));
497
+ // Spendable = unreserved-available + swap-reserved (mirrors
498
+ // Leaves::balance, which also counts missing-from-operators leaves
499
+ // that are still Available and unreserved).
500
+ let balance = 0n;
501
+ for (const row of res.leaves) {
502
+ const purpose =
503
+ row.reservation_id != null
504
+ ? resMap.get(row.reservation_id)?.purpose
505
+ : undefined;
506
+ const included =
507
+ (row.reservation_id == null && row.status === "Available") ||
508
+ purpose === "Swap";
509
+ if (included) balance += BigInt(row.value);
510
+ }
511
+ return balance;
512
+ }
513
+ );
514
+ } catch (error) {
515
+ if (error instanceof TreeStoreError) throw error;
516
+ throw new TreeStoreError(
517
+ `Failed to get available balance: ${error.message}`,
518
+ error
519
+ );
520
+ }
521
+ }
522
+
523
+ async getVerifiedLeafKeys() {
524
+ try {
525
+ return await this._txRun(
526
+ [STORE_LEAVES, STORE_RESERVATIONS],
527
+ "readonly",
528
+ [
529
+ { name: "leaves", store: STORE_LEAVES },
530
+ { name: "reservations", store: STORE_RESERVATIONS },
531
+ ],
532
+ (res) => {
533
+ const resIds = new Set(res.reservations.map((r) => r.id));
534
+ const out = [];
535
+ for (const row of res.leaves) {
536
+ const hasReservation =
537
+ row.reservation_id != null && resIds.has(row.reservation_id);
538
+ // Every reserved leaf plus every Available one; nothing that is
539
+ // neither reserved nor Available.
540
+ if (hasReservation || row.status === "Available") {
541
+ out.push([
542
+ row.id,
543
+ row.verifying_public_key,
544
+ row.signing_public_key,
545
+ ]);
546
+ }
547
+ }
548
+ return out;
549
+ }
550
+ );
551
+ } catch (error) {
552
+ if (error instanceof TreeStoreError) throw error;
553
+ throw new TreeStoreError(
554
+ `Failed to get verified leaf keys: ${error.message}`,
555
+ error
556
+ );
557
+ }
558
+ }
559
+
560
+ async now() {
561
+ return this._nowMs();
562
+ }
563
+
564
+ // ===== Writes =====
565
+
566
+ async addLeaves(leaves) {
567
+ try {
568
+ if (!leaves || leaves.length === 0) return;
569
+ const leafIds = leaves.map((l) => l.id);
570
+
571
+ await this._txRun(
572
+ [STORE_LEAVES, STORE_SPENT],
573
+ "readwrite",
574
+ [{ name: "leaves", store: STORE_LEAVES }],
575
+ (res, tx) => {
576
+ const leavesStore = tx.objectStore(STORE_LEAVES);
577
+ const spentStore = tx.objectStore(STORE_SPENT);
578
+
579
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
580
+
581
+ // Re-adding a leaf clears any stale spent marker for it.
582
+ for (const id of leafIds) spentStore.delete(id);
583
+
584
+ for (const leaf of leaves) {
585
+ leavesStore.put(this._leafRow(leaf, false, leafMap.get(leaf.id)));
586
+ }
587
+ }
588
+ );
589
+ } catch (error) {
590
+ if (error instanceof TreeStoreError) throw error;
591
+ throw new TreeStoreError(`Failed to add leaves: ${error.message}`, error);
592
+ }
593
+ }
594
+
595
+ async storeAncestors(pedigrees) {
596
+ try {
597
+ if (!pedigrees || pedigrees.length === 0) return;
598
+
599
+ // Scoped to the pedigrees' own rows: reading the two stores whole would
600
+ // deserialize every leaf and ancestor row the wallet holds to write a
601
+ // handful of chains.
602
+ const reads = [];
603
+ for (const p of pedigrees) {
604
+ reads.push({
605
+ name: `leaf:${p.leaf.id}`,
606
+ store: STORE_LEAVES,
607
+ key: p.leaf.id,
608
+ });
609
+ reads.push({
610
+ name: `ancestors:${p.leaf.id}`,
611
+ store: STORE_ANCESTORS,
612
+ index: "leaf_id",
613
+ key: p.leaf.id,
614
+ all: true,
615
+ });
616
+ }
617
+
618
+ await this._txRun(
619
+ [STORE_LEAVES, STORE_ANCESTORS],
620
+ "readwrite",
621
+ reads,
622
+ (res, tx) => {
623
+ const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
624
+ const leavesStore = tx.objectStore(STORE_LEAVES);
625
+ const ancestorRowsByLeaf = this._ancestorRowsByLeaf(
626
+ pedigrees.flatMap((p) => res[`ancestors:${p.leaf.id}`] || [])
627
+ );
628
+ // A leaf can be spent between its chain being resolved and this write,
629
+ // and a chain is only ever removed with its leaf. Writing one for a leaf
630
+ // that is already gone would leave it behind for good.
631
+ const leafRowsById = new Map();
632
+ for (const p of pedigrees) {
633
+ const row = res[`leaf:${p.leaf.id}`];
634
+ if (row) {
635
+ leafRowsById.set(row.id, row);
636
+ }
637
+ }
638
+
639
+ for (const p of pedigrees) {
640
+ const leafRow = leafRowsById.get(p.leaf.id);
641
+ if (!leafRow) {
642
+ continue;
643
+ }
644
+ this._replaceAncestors(
645
+ ancestorsStore,
646
+ ancestorRowsByLeaf,
647
+ p.leaf.id,
648
+ p.ancestors
649
+ );
650
+ // This write only touches ancestor rows, so the leaf's completeness
651
+ // flag has to be refreshed alongside them. Judged against the stored
652
+ // leaf, not the fetched one: a renewal may have reparented it while
653
+ // the chain was in flight, in which case the chain that just arrived
654
+ // no longer reaches it.
655
+ const chainComplete = this._chainComplete(
656
+ leafRow.data,
657
+ p.ancestors,
658
+ leafRow
659
+ );
660
+ if (chainComplete !== !!leafRow.chain_complete) {
661
+ leavesStore.put({
662
+ ...leafRow,
663
+ chain_complete: chainComplete ? 1 : 0,
664
+ });
665
+ }
666
+ }
667
+ }
668
+ );
669
+ } catch (error) {
670
+ if (error instanceof TreeStoreError) throw error;
671
+ throw new TreeStoreError(`Failed to store ancestors: ${error.message}`, error);
672
+ }
673
+ }
674
+
675
+ async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
676
+ try {
677
+ const reported = leaves || [];
678
+ const missing = missingLeaves || [];
679
+ const refreshMs = refreshStartedAtMs;
680
+
681
+ await this._txRun(
682
+ [STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT, STORE_SWAP_STATUS],
683
+ "readwrite",
684
+ [
685
+ { name: "leaves", store: STORE_LEAVES },
686
+ { name: "ancestors", store: STORE_ANCESTORS },
687
+ { name: "reservations", store: STORE_RESERVATIONS },
688
+ { name: "spent", store: STORE_SPENT },
689
+ { name: "swap", store: STORE_SWAP_STATUS, key: SWAP_STATUS_ID },
690
+ ],
691
+ (res, tx) => {
692
+ const leavesStore = tx.objectStore(STORE_LEAVES);
693
+ const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
694
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
695
+ const spentStore = tx.objectStore(STORE_SPENT);
696
+
697
+ const nowMs = this._nowMs();
698
+ const leafMap = new Map(res.leaves.map((r) => [r.id, { ...r }]));
699
+ const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
700
+
701
+ // Release + drop stale reservations BEFORE the swap guard, otherwise a
702
+ // stale Swap reservation would pin has_active_swap true forever and
703
+ // set_leaves could never make progress.
704
+ const staleCutoff = nowMs - RESERVATION_TIMEOUT_MS;
705
+ const staleIds = new Set(
706
+ res.reservations.filter((r) => r.created_at < staleCutoff).map((r) => r.id)
707
+ );
708
+ for (const id of staleIds) reservationsStore.delete(id);
709
+ for (const row of leafMap.values()) {
710
+ if (row.reservation_id != null && staleIds.has(row.reservation_id)) {
711
+ row.reservation_id = null;
712
+ leavesStore.put(row);
713
+ }
714
+ }
715
+ const remainingReservations = res.reservations.filter(
716
+ (r) => !staleIds.has(r.id)
717
+ );
718
+
719
+ // Swap guard: skip the refresh body if a swap is in flight or one
720
+ // completed during the refresh (its change would otherwise be lost).
721
+ const hasActiveSwap = remainingReservations.some((r) => r.purpose === "Swap");
722
+ const swapCompletedDuringRefresh =
723
+ !!res.swap && res.swap.last_completed_at >= refreshMs;
724
+ if (hasActiveSwap || swapCompletedDuringRefresh) {
725
+ return; // stale-reservation cleanup above still commits
726
+ }
727
+
728
+ // Prune old spent markers, then collect the ones still fresh enough to
729
+ // suppress re-adding a just-spent leaf during this refresh window.
730
+ const spentCleanupCutoff = refreshMs - SPENT_MARKER_CLEANUP_THRESHOLD_MS;
731
+ for (const s of res.spent) {
732
+ if (s.spent_at < spentCleanupCutoff) spentStore.delete(s.id);
733
+ }
734
+ const spentIds = new Set(
735
+ res.spent.filter((s) => s.spent_at >= refreshMs).map((s) => s.id)
736
+ );
737
+
738
+ // Delete non-reserved leaves added before the refresh started (this
739
+ // includes leaves released just above by the stale-reservation
740
+ // cleanup). A leaf reported again in this same refresh is re-inserted
741
+ // below, so its ancestor rows must survive: only ids that do NOT
742
+ // reappear (truly gone, e.g. spent) get their ancestor rows dropped
743
+ // alongside them.
744
+ // Taken before the deletion below: a refresh carries leaves alone, so
745
+ // the chain-complete flag a reported leaf keeps has to come from the
746
+ // row it already had, or every refresh would report every leaf as
747
+ // missing its chain.
748
+ const priorRows = new Map(leafMap);
749
+
750
+ const deletedIds = [];
751
+ for (const row of Array.from(leafMap.values())) {
752
+ if (row.reservation_id == null && row.added_at < refreshMs) {
753
+ leavesStore.delete(row.id);
754
+ leafMap.delete(row.id);
755
+ deletedIds.push(row.id);
756
+ }
757
+ }
758
+
759
+ const liveLeaves = reported.filter((p) => !spentIds.has(p.id));
760
+ const liveMissing = missing.filter((p) => !spentIds.has(p.id));
761
+
762
+ for (const p of liveLeaves) {
763
+ const row = this._leafRow(p, false, priorRows.get(p.id));
764
+ leavesStore.put(row);
765
+ leafMap.set(row.id, row);
766
+ }
767
+ for (const p of liveMissing) {
768
+ const row = this._leafRow(p, true, priorRows.get(p.id));
769
+ leavesStore.put(row);
770
+ leafMap.set(row.id, row);
771
+ }
772
+
773
+ const survivingIds = new Set();
774
+ for (const p of reported.concat(missing)) {
775
+ if (!spentIds.has(p.id)) survivingIds.add(p.id);
776
+ }
777
+ for (const id of deletedIds) {
778
+ if (survivingIds.has(id)) continue;
779
+ for (const existingId of (ancestorRowsByLeaf.get(id) || new Map()).keys()) {
780
+ ancestorsStore.delete([id, existingId]);
781
+ }
782
+ }
783
+ }
784
+ );
785
+ } catch (error) {
786
+ if (error instanceof TreeStoreError) throw error;
787
+ throw new TreeStoreError(`Failed to set leaves: ${error.message}`, error);
788
+ }
789
+ }
790
+
791
+ async cancelReservation(id, leavesToKeep) {
792
+ try {
793
+ const keep = leavesToKeep || [];
794
+ await this._txRun(
795
+ [STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS],
796
+ "readwrite",
797
+ [
798
+ { name: "leaves", store: STORE_LEAVES },
799
+ { name: "ancestors", store: STORE_ANCESTORS },
800
+ { name: "res", store: STORE_RESERVATIONS, key: id },
801
+ ],
802
+ (res, tx) => {
803
+ // Return leavesToKeep to the pool even when the reservation is already
804
+ // gone (e.g. released by stale cleanup): dropping them here would lose
805
+ // the leaves until the next refresh. The deletes below no-op in that case.
806
+ const leavesStore = tx.objectStore(STORE_LEAVES);
807
+ const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
808
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
809
+
810
+ const keepIds = new Set(keep.map((l) => l.id));
811
+ const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
812
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
813
+ // A kept leaf keeps its ancestor rows, so the row rebuilt below has to
814
+ // keep the flag that describes them.
815
+ const priorRows = new Map(leafMap);
816
+ for (const l of res.leaves) {
817
+ if (l.reservation_id !== id) continue;
818
+ leavesStore.delete(l.id);
819
+ leafMap.delete(l.id);
820
+ // A kept leaf's ancestor rows stay put; a dropped leaf's are
821
+ // removed with it.
822
+ if (!keepIds.has(l.id)) {
823
+ for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
824
+ ancestorsStore.delete([l.id, existingId]);
825
+ }
826
+ }
827
+ }
828
+ reservationsStore.delete(id);
829
+
830
+ if (keep.length > 0) {
831
+ // Only re-insert the leaves: a kept leaf's ancestor rows were left
832
+ // untouched above.
833
+ for (const leaf of keep) {
834
+ // The chain flag carries over: those rows were deliberately kept.
835
+ // So does any reservation other than this one, since a leaf the
836
+ // refresh released and another reservation then took is not this
837
+ // cancellation's to free.
838
+ const prior = priorRows.get(leaf.id);
839
+ const held = prior?.reservation_id === id ? undefined : prior;
840
+ const chainComplete = this._chainComplete(leaf, undefined, prior);
841
+ leavesStore.put(this._leafRow(leaf, false, held, chainComplete));
842
+ }
843
+ }
844
+ }
845
+ );
846
+ } catch (error) {
847
+ if (error instanceof TreeStoreError) throw error;
848
+ throw new TreeStoreError(
849
+ `Failed to cancel reservation '${id}': ${error.message}`,
850
+ error
851
+ );
852
+ }
853
+ }
854
+
855
+ async finalizeReservation(id, newLeaves) {
856
+ try {
857
+ const added = newLeaves || null;
858
+ await this._txRun(
859
+ [STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT, STORE_SWAP_STATUS],
860
+ "readwrite",
861
+ [
862
+ { name: "leaves", store: STORE_LEAVES },
863
+ { name: "ancestors", store: STORE_ANCESTORS },
864
+ { name: "res", store: STORE_RESERVATIONS, key: id },
865
+ ],
866
+ (res, tx) => {
867
+ const leavesStore = tx.objectStore(STORE_LEAVES);
868
+ const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
869
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
870
+ const spentStore = tx.objectStore(STORE_SPENT);
871
+ const swapStore = tx.objectStore(STORE_SWAP_STATUS);
872
+
873
+ const nowMs = this._nowMs();
874
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
875
+ const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
876
+
877
+ let isSwap = false;
878
+ if (res.res) {
879
+ isSwap = res.res.purpose === "Swap";
880
+ for (const l of res.leaves) {
881
+ if (l.reservation_id === id) {
882
+ spentStore.put({ id: l.id, spent_at: nowMs });
883
+ leavesStore.delete(l.id);
884
+ leafMap.delete(l.id);
885
+ // The spent leaf owns these ancestor rows; remove them now
886
+ // since there is no later reclaim pass.
887
+ for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
888
+ ancestorsStore.delete([l.id, existingId]);
889
+ }
890
+ }
891
+ }
892
+ reservationsStore.delete(id);
893
+ }
894
+
895
+ if (added && added.length > 0) {
896
+ for (const p of added) {
897
+ const row = this._leafRow(p, false, leafMap.get(p.id));
898
+ leavesStore.put(row);
899
+ leafMap.set(row.id, row);
900
+ }
901
+ }
902
+
903
+ if (isSwap && added && added.length > 0) {
904
+ swapStore.put({ id: SWAP_STATUS_ID, last_completed_at: nowMs });
905
+ }
906
+ }
907
+ );
908
+ } catch (error) {
909
+ if (error instanceof TreeStoreError) throw error;
910
+ throw new TreeStoreError(
911
+ `Failed to finalize reservation '${id}': ${error.message}`,
912
+ error
913
+ );
914
+ }
915
+ }
916
+
917
+ async updateReservation(reservationId, reservedLeaves, changeLeaves) {
918
+ try {
919
+ const reserved = reservedLeaves || [];
920
+ const change = changeLeaves || [];
921
+ return await this._txRun(
922
+ [STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT],
923
+ "readwrite",
924
+ [
925
+ { name: "leaves", store: STORE_LEAVES },
926
+ { name: "ancestors", store: STORE_ANCESTORS },
927
+ { name: "res", store: STORE_RESERVATIONS, key: reservationId },
928
+ ],
929
+ (res, tx) => {
930
+ if (!res.res) {
931
+ throw new TreeStoreError(`Reservation ${reservationId} not found`);
932
+ }
933
+ const leavesStore = tx.objectStore(STORE_LEAVES);
934
+ const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
935
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
936
+ const spentStore = tx.objectStore(STORE_SPENT);
937
+
938
+ const nowMs = this._nowMs();
939
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
940
+ const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
941
+
942
+ // Old reserved leaves are consumed by the swap: mark spent and drop,
943
+ // along with the ancestor rows they own, since there is no later
944
+ // reclaim pass.
945
+ for (const l of res.leaves) {
946
+ if (l.reservation_id === reservationId) {
947
+ spentStore.put({ id: l.id, spent_at: nowMs });
948
+ leavesStore.delete(l.id);
949
+ leafMap.delete(l.id);
950
+ for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
951
+ ancestorsStore.delete([l.id, existingId]);
952
+ }
953
+ }
954
+ }
955
+
956
+
957
+ const leafNodes = change.concat(reserved);
958
+
959
+ // Change leaves go back to the available pool.
960
+ for (const p of change) {
961
+ const row = this._leafRow(p, false, leafMap.get(p.id));
962
+ leavesStore.put(row);
963
+ leafMap.set(row.id, row);
964
+ }
965
+ // Reserved leaves stay attached to this same reservation.
966
+ for (const p of reserved) {
967
+ const row = this._leafRow(p, false, leafMap.get(p.id));
968
+ row.reservation_id = reservationId;
969
+ leavesStore.put(row);
970
+ leafMap.set(row.id, row);
971
+ }
972
+
973
+ reservationsStore.put({ ...res.res, pending_change_amount: 0 });
974
+
975
+ // Return value must be plain TreeNodes: the Rust side deserializes
976
+ // Vec<TreeNode>.
977
+ return { id: reservationId, leaves: reserved };
978
+ }
979
+ );
980
+ } catch (error) {
981
+ if (error instanceof TreeStoreError) throw error;
982
+ throw new TreeStoreError(
983
+ `Failed to update reservation '${reservationId}': ${error.message}`,
984
+ error
985
+ );
986
+ }
987
+ }
988
+
989
+ async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
990
+ try {
991
+ return await this._txRun(
992
+ [STORE_LEAVES, STORE_RESERVATIONS],
993
+ "readwrite",
994
+ [
995
+ { name: "leaves", store: STORE_LEAVES },
996
+ { name: "reservations", store: STORE_RESERVATIONS },
997
+ ],
998
+ (res, tx) => {
999
+ const leavesStore = tx.objectStore(STORE_LEAVES);
1000
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
1001
+
1002
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
1003
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
1004
+
1005
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
1006
+ const eligible = this._eligibleSlim(res.leaves);
1007
+ // True total over ALL eligible leaves, not the prefiltered set: the
1008
+ // WaitForPending decision below must not be derived from the slim set.
1009
+ const available = eligible.reduce((s, l) => s + l.value, 0);
1010
+ const slim = this._slimCandidates(eligible, maxTarget);
1011
+ const pending = res.reservations.reduce(
1012
+ (s, r) => s + (r.pending_change_amount || 0),
1013
+ 0
1014
+ );
1015
+
1016
+ const selected = this._selectLeavesByTargetAmounts(slim, targetAmounts);
1017
+ if (selected !== null) {
1018
+ if (selected.length === 0) {
1019
+ throw new TreeStoreError("NonReservableLeaves");
1020
+ }
1021
+ const ids = selected.map((s) => s.id);
1022
+ const fullLeaves = ids.map((leafId) => leafMap.get(leafId).data);
1023
+ const reservationId = this._generateId();
1024
+ this._createReservation(
1025
+ reservationsStore,
1026
+ leavesStore,
1027
+ leafMap,
1028
+ reservationId,
1029
+ ids,
1030
+ purpose,
1031
+ 0
1032
+ );
1033
+ return {
1034
+ type: "success",
1035
+ reservation: { id: reservationId, leaves: fullLeaves },
1036
+ };
1037
+ }
1038
+
1039
+ if (!exactOnly) {
1040
+ const minSelected = this._selectLeavesByMinimumAmount(slim, targetAmount);
1041
+ if (minSelected !== null) {
1042
+ const ids = minSelected.map((s) => s.id);
1043
+ const fullLeaves = ids.map((leafId) => leafMap.get(leafId).data);
1044
+ const reservedAmount = fullLeaves.reduce((s, l) => s + l.value, 0);
1045
+ const pendingChange =
1046
+ reservedAmount > targetAmount && targetAmount > 0
1047
+ ? reservedAmount - targetAmount
1048
+ : 0;
1049
+ const reservationId = this._generateId();
1050
+ this._createReservation(
1051
+ reservationsStore,
1052
+ leavesStore,
1053
+ leafMap,
1054
+ reservationId,
1055
+ ids,
1056
+ purpose,
1057
+ pendingChange
1058
+ );
1059
+ return {
1060
+ type: "success",
1061
+ reservation: { id: reservationId, leaves: fullLeaves },
1062
+ };
1063
+ }
1064
+ }
1065
+
1066
+ if (available + pending >= targetAmount) {
1067
+ return {
1068
+ type: "waitForPending",
1069
+ needed: targetAmount,
1070
+ available,
1071
+ pending,
1072
+ };
1073
+ }
1074
+ return { type: "insufficientFunds" };
1075
+ }
1076
+ );
1077
+ } catch (error) {
1078
+ if (error instanceof TreeStoreError) throw error;
1079
+ throw new TreeStoreError(
1080
+ `Failed to try reserve leaves: ${error.message}`,
1081
+ error
1082
+ );
1083
+ }
1084
+ }
1085
+
1086
+ async tryReserveLeavesByIds(leafIds, purpose) {
1087
+ try {
1088
+ if (!leafIds || leafIds.length === 0) {
1089
+ throw new TreeStoreError("NonReservableLeaves");
1090
+ }
1091
+ return await this._txRun(
1092
+ [STORE_LEAVES, STORE_RESERVATIONS],
1093
+ "readwrite",
1094
+ [{ name: "leaves", store: STORE_LEAVES }],
1095
+ (res, tx) => {
1096
+ const leavesStore = tx.objectStore(STORE_LEAVES);
1097
+ const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
1098
+
1099
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
1100
+ const eligibleIds = new Set(
1101
+ res.leaves
1102
+ .filter(
1103
+ (r) =>
1104
+ r.status === "Available" &&
1105
+ !r.is_missing_from_operators &&
1106
+ r.reservation_id == null
1107
+ )
1108
+ .map((r) => r.id)
1109
+ );
1110
+ // Every requested leaf must be available and unreserved (and the ids
1111
+ // distinct); otherwise reserve nothing.
1112
+ const matched = new Set(leafIds.filter((id) => eligibleIds.has(id)));
1113
+ if (matched.size !== leafIds.length) {
1114
+ throw new TreeStoreError("NonReservableLeaves");
1115
+ }
1116
+
1117
+ const fullLeaves = leafIds.map((id) => leafMap.get(id).data);
1118
+ const reservationId = this._generateId();
1119
+ this._createReservation(
1120
+ reservationsStore,
1121
+ leavesStore,
1122
+ leafMap,
1123
+ reservationId,
1124
+ leafIds,
1125
+ purpose,
1126
+ 0
1127
+ );
1128
+ return { id: reservationId, leaves: fullLeaves };
1129
+ }
1130
+ );
1131
+ } catch (error) {
1132
+ if (error instanceof TreeStoreError) throw error;
1133
+ throw new TreeStoreError(
1134
+ `Failed to try reserve leaves by ids: ${error.message}`,
1135
+ error
1136
+ );
1137
+ }
1138
+ }
1139
+
1140
+ async trySelectLeaves(targetAmounts) {
1141
+ try {
1142
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
1143
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
1144
+ return await this._txRun(
1145
+ [STORE_LEAVES],
1146
+ "readonly",
1147
+ [{ name: "leaves", store: STORE_LEAVES }],
1148
+ (res) => {
1149
+ const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
1150
+ const slim = this._slimCandidates(this._eligibleSlim(res.leaves), maxTarget);
1151
+
1152
+ const selected = this._selectLeavesByTargetAmounts(slim, targetAmounts);
1153
+ if (selected !== null && selected.length > 0) {
1154
+ return {
1155
+ type: "exact",
1156
+ leaves: selected.map((s) => leafMap.get(s.id).data),
1157
+ };
1158
+ }
1159
+
1160
+ const minSelected = this._selectLeavesByMinimumAmount(slim, targetAmount);
1161
+ if (minSelected !== null) {
1162
+ return {
1163
+ type: "swapNeeded",
1164
+ leaves: minSelected.map((s) => leafMap.get(s.id).data),
1165
+ };
1166
+ }
1167
+
1168
+ return { type: "insufficientFunds" };
1169
+ }
1170
+ );
1171
+ } catch (error) {
1172
+ if (error instanceof TreeStoreError) throw error;
1173
+ throw new TreeStoreError(
1174
+ `Failed to try select leaves: ${error.message}`,
1175
+ error
1176
+ );
1177
+ }
1178
+ }
1179
+
1180
+ // ===== Private helpers =====
1181
+
1182
+ _nowMs() {
1183
+ return Date.now();
1184
+ }
1185
+
1186
+ _generateId() {
1187
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1188
+ return crypto.randomUUID();
1189
+ }
1190
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1191
+ const r = (Math.random() * 16) | 0;
1192
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
1193
+ return v.toString(16);
1194
+ });
1195
+ }
1196
+
1197
+ /** Row shape for the leaf pool. Preserves `reservation_id` from an existing
1198
+ * row; every other field comes from the operators' latest copy. */
1199
+ _leafRow(node, isMissing, existingRow, chainComplete) {
1200
+ return {
1201
+ id: node.id,
1202
+ parent_node_id: node.parent_node_id ?? null,
1203
+ status: node.status,
1204
+ value: node.value,
1205
+ verifying_public_key: node.verifying_public_key,
1206
+ signing_public_key: node.signing_keyshare.public_key,
1207
+ is_missing_from_operators: !!isMissing,
1208
+ reservation_id: existingRow ? existingRow.reservation_id ?? null : null,
1209
+ added_at: this._nowMs(),
1210
+ // Denormalized and indexed so leavesMissingExitChains reads only the ids
1211
+ // it wants, rather than every leaf row. Stored 0/1: IndexedDB rejects
1212
+ // booleans as index keys.
1213
+ chain_complete: (
1214
+ chainComplete !== undefined
1215
+ ? chainComplete
1216
+ : this._chainComplete(node, undefined, existingRow)
1217
+ )
1218
+ ? 1
1219
+ : 0,
1220
+ data: node,
1221
+ };
1222
+ }
1223
+
1224
+ /**
1225
+ * Whether `node` has a stored chain reaching its current parent, which is what
1226
+ * makes it exitable without the operators. A stored chain runs from some parent
1227
+ * to a root, so holding the parent the leaf has now means it spans the whole
1228
+ * path. A leaf that is itself a root needs no ancestors. An empty incoming
1229
+ * chain means "unknown", not "none", so it keeps whatever the row already
1230
+ * claimed, unless the leaf has been reparented since: the chain then describes
1231
+ * the parent it had before.
1232
+ */
1233
+ _chainComplete(node, ancestors, existingRow) {
1234
+ if (node.parent_node_id == null) return true;
1235
+ if (ancestors && ancestors.length > 0) {
1236
+ return ancestors.some((a) => a.id === node.parent_node_id);
1237
+ }
1238
+ if (!existingRow) return false;
1239
+ return (
1240
+ existingRow.parent_node_id === node.parent_node_id &&
1241
+ !!existingRow.chain_complete
1242
+ );
1243
+ }
1244
+
1245
+ /** Row shape for an ancestor: no pool metadata (reservation / missing / added_at).
1246
+ * `leaf_id` plus `id` is the key, so the same node stores one row per leaf
1247
+ * that descends from it rather than one row shared by all of them. */
1248
+ _ancestorRow(leafId, node) {
1249
+ return {
1250
+ leaf_id: leafId,
1251
+ id: node.id,
1252
+ parent_node_id: node.parent_node_id ?? null,
1253
+ status: node.status,
1254
+ value: node.value,
1255
+ verifying_public_key: node.verifying_public_key,
1256
+ signing_public_key: node.signing_keyshare.public_key,
1257
+ data: node,
1258
+ };
1259
+ }
1260
+
1261
+ /** Groups ancestor rows (from a full-store read) by the leaf that owns them,
1262
+ * as `Map<leaf_id, Map<id, row>>`, so a leaf's existing rows can be found
1263
+ * and deleted by key without a range scan on the compound `[leaf_id, id]`
1264
+ * keyPath. */
1265
+ _ancestorRowsByLeaf(ancestorRows) {
1266
+ const map = new Map();
1267
+ for (const row of ancestorRows || []) {
1268
+ let byId = map.get(row.leaf_id);
1269
+ if (!byId) {
1270
+ byId = new Map();
1271
+ map.set(row.leaf_id, byId);
1272
+ }
1273
+ byId.set(row.id, row);
1274
+ }
1275
+ return map;
1276
+ }
1277
+
1278
+ /**
1279
+ * Replaces `leafId`'s ancestor rows wholesale (delete then insert), keeping
1280
+ * `ancestorRowsByLeaf` current so a later step in the same transaction sees
1281
+ * this leaf's live rows rather than a stale snapshot. An empty `nodes` list
1282
+ * is a no-op: it means the chain is unknown, not that the leaf has none.
1283
+ */
1284
+ _replaceAncestors(ancestorsStore, ancestorRowsByLeaf, leafId, nodes) {
1285
+ if (!nodes || nodes.length === 0) return;
1286
+
1287
+ const ownRows = ancestorRowsByLeaf.get(leafId);
1288
+ if (ownRows) {
1289
+ for (const existingId of ownRows.keys()) {
1290
+ ancestorsStore.delete([leafId, existingId]);
1291
+ }
1292
+ }
1293
+ const newRows = new Map();
1294
+ for (const node of nodes) {
1295
+ const row = this._ancestorRow(leafId, node);
1296
+ ancestorsStore.put(row);
1297
+ newRows.set(node.id, row);
1298
+ }
1299
+ ancestorRowsByLeaf.set(leafId, newRows);
1300
+ }
1301
+
1302
+ _createReservation(
1303
+ reservationsStore,
1304
+ leavesStore,
1305
+ leafMap,
1306
+ reservationId,
1307
+ leafIds,
1308
+ purpose,
1309
+ pendingChange
1310
+ ) {
1311
+ reservationsStore.put({
1312
+ id: reservationId,
1313
+ purpose,
1314
+ pending_change_amount: pendingChange,
1315
+ created_at: this._nowMs(),
1316
+ });
1317
+ for (const id of leafIds) {
1318
+ const row = leafMap.get(id);
1319
+ if (row) {
1320
+ row.reservation_id = reservationId;
1321
+ leavesStore.put(row);
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ /** Slim `{id, value}` projection of the leaves eligible for selection. */
1327
+ _eligibleSlim(leafRows) {
1328
+ return leafRows
1329
+ .filter(
1330
+ (r) =>
1331
+ r.status === "Available" &&
1332
+ !r.is_missing_from_operators &&
1333
+ r.reservation_id == null
1334
+ )
1335
+ .map((r) => ({ id: r.id, value: r.value }));
1336
+ }
1337
+
1338
+ /**
1339
+ * Prefilter mirroring the SQL slim candidate set: every eligible leaf with
1340
+ * value <= maxTarget, plus the single smallest leaf with value > maxTarget
1341
+ * (the minimum-amount fallback where one larger leaf alone suffices).
1342
+ */
1343
+ _slimCandidates(eligible, maxTarget) {
1344
+ const small = eligible.filter((l) => l.value <= maxTarget);
1345
+ let smallestBig = null;
1346
+ for (const l of eligible) {
1347
+ if (l.value > maxTarget && (smallestBig === null || l.value < smallestBig.value)) {
1348
+ smallestBig = l;
1349
+ }
1350
+ }
1351
+ return smallestBig ? [...small, smallestBig] : small;
1352
+ }
1353
+
1354
+ _maxTargetForPrefilter(targetAmounts) {
1355
+ if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
1356
+ if (targetAmounts.type === "amountAndFee") {
1357
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
1358
+ }
1359
+ if (targetAmounts.type === "exactDenominations") {
1360
+ return targetAmounts.denominations.reduce((m, v) => m + v, 0);
1361
+ }
1362
+ return Number.MAX_SAFE_INTEGER;
1363
+ }
1364
+
1365
+ _totalSats(targetAmounts) {
1366
+ if (targetAmounts.type === "amountAndFee") {
1367
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
1368
+ }
1369
+ if (targetAmounts.type === "exactDenominations") {
1370
+ return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
1371
+ }
1372
+ return 0;
1373
+ }
1374
+
1375
+ _selectLeavesByTargetAmounts(leaves, targetAmounts) {
1376
+ if (!targetAmounts) {
1377
+ return [...leaves];
1378
+ }
1379
+
1380
+ if (targetAmounts.type === "amountAndFee") {
1381
+ const amountLeaves = this._selectLeavesByExactAmount(
1382
+ leaves,
1383
+ targetAmounts.amountSats
1384
+ );
1385
+ if (amountLeaves === null) return null;
1386
+
1387
+ if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
1388
+ const amountIds = new Set(amountLeaves.map((l) => l.id));
1389
+ const remaining = leaves.filter((l) => !amountIds.has(l.id));
1390
+ const feeLeaves = this._selectLeavesByExactAmount(
1391
+ remaining,
1392
+ targetAmounts.feeSats
1393
+ );
1394
+ if (feeLeaves === null) return null;
1395
+ return [...amountLeaves, ...feeLeaves];
1396
+ }
1397
+
1398
+ return amountLeaves;
1399
+ }
1400
+
1401
+ if (targetAmounts.type === "exactDenominations") {
1402
+ return this._selectLeavesByExactDenominations(
1403
+ leaves,
1404
+ targetAmounts.denominations
1405
+ );
1406
+ }
1407
+
1408
+ return null;
1409
+ }
1410
+
1411
+ _selectLeavesByExactAmount(leaves, targetAmount) {
1412
+ if (targetAmount === 0) return null;
1413
+
1414
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
1415
+ if (totalAvailable < targetAmount) return null;
1416
+
1417
+ const single = leaves.find((l) => l.value === targetAmount);
1418
+ if (single) return [single];
1419
+
1420
+ const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
1421
+ return multipleResult;
1422
+ }
1423
+
1424
+ _selectLeavesByExactDenominations(leaves, denominations) {
1425
+ const remaining = [...leaves];
1426
+ const selected = [];
1427
+
1428
+ for (const denomination of denominations) {
1429
+ const idx = remaining.findIndex((l) => l.value === denomination);
1430
+ if (idx === -1) return null;
1431
+ selected.push(remaining[idx]);
1432
+ remaining.splice(idx, 1);
1433
+ }
1434
+
1435
+ return selected;
1436
+ }
1437
+
1438
+ _selectLeavesByMinimumAmount(leaves, targetAmount) {
1439
+ if (targetAmount === 0) return null;
1440
+
1441
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
1442
+ if (totalAvailable < targetAmount) return null;
1443
+
1444
+ const result = [];
1445
+ let sum = 0;
1446
+ for (const leaf of leaves) {
1447
+ sum += leaf.value;
1448
+ result.push(leaf);
1449
+ if (sum >= targetAmount) break;
1450
+ }
1451
+
1452
+ return sum >= targetAmount ? result : null;
1453
+ }
1454
+
1455
+ _findExactMultipleMatch(leaves, targetAmount) {
1456
+ if (targetAmount === 0) return [];
1457
+ if (leaves.length === 0) return null;
1458
+
1459
+ const result = this._greedyExactMatch(leaves, targetAmount);
1460
+ if (result) return result;
1461
+
1462
+ const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
1463
+ if (powerOfTwoLeaves.length === leaves.length) return null;
1464
+
1465
+ return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
1466
+ }
1467
+
1468
+ _greedyExactMatch(leaves, targetAmount) {
1469
+ const sorted = [...leaves].sort((a, b) => b.value - a.value);
1470
+ const result = [];
1471
+ let remaining = targetAmount;
1472
+
1473
+ for (const leaf of sorted) {
1474
+ if (leaf.value > remaining) continue;
1475
+ remaining -= leaf.value;
1476
+ result.push(leaf);
1477
+ if (remaining === 0) return result;
1478
+ }
1479
+
1480
+ return null;
1481
+ }
1482
+
1483
+ _isPowerOfTwo(value) {
1484
+ return value > 0 && (value & (value - 1)) === 0;
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Opens (or creates) the IndexedDB database and returns a ready tree store.
1490
+ *
1491
+ * @param {string} dbName - Database name (one database per SDK instance).
1492
+ * @param {object} [logger] - Optional logger.
1493
+ * @returns {Promise<WebTreeStore>}
1494
+ */
1495
+ export async function createWebTreeStore(dbName, logger = null) {
1496
+ const store = new WebTreeStore(dbName, logger);
1497
+ await store.initialize();
1498
+ return store;
1499
+ }
1500
+
1501
+ /**
1502
+ * Deletes a tree store database. Intended for tests that need a clean database;
1503
+ * production code never calls this.
1504
+ *
1505
+ * @param {string} dbName
1506
+ * @returns {Promise<void>}
1507
+ */
1508
+ export async function deleteWebTreeStore(dbName) {
1509
+ await new Promise((resolve, reject) => {
1510
+ const req = indexedDB.deleteDatabase(dbName);
1511
+ req.onsuccess = () => resolve();
1512
+ req.onerror = () => reject(req.error);
1513
+ // A stale open connection can block deletion; proceed anyway.
1514
+ req.onblocked = () => resolve();
1515
+ });
1516
+ }
1517
+
1518
+ export { WebTreeStore, TreeStoreError };