@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
@@ -0,0 +1,988 @@
1
+ /**
2
+ * CommonJS implementation for Node.js MySQL Token Store.
3
+ *
4
+ * Mirrors `postgres-token-store/index.cjs` for MySQL 8.0+. See
5
+ * `mysql-storage/index.cjs` for SQL translation rules.
6
+ */
7
+
8
+ let mysql;
9
+ try {
10
+ const mainModule = require.main;
11
+ if (mainModule) {
12
+ mysql = mainModule.require("mysql2/promise");
13
+ } else {
14
+ mysql = require("mysql2/promise");
15
+ }
16
+ } catch (error) {
17
+ try {
18
+ mysql = require("mysql2/promise");
19
+ } catch (fallbackError) {
20
+ throw new Error(
21
+ `mysql2 not found. Please install it in your project: npm install mysql2@^3.11.0\n` +
22
+ `Original error: ${error.message}\nFallback error: ${fallbackError.message}`
23
+ );
24
+ }
25
+ }
26
+
27
+ const { TokenStoreError } = require("./errors.cjs");
28
+ const { MysqlTokenStoreMigrationManager } = require("./migrations.cjs");
29
+
30
+ /**
31
+ * Domain prefix mixed into the per-tenant `GET_LOCK` name. Distinct prefixes
32
+ * guarantee that tree-store and token-store locks never collide.
33
+ */
34
+ const TOKEN_STORE_LOCK_PREFIX = "breez-spark-sdk:token:";
35
+ /** Seconds to wait when acquiring the write lock. */
36
+ const WRITE_LOCK_TIMEOUT_SECS = 30;
37
+
38
+ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000;
39
+ const RESERVATION_TIMEOUT_SECS = 300;
40
+
41
+ /**
42
+ * Derive a stable per-tenant lock name from a tenant identity pubkey. Hashes
43
+ * a domain prefix together with the identity (SHA-256, first 8 bytes hex).
44
+ */
45
+ function _identityLockName(prefix, identity) {
46
+ const crypto = require("crypto");
47
+ const hash = crypto.createHash("sha256");
48
+ hash.update(prefix);
49
+ hash.update(Buffer.from(identity));
50
+ return prefix + hash.digest("hex").slice(0, 16);
51
+ }
52
+
53
+ function parseJson(value) {
54
+ if (value == null) return null;
55
+ if (typeof value === "string") return JSON.parse(value);
56
+ return value;
57
+ }
58
+
59
+ function toBool(value) {
60
+ if (value == null) return null;
61
+ if (typeof value === "boolean") return value;
62
+ return value === 1 || value === "1" || value === true;
63
+ }
64
+
65
+ function buildPlaceholders(n) {
66
+ return new Array(n).fill("?").join(", ");
67
+ }
68
+
69
+ class MysqlTokenStore {
70
+ /**
71
+ * @param {import('mysql2/promise').Pool} pool
72
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
73
+ * identifying the tenant. All reads and writes are scoped by this.
74
+ * @param {object} [logger]
75
+ */
76
+ constructor(pool, identity, logger = null) {
77
+ if (!identity || identity.length !== 33) {
78
+ throw new TokenStoreError(
79
+ "tenant identity (33-byte secp256k1 pubkey) is required"
80
+ );
81
+ }
82
+ this.pool = pool;
83
+ this.identity = Buffer.from(identity);
84
+ this.lockName = _identityLockName(TOKEN_STORE_LOCK_PREFIX, identity);
85
+ this.logger = logger;
86
+ }
87
+
88
+ async initialize() {
89
+ try {
90
+ const migrationManager = new MysqlTokenStoreMigrationManager(this.logger);
91
+ await migrationManager.migrate(this.pool, this.identity);
92
+ return this;
93
+ } catch (error) {
94
+ throw new TokenStoreError(
95
+ `Failed to initialize MySQL token store: ${error.message}`,
96
+ error
97
+ );
98
+ }
99
+ }
100
+
101
+ async close() {
102
+ if (this.pool) {
103
+ await this.pool.end();
104
+ this.pool = null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Run a function inside a transaction holding the named write lock. Reserved
110
+ * for operations whose correctness depends on serializing the
111
+ * available-output set (`reserveTokenOutputs`, `setTokensOutputs`).
112
+ * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
113
+ * @returns {Promise<T>}
114
+ * @template T
115
+ */
116
+ async _withWriteTransaction(fn) {
117
+ const conn = await this.pool.getConnection();
118
+ let lockAcquired = false;
119
+ try {
120
+ const [lockRows] = await conn.query(
121
+ "SELECT GET_LOCK(?, ?) AS acquired",
122
+ [this.lockName, WRITE_LOCK_TIMEOUT_SECS]
123
+ );
124
+ if (!lockRows || lockRows[0].acquired !== 1) {
125
+ throw new TokenStoreError(
126
+ `Failed to acquire token store write lock within ${WRITE_LOCK_TIMEOUT_SECS}s`
127
+ );
128
+ }
129
+ lockAcquired = true;
130
+
131
+ await conn.beginTransaction();
132
+ const result = await fn(conn);
133
+ await conn.commit();
134
+ return result;
135
+ } catch (error) {
136
+ await conn.rollback().catch(() => {});
137
+ throw error;
138
+ } finally {
139
+ if (lockAcquired) {
140
+ await conn
141
+ .query("SELECT RELEASE_LOCK(?)", [this.lockName])
142
+ .catch(() => {});
143
+ }
144
+ conn.release();
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Run a function inside a transaction without the advisory lock. Used by
150
+ * operations scoped to a single reservation_id (`cancelReservation`)
151
+ * where row-level FK + InnoDB MVCC suffice and the global lock would only
152
+ * add contention.
153
+ * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
154
+ * @returns {Promise<T>}
155
+ * @template T
156
+ */
157
+ async _withTransaction(fn) {
158
+ const conn = await this.pool.getConnection();
159
+ try {
160
+ await conn.beginTransaction();
161
+ const result = await fn(conn);
162
+ await conn.commit();
163
+ return result;
164
+ } catch (error) {
165
+ await conn.rollback().catch(() => {});
166
+ throw error;
167
+ } finally {
168
+ conn.release();
169
+ }
170
+ }
171
+
172
+ // ===== TokenOutputStore Methods =====
173
+
174
+ async setTokensOutputs(tokenOutputs, refreshStartedAtMs) {
175
+ try {
176
+ const refreshTimestamp = new Date(refreshStartedAtMs);
177
+
178
+ await this._withWriteTransaction(async (conn) => {
179
+ // Drop expired reservations BEFORE evaluating has_active_swap, otherwise a stale
180
+ // Swap reservation (from a crashed client or a swap whose finalize/cancel never
181
+ // ran) keeps has_active_swap true forever, which makes setTokensOutputs
182
+ // early-return and never reach any subsequent reconciliation. The reservation
183
+ // pins itself in place and the local token-output set freezes.
184
+ await this._cleanupStaleReservations(conn);
185
+
186
+ const [swapRows] = await conn.query(
187
+ `SELECT
188
+ (SELECT EXISTS(SELECT 1 FROM token_reservations WHERE user_id = ? AND purpose = 'Swap')) AS has_active_swap,
189
+ COALESCE(
190
+ (SELECT (last_completed_at >= ?) FROM token_swap_status WHERE user_id = ?),
191
+ 0
192
+ ) AS swap_completed`,
193
+ [this.identity, refreshTimestamp, this.identity]
194
+ );
195
+ const hasActiveSwap = !!swapRows[0].has_active_swap;
196
+ const swapCompleted = !!swapRows[0].swap_completed;
197
+ if (hasActiveSwap || swapCompleted) {
198
+ return;
199
+ }
200
+
201
+ const cleanupCutoff = new Date(
202
+ refreshTimestamp.getTime() - SPENT_MARKER_CLEANUP_THRESHOLD_MS
203
+ );
204
+ await conn.query(
205
+ "DELETE FROM token_spent_outputs WHERE user_id = ? AND spent_at < ?",
206
+ [this.identity, cleanupCutoff]
207
+ );
208
+
209
+ const [spentRows] = await conn.query(
210
+ "SELECT output_id FROM token_spent_outputs WHERE user_id = ? AND spent_at >= ?",
211
+ [this.identity, refreshTimestamp]
212
+ );
213
+ const spentIds = new Set(spentRows.map((r) => r.output_id));
214
+
215
+ await conn.query(
216
+ "DELETE FROM token_outputs WHERE user_id = ? AND reservation_id IS NULL AND added_at < ?",
217
+ [this.identity, refreshTimestamp]
218
+ );
219
+
220
+ const incomingOutputIds = new Set();
221
+ for (const to of tokenOutputs) {
222
+ for (const o of to.outputs) {
223
+ incomingOutputIds.add(o.output.id);
224
+ }
225
+ }
226
+
227
+ const [reservedRows] = await conn.query(
228
+ `SELECT r.id, o.id AS output_id
229
+ FROM token_reservations r
230
+ JOIN token_outputs o
231
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
232
+ WHERE r.user_id = ?`,
233
+ [this.identity]
234
+ );
235
+
236
+ const reservationOutputs = new Map();
237
+ for (const row of reservedRows) {
238
+ if (!reservationOutputs.has(row.id)) {
239
+ reservationOutputs.set(row.id, []);
240
+ }
241
+ reservationOutputs.get(row.id).push(row.output_id);
242
+ }
243
+
244
+ const reservationsToDelete = [];
245
+ const outputsToRemoveFromReservation = [];
246
+ for (const [reservationId, outputIds] of reservationOutputs) {
247
+ const validIds = outputIds.filter((id) => incomingOutputIds.has(id));
248
+ if (validIds.length === 0) {
249
+ reservationsToDelete.push(reservationId);
250
+ } else {
251
+ for (const id of outputIds) {
252
+ if (!incomingOutputIds.has(id)) {
253
+ outputsToRemoveFromReservation.push(id);
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ if (reservationsToDelete.length > 0) {
260
+ const placeholders = buildPlaceholders(reservationsToDelete.length);
261
+ await conn.query(
262
+ `DELETE FROM token_outputs WHERE user_id = ? AND reservation_id IN (${placeholders})`,
263
+ [this.identity, ...reservationsToDelete]
264
+ );
265
+ await conn.query(
266
+ `DELETE FROM token_reservations WHERE user_id = ? AND id IN (${placeholders})`,
267
+ [this.identity, ...reservationsToDelete]
268
+ );
269
+ }
270
+
271
+ if (outputsToRemoveFromReservation.length > 0) {
272
+ const placeholders = buildPlaceholders(
273
+ outputsToRemoveFromReservation.length
274
+ );
275
+ await conn.query(
276
+ `DELETE FROM token_outputs WHERE user_id = ? AND id IN (${placeholders})`,
277
+ [this.identity, ...outputsToRemoveFromReservation]
278
+ );
279
+
280
+ const [emptyRows] = await conn.query(
281
+ `SELECT r.id FROM token_reservations r
282
+ LEFT JOIN token_outputs o
283
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
284
+ WHERE r.user_id = ? AND o.id IS NULL`,
285
+ [this.identity]
286
+ );
287
+ const emptyIds = emptyRows.map((r) => r.id);
288
+ if (emptyIds.length > 0) {
289
+ const emptyPlaceholders = buildPlaceholders(emptyIds.length);
290
+ await conn.query(
291
+ `DELETE FROM token_reservations WHERE user_id = ? AND id IN (${emptyPlaceholders})`,
292
+ [this.identity, ...emptyIds]
293
+ );
294
+ }
295
+ }
296
+
297
+ const [reservedOutputRows] = await conn.query(
298
+ "SELECT id FROM token_outputs WHERE user_id = ? AND reservation_id IS NOT NULL",
299
+ [this.identity]
300
+ );
301
+ const reservedOutputIds = new Set(reservedOutputRows.map((r) => r.id));
302
+
303
+ await conn.query(
304
+ `DELETE FROM token_metadata
305
+ WHERE user_id = ?
306
+ AND identifier NOT IN (
307
+ SELECT DISTINCT token_identifier FROM token_outputs WHERE user_id = ?
308
+ )`,
309
+ [this.identity, this.identity]
310
+ );
311
+
312
+ for (const to of tokenOutputs) {
313
+ await this._upsertMetadata(conn, to.metadata);
314
+
315
+ for (const output of to.outputs) {
316
+ if (
317
+ reservedOutputIds.has(output.output.id) ||
318
+ spentIds.has(output.output.id)
319
+ ) {
320
+ continue;
321
+ }
322
+ await this._insertSingleOutput(
323
+ conn,
324
+ to.metadata.identifier,
325
+ output
326
+ );
327
+ }
328
+ }
329
+ });
330
+ } catch (error) {
331
+ if (error instanceof TokenStoreError) throw error;
332
+ throw new TokenStoreError(
333
+ `Failed to set token outputs: ${error.message}`,
334
+ error
335
+ );
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Returns the spendable per-token balances aggregated server-side.
341
+ * Each entry includes full token metadata + the available + swap-reserved sum.
342
+ * Matches the in-memory default impl which returns all tokens that have
343
+ * at least one output (including zero spendable balance).
344
+ * @returns {Promise<Array<{metadata: object, balance: string}>>}
345
+ */
346
+ async getTokenBalances() {
347
+ try {
348
+ const [rows] = await this.pool.query(
349
+ `SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
350
+ m.max_supply, m.is_freezable, m.creation_entity_public_key,
351
+ CAST(COALESCE(SUM(
352
+ CASE
353
+ WHEN o.reservation_id IS NULL THEN CAST(o.token_amount AS DECIMAL(65,0))
354
+ WHEN r.purpose = 'Swap' THEN CAST(o.token_amount AS DECIMAL(65,0))
355
+ ELSE 0
356
+ END
357
+ ), 0) AS CHAR) AS balance
358
+ FROM token_metadata m
359
+ JOIN token_outputs o
360
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
361
+ LEFT JOIN token_reservations r
362
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
363
+ WHERE m.user_id = ?
364
+ GROUP BY m.identifier, m.issuer_public_key, m.name, m.ticker,
365
+ m.decimals, m.max_supply, m.is_freezable, m.creation_entity_public_key`,
366
+ [this.identity]
367
+ );
368
+ return rows.map((row) => ({
369
+ metadata: {
370
+ identifier: row.identifier,
371
+ issuerPublicKey: row.issuer_public_key,
372
+ name: row.name,
373
+ ticker: row.ticker,
374
+ decimals: row.decimals,
375
+ maxSupply: row.max_supply,
376
+ isFreezable: toBool(row.is_freezable) ?? false,
377
+ creationEntityPublicKey: row.creation_entity_public_key || null,
378
+ },
379
+ balance: row.balance,
380
+ }));
381
+ } catch (error) {
382
+ throw new TokenStoreError(
383
+ `Failed to get token balances: ${error.message}`,
384
+ error
385
+ );
386
+ }
387
+ }
388
+
389
+ async listTokensOutputs() {
390
+ try {
391
+ const [rows] = await this.pool.query(
392
+ `SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
393
+ m.max_supply, m.is_freezable, m.creation_entity_public_key,
394
+ o.id AS output_id, o.owner_public_key, o.revocation_commitment,
395
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
396
+ o.token_public_key, o.token_amount, o.token_identifier,
397
+ o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
398
+ r.purpose
399
+ FROM token_metadata m
400
+ LEFT JOIN token_outputs o
401
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
402
+ LEFT JOIN token_reservations r
403
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
404
+ WHERE m.user_id = ?
405
+ ORDER BY m.identifier, CAST(o.token_amount AS DECIMAL(65,0)) ASC`,
406
+ [this.identity]
407
+ );
408
+
409
+ const map = new Map();
410
+
411
+ for (const row of rows) {
412
+ if (!map.has(row.identifier)) {
413
+ map.set(row.identifier, {
414
+ metadata: this._metadataFromRow(row),
415
+ available: [],
416
+ reservedForPayment: [],
417
+ reservedForSwap: [],
418
+ });
419
+ }
420
+
421
+ const entry = map.get(row.identifier);
422
+
423
+ if (!row.output_id) {
424
+ continue;
425
+ }
426
+
427
+ const output = this._outputFromRow(row);
428
+
429
+ if (row.purpose === "Payment") {
430
+ entry.reservedForPayment.push(output);
431
+ } else if (row.purpose === "Swap") {
432
+ entry.reservedForSwap.push(output);
433
+ } else {
434
+ entry.available.push(output);
435
+ }
436
+ }
437
+
438
+ return Array.from(map.values());
439
+ } catch (error) {
440
+ if (error instanceof TokenStoreError) throw error;
441
+ throw new TokenStoreError(
442
+ `Failed to list token outputs: ${error.message}`,
443
+ error
444
+ );
445
+ }
446
+ }
447
+
448
+ async getTokenOutputs(filter) {
449
+ try {
450
+ let whereClause;
451
+ let param;
452
+
453
+ if (filter.type === "identifier") {
454
+ whereClause = "m.identifier = ?";
455
+ param = filter.identifier;
456
+ } else if (filter.type === "issuerPublicKey") {
457
+ whereClause = "m.issuer_public_key = ?";
458
+ param = filter.issuerPublicKey;
459
+ } else {
460
+ throw new TokenStoreError(`Unknown filter type: ${filter.type}`);
461
+ }
462
+
463
+ const [rows] = await this.pool.query(
464
+ `SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
465
+ m.max_supply, m.is_freezable, m.creation_entity_public_key,
466
+ o.id AS output_id, o.owner_public_key, o.revocation_commitment,
467
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
468
+ o.token_public_key, o.token_amount, o.token_identifier,
469
+ o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
470
+ r.purpose
471
+ FROM token_metadata m
472
+ LEFT JOIN token_outputs o
473
+ ON o.token_identifier = m.identifier AND o.user_id = m.user_id
474
+ LEFT JOIN token_reservations r
475
+ ON o.reservation_id = r.id AND o.user_id = r.user_id
476
+ WHERE m.user_id = ? AND ${whereClause}
477
+ ORDER BY CAST(o.token_amount AS DECIMAL(65,0)) ASC`,
478
+ [this.identity, param]
479
+ );
480
+
481
+ if (rows.length === 0) {
482
+ throw new TokenStoreError("Token outputs not found");
483
+ }
484
+
485
+ const metadata = this._metadataFromRow(rows[0]);
486
+ const entry = {
487
+ metadata,
488
+ available: [],
489
+ reservedForPayment: [],
490
+ reservedForSwap: [],
491
+ };
492
+
493
+ for (const row of rows) {
494
+ if (!row.output_id) {
495
+ continue;
496
+ }
497
+
498
+ const output = this._outputFromRow(row);
499
+
500
+ if (row.purpose === "Payment") {
501
+ entry.reservedForPayment.push(output);
502
+ } else if (row.purpose === "Swap") {
503
+ entry.reservedForSwap.push(output);
504
+ } else {
505
+ entry.available.push(output);
506
+ }
507
+ }
508
+
509
+ return entry;
510
+ } catch (error) {
511
+ if (error instanceof TokenStoreError) throw error;
512
+ throw new TokenStoreError(
513
+ `Failed to get token outputs: ${error.message}`,
514
+ error
515
+ );
516
+ }
517
+ }
518
+
519
+ async insertTokenOutputs(tokenOutputs) {
520
+ try {
521
+ const conn = await this.pool.getConnection();
522
+ try {
523
+ await conn.beginTransaction();
524
+
525
+ await this._upsertMetadata(conn, tokenOutputs.metadata);
526
+
527
+ const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
528
+ if (outputIds.length > 0) {
529
+ const placeholders = buildPlaceholders(outputIds.length);
530
+ await conn.query(
531
+ `DELETE FROM token_spent_outputs WHERE user_id = ? AND output_id IN (${placeholders})`,
532
+ [this.identity, ...outputIds]
533
+ );
534
+ }
535
+
536
+ for (const output of tokenOutputs.outputs) {
537
+ await this._insertSingleOutput(
538
+ conn,
539
+ tokenOutputs.metadata.identifier,
540
+ output
541
+ );
542
+ }
543
+
544
+ await conn.commit();
545
+ } catch (error) {
546
+ await conn.rollback().catch(() => {});
547
+ throw error;
548
+ } finally {
549
+ conn.release();
550
+ }
551
+ } catch (error) {
552
+ if (error instanceof TokenStoreError) throw error;
553
+ throw new TokenStoreError(
554
+ `Failed to insert token outputs: ${error.message}`,
555
+ error
556
+ );
557
+ }
558
+ }
559
+
560
+ async reserveTokenOutputs(
561
+ tokenIdentifier,
562
+ target,
563
+ purpose,
564
+ preferredOutputs,
565
+ selectionStrategy
566
+ ) {
567
+ try {
568
+ return await this._withWriteTransaction(async (conn) => {
569
+ if (
570
+ target.type === "minTotalValue" &&
571
+ (!target.value || target.value === "0")
572
+ ) {
573
+ throw new TokenStoreError(
574
+ "Amount to reserve must be greater than zero"
575
+ );
576
+ }
577
+ if (
578
+ target.type === "maxOutputCount" &&
579
+ (!target.value || target.value === 0)
580
+ ) {
581
+ throw new TokenStoreError(
582
+ "Count to reserve must be greater than zero"
583
+ );
584
+ }
585
+
586
+ const [metadataRows] = await conn.query(
587
+ "SELECT * FROM token_metadata WHERE user_id = ? AND identifier = ?",
588
+ [this.identity, tokenIdentifier]
589
+ );
590
+
591
+ if (metadataRows.length === 0) {
592
+ throw new TokenStoreError(
593
+ `Token outputs not found for identifier: ${tokenIdentifier}`
594
+ );
595
+ }
596
+
597
+ const metadata = this._metadataFromRow(metadataRows[0]);
598
+
599
+ const [outputRows] = await conn.query(
600
+ `SELECT o.id AS output_id, o.owner_public_key, o.revocation_commitment,
601
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
602
+ o.token_public_key, o.token_amount, o.token_identifier,
603
+ o.prev_tx_hash, o.prev_tx_vout
604
+ FROM token_outputs o
605
+ WHERE o.user_id = ? AND o.token_identifier = ? AND o.reservation_id IS NULL`,
606
+ [this.identity, tokenIdentifier]
607
+ );
608
+
609
+ let outputs = outputRows.map((row) => this._outputFromRow(row));
610
+
611
+ if (preferredOutputs && preferredOutputs.length > 0) {
612
+ const preferredIds = new Set(
613
+ preferredOutputs.map((p) => p.output.id)
614
+ );
615
+ outputs = outputs.filter((o) => preferredIds.has(o.output.id));
616
+ }
617
+
618
+ let selectedOutputs;
619
+
620
+ if (target.type === "minTotalValue") {
621
+ const amount = BigInt(target.value);
622
+
623
+ const totalAvailable = outputs.reduce(
624
+ (sum, o) => sum + BigInt(o.output.tokenAmount),
625
+ 0n
626
+ );
627
+ if (totalAvailable < amount) {
628
+ throw new TokenStoreError("InsufficientFunds");
629
+ }
630
+
631
+ const exactMatch = outputs.find(
632
+ (o) => BigInt(o.output.tokenAmount) === amount
633
+ );
634
+ if (exactMatch) {
635
+ selectedOutputs = [exactMatch];
636
+ } else {
637
+ if (selectionStrategy === "LargestFirst") {
638
+ outputs.sort(
639
+ (a, b) =>
640
+ Number(
641
+ BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount)
642
+ )
643
+ );
644
+ } else {
645
+ outputs.sort(
646
+ (a, b) =>
647
+ Number(
648
+ BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount)
649
+ )
650
+ );
651
+ }
652
+
653
+ selectedOutputs = [];
654
+ let remaining = amount;
655
+ for (const output of outputs) {
656
+ if (remaining <= 0n) break;
657
+ selectedOutputs.push(output);
658
+ remaining -= BigInt(output.output.tokenAmount);
659
+ }
660
+ if (remaining > 0n) {
661
+ throw new TokenStoreError("InsufficientFunds");
662
+ }
663
+ }
664
+ } else if (target.type === "maxOutputCount") {
665
+ const count = target.value;
666
+
667
+ if (selectionStrategy === "LargestFirst") {
668
+ outputs.sort(
669
+ (a, b) =>
670
+ Number(
671
+ BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount)
672
+ )
673
+ );
674
+ } else {
675
+ outputs.sort(
676
+ (a, b) =>
677
+ Number(
678
+ BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount)
679
+ )
680
+ );
681
+ }
682
+
683
+ selectedOutputs = outputs.slice(0, count);
684
+ } else {
685
+ throw new TokenStoreError(`Unknown target type: ${target.type}`);
686
+ }
687
+
688
+ const reservationId = this._generateId();
689
+
690
+ await conn.query(
691
+ "INSERT INTO token_reservations (user_id, id, purpose) VALUES (?, ?, ?)",
692
+ [this.identity, reservationId, purpose]
693
+ );
694
+
695
+ const selectedIds = selectedOutputs.map((o) => o.output.id);
696
+ if (selectedIds.length > 0) {
697
+ const placeholders = buildPlaceholders(selectedIds.length);
698
+ await conn.query(
699
+ `UPDATE token_outputs SET reservation_id = ? WHERE user_id = ? AND id IN (${placeholders})`,
700
+ [reservationId, this.identity, ...selectedIds]
701
+ );
702
+ }
703
+
704
+ return {
705
+ id: reservationId,
706
+ tokenOutputs: { metadata, outputs: selectedOutputs },
707
+ };
708
+ });
709
+ } catch (error) {
710
+ if (error instanceof TokenStoreError) throw error;
711
+ throw new TokenStoreError(
712
+ `Failed to reserve token outputs: ${error.message}`,
713
+ error
714
+ );
715
+ }
716
+ }
717
+
718
+ async cancelReservation(id) {
719
+ try {
720
+ await this._withTransaction(async (conn) => {
721
+ // Clear reservation_id from outputs first — the composite FK uses NO
722
+ // ACTION (a whole-row SET NULL would null user_id, which is NOT NULL).
723
+ await conn.query(
724
+ "UPDATE token_outputs SET reservation_id = NULL WHERE user_id = ? AND reservation_id = ?",
725
+ [this.identity, id]
726
+ );
727
+ await conn.query(
728
+ "DELETE FROM token_reservations WHERE user_id = ? AND id = ?",
729
+ [this.identity, id]
730
+ );
731
+ });
732
+ } catch (error) {
733
+ if (error instanceof TokenStoreError) throw error;
734
+ throw new TokenStoreError(
735
+ `Failed to cancel reservation '${id}': ${error.message}`,
736
+ error
737
+ );
738
+ }
739
+ }
740
+
741
+ async finalizeReservation(id) {
742
+ try {
743
+ // _withWriteTransaction acquires the GET_LOCK so this serializes
744
+ // against `setTokensOutputs`. Without it, a concurrent setTokensOutputs
745
+ // could read token_spent_outputs before our marker commits and re-insert
746
+ // the just-spent output as Available.
747
+ await this._withWriteTransaction(async (conn) => {
748
+ const [reservationRows] = await conn.query(
749
+ "SELECT purpose FROM token_reservations WHERE user_id = ? AND id = ?",
750
+ [this.identity, id]
751
+ );
752
+ if (reservationRows.length === 0) {
753
+ return;
754
+ }
755
+ const isSwap = reservationRows[0].purpose === "Swap";
756
+
757
+ const [reservedRows] = await conn.query(
758
+ "SELECT id FROM token_outputs WHERE user_id = ? AND reservation_id = ?",
759
+ [this.identity, id]
760
+ );
761
+ const reservedOutputIds = reservedRows.map((r) => r.id);
762
+
763
+ if (reservedOutputIds.length > 0) {
764
+ const valueClauses = new Array(reservedOutputIds.length)
765
+ .fill("(?, ?)")
766
+ .join(", ");
767
+ const params = [];
768
+ for (const outputId of reservedOutputIds) {
769
+ params.push(this.identity, outputId);
770
+ }
771
+ // Suppress duplicate-PK errors only.
772
+ await conn.query(
773
+ `INSERT INTO token_spent_outputs (user_id, output_id) VALUES ${valueClauses}
774
+ ON DUPLICATE KEY UPDATE output_id = output_id`,
775
+ params
776
+ );
777
+ }
778
+
779
+ await conn.query(
780
+ "DELETE FROM token_outputs WHERE user_id = ? AND reservation_id = ?",
781
+ [this.identity, id]
782
+ );
783
+ await conn.query(
784
+ "DELETE FROM token_reservations WHERE user_id = ? AND id = ?",
785
+ [this.identity, id]
786
+ );
787
+
788
+ // UPSERT so a tenant that joined after the multi-tenant migration
789
+ // (and thus has no row) gets one created lazily.
790
+ if (isSwap) {
791
+ await conn.query(
792
+ `INSERT INTO token_swap_status (user_id, last_completed_at) VALUES (?, NOW(6))
793
+ ON DUPLICATE KEY UPDATE last_completed_at = VALUES(last_completed_at)`,
794
+ [this.identity]
795
+ );
796
+ }
797
+
798
+ await conn.query(
799
+ `DELETE FROM token_metadata
800
+ WHERE user_id = ?
801
+ AND identifier NOT IN (
802
+ SELECT DISTINCT token_identifier FROM token_outputs WHERE user_id = ?
803
+ )`,
804
+ [this.identity, this.identity]
805
+ );
806
+ });
807
+ } catch (error) {
808
+ if (error instanceof TokenStoreError) throw error;
809
+ throw new TokenStoreError(
810
+ `Failed to finalize reservation '${id}': ${error.message}`,
811
+ error
812
+ );
813
+ }
814
+ }
815
+
816
+ async now() {
817
+ try {
818
+ const [rows] = await this.pool.query("SELECT NOW(6) AS now");
819
+ const value = rows[0].now;
820
+ if (value instanceof Date) return value.getTime();
821
+ return new Date(value).getTime();
822
+ } catch (error) {
823
+ if (error instanceof TokenStoreError) throw error;
824
+ throw new TokenStoreError(
825
+ `Failed to get current time: ${error.message}`,
826
+ error
827
+ );
828
+ }
829
+ }
830
+
831
+ // ===== Private Helpers =====
832
+
833
+ _generateId() {
834
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
835
+ return crypto.randomUUID();
836
+ }
837
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
838
+ const r = (Math.random() * 16) | 0;
839
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
840
+ return v.toString(16);
841
+ });
842
+ }
843
+
844
+ /// Cleans up stale reservations for THIS tenant. Releases dependent outputs
845
+ /// by clearing reservation_id first, then deletes the parent rows — the
846
+ /// composite FK uses NO ACTION because column-list SET NULL would null
847
+ /// user_id (NOT NULL).
848
+ async _cleanupStaleReservations(conn) {
849
+ await conn.query(
850
+ `UPDATE token_outputs SET reservation_id = NULL
851
+ WHERE user_id = ?
852
+ AND reservation_id IN (
853
+ SELECT id FROM (
854
+ SELECT id FROM token_reservations
855
+ WHERE user_id = ?
856
+ AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)
857
+ ) AS stale
858
+ )`,
859
+ [this.identity, this.identity, RESERVATION_TIMEOUT_SECS]
860
+ );
861
+ await conn.query(
862
+ `DELETE FROM token_reservations
863
+ WHERE user_id = ? AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)`,
864
+ [this.identity, RESERVATION_TIMEOUT_SECS]
865
+ );
866
+ }
867
+
868
+ async _upsertMetadata(conn, metadata) {
869
+ await conn.query(
870
+ `INSERT INTO token_metadata
871
+ (user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
872
+ is_freezable, creation_entity_public_key)
873
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
874
+ ON DUPLICATE KEY UPDATE
875
+ issuer_public_key = VALUES(issuer_public_key),
876
+ name = VALUES(name),
877
+ ticker = VALUES(ticker),
878
+ decimals = VALUES(decimals),
879
+ max_supply = VALUES(max_supply),
880
+ is_freezable = VALUES(is_freezable),
881
+ creation_entity_public_key = VALUES(creation_entity_public_key)`,
882
+ [
883
+ this.identity,
884
+ metadata.identifier,
885
+ metadata.issuerPublicKey,
886
+ metadata.name,
887
+ metadata.ticker,
888
+ metadata.decimals,
889
+ metadata.maxSupply,
890
+ metadata.isFreezable ? 1 : 0,
891
+ metadata.creationEntityPublicKey || null,
892
+ ]
893
+ );
894
+ }
895
+
896
+ async _insertSingleOutput(conn, tokenIdentifier, output) {
897
+ // ON DUPLICATE KEY UPDATE id = id no-ops on the (user_id, id) primary key
898
+ // conflict only — unlike INSERT IGNORE, FK / NOT NULL / type errors
899
+ // still propagate.
900
+ await conn.query(
901
+ `INSERT INTO token_outputs
902
+ (user_id, id, token_identifier, owner_public_key, revocation_commitment,
903
+ withdraw_bond_sats, withdraw_relative_block_locktime,
904
+ token_public_key, token_amount, prev_tx_hash, prev_tx_vout, added_at)
905
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(6))
906
+ ON DUPLICATE KEY UPDATE id = id`,
907
+ [
908
+ this.identity,
909
+ output.output.id,
910
+ tokenIdentifier,
911
+ output.output.ownerPublicKey,
912
+ output.output.revocationCommitment,
913
+ output.output.withdrawBondSats,
914
+ output.output.withdrawRelativeBlockLocktime,
915
+ output.output.tokenPublicKey || null,
916
+ output.output.tokenAmount,
917
+ output.prevTxHash,
918
+ output.prevTxVout,
919
+ ]
920
+ );
921
+ }
922
+
923
+ _metadataFromRow(row) {
924
+ return {
925
+ identifier: row.identifier,
926
+ issuerPublicKey: row.issuer_public_key,
927
+ name: row.name,
928
+ ticker: row.ticker,
929
+ decimals: row.decimals,
930
+ maxSupply: row.max_supply,
931
+ isFreezable: toBool(row.is_freezable) ?? false,
932
+ creationEntityPublicKey: row.creation_entity_public_key || null,
933
+ };
934
+ }
935
+
936
+ _outputFromRow(row) {
937
+ return {
938
+ output: {
939
+ id: row.output_id,
940
+ ownerPublicKey: row.owner_public_key,
941
+ revocationCommitment: row.revocation_commitment,
942
+ withdrawBondSats: Number(row.withdraw_bond_sats),
943
+ withdrawRelativeBlockLocktime: Number(
944
+ row.withdraw_relative_block_locktime
945
+ ),
946
+ tokenPublicKey: row.token_public_key || null,
947
+ tokenIdentifier: row.token_identifier || row.identifier,
948
+ tokenAmount: row.token_amount,
949
+ },
950
+ prevTxHash: row.prev_tx_hash,
951
+ prevTxVout: row.prev_tx_vout,
952
+ };
953
+ }
954
+ }
955
+
956
+ function createMysqlPool(config) {
957
+ return mysql.createPool({
958
+ uri: config.connectionString,
959
+ connectionLimit: config.maxPoolSize,
960
+ connectTimeout: (config.createTimeoutSecs || 0) * 1000 || 10000,
961
+ idleTimeout: (config.recycleTimeoutSecs || 0) * 1000 || 10000,
962
+ waitForConnections: true,
963
+ });
964
+ }
965
+
966
+ /**
967
+ * @param {object} config - MySQL configuration
968
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
969
+ * identifying the tenant. All reads and writes are scoped by this.
970
+ * @param {object} [logger]
971
+ */
972
+ async function createMysqlTokenStore(config, identity, logger = null) {
973
+ const pool = createMysqlPool(config);
974
+ return createMysqlTokenStoreWithPool(pool, identity, logger);
975
+ }
976
+
977
+ async function createMysqlTokenStoreWithPool(pool, identity, logger = null) {
978
+ const store = new MysqlTokenStore(pool, identity, logger);
979
+ await store.initialize();
980
+ return store;
981
+ }
982
+
983
+ module.exports = {
984
+ MysqlTokenStore,
985
+ createMysqlTokenStore,
986
+ createMysqlTokenStoreWithPool,
987
+ TokenStoreError,
988
+ };