@breeztech/breez-sdk-spark 0.18.0 → 0.19.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.
@@ -668,7 +668,7 @@ class PostgresTokenStore {
668
668
  let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
669
669
 
670
670
  // Filter by preferred if provided
671
- if (preferredOutputs && preferredOutputs.length > 0) {
671
+ if (preferredOutputs) {
672
672
  const preferredOutpoints = new Set(
673
673
  preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
674
674
  );
@@ -677,74 +677,11 @@ class PostgresTokenStore {
677
677
  );
678
678
  }
679
679
 
680
- // Select outputs based on target
681
- let selectedOutputs;
682
-
683
- if (target.type === "minTotalValue") {
684
- const amount = BigInt(target.value);
685
-
686
- // Check sufficiency
687
- const totalAvailable = outputs.reduce(
688
- (sum, o) => sum + BigInt(o.output.tokenAmount),
689
- 0n
690
- );
691
- if (totalAvailable < amount) {
692
- throw new TokenStoreError("InsufficientFunds");
693
- }
694
-
695
- // Try exact match first
696
- const exactMatch = outputs.find(
697
- (o) => BigInt(o.output.tokenAmount) === amount
698
- );
699
- if (exactMatch) {
700
- selectedOutputs = [exactMatch];
701
- } else {
702
- // Sort by selection strategy
703
- if (selectionStrategy === "LargestFirst") {
704
- outputs.sort(
705
- (a, b) =>
706
- Number(BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount))
707
- );
708
- } else {
709
- // Default: SmallestFirst
710
- outputs.sort(
711
- (a, b) =>
712
- Number(BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount))
713
- );
714
- }
715
-
716
- selectedOutputs = [];
717
- let remaining = amount;
718
- for (const output of outputs) {
719
- if (remaining <= 0n) break;
720
- selectedOutputs.push(output);
721
- remaining -= BigInt(output.output.tokenAmount);
722
- }
723
- if (remaining > 0n) {
724
- throw new TokenStoreError("InsufficientFunds");
725
- }
726
- }
727
- } else if (target.type === "maxOutputCount") {
728
- const count = target.value;
729
-
730
- // Sort by selection strategy
731
- if (selectionStrategy === "LargestFirst") {
732
- outputs.sort(
733
- (a, b) =>
734
- Number(BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount))
735
- );
736
- } else {
737
- // Default: SmallestFirst
738
- outputs.sort(
739
- (a, b) =>
740
- Number(BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount))
741
- );
742
- }
743
-
744
- selectedOutputs = outputs.slice(0, count);
745
- } else {
746
- throw new TokenStoreError(`Unknown target type: ${target.type}`);
747
- }
680
+ const selectedOutputs = this._selectOutputs(
681
+ outputs,
682
+ target,
683
+ selectionStrategy
684
+ );
748
685
 
749
686
  // Create reservation
750
687
  const reservationId = this._generateId();
@@ -785,6 +722,207 @@ class PostgresTokenStore {
785
722
  }
786
723
  }
787
724
 
