@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
@@ -58,22 +58,22 @@ const LEAF_UPSERT_CHUNK_SIZE = 1000;
58
58
  * leaf is sufficient). $1 is the user id.
59
59
  */
60
60
  const SLIM_LEAF_CANDIDATES_SQL = `
61
- SELECT id, (data->>'value')::bigint AS value
61
+ SELECT id, value
62
62
  FROM brz_tree_leaves
63
63
  WHERE user_id = $1
64
64
  AND status = 'Available'
65
65
  AND is_missing_from_operators = FALSE
66
66
  AND reservation_id IS NULL
67
67
  AND (
68
- (data->>'value')::bigint <= $2
68
+ value <= $2
69
69
  OR id = (
70
70
  SELECT id FROM brz_tree_leaves
71
71
  WHERE user_id = $1
72
72
  AND status = 'Available'
73
73
  AND is_missing_from_operators = FALSE
74
74
  AND reservation_id IS NULL
75
- AND (data->>'value')::bigint > $2
76
- ORDER BY (data->>'value')::bigint
75
+ AND value > $2
76
+ ORDER BY value
77
77
  LIMIT 1
78
78
  )
79
79
  )
@@ -94,6 +94,30 @@ function _identityLockKey(prefix, identity) {
94
94
  return hash.digest().readBigInt64BE(0);
95
95
  }
96
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
+
97
121
  class PostgresTreeStore {
98
122
  /**
99
123
  * @param {import('pg').Pool} pool
@@ -143,9 +167,8 @@ class PostgresTreeStore {
143
167
  }
144
168
 
145
169
  /**
146
- * Run a function inside a transaction with the advisory lock. Reserved for
147
- * operations whose correctness depends on serializing the available-leaf set
148
- * (`tryReserveLeaves`, `setLeaves`).
170
+ * Run a function inside a transaction with the advisory lock. Used by every
171
+ * operation that mutates the leaf set or its reservations.
149
172
  * @param {function(import('pg').PoolClient): Promise<T>} fn
150
173
  * @returns {Promise<T>}
151
174
  * @template T
@@ -171,9 +194,8 @@ class PostgresTreeStore {
171
194
 
172
195
  /**
173
196
  * Run a function inside a transaction without the advisory lock. Used by
174
- * operations scoped to a single reservation_id (`addLeaves`,
175
- * `cancelReservation`, `updateReservation`) where MVCC + row-level locks
176
- * suffice and the global lock would only add contention.
197
+ * `addLeaves` and by read-only queries (`trySelectLeaves`), where MVCC +
198
+ * row-level locks suffice and the global lock would only add contention.
177
199
  * @param {function(import('pg').PoolClient): Promise<T>} fn
178
200
  * @returns {Promise<T>}
179
201
  * @template T
@@ -205,13 +227,14 @@ class PostgresTreeStore {
205
227
  return;
206
228
  }
207
229
 
230
+ const leafNodes = leaves;
208
231
  await this._withTransaction(async (client) => {
209
232
  // Remove these leaves from spent_leaves table
210
- const leafIds = leaves.map((l) => l.id);
233
+ const leafIds = leafNodes.map((l) => l.id);
211
234
  await this._batchRemoveSpentLeaves(client, leafIds);
212
235
 
213
236
  // Batch upsert all leaves
214
- await this._batchUpsertLeaves(client, leaves, false, null);
237
+ await this._batchUpsertLeaves(client, leafNodes, false, null);
215
238
  });
216
239
  } catch (error) {
217
240
  if (error instanceof TreeStoreError) throw error;
@@ -222,6 +245,120 @@ class PostgresTreeStore {
222
245
  }
223
246
  }
224
247
 
248
+ /**
249
+ * Store the ancestor chain of each pedigree, leaving the leaf pool and any
250
+ * spent marker untouched.
251
+ * @param {Array} pedigrees - Array of LeafPedigree { leaf, ancestors }
252
+ */
253
+ async storeAncestors(pedigrees) {
254
+ try {
255
+ if (!pedigrees || pedigrees.length === 0) {
256
+ return;
257
+ }
258
+
259
+ await this._withWriteTransaction(async (client) => {
260
+ // A leaf can be spent between its chain being resolved and this write, and
261
+ // a chain is only ever removed with its leaf. Writing one for a leaf that is
262
+ // already gone would leave it behind for good.
263
+ const stored = await client.query(
264
+ "SELECT id FROM brz_tree_leaves WHERE user_id = $1 AND id = ANY($2)",
265
+ [this.identity, pedigrees.map((p) => p.leaf.id)]
266
+ );
267
+ const storedLeafIds = new Set(stored.rows.map((row) => row.id));
268
+
269
+ await this._batchUpsertAncestors(
270
+ client,
271
+ pedigrees.filter((p) => storedLeafIds.has(p.leaf.id))
272
+ );
273
+ });
274
+ } catch (error) {
275
+ if (error instanceof TreeStoreError) throw error;
276
+ throw new TreeStoreError(
277
+ `Failed to store ancestors: ${error.message}`,
278
+ error
279
+ );
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Ids of the stored leaves whose chain cannot back an exit: the leaf has a
285
+ * parent, and no ancestor row of its own holds that parent.
286
+ * @returns {Promise<Array<string>>}
287
+ */
288
+ async leavesMissingExitChains() {
289
+ try {
290
+ const result = await this.pool.query(
291
+ // A stored chain runs from its leaf's parent to a root, so a leaf whose
292
+ // chain holds the parent it has now is exitable. The join binds all three
293
+ // primary key columns, making it one index probe per leaf. A leaf that is
294
+ // itself a root needs no chain.
295
+ `SELECT l.id
296
+ FROM brz_tree_leaves l
297
+ LEFT JOIN brz_tree_ancestors link
298
+ ON link.user_id = l.user_id AND link.leaf_id = l.id
299
+ AND link.id = l.parent_node_id
300
+ WHERE l.user_id = $1
301
+ AND l.parent_node_id IS NOT NULL
302
+ AND link.leaf_id IS NULL`,
303
+ [this.identity]
304
+ );
305
+ return result.rows.map((r) => r.id);
306
+ } catch (error) {
307
+ if (error instanceof TreeStoreError) throw error;
308
+ throw new TreeStoreError(
309
+ `Failed to get leaves missing exit chains: ${error.message}`,
310
+ error
311
+ );
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Reconstruct the exit chains for many leaves in one query, each as
317
+ * { leaf, ancestors } with ancestors nearest first. A leaf absent from the store
318
+ * is skipped; a chain that hits a gap comes back partial.
319
+ * @param {Array<string>} leafIds
320
+ * @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
321
+ */
322
+ async getExitChains(leafIds) {
323
+ try {
324
+ if (!leafIds || leafIds.length === 0) return [];
325
+ // One query loads each requested leaf's own row plus its ancestor rows,
326
+ // both tagged by the owning leaf id (a leaf's own row is tagged with its
327
+ // own id). Reading them in two queries could pair a leaf with ancestors
328
+ // from a different snapshot. Grouping by that tag keeps each leaf's node
329
+ // set separate, so a node id stored under another leaf can never
330
+ // cross-contaminate this one.
331
+ const result_rows = await this.pool.query(
332
+ `SELECT leaf_id, data FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)
333
+ UNION ALL
334
+ SELECT id AS leaf_id, data FROM brz_tree_leaves WHERE user_id = $1 AND id = ANY($2)`,
335
+ [this.identity, leafIds]
336
+ );
337
+
338
+ const nodesByLeaf = new Map();
339
+ for (const r of result_rows.rows) {
340
+ let nodes = nodesByLeaf.get(r.leaf_id);
341
+ if (!nodes) {
342
+ nodes = new Map();
343
+ nodesByLeaf.set(r.leaf_id, nodes);
344
+ }
345
+ nodes.set(r.data.id, r.data);
346
+ }
347
+
348
+ const result = [];
349
+ for (const id of leafIds) {
350
+ const nodes = nodesByLeaf.get(id);
351
+ if (!nodes) continue;
352
+ const pedigree = assembleExitChain(nodes, id);
353
+ if (pedigree) result.push(pedigree);
354
+ }
355
+ return result;
356
+ } catch (error) {
357
+ if (error instanceof TreeStoreError) throw error;
358
+ throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
359
+ }
360
+ }
361
+
225
362
  /**
226
363
  * Get all leaves categorized by status.
227
364
  * @returns {Promise<Object>} Leaves object with available, notAvailable, etc.
@@ -235,7 +372,7 @@ class PostgresTreeStore {
235
372
  try {
236
373
  const result = await this.pool.query(
237
374
  `
