@breeztech/breez-sdk-spark 0.13.10-dev → 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 +33 -0
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +66 -24
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  7. package/deno/breez_sdk_spark_wasm.d.ts +33 -0
  8. package/deno/breez_sdk_spark_wasm.js +66 -24
  9. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  10. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  11. package/nodejs/breez_sdk_spark_wasm.d.ts +33 -0
  12. package/nodejs/breez_sdk_spark_wasm.js +67 -24
  13. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  15. package/nodejs/index.js +34 -0
  16. package/nodejs/index.mjs +1 -0
  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 +176 -89
  33. package/nodejs/postgres-token-store/migrations.cjs +92 -3
  34. package/nodejs/postgres-tree-store/index.cjs +168 -83
  35. package/nodejs/postgres-tree-store/migrations.cjs +80 -3
  36. package/package.json +1 -1
  37. package/ssr/index.js +5 -0
  38. package/web/breez_sdk_spark_wasm.d.ts +40 -5
  39. package/web/breez_sdk_spark_wasm.js +66 -24
  40. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  41. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
@@ -20,9 +20,14 @@ class PostgresMigrationManager {
20
20
 
21
21
  /**
22
22
  * Run all pending migrations inside a single transaction with an advisory lock.
23
+ *
23
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 migration so that pre-existing single-tenant data remains
28
+ * readable.
24
29
  */
25
- async migrate(pool) {
30
+ async migrate(pool, identity) {
26
31
  const client = await pool.connect();
27
32
  try {
28
33
  await client.query("BEGIN");
@@ -44,7 +49,7 @@ class PostgresMigrationManager {
44
49
  );
45
50
  const currentVersion = versionResult.rows[0].version;
46
51
 
47
- const migrations = this._getMigrations();
52
+ const migrations = this._getMigrations(identity);
48
53
 
49
54
  if (currentVersion >= migrations.length) {
50
55
  this._log("info", `Database is up to date (version ${currentVersion})`);
@@ -97,8 +102,27 @@ class PostgresMigrationManager {
97
102
  * Single migration creating all tables at their final schema.
98
103
  * This mirrors the Rust-native PostgresStorage schema but uses camelCase
99
104
  * enum values (as produced by the WASM bridge).
105
+ *
106
+ * @param {Buffer|Uint8Array} identity - 33-byte tenant identity. Inlined as
107
+ * a hex BYTEA literal in the multi-tenant scoping migration. Safe because
108
+ * the bytes come from a typed secp256k1 pubkey (character set
109
+ * `[0-9a-f]{66}` after hex encoding) — not user-controlled input.
100
110
  */
101
- _getMigrations() {
111
+ _getMigrations(identity) {
112
+ const idHex = Buffer.from(identity).toString("hex");
113
+ const idLit = `'\\x${idHex}'::bytea`;
114
+
115
+ // Helper for the per-table backfill: ADD COLUMN nullable -> UPDATE -> SET
116
+ // NOT NULL + drop/recreate PK. Returns an array of statements.
117
+ const scopeTable = (table, pkCols) => [
118
+ `ALTER TABLE ${table} ADD COLUMN user_id BYTEA`,
119
+ `UPDATE ${table} SET user_id = ${idLit}`,
120
+ `ALTER TABLE ${table}
121
+ ALTER COLUMN user_id SET NOT NULL,
122
+ DROP CONSTRAINT IF EXISTS ${table}_pkey,
123
+ ADD PRIMARY KEY (user_id, ${pkCols})`,
124
+ ];
125
+
102
126
  return [
103
127
  {
104
128
  name: "Create all tables at final schema",
@@ -252,12 +276,69 @@ class PostgresMigrationManager {
252
276
  `ALTER TABLE unclaimed_deposits ADD COLUMN is_mature BOOLEAN NOT NULL DEFAULT TRUE`,
253
277
  ],
254
278
  },
255
- {
279
+ {
256
280
  name: "Add conversion_status to payment_metadata",
257
281
  sql: [
258
282
  `ALTER TABLE payment_metadata ADD COLUMN IF NOT EXISTS conversion_status TEXT`,
259
283
  ],
260
284
  },
285
+ {
286
+ name: "Multi-tenant scoping: add user_id and rewrite primary keys",
287
+ sql: [
288
+ // Per-user tables
289
+ ...scopeTable("payments", "id"),
290
+ `DROP INDEX IF EXISTS idx_payments_timestamp`,
291
+ `DROP INDEX IF EXISTS idx_payments_payment_type`,
292
+ `DROP INDEX IF EXISTS idx_payments_status`,
293
+ `CREATE INDEX idx_payments_user_timestamp ON payments(user_id, timestamp)`,
294
+ `CREATE INDEX idx_payments_user_payment_type ON payments(user_id, payment_type)`,
295
+ `CREATE INDEX idx_payments_user_status ON payments(user_id, status)`,
296
+
297
+ ...scopeTable("payment_metadata", "payment_id"),
298
+ `DROP INDEX IF EXISTS idx_payment_metadata_parent`,
299
+ `CREATE INDEX idx_payment_metadata_user_parent
300
+ ON payment_metadata(user_id, parent_payment_id)`,
301
+
302
+ ...scopeTable("payment_details_lightning", "payment_id"),
303
+ `DROP INDEX IF EXISTS idx_payment_details_lightning_invoice`,
304
+ `DROP INDEX IF EXISTS idx_payment_details_lightning_payment_hash`,
305
+ `CREATE INDEX idx_payment_details_lightning_user_invoice
306
+ ON payment_details_lightning(user_id, invoice)`,
307
+ `CREATE INDEX idx_payment_details_lightning_user_payment_hash
308
+ ON payment_details_lightning(user_id, payment_hash)`,
309
+
310
+ ...scopeTable("payment_details_token", "payment_id"),
311
+ ...scopeTable("payment_details_spark", "payment_id"),
312
+ ...scopeTable("lnurl_receive_metadata", "payment_hash"),
313
+ ...scopeTable("unclaimed_deposits", "txid, vout"),
314
+ ...scopeTable("contacts", "id"),
315
+ ...scopeTable("settings", "key"),
316
+
317
+ // sync_revision: drop the singleton id (CASCADE removes PK + CHECK),
318
+ // then re-key by user_id so each tenant has its own revision row.
319
+ `ALTER TABLE sync_revision DROP COLUMN id CASCADE`,
320
+ `ALTER TABLE sync_revision ADD COLUMN user_id BYTEA`,
321
+ `UPDATE sync_revision SET user_id = ${idLit}`,
322
+ `ALTER TABLE sync_revision
323
+ ALTER COLUMN user_id SET NOT NULL,
324
+ ADD PRIMARY KEY (user_id)`,
325
+
326
+ // sync_outgoing has no PK, only an index — just add user_id and rewrite the index.
327
+ `ALTER TABLE sync_outgoing ADD COLUMN user_id BYTEA`,
328
+ `UPDATE sync_outgoing SET user_id = ${idLit}`,
329
+ `ALTER TABLE sync_outgoing ALTER COLUMN user_id SET NOT NULL`,
330
+ `DROP INDEX IF EXISTS idx_sync_outgoing_data_id_record_type`,
331
+ `CREATE INDEX idx_sync_outgoing_user_record_type_data_id
332
+ ON sync_outgoing(user_id, record_type, data_id)`,
333
+
334
+ ...scopeTable("sync_state", "record_type, data_id"),
335
+
336
+ ...scopeTable("sync_incoming", "record_type, data_id, revision"),
337
+ `DROP INDEX IF EXISTS idx_sync_incoming_revision`,
338
+ `CREATE INDEX idx_sync_incoming_user_revision
339
+ ON sync_incoming(user_id, revision)`,
340
+ ],
341
+ },
261
342
  ];
262
343
  }
263
344
  }
@@ -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) {
@@ -483,8 +542,8 @@ class PostgresTokenStore {
483
542
  const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
484
543
  if (outputIds.length > 0) {
485
544
  await client.query(
486
- "DELETE FROM token_spent_outputs WHERE output_id = ANY($1)",
487
- [outputIds]
545
+ "DELETE FROM token_spent_outputs WHERE user_id = $1 AND output_id = ANY($2)",
546
+ [this.identity, outputIds]
488
547
  );
489
548
  }
490
549
 
@@ -544,8 +603,8 @@ class PostgresTokenStore {
544
603
 
545
604
  // Get metadata
546
605
  const metadataResult = await client.query(
547
- "SELECT * FROM token_metadata WHERE identifier = $1",
548
- [tokenIdentifier]
606
+ "SELECT * FROM token_metadata WHERE user_id = $1 AND identifier = $2",
607
+ [this.identity, tokenIdentifier]
549
608
  );
550
609
 
551
610
  if (metadataResult.rows.length === 0) {
@@ -563,8 +622,10 @@ class PostgresTokenStore {
563
622
  o.token_public_key, o.token_amount, o.token_identifier,
564
623
  o.prev_tx_hash, o.prev_tx_vout
565
624
  FROM token_outputs o
566
- WHERE o.token_identifier = $1 AND o.reservation_id IS NULL`,
567
- [tokenIdentifier]
625
+ WHERE o.user_id = $1
626
+ AND o.token_identifier = $2
627
+ AND o.reservation_id IS NULL`,
628
+ [this.identity, tokenIdentifier]
568
629
  );
569
630
 
570
631
  let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
@@ -650,16 +711,16 @@ class PostgresTokenStore {
650
711
  const reservationId = this._generateId();
651
712
 
652
713
  await client.query(
653
- "INSERT INTO token_reservations (id, purpose) VALUES ($1, $2)",
654
- [reservationId, purpose]
714
+ "INSERT INTO token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
715
+ [this.identity, reservationId, purpose]
655
716
  );
656
717
 
657
718
  // Set reservation_id on selected outputs
658
719
  const selectedIds = selectedOutputs.map((o) => o.output.id);
659
720
  if (selectedIds.length > 0) {
660
721
  await client.query(
661
- "UPDATE token_outputs SET reservation_id = $1 WHERE id = ANY($2)",
662
- [reservationId, selectedIds]
722
+ "UPDATE token_outputs SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
723
+ [reservationId, selectedIds, this.identity]
663
724
  );
664
725
  }
665
726
 
@@ -687,16 +748,18 @@ class PostgresTokenStore {
687
748
  async cancelReservation(id) {
688
749
  try {
689
750
  await this._withTransaction(async (client) => {
690
- // 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).
691
754
  await client.query(
692
- "UPDATE token_outputs SET reservation_id = NULL WHERE reservation_id = $1",
693
- [id]
755
+ "UPDATE token_outputs SET reservation_id = NULL WHERE user_id = $1 AND reservation_id = $2",
756
+ [this.identity, id]
694
757
  );
695
758
 
696
759
  // Delete the reservation
697
760
  await client.query(
698
- "DELETE FROM token_reservations WHERE id = $1",
699
- [id]
761
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
762
+ [this.identity, id]
700
763
  );
701
764
  });
702
765
  } catch (error) {
@@ -721,8 +784,8 @@ class PostgresTokenStore {
721
784
  await this._withWriteTransaction(async (client) => {
722
785
  // Get reservation purpose
723
786
  const reservationResult = await client.query(
724
- "SELECT purpose FROM token_reservations WHERE id = $1",
725
- [id]
787
+ "SELECT purpose FROM token_reservations WHERE user_id = $1 AND id = $2",
788
+ [this.identity, id]
726
789
  );
727
790
  if (reservationResult.rows.length === 0) {
728
791
  return; // Non-existing reservation
@@ -731,45 +794,53 @@ class PostgresTokenStore {
731
794
 
732
795
  // Get reserved output IDs and mark them as spent
733
796
  const reservedOutputsResult = await client.query(
734
- "SELECT id FROM token_outputs WHERE reservation_id = $1",
735
- [id]
797
+ "SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
798
+ [this.identity, id]
736
799
  );
737
800
  const reservedOutputIds = reservedOutputsResult.rows.map((r) => r.id);
738
801
 
739
802
  if (reservedOutputIds.length > 0) {
740
803
  await client.query(
741
- `INSERT INTO token_spent_outputs (output_id)
742
- 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)
743
806
  ON CONFLICT DO NOTHING`,
744
- [reservedOutputIds]
807
+ [reservedOutputIds, this.identity]
745
808
  );
746
809
  }
747
810
 
748
811
  // Delete reserved outputs
749
812
  await client.query(
750
- "DELETE FROM token_outputs WHERE reservation_id = $1",
751
- [id]
813
+ "DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
814
+ [this.identity, id]
752
815
  );
753
816
 
754
817
  // Delete the reservation
755
818
  await client.query(
756
- "DELETE FROM token_reservations WHERE id = $1",
757
- [id]
819
+ "DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
820
+ [this.identity, id]
758
821
  );
759
822
 
760
- // 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.
761
825
  if (isSwap) {
762
826
  await client.query(
763
- "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]
764
832
  );
765
833
  }
766
834
 
767
- // Clean up orphaned metadata
835
+ // Clean up orphaned metadata (per-tenant)
768
836
  await client.query(
769
837
  `DELETE FROM token_metadata
770
- WHERE identifier NOT IN (
771
- SELECT DISTINCT token_identifier FROM token_outputs
772
- )`
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]
773
844
  );
774
845
  });
775
846
  } catch (error) {
@@ -815,15 +886,27 @@ class PostgresTokenStore {
815
886
  }
816
887
 
817
888
  /**
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.
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).
821
893
  */
822
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
+ );
823
905
  await client.query(
824
906
  `DELETE FROM token_reservations
825
- WHERE created_at < NOW() - make_interval(secs => $1)`,
826
- [RESERVATION_TIMEOUT_SECS]
907
+ WHERE user_id = $2
908
+ AND created_at < NOW() - make_interval(secs => $1)`,
909
+ [RESERVATION_TIMEOUT_SECS, this.identity]
827
910
  );
828
911
  }
829
912
 
@@ -833,10 +916,10 @@ class PostgresTokenStore {
833
916
  async _upsertMetadata(client, metadata) {
834
917
  await client.query(
835
918
  `INSERT INTO token_metadata
836
- (identifier, issuer_public_key, name, ticker, decimals, max_supply,
919
+ (user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
837
920
  is_freezable, creation_entity_public_key)
838
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
839
- 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
840
923
  issuer_public_key = EXCLUDED.issuer_public_key,
841
924
  name = EXCLUDED.name,
842
925
  ticker = EXCLUDED.ticker,
@@ -853,6 +936,7 @@ class PostgresTokenStore {
853
936
  metadata.maxSupply,
854
937
  metadata.isFreezable,
855
938
  metadata.creationEntityPublicKey || null,
939
+ this.identity,
856
940
  ]
857
941
  );
858
942
  }
@@ -863,11 +947,11 @@ class PostgresTokenStore {
863
947
  async _insertSingleOutput(client, tokenIdentifier, output) {
864
948
  await client.query(
865
949
  `INSERT INTO token_outputs
866
- (id, token_identifier, owner_public_key, revocation_commitment,
950
+ (user_id, id, token_identifier, owner_public_key, revocation_commitment,
867
951
  withdraw_bond_sats, withdraw_relative_block_locktime,
868
952
  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`,
953
+ VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
954
+ ON CONFLICT (user_id, id) DO NOTHING`,
871
955
  [
872
956
  output.output.id,
873
957
  tokenIdentifier,
@@ -879,6 +963,7 @@ class PostgresTokenStore {
879
963
  output.output.tokenAmount,
880
964
  output.prevTxHash,
881
965
  output.prevTxVout,
966
+ this.identity,
882
967
  ]
883
968
  );
884
969
  }
@@ -930,28 +1015,30 @@ class PostgresTokenStore {
930
1015
  * @param {number} config.maxPoolSize - Maximum number of connections in the pool
931
1016
  * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
932
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
933
1019
  * @param {object} [logger] - Optional logger
934
1020
  * @returns {Promise<PostgresTokenStore>}
935
1021
  */
936
- async function createPostgresTokenStore(config, logger = null) {
1022
+ async function createPostgresTokenStore(config, identity, logger = null) {
937
1023
  const pool = new pg.Pool({
938
1024
  connectionString: config.connectionString,
939
1025
  max: config.maxPoolSize,
940
1026
  connectionTimeoutMillis: config.createTimeoutSecs * 1000,
941
1027
  idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
942
1028
  });
943
- return createPostgresTokenStoreWithPool(pool, logger);
1029
+ return createPostgresTokenStoreWithPool(pool, identity, logger);
944
1030
  }
945
1031
 
946
1032
  /**
947
1033
  * Create a PostgresTokenStore instance from an existing pg.Pool.
948
1034
  *
949
1035
  * @param {pg.Pool} pool - An existing connection pool
1036
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
950
1037
  * @param {object} [logger] - Optional logger
951
1038
  * @returns {Promise<PostgresTokenStore>}
952
1039
  */
953
- async function createPostgresTokenStoreWithPool(pool, logger = null) {
954
- const store = new PostgresTokenStore(pool, logger);
1040
+ async function createPostgresTokenStoreWithPool(pool, identity, logger = null) {
1041
+ const store = new PostgresTokenStore(pool, identity, logger);
955
1042
  await store.initialize();
956
1043
  return store;
957
1044
  }