725
+ _selectOutputs(outputs, target, selectionStrategy) {
726
+ if (target.type === "minTotalValue") {
727
+ const amount = BigInt(target.value);
728
+ const totalAvailable = outputs.reduce(
729
+ (sum, o) => sum + BigInt(o.output.tokenAmount),
730
+ 0n
731
+ );
732
+ if (totalAvailable < amount) {
733
+ throw new TokenStoreError("InsufficientFunds");
734
+ }
735
+
736
+ const exactMatch = outputs.find(
737
+ (o) => BigInt(o.output.tokenAmount) === amount
738
+ );
739
+ if (exactMatch) {
740
+ return [exactMatch];
741
+ }
742
+
743
+ if (selectionStrategy === "LargestFirst") {
744
+ outputs.sort(
745
+ (a, b) =>
746
+ Number(BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount))
747
+ );
748
+ } else {
749
+ outputs.sort(
750
+ (a, b) =>
751
+ Number(BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount))
752
+ );
753
+ }
754
+
755
+ const selected = [];
756
+ let remaining = amount;
757
+ for (const output of outputs) {
758
+ if (remaining <= 0n) break;
759
+ selected.push(output);
760
+ remaining -= BigInt(output.output.tokenAmount);
761
+ }
762
+ if (remaining > 0n) {
763
+ throw new TokenStoreError("InsufficientFunds");
764
+ }
765
+ return selected;
766
+ }
767
+
768
+ if (target.type === "maxOutputCount") {
769
+ const count = target.value;
770
+ if (selectionStrategy === "LargestFirst") {
771
+ outputs.sort(
772
+ (a, b) =>
773
+ Number(BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount))
774
+ );
775
+ } else {
776
+ outputs.sort(
777
+ (a, b) =>
778
+ Number(BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount))
779
+ );
780
+ }
781
+ return outputs.slice(0, count);
782
+ }
783
+
784
+ throw new TokenStoreError(`Unknown target type: ${target.type}`);
785
+ }
786
+
787
+ async selectTokenOutputs(
788
+ tokenIdentifier,
789
+ target,
790
+ preferredOutputs,
791
+ selectionStrategy
792
+ ) {
793
+ try {
794
+ if (target.type === "minTotalValue" && (!target.value || target.value === "0")) {
795
+ throw new TokenStoreError("Amount to reserve must be greater than zero");
796
+ }
797
+ if (target.type === "maxOutputCount" && (!target.value || target.value === 0)) {
798
+ throw new TokenStoreError("Count to reserve must be greater than zero");
799
+ }
800
+
801
+ const metadataResult = await this.pool.query(
802
+ "SELECT * FROM brz_token_metadata WHERE user_id = $1 AND identifier = $2",
803
+ [this.identity, tokenIdentifier]
804
+ );
805
+ if (metadataResult.rows.length === 0) {
806
+ throw new TokenStoreError(
807
+ `Token outputs not found for identifier: ${tokenIdentifier}`
808
+ );
809
+ }
810
+ const metadata = this._metadataFromRow(metadataResult.rows[0]);
811
+
812
+ const outputRows = await this.pool.query(
813
+ `SELECT o.owner_public_key, o.revocation_commitment,
814
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
815
+ o.token_public_key, o.token_amount, o.token_identifier,
816
+ o.prev_tx_hash, o.prev_tx_vout
817
+ FROM brz_token_outputs o
818
+ WHERE o.user_id = $1
819
+ AND o.token_identifier = $2
820
+ AND o.reservation_id IS NULL`,
821
+ [this.identity, tokenIdentifier]
822
+ );
823
+
824
+ let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
825
+
826
+ if (preferredOutputs) {
827
+ const preferredOutpoints = new Set(
828
+ preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
829
+ );
830
+ outputs = outputs.filter((o) =>
831
+ preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
832
+ );
833
+ }
834
+
835
+ const selectedOutputs = this._selectOutputs(
836
+ outputs,
837
+ target,
838
+ selectionStrategy
839
+ );
840
+ return { metadata, outputs: selectedOutputs };
841
+ } catch (error) {
842
+ if (error instanceof TokenStoreError) throw error;
843
+ throw new TokenStoreError(
844
+ `Failed to select token outputs: ${error.message}`,
845
+ error
846
+ );
847
+ }
848
+ }
849
+
850
+ async reserveTokenOutputsByOutpoints(tokenIdentifier, outpoints, purpose) {
851
+ try {
852
+ if (!outpoints || outpoints.length === 0) {
853
+ throw new TokenStoreError("No outpoints provided");
854
+ }
855
+ return await this._withWriteTransaction(async (client) => {
856
+ const metadataResult = await client.query(
857
+ "SELECT * FROM brz_token_metadata WHERE user_id = $1 AND identifier = $2",
858
+ [this.identity, tokenIdentifier]
859
+ );
860
+ if (metadataResult.rows.length === 0) {
861
+ throw new TokenStoreError(
862
+ `Token outputs not found for identifier: ${tokenIdentifier}`
863
+ );
864
+ }
865
+ const metadata = this._metadataFromRow(metadataResult.rows[0]);
866
+
867
+ const txHashes = outpoints.map((o) => o.prevTxHash);
868
+ const vouts = outpoints.map((o) => o.prevTxVout);
869
+ const outputRows = await client.query(
870
+ `SELECT o.owner_public_key, o.revocation_commitment,
871
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
872
+ o.token_public_key, o.token_amount, o.token_identifier,
873
+ o.prev_tx_hash, o.prev_tx_vout
874
+ FROM brz_token_outputs o
875
+ WHERE o.user_id = $1
876
+ AND o.token_identifier = $2
877
+ AND o.reservation_id IS NULL
878
+ AND (o.prev_tx_hash, o.prev_tx_vout) IN (
879
+ SELECT * FROM UNNEST($3::text[], $4::int[])
880
+ )`,
881
+ [this.identity, tokenIdentifier, txHashes, vouts]
882
+ );
883
+
884
+ const selectedOutputs = outputRows.rows.map((row) =>
885
+ this._outputFromRow(row)
886
+ );
887
+
888
+ const distinct = new Set(
889
+ outpoints.map((o) => `${o.prevTxHash}:${o.prevTxVout}`)
890
+ );
891
+ if (selectedOutputs.length !== distinct.size) {
892
+ throw new TokenStoreError("InsufficientFunds");
893
+ }
894
+
895
+ const reservationId = this._generateId();
896
+ await client.query(
897
+ "INSERT INTO brz_token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
898
+ [this.identity, reservationId, purpose]
899
+ );
900
+ await client.query(
901
+ `UPDATE brz_token_outputs SET reservation_id = $1
902
+ WHERE user_id = $4
903
+ AND (prev_tx_hash, prev_tx_vout) IN (
904
+ SELECT * FROM UNNEST($2::text[], $3::int[])
905
+ )`,
906
+ [reservationId, txHashes, vouts, this.identity]
907
+ );
908
+
909
+ return {
910
+ id: reservationId,
911
+ tokenOutputs: {
912
+ metadata,
913
+ outputs: selectedOutputs,
914
+ },
915
+ };
916
+ });
917
+ } catch (error) {
918
+ if (error instanceof TokenStoreError) throw error;
919
+ throw new TokenStoreError(
920
+ `Failed to reserve token outputs by outpoints: ${error.message}`,
921
+ error
922
+ );
923
+ }
924
+ }
925
+
788
926
  /**
789
927
  * Cancel a reservation, releasing reserved outputs.
790
928
  * @param {string} id - Reservation ID
@@ -40,6 +40,35 @@ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
40
40
  */