238
- SELECT COALESCE(SUM((l.data->>'value')::bigint), 0)::bigint AS balance
375
+ SELECT COALESCE(SUM(l.value), 0)::bigint AS balance
239
376
  FROM brz_tree_leaves l
240
377
  LEFT JOIN brz_tree_reservations r
241
378
  ON l.reservation_id = r.id AND l.user_id = r.user_id
@@ -265,8 +402,8 @@ class PostgresTreeStore {
265
402
  const result = await this.pool.query(
266
403
  `
267
404
  SELECT l.id AS id,
268
- l.data->>'verifying_public_key' AS verifying,
269
- l.data->'signing_keyshare'->>'public_key' AS keyshare
405
+ l.verifying_public_key AS verifying,
406
+ l.signing_public_key AS keyshare
270
407
  FROM brz_tree_leaves l
271
408
  LEFT JOIN brz_tree_reservations r
272
409
  ON l.reservation_id = r.id AND l.user_id = r.user_id
@@ -306,6 +443,7 @@ class PostgresTreeStore {
306
443
 
307
444
  for (const row of result.rows) {
308
445
  const node = row.data;
446
+ const spendable = node.status === "Available";
309
447
 
310
448
  if (row.purpose) {
311
449
  if (row.purpose === "Payment") {
@@ -313,14 +451,12 @@ class PostgresTreeStore {
313
451
  } else if (row.purpose === "Swap") {
314
452
  reservedForSwap.push(node);
315
453
  }
454
+ } else if (!spendable) {
455
+ notAvailable.push(node);
316
456
  } else if (row.is_missing_from_operators) {
317
- if (node.status === "Available") {
318
- availableMissingFromOperators.push(node);
319
- }
320
- } else if (node.status === "Available") {
321
- available.push(node);
457
+ availableMissingFromOperators.push(node);
322
458
  } else {
323
- notAvailable.push(node);
459
+ available.push(node);
324
460
  }
325
461
  }
326
462
 
@@ -393,14 +529,29 @@ class PostgresTreeStore {
393
529
  // Includes leaves released earlier in this transaction by
394
530
  // _cleanupStaleReservations (which now NULLs reservation_id explicitly,
395
531
  // since the composite FK uses NO ACTION).
396
- await client.query(
397
- "DELETE FROM brz_tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
532
+ const deleted = await client.query(
533
+ "DELETE FROM brz_tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2 RETURNING id",
398
534
  [this.identity, refreshTimestamp]
399
535
  );
400
536
 
401
537
  // Upsert all leaves (filtering spent)
402
538
  await this._batchUpsertLeaves(client, leaves, false, spentIds);
403
539
  await this._batchUpsertLeaves(client, missingLeaves, true, spentIds);
540
+
541
+ // A leaf reported again in this same refresh is re-inserted above, so its
542
+ // ancestor rows must survive: only ids that do NOT reappear (truly gone,
543
+ // e.g. spent) get their ancestor rows dropped alongside them.
544
+ const survivingIds = new Set();
545
+ for (const leaf of leaves.concat(missingLeaves || [])) {
546
+ if (!spentIds.has(leaf.id)) survivingIds.add(leaf.id);
547
+ }
548
+ const goneIds = deleted.rows.map((r) => r.id).filter((id) => !survivingIds.has(id));
549
+ if (goneIds.length > 0) {
550
+ await client.query(
551
+ "DELETE FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)",
552
+ [this.identity, goneIds]
553
+ );
554
+ }
404
555
  });
405
556
  } catch (error) {
406
557
  if (error instanceof TreeStoreError) throw error;
@@ -427,14 +578,19 @@ class PostgresTreeStore {
427
578
  async cancelReservation(id, leavesToKeep) {
428
579
  try {
429
580
  await this._withTransaction(async (client) => {
430
- const res = await client.query(
431
- "SELECT id FROM brz_tree_reservations WHERE user_id = $1 AND id = $2",
581
+ // Return leavesToKeep to the pool even when the reservation is already
582
+ // gone (e.g. released by stale cleanup): dropping them here would lose
583
+ // the leaves until the next refresh. The deletes no-op in that case.
584
+ // Only the leaves are re-inserted: a kept leaf's ancestor rows stay put
585
+ // (they are not touched below); a dropped leaf's are removed with it.
586
+ const keepIds = new Set((leavesToKeep || []).map((l) => l.id));
587
+ const reservedResult = await client.query(
588
+ "SELECT id FROM brz_tree_leaves WHERE user_id = $1 AND reservation_id = $2",
432
589
  [this.identity, id]
433
590
  );
434
-
435
- if (res.rows.length === 0) {
436
- return;
437
- }
591
+ const droppedIds = reservedResult.rows
592
+ .map((r) => r.id)
593
+ .filter((rid) => !keepIds.has(rid));
438
594
 
439
595
  await client.query(
440
596
  "DELETE FROM brz_tree_leaves WHERE user_id = $1 AND reservation_id = $2",
@@ -446,6 +602,13 @@ class PostgresTreeStore {
446
602
  [this.identity, id]
447
603
  );
448
604
 
605
+ if (droppedIds.length > 0) {
606
+ await client.query(
607
+ "DELETE FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)",
608
+ [this.identity, droppedIds]
609
+ );
610
+ }
611
+
449
612
  if (leavesToKeep && leavesToKeep.length > 0) {
450
613
  await this._batchUpsertLeaves(client, leavesToKeep, false, null);
451
614
  }
@@ -495,6 +658,14 @@ class PostgresTreeStore {
495
658
  "DELETE FROM brz_tree_reservations WHERE user_id = $1 AND id = $2",
496
659
  [this.identity, id]
497
660
  );
661
+ // The spent leaves own these ancestor rows; remove them in the same
662
+ // transaction rather than leaving them to a separate reclaim pass.
663
+ if (reservedLeafIds.length > 0) {
664
+ await client.query(
665
+ "DELETE FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)",
666
+ [this.identity, reservedLeafIds]
667
+ );
668
+ }
498
669
  }
499
670
 
500
671
  // Add new leaves if provided
@@ -541,7 +712,7 @@ class PostgresTreeStore {
541
712
  // from the prefiltered set since the prefilter may exclude big leaves.
542
713
  const totalResult = await client.query(
543
714
  `
544
- SELECT COALESCE(SUM((data->>'value')::bigint), 0)::bigint AS total
715
+ SELECT COALESCE(SUM(value), 0)::bigint AS total
545
716
  FROM brz_tree_leaves
546
717
  WHERE user_id = $1
547
718
  AND status = 'Available'
@@ -802,6 +973,14 @@ class PostgresTreeStore {
802
973
  "DELETE FROM brz_tree_leaves WHERE user_id = $1 AND reservation_id = $2",
803
974
  [this.identity, reservationId]
804
975
  );
976
+ // The spent leaves own these ancestor rows; remove them now since there
977
+ // is no later reclaim pass.
978
+ if (oldLeafIds.length > 0) {
979
+ await client.query(
980
+ "DELETE FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)",
981
+ [this.identity, oldLeafIds]
982
+ );
983
+ }
805
984
 
806
985
  // Upsert change leaves to available pool
807
986
  await this._batchUpsertLeaves(client, changeLeaves, false, null);
@@ -819,6 +998,8 @@ class PostgresTreeStore {
819
998
  [this.identity, reservationId]
820
999
  );
821
1000
 
1001
+ // Return value must be plain TreeNodes: the Rust side deserializes
1002
+ // Vec<TreeNode>.
822
1003
  return {
823
1004
  id: reservationId,
824
1005
  leaves: reservedLeaves,
@@ -1023,37 +1204,117 @@ class PostgresTreeStore {
1023
1204
  async _batchUpsertLeaves(client, leaves, isMissingFromOperators, skipIds) {
1024
1205
  if (!leaves || leaves.length === 0) return;
1025
1206
 
1026
- const filtered = skipIds
1207
+ const leafNodes = skipIds
1027
1208
  ? leaves.filter((l) => !skipIds.has(l.id))
1028
1209
  : leaves;
1029
1210
 
1030
- if (filtered.length === 0) return;
1211
+ if (leafNodes.length === 0) return;
1212
+
1031
1213
 
1032
1214
  // All chunks run inside the caller's transaction, and NOW() is the
1033
1215
  // transaction timestamp, so every row still lands atomically with one
1034
1216
  // shared added_at.
1035
- for (let i = 0; i < filtered.length; i += LEAF_UPSERT_CHUNK_SIZE) {
1036
- const chunk = filtered.slice(i, i + LEAF_UPSERT_CHUNK_SIZE);
1217
+ for (let i = 0; i < leafNodes.length; i += LEAF_UPSERT_CHUNK_SIZE) {
1218
+ const chunk = leafNodes.slice(i, i + LEAF_UPSERT_CHUNK_SIZE);
1037
1219
  const ids = chunk.map((l) => l.id);
1038
1220
  const statuses = chunk.map((l) => l.status);
1039
1221
  const missingFlags = chunk.map(() => isMissingFromOperators);
1040
1222
  const dataValues = chunk.map((l) => JSON.stringify(l));
1223
+ const values = chunk.map((l) => l.value);
1224
+ const parents = chunk.map((l) => l.parent_node_id ?? null);
1225
+ const verifyings = chunk.map((l) => l.verifying_public_key);
1226
+ const signings = chunk.map((l) => l.signing_keyshare.public_key);
1041
1227
 
1042
1228
  await client.query(
1043
- `INSERT INTO brz_tree_leaves (user_id, id, status, is_missing_from_operators, data, added_at)
1044
- SELECT $5, id, status, missing, data::jsonb, NOW()
1045
- FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
1046
- AS t(id, status, missing, data)
1229
+ `INSERT INTO brz_tree_leaves
1230
+ (user_id, id, status, is_missing_from_operators, data, added_at,
1231
+ value, parent_node_id, verifying_public_key, signing_public_key)
1232
+ SELECT $5, id, status, missing, data::jsonb, NOW(),
1233
+ value, parent_node_id, verifying, signing
1234
+ FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[],
1235
+ $6::bigint[], $7::text[], $8::text[], $9::text[])
1236
+ AS t(id, status, missing, data, value, parent_node_id, verifying, signing)
1047
1237
  ON CONFLICT (user_id, id) DO UPDATE SET
1048
1238
  status = EXCLUDED.status,
1049
1239
  is_missing_from_operators = EXCLUDED.is_missing_from_operators,
1050
1240
  data = EXCLUDED.data,
1051
- added_at = NOW()`,
1052
- [ids, statuses, missingFlags, dataValues, this.identity]
1241
+ added_at = NOW(),
1242
+ value = EXCLUDED.value,
1243
+ parent_node_id = EXCLUDED.parent_node_id,
1244
+ verifying_public_key = EXCLUDED.verifying_public_key,
1245
+ signing_public_key = EXCLUDED.signing_public_key`,
1246
+ [
1247
+ ids,
1248
+ statuses,
1249
+ missingFlags,
1250
+ dataValues,
1251
+ this.identity,
1252
+ values,
1253
+ parents,
1254
+ verifyings,
1255
+ signings,
1256
+ ]
1053
1257
  );
1054
1258
  }
1055
1259
  }
1056
1260
 
1261
+ /**
1262
+ * Replaces the ancestor rows of every pedigree wholesale (delete then
1263
+ * insert), in two statements however many leaves there are. A pedigree
1264
+ * carrying no ancestors is skipped: an empty list means the chain is unknown,
1265
+ * not that the leaf has none, so a stored chain must survive being re-added
1266
+ * without one.
1267
+ */
1268
+ async _batchUpsertAncestors(client, pedigrees) {
1269
+ const withAncestors = (pedigrees || []).filter(
1270
+ (p) => p.ancestors && p.ancestors.length > 0
1271
+ );
1272
+ if (withAncestors.length === 0) return;
1273
+
1274
+ await client.query(
1275
+ "DELETE FROM brz_tree_ancestors WHERE user_id = $1 AND leaf_id = ANY($2)",
1276
+ [this.identity, withAncestors.map((p) => p.leaf.id)]
1277
+ );
1278
+
1279
+ const leafIds = [];
1280
+ const ids = [];
1281
+ const parents = [];
1282
+ const statuses = [];
1283
+ const dataValues = [];
1284
+ const values = [];
1285
+ const verifyings = [];
1286
+ for (const pedigree of withAncestors) {
1287
+ for (const node of pedigree.ancestors) {
1288
+ leafIds.push(pedigree.leaf.id);
1289
+ ids.push(node.id);
1290
+ parents.push(node.parent_node_id ?? null);
1291
+ statuses.push(node.status);
1292
+ dataValues.push(JSON.stringify(node));
1293
+ values.push(node.value);
1294
+ verifyings.push(node.verifying_public_key);
1295
+ }
1296
+ }
1297
+
1298
+ await client.query(
1299
+ `INSERT INTO brz_tree_ancestors
1300
+ (user_id, leaf_id, id, parent_node_id, status, data, value, verifying_public_key)
1301
+ SELECT $1, leaf_id, id, parent_node_id, status, data::jsonb, value, verifying
1302
+ FROM UNNEST($2::text[], $3::text[], $4::text[], $5::text[], $6::text[],
1303
+ $7::bigint[], $8::text[])
1304
+ AS t(leaf_id, id, parent_node_id, status, data, value, verifying)`,
1305
+ [
1306
+ this.identity,
1307
+ leafIds,
1308
+ ids,
1309
+ parents,
1310
+ statuses,
1311
+ dataValues,
1312
+ values,
1313
+ verifyings,
1314
+ ]
1315
+ );
1316
+ }
1317
+
1057
1318
  /**
1058
1319
  * Batch set reservation_id on leaves.
1059
1320
  */
@@ -304,6 +304,48 @@ class TreeStoreMigrationManager {
304
304
  ADD PRIMARY KEY (user_id)`,
305
305
  ],
306
306
  },
307
+ {
308
+ // Mirrors Rust migration 4 in spark-postgres/src/tree_store.rs.
309
+ // Ancestor chain: intermediate nodes a leaf's exit chain walks through,
310
+ // kept separate from the spendable leaf pool and carrying no pool
311
+ // metadata. Each row is owned by the leaf it belongs to (`leaf_id` is
312
+ // part of the primary key), so a node shared by several leaves' chains
313
+ // stores one row per leaf rather than one deduplicated row. Multi-tenant
314
+ // from creation.
315
+ name: "Add ancestor chain table",
316
+ sql: [
317
+ `CREATE TABLE IF NOT EXISTS brz_tree_ancestors (
318
+ user_id BYTEA NOT NULL,
319
+ leaf_id TEXT NOT NULL,
320
+ id TEXT NOT NULL,
321
+ parent_node_id TEXT,
322
+ status TEXT NOT NULL,
323
+ value BIGINT NOT NULL DEFAULT 0,
324
+ verifying_public_key TEXT NOT NULL DEFAULT '',
325
+ data JSONB NOT NULL,
326
+ PRIMARY KEY (user_id, leaf_id, id)
327
+ )`,
328
+ ],
329
+ },
330
+ {
331
+ // Promote the four JSON fields that queries pull out of `data` into
332
+ // dedicated columns (value, parent_node_id, verifying_public_key,
333
+ // signing_public_key), then backfill from the existing blob. Balance,
334
+ // selection, exit-chain, and key-verification queries read the columns
335
+ // instead of re-parsing JSON. Everything else stays in `data`.
336
+ name: "Promote leaf JSON fields to columns",
337
+ sql: [
338
+ `ALTER TABLE brz_tree_leaves ADD COLUMN IF NOT EXISTS value BIGINT NOT NULL DEFAULT 0`,
339
+ `ALTER TABLE brz_tree_leaves ADD COLUMN IF NOT EXISTS parent_node_id TEXT`,
340
+ `ALTER TABLE brz_tree_leaves ADD COLUMN IF NOT EXISTS verifying_public_key TEXT NOT NULL DEFAULT ''`,
341
+ `ALTER TABLE brz_tree_leaves ADD COLUMN IF NOT EXISTS signing_public_key TEXT NOT NULL DEFAULT ''`,
342
+ `UPDATE brz_tree_leaves SET
343
+ value = (data->>'value')::bigint,
344
+ parent_node_id = data->>'parent_node_id',
345
+ verifying_public_key = data->>'verifying_public_key',
346
+ signing_public_key = data->'signing_keyshare'->>'public_key'`,
347
+ ],
348
+ },
307
349
  ];
308
350
  }
309
351
  }
@@ -700,7 +700,7 @@ class SqliteStorage {
700
700
  listDeposits() {
701
701
  try {
702
702
  const stmt = this.db.prepare(`
703
- SELECT txid, vout, amount_sats, is_mature, claim_error, refund_tx, refund_tx_id
703
+ SELECT txid, vout, amount_sats, is_mature, claim_error, refund_tx, refund_tx_id, instant_claim_status
704
704
  FROM unclaimed_deposits
705
705
  `);
706
706
 
@@ -714,6 +714,9 @@ class SqliteStorage {
714
714
  claimError: row.claim_error ? JSON.parse(row.claim_error) : null,
715
715
  refundTx: row.refund_tx,
716
716
  refundTxId: row.refund_tx_id,
717
+ instantClaimStatus: row.instant_claim_status
718
+ ? JSON.parse(row.instant_claim_status)
719
+ : null,
717
720
  }))
718
721
  );
719
722
  } catch (error) {
@@ -735,12 +738,20 @@ class SqliteStorage {
735
738
  stmt.run(JSON.stringify(payload.error), txid, vout);
736
739
  } else if (payload.type === "refund") {
737
740
  const stmt = this.db.prepare(`
738
- UPDATE unclaimed_deposits
739
- SET refund_tx = ?, refund_tx_id = ?, claim_error = NULL
741
+ UPDATE unclaimed_deposits
742
+ SET refund_tx = ?, refund_tx_id = ?, claim_error = NULL
740
743
  WHERE txid = ? AND vout = ?
741
744
  `);
742
745
 
743
746
  stmt.run(payload.refundTx, payload.refundTxid, txid, vout);
747
+ } else if (payload.type === "instantClaim") {
748
+ const stmt = this.db.prepare(`
749
+ UPDATE unclaimed_deposits
750
+ SET instant_claim_status = ?
751
+ WHERE txid = ? AND vout = ?
752
+ `);
753
+
754
+ stmt.run(JSON.stringify(payload.status), txid, vout);
744
755
  } else {
745
756
  return Promise.reject(
746
757
  new StorageError(`Unknown payload type: ${payload.type}`)
@@ -480,6 +480,12 @@ class MigrationManager {
480
480
  ON cross_chain_swaps(provider, is_terminal)`,
481
481
  ],
482
482
  },
483
+ {
484
+ name: "Add instant claim status to unclaimed_deposits",
485
+ sql: [
486
+ `ALTER TABLE unclaimed_deposits ADD COLUMN instant_claim_status TEXT`,
487
+ ],
488
+ },
483
489
  ];
484
490
  }
485
491
  }
@@ -0,0 +1,13 @@
1
+ // errors.cjs - Tree store error wrapper with cause chain support
2
+ class TreeStoreError extends Error {
3
+ constructor(message, cause = null) {
4
+ super(message);
5
+ this.name = 'TreeStoreError';
6
+ this.cause = cause;
7
+ if (Error.captureStackTrace) {
8
+ Error.captureStackTrace(this, TreeStoreError);
9
+ }
10
+ }
11
+ }
12
+
13
+ module.exports = { TreeStoreError };