@breeztech/breez-sdk-spark 0.16.1-dev1 → 0.17.1

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.
@@ -105,6 +105,21 @@ function toBool(value) {
105
105
  return value === 1 || value === "1" || value === true;
106
106
  }
107
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
+
108
123
  class MysqlStorage {
109
124
  /**
110
125
  * @param {import('mysql2/promise').Pool} pool - Connection pool (may be shared with other tenants).
@@ -260,6 +275,17 @@ class MysqlStorage {
260
275
  for (const paymentDetailsFilter of request.paymentDetailsFilter) {
261
276
  const paymentDetailsClauses = [];
262
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
+
263
289
  const htlcAlias =
264
290
  paymentDetailsFilter.type === "spark"
265
291
  ? "s"
@@ -284,23 +310,40 @@ class MysqlStorage {
284
310
  params.push(...paymentDetailsFilter.htlcStatus);
285
311
  }
286
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)
287
318
  if (
288
319
  (paymentDetailsFilter.type === "spark" ||
289
- paymentDetailsFilter.type === "token") &&
290
- paymentDetailsFilter.conversionRefundNeeded !== undefined
320
+ paymentDetailsFilter.type === "token" ||
321
+ paymentDetailsFilter.type === "lightning") &&
322
+ paymentDetailsFilter.conversionFilter !== undefined
291
323
  ) {
292
- const typeCheck =
293
- paymentDetailsFilter.type === "spark"
294
- ? "p.spark = 1"
295
- : "p.spark IS NULL";
296
- const refundNeeded =
297
- paymentDetailsFilter.conversionRefundNeeded === true
298
- ? "= 'refundNeeded'"
299
- : "!= 'refundNeeded'";
300
- paymentDetailsClauses.push(
301
- `${typeCheck} AND pm.conversion_info IS NOT NULL AND
302
- JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) ${refundNeeded}`
303
- );
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
+ }
304
347
  }
305
348
 
306
349
  if (
@@ -877,6 +920,10 @@ class MysqlStorage {
877
920
  senderComment: row.lnurl_sender_comment || null,
878
921
  };
879
922
  }
923
+
924
+ if (row.conversion_info) {
925
+ details.conversionInfo = parseJson(row.conversion_info);
926
+ }
880
927
  } else if (row.withdraw_tx_id) {
881
928
  details = {
882
929
  type: "withdraw",
@@ -1030,6 +1077,74 @@ class MysqlStorage {
1030
1077
  }
1031
1078
  }
1032
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
+
1033
1148
  // ===== Sync Operations =====
1034
1149
 
1035
1150
  async syncAddOutgoingChange(record) {
@@ -513,7 +513,7 @@ class MysqlMigrationManager {
513
513
  // the schema matches brz_payment_details_lightning / _token / _spark. We
514
514
  // can't safely backfill the new table from the dropped deposit_tx_id
515
515
  // column: we never stored the original SSP output_index, and vout=0 is a
516
- // valid output index defaulting would silently mislabel. Drop the
516
+ // valid output index, so defaulting would silently mislabel. Drop the
517
517
  // column and leave the brz_payments row in place. The read path sees an
518
518
  // unjoined deposit row as `details: None` until the resync re-fetches the
519
519
  // SSP user_request and the upsert inserts the new details row.
@@ -532,6 +532,36 @@ class MysqlMigrationManager {
532
532
  WHERE \`key\` = 'sync_offset' AND value IS NOT NULL`,
533
533
  ],
534
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
+ },
535
565
  ];
536
566
  }
537
567
  }
@@ -239,6 +239,15 @@ class PostgresStorage {
239
239
  for (const paymentDetailsFilter of request.paymentDetailsFilter) {
240
240
  const paymentDetailsClauses = [];
241
241
 
242
+ // Base type check: ensure payment type matches the filter type
243
+ if (paymentDetailsFilter.type === "spark") {
244
+ paymentDetailsClauses.push("p.spark = true");
245
+ } else if (paymentDetailsFilter.type === "token") {
246
+ paymentDetailsClauses.push("p.spark IS NULL AND t.tx_hash IS NOT NULL");
247
+ } else if (paymentDetailsFilter.type === "lightning") {
248
+ paymentDetailsClauses.push("l.htlc_status IS NOT NULL");
249
+ }
250
+
242
251
  // Filter by HTLC status (Spark or Lightning)
243
252
  const htlcAlias =
244
253
  paymentDetailsFilter.type === "spark"
@@ -266,24 +275,35 @@ class PostgresStorage {
266
275
  params.push(...paymentDetailsFilter.htlcStatus);
267
276
  }
268
277
 
269
- // Filter by conversion refund needed
278
+ // Filter by conversion type + status
270
279
  if (
271
280
  (paymentDetailsFilter.type === "spark" ||
272
- paymentDetailsFilter.type === "token") &&
273
- paymentDetailsFilter.conversionRefundNeeded !== undefined
281
+ paymentDetailsFilter.type === "token" ||
282
+ paymentDetailsFilter.type === "lightning") &&
283
+ paymentDetailsFilter.conversionFilter !== undefined
274
284
  ) {
285
+ // Lightning carries the Boltz conversion; its `conversion_info.type`
286
+ // is the authoritative discriminator, so no payment-type column check.
275
287
  const typeCheck =
276
288
  paymentDetailsFilter.type === "spark"
277
289
  ? "p.spark = true"
278
- : "p.spark IS NULL";
279
- const refundNeeded =
280
- paymentDetailsFilter.conversionRefundNeeded === true
281
- ? "= 'refundNeeded'"
282
- : "!= 'refundNeeded'";
283
- paymentDetailsClauses.push(
284
- `${typeCheck} AND pm.conversion_info IS NOT NULL AND
285
- pm.conversion_info::jsonb->>'status' ${refundNeeded}`
286
- );
290
+ : paymentDetailsFilter.type === "token"
291
+ ? "p.spark IS NULL"
292
+ : null;
293
+ let statusClause;
294
+ if (paymentDetailsFilter.conversionFilter === "ammRefundNeeded") {
295
+ statusClause = "pm.conversion_info::jsonb->>'type' = 'amm' AND pm.conversion_info::jsonb->>'status' = 'refundNeeded'";
296
+ } else if (paymentDetailsFilter.conversionFilter === "orchestraPending") {
297
+ statusClause = "pm.conversion_info::jsonb->>'type' = 'orchestra' AND pm.conversion_info::jsonb->>'status' NOT IN ('completed', 'failed', 'refunded')";
298
+ } else if (paymentDetailsFilter.conversionFilter === "boltzPending") {
299
+ statusClause = "pm.conversion_info::jsonb->>'type' = 'boltz' AND pm.conversion_info::jsonb->>'status' NOT IN ('completed', 'failed', 'refunded')";
300
+ }
301
+ if (statusClause) {
302
+ const prefix = typeCheck ? `${typeCheck} AND ` : "";
303
+ paymentDetailsClauses.push(
304
+ `${prefix}pm.conversion_info IS NOT NULL AND ${statusClause}`
305
+ );
306
+ }
287
307
  }
288
308
 
289
309
  // Filter by token transaction hash
@@ -835,6 +855,13 @@ class PostgresStorage {
835
855
  senderComment: row.lnurl_sender_comment || null,
836
856
  };
837
857
  }
858
+
859
+ if (row.conversion_info) {
860
+ details.conversionInfo =
861
+ typeof row.conversion_info === "string"
862
+ ? JSON.parse(row.conversion_info)
863
+ : row.conversion_info;
864
+ }
838
865
  } else if (row.withdraw_tx_id) {
839
866
  details = {
840
867
  type: "withdraw",
@@ -1017,6 +1044,74 @@ class PostgresStorage {
1017
1044
  }
1018
1045
  }
1019
1046
 
1047
+ // ===== Cross-Chain Swap Operations =====
1048
+
1049
+ async setCrossChainSwap(swap) {
1050
+ try {
1051
+ await this.pool.query(
1052
+ `INSERT INTO brz_cross_chain_swaps
1053
+ (user_id, provider, id, is_terminal, updated_at, data, secrets)
1054
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
1055
+ ON CONFLICT(user_id, provider, id) DO UPDATE SET
1056
+ is_terminal = EXCLUDED.is_terminal,
1057
+ updated_at = EXCLUDED.updated_at,
1058
+ data = EXCLUDED.data,
1059
+ secrets = EXCLUDED.secrets`,
1060
+ [
1061
+ this.identity,
1062
+ swap.provider,
1063
+ swap.id,
1064
+ swap.isTerminal,
1065
+ swap.updatedAt,
1066
+ swap.data,
1067
+ swap.secrets,
1068
+ ]
1069
+ );
1070
+ } catch (error) {
1071
+ throw new StorageError(
1072
+ `Failed to set cross-chain swap: ${error.message}`,
1073
+ error
1074
+ );
1075
+ }
1076
+ }
1077
+
1078
+ async getCrossChainSwap(provider, id) {
1079
+ try {
1080
+ const result = await this.pool.query(
1081
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1082
+ FROM brz_cross_chain_swaps
1083
+ WHERE user_id = $1 AND provider = $2 AND id = $3`,
1084
+ [this.identity, provider, id]
1085
+ );
1086
+ if (result.rows.length === 0) {
1087
+ return null;
1088
+ }
1089
+ return crossChainSwapFromRow(result.rows[0]);
1090
+ } catch (error) {
1091
+ throw new StorageError(
1092
+ `Failed to get cross-chain swap: ${error.message}`,
1093
+ error
1094
+ );
1095
+ }
1096
+ }
1097
+
1098
+ async listActiveCrossChainSwaps(provider) {
1099
+ try {
1100
+ const result = await this.pool.query(
1101
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1102
+ FROM brz_cross_chain_swaps
1103
+ WHERE user_id = $1 AND provider = $2 AND is_terminal = FALSE`,
1104
+ [this.identity, provider]
1105
+ );
1106
+ return result.rows.map(crossChainSwapFromRow);
1107
+ } catch (error) {
1108
+ throw new StorageError(
1109
+ `Failed to list active cross-chain swaps: ${error.message}`,
1110
+ error
1111
+ );
1112
+ }
1113
+ }
1114
+
1020
1115
  // ===== Sync Operations =====
1021
1116
 
1022
1117
  async syncAddOutgoingChange(record) {
@@ -1443,6 +1538,21 @@ class PostgresStorage {
1443
1538
  }
1444
1539
  }
1445
1540
 
1541
+ /**
1542
+ * Maps a brz_cross_chain_swaps row to the camelCase StoredCrossChainSwap shape
1543
+ * the SDK expects.
1544
+ */
1545
+ function crossChainSwapFromRow(row) {
1546
+ return {
1547
+ provider: row.provider,
1548
+ id: row.id,
1549
+ isTerminal: row.is_terminal,
1550
+ updatedAt: Number(row.updated_at),
1551
+ data: row.data,
1552
+ secrets: row.secrets,
1553
+ };
1554
+ }
1555
+
1446
1556
  /**
1447
1557
  * Creates a PostgresStorageConfig with the given connection string and default pool settings.
1448
1558
  *
@@ -472,7 +472,7 @@ class PostgresMigrationManager {
472
472
  // the schema matches brz_payment_details_lightning / _token / _spark. We
473
473
  // can't safely backfill the new table from the dropped deposit_tx_id
474
474
  // column: we never stored the original SSP output_index, and vout=0 is a
475
- // valid output index defaulting would silently mislabel. Drop the
475
+ // valid output index, so defaulting would silently mislabel. Drop the
476
476
  // column and leave the brz_payments row in place. The read path sees an
477
477
  // unjoined deposit row as `details: None` until the resync re-fetches the
478
478
  // SSP user_request and the upsert inserts the new details row.
@@ -491,6 +491,33 @@ class PostgresMigrationManager {
491
491
  WHERE key = 'sync_offset' AND value IS NOT NULL`,
492
492
  ],
493
493
  },
494
+ {
495
+ name: "Backfill conversion_info type discriminator",
496
+ sql: [
497
+ `UPDATE brz_payment_metadata SET conversion_info = conversion_info::jsonb || '{"type": "amm"}'::jsonb WHERE conversion_info IS NOT NULL AND conversion_info::jsonb->>'type' IS NULL`,
498
+ ],
499
+ },
500
+ {
501
+ // Cross-chain swap rows, synced for cross-instance recovery. Shared
502
+ // across providers, discriminated by `provider`. Born multi-tenant
503
+ // (user_id in the PK). `data` is provider-opaque JSON; `secrets` is
504
+ // provider-opaque ciphertext (empty when the provider has none).
505
+ name: "Create brz_cross_chain_swaps table",
506
+ sql: [
507
+ `CREATE TABLE IF NOT EXISTS brz_cross_chain_swaps (
508
+ user_id BYTEA NOT NULL,
509
+ provider TEXT NOT NULL,
510
+ id TEXT NOT NULL,
511
+ is_terminal BOOLEAN NOT NULL,
512
+ updated_at BIGINT NOT NULL,
513
+ data TEXT NOT NULL,
514
+ secrets TEXT NOT NULL,
515
+ PRIMARY KEY (user_id, provider, id)
516
+ )`,
517
+ `CREATE INDEX brz_idx_cross_chain_swaps_user_provider_is_terminal
518
+ ON brz_cross_chain_swaps(user_id, provider, is_terminal)`,
519
+ ],
520
+ },
494
521
  ];
495
522
  }
496
523
  }
@@ -206,6 +206,14 @@ class SqliteStorage {
206
206
  const allPaymentDetailsClauses = [];
207
207
  for (const paymentDetailsFilter of request.paymentDetailsFilter) {
208
208
  const paymentDetailsClauses = [];
209
+ // Base type check: ensure payment type matches the filter type
210
+ if (paymentDetailsFilter.type === "spark") {
211
+ paymentDetailsClauses.push("p.spark = 1");
212
+ } else if (paymentDetailsFilter.type === "token") {
213
+ paymentDetailsClauses.push("p.spark IS NULL AND t.tx_hash IS NOT NULL");
214
+ } else if (paymentDetailsFilter.type === "lightning") {
215
+ paymentDetailsClauses.push("l.htlc_status IS NOT NULL");
216
+ }
209
217
  // Filter by HTLC status (Spark or Lightning)
210
218
  const htlcAlias =
211
219
  paymentDetailsFilter.type === "spark" ? "s"
@@ -232,20 +240,35 @@ class SqliteStorage {
232
240
  }
233
241
  params.push(...paymentDetailsFilter.htlcStatus);
234
242
  }
235
- // Filter by token conversion info presence
243
+ // Filter by conversion type + status
236
244
  if (
237
- (paymentDetailsFilter.type === "spark" || paymentDetailsFilter.type === "token") &&
238
- paymentDetailsFilter.conversionRefundNeeded !== undefined
245
+ (paymentDetailsFilter.type === "spark" ||
246
+ paymentDetailsFilter.type === "token" ||
247
+ paymentDetailsFilter.type === "lightning") &&
248
+ paymentDetailsFilter.conversionFilter !== undefined
239
249
  ) {
240
- const typeCheck = paymentDetailsFilter.type === "spark" ? "p.spark = 1" : "p.spark IS NULL";
241
- const refundNeeded =
242
- paymentDetailsFilter.conversionRefundNeeded === true
243
- ? "= 'refundNeeded'"
244
- : "!= 'refundNeeded'";
245
- paymentDetailsClauses.push(
246
- `${typeCheck} AND pm.conversion_info IS NOT NULL AND
247
- json_extract(pm.conversion_info, '$.status') ${refundNeeded}`
248
- );
250
+ // Lightning carries the Boltz conversion; its `conversion_info.type`
251
+ // is the authoritative discriminator, so no payment-type column check.
252
+ const typeCheck =
253
+ paymentDetailsFilter.type === "spark"
254
+ ? "p.spark = 1"
255
+ : paymentDetailsFilter.type === "token"
256
+ ? "p.spark IS NULL"
257
+ : null;
258
+ let statusClause;
259
+ if (paymentDetailsFilter.conversionFilter === "ammRefundNeeded") {
260
+ statusClause = "json_extract(pm.conversion_info, '$.type') = 'amm' AND json_extract(pm.conversion_info, '$.status') = 'refundNeeded'";
261
+ } else if (paymentDetailsFilter.conversionFilter === "orchestraPending") {
262
+ statusClause = "json_extract(pm.conversion_info, '$.type') = 'orchestra' AND json_extract(pm.conversion_info, '$.status') NOT IN ('completed', 'failed', 'refunded')";
263
+ } else if (paymentDetailsFilter.conversionFilter === "boltzPending") {
264
+ statusClause = "json_extract(pm.conversion_info, '$.type') = 'boltz' AND json_extract(pm.conversion_info, '$.status') NOT IN ('completed', 'failed', 'refunded')";
265
+ }
266
+ if (statusClause) {
267
+ const prefix = typeCheck ? `${typeCheck} AND ` : "";
268
+ paymentDetailsClauses.push(
269
+ `${prefix}pm.conversion_info IS NOT NULL AND ${statusClause}`
270
+ );
271
+ }
249
272
  }
250
273
  // Filter by token transaction hash
251
274
  if (
@@ -813,6 +836,17 @@ class SqliteStorage {
813
836
  senderComment: row.lnurl_sender_comment || null,
814
837
  };
815
838
  }
839
+
840
+ if (row.conversion_info) {
841
+ try {
842
+ details.conversionInfo = JSON.parse(row.conversion_info);
843
+ } catch (e) {
844
+ throw new StorageError(
845
+ `Failed to parse conversion_info JSON for payment ${row.id}: ${e.message}`,
846
+ e
847
+ );
848
+ }
849
+ }
816
850
  } else if (row.withdraw_tx_id) {
817
851
  details = {
818
852
  type: "withdraw",
@@ -1378,6 +1412,78 @@ class SqliteStorage {
1378
1412
  );
1379
1413
  }
1380
1414
  }
1415
+
1416
+ // ===== Cross-Chain Swap Operations =====
1417
+
1418
+ setCrossChainSwap(swap) {
1419
+ try {
1420
+ const stmt = this.db.prepare(`
1421
+ INSERT INTO cross_chain_swaps (provider, id, is_terminal, updated_at, data, secrets)
1422
+ VALUES (?, ?, ?, ?, ?, ?)
1423
+ ON CONFLICT(provider, id) DO UPDATE SET
1424
+ is_terminal = excluded.is_terminal,
1425
+ updated_at = excluded.updated_at,
1426
+ data = excluded.data,
1427
+ secrets = excluded.secrets
1428
+ `);
1429
+ stmt.run(
1430
+ swap.provider,
1431
+ swap.id,
1432
+ swap.isTerminal ? 1 : 0,
1433
+ swap.updatedAt,
1434
+ swap.data,
1435
+ swap.secrets
1436
+ );
1437
+ return Promise.resolve();
1438
+ } catch (error) {
1439
+ return Promise.reject(
1440
+ new StorageError(`Failed to set cross-chain swap: ${error.message}`, error)
1441
+ );
1442
+ }
1443
+ }
1444
+
1445
+ getCrossChainSwap(provider, id) {
1446
+ try {
1447
+ const stmt = this.db.prepare(
1448
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1449
+ FROM cross_chain_swaps WHERE provider = ? AND id = ?`
1450
+ );
1451
+ const row = stmt.get(provider, id);
1452
+ return Promise.resolve(row ? crossChainSwapFromRow(row) : null);
1453
+ } catch (error) {
1454
+ return Promise.reject(
1455
+ new StorageError(`Failed to get cross-chain swap: ${error.message}`, error)
1456
+ );
1457
+ }
1458
+ }
1459
+
1460
+ listActiveCrossChainSwaps(provider) {
1461
+ try {
1462
+ const stmt = this.db.prepare(
1463
+ `SELECT provider, id, is_terminal, updated_at, data, secrets
1464
+ FROM cross_chain_swaps WHERE provider = ? AND is_terminal = 0`
1465
+ );
1466
+ const rows = stmt.all(provider);
1467
+ return Promise.resolve(rows.map(crossChainSwapFromRow));
1468
+ } catch (error) {
1469
+ return Promise.reject(
1470
+ new StorageError(`Failed to list active cross-chain swaps: ${error.message}`, error)
1471
+ );
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ /// Maps a `cross_chain_swaps` row to a StoredCrossChainSwap, parsing `data` and
1477
+ /// the is_terminal flag back into the camelCase shape the SDK expects.
1478
+ function crossChainSwapFromRow(row) {
1479
+ return {
1480
+ provider: row.provider,
1481
+ id: row.id,
1482
+ isTerminal: row.is_terminal !== 0,
1483
+ updatedAt: Number(row.updated_at),
1484
+ data: row.data,
1485
+ secrets: row.secrets,
1486
+ };
1381
1487
  }
1382
1488
 
1383
1489
  async function createDefaultStorage(dataDir, logger = null) {
@@ -438,7 +438,7 @@ class MigrationManager {
438
438
  // the schema matches payment_details_lightning / _token / _spark. We can't
439
439
  // safely backfill the new table from the dropped deposit_tx_id column: we
440
440
  // never stored the original SSP output_index, and vout=0 is a valid output
441
- // index defaulting would silently mislabel. Drop the column and leave
441
+ // index, so defaulting would silently mislabel. Drop the column and leave
442
442
  // the payments row in place. The read path sees an unjoined deposit row
443
443
  // as `details: None` until the resync re-fetches the SSP user_request and
444
444
  // the upsert inserts the new details row.
@@ -456,6 +456,30 @@ class MigrationManager {
456
456
  WHERE key = 'sync_offset' AND json_valid(value)`,
457
457
  ],
458
458
  },
459
+ {
460
+ name: "Backfill conversion_info type discriminator",
461
+ sql: `UPDATE 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`,
462
+ },
463
+ {
464
+ // Cross-chain swap rows, synced for cross-instance recovery. Shared
465
+ // across providers, discriminated by `provider`. `data` is
466
+ // provider-opaque JSON; `secrets` is provider-opaque ciphertext (empty
467
+ // when the provider has none).
468
+ name: "Create cross_chain_swaps table",
469
+ sql: [
470
+ `CREATE TABLE cross_chain_swaps (
471
+ provider TEXT NOT NULL,
472
+ id TEXT NOT NULL,
473
+ is_terminal INTEGER NOT NULL,
474
+ updated_at INTEGER NOT NULL,
475
+ data TEXT NOT NULL,
476
+ secrets TEXT NOT NULL,
477
+ PRIMARY KEY (provider, id)
478
+ )`,
479
+ `CREATE INDEX idx_cross_chain_swaps_provider_is_terminal
480
+ ON cross_chain_swaps(provider, is_terminal)`,
481
+ ],
482
+ },
459
483
  ];
460
484
  }
461
485
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breeztech/breez-sdk-spark",
3
- "version": "0.16.1-dev1",
3
+ "version": "0.17.1",
4
4
  "description": "Breez Spark SDK",
5
5
  "repository": "https://github.com/breez/spark-sdk",
6
6
  "author": "Breez <contact@breez.technology> (https://github.com/breez)",
package/ssr/index.js CHANGED
@@ -98,6 +98,11 @@ export function postgresStorage(...args) {
98
98
  return _module.postgresStorage(...args);
99
99
  }
100
100
 
101
+ export function start(...args) {
102
+ if (!_module) _notInitialized('start');
103
+ return _module.start(...args);
104
+ }
105
+
101
106
  export function task_worker_entry_point(...args) {
102
107
  if (!_module) _notInitialized('task_worker_entry_point');
103
108
  return _module.task_worker_entry_point(...args);