@breeztech/breez-sdk-spark 0.20.0-dev1 → 0.21.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.
@@ -57,6 +57,56 @@ function _identityLockKey(prefix, identity) {
57
57
  return hash.digest().readBigInt64BE(0);
58
58
  }
59
59
 
60
+ /**
61
+ * Groups outputs by the token each one names, keeping first-seen token order.
62
+ * @param {Array<Object>} outputs
63
+ * @returns {Array<[string, Array<Object>]>}
64
+ */
65
+ function _groupOutputsByToken(outputs) {
66
+ const grouped = new Map();
67
+ for (const output of outputs ?? []) {
68
+ const identifier = output.output.tokenIdentifier;
69
+ if (!grouped.has(identifier)) {
70
+ grouped.set(identifier, []);
71
+ }
72
+ grouped.get(identifier).push(output);
73
+ }
74
+ return Array.from(grouped.entries());
75
+ }
76
+
77
+ /**
78
+ * The metadata entry describing `identifier`, or null when absent.
79
+ */
80
+ function _metadataFor(metadata, identifier) {
81
+ return (metadata ?? []).find((m) => m.identifier === identifier) ?? null;
82
+ }
83
+
84
+ /** The distinct token identifiers the outputs belong to, in first-seen order. */
85
+ function _tokenIdentifiersOf(outputs) {
86
+ return Array.from(new Set(outputs.map((o) => o.output.tokenIdentifier)));
87
+ }
88
+
89
+ /**
90
+ * Rejects reservation targets that can never be satisfied.
91
+ * @param {Array<[string, {type: string, value: string|number}]>} targets
92
+ */
93
+ function _validateTargets(targets) {
94
+ if (!targets || targets.length === 0) {
95
+ throw new TokenStoreError("No reservation targets provided");
96
+ }
97
+ for (const [, target] of targets) {
98
+ if (
99
+ target.type === "minTotalValue" &&
100
+ (!target.value || target.value === "0")
101
+ ) {
102
+ throw new TokenStoreError("Amount to reserve must be greater than zero");
103
+ }
104
+ if (target.type === "maxOutputCount" && !target.value) {
105
+ throw new TokenStoreError("Count to reserve must be greater than zero");
106
+ }
107
+ }
108
+ }
109
+
60
110
  class PostgresTokenStore {
61
111
  /**
62
112
  * @param {import('pg').Pool} pool
@@ -160,7 +210,9 @@ class PostgresTokenStore {
160
210
 
161
211
  /**
162
212
  * Set the full set of token outputs, reconciling reservations.
163
- * @param {Array<{metadata: Object, outputs: Array}>} tokenOutputs
213
+ * @param {{metadata: Array<Object>, outputs: Array<Object>}} tokenOutputs - A flat
214
+ * output list that may span several tokens, plus one metadata entry per token
215
+ * the outputs name.
164
216
  * @param {number} refreshStartedAtMs - Milliseconds since epoch when the refresh started
165
217
  */
166
218
  async setTokensOutputs(tokenOutputs, refreshStartedAtMs) {
@@ -217,12 +269,9 @@ class PostgresTokenStore {
217
269
  );
218
270
 
219
271
  // Build a set of all incoming outpoints for reconciliation
220
- const incomingOutpoints = new Set();
221
- for (const to of tokenOutputs) {
222
- for (const o of to.outputs) {
223
- incomingOutpoints.add(`${o.prevTxHash}:${o.prevTxVout}`);
224
- }
225
- }
272
+ const incomingOutpoints = new Set(
273
+ tokenOutputs.outputs.map((o) => `${o.prevTxHash}:${o.prevTxVout}`)
274
+ );
226
275
 
227
276
  // Reconcile reservations: find reserved outputs that no longer exist
228
277
  const reservedRows = await client.query(
@@ -326,19 +375,25 @@ class PostgresTokenStore {
326
375
  );
327
376
 
328
377
  // Insert new metadata and outputs, excluding spent and reserved
329
- for (const to of tokenOutputs) {
330
- await this._upsertMetadata(client, to.metadata);
378
+ for (const [tokenIdentifier, outputs] of _groupOutputsByToken(
379
+ tokenOutputs.outputs
380
+ )) {
381
+ const metadata = _metadataFor(tokenOutputs.metadata, tokenIdentifier);
382
+ if (!metadata) {
383
+ this._log(
384
+ "warn",
385
+ `Skipping outputs of token ${tokenIdentifier}: no metadata provided`
386
+ );
387
+ continue;
388
+ }
389
+ await this._upsertMetadata(client, metadata);
331
390
 
332
- for (const output of to.outputs) {
391
+ for (const output of outputs) {
333
392
  const outpoint = `${output.prevTxHash}:${output.prevTxVout}`;
334
393
  if (reservedOutpoints.has(outpoint) || spentOutpoints.has(outpoint)) {
335
394
  continue;
336
395
  }
337
- await this._insertSingleOutput(
338
- client,
339
- to.metadata.identifier,
340
- output
341
- );
396
+ await this._insertSingleOutput(client, tokenIdentifier, output);
342
397
  }
343
398
  }
344
399
  });
@@ -542,14 +597,12 @@ class PostgresTokenStore {
542
597
  }
543
598
  }
544
599
 
545
- /**
546
- * Insert token outputs (upsert metadata, insert outputs with ON CONFLICT DO NOTHING).
547
- * @param {{metadata: Object, outputs: Array}} tokenOutputs
548
- */
549
600
  /**
550
601
  * Atomically remove spent outputs and insert new outputs.
551
602
  * @param {Array<[string, number]>} outputsToRemove - Array of [prevTxHash, prevTxVout] tuples
552
- * @param {Object|null} outputsToAdd - Token outputs to insert (with metadata)
603
+ * @param {{metadata: Array<Object>, outputs: Array<Object>}} outputsToAdd - A flat
604
+ * output list that may span several tokens, plus one metadata entry per token
605
+ * the outputs name.
553
606
  * @returns {Promise<void>}
554
607
  */
555
608
  async updateTokenOutputs(outputsToRemove, outputsToAdd) {
@@ -573,29 +626,34 @@ class PostgresTokenStore {
573
626
  }
574
627
  }
575
628
 
576
- // 2. Insert new outputs.
577
- if (outputsToAdd) {
578
- await this._upsertMetadata(client, outputsToAdd.metadata);
579
-
580
- if (outputsToAdd.outputs.length > 0) {
581
- const txHashes = outputsToAdd.outputs.map((o) => o.prevTxHash);
582
- const vouts = outputsToAdd.outputs.map((o) => o.prevTxVout);
583
- await client.query(
584
- `DELETE FROM brz_token_spent_outputs
585
- WHERE user_id = $1
586
- AND (prev_tx_hash, prev_tx_vout) IN (
587
- SELECT * FROM UNNEST($2::text[], $3::int[])
588
- )`,
589
- [this.identity, txHashes, vouts]
629
+ // 2. Insert new outputs, grouped by the token each one names.
630
+ for (const [tokenIdentifier, outputs] of _groupOutputsByToken(
631
+ outputsToAdd?.outputs
632
+ )) {
633
+ const metadata = _metadataFor(outputsToAdd.metadata, tokenIdentifier);
634
+ if (!metadata) {
635
+ this._log(
636
+ "warn",
637
+ `Skipping outputs of token ${tokenIdentifier}: no metadata provided`
590
638
  );
639
+ continue;
591
640
  }
641
+ await this._upsertMetadata(client, metadata);
592
642
 
593
- for (const output of outputsToAdd.outputs) {
594
- await this._insertSingleOutput(
595
- client,
596
- outputsToAdd.metadata.identifier,
597
- output
598
- );
643
+ // Clear spent status for outputs being (re-)added.
644
+ const txHashes = outputs.map((o) => o.prevTxHash);
645
+ const vouts = outputs.map((o) => o.prevTxVout);
646
+ await client.query(
647
+ `DELETE FROM brz_token_spent_outputs
648
+ WHERE user_id = $1
649
+ AND (prev_tx_hash, prev_tx_vout) IN (
650
+ SELECT * FROM UNNEST($2::text[], $3::int[])
651
+ )`,
652
+ [this.identity, txHashes, vouts]
653
+ );
654
+
655
+ for (const output of outputs) {
656
+ await this._insertSingleOutput(client, tokenIdentifier, output);
599
657
  }
600
658
  }
601
659
  });
@@ -610,108 +668,44 @@ class PostgresTokenStore {
610
668
 
611
669
  /**
612
670
  * Reserve token outputs for a payment or swap.
613
- * @param {string} tokenIdentifier
614
- * @param {{type: string, value: number}} target - MinTotalValue or MaxOutputCount
671
+ *
672
+ * Selection and reservation of every target share one transaction and the
673
+ * write lock it holds, so a multi-token reservation is all-or-nothing: no
674
+ * concurrent writer can take an output between two targets being selected.
675
+ * @param {Array<[string, {type: string, value: string|number}]>} targets - One
676
+ * [tokenIdentifier, target] pair per token to reserve for, target being
677
+ * MinTotalValue or MaxOutputCount
615
678
  * @param {string} purpose - "Payment" or "Swap"
616
679
  * @param {Array|null} preferredOutputs
617
680
  * @param {string|null} selectionStrategy - "SmallestFirst" or "LargestFirst"
618
- * @returns {Promise<{id: string, tokenOutputs: {metadata: Object, outputs: Array}}>}
681
+ * @returns {Promise<{id: string, tokenOutputs: {metadata: Array<Object>, outputs: Array}}>}
619
682
  */
620
683
  async reserveTokenOutputs(
621
- tokenIdentifier,
622
- target,
684
+ targets,
623
685
  purpose,
624
686
  preferredOutputs,
625
687
  selectionStrategy
626
688
  ) {
627
689
  try {
628
- return await this._withWriteTransaction(async (client) => {
629
- // Validate target
630
- if (target.type === "minTotalValue" && (!target.value || target.value === "0")) {
631
- throw new TokenStoreError(
632
- "Amount to reserve must be greater than zero"
633
- );
634
- }
635
- if (target.type === "maxOutputCount" && (!target.value || target.value === 0)) {
636
- throw new TokenStoreError(
637
- "Count to reserve must be greater than zero"
638
- );
639
- }
690
+ _validateTargets(targets);
640
691
 
641
- // Get metadata
642
- const metadataResult = await client.query(
643
- "SELECT * FROM brz_token_metadata WHERE user_id = $1 AND identifier = $2",
644
- [this.identity, tokenIdentifier]
645
- );
646
-
647
- if (metadataResult.rows.length === 0) {
648
- throw new TokenStoreError(
649
- `Token outputs not found for identifier: ${tokenIdentifier}`
650
- );
651
- }
652
-
653
- const metadata = this._metadataFromRow(metadataResult.rows[0]);
654
-
655
- // Get available (non-reserved) outputs
656
- const outputRows = await client.query(
657
- `SELECT o.owner_public_key, o.revocation_commitment,
658
- o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
659
- o.token_public_key, o.token_amount, o.token_identifier,
660
- o.prev_tx_hash, o.prev_tx_vout
661
- FROM brz_token_outputs o
662
- WHERE o.user_id = $1
663
- AND o.token_identifier = $2
664
- AND o.reservation_id IS NULL`,
665
- [this.identity, tokenIdentifier]
666
- );
667
-
668
- let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
669
-
670
- // Filter by preferred if provided
671
- if (preferredOutputs) {
672
- const preferredOutpoints = new Set(
673
- preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
674
- );
675
- outputs = outputs.filter((o) =>
676
- preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
677
- );
678
- }
679
-
680
- const selectedOutputs = this._selectOutputs(
681
- outputs,
682
- target,
692
+ return await this._withWriteTransaction(async (client) => {
693
+ const selected = await this._selectForTargets(
694
+ client,
695
+ targets,
696
+ preferredOutputs,
683
697
  selectionStrategy
684
698
  );
685
699
 
686
- // Create reservation
687
700
  const reservationId = this._generateId();
688
-
689
- await client.query(
690
- "INSERT INTO brz_token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
691
- [this.identity, reservationId, purpose]
701
+ await this._insertReservation(client, reservationId, purpose);
702
+ await this._assignReservation(
703
+ client,
704
+ reservationId,
705
+ selected.outputs
692
706
  );
693
707
 
694
- // Set reservation_id on selected outputs (by outpoint)
695
- if (selectedOutputs.length > 0) {
696
- const selectedTxHashes = selectedOutputs.map((o) => o.prevTxHash);
697
- const selectedVouts = selectedOutputs.map((o) => o.prevTxVout);
698
- await client.query(
699
- `UPDATE brz_token_outputs SET reservation_id = $1
700
- WHERE user_id = $4
701
- AND (prev_tx_hash, prev_tx_vout) IN (
702
- SELECT * FROM UNNEST($2::text[], $3::int[])
703
- )`,
704
- [reservationId, selectedTxHashes, selectedVouts, this.identity]
705
- );
706
- }
707
-
708
- return {
709
- id: reservationId,
710
- tokenOutputs: {
711
- metadata,
712
- outputs: selectedOutputs,
713
- },
714
- };
708
+ return { id: reservationId, tokenOutputs: selected };
715
709
  });
716
710
  } catch (error) {
717
711
  if (error instanceof TokenStoreError) throw error;
@@ -722,7 +716,7 @@ class PostgresTokenStore {
722
716
  }
723
717
  }
724
718
 
725
- _selectOutputs(outputs, target, selectionStrategy) {
719
+ _selectOutputs(tokenIdentifier, outputs, target, selectionStrategy) {
726
720
  if (target.type === "minTotalValue") {
727
721
  const amount = BigInt(target.value);
728
722
  const totalAvailable = outputs.reduce(
@@ -730,7 +724,9 @@ class PostgresTokenStore {
730
724
  0n
731
725
  );
732
726
  if (totalAvailable < amount) {
733
- throw new TokenStoreError("InsufficientFunds");
727
+ throw new TokenStoreError(
728
+ `InsufficientFunds: ${tokenIdentifier}`
729
+ );
734
730
  }
735
731
 
736
732
  const exactMatch = outputs.find(
@@ -760,7 +756,9 @@ class PostgresTokenStore {
760
756
  remaining -= BigInt(output.output.tokenAmount);
761
757
  }
762
758
  if (remaining > 0n) {
763
- throw new TokenStoreError("InsufficientFunds");
759
+ throw new TokenStoreError(
760
+ `InsufficientFunds: ${tokenIdentifier}`
761
+ );
764
762
  }
765
763
  return selected;
766
764
  }
@@ -784,60 +782,24 @@ class PostgresTokenStore {
784
782
  throw new TokenStoreError(`Unknown target type: ${target.type}`);
785
783
  }
786
784
 
787
- async selectTokenOutputs(
788
- tokenIdentifier,
789
- target,
790
- preferredOutputs,
791
- selectionStrategy
792
- ) {
785
+ /**
786
+ * Select outputs covering every target, without reserving them.
787
+ * @param {Array<[string, {type: string, value: string|number}]>} targets - One
788
+ * [tokenIdentifier, target] pair per token to select for
789
+ * @param {Array|null} preferredOutputs
790
+ * @param {string|null} selectionStrategy - "SmallestFirst" or "LargestFirst"
791
+ * @returns {Promise<{metadata: Array<Object>, outputs: Array}>}
792
+ */
793
+ async selectTokenOutputs(targets, preferredOutputs, selectionStrategy) {
793
794
  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]);
795
+ _validateTargets(targets);
811
796
 
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,
797
+ return await this._selectForTargets(
798
+ this.pool,
799
+ targets,
800
+ preferredOutputs,
838
801
  selectionStrategy
839
802
  );
840
- return { metadata, outputs: selectedOutputs };
841
803
  } catch (error) {
842
804
  if (error instanceof TokenStoreError) throw error;
843
805
  throw new TokenStoreError(
@@ -847,25 +809,22 @@ class PostgresTokenStore {
847
809
  }
848
810
  }
849
811
 
850
- async reserveTokenOutputsByOutpoints(tokenIdentifier, outpoints, purpose) {
812
+ /**
813
+ * Reserve the outputs at the given outpoints, which may belong to several tokens.
814
+ * @param {Array<{prevTxHash: string, prevTxVout: number}>} outpoints
815
+ * @param {string} purpose - "Payment" or "Swap"
816
+ * @returns {Promise<{id: string, tokenOutputs: {metadata: Array<Object>, outputs: Array}}>}
817
+ */
818
+ async reserveTokenOutputsByOutpoints(outpoints, purpose) {
851
819
  try {
852
820
  if (!outpoints || outpoints.length === 0) {
853
821
  throw new TokenStoreError("No outpoints provided");
854
822
  }
855
823
  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
824
  const txHashes = outpoints.map((o) => o.prevTxHash);
868
825
  const vouts = outpoints.map((o) => o.prevTxVout);
826
+
827
+ // The outpoints are not scoped to a token: they may belong to several.
869
828
  const outputRows = await client.query(
870
829
  `SELECT o.owner_public_key, o.revocation_commitment,
871
830
  o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
@@ -873,12 +832,11 @@ class PostgresTokenStore {
873
832
  o.prev_tx_hash, o.prev_tx_vout
874
833
  FROM brz_token_outputs o
875
834
  WHERE o.user_id = $1
876
- AND o.token_identifier = $2
877
835
  AND o.reservation_id IS NULL
878
836
  AND (o.prev_tx_hash, o.prev_tx_vout) IN (
879
- SELECT * FROM UNNEST($3::text[], $4::int[])
837
+ SELECT * FROM UNNEST($2::text[], $3::int[])
880
838
  )`,
881
- [this.identity, tokenIdentifier, txHashes, vouts]
839
+ [this.identity, txHashes, vouts]
882
840
  );
883
841
 
884
842
  const selectedOutputs = outputRows.rows.map((row) =>
@@ -892,20 +850,15 @@ class PostgresTokenStore {
892
850
  throw new TokenStoreError("InsufficientFunds");
893
851
  }
894
852
 
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]
853
+ const metadata = await this._fetchMetadata(
854
+ client,
855
+ _tokenIdentifiersOf(selectedOutputs)
907
856
  );
908
857
 
858
+ const reservationId = this._generateId();
859
+ await this._insertReservation(client, reservationId, purpose);
860
+ await this._assignReservation(client, reservationId, selectedOutputs);
861
+
909
862
  return {
910
863
  id: reservationId,
911
864
  tokenOutputs: {
@@ -1054,6 +1007,132 @@ class PostgresTokenStore {
1054
1007
 
1055
1008
  // ===== Private Helpers =====
1056
1009
 
1010
+ _log(level, message) {
1011
+ if (this.logger && typeof this.logger.log === "function") {
1012
+ this.logger.log({ line: message, level });
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Select available outputs covering every target, without reserving them.
1018
+ *
1019
+ * The result carries metadata for every requested token. A target whose token
1020
+ * is unknown to this tenant is an error, and a target that cannot be covered
1021
+ * by the available outputs yields `InsufficientFunds`.
1022
+ * @param {import('pg').PoolClient|import('pg').Pool} client
1023
+ * @returns {Promise<{metadata: Array<Object>, outputs: Array}>}
1024
+ */
1025
+ async _selectForTargets(client, targets, preferredOutputs, selectionStrategy) {
1026
+ const tokenIdentifiers = Array.from(new Set(targets.map(([id]) => id)));
1027
+
1028
+ const metadata = await this._fetchMetadata(client, tokenIdentifiers);
1029
+ for (const identifier of tokenIdentifiers) {
1030
+ if (!metadata.some((m) => m.identifier === identifier)) {
1031
+ throw new TokenStoreError(
1032
+ `Token outputs not found for identifier: ${identifier}`
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ const outputRows = await client.query(
1038
+ `SELECT o.owner_public_key, o.revocation_commitment,
1039
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
1040
+ o.token_public_key, o.token_amount, o.token_identifier,
1041
+ o.prev_tx_hash, o.prev_tx_vout
1042
+ FROM brz_token_outputs o
1043
+ WHERE o.user_id = $1
1044
+ AND o.token_identifier = ANY($2)
1045
+ AND o.reservation_id IS NULL`,
1046
+ [this.identity, tokenIdentifiers]
1047
+ );
1048
+
1049
+ let available = outputRows.rows.map((row) => this._outputFromRow(row));
1050
+
1051
+ if (preferredOutputs) {
1052
+ const preferredOutpoints = new Set(
1053
+ preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
1054
+ );
1055
+ available = available.filter((o) =>
1056
+ preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
1057
+ );
1058
+ }
1059
+
1060
+ const availablePerToken = new Map();
1061
+ for (const output of available) {
1062
+ const identifier = output.output.tokenIdentifier;
1063
+ if (!availablePerToken.has(identifier)) {
1064
+ availablePerToken.set(identifier, []);
1065
+ }
1066
+ availablePerToken.get(identifier).push(output);
1067
+ }
1068
+
1069
+ const outputs = [];
1070
+ for (const [tokenIdentifier, target] of targets) {
1071
+ const candidates = availablePerToken.get(tokenIdentifier) ?? [];
1072
+ const selected = this._selectOutputs(
1073
+ tokenIdentifier,
1074
+ candidates,
1075
+ target,
1076
+ selectionStrategy
1077
+ );
1078
+
1079
+ // Outputs picked for one target are withheld from the next, so repeated
1080
+ // entries for the same token do not select the same output twice.
1081
+ const taken = new Set(
1082
+ selected.map((o) => `${o.prevTxHash}:${o.prevTxVout}`)
1083
+ );
1084
+ availablePerToken.set(
1085
+ tokenIdentifier,
1086
+ candidates.filter((o) => !taken.has(`${o.prevTxHash}:${o.prevTxVout}`))
1087
+ );
1088
+
1089
+ outputs.push(...selected);
1090
+ }
1091
+
1092
+ return { metadata, outputs };
1093
+ }
1094
+
1095
+ /**
1096
+ * Load this tenant's metadata for the given token identifiers. Unknown
1097
+ * identifiers are simply absent from the result.
1098
+ */
1099
+ async _fetchMetadata(client, tokenIdentifiers) {
1100
+ if (tokenIdentifiers.length === 0) {
1101
+ return [];
1102
+ }
1103
+ const result = await client.query(
1104
+ "SELECT * FROM brz_token_metadata WHERE user_id = $1 AND identifier = ANY($2)",
1105
+ [this.identity, tokenIdentifiers]
1106
+ );
1107
+ return result.rows.map((row) => this._metadataFromRow(row));
1108
+ }
1109
+
1110
+ async _insertReservation(client, reservationId, purpose) {
1111
+ await client.query(
1112
+ "INSERT INTO brz_token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
1113
+ [this.identity, reservationId, purpose]
1114
+ );
1115
+ }
1116
+
1117
+ /**
1118
+ * Point the given outputs at the reservation, addressing them by outpoint.
1119
+ */
1120
+ async _assignReservation(client, reservationId, outputs) {
1121
+ if (outputs.length === 0) {
1122
+ return;
1123
+ }
1124
+ const txHashes = outputs.map((o) => o.prevTxHash);
1125
+ const vouts = outputs.map((o) => o.prevTxVout);
1126
+ await client.query(
1127
+ `UPDATE brz_token_outputs SET reservation_id = $1
1128
+ WHERE user_id = $4
1129
+ AND (prev_tx_hash, prev_tx_vout) IN (
1130
+ SELECT * FROM UNNEST($2::text[], $3::int[])
1131
+ )`,
1132
+ [reservationId, txHashes, vouts, this.identity]
1133
+ );
1134
+ }
1135
+
1057
1136
  /**
1058
1137
  * Generate a unique reservation ID (UUIDv4).
1059
1138
  */