@breeztech/breez-sdk-spark 0.13.9-debug → 0.13.11-dev1

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 (41) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +1113 -1050
  3. package/bundler/breez_sdk_spark_wasm.js +5 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +1493 -1628
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
  7. package/deno/breez_sdk_spark_wasm.d.ts +1113 -1050
  8. package/deno/breez_sdk_spark_wasm.js +1394 -1284
  9. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  10. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
  11. package/nodejs/breez_sdk_spark_wasm.d.ts +1113 -1050
  12. package/nodejs/breez_sdk_spark_wasm.js +2527 -2654
  13. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
  15. package/nodejs/index.js +34 -0
  16. package/nodejs/index.mjs +5 -4
  17. package/nodejs/mysql-storage/errors.cjs +19 -0
  18. package/nodejs/mysql-storage/index.cjs +1366 -0
  19. package/nodejs/mysql-storage/migrations.cjs +387 -0
  20. package/nodejs/mysql-storage/package.json +9 -0
  21. package/nodejs/mysql-token-store/errors.cjs +9 -0
  22. package/nodejs/mysql-token-store/index.cjs +988 -0
  23. package/nodejs/mysql-token-store/migrations.cjs +255 -0
  24. package/nodejs/mysql-token-store/package.json +9 -0
  25. package/nodejs/mysql-tree-store/errors.cjs +9 -0
  26. package/nodejs/mysql-tree-store/index.cjs +939 -0
  27. package/nodejs/mysql-tree-store/migrations.cjs +221 -0
  28. package/nodejs/mysql-tree-store/package.json +9 -0
  29. package/nodejs/package.json +3 -0
  30. package/nodejs/postgres-storage/index.cjs +147 -92
  31. package/nodejs/postgres-storage/migrations.cjs +85 -4
  32. package/nodejs/postgres-token-store/index.cjs +186 -101
  33. package/nodejs/postgres-token-store/migrations.cjs +92 -3
  34. package/nodejs/postgres-tree-store/index.cjs +177 -93
  35. package/nodejs/postgres-tree-store/migrations.cjs +80 -3
  36. package/package.json +1 -1
  37. package/ssr/index.js +19 -14
  38. package/web/breez_sdk_spark_wasm.d.ts +1267 -1195
  39. package/web/breez_sdk_spark_wasm.js +2295 -2169
  40. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  41. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