41
41
  const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
42
42
 
43
+ /**
44
+ * Slim projection: only (id, value) for leaves the selection might use.
45
+ * Includes all leaves with value <= $2 (covers exact-match + the small-leaf
46
+ * accumulators for the minimum-amount path) plus the single smallest leaf
47
+ * with value > $2 (covers the minimum-amount fallback case where one larger
48
+ * leaf is sufficient). $1 is the user id.
49
+ */
50
+ const SLIM_LEAF_CANDIDATES_SQL = `
51
+ SELECT id, (data->>'value')::bigint AS value
52
+ FROM brz_tree_leaves
53
+ WHERE user_id = $1
54
+ AND status = 'Available'
55
+ AND is_missing_from_operators = FALSE
56
+ AND reservation_id IS NULL
57
+ AND (
58
+ (data->>'value')::bigint <= $2
59
+ OR id = (
60
+ SELECT id FROM brz_tree_leaves
61
+ WHERE user_id = $1
62
+ AND status = 'Available'
63
+ AND is_missing_from_operators = FALSE
64
+ AND reservation_id IS NULL
65
+ AND (data->>'value')::bigint > $2
66
+ ORDER BY (data->>'value')::bigint
67
+ LIMIT 1
68
+ )
69
+ )
70
+ `;
71
+
43
72
  /**
44
73
  * Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
45
74
  * prefix together with the identity pubkey and folding the first 8 bytes of
@@ -217,6 +246,34 @@ class PostgresTreeStore {
217
246
  }
218
247
  }
219
248
 
249
+ async getVerifiedLeafKeys() {
250
+ try {
251
+ // Project just the two pubkeys out of the JSON, skipping each leaf's
252
+ // `data` blob (up to five transactions). The filter matches the verified
253
+ // categories the SDK expects: every reserved leaf plus every Available
254
+ // one, and nothing non-Available and unreserved.
255
+ const result = await this.pool.query(
256
+ `
257
+ SELECT l.id AS id,
258
+ l.data->>'verifying_public_key' AS verifying,
259
+ l.data->'signing_keyshare'->>'public_key' AS keyshare
260
+ FROM brz_tree_leaves l
261
+ LEFT JOIN brz_tree_reservations r
262
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
263
+ WHERE l.user_id = $1
264
+ AND (r.purpose IS NOT NULL OR l.status = 'Available')
265
+ `,
266
+ [this.identity]
267
+ );
268
+ return result.rows.map((row) => [row.id, row.verifying, row.keyshare]);
269
+ } catch (error) {
270
+ throw new TreeStoreError(
271
+ `Failed to get verified leaf keys: ${error.message}`,
272
+ error
273
+ );
274
+ }
275
+ }
276
+
220
277
  async getLeaves() {
221
278
  try {
222
279
  const result = await this.pool.query(
@@ -485,35 +542,10 @@ class PostgresTreeStore {
485
542
  );
486
543
  const available = Number(totalResult.rows[0].total);
487
544
 
488
- // Slim projection: only (id, value) for leaves the selection might use.
489
- // Includes all leaves with value <= maxTarget (covers exact-match + the
490
- // small-leaf accumulators for the minimum-amount path) plus the single
491
- // smallest leaf with value > maxTarget (covers the minimum-amount
492
- // fallback case where one larger leaf is sufficient).
493
- const slimResult = await client.query(
494
- `
495
- SELECT id, (data->>'value')::bigint AS value
496
- FROM brz_tree_leaves
497
- WHERE user_id = $1
498
- AND status = 'Available'
499
- AND is_missing_from_operators = FALSE
500
- AND reservation_id IS NULL
501
- AND (
502
- (data->>'value')::bigint <= $2
503
- OR id = (
504
- SELECT id FROM brz_tree_leaves
505
- WHERE user_id = $1
506
- AND status = 'Available'
507
- AND is_missing_from_operators = FALSE
508
- AND reservation_id IS NULL
509
- AND (data->>'value')::bigint > $2
510
- ORDER BY (data->>'value')::bigint
511
- LIMIT 1
512
- )
513
- )
514
- `,
515
- [this.identity, maxTarget]
516
- );
545
+ const slimResult = await client.query(SLIM_LEAF_CANDIDATES_SQL, [
546
+ this.identity,
547
+ maxTarget,
548
+ ]);
517
549
 
518
550
  const slimLeaves = slimResult.rows.map((r) => ({
519
551
  id: r.id,
@@ -594,6 +626,87 @@ class PostgresTreeStore {
594
626
  }
595
627
  }
596
628
 
629
+ async trySelectLeaves(targetAmounts) {
630
+ try {
631
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
632
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
633
+
634
+ return await this._withTransaction(async (client) => {
635
+ const slimResult = await client.query(SLIM_LEAF_CANDIDATES_SQL, [
636
+ this.identity,
637
+ maxTarget,
638
+ ]);
639
+
640
+ const slimLeaves = slimResult.rows.map((r) => ({
641
+ id: r.id,
642
+ value: Number(r.value),
643
+ }));
644
+
645
+ const selected = this._selectLeavesByTargetAmounts(slimLeaves, targetAmounts);
646
+ if (selected !== null && selected.length > 0) {
647
+ const fullLeaves = await this._fetchFullLeavesByIds(
648
+ client,
649
+ selected.map((l) => l.id)
650
+ );
651
+ return { type: "exact", leaves: fullLeaves };
652
+ }
653
+
654
+ const minSelected = this._selectLeavesByMinimumAmount(slimLeaves, targetAmount);
655
+ if (minSelected !== null) {
656
+ const fullLeaves = await this._fetchFullLeavesByIds(
657
+ client,
658
+ minSelected.map((l) => l.id)
659
+ );
660
+ return { type: "swapNeeded", leaves: fullLeaves };
661
+ }
662
+
663
+ return { type: "insufficientFunds" };
664
+ });
665
+ } catch (error) {
666
+ if (error instanceof TreeStoreError) throw error;
667
+ throw new TreeStoreError(
668
+ `Failed to try select leaves: ${error.message}`,
669
+ error
670
+ );
671
+ }
672
+ }
673
+
674
+ async tryReserveLeavesByIds(leafIds, purpose) {
675
+ try {
676
+ return await this._withWriteTransaction(async (client) => {
677
+ if (!leafIds || leafIds.length === 0) {
678
+ throw new TreeStoreError("NonReservableLeaves");
679
+ }
680
+ // Every requested leaf must be available and unreserved; otherwise
681
+ // reserve nothing (the transaction rolls back).
682
+ const availableResult = await client.query(
683
+ `
684
+ SELECT id FROM brz_tree_leaves
685
+ WHERE user_id = $1
686
+ AND id = ANY($2)
687
+ AND status = 'Available'
688
+ AND is_missing_from_operators = FALSE
689
+ AND reservation_id IS NULL
690
+ `,
691
+ [this.identity, leafIds]
692
+ );
693
+ if (availableResult.rows.length !== leafIds.length) {
694
+ throw new TreeStoreError("NonReservableLeaves");
695
+ }
696
+ const fullLeaves = await this._fetchFullLeavesByIds(client, leafIds);
697
+ const reservationId = this._generateId();
698
+ await this._createReservation(client, reservationId, fullLeaves, purpose, 0);
699
+ return { id: reservationId, leaves: fullLeaves };
700
+ });
701
+ } catch (error) {
702
+ if (error instanceof TreeStoreError) throw error;
703
+ throw new TreeStoreError(
704
+ `Failed to try reserve leaves by ids: ${error.message}`,
705
+ error
706
+ );
707
+ }
708
+ }
709
+
597
710
  _maxTargetForPrefilter(targetAmounts) {
598
711
  if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
599
712
  if (targetAmounts.type === "amountAndFee") {
@@ -612,10 +725,23 @@ class PostgresTreeStore {
612
725
  async _fetchFullLeavesByIds(client, ids) {
613
726
  if (!ids || ids.length === 0) return [];
614
727
  const result = await client.query(
615
- "SELECT data FROM brz_tree_leaves WHERE user_id = $2 AND id = ANY($1)",
728
+ "SELECT id, data FROM brz_tree_leaves WHERE user_id = $2 AND id = ANY($1)",
616
729
  [ids, this.identity]
617
730
  );
618
- return result.rows.map((r) => r.data);
731
+ const byId = new Map(result.rows.map((r) => [r.id, r.data]));
732
+ const ordered = ids
733
+ .map((id) => {
734
+ const data = byId.get(id);
735
+ byId.delete(id);
736
+ return data;
737
+ })
738
+ .filter((data) => data !== undefined);
739
+ if (ordered.length !== ids.length) {
740
+ throw new TreeStoreError(
741
+ `Could not resolve full data for all selected leaves (wanted ${ids.length}, got ${ordered.length})`
742
+ );
743
+ }
744
+ return ordered;
619
745
  }
620
746
 
621
747
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breeztech/breez-sdk-spark",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
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
@@ -73,6 +73,11 @@ export function defaultServerConfig(...args) {
73
73
  return _module.defaultServerConfig(...args);
74
74
  }
75
75
 
76
+ export function defaultSessionStore(...args) {
77
+ if (!_module) _notInitialized('defaultSessionStore');
78
+ return _module.defaultSessionStore(...args);
79
+ }
80
+
76
81
  export function defaultStorage(...args) {
77
82
  if (!_module) _notInitialized('defaultStorage');
78
83
  return _module.defaultStorage(...args);
@@ -137,6 +142,13 @@ export class BreezSdk {
137
142
  }
138
143
  }
139
144
 
145
+ export class DefaultSessionStore {
146
+ constructor(...args) {
147
+ if (!_module) _notInitialized('new DefaultSessionStore');
148
+ return new _module.DefaultSessionStore(...args);
149
+ }
150
+ }
151
+
140
152
  export class ExternalBreezSignerHandle {
141
153
  constructor(...args) {
142
154
  if (!_module) _notInitialized('new ExternalBreezSignerHandle');