@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,1185 @@
1
+ /**
2
+ * CommonJS implementation for the Node.js SQLite Tree Store.
3
+ *
4
+ * The single durable source of truth for one wallet's leaves, ancestors,
5
+ * reservations, and spent records. It shares the wallet's main better-sqlite3
6
+ * database file. Its `tree` table names are what keep it clear of the main
7
+ * storage's; the `brz_` prefix on top of those is for consistency with the
8
+ * Postgres and MySQL backends. There is no tenant/user column and no advisory
9
+ * locking:
10
+ * better-sqlite3 is synchronous and each method runs its transaction to
11
+ * completion without yielding.
12
+ *
13
+ * Two-table model (see migrations.cjs): `brz_tree_leaves` is the spendable pool;
14
+ * `brz_tree_ancestors` holds the intermediate exit-chain nodes, one row per
15
+ * leaf that descends from it (keyed by `leaf_id`), so fetching or dropping a
16
+ * leaf's chain never touches another leaf's copy of a shared node. SQL mirrors
17
+ * the Rust `spark-sqlite` store (crates/spark-sqlite/src/lib.rs). Selection
18
+ * logic mirrors the PostgreSQL tree store (js/postgres-tree-store/index.cjs).
19
+ */
20
+
21
+ /**
22
+ * Ids bound per statement. SQLite caps a statement at 32766 parameters, which a
23
+ * wallet-wide chain backfill would otherwise pass. Low enough that the query
24
+ * binding each id twice stays under it as well. Mirrors `IDS_PER_STATEMENT` in
25
+ * the Rust `spark-sqlite` store.
26
+ */
27
+ const IDS_PER_STATEMENT = 8000;
28
+
29
+ /** Splits `ids` into slices small enough to bind in one statement. */
30
+ function idChunks(ids) {
31
+ const chunks = [];
32
+ for (let i = 0; i < ids.length; i += IDS_PER_STATEMENT) {
33
+ chunks.push(ids.slice(i, i + IDS_PER_STATEMENT));
34
+ }
35
+ return chunks;
36
+ }
37
+
38
+ // Resolve better-sqlite3 from the calling module's context, same as node-storage.
39
+ let Database;
40
+ try {
41
+ const mainModule = require.main;
42
+ if (mainModule) {
43
+ Database = mainModule.require("better-sqlite3");
44
+ } else {
45
+ Database = require("better-sqlite3");
46
+ }
47
+ } catch (error) {
48
+ try {
49
+ Database = require("better-sqlite3");
50
+ } catch (fallbackError) {
51
+ throw new Error(
52
+ `better-sqlite3 not found. Please install it in your project: npm install better-sqlite3@^9.2.2\n` +
53
+ `Original error: ${error.message}\nFallback error: ${fallbackError.message}`
54
+ );
55
+ }
56
+ }
57
+
58
+ const { TreeStoreError } = require("./errors.cjs");
59
+ const { TreeStoreMigrationManager } = require("./migrations.cjs");
60
+
61
+ /**
62
+ * Reservations idle longer than this are treated as abandoned by a crashed
63
+ * client and released during setLeaves.
64
+ */
65
+ const RESERVATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
66
+
67
+ /**
68
+ * Spent markers older than this (relative to a refresh) are pruned.
69
+ */
70
+ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
71
+
72
+ /**
73
+ * Slim projection: only (id, value) for leaves the selection might use. Every
74
+ * eligible leaf with value <= ? plus the single smallest eligible leaf above it
75
+ * (the minimum-amount fallback where one larger leaf suffices). The bound is
76
+ * bound twice positionally.
77
+ */
78
+ const SLIM_LEAF_CANDIDATES_SQL = `
79
+ SELECT id, value FROM brz_tree_leaves
80
+ WHERE status = 'Available'
81
+ AND is_missing_from_operators = 0
82
+ AND reservation_id IS NULL
83
+ AND (
84
+ value <= ?
85
+ OR id = (
86
+ SELECT id FROM brz_tree_leaves
87
+ WHERE status = 'Available'
88
+ AND is_missing_from_operators = 0
89
+ AND reservation_id IS NULL
90
+ AND value > ?
91
+ ORDER BY value
92
+ LIMIT 1
93
+ )
94
+ )
95
+ `;
96
+
97
+ /**
98
+ * Pair a leaf with its ancestors (nearest first) by walking `parent_node_id`
99
+ * through `nodes`. Returns null if the leaf itself is absent; stops at a gap or
100
+ * cycle, returning a partial chain.
101
+ * @param {Map<string, object>} nodes
102
+ * @param {string} leafId
103
+ * @returns {{leaf: object, ancestors: Array<object>}|null}
104
+ */
105
+ function assembleExitChain(nodes, leafId) {
106
+ const leaf = nodes.get(leafId);
107
+ if (!leaf) return null;
108
+ const ancestors = [];
109
+ const visited = new Set([leafId]);
110
+ let current = leaf.parent_node_id;
111
+ while (current != null && !visited.has(current)) {
112
+ visited.add(current);
113
+ const node = nodes.get(current);
114
+ if (!node) break;
115
+ ancestors.push(node);
116
+ current = node.parent_node_id;
117
+ }
118
+ return { leaf, ancestors };
119
+ }
120
+
121
+ class NodeTreeStore {
122
+ /**
123
+ * @param {string} dbPath - Path to the SQLite database file for this wallet.
124
+ * @param {object} [logger]
125
+ * @param {boolean} [runMigration]
126
+ */
127
+ constructor(dbPath, logger = null, runMigration = true) {
128
+ this.dbPath = dbPath;
129
+ this.db = null;
130
+ this.migrationManager = null;
131
+ this.logger = logger;
132
+ this.runMigration = runMigration;
133
+ }
134
+
135
+ /**
136
+ * Open the database and run migrations. Returns the store instance.
137
+ */
138
+ initialize() {
139
+ try {
140
+ this.db = new Database(this.dbPath);
141
+ // Shared file: WAL lets this connection read and write alongside the main
142
+ // storage's.
143
+ this.db.pragma("journal_mode = WAL");
144
+ if (this.runMigration) {
145
+ this.migrationManager = new TreeStoreMigrationManager(
146
+ this.db,
147
+ TreeStoreError,
148
+ this.logger
149
+ );
150
+ this.migrationManager.migrate();
151
+ }
152
+ return this;
153
+ } catch (error) {
154
+ throw new TreeStoreError(
155
+ `Failed to initialize tree store at '${this.dbPath}': ${error.message}`,
156
+ error
157
+ );
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Close the database connection.
163
+ */
164
+ close() {
165
+ if (this.db) {
166
+ this.db.close();
167
+ this.db = null;
168
+ }
169
+ }
170
+
171
+ // ===== TreeStore Methods =====
172
+
173
+ /**
174
+ * Add leaves to the pool. Receiving a leaf back clears any prior spent
175
+ * marker. Re-adding an id refreshes its mutable fields.
176
+ * @param {Array} leaves - Array of TreeNode
177
+ */
178
+ async addLeaves(leaves) {
179
+ try {
180
+ if (!leaves || leaves.length === 0) {
181
+ return;
182
+ }
183
+ const leafNodes = leaves;
184
+ this.db.transaction(() => {
185
+ this._removeSpent(leafNodes.map((l) => l.id));
186
+ this._upsertLeaves(leafNodes, false, null);
187
+ })();
188
+ } catch (error) {
189
+ if (error instanceof TreeStoreError) throw error;
190
+ throw new TreeStoreError(`Failed to add leaves: ${error.message}`, error);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Store the ancestor chain of each pedigree, leaving the leaf pool and any
196
+ * spent marker untouched.
197
+ * @param {Array} pedigrees - Array of LeafPedigree { leaf, ancestors }
198
+ */
199
+ async storeAncestors(pedigrees) {
200
+ try {
201
+ if (!pedigrees || pedigrees.length === 0) {
202
+ return;
203
+ }
204
+ const leafIds = pedigrees.map((p) => p.leaf.id);
205
+ this.db.transaction(() => {
206
+ // A leaf can be spent between its chain being resolved and this write, and a
207
+ // chain is only ever removed with its leaf. Writing one for a leaf that is
208
+ // already gone would leave it behind for good.
209
+ const storedLeafIds = new Set();
210
+ for (const chunk of idChunks(leafIds)) {
211
+ const placeholders = chunk.map(() => "?").join(",");
212
+ for (const row of this.db
213
+ .prepare(`SELECT id FROM brz_tree_leaves WHERE id IN (${placeholders})`)
214
+ .all(...chunk)) {
215
+ storedLeafIds.add(row.id);
216
+ }
217
+ }
218
+ for (const pedigree of pedigrees) {
219
+ if (!storedLeafIds.has(pedigree.leaf.id)) {
220
+ continue;
221
+ }
222
+ this._upsertAncestors(pedigree.leaf.id, pedigree.ancestors);
223
+ }
224
+ })();
225
+ } catch (error) {
226
+ if (error instanceof TreeStoreError) throw error;
227
+ throw new TreeStoreError(`Failed to store ancestors: ${error.message}`, error);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Ids of the stored leaves whose chain cannot back an exit: the leaf has a
233
+ * parent, and no ancestor row of its own holds that parent.
234
+ * @returns {Promise<Array<string>>}
235
+ */
236
+ async leavesMissingExitChains() {
237
+ try {
238
+ const rows = this.db
239
+ .prepare(
240
+ // A stored chain runs from its leaf's parent to a root, so a leaf
241
+ // whose chain holds the parent it has now is exitable. The join binds
242
+ // both primary key columns, making it one index probe per leaf. A leaf
243
+ // that is itself a root needs no chain.
244
+ `SELECT l.id
245
+ FROM brz_tree_leaves l
246
+ LEFT JOIN brz_tree_ancestors link
247
+ ON link.leaf_id = l.id AND link.id = l.parent_node_id
248
+ WHERE l.parent_node_id IS NOT NULL
249
+ AND link.leaf_id IS NULL`
250
+ )
251
+ .all();
252
+ return rows.map((r) => r.id);
253
+ } catch (error) {
254
+ if (error instanceof TreeStoreError) throw error;
255
+ throw new TreeStoreError(
256
+ `Failed to get leaves missing exit chains: ${error.message}`,
257
+ error
258
+ );
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Reconstruct the exit chains for many leaves, each as { leaf, ancestors } with
264
+ * ancestors nearest first. A leaf absent from the store is skipped; a chain that
265
+ * hits a gap comes back partial.
266
+ * @param {Array<string>} leafIds
267
+ * @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
268
+ */
269
+ async getExitChains(leafIds) {
270
+ try {
271
+ if (!leafIds || leafIds.length === 0) return [];
272
+ // Each statement pairs a leaf's own row with its ancestor rows, both
273
+ // tagged by the owning leaf id, and one transaction spans however many it
274
+ // takes so no two are read at different points in time. Grouping by that
275
+ // tag keeps each leaf's node set separate, so a node id stored under
276
+ // another leaf can never cross-contaminate this one.
277
+ const byLeaf = new Map();
278
+ this.db.transaction(() => {
279
+ for (const chunk of idChunks(leafIds)) {
280
+ const placeholders = chunk.map(() => "?").join(",");
281
+ const rows = this.db
282
+ .prepare(
283
+ `SELECT leaf_id, data FROM brz_tree_ancestors WHERE leaf_id IN (${placeholders})
284
+ UNION ALL
285
+ SELECT id AS leaf_id, data FROM brz_tree_leaves WHERE id IN (${placeholders})`
286
+ )
287
+ .all(...chunk, ...chunk);
288
+ for (const r of rows) {
289
+ let nodes = byLeaf.get(r.leaf_id);
290
+ if (!nodes) {
291
+ nodes = new Map();
292
+ byLeaf.set(r.leaf_id, nodes);
293
+ }
294
+ const node = JSON.parse(r.data);
295
+ nodes.set(node.id, node);
296
+ }
297
+ }
298
+ })();
299
+
300
+ const result = [];
301
+ for (const id of leafIds) {
302
+ const nodes = byLeaf.get(id);
303
+ if (!nodes) continue;
304
+ const pedigree = assembleExitChain(nodes, id);
305
+ if (pedigree) result.push(pedigree);
306
+ }
307
+ return result;
308
+ } catch (error) {
309
+ if (error instanceof TreeStoreError) throw error;
310
+ throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Return the wallet's spendable balance (available + swap-reserved) as a
316
+ * BigInt. Aggregated in SQL so we don't fetch every leaf.
317
+ * @returns {Promise<bigint>}
318
+ */
319
+ async getAvailableBalance() {
320
+ try {
321
+ const row = this.db
322
+ .prepare(
323
+ `SELECT COALESCE(SUM(l.value), 0) AS balance
324
+ FROM brz_tree_leaves l
325
+ LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id
326
+ WHERE (l.reservation_id IS NULL AND l.status = 'Available')
327
+ OR r.purpose = 'Swap'`
328
+ )
329
+ .get();
330
+ return BigInt(row.balance);
331
+ } catch (error) {
332
+ throw new TreeStoreError(
333
+ `Failed to get available balance: ${error.message}`,
334
+ error
335
+ );
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Return [id, verifyingPublicKey, signingKeysharePublicKey] triples for every
341
+ * reserved or Available leaf. The two pubkeys are projected out of the JSON so
342
+ * we skip each leaf's transaction blob.
343
+ * @returns {Promise<Array<[string, string, string]>>}
344
+ */
345
+ async getVerifiedLeafKeys() {
346
+ try {
347
+ const rows = this.db
348
+ .prepare(
349
+ `SELECT l.id AS id,
350
+ l.verifying_public_key AS verifying,
351
+ l.signing_public_key AS keyshare
352
+ FROM brz_tree_leaves l
353
+ LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id
354
+ WHERE r.purpose IS NOT NULL OR l.status = 'Available'`
355
+ )
356
+ .all();
357
+ return rows.map((row) => [row.id, row.verifying, row.keyshare]);
358
+ } catch (error) {
359
+ throw new TreeStoreError(
360
+ `Failed to get verified leaf keys: ${error.message}`,
361
+ error
362
+ );
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Return all pool leaves categorized by status and reservation purpose.
368
+ * @returns {Promise<Object>}
369
+ */
370
+ async getLeaves() {
371
+ try {
372
+ const rows = this.db
373
+ .prepare(
374
+ `SELECT l.status, l.is_missing_from_operators, l.data,
375
+ l.reservation_id, r.purpose
376
+ FROM brz_tree_leaves l
377
+ LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id`
378
+ )
379
+ .all();
380
+
381
+ const available = [];
382
+ const notAvailable = [];
383
+ const availableMissingFromOperators = [];
384
+ const reservedForPayment = [];
385
+ const reservedForSwap = [];
386
+
387
+ for (const row of rows) {
388
+ const node = JSON.parse(row.data);
389
+ const spendable = node.status === "Available";
390
+
391
+ if (row.purpose) {
392
+ if (row.purpose === "Payment") {
393
+ reservedForPayment.push(node);
394
+ } else if (row.purpose === "Swap") {
395
+ reservedForSwap.push(node);
396
+ }
397
+ } else if (!spendable) {
398
+ notAvailable.push(node);
399
+ } else if (row.is_missing_from_operators) {
400
+ availableMissingFromOperators.push(node);
401
+ } else {
402
+ available.push(node);
403
+ }
404
+ }
405
+
406
+ return {
407
+ available,
408
+ notAvailable,
409
+ availableMissingFromOperators,
410
+ reservedForPayment,
411
+ reservedForSwap,
412
+ };
413
+ } catch (error) {
414
+ if (error instanceof TreeStoreError) throw error;
415
+ throw new TreeStoreError(`Failed to get leaves: ${error.message}`, error);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Replace the pool from a refresh. Skipped while a swap is in flight or one
421
+ * completed during the refresh, so a swap's leaves are never clobbered.
422
+ * @param {Array} leaves - Available TreeNode
423
+ * @param {Array} missingLeaves - TreeNode missing from some operators
424
+ * @param {number} refreshStartedAtMs - Epoch milliseconds when refresh started
425
+ */
426
+ async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
427
+ try {
428
+ const refreshMs = refreshStartedAtMs;
429
+ this.db.transaction(() => {
430
+ // Release abandoned reservations before evaluating the swap guard so a
431
+ // stale swap cannot pin setLeaves forever.
432
+ this._cleanupStaleReservations();
433
+
434
+ const hasActiveSwap =
435
+ this.db
436
+ .prepare(
437
+ "SELECT EXISTS(SELECT 1 FROM brz_tree_reservations WHERE purpose = 'Swap') AS has_active_swap"
438
+ )
439
+ .get().has_active_swap !== 0;
440
+ const statusRow = this.db
441
+ .prepare("SELECT last_completed_at FROM brz_tree_swap_status WHERE id = 1")
442
+ .get();
443
+ const swapCompleted =
444
+ statusRow != null &&
445
+ statusRow.last_completed_at != null &&
446
+ statusRow.last_completed_at >= refreshMs;
447
+
448
+ if (hasActiveSwap || swapCompleted) {
449
+ return;
450
+ }
451
+
452
+ this._cleanupSpentMarkers(refreshMs);
453
+ const spentIds = this._spentIdsSince(refreshMs);
454
+
455
+ // Delete non-reserved pool leaves older than the refresh; reserved and
456
+ // after-refresh leaves are immune. A leaf reported again in this same
457
+ // refresh is re-inserted below, so its ancestor rows must survive: only
458
+ // ids that do NOT reappear in this refresh (truly gone, e.g. spent) get
459
+ // their ancestor rows dropped alongside them.
460
+ const deletedIds = this.db
461
+ .prepare(
462
+ "DELETE FROM brz_tree_leaves WHERE reservation_id IS NULL AND added_at < ? RETURNING id"
463
+ )
464
+ .all(refreshMs)
465
+ .map((r) => r.id);
466
+
467
+ this._upsertLeaves(leaves, false, spentIds);
468
+ this._upsertLeaves(missingLeaves, true, spentIds);
469
+
470
+ const survivingIds = new Set();
471
+ for (const leaf of leaves.concat(missingLeaves)) {
472
+ if (!spentIds.has(leaf.id)) survivingIds.add(leaf.id);
473
+ }
474
+ const goneIds = deletedIds.filter((id) => !survivingIds.has(id));
475
+ this._deleteAncestorsForLeaves(goneIds);
476
+ })();
477
+ } catch (error) {
478
+ if (error instanceof TreeStoreError) throw error;
479
+ throw new TreeStoreError(`Failed to set leaves: ${error.message}`, error);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Cancel a reservation. Its leaves are deleted from the pool and the row is
485
+ * dropped. `leavesToKeep` are re-inserted into the available pool; their
486
+ * ancestors are already stored (they stayed while the leaves were reserved).
487
+ * @param {string} id
488
+ * @param {Array} leavesToKeep - TreeNode leaves to return to the available pool
489
+ */
490
+ async cancelReservation(id, leavesToKeep) {
491
+ try {
492
+ this.db.transaction(() => {
493
+ // Return leavesToKeep to the pool even when the reservation is already
494
+ // gone (e.g. released by stale cleanup): dropping them here would lose
495
+ // the leaves until the next refresh. The deletes no-op in that case.
496
+ // Only the leaves are re-inserted: a kept leaf's ancestor rows stay put
497
+ // (they are not touched below); a dropped leaf's are removed with it.
498
+ const keepIds = new Set((leavesToKeep || []).map((l) => l.id));
499
+ const droppedIds = this.db
500
+ .prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
501
+ .all(id)
502
+ .map((r) => r.id)
503
+ .filter((rid) => !keepIds.has(rid));
504
+
505
+ this.db.prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?").run(id);
506
+ this.db.prepare("DELETE FROM brz_tree_reservations WHERE id = ?").run(id);
507
+ this._deleteAncestorsForLeaves(droppedIds);
508
+ this._upsertLeaves(leavesToKeep, false, null);
509
+ })();
510
+ } catch (error) {
511
+ if (error instanceof TreeStoreError) throw error;
512
+ throw new TreeStoreError(
513
+ `Failed to cancel reservation '${id}': ${error.message}`,
514
+ error
515
+ );
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Finalize a reservation, marking its leaves spent and adding any new leaves.
521
+ * @param {string} id
522
+ * @param {Array|null} newLeaves - Optional new TreeNode
523
+ */
524
+ async finalizeReservation(id, newLeaves) {
525
+ try {
526
+ this.db.transaction(() => {
527
+ const res = this.db
528
+ .prepare("SELECT id, purpose FROM brz_tree_reservations WHERE id = ?")
529
+ .get(id);
530
+
531
+ let isSwap = false;
532
+ if (res) {
533
+ isSwap = res.purpose === "Swap";
534
+ const reservedLeafIds = this.db
535
+ .prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
536
+ .all(id)
537
+ .map((r) => r.id);
538
+ this._insertSpent(reservedLeafIds);
539
+ this.db.prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?").run(id);
540
+ this.db.prepare("DELETE FROM brz_tree_reservations WHERE id = ?").run(id);
541
+ // The spent leaves own these ancestor rows; remove them in the same
542
+ // transaction rather than leaving them to a separate reclaim pass.
543
+ this._deleteAncestorsForLeaves(reservedLeafIds);
544
+ }
545
+
546
+ if (newLeaves && newLeaves.length > 0) {
547
+ this._upsertLeaves(newLeaves, false, null);
548
+ }
549
+
550
+ // Record the swap only when it produced change: the setLeaves guard uses
551
+ // this to skip a refresh that raced the swap.
552
+ if (isSwap && newLeaves && newLeaves.length > 0) {
553
+ this._markSwapCompleted();
554
+ }
555
+ })();
556
+ } catch (error) {
557
+ if (error instanceof TreeStoreError) throw error;
558
+ throw new TreeStoreError(
559
+ `Failed to finalize reservation '${id}': ${error.message}`,
560
+ error
561
+ );
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Try to reserve leaves matching target amounts.
567
+ * @param {Object|null} targetAmounts
568
+ * @param {boolean} exactOnly - If true, only exact matches
569
+ * @param {string} purpose - "Payment" or "Swap"
570
+ * @returns {Promise<Object>} ReserveResult
571
+ */
572
+ async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
573
+ try {
574
+ return this.db.transaction(() => {
575
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
576
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
577
+
578
+ // True total available over ALL eligible leaves, for the WaitForPending
579
+ // decision below: must not be derived from the prefiltered slim set.
580
+ const available = this._availableTotal();
581
+ const slimLeaves = this._slimCandidates(maxTarget);
582
+ const pending = this._pendingBalance();
583
+
584
+ const selected = this._selectLeavesByTargetAmounts(slimLeaves, targetAmounts);
585
+ if (selected !== null) {
586
+ if (selected.length === 0) {
587
+ throw new TreeStoreError("NonReservableLeaves");
588
+ }
589
+ const fullLeaves = this._resolveFullLeaves(selected.map((l) => l.id));
590
+ const reservationId = this._generateId();
591
+ this._createReservation(reservationId, fullLeaves, purpose, 0);
592
+ return {
593
+ type: "success",
594
+ reservation: { id: reservationId, leaves: fullLeaves },
595
+ };
596
+ }
597
+
598
+ if (!exactOnly) {
599
+ const minSelected = this._selectLeavesByMinimumAmount(slimLeaves, targetAmount);
600
+ if (minSelected !== null) {
601
+ const fullLeaves = this._resolveFullLeaves(minSelected.map((l) => l.id));
602
+ const reservedAmount = fullLeaves.reduce((sum, l) => sum + l.value, 0);
603
+ const pendingChange =
604
+ reservedAmount > targetAmount && targetAmount > 0
605
+ ? reservedAmount - targetAmount
606
+ : 0;
607
+ const reservationId = this._generateId();
608
+ this._createReservation(reservationId, fullLeaves, purpose, pendingChange);
609
+ return {
610
+ type: "success",
611
+ reservation: { id: reservationId, leaves: fullLeaves },
612
+ };
613
+ }
614
+ }
615
+
616
+ if (available + pending >= targetAmount) {
617
+ return { type: "waitForPending", needed: targetAmount, available, pending };
618
+ }
619
+ return { type: "insufficientFunds" };
620
+ })();
621
+ } catch (error) {
622
+ if (error instanceof TreeStoreError) throw error;
623
+ throw new TreeStoreError(
624
+ `Failed to try reserve leaves: ${error.message}`,
625
+ error
626
+ );
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Select (without reserving) leaves matching target amounts.
632
+ * @param {Object|null} targetAmounts
633
+ * @returns {Promise<Object>} LeafSelection
634
+ */
635
+ async trySelectLeaves(targetAmounts) {
636
+ try {
637
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
638
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
639
+
640
+ return this.db.transaction(() => {
641
+ const slimLeaves = this._slimCandidates(maxTarget);
642
+
643
+ const selected = this._selectLeavesByTargetAmounts(slimLeaves, targetAmounts);
644
+ if (selected !== null && selected.length > 0) {
645
+ const fullLeaves = this._resolveFullLeaves(selected.map((l) => l.id));
646
+ return { type: "exact", leaves: fullLeaves };
647
+ }
648
+
649
+ const minSelected = this._selectLeavesByMinimumAmount(slimLeaves, targetAmount);
650
+ if (minSelected !== null) {
651
+ const fullLeaves = this._resolveFullLeaves(minSelected.map((l) => l.id));
652
+ return { type: "swapNeeded", leaves: fullLeaves };
653
+ }
654
+
655
+ return { type: "insufficientFunds" };
656
+ })();
657
+ } catch (error) {
658
+ if (error instanceof TreeStoreError) throw error;
659
+ throw new TreeStoreError(
660
+ `Failed to try select leaves: ${error.message}`,
661
+ error
662
+ );
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Reserve exactly the given leaves. Every id must be available and unreserved,
668
+ * or nothing is reserved.
669
+ * @param {Array<string>} leafIds
670
+ * @param {string} purpose - "Payment" or "Swap"
671
+ * @returns {Promise<Object>} { id, leaves }
672
+ */
673
+ async tryReserveLeavesByIds(leafIds, purpose) {
674
+ try {
675
+ return this.db.transaction(() => {
676
+ if (!leafIds || leafIds.length === 0) {
677
+ throw new TreeStoreError("NonReservableLeaves");
678
+ }
679
+ // Count DISTINCT matching ids so a duplicate id can't satisfy two slots:
680
+ // every requested id must resolve to its own available, unreserved leaf.
681
+ const placeholders = leafIds.map(() => "?").join(", ");
682
+ const matched = this.db
683
+ .prepare(
684
+ `SELECT DISTINCT id FROM brz_tree_leaves
685
+ WHERE id IN (${placeholders}) AND status = 'Available'
686
+ AND is_missing_from_operators = 0 AND reservation_id IS NULL`
687
+ )
688
+ .all(...leafIds);
689
+ if (matched.length !== leafIds.length) {
690
+ throw new TreeStoreError("NonReservableLeaves");
691
+ }
692
+ const fullLeaves = this._resolveFullLeaves(leafIds);
693
+ const reservationId = this._generateId();
694
+ this._createReservation(reservationId, fullLeaves, purpose, 0);
695
+ return { id: reservationId, leaves: fullLeaves };
696
+ })();
697
+ } catch (error) {
698
+ if (error instanceof TreeStoreError) throw error;
699
+ throw new TreeStoreError(
700
+ `Failed to try reserve leaves by ids: ${error.message}`,
701
+ error
702
+ );
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Current wall-clock time as epoch milliseconds.
708
+ * @returns {Promise<number>}
709
+ */
710
+ async now() {
711
+ return Date.now();
712
+ }
713
+
714
+ /**
715
+ * Update a reservation after a swap: spend the old reserved leaves, add the
716
+ * change leaves to the pool, and attach the new reserved leaves.
717
+ * @param {string} reservationId
718
+ * @param {Array} reservedLeaves - New reserved TreeNode
719
+ * @param {Array} changeLeaves - Change TreeNode for the available pool
720
+ * @returns {Promise<Object>} { id, leaves }
721
+ */
722
+ async updateReservation(reservationId, reservedLeaves, changeLeaves) {
723
+ try {
724
+ return this.db.transaction(() => {
725
+ const res = this.db
726
+ .prepare("SELECT id FROM brz_tree_reservations WHERE id = ?")
727
+ .get(reservationId);
728
+ if (!res) {
729
+ throw new TreeStoreError(`Reservation ${reservationId} not found`);
730
+ }
731
+
732
+ const oldLeafIds = this.db
733
+ .prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
734
+ .all(reservationId)
735
+ .map((r) => r.id);
736
+ this._insertSpent(oldLeafIds);
737
+ this.db
738
+ .prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?")
739
+ .run(reservationId);
740
+ // The spent leaves own these ancestor rows; remove them now since there
741
+ // is no later reclaim pass.
742
+ this._deleteAncestorsForLeaves(oldLeafIds);
743
+
744
+ this._upsertLeaves(changeLeaves, false, null);
745
+ this._upsertLeaves(reservedLeaves, false, null);
746
+ this._setReservationId(
747
+ reservationId,
748
+ reservedLeaves.map((l) => l.id)
749
+ );
750
+
751
+ this.db
752
+ .prepare(
753
+ "UPDATE brz_tree_reservations SET pending_change_amount = 0 WHERE id = ?"
754
+ )
755
+ .run(reservationId);
756
+
757
+ // Return value must be plain TreeNodes: the Rust side deserializes
758
+ // Vec<TreeNode>.
759
+ return { id: reservationId, leaves: reservedLeaves };
760
+ })();
761
+ } catch (error) {
762
+ if (error instanceof TreeStoreError) throw error;
763
+ throw new TreeStoreError(
764
+ `Failed to update reservation '${reservationId}': ${error.message}`,
765
+ error
766
+ );
767
+ }
768
+ }
769
+
770
+ // ===== Private DB helpers (synchronous) =====
771
+
772
+ /**
773
+ * Upsert leaf pedigrees into the pool, skipping any id in `skipIds` (spent).
774
+ * Refreshes the leaf's mutable fields; preserves reservation_id (not in the
775
+ * SET list).
776
+ */
777
+ _upsertLeaves(leaves, isMissing, skipIds) {
778
+ if (!leaves || leaves.length === 0) return;
779
+ const stmt = this.db.prepare(
780
+ `INSERT INTO brz_tree_leaves
781
+ (id, parent_node_id, status, value, verifying_public_key,
782
+ signing_public_key, data, is_missing_from_operators, added_at)
783
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
784
+ ON CONFLICT(id) DO UPDATE SET
785
+ parent_node_id = excluded.parent_node_id,
786
+ status = excluded.status,
787
+ value = excluded.value,
788
+ verifying_public_key = excluded.verifying_public_key,
789
+ signing_public_key = excluded.signing_public_key,
790
+ data = excluded.data,
791
+ is_missing_from_operators = excluded.is_missing_from_operators,
792
+ added_at = excluded.added_at`
793
+ );
794
+ const now = Date.now();
795
+ for (const leaf of leaves) {
796
+ if (skipIds && skipIds.has(leaf.id)) continue;
797
+ stmt.run(
798
+ leaf.id,
799
+ leaf.parent_node_id ?? null,
800
+ leaf.status,
801
+ leaf.value,
802
+ leaf.verifying_public_key,
803
+ leaf.signing_keyshare.public_key,
804
+ JSON.stringify(leaf),
805
+ isMissing ? 1 : 0,
806
+ now
807
+ );
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Replaces `leafId`'s ancestor rows wholesale (delete then insert). An empty
813
+ * `nodes` list is a no-op: it means the chain is unknown, not that the leaf
814
+ * has none, so a stored chain must survive being re-added without one.
815
+ */
816
+ _upsertAncestors(leafId, nodes) {
817
+ if (!nodes || nodes.length === 0) return;
818
+ this.db.prepare("DELETE FROM brz_tree_ancestors WHERE leaf_id = ?").run(leafId);
819
+ const stmt = this.db.prepare(
820
+ `INSERT INTO brz_tree_ancestors
821
+ (leaf_id, id, parent_node_id, status, value, verifying_public_key, data)
822
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
823
+ );
824
+ for (const node of nodes) {
825
+ stmt.run(
826
+ leafId,
827
+ node.id,
828
+ node.parent_node_id ?? null,
829
+ node.status,
830
+ node.value,
831
+ node.verifying_public_key,
832
+ JSON.stringify(node)
833
+ );
834
+ }
835
+ }
836
+
837
+ /** Drops the ancestor rows of every named leaf. */
838
+ _deleteAncestorsForLeaves(leafIds) {
839
+ if (!leafIds || leafIds.length === 0) return;
840
+ for (const chunk of idChunks(leafIds)) {
841
+ const placeholders = chunk.map(() => "?").join(",");
842
+ this.db
843
+ .prepare(`DELETE FROM brz_tree_ancestors WHERE leaf_id IN (${placeholders})`)
844
+ .run(...chunk);
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Full node data for the selected ids, preserving selection order. Errors if
850
+ * any selected leaf is missing.
851
+ */
852
+ _resolveFullLeaves(ids) {
853
+ if (!ids || ids.length === 0) return [];
854
+ const stmt = this.db.prepare("SELECT data FROM brz_tree_leaves WHERE id = ?");
855
+ const leaves = [];
856
+ for (const id of ids) {
857
+ const row = stmt.get(id);
858
+ if (!row) {
859
+ throw new TreeStoreError(`selected leaf ${id} not found in store`);
860
+ }
861
+ leaves.push(JSON.parse(row.data));
862
+ }
863
+ return leaves;
864
+ }
865
+
866
+ /**
867
+ * Create a reservation and attach it to the given leaves.
868
+ */
869
+ _createReservation(id, leaves, purpose, pendingChange) {
870
+ this.db
871
+ .prepare(
872
+ "INSERT INTO brz_tree_reservations (id, purpose, pending_change_amount, created_at) VALUES (?, ?, ?, ?)"
873
+ )
874
+ .run(id, purpose, pendingChange, Date.now());
875
+ this._setReservationId(
876
+ id,
877
+ leaves.map((l) => l.id)
878
+ );
879
+ }
880
+
881
+ _setReservationId(reservationId, leafIds) {
882
+ if (!leafIds || leafIds.length === 0) return;
883
+ const stmt = this.db.prepare(
884
+ "UPDATE brz_tree_leaves SET reservation_id = ? WHERE id = ?"
885
+ );
886
+ for (const id of leafIds) {
887
+ stmt.run(reservationId, id);
888
+ }
889
+ }
890
+
891
+ _insertSpent(ids) {
892
+ if (!ids || ids.length === 0) return;
893
+ const stmt = this.db.prepare(
894
+ "INSERT OR IGNORE INTO brz_tree_spent (id, spent_at) VALUES (?, ?)"
895
+ );
896
+ const now = Date.now();
897
+ for (const id of ids) {
898
+ stmt.run(id, now);
899
+ }
900
+ }
901
+
902
+ _removeSpent(ids) {
903
+ if (!ids || ids.length === 0) return;
904
+ const stmt = this.db.prepare("DELETE FROM brz_tree_spent WHERE id = ?");
905
+ for (const id of ids) {
906
+ stmt.run(id);
907
+ }
908
+ }
909
+
910
+ _spentIdsSince(refreshMs) {
911
+ const rows = this.db
912
+ .prepare("SELECT id FROM brz_tree_spent WHERE spent_at >= ?")
913
+ .all(refreshMs);
914
+ return new Set(rows.map((r) => r.id));
915
+ }
916
+
917
+ _cleanupStaleReservations() {
918
+ const cutoff = Date.now() - RESERVATION_TIMEOUT_MS;
919
+ this.db
920
+ .prepare(
921
+ `UPDATE brz_tree_leaves SET reservation_id = NULL
922
+ WHERE reservation_id IN (
923
+ SELECT id FROM brz_tree_reservations WHERE created_at < ?
924
+ )`
925
+ )
926
+ .run(cutoff);
927
+ this.db
928
+ .prepare("DELETE FROM brz_tree_reservations WHERE created_at < ?")
929
+ .run(cutoff);
930
+ }
931
+
932
+ _cleanupSpentMarkers(refreshMs) {
933
+ this.db
934
+ .prepare("DELETE FROM brz_tree_spent WHERE spent_at < ?")
935
+ .run(refreshMs - SPENT_MARKER_CLEANUP_THRESHOLD_MS);
936
+ }
937
+
938
+ _markSwapCompleted() {
939
+ this.db
940
+ .prepare("UPDATE brz_tree_swap_status SET last_completed_at = ? WHERE id = 1")
941
+ .run(Date.now());
942
+ }
943
+
944
+ /**
945
+ * Total value of unreserved available pool leaves (drives WaitForPending).
946
+ */
947
+ _availableTotal() {
948
+ return this.db
949
+ .prepare(
950
+ `SELECT COALESCE(SUM(value), 0) AS total FROM brz_tree_leaves
951
+ WHERE status = 'Available'
952
+ AND is_missing_from_operators = 0 AND reservation_id IS NULL`
953
+ )
954
+ .get().total;
955
+ }
956
+
957
+ _pendingBalance() {
958
+ return this.db
959
+ .prepare(
960
+ "SELECT COALESCE(SUM(pending_change_amount), 0) AS pending FROM brz_tree_reservations"
961
+ )
962
+ .get().pending;
963
+ }
964
+
965
+ _slimCandidates(maxTarget) {
966
+ return this.db
967
+ .prepare(SLIM_LEAF_CANDIDATES_SQL)
968
+ .all(maxTarget, maxTarget)
969
+ .map((r) => ({ id: r.id, value: r.value }));
970
+ }
971
+
972
+ // ===== Private selection helpers (pure) =====
973
+
974
+ /**
975
+ * Generate a unique reservation ID (UUIDv4).
976
+ */
977
+ _generateId() {
978
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
979
+ return crypto.randomUUID();
980
+ }
981
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
982
+ const r = (Math.random() * 16) | 0;
983
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
984
+ return v.toString(16);
985
+ });
986
+ }
987
+
988
+ /**
989
+ * Calculate total sats from target amounts.
990
+ */
991
+ _totalSats(targetAmounts) {
992
+ if (targetAmounts.type === "amountAndFee") {
993
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
994
+ }
995
+ if (targetAmounts.type === "exactDenominations") {
996
+ return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
997
+ }
998
+ return 0;
999
+ }
1000
+
1001
+ _maxTargetForPrefilter(targetAmounts) {
1002
+ if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
1003
+ if (targetAmounts.type === "amountAndFee") {
1004
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
1005
+ }
1006
+ if (targetAmounts.type === "exactDenominations") {
1007
+ return targetAmounts.denominations.reduce((m, v) => m + v, 0);
1008
+ }
1009
+ return Number.MAX_SAFE_INTEGER;
1010
+ }
1011
+
1012
+ /**
1013
+ * Select leaves by target amounts. Returns null if no exact match found.
1014
+ */
1015
+ _selectLeavesByTargetAmounts(leaves, targetAmounts) {
1016
+ if (!targetAmounts) {
1017
+ // No target: return all leaves (may be empty)
1018
+ return [...leaves];
1019
+ }
1020
+
1021
+ if (targetAmounts.type === "amountAndFee") {
1022
+ const amountLeaves = this._selectLeavesByExactAmount(leaves, targetAmounts.amountSats);
1023
+ if (amountLeaves === null) return null;
1024
+
1025
+ if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
1026
+ const amountIds = new Set(amountLeaves.map((l) => l.id));
1027
+ const remaining = leaves.filter((l) => !amountIds.has(l.id));
1028
+ const feeLeaves = this._selectLeavesByExactAmount(remaining, targetAmounts.feeSats);
1029
+ if (feeLeaves === null) return null;
1030
+ return [...amountLeaves, ...feeLeaves];
1031
+ }
1032
+
1033
+ return amountLeaves;
1034
+ }
1035
+
1036
+ if (targetAmounts.type === "exactDenominations") {
1037
+ return this._selectLeavesByExactDenominations(leaves, targetAmounts.denominations);
1038
+ }
1039
+
1040
+ return null;
1041
+ }
1042
+
1043
+ /**
1044
+ * Select leaves that sum to exactly the target amount.
1045
+ */
1046
+ _selectLeavesByExactAmount(leaves, targetAmount) {
1047
+ if (targetAmount === 0) return null; // Invalid amount
1048
+
1049
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
1050
+ if (totalAvailable < targetAmount) return null; // Insufficient funds
1051
+
1052
+ // Try single exact match
1053
+ const single = leaves.find((l) => l.value === targetAmount);
1054
+ if (single) return [single];
1055
+
1056
+ // Try greedy multiple match
1057
+ const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
1058
+ return multipleResult;
1059
+ }
1060
+
1061
+ /**
1062
+ * Select leaves that match exact denominations.
1063
+ */
1064
+ _selectLeavesByExactDenominations(leaves, denominations) {
1065
+ const remaining = [...leaves];
1066
+ const selected = [];
1067
+
1068
+ for (const denomination of denominations) {
1069
+ const idx = remaining.findIndex((l) => l.value === denomination);
1070
+ if (idx === -1) return null; // Can't match this denomination
1071
+ selected.push(remaining[idx]);
1072
+ remaining.splice(idx, 1);
1073
+ }
1074
+
1075
+ return selected;
1076
+ }
1077
+
1078
+ /**
1079
+ * Select leaves summing to at least the target amount.
1080
+ */
1081
+ _selectLeavesByMinimumAmount(leaves, targetAmount) {
1082
+ if (targetAmount === 0) return null;
1083
+
1084
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
1085
+ if (totalAvailable < targetAmount) return null;
1086
+
1087
+ const result = [];
1088
+ let sum = 0;
1089
+ for (const leaf of leaves) {
1090
+ sum += leaf.value;
1091
+ result.push(leaf);
1092
+ if (sum >= targetAmount) break;
1093
+ }
1094
+
1095
+ return sum >= targetAmount ? result : null;
1096
+ }
1097
+
1098
+ /**
1099
+ * Find exact multiple match using greedy algorithm.
1100
+ */
1101
+ _findExactMultipleMatch(leaves, targetAmount) {
1102
+ if (targetAmount === 0) return [];
1103
+ if (leaves.length === 0) return null;
1104
+
1105
+ // Pass 1: Try greedy on all leaves
1106
+ const result = this._greedyExactMatch(leaves, targetAmount);
1107
+ if (result) return result;
1108
+
1109
+ // Pass 2: Try with only power-of-two leaves
1110
+ const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
1111
+ if (powerOfTwoLeaves.length === leaves.length) return null;
1112
+
1113
+ return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
1114
+ }
1115
+
1116
+ /**
1117
+ * Greedy exact match algorithm.
1118
+ */
1119
+ _greedyExactMatch(leaves, targetAmount) {
1120
+ const sorted = [...leaves].sort((a, b) => b.value - a.value);
1121
+ const result = [];
1122
+ let remaining = targetAmount;
1123
+
1124
+ for (const leaf of sorted) {
1125
+ if (leaf.value > remaining) continue;
1126
+ remaining -= leaf.value;
1127
+ result.push(leaf);
1128
+ if (remaining === 0) return result;
1129
+ }
1130
+
1131
+ return null;
1132
+ }
1133
+
1134
+ /**
1135
+ * Check if value is a power of two.
1136
+ */
1137
+ _isPowerOfTwo(value) {
1138
+ return value > 0 && (value & (value - 1)) === 0;
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Create and initialize a NodeTreeStore for the SQLite database at `dbPath`.
1144
+ * Returns the initialized store instance. Unlike the async PostgreSQL factory,
1145
+ * this is synchronous because better-sqlite3 initialization is synchronous; the
1146
+ * return value is still awaitable.
1147
+ *
1148
+ * @param {string} dbPath - Path to the SQLite database file for this wallet.
1149
+ * @param {object} [logger]
1150
+ * @param {boolean} [runMigration]
1151
+ * @returns {NodeTreeStore}
1152
+ */
1153
+ // Async so it satisfies the wasm-bindgen `async fn` import the Rust bridge binds
1154
+ // to, even though better-sqlite3 initialization itself is synchronous.
1155
+ async function createNodeTreeStore(dbPath, logger = null, runMigration = true) {
1156
+ const store = new NodeTreeStore(dbPath, logger, runMigration);
1157
+ return store.initialize();
1158
+ }
1159
+
1160
+ /**
1161
+ * Create and initialize the tree store for the wallet whose data lives in
1162
+ * `dataDir`. Takes the directory rather than a file so it mirrors
1163
+ * `createDefaultStorage`, which receives the same path from the WASM bridge.
1164
+ *
1165
+ * @param {string} dataDir - Directory holding this wallet's database.
1166
+ * @param {object} [logger]
1167
+ * @returns {Promise<NodeTreeStore>}
1168
+ */
1169
+ async function createDefaultTreeStore(dataDir, logger = null) {
1170
+ const path = require("path");
1171
+ const fs = require("fs").promises;
1172
+
1173
+ await fs.mkdir(dataDir, { recursive: true });
1174
+
1175
+ // Same file name as node-storage's createDefaultStorage: the tree store
1176
+ // shares the main storage's database.
1177
+ return createNodeTreeStore(path.join(dataDir, "storage.sql"), logger, true);
1178
+ }
1179
+
1180
+ module.exports = {
1181
+ NodeTreeStore,
1182
+ createNodeTreeStore,
1183
+ createDefaultTreeStore,
1184
+ TreeStoreError,
1185
+ };