@breeztech/breez-sdk-spark 0.15.1 → 0.17.0

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 (51) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +604 -237
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +729 -434
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +63 -49
  7. package/bundler/storage/index.js +356 -34
  8. package/deno/breez_sdk_spark_wasm.d.ts +604 -237
  9. package/deno/breez_sdk_spark_wasm.js +729 -434
  10. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  11. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +63 -49
  12. package/nodejs/breez_sdk_spark_wasm.d.ts +604 -237
  13. package/nodejs/breez_sdk_spark_wasm.js +741 -441
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  15. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +63 -49
  16. package/nodejs/index.js +10 -10
  17. package/nodejs/index.mjs +13 -8
  18. package/nodejs/mysql-session-store/errors.cjs +13 -0
  19. package/nodejs/{mysql-session-manager → mysql-session-store}/index.cjs +24 -21
  20. package/nodejs/{mysql-session-manager → mysql-session-store}/migrations.cjs +17 -11
  21. package/nodejs/mysql-session-store/package.json +9 -0
  22. package/nodejs/mysql-storage/index.cjs +358 -125
  23. package/nodejs/mysql-storage/migrations.cjs +67 -2
  24. package/nodejs/mysql-token-store/index.cjs +99 -79
  25. package/nodejs/mysql-token-store/migrations.cjs +59 -2
  26. package/nodejs/mysql-tree-store/index.cjs +15 -9
  27. package/nodejs/mysql-tree-store/migrations.cjs +16 -2
  28. package/nodejs/package.json +2 -2
  29. package/nodejs/postgres-session-store/errors.cjs +13 -0
  30. package/nodejs/{postgres-session-manager → postgres-session-store}/index.cjs +23 -23
  31. package/nodejs/{postgres-session-manager → postgres-session-store}/migrations.cjs +14 -14
  32. package/nodejs/postgres-session-store/package.json +9 -0
  33. package/nodejs/postgres-storage/index.cjs +296 -119
  34. package/nodejs/postgres-storage/migrations.cjs +51 -0
  35. package/nodejs/postgres-token-store/index.cjs +89 -64
  36. package/nodejs/postgres-token-store/migrations.cjs +44 -0
  37. package/nodejs/storage/index.cjs +285 -125
  38. package/nodejs/storage/migrations.cjs +47 -0
  39. package/package.json +6 -1
  40. package/ssr/index.js +57 -28
  41. package/web/breez_sdk_spark_wasm.d.ts +667 -286
  42. package/web/breez_sdk_spark_wasm.js +729 -434
  43. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  44. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +63 -49
  45. package/web/passkey-prf-provider/index.d.ts +203 -0
  46. package/web/passkey-prf-provider/index.js +733 -0
  47. package/web/storage/index.js +356 -34
  48. package/nodejs/mysql-session-manager/errors.cjs +0 -13
  49. package/nodejs/mysql-session-manager/package.json +0 -9
  50. package/nodejs/postgres-session-manager/errors.cjs +0 -13
  51. package/nodejs/postgres-session-manager/package.json +0 -9
@@ -16,6 +16,8 @@
16
16
  * → `conn.beginTransaction()`/`conn.commit()`/`conn.rollback()`.
17
17
  */
18
18
 
19
+ const crypto = require("crypto");
20
+
19
21
  let mysql;
