@breeztech/breez-sdk-spark 0.7.19 → 0.8.0-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.
@@ -27,6 +27,47 @@ try {
27
27
  const { StorageError } = require("./errors.cjs");
28
28
  const { MigrationManager } = require("./migrations.cjs");
29
29
 
30
+ /**
31
+ * Base query for payment lookups.
32
+ * All columns are accessed by name in _rowToPayment.
33
+ * parent_payment_id is only used by getPaymentsByParentIds.
34
+ */
35
+ const SELECT_PAYMENT_SQL = `
36
+ SELECT p.id,
37
+ p.payment_type,
38
+ p.status,
39
+ p.amount,
40
+ p.fees,
41
+ p.timestamp,
42
+ p.method,
43
+ p.withdraw_tx_id,
44
+ p.deposit_tx_id,
45
+ p.spark,
46
+ l.invoice AS lightning_invoice,
47
+ l.payment_hash AS lightning_payment_hash,
48
+ l.destination_pubkey AS lightning_destination_pubkey,
49
+ COALESCE(l.description, pm.lnurl_description) AS lightning_description,
50
+ l.preimage AS lightning_preimage,
51
+ pm.lnurl_pay_info,
52
+ pm.lnurl_withdraw_info,
53
+ pm.conversion_info,
54
+ t.metadata AS token_metadata,
55
+ t.tx_hash AS token_tx_hash,
56
+ t.tx_type AS token_tx_type,
57
+ t.invoice_details AS token_invoice_details,
58
+ s.invoice_details AS spark_invoice_details,
59
+ s.htlc_details AS spark_htlc_details,
60
+ lrm.nostr_zap_request AS lnurl_nostr_zap_request,
61
+ lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt,
62
+ lrm.sender_comment AS lnurl_sender_comment,
63
+ pm.parent_payment_id
64
+ FROM payments p
65
+ LEFT JOIN payment_details_lightning l ON p.id = l.payment_id
66
+ LEFT JOIN payment_details_token t ON p.id = t.payment_id
67
+ LEFT JOIN payment_details_spark s ON p.id = s.payment_id
68
+ LEFT JOIN payment_metadata pm ON p.id = pm.payment_id
69
+ LEFT JOIN lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash`;
70
+
30
71
  class SqliteStorage {
31
72
  constructor(dbPath, logger = null) {
32
73
  this.dbPath = dbPath;
@@ -196,6 +237,14 @@ class SqliteStorage {
196
237
  paymentDetailsClauses.push("t.tx_hash = ?");
197
238
  params.push(paymentDetailsFilter.txHash);
198
239
  }
240
+ // Filter by token transaction type
241
+ if (
242
+ paymentDetailsFilter.type === "token" &&
243
+ paymentDetailsFilter.txType !== undefined
244
+ ) {
245
+ paymentDetailsClauses.push("t.tx_type = ?");
246
+ params.push(paymentDetailsFilter.txType);
247
+ }
199
248
 
200
249
  if (paymentDetailsClauses.length > 0) {
201
250
  allPaymentDetailsClauses.push(`(${paymentDetailsClauses.join(" AND ")})`);
@@ -221,50 +270,16 @@ class SqliteStorage {
221
270
  }
222
271
  }
223
272
 
273
+ // Exclude child payments (those with a parent_payment_id)
274
+ whereClauses.push("pm.parent_payment_id IS NULL");
275
+
224
276
  // Build the WHERE clause
225
277
  const whereSql =
226
278
  whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
227
279
 
228
280
  // Determine sort order
229
281
  const orderDirection = request.sortAscending ? "ASC" : "DESC";
230
-
231
- const query = `
232
- SELECT p.id
233
- , p.payment_type
234
- , p.status
235
- , p.amount
236
- , p.fees
237
- , p.timestamp
238
- , p.method
239
- , p.withdraw_tx_id
240
- , p.deposit_tx_id
241
- , p.spark
242
- , l.invoice AS lightning_invoice
243
- , l.payment_hash AS lightning_payment_hash
244
- , l.destination_pubkey AS lightning_destination_pubkey
245
- , COALESCE(l.description, pm.lnurl_description) AS lightning_description
246
- , l.preimage AS lightning_preimage
247
- , pm.lnurl_pay_info
248
- , pm.lnurl_withdraw_info
249
- , pm.conversion_info
250
- , t.metadata AS token_metadata
251
- , t.tx_hash AS token_tx_hash
252
- , t.invoice_details AS token_invoice_details
253
- , s.invoice_details AS spark_invoice_details
254
- , s.htlc_details AS spark_htlc_details
255
- , lrm.nostr_zap_request AS lnurl_nostr_zap_request
256
- , lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt
257
- , lrm.sender_comment AS lnurl_sender_comment
258
- FROM payments p
259
- LEFT JOIN payment_details_lightning l ON p.id = l.payment_id
260
- LEFT JOIN payment_details_token t ON p.id = t.payment_id
261
- LEFT JOIN payment_details_spark s ON p.id = s.payment_id
262
- LEFT JOIN payment_metadata pm ON p.id = pm.payment_id
263
- LEFT JOIN lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash
264
- ${whereSql}
265
- ORDER BY p.timestamp ${orderDirection}
266
- LIMIT ? OFFSET ?
267
- `;
282
+ const query = `${SELECT_PAYMENT_SQL} ${whereSql} ORDER BY p.timestamp ${orderDirection} LIMIT ? OFFSET ?`;
268
283
 
269
284
  params.push(actualLimit, actualOffset);
270
285
  const stmt = this.db.prepare(query);
@@ -318,11 +333,12 @@ class SqliteStorage {
318
333
  );
319
334
  const tokenInsert = this.db.prepare(
320
335
  `INSERT INTO payment_details_token
321
- (payment_id, metadata, tx_hash, invoice_details)
322
- VALUES (@id, @metadata, @txHash, @invoiceDetails)
336
+ (payment_id, metadata, tx_hash, tx_type, invoice_details)
337
+ VALUES (@id, @metadata, @txHash, @txType, @invoiceDetails)
323
338
  ON CONFLICT(payment_id) DO UPDATE SET
324
339
  metadata=excluded.metadata,
325
340
  tx_hash=excluded.tx_hash,
341
+ tx_type=excluded.tx_type,
326
342
  invoice_details=COALESCE(excluded.invoice_details, payment_details_token.invoice_details)`
327
343
  );
328
344
  const sparkInsert = this.db.prepare(
@@ -381,6 +397,7 @@ class SqliteStorage {
381
397
  id: payment.id,
382
398
  metadata: JSON.stringify(payment.details.metadata),
383
399
  txHash: payment.details.txHash,
400
+ txType: payment.details.txType,
384
401
  invoiceDetails: payment.details.invoiceDetails
385
402
  ? JSON.stringify(payment.details.invoiceDetails)
386
403
  : null,
@@ -408,43 +425,9 @@ class SqliteStorage {
408
425
  );
409
426
  }
410
427
 
411
- const stmt = this.db.prepare(`
412
- SELECT p.id
413
- , p.payment_type
414
- , p.status
415
- , p.amount
416
- , p.fees
417
- , p.timestamp
418
- , p.method
419
- , p.withdraw_tx_id
420
- , p.deposit_tx_id
421
- , p.spark
422
- , l.invoice AS lightning_invoice
423
- , l.payment_hash AS lightning_payment_hash
424
- , l.destination_pubkey AS lightning_destination_pubkey
425
- , COALESCE(l.description, pm.lnurl_description) AS lightning_description
426
- , l.preimage AS lightning_preimage
427
- , pm.lnurl_pay_info
428
- , pm.lnurl_withdraw_info
429
- , pm.conversion_info
430
- , t.metadata AS token_metadata
431
- , t.tx_hash AS token_tx_hash
432
- , t.invoice_details AS token_invoice_details
433
- , s.invoice_details AS spark_invoice_details
434
- , s.htlc_details AS spark_htlc_details
435
- , lrm.nostr_zap_request AS lnurl_nostr_zap_request
436
- , lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt
437
- , lrm.sender_comment AS lnurl_sender_comment
438
- FROM payments p
439
- LEFT JOIN payment_details_lightning l ON p.id = l.payment_id
440
- LEFT JOIN payment_details_token t ON p.id = t.payment_id
441
- LEFT JOIN payment_details_spark s ON p.id = s.payment_id
442
- LEFT JOIN payment_metadata pm ON p.id = pm.payment_id
443
- LEFT JOIN lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash
444
- WHERE p.id = ?
445
- `);
446
-
428
+ const stmt = this.db.prepare(`${SELECT_PAYMENT_SQL} WHERE p.id = ?`);
447
429
  const row = stmt.get(id);
430
+
448
431
  if (!row) {
449
432
  return Promise.reject(
450
433
  new StorageError(`Payment with id '${id}' not found`)
@@ -472,43 +455,9 @@ class SqliteStorage {
472
455
  );
473
456
  }
474
457
 
475
- const stmt = this.db.prepare(`
476
- SELECT p.id
477
- , p.payment_type
478
- , p.status
479
- , p.amount
480
- , p.fees
481
- , p.timestamp
482
- , p.method
483
- , p.withdraw_tx_id
484
- , p.deposit_tx_id
485
- , p.spark
486
- , l.invoice AS lightning_invoice
487
- , l.payment_hash AS lightning_payment_hash
488
- , l.destination_pubkey AS lightning_destination_pubkey
489
- , COALESCE(l.description, pm.lnurl_description) AS lightning_description
490
- , l.preimage AS lightning_preimage
491
- , pm.lnurl_pay_info
492
- , pm.lnurl_withdraw_info
493
- , pm.conversion_info
494
- , t.metadata AS token_metadata
495
- , t.tx_hash AS token_tx_hash
496
- , t.invoice_details AS token_invoice_details
497
- , s.invoice_details AS spark_invoice_details
498
- , s.htlc_details AS spark_htlc_details
499
- , lrm.nostr_zap_request AS lnurl_nostr_zap_request
500
- , lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt
501
- , lrm.sender_comment AS lnurl_sender_comment
502
- FROM payments p
503
- LEFT JOIN payment_details_lightning l ON p.id = l.payment_id
504
- LEFT JOIN payment_details_token t ON p.id = t.payment_id
505
- LEFT JOIN payment_details_spark s ON p.id = s.payment_id
506
- LEFT JOIN payment_metadata pm ON p.id = pm.payment_id
507
- LEFT JOIN lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash
508
- WHERE l.invoice = ?
509
- `);
510
-
458
+ const stmt = this.db.prepare(`${SELECT_PAYMENT_SQL} WHERE l.invoice = ?`);
511
459
  const row = stmt.get(invoice);
460
+
512
461
  if (!row) {
513
462
  return Promise.resolve(null);
514
463
  }
@@ -526,11 +475,66 @@ class SqliteStorage {
526
475
  }
527
476
  }
528
477
 
529
- setPaymentMetadata(paymentId, metadata) {
478
+ /**
479
+ * Gets payments that have any of the specified parent payment IDs.
480
+ * @param {string[]} parentPaymentIds - Array of parent payment IDs
481
+ * @returns {Promise<Object>} Map of parentPaymentId -> array of RelatedPayment objects
482
+ */
483
+ getPaymentsByParentIds(parentPaymentIds) {
484
+ try {
485
+ if (!parentPaymentIds || parentPaymentIds.length === 0) {
486
+ return Promise.resolve({});
487
+ }
488
+
489
+ // Early exit if no related payments exist
490
+ const hasRelated = this.db
491
+ .prepare(
492
+ "SELECT EXISTS(SELECT 1 FROM payment_metadata WHERE parent_payment_id IS NOT NULL LIMIT 1)"
493
+ )
494
+ .pluck()
495
+ .get();
496
+ if (!hasRelated) {
497
+ return Promise.resolve({});
498
+ }
499
+
500
+ const placeholders = parentPaymentIds.map(() => "?").join(", ");
501
+ const query = `${SELECT_PAYMENT_SQL} WHERE pm.parent_payment_id IN (${placeholders}) ORDER BY p.timestamp ASC`;
502
+
503
+ const stmt = this.db.prepare(query);
504
+ const rows = stmt.all(...parentPaymentIds);
505
+
506
+ // Group payments by parent_payment_id
507
+ const result = {};
508
+ for (const row of rows) {
509
+ const parentId = row.parent_payment_id;
510
+ if (!result[parentId]) {
511
+ result[parentId] = [];
512
+ }
513
+ result[parentId].push(this._rowToPayment(row));
514
+ }
515
+
516
+ return Promise.resolve(result);
517
+ } catch (error) {
518
+ return Promise.reject(
519
+ new StorageError(
520
+ `Failed to get payments by parent ids: ${error.message}`,
521
+ error
522
+ )
523
+ );
524
+ }
525
+ }
526
+
527
+ insertPaymentMetadata(paymentId, metadata) {
530
528
  try {
531
529
  const stmt = this.db.prepare(`
532
- INSERT OR REPLACE INTO payment_metadata (payment_id, parent_payment_id, lnurl_pay_info, lnurl_withdraw_info, lnurl_description, conversion_info)
530
+ INSERT INTO payment_metadata (payment_id, parent_payment_id, lnurl_pay_info, lnurl_withdraw_info, lnurl_description, conversion_info)
533
531
  VALUES (?, ?, ?, ?, ?, ?)
532
+ ON CONFLICT(payment_id) DO UPDATE SET
533
+ parent_payment_id = COALESCE(excluded.parent_payment_id, parent_payment_id),
534
+ lnurl_pay_info = COALESCE(excluded.lnurl_pay_info, lnurl_pay_info),
535
+ lnurl_withdraw_info = COALESCE(excluded.lnurl_withdraw_info, lnurl_withdraw_info),
536
+ lnurl_description = COALESCE(excluded.lnurl_description, lnurl_description),
537
+ conversion_info = COALESCE(excluded.conversion_info, conversion_info)
534
538
  `);
535
539
 
536
540
  stmt.run(
@@ -755,6 +759,7 @@ class SqliteStorage {
755
759
  type: "token",
756
760
  metadata: JSON.parse(row.token_metadata),
757
761
  txHash: row.token_tx_hash,
762
+ txType: row.token_tx_type,
758
763
  invoiceDetails: row.token_invoice_details
759
764
  ? JSON.parse(row.token_invoice_details)
760
765
  : null,
@@ -793,11 +798,14 @@ class SqliteStorage {
793
798
  syncAddOutgoingChange(record) {
794
799
  try {
795
800
  const transaction = this.db.transaction(() => {
796
- // Get the next revision
801
+ // Compute next revision as max(committed, max outgoing) + 1, without updating sync_revision
797
802
  const revisionQuery = this.db.prepare(`
798
- UPDATE sync_revision
799
- SET revision = revision + 1
800
- RETURNING CAST(revision AS TEXT) AS revision
803
+ SELECT CAST(
804
+ MAX(
805
+ (SELECT revision FROM sync_revision),
806
+ COALESCE((SELECT MAX(revision) FROM sync_outgoing), 0)
807
+ ) + 1
808
+ AS TEXT) AS revision
801
809
  `);
802
810
  const revision = BigInt(revisionQuery.get().revision);
803
811
 
@@ -836,7 +844,7 @@ class SqliteStorage {
836
844
  }
837
845
  }
838
846
 
839
- syncCompleteOutgoingSync(record) {
847
+ syncCompleteOutgoingSync(record, localRevision) {
840
848
  try {
841
849
  const transaction = this.db.transaction(() => {
842
850
  // Delete records that have been synced
@@ -848,7 +856,7 @@ class SqliteStorage {
848
856
  deleteStmt.run(
849
857
  record.id.type,
850
858
  record.id.dataId,
851
- record.revision.toString()
859
+ localRevision.toString()
852
860
  );
853
861
 
854
862
  // Update or insert the sync state
@@ -871,6 +879,12 @@ class SqliteStorage {
871
879
  Math.floor(Date.now() / 1000),
872
880
  JSON.stringify(record.data)
873
881
  );
882
+
883
+ // Update sync_revision to track the highest known revision
884
+ const updateRevisionStmt = this.db.prepare(`
885
+ UPDATE sync_revision SET revision = MAX(revision, CAST(? AS INTEGER))
886
+ `);
887
+ updateRevisionStmt.run(record.revision.toString());
874
888
  });
875
889
 
876
890
  transaction();
@@ -953,7 +967,7 @@ class SqliteStorage {
953
967
  syncGetLastRevision() {
954
968
  try {
955
969
  const stmt = this.db.prepare(
956
- `SELECT CAST(COALESCE(MAX(revision), 0) AS TEXT) as revision FROM sync_state`
970
+ `SELECT CAST(revision AS TEXT) as revision FROM sync_revision`
957
971
  );
958
972
  const row = stmt.get();
959
973
 
@@ -1031,9 +1045,9 @@ class SqliteStorage {
1031
1045
  syncRebasePendingOutgoingRecords(revision) {
1032
1046
  try {
1033
1047
  const transaction = this.db.transaction(() => {
1034
- // Get current revision
1048
+ // Get current committed revision from sync_revision table
1035
1049
  const getLastRevisionStmt = this.db.prepare(`
1036
- SELECT CAST(COALESCE(MAX(revision), 0) AS TEXT) as last_revision FROM sync_state
1050
+ SELECT CAST(revision AS TEXT) as last_revision FROM sync_revision
1037
1051
  `);
1038
1052
  const revisionRow = getLastRevisionStmt.get();
1039
1053
  const lastRevision = revisionRow
@@ -1044,17 +1058,20 @@ class SqliteStorage {
1044
1058
  const diff =
1045
1059
  revision > lastRevision ? revision - lastRevision : BigInt(0);
1046
1060
 
1047
- if (diff === BigInt(0)) {
1048
- return; // No rebasing needed
1061
+ if (diff > BigInt(0)) {
1062
+ // Update all pending outgoing records
1063
+ const updateRecordsStmt = this.db.prepare(`
1064
+ UPDATE sync_outgoing
1065
+ SET revision = revision + CAST(? AS INTEGER)
1066
+ `);
1067
+ updateRecordsStmt.run(diff.toString());
1049
1068
  }
1050
1069
 
1051
- // Update all pending outgoing records
1052
- const updateRecordsStmt = this.db.prepare(`
1053
- UPDATE sync_outgoing
1054
- SET revision = revision + CAST(? AS INTEGER)
1070
+ // Update sync_revision within the same transaction so retries are idempotent
1071
+ const updateRevisionStmt = this.db.prepare(`
1072
+ UPDATE sync_revision SET revision = MAX(revision, CAST(? AS INTEGER))
1055
1073
  `);
1056
-
1057
- updateRecordsStmt.run(diff.toString());
1074
+ updateRevisionStmt.run(revision.toString());
1058
1075
  });
1059
1076
 
1060
1077
  transaction();
@@ -1206,26 +1223,35 @@ class SqliteStorage {
1206
1223
 
1207
1224
  syncUpdateRecordFromIncoming(record) {
1208
1225
  try {
1209
- const stmt = this.db.prepare(`
1210
- INSERT OR REPLACE INTO sync_state (
1211
- record_type,
1212
- data_id,
1213
- revision,
1214
- schema_version,
1215
- commit_time,
1216
- data
1217
- ) VALUES (?, ?, CAST(? AS INTEGER), ?, ?, ?)
1218
- `);
1226
+ const transaction = this.db.transaction(() => {
1227
+ const stmt = this.db.prepare(`
1228
+ INSERT OR REPLACE INTO sync_state (
1229
+ record_type,
1230
+ data_id,
1231
+ revision,
1232
+ schema_version,
1233
+ commit_time,
1234
+ data
1235
+ ) VALUES (?, ?, CAST(? AS INTEGER), ?, ?, ?)
1236
+ `);
1219
1237
 
1220
- stmt.run(
1221
- record.id.type,
1222
- record.id.dataId,
1223
- record.revision.toString(),
1224
- record.schemaVersion,
1225
- Math.floor(Date.now() / 1000),
1226
- JSON.stringify(record.data)
1227
- );
1238
+ stmt.run(
1239
+ record.id.type,
1240
+ record.id.dataId,
1241
+ record.revision.toString(),
1242
+ record.schemaVersion,
1243
+ Math.floor(Date.now() / 1000),
1244
+ JSON.stringify(record.data)
1245
+ );
1228
1246
 
1247
+ // Update sync_revision to track the highest known revision
1248
+ const updateRevisionStmt = this.db.prepare(`
1249
+ UPDATE sync_revision SET revision = MAX(revision, CAST(? AS INTEGER))
1250
+ `);
1251
+ updateRevisionStmt.run(record.revision.toString());
1252
+ });
1253
+
1254
+ transaction();
1229
1255
  return Promise.resolve();
1230
1256
  } catch (error) {
1231
1257
  return Promise.reject(
@@ -229,6 +229,8 @@ class MigrationManager {
229
229
  {
230
230
  name: "Create sync tables",
231
231
  sql: [
232
+ // sync_revision: tracks the last committed revision (from server-acknowledged
233
+ // or server-received records). Does NOT include pending outgoing revisions.
232
234
  `CREATE TABLE sync_revision (
233
235
  revision INTEGER NOT NULL DEFAULT 0
234
236
  )`,
@@ -285,13 +287,13 @@ class MigrationManager {
285
287
  nostr_zap_request TEXT,
286
288
  nostr_zap_receipt TEXT,
287
289
  sender_comment TEXT
288
- )`
290
+ )`,
289
291
  },
290
292
  {
291
293
  // Delete all unclaimed deposits to clear old claim_error JSON format.
292
294
  // Deposits will be recovered on next sync.
293
295
  name: "Clear unclaimed deposits for claim_error format change",
294
- sql: `DELETE FROM unclaimed_deposits`
296
+ sql: `DELETE FROM unclaimed_deposits`,
295
297
  },
296
298
  {
297
299
  // Clear all sync tables due to BreezSigner signature change.
@@ -309,7 +311,7 @@ class MigrationManager {
309
311
  },
310
312
  {
311
313
  name: "Add token conversion info to payment_metadata",
312
- sql: `ALTER TABLE payment_metadata ADD COLUMN token_conversion_info TEXT`
314
+ sql: `ALTER TABLE payment_metadata ADD COLUMN token_conversion_info TEXT`,
313
315
  },
314
316
  {
315
317
  name: "Add parent payment id to payment_metadata",
@@ -321,6 +323,28 @@ class MigrationManager {
321
323
  `ALTER TABLE payment_metadata DROP COLUMN token_conversion_info`,
322
324
  `ALTER TABLE payment_metadata ADD COLUMN conversion_info TEXT`]
323
325
  },
326
+ {
327
+ name: "Add tx_type column to payment_details_token",
328
+ sql: [
329
+ // Add tx_type column with a default value of 'transfer'.
330
+ // Delete sync cache to trigger token re-sync which will update all records with correct tx_type.
331
+ // Note: This intentionally couples to the CachedSyncInfo schema at migration time.
332
+ `ALTER TABLE payment_details_token ADD COLUMN tx_type TEXT NOT NULL DEFAULT 'transfer'`,
333
+ `UPDATE settings
334
+ SET value = json_set(value, '$.last_synced_final_token_payment_id', NULL)
335
+ WHERE key = 'sync_offset' AND json_valid(value) AND json_type(value, '$.last_synced_final_token_payment_id') IS NOT NULL`,
336
+ ],
337
+ },
338
+ {
339
+ name: "Clear sync tables to force re-sync",
340
+ sql: [
341
+ `DELETE FROM sync_outgoing`,
342
+ `DELETE FROM sync_incoming`,
343
+ `DELETE FROM sync_state`,
344
+ `UPDATE sync_revision SET revision = 0`,
345
+ `DELETE FROM settings WHERE key = 'sync_initial_complete'`
346
+ ]
347
+ },
324
348
  ];
325
349
  }
326
350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breeztech/breez-sdk-spark",
3
- "version": "0.7.19",
3
+ "version": "0.8.0-dev1",
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)",