@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
@@ -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;
@@ -100,9 +131,9 @@ class PostgresTokenStore {
100
131
 
101
132
  /**
102
133
  * Run a function inside a transaction without the advisory lock. Used by
103
- * operations scoped to a single reservation_id (`cancelReservation`,
104
- * `finalizeReservation`) where MVCC + row-level locks suffice and the global
105
- * lock would only add contention.
134
+ * operations scoped to a single reservation_id (`cancelReservation`)
135
+ * where MVCC + row-level locks suffice and the global lock would only add
136
+ * contention.
106
137
  * @param {function(import('pg').PoolClient): Promise<T>} fn
107
138
  * @returns {Promise<T>}
108
139
  * @template T
@@ -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
@@ -295,12 +342,14 @@ class PostgresTokenStore {
295
342
  /**
296
343
  * Returns the spendable per-token balances aggregated server-side.
297
344
  * Each entry includes full token metadata + the available + swap-reserved sum.
298
- * Tokens with zero spendable balance are filtered out by the HAVING clause.
345
+ * Matches the in-memory default impl which returns all tokens that have
346
+ * at least one output (including zero spendable balance).
299
347
  * @returns {Promise<Array<{metadata: object, balance: string}>>}
300
348
  */
301
349
  async getTokenBalances() {
302
350
  try {
303
- const result = await this.pool.query(`
351
+ const result = await this.pool.query(
352
+ `
304
353
  SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
305
354
  m.max_supply, m.is_freezable, m.creation_entity_public_key,
306
355
  COALESCE(SUM(
@@ -311,18 +360,16 @@ class PostgresTokenStore {
311
360
  END
312
361
  ), 0)::text AS balance
313
362
  FROM token_metadata m
314
- JOIN token_outputs o ON o.token_identifier = m.identifier
315
- 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
316
368
  GROUP BY m.identifier, m.issuer_public_key, m.name, m.ticker,
317
369
  m.decimals, m.max_supply, m.is_freezable, m.creation_entity_public_key
318
- HAVING COALESCE(SUM(
319
- CASE
320
- WHEN o.reservation_id IS NULL THEN o.token_amount::numeric
321
- WHEN r.purpose = 'Swap' THEN o.token_amount::numeric
322
- ELSE 0
323
- END
324
- ), 0) > 0
325
- `);
370
+ `,
371
+ [this.identity]
372
+ );
326
373
  return result.rows.map((row) => ({
327
374
  metadata: {
328
375
  identifier: row.identifier,
@@ -355,9 +402,13 @@ class PostgresTokenStore {
355
402
  o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
356
403
  r.purpose
357
404
  FROM token_metadata m
358
- LEFT JOIN token_outputs o ON o.token_identifier = m.identifier
359
- LEFT JOIN token_reservations r ON o.reservation_id = r.id
360
- 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]
361
412
  );
362
413
 
363
414
  const map = new Map();
@@ -428,11 +479,13 @@ class PostgresTokenStore {
428
479
  o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
429
480
  r.purpose
430
481
  FROM token_metadata m
431
- LEFT JOIN token_outputs o ON o.token_identifier = m.identifier
432
- LEFT JOIN token_reservations r ON o.reservation_id = r.id
433
- 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}
434
487
  ORDER BY o.token_amount::NUMERIC ASC`,
435
- [param]
488
+ [param, this.identity]
436
489
  );
437
490
 
438
491
  if (result.rows.length === 0) {
@@ -489,8 +542,8 @@ class PostgresTokenStore {
489
542
  const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
490
543
  if (outputIds.length > 0) {
491
544
  await client.query(
492
- "DELETE FROM token_spent_outputs WHERE output_id = ANY($1)",
493
- [outputIds]
545
+ "DELETE FROM token_spent_outputs WHERE user_id = $1 AND output_id = ANY($2)",
546
+ [this.identity, outputIds]
494
547
  );
495
548
  }
496
549
 
@@ -550,8 +603,8 @@ class PostgresTokenStore {
550
603
 
551
604
  // Get metadata
552
605
  const metadataResult = await client.query(
553
- "SELECT * FROM token_metadata WHERE identifier = $1",
554
- [tokenIdentifier]
606
+ "SELECT * FROM token_metadata WHERE user_id = $1 AND identifier = $2",
607
+ [this.identity, tokenIdentifier]
555
608
  );
556
609
 
557
610
  if (metadataResult.rows.length === 0) {
@@ -569,8 +622,10 @@ class PostgresTokenStore {
569
622
  o.token_public_key, o.token_amount, o.token_identifier,
570
623
  o.prev_tx_hash, o.prev_tx_vout
571
624
  FROM token_outputs o
572
- WHERE o.token_identifier = $1 AND o.reservation_id IS NULL`,
573
- [tokenIdentifier]
625
+ WHERE o.user_id = $1
626
+ AND o.token_identifier = $2
627
+ AND o.reservation_id IS NULL`,
628
+ [this.identity, tokenIdentifier]
574
629
  );
575
630
 
576
631
  let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
@@ -656,16 +711,16 @@ class PostgresTokenStore {
656
711
  const reservationId = this._generateId();
657
712
 
658
713
  await client.query(
659
- "INSERT INTO token_reservations (id, purpose) VALUES ($1, $2)",
660
- [reservationId, purpose]
714
+ "INSERT INTO token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
715
+ [this.identity, reservationId, purpose]
661
716
  );
662
717
 
663
718
  // Set reservation_id on selected outputs
664
719
  const selectedIds = selectedOutputs.map((o) => o.output.id);
665
720
  if (selectedIds.length > 0) {
666
721
  await client.query(
667
- "UPDATE token_outputs SET reservation_id = $1 WHERE id = ANY($2)",
668
- [reservationId, selectedIds]
722
+ "UPDATE token_outputs SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
723
+ [reservationId, selectedIds, this.identity]
669
724
  );
670
725
  }
671
726
 
@@ -693,16 +748,18 @@ class PostgresTokenStore {
693
748
  async cancelReservation(id) {
694
749
  try {
695
750
  await this._withTransaction(async (client) => {
696
- // Clear reservation_id from outputs
751
+ // Clear reservation_id from outputs first — the composite FK uses NO
752
+ // ACTION (column-list SET NULL is PG15+ and a whole-row SET NULL would
753
+ // null user_id, which is NOT NULL).
697
754
  await client.query(
698
- "UPDATE token_outputs SET reservation_id = NULL WHERE reservation_id = $1",
699
- [id]
755
+ "UPDATE token_outputs SET reservation_id = NULL WHERE user_id = $1 AND reservation_id = $2",
756
+ [this.identity, id]
700
757
  );
701
758
 
702
759
  // Delete the reservation
703
760
  await client.query(
704
- "DELETE FROM token_reservations WHERE id = $1",
705
- [id]
761
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
762
+ [this.identity, id]
706
763
  );
707
764
  });
708
765
  } catch (error) {
@@ -720,11 +777,15 @@ class PostgresTokenStore {
720
777
  */
721
778
  async finalizeReservation(id) {
722
779
  try {
723
- await this._withTransaction(async (client) => {
780
+ // _withWriteTransaction acquires the advisory lock so this serializes
781
+ // against `setTokensOutputs`. Without it, a concurrent setTokensOutputs
782
+ // could read token_spent_outputs before our marker commits and re-insert
783
+ // the just-spent output as Available.
784
+ await this._withWriteTransaction(async (client) => {
724
785
  // Get reservation purpose
725
786
  const reservationResult = await client.query(
726
- "SELECT purpose FROM token_reservations WHERE id = $1",
727
- [id]
787
+ "SELECT purpose FROM token_reservations WHERE user_id = $1 AND id = $2",
788
+ [this.identity, id]
728
789
  );
729
790
  if (reservationResult.rows.length === 0) {
730
791
  return; // Non-existing reservation
@@ -733,45 +794,53 @@ class PostgresTokenStore {
733
794
 
734
795
  // Get reserved output IDs and mark them as spent
735
796
  const reservedOutputsResult = await client.query(
736
- "SELECT id FROM token_outputs WHERE reservation_id = $1",
737
- [id]
797
+ "SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
798
+ [this.identity, id]
738
799
  );
739
800
  const reservedOutputIds = reservedOutputsResult.rows.map((r) => r.id);
740
801
 
741
802
  if (reservedOutputIds.length > 0) {
742
803
  await client.query(
743
- `INSERT INTO token_spent_outputs (output_id)
744
- SELECT * FROM UNNEST($1::text[])
804
+ `INSERT INTO token_spent_outputs (user_id, output_id)
805
+ SELECT $2, output_id FROM UNNEST($1::text[]) AS t(output_id)
745
806
  ON CONFLICT DO NOTHING`,
746
- [reservedOutputIds]
807
+ [reservedOutputIds, this.identity]
747
808
  );
748
809
  }
749
810
 
750
811
  // Delete reserved outputs
751
812
  await client.query(
752
- "DELETE FROM token_outputs WHERE reservation_id = $1",
753
- [id]
813
+ "DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
814
+ [this.identity, id]
754
815
  );
755
816
 
756
817
  // Delete the reservation
757
818
  await client.query(
758
- "DELETE FROM token_reservations WHERE id = $1",
759
- [id]
819
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
820
+ [this.identity, id]
760
821
  );
761
822
 
762
- // If this was a swap reservation, update last_completed_at
823
+ // If this was a swap reservation, update last_completed_at. UPSERT so a
824
+ // tenant that joined after migration 2 (and thus has no row) gets one.
763
825
  if (isSwap) {
764
826
  await client.query(
765
- "UPDATE token_swap_status SET last_completed_at = NOW() WHERE id = 1"
827
+ `INSERT INTO token_swap_status (user_id, last_completed_at)
828
+ VALUES ($1, NOW())
829
+ ON CONFLICT (user_id) DO UPDATE
830
+ SET last_completed_at = EXCLUDED.last_completed_at`,
831
+ [this.identity]
766
832
  );
767
833
  }
768
834
 
769
- // Clean up orphaned metadata
835
+ // Clean up orphaned metadata (per-tenant)
770
836
  await client.query(
771
837
  `DELETE FROM token_metadata
772
- WHERE identifier NOT IN (
773
- SELECT DISTINCT token_identifier FROM token_outputs
774
- )`
838
+ WHERE user_id = $1
839
+ AND identifier NOT IN (
840
+ SELECT DISTINCT token_identifier
841
+ FROM token_outputs WHERE user_id = $1
842
+ )`,
843
+ [this.identity]
775
844
  );
776
845
  });
777
846
  } catch (error) {
@@ -817,15 +886,27 @@ class PostgresTokenStore {
817
886
  }
818
887
 
819
888
  /**
820
- * Delete reservations that have exceeded the timeout.
821
- * Called during setTokensOutputs to clean up stale reservations from crashed clients.
822
- * The ON DELETE SET NULL foreign key constraint automatically releases the outputs.
889
+ * Delete reservations that have exceeded the timeout. Releases outputs by
890
+ * clearing reservation_id explicitly, then deletes the parents the
891
+ * composite FK uses NO ACTION (column-list SET NULL is PG15+ and a
892
+ * whole-row SET NULL would null user_id, NOT NULL).
823
893
  */
824
894
  async _cleanupStaleReservations(client) {
895
+ await client.query(
896
+ `UPDATE token_outputs SET reservation_id = NULL
897
+ WHERE user_id = $2
898
+ AND reservation_id IN (
899
+ SELECT id FROM token_reservations
900
+ WHERE user_id = $2
901
+ AND created_at < NOW() - make_interval(secs => $1)
902
+ )`,
903
+ [RESERVATION_TIMEOUT_SECS, this.identity]
904
+ );
825
905
  await client.query(
826
906
  `DELETE FROM token_reservations
827
- WHERE created_at < NOW() - make_interval(secs => $1)`,
828
- [RESERVATION_TIMEOUT_SECS]
907
+ WHERE user_id = $2
908
+ AND created_at < NOW() - make_interval(secs => $1)`,
909
+ [RESERVATION_TIMEOUT_SECS, this.identity]
829
910
  );
830
911
  }
831
912
 
@@ -835,10 +916,10 @@ class PostgresTokenStore {
835
916
  async _upsertMetadata(client, metadata) {
836
917
  await client.query(
837
918
  `INSERT INTO token_metadata
838
- (identifier, issuer_public_key, name, ticker, decimals, max_supply,
919
+ (user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
839
920
  is_freezable, creation_entity_public_key)
840
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
841
- ON CONFLICT (identifier) DO UPDATE SET
921
+ VALUES ($9, $1, $2, $3, $4, $5, $6, $7, $8)
922
+ ON CONFLICT (user_id, identifier) DO UPDATE SET
842
923
  issuer_public_key = EXCLUDED.issuer_public_key,
843
924
  name = EXCLUDED.name,
844
925
  ticker = EXCLUDED.ticker,
@@ -855,6 +936,7 @@ class PostgresTokenStore {
855
936
  metadata.maxSupply,
856
937
  metadata.isFreezable,
857
938
  metadata.creationEntityPublicKey || null,
939
+ this.identity,
858
940
  ]
859
941
  );
860
942
  }
@@ -865,11 +947,11 @@ class PostgresTokenStore {
865
947
  async _insertSingleOutput(client, tokenIdentifier, output) {
866
948
  await client.query(
867
949
  `INSERT INTO token_outputs
868
- (id, token_identifier, owner_public_key, revocation_commitment,
950
+ (user_id, id, token_identifier, owner_public_key, revocation_commitment,
869
951
  withdraw_bond_sats, withdraw_relative_block_locktime,
870
952
  token_public_key, token_amount, prev_tx_hash, prev_tx_vout, added_at)
871
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
872
- ON CONFLICT (id) DO NOTHING`,
953
+ VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
954
+ ON CONFLICT (user_id, id) DO NOTHING`,
873
955
  [
874
956
  output.output.id,
875
957
  tokenIdentifier,
@@ -881,6 +963,7 @@ class PostgresTokenStore {
881
963
  output.output.tokenAmount,
882
964
  output.prevTxHash,
883
965
  output.prevTxVout,
966
+ this.identity,
884
967
  ]
885
968
  );
886
969
  }
@@ -932,28 +1015,30 @@ class PostgresTokenStore {
932
1015
  * @param {number} config.maxPoolSize - Maximum number of connections in the pool
933
1016
  * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
934
1017
  * @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
1018
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
935
1019
  * @param {object} [logger] - Optional logger
936
1020
  * @returns {Promise<PostgresTokenStore>}
937
1021
  */
938
- async function createPostgresTokenStore(config, logger = null) {
1022
+ async function createPostgresTokenStore(config, identity, logger = null) {
939
1023
  const pool = new pg.Pool({
940
1024
  connectionString: config.connectionString,
941
1025
  max: config.maxPoolSize,
942
1026
  connectionTimeoutMillis: config.createTimeoutSecs * 1000,
943
1027
  idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
944
1028
  });
945
- return createPostgresTokenStoreWithPool(pool, logger);
1029
+ return createPostgresTokenStoreWithPool(pool, identity, logger);
946
1030
  }
947
1031
 
948
1032
  /**
949
1033
  * Create a PostgresTokenStore instance from an existing pg.Pool.
950
1034
  *
951
1035
  * @param {pg.Pool} pool - An existing connection pool
1036
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
952
1037
  * @param {object} [logger] - Optional logger
953
1038
  * @returns {Promise<PostgresTokenStore>}
954
1039
  */
955
- async function createPostgresTokenStoreWithPool(pool, logger = null) {
956
- const store = new PostgresTokenStore(pool, logger);
1040
+ async function createPostgresTokenStoreWithPool(pool, identity, logger = null) {
1041
+ const store = new PostgresTokenStore(pool, identity, logger);
957
1042
  await store.initialize();
958
1043
  return store;
959
1044
  }