20
22
  try {
21
23
  const mainModule = require.main;
@@ -38,6 +40,8 @@ try {
38
40
  const { StorageError } = require("./errors.cjs");
39
41
  const { MysqlMigrationManager } = require("./migrations.cjs");
40
42
 
43
+ const PAYMENT_UPDATE_LOCK_TIMEOUT_SECS = 10;
44
+
41
45
  /**
42
46
  * Base query for payment lookups. All columns are accessed by name in _rowToPayment.
43
47
  * parent_payment_id is only used by getPaymentsByParentIds.
@@ -51,7 +55,8 @@ const SELECT_PAYMENT_SQL = `
51
55
  p.timestamp,
52
56
  p.method,
53
57
  p.withdraw_tx_id,
54
- p.deposit_tx_id,
58
+ pd.tx_id AS deposit_tx_id,
59
+ pd.vout AS deposit_vout,
55
60
  p.spark,
56
61
  l.invoice AS lightning_invoice,
57
62
  l.payment_hash AS lightning_payment_hash,
@@ -79,6 +84,7 @@ const SELECT_PAYMENT_SQL = `
79
84
  LEFT JOIN brz_payment_details_lightning l ON p.id = l.payment_id AND p.user_id = l.user_id
80
85
  LEFT JOIN brz_payment_details_token t ON p.id = t.payment_id AND p.user_id = t.user_id
81
86
  LEFT JOIN brz_payment_details_spark s ON p.id = s.payment_id AND p.user_id = s.user_id
87
+ LEFT JOIN brz_payment_details_deposit pd ON p.id = pd.payment_id AND p.user_id = pd.user_id
82
88
  LEFT JOIN brz_payment_metadata pm ON p.id = pm.payment_id AND p.user_id = pm.user_id
83
89
  LEFT JOIN brz_lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash AND l.user_id = lrm.user_id`;
84
90
 
@@ -99,6 +105,21 @@ function toBool(value) {
99
105
  return value === 1 || value === "1" || value === true;
100
106
  }
101
107
 
108
+ /**
109
+ * Maps a brz_cross_chain_swaps row to the camelCase StoredCrossChainSwap shape
110
+ * the SDK expects, coercing TINYINT(1) to a boolean.
111
+ */
112
+ function crossChainSwapFromRow(row) {
113
+ return {
114
+ provider: row.provider,
115
+ id: row.id,
116
+ isTerminal: toBool(row.is_terminal),
117
+ updatedAt: Number(row.updated_at),
118
+ data: row.data,
119
+ secrets: row.secrets,
120
+ };
121
+ }
122
+
102
123
  class MysqlStorage {
103
124
  /**
104
125
  * @param {import('mysql2/promise').Pool} pool - Connection pool (may be shared with other tenants).
@@ -254,6 +275,17 @@ class MysqlStorage {
254
275
  for (const paymentDetailsFilter of request.paymentDetailsFilter) {
255
276
  const paymentDetailsClauses = [];
256
277
 
278
+ // Base type check: ensure payment type matches the filter type.
279
+ // Token payments are identified by spark = NULL AND a token row
280
+ // joined in via t.tx_hash.
281
+ if (paymentDetailsFilter.type === "spark") {
282
+ paymentDetailsClauses.push("p.spark = 1");
283
+ } else if (paymentDetailsFilter.type === "token") {
284
+ paymentDetailsClauses.push("p.spark IS NULL AND t.tx_hash IS NOT NULL");
285
+ } else if (paymentDetailsFilter.type === "lightning") {
286
+ paymentDetailsClauses.push("l.htlc_status IS NOT NULL");
287
+ }
288
+
257
289
  const htlcAlias =
258
290
  paymentDetailsFilter.type === "spark"
259
291
  ? "s"
@@ -278,23 +310,40 @@ class MysqlStorage {
278
310
  params.push(...paymentDetailsFilter.htlcStatus);
279
311
  }
280
312
 
313
+ // Conversion filter — same enum shape as the Rust storage backend:
314
+ // "ammRefundNeeded" → AMM conversion that needs a clawback
315
+ // "orchestraPending" → Orchestra order not yet terminal
316
+ // "boltzPending" → Boltz reverse swap not yet terminal
317
+ // (lives on the Lightning hold-invoice leg)
281
318
  if (
282
319
  (paymentDetailsFilter.type === "spark" ||
283
- paymentDetailsFilter.type === "token") &&
284
- paymentDetailsFilter.conversionRefundNeeded !== undefined
320
+ paymentDetailsFilter.type === "token" ||
321
+ paymentDetailsFilter.type === "lightning") &&
322
+ paymentDetailsFilter.conversionFilter !== undefined
285
323
  ) {
286
- const typeCheck =
287
- paymentDetailsFilter.type === "spark"
288
- ? "p.spark = 1"
289
- : "p.spark IS NULL";
290
- const refundNeeded =
291
- paymentDetailsFilter.conversionRefundNeeded === true
292
- ? "= 'refundNeeded'"
293
- : "!= 'refundNeeded'";
294
- paymentDetailsClauses.push(
295
- `${typeCheck} AND pm.conversion_info IS NOT NULL AND
296
- JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) ${refundNeeded}`
297
- );
324
+ let statusClause;
325
+ if (paymentDetailsFilter.conversionFilter === "ammRefundNeeded") {
326
+ statusClause =
327
+ "JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.type')) = 'amm' AND \
328
+ JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) = 'refundNeeded'";
329
+ } else if (
330
+ paymentDetailsFilter.conversionFilter === "orchestraPending"
331
+ ) {
332
+ statusClause =
333
+ "JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.type')) = 'orchestra' AND \
334
+ JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) NOT IN ('completed', 'failed', 'refunded')";
335
+ } else if (
336
+ paymentDetailsFilter.conversionFilter === "boltzPending"
337
+ ) {
338
+ statusClause =
339
+ "JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.type')) = 'boltz' AND \
340
+ JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) NOT IN ('completed', 'failed', 'refunded')";
341
+ }
342
+ if (statusClause) {
343
+ paymentDetailsClauses.push(
344
+ `pm.conversion_info IS NOT NULL AND ${statusClause}`
345
+ );
346
+ }
298
347
  }
299
348
 
300
349
  if (
@@ -360,130 +409,237 @@ class MysqlStorage {
360
409
  }
361
410
  }
362
411
 
363
- async insertPayment(payment) {
412
+ async applyPaymentUpdate(payment) {
413
+ if (!payment) {
414
+ throw new StorageError("Payment cannot be null or undefined");
415
+ }
416
+
417
+ let conn = null;
418
+ const lockName = this._paymentUpdateLockName(payment.id);
419
+ let acquired = false;
420
+ let shouldEmit = false;
421
+ let operationError = null;
422
+ let releaseError = null;
423
+
364
424
  try {
365
- if (!payment) {
366
- throw new StorageError("Payment cannot be null or undefined");
425
+ conn = await this.pool.getConnection();
426
+ const [lockRows] = await conn.query("SELECT GET_LOCK(?, ?) AS acquired", [
427
+ lockName,
428
+ PAYMENT_UPDATE_LOCK_TIMEOUT_SECS,
429
+ ]);
430
+ acquired = Number(lockRows?.[0]?.acquired) === 1;
431
+ if (!acquired) {
432
+ throw new StorageError(`Timed out acquiring payment update lock '${lockName}'`);
367
433
  }
368
434
 
369
- await this._withTransaction(async (conn) => {
370
- const withdrawTxId =
371
- payment.details?.type === "withdraw" ? payment.details.txId : null;
372
- const depositTxId =
373
- payment.details?.type === "deposit" ? payment.details.txId : null;
374
- const spark = payment.details?.type === "spark" ? 1 : null;
375
-
376
- await conn.query(
377
- `INSERT INTO brz_payments (user_id, id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark)
378
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
379
- ON DUPLICATE KEY UPDATE
380
- payment_type=VALUES(payment_type),
381
- status=VALUES(status),
382
- amount=VALUES(amount),
383
- fees=VALUES(fees),
384
- timestamp=VALUES(timestamp),
385
- method=VALUES(method),
386
- withdraw_tx_id=VALUES(withdraw_tx_id),
387
- deposit_tx_id=VALUES(deposit_tx_id),
388
- spark=VALUES(spark)`,
389
- [
390
- this.identity,
391
- payment.id,
392
- payment.paymentType,
393
- payment.status,
394
- payment.amount.toString(),
395
- payment.fees.toString(),
396
- payment.timestamp,
397
- payment.method ? JSON.stringify(payment.method) : null,
398
- withdrawTxId,
399
- depositTxId,
400
- spark,
401
- ]
435
+ await conn.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
436
+ await conn.beginTransaction();
437
+ try {
438
+ const [rows] = await conn.query(
439
+ "SELECT status FROM brz_payments WHERE user_id = ? AND id = ? FOR UPDATE",
440
+ [this.identity, payment.id]
402
441
  );
403
-
404
- if (
405
- payment.details?.type === "spark" &&
406
- (payment.details.invoiceDetails != null ||
407
- payment.details.htlcDetails != null)
408
- ) {
409
- await conn.query(
410
- `INSERT INTO brz_payment_details_spark (user_id, payment_id, invoice_details, htlc_details)
411
- VALUES (?, ?, ?, ?)
412
- ON DUPLICATE KEY UPDATE
413
- invoice_details=COALESCE(VALUES(invoice_details), invoice_details),
414
- htlc_details=COALESCE(VALUES(htlc_details), htlc_details)`,
415
- [
416
- this.identity,
417
- payment.id,
418
- payment.details.invoiceDetails
419
- ? JSON.stringify(payment.details.invoiceDetails)
420
- : null,
421
- payment.details.htlcDetails
422
- ? JSON.stringify(payment.details.htlcDetails)
423
- : null,
424
- ]
442
+ const stored = rows.length > 0
443
+ ? this._normalizePaymentStatus(rows[0].status)
444
+ : null;
445
+ const next = this._normalizePaymentStatus(payment.status);
446
+
447
+ if (stored == null) {
448
+ await this._runPaymentUpsert(conn, payment);
449
+ shouldEmit = true;
450
+ } else if (stored === next) {
451
+ console.debug(
452
+ `Skipping redundant payment event: id=${payment.id} status=${next}`
425
453
  );
426
- }
427
-
428
- if (payment.details?.type === "lightning") {
429
- await conn.query(
430
- `INSERT INTO brz_payment_details_lightning
431
- (user_id, payment_id, invoice, payment_hash, destination_pubkey, description, preimage, htlc_status, htlc_expiry_time)
432
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
433
- ON DUPLICATE KEY UPDATE
434
- invoice=VALUES(invoice),
435
- payment_hash=VALUES(payment_hash),
436
- destination_pubkey=VALUES(destination_pubkey),
437
- description=VALUES(description),
438
- preimage=COALESCE(VALUES(preimage), preimage),
439
- htlc_status=COALESCE(VALUES(htlc_status), htlc_status),
440
- htlc_expiry_time=COALESCE(VALUES(htlc_expiry_time), htlc_expiry_time)`,
441
- [
442
- this.identity,
443
- payment.id,
444
- payment.details.invoice,
445
- payment.details.htlcDetails.paymentHash,
446
- payment.details.destinationPubkey,
447
- payment.details.description,
448
- payment.details.htlcDetails?.preimage,
449
- payment.details.htlcDetails?.status ?? null,
450
- payment.details.htlcDetails?.expiryTime ?? 0,
451
- ]
454
+ await this._runPaymentUpsert(conn, payment);
455
+ shouldEmit = false;
456
+ } else if (this._isFinalPaymentStatus(stored)) {
457
+ console.warn(
458
+ `Skipping payment update (would replace terminal status): id=${payment.id} stored=${stored} new=${next}`
452
459
  );
460
+ shouldEmit = false;
461
+ } else {
462
+ await this._runPaymentUpsert(conn, payment);
463
+ shouldEmit = true;
453
464
  }
454
465
 
455
- if (payment.details?.type === "token") {
456
- await conn.query(
457
- `INSERT INTO brz_payment_details_token
458
- (user_id, payment_id, metadata, tx_hash, tx_type, invoice_details)
459
- VALUES (?, ?, ?, ?, ?, ?)
460
- ON DUPLICATE KEY UPDATE
461
- metadata=VALUES(metadata),
462
- tx_hash=VALUES(tx_hash),
463
- tx_type=VALUES(tx_type),
464
- invoice_details=COALESCE(VALUES(invoice_details), invoice_details)`,
465
- [
466
- this.identity,
467
- payment.id,
468
- JSON.stringify(payment.details.metadata),
469
- payment.details.txHash,
470
- payment.details.txType,
471
- payment.details.invoiceDetails
472
- ? JSON.stringify(payment.details.invoiceDetails)
473
- : null,
474
- ]
475
- );
476
- }
477
- });
466
+ await conn.commit();
467
+ } catch (error) {
468
+ await conn.rollback().catch(() => {});
469
+ throw error;
470
+ }
478
471
  } catch (error) {
479
- if (error instanceof StorageError) throw error;
472
+ operationError = error;
473
+ } finally {
474
+ if (conn && acquired) {
475
+ try {
476
+ await conn.query("SELECT RELEASE_LOCK(?)", [lockName]);
477
+ } catch (error) {
478
+ releaseError = error;
479
+ }
480
+ }
481
+ if (conn) {
482
+ conn.release();
483
+ }
484
+ }
485
+
486
+ if (operationError) {
487
+ if (operationError instanceof StorageError) {
488
+ throw operationError;
489
+ }
480
490
  throw new StorageError(
481
- `Failed to insert payment '${payment.id}': ${error.message}`,
482
- error
491
+ `Failed to apply payment update '${payment.id}': ${operationError.message}`,
492
+ operationError
483
493
  );
484
494
  }
495
+
496
+ if (releaseError) {
497
+ throw new StorageError(
498
+ `Failed to release payment update lock '${lockName}': ${releaseError.message}`,
499
+ releaseError
500
+ );
501
+ }
502
+
503
+ return shouldEmit;
485
504
  }
486
505
 
506
+ async _runPaymentUpsert(conn, payment) {
507
+ const withdrawTxId =
508
+ payment.details?.type === "withdraw" ? payment.details.txId : null;
509
+ const spark = payment.details?.type === "spark" ? 1 : null;
510
+
511
+ await conn.query(
512
+ `INSERT INTO brz_payments (user_id, id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, spark)
513
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
514
+ ON DUPLICATE KEY UPDATE
515
+ payment_type=VALUES(payment_type),
516
+ status=VALUES(status),
517
+ amount=VALUES(amount),
518
+ fees=VALUES(fees),
519
+ timestamp=VALUES(timestamp),
520
+ method=VALUES(method),
521
+ withdraw_tx_id=VALUES(withdraw_tx_id),
522
+ spark=VALUES(spark)`,
523
+ [
524
+ this.identity,
525
+ payment.id,
526
+ payment.paymentType,
527
+ payment.status,
528
+ payment.amount.toString(),
529
+ payment.fees.toString(),
530
+ payment.timestamp,
531
+ payment.method ? JSON.stringify(payment.method) : null,
532
+ withdrawTxId,
533
+ spark,
534
+ ]
535
+ );
536
+
537
+ if (payment.details?.type === "deposit") {
538
+ await conn.query(
539
+ `INSERT INTO brz_payment_details_deposit (user_id, payment_id, tx_id, vout)
540
+ VALUES (?, ?, ?, ?)
541
+ ON DUPLICATE KEY UPDATE
542
+ tx_id=VALUES(tx_id),
543
+ vout=VALUES(vout)`,
544
+ [this.identity, payment.id, payment.details.txId, payment.details.vout]
545
+ );
546
+ }
547
+
548
+ if (
549
+ payment.details?.type === "spark" &&
550
+ (payment.details.invoiceDetails != null ||
551
+ payment.details.htlcDetails != null)
552
+ ) {
553
+ await conn.query(
554
+ `INSERT INTO brz_payment_details_spark (user_id, payment_id, invoice_details, htlc_details)
555
+ VALUES (?, ?, ?, ?)
556
+ ON DUPLICATE KEY UPDATE
557
+ invoice_details=COALESCE(VALUES(invoice_details), invoice_details),
558
+ htlc_details=COALESCE(VALUES(htlc_details), htlc_details)`,
559
+ [
560
+ this.identity,
561
+ payment.id,
562
+ payment.details.invoiceDetails
563
+ ? JSON.stringify(payment.details.invoiceDetails)
564
+ : null,
565
+ payment.details.htlcDetails
566
+ ? JSON.stringify(payment.details.htlcDetails)
567
+ : null,
568
+ ]
569
+ );
570
+ }
571
+
572
+ if (payment.details?.type === "lightning") {
573
+ await conn.query(
574
+ `INSERT INTO brz_payment_details_lightning
575
+ (user_id, payment_id, invoice, payment_hash, destination_pubkey, description, preimage, htlc_status, htlc_expiry_time)
576
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
577
+ ON DUPLICATE KEY UPDATE
578
+ invoice=VALUES(invoice),
579
+ payment_hash=VALUES(payment_hash),
580
+ destination_pubkey=VALUES(destination_pubkey),
581
+ description=VALUES(description),
582
+ preimage=COALESCE(VALUES(preimage), preimage),
583
+ htlc_status=COALESCE(VALUES(htlc_status), htlc_status),
584
+ htlc_expiry_time=COALESCE(VALUES(htlc_expiry_time), htlc_expiry_time)`,
585
+ [
586
+ this.identity,
587
+ payment.id,
588
+ payment.details.invoice,
589
+ payment.details.htlcDetails.paymentHash,
590
+ payment.details.destinationPubkey,
591
+ payment.details.description,
592
+ payment.details.htlcDetails?.preimage,
593
+ payment.details.htlcDetails?.status ?? null,
594
+ payment.details.htlcDetails?.expiryTime ?? 0,
595
+ ]
596
+ );
597
+ }
598
+
599
+ if (payment.details?.type === "token") {
600
+ await conn.query(
601
+ `INSERT INTO brz_payment_details_token
602
+ (user_id, payment_id, metadata, tx_hash, tx_type, invoice_details)
603
+ VALUES (?, ?, ?, ?, ?, ?)
604
+ ON DUPLICATE KEY UPDATE
605
+ metadata=VALUES(metadata),
606
+ tx_hash=VALUES(tx_hash),
607
+ tx_type=VALUES(tx_type),
608
+ invoice_details=COALESCE(VALUES(invoice_details), invoice_details)`,
609
+ [
610
+ this.identity,
611
+ payment.id,
612
+ JSON.stringify(payment.details.metadata),
613
+ payment.details.txHash,
614
+ payment.details.txType,
615
+ payment.details.invoiceDetails
616
+ ? JSON.stringify(payment.details.invoiceDetails)
617
+ : null,
618
+ ]
619
+ );
620
+ }
621
+ }
622
+
623
+ _paymentUpdateLockName(paymentId) {
624
+ return crypto
625
+ .createHash("sha256")
626
+ .update("brz_payment_update")
627
+ .update(this.identity)
628
+ .update(Buffer.from(paymentId))
629
+ .digest("hex")
630
+ .slice(0, 32);
631
+ }
632
+
633
+ _normalizePaymentStatus(status) {
634
+ return typeof status === "string" ? status.toLowerCase() : status;
635
+ }
636
+
637
+ _isFinalPaymentStatus(status) {
638
+ const normalized = this._normalizePaymentStatus(status);
639
+ return normalized === "completed" || normalized === "failed";
640
+ }
641
+
642
+
487
643
  async getPaymentById(id) {
488
644
  try {
489
645
  if (!id) {
@@ -764,6 +920,10 @@ class MysqlStorage {
764
920
  senderComment: row.lnurl_sender_comment || null,
765
921
  };
766
922
  }
923
+
924
+ if (row.conversion_info) {
925
+ details.conversionInfo = parseJson(row.conversion_info);
926
+ }
767
927
  } else if (row.withdraw_tx_id) {
768
928
  details = {
769
929
  type: "withdraw",
@@ -773,6 +933,7 @@ class MysqlStorage {
773
933
  details = {
774
934
  type: "deposit",
775
935
  txId: row.deposit_tx_id,
936
+ vout: Number(row.deposit_vout),
776
937
  };
777
938
  } else if (toBool(row.spark)) {
778
939
  details = {
@@ -916,6 +1077,74 @@ class MysqlStorage {
916
1077
  }
917
1078
  }
918
1079
 
1080
+ // ===== Cross-Chain Swap Operations =====
1081
+
1082
+ async setCrossChainSwap(swap) {
1083
+ try {
1084
+ await this.pool.query(
1085
+ `INSERT INTO brz_cross_chain_swaps
1086
+ (user_id, provider, id, is_terminal, updated_at, data, secrets)
1087
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1088
+ ON DUPLICATE KEY UPDATE
1089
+ is_terminal = VALUES(is_terminal),
1090
+ updated_at = VALUES(updated_at),
1091
+ data = VALUES(data),
1092
+ secrets = VALUES(secrets)`,
1093
+ [
1094
+ this.identity,
1095
+ swap.provider,
1096
+ swap.id,
1097
+ swap.isTerminal ? 1 : 0,
1098
+ swap.updatedAt,
1099
+ swap.data,
1100
+ swap.secrets,
1101
+ ]
1102
+ );
1103
+ } catch (error) {
1104
+ throw new StorageError(
1105
+ `Failed to set cross-chain swap: ${error.message}`,
1106
+ error
1107
+ );
1108
+ }
1109
+ }
1110
+
1111
+ async getCrossChainSwap(provider, id) {
1112
+ try {
1113
+ const [rows] = await this.pool.query(
1114
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1115
+ FROM brz_cross_chain_swaps
1116
+ WHERE user_id = ? AND provider = ? AND id = ?`,
1117
+ [this.identity, provider, id]
1118
+ );
1119
+ if (rows.length === 0) {
1120
+ return null;
1121
+ }
1122
+ return crossChainSwapFromRow(rows[0]);
1123
+ } catch (error) {
1124
+ throw new StorageError(
1125
+ `Failed to get cross-chain swap: ${error.message}`,
1126
+ error
1127
+ );
1128
+ }
1129
+ }
1130
+
1131
+ async listActiveCrossChainSwaps(provider) {
1132
+ try {
1133
+ const [rows] = await this.pool.query(
1134
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1135
+ FROM brz_cross_chain_swaps
1136
+ WHERE user_id = ? AND provider = ? AND is_terminal = FALSE`,
1137
+ [this.identity, provider]
1138
+ );
1139
+ return rows.map(crossChainSwapFromRow);
1140
+ } catch (error) {
1141
+ throw new StorageError(
1142
+ `Failed to list active cross-chain swaps: ${error.message}`,
1143
+ error
1144
+ );
1145
+ }
1146
+ }
1147
+
919
1148
  // ===== Sync Operations =====
920
1149
 
921
1150
  async syncAddOutgoingChange(record) {
@@ -1332,6 +1561,10 @@ function createMysqlPool(config) {
1332
1561
  connectTimeout: (config.createTimeoutSecs || 0) * 1000 || 10000,
1333
1562
  idleTimeout: (config.recycleTimeoutSecs || 0) * 1000 || 10000,
1334
1563
  waitForConnections: true,
1564
+ // Serialize JS `Date` parameters as UTC strings rather than host-local
1565
+ // time. Paired with explicit `UTC_TIMESTAMP(6)` on the server side, this
1566
+ // keeps timestamp comparisons consistent regardless of the host TZ.
1567
+ timezone: "Z",
1335
1568
  });
1336
1569
  }
1337
1570
 
@@ -89,7 +89,7 @@ class MysqlMigrationManager {
89
89
  await conn.query(`
90
90
  CREATE TABLE IF NOT EXISTS brz_schema_migrations (
91
91
  version INT PRIMARY KEY,
92
- applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
92
+ applied_at DATETIME(6) NOT NULL DEFAULT (UTC_TIMESTAMP(6))
93
93
  )
94
94
  `);
95
95
 
@@ -121,7 +121,7 @@ class MysqlMigrationManager {
121
121
  }
122
122
 
123
123
  await conn.query(
124
- "INSERT INTO brz_schema_migrations (version) VALUES (?)",
124
+ "INSERT INTO brz_schema_migrations (version, applied_at) VALUES (?, UTC_TIMESTAMP(6))",
125
125
  [version]
126
126
  );
127
127
  }
@@ -497,6 +497,71 @@ class MysqlMigrationManager {
497
497
  ON brz_sync_incoming(user_id, revision)`,
498
498
  ],
499
499
  },
500
+ {
501
+ // Pin the migration-tracking table's `applied_at` default to UTC.
502
+ // The migration manager already passes `UTC_TIMESTAMP(6)` explicitly
503
+ // on INSERT, but aligning the default keeps `SHOW CREATE TABLE`
504
+ // output consistent with the token-store / tree-store migrations
505
+ // table and avoids future mistakes if a callsite omits the column.
506
+ name: "Pin schema-migrations applied_at default to UTC",
507
+ sql: [
508
+ `ALTER TABLE brz_schema_migrations MODIFY COLUMN applied_at DATETIME(6) NOT NULL DEFAULT (UTC_TIMESTAMP(6))`,
509
+ ],
510
+ },
511
+ {
512
+ // Move deposit details into their own table so vout can be NOT NULL and
513
+ // the schema matches brz_payment_details_lightning / _token / _spark. We
514
+ // can't safely backfill the new table from the dropped deposit_tx_id
515
+ // column: we never stored the original SSP output_index, and vout=0 is a
516
+ // valid output index, so defaulting would silently mislabel. Drop the
517
+ // column and leave the brz_payments row in place. The read path sees an
518
+ // unjoined deposit row as `details: None` until the resync re-fetches the
519
+ // SSP user_request and the upsert inserts the new details row.
520
+ name: "Move deposit details into brz_payment_details_deposit table",
521
+ sql: [
522
+ `CREATE TABLE IF NOT EXISTS brz_payment_details_deposit (
523
+ user_id VARBINARY(33) NOT NULL,
524
+ payment_id VARCHAR(255) NOT NULL,
525
+ tx_id VARCHAR(255) NOT NULL,
526
+ vout INT UNSIGNED NOT NULL,
527
+ PRIMARY KEY (user_id, payment_id)
528
+ )`,
529
+ `ALTER TABLE brz_payments DROP COLUMN deposit_tx_id`,
530
+ `UPDATE brz_settings
531
+ SET value = JSON_SET(value, '$.offset', 0)
532
+ WHERE \`key\` = 'sync_offset' AND value IS NOT NULL`,
533
+ ],
534
+ },
535
+ {
536
+ // Backfill the conversion_info `type` discriminator for the
537
+ // ConversionInfo enum refactor. All existing rows are AMM.
538
+ // Mirrors the Rust mysql migration of the same name.
539
+ name: "Backfill conversion_info type discriminator",
540
+ sql: [
541
+ `UPDATE brz_payment_metadata SET conversion_info = JSON_SET(conversion_info, '$.type', 'amm') WHERE conversion_info IS NOT NULL AND JSON_EXTRACT(conversion_info, '$.type') IS NULL`,
542
+ ],
543
+ },
544
+ {
545
+ // Cross-chain swap rows, synced for cross-instance recovery. Shared
546
+ // across providers, discriminated by `provider`. Born multi-tenant
547
+ // (user_id in the PK). `data` is provider-opaque JSON; `secrets` is
548
+ // provider-opaque ciphertext (empty when the provider has none).
549
+ name: "Create brz_cross_chain_swaps table",
550
+ sql: [
551
+ `CREATE TABLE IF NOT EXISTS brz_cross_chain_swaps (
552
+ user_id VARBINARY(33) NOT NULL,
553
+ provider VARCHAR(64) NOT NULL,
554
+ id VARCHAR(255) NOT NULL,
555
+ is_terminal TINYINT(1) NOT NULL,
556
+ updated_at BIGINT NOT NULL,
557
+ data LONGTEXT NOT NULL,
558
+ secrets LONGTEXT NOT NULL,
559
+ PRIMARY KEY (user_id, provider, id),
560
+ INDEX brz_idx_cross_chain_swaps_user_provider_is_terminal
561
+ (user_id, provider, is_terminal)
562
+ )`,
563
+ ],
564
+ },
500
565
  ];
501
566
  }
502
567
  }