@@ -22,8 +22,16 @@ class TokenStoreMigrationManager {
22
22
  /**
23
23
  * Run all pending migrations inside a single transaction with an advisory lock.
24
24
  * @param {import('pg').Pool} pool
25
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
26
+ * identifying the tenant. Used to backfill `user_id` columns in the
27
+ * multi-tenant scoping migration. Required.
25
28
  */
26
- async migrate(pool) {
29
+ async migrate(pool, identity) {
30
+ if (!identity || identity.length !== 33) {
31
+ throw new TokenStoreError(
32
+ "tenant identity (33-byte secp256k1 pubkey) is required"
33
+ );
34
+ }
27
35
  const client = await pool.connect();
28
36
  try {
29
37
  await client.query("BEGIN");
@@ -45,7 +53,7 @@ class TokenStoreMigrationManager {
45
53
  );
46
54
  const currentVersion = versionResult.rows[0].version;
47
55
 
48
- const migrations = this._getMigrations();
56
+ const migrations = this._getMigrations(identity);
49
57
 
50
58
  if (currentVersion >= migrations.length) {
51
59
  this._log("info", `Token store database is up to date (version ${currentVersion})`);
@@ -96,8 +104,16 @@ class TokenStoreMigrationManager {
96
104
 
97
105
  /**
98
106
  * Migrations matching the Rust PostgresTokenStore schema exactly.
107
+ *
108
+ * @param {Buffer|Uint8Array} identity - tenant identity inlined as a hex
109
+ * BYTEA literal in the multi-tenant scoping migration. Safe because the
110
+ * bytes come from a typed secp256k1 pubkey (`[0-9a-f]{66}` after hex
111
+ * encoding) — not user-controlled input.
99
112
  */
100
- _getMigrations() {
113
+ _getMigrations(identity) {
114
+ const idHex = Buffer.from(identity).toString("hex");
115
+ const idLit = `'\\x${idHex}'::bytea`;
116
+
101
117
  return [
102
118
  {
103
119
  name: "Create token store tables with race condition protection",
@@ -156,6 +172,79 @@ class TokenStoreMigrationManager {
156
172
  `INSERT INTO token_swap_status (id) VALUES (1) ON CONFLICT DO NOTHING`,
157
173
  ],
158
174
  },
175
+ {
176
+ // Mirrors Rust migration 2 in spark-postgres/src/token_store.rs.
177
+ // Adds user_id to every token-store table (including token_metadata —
178
+ // per-tenant to avoid 0-balance leakage for tokens a tenant never
179
+ // owned), backfills with the connecting tenant, and rewrites primary
180
+ // keys / FKs / indexes to lead with user_id. Composite FKs use NO
181
+ // ACTION because column-list SET NULL is PG15+ and a whole-row SET
182
+ // NULL would null user_id (NOT NULL).
183
+ name: "Multi-tenant scoping: add user_id and rewrite primary keys",
184
+ sql: [
185
+ // Drop dependent FKs FIRST so we can rebuild parent PKs they
186
+ // reference. Inline `REFERENCES` clauses get auto-named
187
+ // `<table>_<column>_fkey`.
188
+ `ALTER TABLE token_outputs
189
+ DROP CONSTRAINT IF EXISTS token_outputs_reservation_id_fkey`,
190
+ `ALTER TABLE token_outputs
191
+ DROP CONSTRAINT IF EXISTS token_outputs_token_identifier_fkey`,
192
+
193
+ // token_metadata: per-tenant scoping (privacy — see header).
194
+ `ALTER TABLE token_metadata ADD COLUMN user_id BYTEA`,
195
+ `UPDATE token_metadata SET user_id = ${idLit}`,
196
+ `ALTER TABLE token_metadata
197
+ ALTER COLUMN user_id SET NOT NULL,
198
+ DROP CONSTRAINT IF EXISTS token_metadata_pkey,
199
+ ADD PRIMARY KEY (user_id, identifier)`,
200
+ `DROP INDEX IF EXISTS idx_token_metadata_issuer_pk`,
201
+ `CREATE INDEX idx_token_metadata_user_issuer_pk
202
+ ON token_metadata (user_id, issuer_public_key)`,
203
+
204
+ // token_reservations: scope by user_id.
205
+ `ALTER TABLE token_reservations ADD COLUMN user_id BYTEA`,
206
+ `UPDATE token_reservations SET user_id = ${idLit}`,
207
+ `ALTER TABLE token_reservations
208
+ ALTER COLUMN user_id SET NOT NULL,
209
+ DROP CONSTRAINT IF EXISTS token_reservations_pkey,
210
+ ADD PRIMARY KEY (user_id, id)`,
211
+
212
+ // token_outputs: scope by user_id, rekey, re-add composite FKs.
213
+ `ALTER TABLE token_outputs ADD COLUMN user_id BYTEA`,
214
+ `UPDATE token_outputs SET user_id = ${idLit}`,
215
+ `ALTER TABLE token_outputs
216
+ ALTER COLUMN user_id SET NOT NULL,
217
+ DROP CONSTRAINT IF EXISTS token_outputs_pkey,
218
+ ADD PRIMARY KEY (user_id, id),
219
+ ADD FOREIGN KEY (user_id, token_identifier)
220
+ REFERENCES token_metadata(user_id, identifier),
221
+ ADD FOREIGN KEY (user_id, reservation_id)
222
+ REFERENCES token_reservations(user_id, id)`,
223
+ `DROP INDEX IF EXISTS idx_token_outputs_identifier`,
224
+ `DROP INDEX IF EXISTS idx_token_outputs_reservation`,
225
+ `CREATE INDEX idx_token_outputs_user_identifier
226
+ ON token_outputs (user_id, token_identifier)`,
227
+ `CREATE INDEX idx_token_outputs_user_reservation
228
+ ON token_outputs (user_id, reservation_id)
229
+ WHERE reservation_id IS NOT NULL`,
230
+
231
+ // token_spent_outputs: scope by user_id.
232
+ `ALTER TABLE token_spent_outputs ADD COLUMN user_id BYTEA`,
233
+ `UPDATE token_spent_outputs SET user_id = ${idLit}`,
234
+ `ALTER TABLE token_spent_outputs
235
+ ALTER COLUMN user_id SET NOT NULL,
236
+ DROP CONSTRAINT IF EXISTS token_spent_outputs_pkey,
237
+ ADD PRIMARY KEY (user_id, output_id)`,
238
+
239
+ // token_swap_status: drop the singleton id, rekey by user_id.
240
+ `ALTER TABLE token_swap_status DROP COLUMN id CASCADE`,
241
+ `ALTER TABLE token_swap_status ADD COLUMN user_id BYTEA`,
242
+ `UPDATE token_swap_status SET user_id = ${idLit}`,
243
+ `ALTER TABLE token_swap_status
244
+ ALTER COLUMN user_id SET NOT NULL,
245
+ ADD PRIMARY KEY (user_id)`,
246
+ ],
247
+ },
159
248
  ];
160
249
  }
161
250
  }
@@ -25,10 +25,10 @@ const { TreeStoreError } = require("./errors.cjs");
25
25
  const { TreeStoreMigrationManager } = require("./migrations.cjs");
26
26
 
27
27
  /**
28
- * Advisory lock key for serializing tree store write operations.
29
- * Matches the Rust constant TREE_STORE_WRITE_LOCK_KEY = 0x7472_6565_5354_4f52
28
+ * Domain prefix mixed into the per-tenant advisory-lock key. Distinct prefixes
29
+ * guarantee that locks from different stores (tree, token, …) never collide.
30
30
  */
31
- const TREE_STORE_WRITE_LOCK_KEY = "8390880541608791890"; // 0x7472656553544f52 as decimal string
31
+ const TREE_STORE_LOCK_PREFIX = "breez-spark-sdk:tree:";
32
32
 
33
33
  /**
34
34
  * Timeout for reservations in seconds. Reservations older than this are stale.
@@ -40,9 +40,37 @@ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
40
40
  */
41
41
  const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
42
42
 
43
+ /**
44
+ * Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
45
+ * prefix together with the identity pubkey and folding the first 8 bytes of
46
+ * the SHA-256 digest into a signed big-endian i64 — the type expected by
47
+ * `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
48
+ * collisions negligible (~1.2e-10 at 65k tenants).
49
+ */
50
+ function _identityLockKey(prefix, identity) {
51
+ const crypto = require("crypto");
52
+ const hash = crypto.createHash("sha256");
53
+ hash.update(prefix);
54
+ hash.update(Buffer.from(identity));
55
+ return hash.digest().readBigInt64BE(0);
56
+ }
57
+
43
58
  class PostgresTreeStore {
44
- constructor(pool, logger = null) {
59
+ /**
60
+ * @param {import('pg').Pool} pool
61
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
62
+ * identifying the tenant. All reads and writes are scoped by this.
63
+ * @param {object} [logger]
64
+ */
65
+ constructor(pool, identity, logger = null) {
66
+ if (!identity || identity.length !== 33) {
67
+ throw new TreeStoreError(
68
+ "tenant identity (33-byte secp256k1 pubkey) is required"
69
+ );
70
+ }
45
71
  this.pool = pool;
72
+ this.identity = Buffer.from(identity);
73
+ this.lockKey = _identityLockKey(TREE_STORE_LOCK_PREFIX, identity);
46
74
  this.logger = logger;
47
75
  }
48
76
 
@@ -52,7 +80,7 @@ class PostgresTreeStore {
52
80
  async initialize() {
53
81
  try {
54
82
  const migrationManager = new TreeStoreMigrationManager(this.logger);
55
- await migrationManager.migrate(this.pool);
83
+ await migrationManager.migrate(this.pool, this.identity);
56
84
  return this;
57
85
  } catch (error) {
58
86
  throw new TreeStoreError(
@@ -84,7 +112,10 @@ class PostgresTreeStore {
84
112
  const client = await this.pool.connect();
85
113
  try {
86
114
  await client.query("BEGIN");
87
- await client.query(`SELECT pg_advisory_xact_lock(${TREE_STORE_WRITE_LOCK_KEY})`);
115
+ // Per-tenant advisory lock: 64-bit key derived from a tree-store domain
116
+ // prefix and the tenant identity, so different tenants don't serialize
117
+ // on each other and tree/token locks never collide.
118
+ await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
88
119
  const result = await fn(client);
89
120
  await client.query("COMMIT");
90
121
  return result;
@@ -99,9 +130,8 @@ class PostgresTreeStore {
99
130
  /**
100
131
  * Run a function inside a transaction without the advisory lock. Used by
101
132
  * operations scoped to a single reservation_id (`addLeaves`,
102
- * `cancelReservation`, `finalizeReservation`, `updateReservation`) where
103
- * MVCC + row-level locks suffice and the global lock would only add
104
- * contention.
133
+ * `cancelReservation`, `updateReservation`) where MVCC + row-level locks
134
+ * suffice and the global lock would only add contention.
105
135
  * @param {function(import('pg').PoolClient): Promise<T>} fn
106
136
  * @returns {Promise<T>}
107
137
  * @template T
@@ -161,14 +191,20 @@ class PostgresTreeStore {
161
191
  */
162
192
  async getAvailableBalance() {
163
193
  try {
164
- const result = await this.pool.query(`
194
+ const result = await this.pool.query(
195
+ `
165
196
  SELECT COALESCE(SUM((l.data->>'value')::bigint), 0)::bigint AS balance
166
197
  FROM tree_leaves l
167
- LEFT JOIN tree_reservations r ON l.reservation_id = r.id
168
- WHERE
169
- (l.reservation_id IS NULL AND l.status = 'Available')
170
- OR r.purpose = 'Swap'
171
- `);
198
+ LEFT JOIN tree_reservations r
199
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
200
+ WHERE l.user_id = $1
201
+ AND (
202
+ (l.reservation_id IS NULL AND l.status = 'Available')
203
+ OR r.purpose = 'Swap'
204
+ )
205
+ `,
206
+ [this.identity]
207
+ );
172
208
  return BigInt(result.rows[0].balance);
173
209
  } catch (error) {
174
210
  throw new TreeStoreError(
@@ -180,12 +216,17 @@ class PostgresTreeStore {
180
216
 
181
217
  async getLeaves() {
182
218
  try {
183
- const result = await this.pool.query(`
219
+ const result = await this.pool.query(
220
+ `
184
221
  SELECT l.id, l.status, l.is_missing_from_operators, l.data,
185
222
  l.reservation_id, r.purpose
186
223
  FROM tree_leaves l
187
- LEFT JOIN tree_reservations r ON l.reservation_id = r.id
188
- `);
224
+ LEFT JOIN tree_reservations r
225
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
226
+ WHERE l.user_id = $1
227
+ `,
228
+ [this.identity]
229
+ );
189
230
 
190
231
  const available = [];
191
232
  const notAvailable = [];
@@ -247,11 +288,21 @@ class PostgresTreeStore {
247
288
  await this._cleanupStaleReservations(client);
248
289
 
249
290
  // Check for active swap or swap completed during refresh
250
- const swapCheckResult = await client.query(`
291
+ const swapCheckResult = await client.query(
292
+ `
251
293
  SELECT
252
- EXISTS(SELECT 1 FROM tree_reservations WHERE purpose = 'Swap') AS has_active_swap,
253
- COALESCE((SELECT last_completed_at >= $1 FROM tree_swap_status WHERE id = 1), FALSE) AS swap_completed_during_refresh
254
- `, [refreshTimestamp]);
294
+ EXISTS(
295
+ SELECT 1 FROM tree_reservations
296
+ WHERE user_id = $1 AND purpose = 'Swap'
297
+ ) AS has_active_swap,
298
+ COALESCE(
299
+ (SELECT last_completed_at >= $2
300
+ FROM tree_swap_status WHERE user_id = $1),
301
+ FALSE
302
+ ) AS swap_completed_during_refresh
303
+ `,
304
+ [this.identity, refreshTimestamp]
305
+ );
255
306
 
256
307
  const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
257
308
 
@@ -263,18 +314,18 @@ class PostgresTreeStore {
263
314
  await this._cleanupSpentMarkers(client, refreshTimestamp);
264
315
 
265
316
  const spentResult = await client.query(
266
- "SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $1",
267
- [refreshTimestamp]
317
+ "SELECT leaf_id FROM tree_spent_leaves WHERE user_id = $1 AND spent_at >= $2",
318
+ [this.identity, refreshTimestamp]
268
319
  );
269
320
  const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
270
321
 
271
322
  // Delete non-reserved leaves added before refresh started.
272
- // Includes leaves released earlier in this transaction by _cleanupStaleReservations
273
- // (FK ON DELETE SET NULL) — those rows kept their old added_at, so they are
274
- // dropped here and re-fetched from the operator response in the upsert below.
323
+ // Includes leaves released earlier in this transaction by
324
+ // _cleanupStaleReservations (which now NULLs reservation_id explicitly,
325
+ // since the composite FK uses NO ACTION).
275
326
  await client.query(
276
- "DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $1",
277
- [refreshTimestamp]
327
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
328
+ [this.identity, refreshTimestamp]
278
329
  );
279
330
 
280
331
  // Upsert all leaves (filtering spent)
@@ -307,8 +358,8 @@ class PostgresTreeStore {
307
358
  try {
308
359
  await this._withTransaction(async (client) => {
309
360
  const res = await client.query(
310
- "SELECT id FROM tree_reservations WHERE id = $1",
311
- [id]
361
+ "SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
362
+ [this.identity, id]
312
363
  );
313
364
 
314
365
  if (res.rows.length === 0) {
@@ -316,13 +367,13 @@ class PostgresTreeStore {
316
367
  }
317
368
 
318
369
  await client.query(
319
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
320
- [id]
370
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
371
+ [this.identity, id]
321
372
  );
322
373
 
323
374
  await client.query(
324
- "DELETE FROM tree_reservations WHERE id = $1",
325
- [id]
375
+ "DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
376
+ [this.identity, id]
326
377
  );
327
378
 
328
379
  if (leavesToKeep && leavesToKeep.length > 0) {
@@ -345,11 +396,15 @@ class PostgresTreeStore {
345
396
  */
346
397
  async finalizeReservation(id, newLeaves) {
347
398
  try {
348
- await this._withTransaction(async (client) => {
399
+ // _withWriteTransaction acquires the advisory lock so this serializes
400
+ // against `setLeaves`. Without it, a concurrent setLeaves could read
401
+ // tree_spent_leaves before our marker commits and re-insert the
402
+ // just-spent leaf as Available.
403
+ await this._withWriteTransaction(async (client) => {
349
404
  // Check if reservation exists and get purpose
350
405
  const res = await client.query(
351
- "SELECT id, purpose FROM tree_reservations WHERE id = $1",
352
- [id]
406
+ "SELECT id, purpose FROM tree_reservations WHERE user_id = $1 AND id = $2",
407
+ [this.identity, id]
353
408
  );
354
409
 
355
410
  let isSwap = false;
@@ -357,18 +412,18 @@ class PostgresTreeStore {
357
412
  if (res.rows.length > 0) {
358
413
  isSwap = res.rows[0].purpose === "Swap";
359
414
  const leafResult = await client.query(
360
- "SELECT id FROM tree_leaves WHERE reservation_id = $1",
361
- [id]
415
+ "SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
416
+ [this.identity, id]
362
417
  );
363
418
  reservedLeafIds = leafResult.rows.map((r) => r.id);
364
419
  await this._batchInsertSpentLeaves(client, reservedLeafIds);
365
420
  await client.query(
366
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
367
- [id]
421
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
422
+ [this.identity, id]
368
423
  );
369
424
  await client.query(
370
- "DELETE FROM tree_reservations WHERE id = $1",
371
- [id]
425
+ "DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
426
+ [this.identity, id]
372
427
  );
373
428
  }
374
429
 
@@ -377,10 +432,15 @@ class PostgresTreeStore {
377
432
  await this._batchUpsertLeaves(client, newLeaves, false, null);
378
433
  }
379
434
 
380
- // If swap with new leaves, update last_completed_at
435
+ // If swap with new leaves, update last_completed_at. UPSERT so a tenant
436
+ // that joined after migration 3 (and thus has no row) gets one created.
381
437
  if (isSwap && newLeaves && newLeaves.length > 0) {
382
438
  await client.query(
383
- "UPDATE tree_swap_status SET last_completed_at = NOW() WHERE id = 1"
439
+ `INSERT INTO tree_swap_status (user_id, last_completed_at)
440
+ VALUES ($1, NOW())
441
+ ON CONFLICT (user_id) DO UPDATE
442
+ SET last_completed_at = EXCLUDED.last_completed_at`,
443
+ [this.identity]
384
444
  );
385
445
  }
386
446
  });
@@ -409,13 +469,17 @@ class PostgresTreeStore {
409
469
  // True total available, computed server-side over ALL eligible leaves.
410
470
  // Required for the WaitForPending decision below — must NOT be derived
411
471
  // from the prefiltered set since the prefilter may exclude big leaves.
412
- const totalResult = await client.query(`
472
+ const totalResult = await client.query(
473
+ `
413
474
  SELECT COALESCE(SUM((data->>'value')::bigint), 0)::bigint AS total
414
475
  FROM tree_leaves
415
- WHERE status = 'Available'
476
+ WHERE user_id = $1
477
+ AND status = 'Available'
416
478
  AND is_missing_from_operators = FALSE
417
479
  AND reservation_id IS NULL
418
- `);
480
+ `,
481
+ [this.identity]
482
+ );
419
483
  const available = Number(totalResult.rows[0].total);
420
484
 
421
485
  // Slim projection: only (id, value) for leaves the selection might use.
@@ -423,25 +487,30 @@ class PostgresTreeStore {
423
487
  // small-leaf accumulators for the minimum-amount path) plus the single
424
488
  // smallest leaf with value > maxTarget (covers the minimum-amount
425
489
  // fallback case where one larger leaf is sufficient).
426
- const slimResult = await client.query(`
490
+ const slimResult = await client.query(
491
+ `
427
492
  SELECT id, (data->>'value')::bigint AS value
428
493
  FROM tree_leaves
429
- WHERE status = 'Available'
494
+ WHERE user_id = $1
495
+ AND status = 'Available'
430
496
  AND is_missing_from_operators = FALSE
431
497
  AND reservation_id IS NULL
432
498
  AND (
433
- (data->>'value')::bigint <= $1
499
+ (data->>'value')::bigint <= $2
434
500
  OR id = (
435
501
  SELECT id FROM tree_leaves
436
- WHERE status = 'Available'
502
+ WHERE user_id = $1
503
+ AND status = 'Available'
437
504
  AND is_missing_from_operators = FALSE
438
505
  AND reservation_id IS NULL
439
- AND (data->>'value')::bigint > $1
506
+ AND (data->>'value')::bigint > $2
440
507
  ORDER BY (data->>'value')::bigint
441
508
  LIMIT 1
442
509
  )
443
510
  )
444
- `, [maxTarget]);
511
+ `,
512
+ [this.identity, maxTarget]
513
+ );
445
514
 
446
515
  const slimLeaves = slimResult.rows.map((r) => ({
447
516
  id: r.id,
@@ -522,17 +591,13 @@ class PostgresTreeStore {
522
591
  }
523
592
  }
524
593
 
525
- /**
526
- * Largest single value the selection algorithm could possibly need.
527
- * For an unbounded target we have to return all leaves (no prefilter).
528
- */
529
594
  _maxTargetForPrefilter(targetAmounts) {
530
595
  if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
531
596
  if (targetAmounts.type === "amountAndFee") {
532
- return Math.max(targetAmounts.amountSats, targetAmounts.feeSats || 0);
597
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
533
598
  }
534
599
  if (targetAmounts.type === "exactDenominations") {
535
- return targetAmounts.denominations.reduce((m, v) => Math.max(m, v), 0);
600
+ return targetAmounts.denominations.reduce((m, v) => m + v, 0);
536
601
  }
537
602
  return Number.MAX_SAFE_INTEGER;
538
603
  }
@@ -544,8 +609,8 @@ class PostgresTreeStore {
544
609
  async _fetchFullLeavesByIds(client, ids) {
545
610
  if (!ids || ids.length === 0) return [];
546
611
  const result = await client.query(
547
- "SELECT data FROM tree_leaves WHERE id = ANY($1)",
548
- [ids]
612
+ "SELECT data FROM tree_leaves WHERE user_id = $2 AND id = ANY($1)",
613
+ [ids, this.identity]
549
614
  );
550
615
  return result.rows.map((r) => r.data);
551
616
  }
@@ -578,8 +643,8 @@ class PostgresTreeStore {
578
643
  return await this._withTransaction(async (client) => {
579
644
  // Check if reservation exists
580
645
  const res = await client.query(
581
- "SELECT id FROM tree_reservations WHERE id = $1",
582
- [reservationId]
646
+ "SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
647
+ [this.identity, reservationId]
583
648
  );
584
649
 
585
650
  if (res.rows.length === 0) {
@@ -588,15 +653,15 @@ class PostgresTreeStore {
588
653
 
589
654
  // Get old reserved leaf IDs and mark as spent
590
655
  const oldLeavesResult = await client.query(
591
- "SELECT id FROM tree_leaves WHERE reservation_id = $1",
592
- [reservationId]
656
+ "SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
657
+ [this.identity, reservationId]
593
658
  );
594
659
  const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
595
660
 
596
661
  await this._batchInsertSpentLeaves(client, oldLeafIds);
597
662
  await client.query(
598
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
599
- [reservationId]
663
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
664
+ [this.identity, reservationId]
600
665
  );
601
666
 
602
667
  // Upsert change leaves to available pool
@@ -611,8 +676,8 @@ class PostgresTreeStore {
611
676
 
612
677
  // Clear pending change amount
613
678
  await client.query(
614
- "UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $1",
615
- [reservationId]
679
+ "UPDATE tree_reservations SET pending_change_amount = 0 WHERE user_id = $1 AND id = $2",
680
+ [this.identity, reservationId]
616
681
  );
617
682
 
618
683
  return {
@@ -794,7 +859,8 @@ class PostgresTreeStore {
794
859
  */
795
860
  async _calculatePendingBalance(client) {
796
861
  const result = await client.query(
797
- "SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
862
+ "SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations WHERE user_id = $1",
863
+ [this.identity]
798
864
  );
799
865
  return Number(result.rows[0].pending);
800
866
  }
@@ -804,8 +870,8 @@ class PostgresTreeStore {
804
870
  */
805
871
  async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
806
872
  await client.query(
807
- "INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
808
- [reservationId, purpose, pendingChange]
873
+ "INSERT INTO tree_reservations (user_id, id, purpose, pending_change_amount) VALUES ($1, $2, $3, $4)",
874
+ [this.identity, reservationId, purpose, pendingChange]
809
875
  );
810
876
 
811
877
  const leafIds = leaves.map((l) => l.id);
@@ -830,16 +896,16 @@ class PostgresTreeStore {
830
896
  const dataValues = filtered.map((l) => JSON.stringify(l));
831
897
 
832
898
  await client.query(
833
- `INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
834
- SELECT id, status, missing, data::jsonb, NOW()
899
+ `INSERT INTO tree_leaves (user_id, id, status, is_missing_from_operators, data, added_at)
900
+ SELECT $5, id, status, missing, data::jsonb, NOW()
835
901
  FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
836
902
  AS t(id, status, missing, data)
837
- ON CONFLICT (id) DO UPDATE SET
903
+ ON CONFLICT (user_id, id) DO UPDATE SET
838
904
  status = EXCLUDED.status,
839
905
  is_missing_from_operators = EXCLUDED.is_missing_from_operators,
840
906
  data = EXCLUDED.data,
841
907
  added_at = NOW()`,
842
- [ids, statuses, missingFlags, dataValues]
908
+ [ids, statuses, missingFlags, dataValues, this.identity]
843
909
  );
844
910
  }
845
911
 
@@ -850,8 +916,8 @@ class PostgresTreeStore {
850
916
  if (leafIds.length === 0) return;
851
917
 
852
918
  await client.query(
853
- "UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
854
- [reservationId, leafIds]
919
+ "UPDATE tree_leaves SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
920
+ [reservationId, leafIds, this.identity]
855
921
  );
856
922
  }
857
923
 
@@ -862,8 +928,10 @@ class PostgresTreeStore {
862
928
  if (leafIds.length === 0) return;
863
929
 
864
930
  await client.query(
865
- "INSERT INTO tree_spent_leaves (leaf_id) SELECT * FROM UNNEST($1::text[]) ON CONFLICT DO NOTHING",
866
- [leafIds]
931
+ `INSERT INTO tree_spent_leaves (user_id, leaf_id)
932
+ SELECT $2, leaf_id FROM UNNEST($1::text[]) AS t(leaf_id)
933
+ ON CONFLICT DO NOTHING`,
934
+ [leafIds, this.identity]
867
935
  );
868
936
  }
869
937
 
@@ -874,19 +942,33 @@ class PostgresTreeStore {
874
942
  if (leafIds.length === 0) return;
875
943
 
876
944
  await client.query(
877
- "DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
878
- [leafIds]
945
+ "DELETE FROM tree_spent_leaves WHERE user_id = $2 AND leaf_id = ANY($1)",
946
+ [leafIds, this.identity]
879
947
  );
880
948
  }
881
949
 
882
950
  /**
883
- * Clean up stale reservations.
951
+ * Clean up stale reservations. Releases the leaves by clearing their
952
+ * reservation_id first, then deletes the parent reservations — the composite
953
+ * FK uses NO ACTION (the default) since column-list SET NULL is PG15+ and a
954
+ * whole-row SET NULL would null user_id (NOT NULL).
884
955
  */
885
956
  async _cleanupStaleReservations(client) {
957
+ await client.query(
958
+ `UPDATE tree_leaves SET reservation_id = NULL
959
+ WHERE user_id = $2
960
+ AND reservation_id IN (
961
+ SELECT id FROM tree_reservations
962
+ WHERE user_id = $2
963
+ AND created_at < NOW() - make_interval(secs => $1)
964
+ )`,
965
+ [RESERVATION_TIMEOUT_SECS, this.identity]
966
+ );
886
967
  await client.query(
887
968
  `DELETE FROM tree_reservations
888
- WHERE created_at < NOW() - make_interval(secs => $1)`,
889
- [RESERVATION_TIMEOUT_SECS]
969
+ WHERE user_id = $2
970
+ AND created_at < NOW() - make_interval(secs => $1)`,
971
+ [RESERVATION_TIMEOUT_SECS, this.identity]
890
972
  );
891
973
  }
892
974
 
@@ -898,8 +980,8 @@ class PostgresTreeStore {
898
980
  const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
899
981
 
900
982
  await client.query(
901
- "DELETE FROM tree_spent_leaves WHERE spent_at < $1",
902
- [cleanupCutoff]
983
+ "DELETE FROM tree_spent_leaves WHERE user_id = $2 AND spent_at < $1",
984
+ [cleanupCutoff, this.identity]
903
985
  );
904
986
  }
905
987
  }
@@ -912,28 +994,30 @@ class PostgresTreeStore {
912
994
  * @param {number} config.maxPoolSize - Maximum number of connections in the pool
913
995
  * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
914
996
  * @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
997
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
915
998
  * @param {object} [logger] - Optional logger
916
999
  * @returns {Promise<PostgresTreeStore>}
917
1000
  */
918
- async function createPostgresTreeStore(config, logger = null) {
1001
+ async function createPostgresTreeStore(config, identity, logger = null) {
919
1002
  const pool = new pg.Pool({
920
1003
  connectionString: config.connectionString,
921
1004
  max: config.maxPoolSize,
922
1005
  connectionTimeoutMillis: config.createTimeoutSecs * 1000,
923
1006
  idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
924
1007
  });
925
- return createPostgresTreeStoreWithPool(pool, logger);
1008
+ return createPostgresTreeStoreWithPool(pool, identity, logger);
926
1009
  }
927
1010
 
928
1011
  /**
929
1012
  * Create a PostgresTreeStore instance from an existing pg.Pool.
930
1013
  *
931
1014
  * @param {pg.Pool} pool - An existing connection pool
1015
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
932
1016
  * @param {object} [logger] - Optional logger
933
1017
  * @returns {Promise<PostgresTreeStore>}
934
1018
  */
935
- async function createPostgresTreeStoreWithPool(pool, logger = null) {
936
- const store = new PostgresTreeStore(pool, logger);
1019
+ async function createPostgresTreeStoreWithPool(pool, identity, logger = null) {
1020
+ const store = new PostgresTreeStore(pool, identity, logger);
937
1021
  await store.initialize();
938
1022
  return store;
939
1023
  }