@breeztech/breez-sdk-spark 0.13.10-dev → 0.13.12-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 (49) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +157 -0
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +419 -41
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  7. package/deno/breez_sdk_spark_wasm.d.ts +157 -0
  8. package/deno/breez_sdk_spark_wasm.js +419 -41
  9. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  10. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  11. package/nodejs/breez_sdk_spark_wasm.d.ts +157 -0
  12. package/nodejs/breez_sdk_spark_wasm.js +428 -41
  13. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  15. package/nodejs/index.js +56 -0
  16. package/nodejs/index.mjs +9 -0
  17. package/nodejs/mysql-session-manager/errors.cjs +13 -0
  18. package/nodejs/mysql-session-manager/index.cjs +144 -0
  19. package/nodejs/mysql-session-manager/migrations.cjs +102 -0
  20. package/nodejs/mysql-session-manager/package.json +9 -0
  21. package/nodejs/mysql-storage/errors.cjs +19 -0
  22. package/nodejs/mysql-storage/index.cjs +1367 -0
  23. package/nodejs/mysql-storage/migrations.cjs +387 -0
  24. package/nodejs/mysql-storage/package.json +9 -0
  25. package/nodejs/mysql-token-store/errors.cjs +9 -0
  26. package/nodejs/mysql-token-store/index.cjs +980 -0
  27. package/nodejs/mysql-token-store/migrations.cjs +255 -0
  28. package/nodejs/mysql-token-store/package.json +9 -0
  29. package/nodejs/mysql-tree-store/errors.cjs +9 -0
  30. package/nodejs/mysql-tree-store/index.cjs +941 -0
  31. package/nodejs/mysql-tree-store/migrations.cjs +221 -0
  32. package/nodejs/mysql-tree-store/package.json +9 -0
  33. package/nodejs/package.json +5 -0
  34. package/nodejs/postgres-session-manager/errors.cjs +13 -0
  35. package/nodejs/postgres-session-manager/index.cjs +165 -0
  36. package/nodejs/postgres-session-manager/migrations.cjs +126 -0
  37. package/nodejs/postgres-session-manager/package.json +9 -0
  38. package/nodejs/postgres-storage/index.cjs +147 -92
  39. package/nodejs/postgres-storage/migrations.cjs +85 -4
  40. package/nodejs/postgres-token-store/index.cjs +178 -102
  41. package/nodejs/postgres-token-store/migrations.cjs +92 -3
  42. package/nodejs/postgres-tree-store/index.cjs +168 -83
  43. package/nodejs/postgres-tree-store/migrations.cjs +80 -3
  44. package/package.json +1 -1
  45. package/ssr/index.js +53 -0
  46. package/web/breez_sdk_spark_wasm.d.ts +184 -7
  47. package/web/breez_sdk_spark_wasm.js +419 -41
  48. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  49. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
@@ -25,10 +25,10 @@ const { TokenStoreError } = require("./errors.cjs");
25
25
  const { TokenStoreMigrationManager } = require("./migrations.cjs");
26
26
 
27
27
  /**
28
- * Advisory lock key for serializing token store write operations.
29
- * Matches the Rust constant TOKEN_STORE_WRITE_LOCK_KEY = 0x746F_6B65_6E53_5452
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 TOKEN_STORE_WRITE_LOCK_KEY = "8390042714201347154"; // 0x746F6B656E535452 as decimal string
31
+ const TOKEN_STORE_LOCK_PREFIX = "breez-spark-sdk:token:";
32
32
 
33
33
  /**
34
34
  * Spent markers are kept for this duration to support multiple SDK instances.
@@ -42,9 +42,37 @@ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
42
42
  */
43
43
  const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
44
44
 
45
+ /**
46
+ * Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
47
+ * prefix together with the identity pubkey and folding the first 8 bytes of
48
+ * the SHA-256 digest into a signed big-endian i64 — the type expected by
49
+ * `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
50
+ * collisions negligible (~1.2e-10 at 65k tenants).
51
+ */
52
+ function _identityLockKey(prefix, identity) {
53
+ const crypto = require("crypto");
54
+ const hash = crypto.createHash("sha256");
55
+ hash.update(prefix);
56
+ hash.update(Buffer.from(identity));
57
+ return hash.digest().readBigInt64BE(0);
58
+ }
59
+
45
60
  class PostgresTokenStore {
46
- constructor(pool, logger = null) {
61
+ /**
62
+ * @param {import('pg').Pool} pool
63
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
64
+ * identifying the tenant. All reads and writes are scoped by this.
65
+ * @param {object} [logger]
66
+ */
67
+ constructor(pool, identity, logger = null) {
68
+ if (!identity || identity.length !== 33) {
69
+ throw new TokenStoreError(
70
+ "tenant identity (33-byte secp256k1 pubkey) is required"
71
+ );
72
+ }
47
73
  this.pool = pool;
74
+ this.identity = Buffer.from(identity);
75
+ this.lockKey = _identityLockKey(TOKEN_STORE_LOCK_PREFIX, identity);
48
76
  this.logger = logger;
49
77
  }
50
78
 
@@ -54,7 +82,7 @@ class PostgresTokenStore {
54
82
  async initialize() {
55
83
  try {
56
84
  const migrationManager = new TokenStoreMigrationManager(this.logger);
57
- await migrationManager.migrate(this.pool);
85
+ await migrationManager.migrate(this.pool, this.identity);
58
86
  return this;
59
87
  } catch (error) {
60
88
  throw new TokenStoreError(
@@ -86,7 +114,10 @@ class PostgresTokenStore {
86
114
  const client = await this.pool.connect();
87
115
  try {
88
116
  await client.query("BEGIN");
89
- await client.query(`SELECT pg_advisory_xact_lock(${TOKEN_STORE_WRITE_LOCK_KEY})`);
117
+ // Per-tenant advisory lock: 64-bit key derived from a token-store domain
118
+ // prefix and the tenant identity, so different tenants don't serialize
119
+ // on each other and tree/token locks never collide.
120
+ await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
90
121
  const result = await fn(client);
91
122
  await client.query("COMMIT");
92
123
  return result;
@@ -144,9 +175,16 @@ class PostgresTokenStore {
144
175
  // Skip if swap is active or completed during this refresh
145
176
  const swapCheckResult = await client.query(
146
177
  `SELECT
147
- EXISTS(SELECT 1 FROM token_reservations WHERE purpose = 'Swap') AS has_active_swap,
148
- COALESCE((SELECT last_completed_at >= $1 FROM token_swap_status WHERE id = 1), FALSE) AS swap_completed`,
149
- [refreshTimestamp]
178
+ EXISTS(
179
+ SELECT 1 FROM token_reservations
180
+ WHERE user_id = $1 AND purpose = 'Swap'
181
+ ) AS has_active_swap,
182
+ COALESCE(
183
+ (SELECT last_completed_at >= $2
184
+ FROM token_swap_status WHERE user_id = $1),
185
+ FALSE
186
+ ) AS swap_completed`,
187
+ [this.identity, refreshTimestamp]
150
188
  );
151
189
  const { has_active_swap, swap_completed } = swapCheckResult.rows[0];
152
190
  if (has_active_swap || swap_completed) {
@@ -156,21 +194,21 @@ class PostgresTokenStore {
156
194
  // Clean up old spent markers
157
195
  const cleanupCutoff = new Date(refreshTimestamp.getTime() - SPENT_MARKER_CLEANUP_THRESHOLD_MS);
158
196
  await client.query(
159
- "DELETE FROM token_spent_outputs WHERE spent_at < $1",
160
- [cleanupCutoff]
197
+ "DELETE FROM token_spent_outputs WHERE user_id = $1 AND spent_at < $2",
198
+ [this.identity, cleanupCutoff]
161
199
  );
162
200
 
163
201
  // Get recent spent output IDs (spent_at >= refresh_timestamp)
164
202
  const spentResult = await client.query(
165
- "SELECT output_id FROM token_spent_outputs WHERE spent_at >= $1",
166
- [refreshTimestamp]
203
+ "SELECT output_id FROM token_spent_outputs WHERE user_id = $1 AND spent_at >= $2",
204
+ [this.identity, refreshTimestamp]
167
205
  );
168
206
  const spentIds = new Set(spentResult.rows.map((r) => r.output_id));
169
207
 
170
208
  // Delete non-reserved outputs added BEFORE the refresh started
171
209
  await client.query(
172
- "DELETE FROM token_outputs WHERE reservation_id IS NULL AND added_at < $1",
173
- [refreshTimestamp]
210
+ "DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
211
+ [this.identity, refreshTimestamp]
174
212
  );
175
213
 
176
214
  // Build a set of all incoming output IDs for reconciliation
@@ -185,7 +223,10 @@ class PostgresTokenStore {
185
223
  const reservedRows = await client.query(
186
224
  `SELECT r.id, o.id AS output_id
187
225
  FROM token_reservations r
188
- JOIN token_outputs o ON o.reservation_id = r.id`
226
+ JOIN token_outputs o
227
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
228
+ WHERE r.user_id = $1`,
229
+ [this.identity]
189
230
  );
190
231
 
191
232
  // Group reserved outputs by reservation ID
@@ -216,51 +257,57 @@ class PostgresTokenStore {
216
257
  // Delete outputs whose reservations are being removed entirely
217
258
  if (reservationsToDelete.length > 0) {
218
259
  await client.query(
219
- "DELETE FROM token_outputs WHERE reservation_id = ANY($1)",
220
- [reservationsToDelete]
260
+ "DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = ANY($2)",
261
+ [this.identity, reservationsToDelete]
221
262
  );
222
263
  await client.query(
223
- "DELETE FROM token_reservations WHERE id = ANY($1)",
224
- [reservationsToDelete]
264
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = ANY($2)",
265
+ [this.identity, reservationsToDelete]
225
266
  );
226
267
  }
227
268
 
228
269
  // Delete individual reserved outputs that no longer exist
229
270
  if (outputsToRemoveFromReservation.length > 0) {
230
271
  await client.query(
231
- "DELETE FROM token_outputs WHERE id = ANY($1)",
232
- [outputsToRemoveFromReservation]
272
+ "DELETE FROM token_outputs WHERE user_id = $1 AND id = ANY($2)",
273
+ [this.identity, outputsToRemoveFromReservation]
233
274
  );
234
275
 
235
276
  // Check if any reservations are now empty
236
277
  const emptyReservations = await client.query(
237
278
  `SELECT r.id FROM token_reservations r
238
- LEFT JOIN token_outputs o ON o.reservation_id = r.id
239
- WHERE o.id IS NULL`
279
+ LEFT JOIN token_outputs o
280
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
281
+ WHERE r.user_id = $1 AND o.id IS NULL`,
282
+ [this.identity]
240
283
  );
241
284
  const emptyIds = emptyReservations.rows.map((r) => r.id);
242
285
  if (emptyIds.length > 0) {
243
286
  await client.query(
244
- "DELETE FROM token_reservations WHERE id = ANY($1)",
245
- [emptyIds]
287
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = ANY($2)",
288
+ [this.identity, emptyIds]
246
289
  );
247
290
  }
248
291
  }
249
292
 
250
293
  // Collect IDs of currently reserved outputs (that survived reconciliation)
251
294
  const reservedOutputIdsResult = await client.query(
252
- "SELECT id FROM token_outputs WHERE reservation_id IS NOT NULL"
295
+ "SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id IS NOT NULL",
296
+ [this.identity]
253
297
  );
254
298
  const reservedOutputIds = new Set(
255
299
  reservedOutputIdsResult.rows.map((r) => r.id)
256
300
  );
257
301
 
258
- // Delete orphan metadata
302
+ // Delete orphan metadata (per-tenant)
259
303
  await client.query(
260
304
  `DELETE FROM token_metadata
261
- WHERE identifier NOT IN (
262
- SELECT DISTINCT token_identifier FROM token_outputs
263
- )`
305
+ WHERE user_id = $1
306
+ AND identifier NOT IN (
307
+ SELECT DISTINCT token_identifier
308
+ FROM token_outputs WHERE user_id = $1
309
+ )`,
310
+ [this.identity]
264
311
  );
265
312
 
266
313
  // Insert new metadata and outputs, excluding spent and reserved
@@ -301,7 +348,8 @@ class PostgresTokenStore {
301
348
  */
302
349
  async getTokenBalances() {
303
350
  try {
304
- const result = await this.pool.query(`
351
+ const result = await this.pool.query(
352
+ `
305
353
  SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
306
354
  m.max_supply, m.is_freezable, m.creation_entity_public_key,
307
355
  COALESCE(SUM(
@@ -312,11 +360,16 @@ class PostgresTokenStore {
312
360
  END
313
361
  ), 0)::text AS balance
314
362
  FROM token_metadata m
315
- JOIN token_outputs o ON o.token_identifier = m.identifier
316
- LEFT JOIN token_reservations r ON o.reservation_id = r.id
363
+ JOIN token_outputs o
364
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
365
+ LEFT JOIN token_reservations r
366
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
367
+ WHERE m.user_id = $1
317
368
  GROUP BY m.identifier, m.issuer_public_key, m.name, m.ticker,
318
369
  m.decimals, m.max_supply, m.is_freezable, m.creation_entity_public_key
319
- `);
370
+ `,
371
+ [this.identity]
372
+ );
320
373
  return result.rows.map((row) => ({
321
374
  metadata: {
322
375
  identifier: row.identifier,
@@ -349,9 +402,13 @@ class PostgresTokenStore {
349
402
  o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
350
403
  r.purpose
351
404
  FROM token_metadata m
352
- LEFT JOIN token_outputs o ON o.token_identifier = m.identifier
353
- LEFT JOIN token_reservations r ON o.reservation_id = r.id
354
- ORDER BY m.identifier, o.token_amount::NUMERIC ASC`
405
+ LEFT JOIN token_outputs o
406
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
407
+ LEFT JOIN token_reservations r
408
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
409
+ WHERE m.user_id = $1
410
+ ORDER BY m.identifier, o.token_amount::NUMERIC ASC`,
411
+ [this.identity]
355
412
  );
356
413
 
357
414
  const map = new Map();
@@ -422,11 +479,13 @@ class PostgresTokenStore {
422
479
  o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
423
480
  r.purpose
424
481
  FROM token_metadata m
425
- LEFT JOIN token_outputs o ON o.token_identifier = m.identifier
426
- LEFT JOIN token_reservations r ON o.reservation_id = r.id
427
- WHERE ${whereClause}
482
+ LEFT JOIN token_outputs o
483
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
484
+ LEFT JOIN token_reservations r
485
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
486
+ WHERE m.user_id = $2 AND ${whereClause}
428
487
  ORDER BY o.token_amount::NUMERIC ASC`,
429
- [param]
488
+ [param, this.identity]
430
489
  );
431
490
 
432
491
  if (result.rows.length === 0) {
@@ -473,18 +532,14 @@ class PostgresTokenStore {
473
532
  */
474
533
  async insertTokenOutputs(tokenOutputs) {
475
534
  try {
476
- const client = await this.pool.connect();
477
- try {
478
- await client.query("BEGIN");
479
-
535
+ await this._withTransaction(async (client) => {
480
536
  await this._upsertMetadata(client, tokenOutputs.metadata);
481
537
 
482
- // Remove inserted output IDs from spent markers (output returned to us)
483
538
  const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
484
539
  if (outputIds.length > 0) {
485
540
  await client.query(
486
- "DELETE FROM token_spent_outputs WHERE output_id = ANY($1)",
487
- [outputIds]
541
+ "DELETE FROM token_spent_outputs WHERE user_id = $1 AND output_id = ANY($2)",
542
+ [this.identity, outputIds]
488
543
  );
489
544
  }
490
545
 
@@ -495,14 +550,7 @@ class PostgresTokenStore {
495
550
  output
496
551
  );
497
552
  }
498
-
499
- await client.query("COMMIT");
500
- } catch (error) {
501
- await client.query("ROLLBACK").catch(() => {});
502
- throw error;
503
- } finally {
504
- client.release();
505
- }
553
+ });
506
554
  } catch (error) {
507
555
  if (error instanceof TokenStoreError) throw error;
508
556
  throw new TokenStoreError(
@@ -544,8 +592,8 @@ class PostgresTokenStore {
544
592
 
545
593
  // Get metadata
546
594
  const metadataResult = await client.query(
547
- "SELECT * FROM token_metadata WHERE identifier = $1",
548
- [tokenIdentifier]
595
+ "SELECT * FROM token_metadata WHERE user_id = $1 AND identifier = $2",
596
+ [this.identity, tokenIdentifier]
549
597
  );
550
598
 
551
599
  if (metadataResult.rows.length === 0) {
@@ -563,8 +611,10 @@ class PostgresTokenStore {
563
611
  o.token_public_key, o.token_amount, o.token_identifier,
564
612
  o.prev_tx_hash, o.prev_tx_vout
565
613
  FROM token_outputs o
566
- WHERE o.token_identifier = $1 AND o.reservation_id IS NULL`,
567
- [tokenIdentifier]
614
+ WHERE o.user_id = $1
615
+ AND o.token_identifier = $2
616
+ AND o.reservation_id IS NULL`,
617
+ [this.identity, tokenIdentifier]
568
618
  );
569
619
 
570
620
  let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
@@ -650,16 +700,16 @@ class PostgresTokenStore {
650
700
  const reservationId = this._generateId();
651
701
 
652
702
  await client.query(
653
- "INSERT INTO token_reservations (id, purpose) VALUES ($1, $2)",
654
- [reservationId, purpose]
703
+ "INSERT INTO token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
704
+ [this.identity, reservationId, purpose]
655
705
  );
656
706
 
657
707
  // Set reservation_id on selected outputs
658
708
  const selectedIds = selectedOutputs.map((o) => o.output.id);
659
709
  if (selectedIds.length > 0) {
660
710
  await client.query(
661
- "UPDATE token_outputs SET reservation_id = $1 WHERE id = ANY($2)",
662
- [reservationId, selectedIds]
711
+ "UPDATE token_outputs SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
712
+ [reservationId, selectedIds, this.identity]
663
713
  );
664
714
  }
665
715
 
@@ -687,16 +737,18 @@ class PostgresTokenStore {
687
737
  async cancelReservation(id) {
688
738
  try {
689
739
  await this._withTransaction(async (client) => {
690
- // Clear reservation_id from outputs
740
+ // Clear reservation_id from outputs first — the composite FK uses NO
741
+ // ACTION (column-list SET NULL is PG15+ and a whole-row SET NULL would
742
+ // null user_id, which is NOT NULL).
691
743
  await client.query(
692
- "UPDATE token_outputs SET reservation_id = NULL WHERE reservation_id = $1",
693
- [id]
744
+ "UPDATE token_outputs SET reservation_id = NULL WHERE user_id = $1 AND reservation_id = $2",
745
+ [this.identity, id]
694
746
  );
695
747
 
696
748
  // Delete the reservation
697
749
  await client.query(
698
- "DELETE FROM token_reservations WHERE id = $1",
699
- [id]
750
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
751
+ [this.identity, id]
700
752
  );
701
753
  });
702
754
  } catch (error) {
@@ -721,8 +773,8 @@ class PostgresTokenStore {
721
773
  await this._withWriteTransaction(async (client) => {
722
774
  // Get reservation purpose
723
775
  const reservationResult = await client.query(
724
- "SELECT purpose FROM token_reservations WHERE id = $1",
725
- [id]
776
+ "SELECT purpose FROM token_reservations WHERE user_id = $1 AND id = $2",
777
+ [this.identity, id]
726
778
  );
727
779
  if (reservationResult.rows.length === 0) {
728
780
  return; // Non-existing reservation
@@ -731,45 +783,53 @@ class PostgresTokenStore {
731
783
 
732
784
  // Get reserved output IDs and mark them as spent
733
785
  const reservedOutputsResult = await client.query(
734
- "SELECT id FROM token_outputs WHERE reservation_id = $1",
735
- [id]
786
+ "SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
787
+ [this.identity, id]
736
788
  );
737
789
  const reservedOutputIds = reservedOutputsResult.rows.map((r) => r.id);
738
790
 
739
791
  if (reservedOutputIds.length > 0) {
740
792
  await client.query(
741
- `INSERT INTO token_spent_outputs (output_id)
742
- SELECT * FROM UNNEST($1::text[])
793
+ `INSERT INTO token_spent_outputs (user_id, output_id)
794
+ SELECT $2, output_id FROM UNNEST($1::text[]) AS t(output_id)
743
795
  ON CONFLICT DO NOTHING`,
744
- [reservedOutputIds]
796
+ [reservedOutputIds, this.identity]
745
797
  );
746
798
  }
747
799
 
748
800
  // Delete reserved outputs
749
801
  await client.query(
750
- "DELETE FROM token_outputs WHERE reservation_id = $1",
751
- [id]
802
+ "DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
803
+ [this.identity, id]
752
804
  );
753
805
 
754
806
  // Delete the reservation
755
807
  await client.query(
756
- "DELETE FROM token_reservations WHERE id = $1",
757
- [id]
808
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
809
+ [this.identity, id]
758
810
  );
759
811
 
760
- // If this was a swap reservation, update last_completed_at
812
+ // If this was a swap reservation, update last_completed_at. UPSERT so a
813
+ // tenant that joined after migration 2 (and thus has no row) gets one.
761
814
  if (isSwap) {
762
815
  await client.query(
763
- "UPDATE token_swap_status SET last_completed_at = NOW() WHERE id = 1"
816
+ `INSERT INTO token_swap_status (user_id, last_completed_at)
817
+ VALUES ($1, NOW())
818
+ ON CONFLICT (user_id) DO UPDATE
819
+ SET last_completed_at = EXCLUDED.last_completed_at`,
820
+ [this.identity]
764
821
  );
765
822
  }
766
823
 
767
- // Clean up orphaned metadata
824
+ // Clean up orphaned metadata (per-tenant)
768
825
  await client.query(
769
826
  `DELETE FROM token_metadata
770
- WHERE identifier NOT IN (
771
- SELECT DISTINCT token_identifier FROM token_outputs
772
- )`
827
+ WHERE user_id = $1
828
+ AND identifier NOT IN (
829
+ SELECT DISTINCT token_identifier
830
+ FROM token_outputs WHERE user_id = $1
831
+ )`,
832
+ [this.identity]
773
833
  );
774
834
  });
775
835
  } catch (error) {
@@ -815,15 +875,27 @@ class PostgresTokenStore {
815
875
  }
816
876
 
817
877
  /**
818
- * Delete reservations that have exceeded the timeout.
819
- * Called during setTokensOutputs to clean up stale reservations from crashed clients.
820
- * The ON DELETE SET NULL foreign key constraint automatically releases the outputs.
878
+ * Delete reservations that have exceeded the timeout. Releases outputs by
879
+ * clearing reservation_id explicitly, then deletes the parents the
880
+ * composite FK uses NO ACTION (column-list SET NULL is PG15+ and a
881
+ * whole-row SET NULL would null user_id, NOT NULL).
821
882
  */
822
883
  async _cleanupStaleReservations(client) {
884
+ await client.query(
885
+ `UPDATE token_outputs SET reservation_id = NULL
886
+ WHERE user_id = $2
887
+ AND reservation_id IN (
888
+ SELECT id FROM token_reservations
889
+ WHERE user_id = $2
890
+ AND created_at < NOW() - make_interval(secs => $1)
891
+ )`,
892
+ [RESERVATION_TIMEOUT_SECS, this.identity]
893
+ );
823
894
  await client.query(
824
895
  `DELETE FROM token_reservations
825
- WHERE created_at < NOW() - make_interval(secs => $1)`,
826
- [RESERVATION_TIMEOUT_SECS]
896
+ WHERE user_id = $2
897
+ AND created_at < NOW() - make_interval(secs => $1)`,
898
+ [RESERVATION_TIMEOUT_SECS, this.identity]
827
899
  );
828
900
  }
829
901
 
@@ -833,10 +905,10 @@ class PostgresTokenStore {
833
905
  async _upsertMetadata(client, metadata) {
834
906
  await client.query(
835
907
  `INSERT INTO token_metadata
836
- (identifier, issuer_public_key, name, ticker, decimals, max_supply,
908
+ (user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
837
909
  is_freezable, creation_entity_public_key)
838
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
839
- ON CONFLICT (identifier) DO UPDATE SET
910
+ VALUES ($9, $1, $2, $3, $4, $5, $6, $7, $8)
911
+ ON CONFLICT (user_id, identifier) DO UPDATE SET
840
912
  issuer_public_key = EXCLUDED.issuer_public_key,
841
913
  name = EXCLUDED.name,
842
914
  ticker = EXCLUDED.ticker,
@@ -853,6 +925,7 @@ class PostgresTokenStore {
853
925
  metadata.maxSupply,
854
926
  metadata.isFreezable,
855
927
  metadata.creationEntityPublicKey || null,
928
+ this.identity,
856
929
  ]
857
930
  );
858
931
  }
@@ -863,11 +936,11 @@ class PostgresTokenStore {
863
936
  async _insertSingleOutput(client, tokenIdentifier, output) {
864
937
  await client.query(
865
938
  `INSERT INTO token_outputs
866
- (id, token_identifier, owner_public_key, revocation_commitment,
939
+ (user_id, id, token_identifier, owner_public_key, revocation_commitment,
867
940
  withdraw_bond_sats, withdraw_relative_block_locktime,
868
941
  token_public_key, token_amount, prev_tx_hash, prev_tx_vout, added_at)
869
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
870
- ON CONFLICT (id) DO NOTHING`,
942
+ VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
943
+ ON CONFLICT (user_id, id) DO NOTHING`,
871
944
  [
872
945
  output.output.id,
873
946
  tokenIdentifier,
@@ -879,6 +952,7 @@ class PostgresTokenStore {
879
952
  output.output.tokenAmount,
880
953
  output.prevTxHash,
881
954
  output.prevTxVout,
955
+ this.identity,
882
956
  ]
883
957
  );
884
958
  }
@@ -930,28 +1004,30 @@ class PostgresTokenStore {
930
1004
  * @param {number} config.maxPoolSize - Maximum number of connections in the pool
931
1005
  * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
932
1006
  * @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
1007
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
933
1008
  * @param {object} [logger] - Optional logger
934
1009
  * @returns {Promise<PostgresTokenStore>}
935
1010
  */
936
- async function createPostgresTokenStore(config, logger = null) {
1011
+ async function createPostgresTokenStore(config, identity, logger = null) {
937
1012
  const pool = new pg.Pool({
938
1013
  connectionString: config.connectionString,
939
1014
  max: config.maxPoolSize,
940
1015
  connectionTimeoutMillis: config.createTimeoutSecs * 1000,
941
1016
  idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
942
1017
  });
943
- return createPostgresTokenStoreWithPool(pool, logger);
1018
+ return createPostgresTokenStoreWithPool(pool, identity, logger);
944
1019
  }
945
1020
 
946
1021
  /**
947
1022
  * Create a PostgresTokenStore instance from an existing pg.Pool.
948
1023
  *
949
1024
  * @param {pg.Pool} pool - An existing connection pool
1025
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
950
1026
  * @param {object} [logger] - Optional logger
951
1027
  * @returns {Promise<PostgresTokenStore>}
952
1028
  */
953
- async function createPostgresTokenStoreWithPool(pool, logger = null) {
954
- const store = new PostgresTokenStore(pool, logger);
1029
+ async function createPostgresTokenStoreWithPool(pool, identity, logger = null) {
1030
+ const store = new PostgresTokenStore(pool, identity, logger);
955
1031
  await store.initialize();
956
1032
  return store;
957
1033
  }