@breeztech/breez-sdk-spark 0.20.0-dev1 → 0.21.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.
@@ -66,6 +66,60 @@ function buildPlaceholders(n) {
66
66
  return new Array(n).fill("?").join(", ");
67
67
  }
68
68
 
69
+ function buildPairPlaceholders(n) {
70
+ return new Array(n).fill("(?, ?)").join(", ");
71
+ }
72
+
73
+ /**
74
+ * Groups outputs by the token each one names, keeping first-seen token order.
75
+ * @param {Array<Object>} outputs
76
+ * @returns {Array<[string, Array<Object>]>}
77
+ */
78
+ function _groupOutputsByToken(outputs) {
79
+ const grouped = new Map();
80
+ for (const output of outputs ?? []) {
81
+ const identifier = output.output.tokenIdentifier;
82
+ if (!grouped.has(identifier)) {
83
+ grouped.set(identifier, []);
84
+ }
85
+ grouped.get(identifier).push(output);
86
+ }
87
+ return Array.from(grouped.entries());
88
+ }
89
+
90
+ /**
91
+ * The metadata entry describing `identifier`, or null when absent.
92
+ */
93
+ function _metadataFor(metadata, identifier) {
94
+ return (metadata ?? []).find((m) => m.identifier === identifier) ?? null;
95
+ }
96
+
97
+ /** The distinct token identifiers the outputs belong to, in first-seen order. */
98
+ function _tokenIdentifiersOf(outputs) {
99
+ return Array.from(new Set(outputs.map((o) => o.output.tokenIdentifier)));
100
+ }
101
+
102
+ /**
103
+ * Rejects reservation targets that can never be satisfied.
104
+ * @param {Array<[string, {type: string, value: string|number}]>} targets
105
+ */
106
+ function _validateTargets(targets) {
107
+ if (!targets || targets.length === 0) {
108
+ throw new TokenStoreError("No reservation targets provided");
109
+ }
110
+ for (const [, target] of targets) {
111
+ if (
112
+ target.type === "minTotalValue" &&
113
+ (!target.value || target.value === "0")
114
+ ) {
115
+ throw new TokenStoreError("Amount to reserve must be greater than zero");
116
+ }
117
+ if (target.type === "maxOutputCount" && !target.value) {
118
+ throw new TokenStoreError("Count to reserve must be greater than zero");
119
+ }
120
+ }
121
+ }
122
+
69
123
  class MysqlTokenStore {
70
124
  /**
71
125
  * @param {import('mysql2/promise').Pool} pool
@@ -190,6 +244,13 @@ class MysqlTokenStore {
190
244
 
191
245
  // ===== TokenOutputStore Methods =====
192
246
 
247
+ /**
248
+ * Set the full set of token outputs, reconciling reservations.
249
+ * @param {{metadata: Array<Object>, outputs: Array<Object>}} tokenOutputs - A flat
250
+ * output list that may span several tokens, plus one metadata entry per token
251
+ * the outputs name.
252
+ * @param {number} refreshStartedAtMs - Milliseconds since epoch when the refresh started
253
+ */
193
254
  async setTokensOutputs(tokenOutputs, refreshStartedAtMs) {
194
255
  try {
195
256
  const refreshTimestamp = new Date(refreshStartedAtMs);
@@ -238,12 +299,9 @@ class MysqlTokenStore {
238
299
  [this.identity, refreshTimestamp]
239
300
  );
240
301
 
241
- const incomingOutpoints = new Set();
242
- for (const to of tokenOutputs) {
243
- for (const o of to.outputs) {
244
- incomingOutpoints.add(`${o.prevTxHash}:${o.prevTxVout}`);
245
- }
246
- }
302
+ const incomingOutpoints = new Set(
303
+ tokenOutputs.outputs.map((o) => `${o.prevTxHash}:${o.prevTxVout}`)
304
+ );
247
305
 
248
306
  const [reservedRows] = await conn.query(
249
307
  `SELECT r.id, o.prev_tx_hash, o.prev_tx_vout
@@ -339,19 +397,25 @@ class MysqlTokenStore {
339
397
  [this.identity, this.identity]
340
398
  );
341
399
 
342
- for (const to of tokenOutputs) {
343
- await this._upsertMetadata(conn, to.metadata);
400
+ for (const [tokenIdentifier, outputs] of _groupOutputsByToken(
401
+ tokenOutputs.outputs
402
+ )) {
403
+ const metadata = _metadataFor(tokenOutputs.metadata, tokenIdentifier);
404
+ if (!metadata) {
405
+ this._log(
406
+ "warn",
407
+ `Skipping outputs of token ${tokenIdentifier}: no metadata provided`
408
+ );
409
+ continue;
410
+ }
411
+ await this._upsertMetadata(conn, metadata);
344
412
 
345
- for (const output of to.outputs) {
413
+ for (const output of outputs) {
346
414
  const outpoint = `${output.prevTxHash}:${output.prevTxVout}`;
347
415
  if (reservedOutpoints.has(outpoint) || spentOutpoints.has(outpoint)) {
348
416
  continue;
349
417
  }
350
- await this._insertSingleOutput(
351
- conn,
352
- to.metadata.identifier,
353
- output
354
- );
418
+ await this._insertSingleOutput(conn, tokenIdentifier, output);
355
419
  }
356
420
  }
357
421
  });
@@ -547,7 +611,9 @@ class MysqlTokenStore {
547
611
  /**
548
612
  * Atomically remove spent outputs and insert new outputs.
549
613
  * @param {Array<[string, number]>} outputsToRemove - Array of [prevTxHash, prevTxVout] tuples
550
- * @param {Object|null} outputsToAdd - Token outputs to insert (with metadata)
614
+ * @param {{metadata: Array<Object>, outputs: Array<Object>}} outputsToAdd - A flat
615
+ * output list that may span several tokens, plus one metadata entry per token
616
+ * the outputs name.
551
617
  * @returns {Promise<void>}
552
618
  */
553
619
  async updateTokenOutputs(outputsToRemove, outputsToAdd) {
@@ -571,30 +637,34 @@ class MysqlTokenStore {
571
637
  }
572
638
  }
573
639
 
574
- // 2. Insert new outputs.
575
- if (outputsToAdd) {
576
- await this._upsertMetadata(conn, outputsToAdd.metadata);
577
-
578
- if (outputsToAdd.outputs.length > 0) {
579
- const pairPlaceholders = outputsToAdd.outputs
580
- .map(() => "(?, ?)")
581
- .join(", ");
582
- const params = [this.identity];
583
- for (const o of outputsToAdd.outputs) {
584
- params.push(o.prevTxHash, o.prevTxVout);
585
- }
586
- await conn.query(
587
- `DELETE FROM brz_token_spent_outputs WHERE user_id = ? AND (prev_tx_hash, prev_tx_vout) IN (${pairPlaceholders})`,
588
- params
640
+ // 2. Insert new outputs, grouped by the token each one names.
641
+ for (const [tokenIdentifier, outputs] of _groupOutputsByToken(
642
+ outputsToAdd?.outputs
643
+ )) {
644
+ const metadata = _metadataFor(outputsToAdd.metadata, tokenIdentifier);
645
+ if (!metadata) {
646
+ this._log(
647
+ "warn",
648
+ `Skipping outputs of token ${tokenIdentifier}: no metadata provided`
589
649
  );
650
+ continue;
590
651
  }
652
+ await this._upsertMetadata(conn, metadata);
591
653
 
592
- for (const output of outputsToAdd.outputs) {
593
- await this._insertSingleOutput(
594
- conn,
595
- outputsToAdd.metadata.identifier,
596
- output
597
- );
654
+ // Clear spent status for outputs being (re-)added.
655
+ const params = [this.identity];
656
+ for (const o of outputs) {
657
+ params.push(o.prevTxHash, o.prevTxVout);
658
+ }
659
+ await conn.query(
660
+ `DELETE FROM brz_token_spent_outputs WHERE user_id = ? AND (prev_tx_hash, prev_tx_vout) IN (${buildPairPlaceholders(
661
+ outputs.length
662
+ )})`,
663
+ params
664
+ );
665
+
666
+ for (const output of outputs) {
667
+ await this._insertSingleOutput(conn, tokenIdentifier, output);
598
668
  }
599
669
  }
600
670
  });
@@ -607,98 +677,42 @@ class MysqlTokenStore {
607
677
  }
608
678
  }
609
679
 
680
+ /**
681
+ * Reserve token outputs for a payment or swap.
682
+ *
683
+ * Selection and reservation of every target share one transaction and the
684
+ * write lock it holds, so a multi-token reservation is all-or-nothing: no
685
+ * concurrent writer can take an output between two targets being selected.
686
+ * @param {Array<[string, {type: string, value: string|number}]>} targets - One
687
+ * [tokenIdentifier, target] pair per token to reserve for, target being
688
+ * MinTotalValue or MaxOutputCount
689
+ * @param {string} purpose - "Payment" or "Swap"
690
+ * @param {Array|null} preferredOutputs
691
+ * @param {string|null} selectionStrategy - "SmallestFirst" or "LargestFirst"
692
+ * @returns {Promise<{id: string, tokenOutputs: {metadata: Array<Object>, outputs: Array}}>}
693
+ */
610
694
  async reserveTokenOutputs(
611
- tokenIdentifier,
612
- target,
695
+ targets,
613
696
  purpose,
614
697
  preferredOutputs,
615
698
  selectionStrategy
616
699
  ) {
617
700
  try {
618
- return await this._withWriteTransaction(async (conn) => {
619
- if (
620
- target.type === "minTotalValue" &&
621
- (!target.value || target.value === "0")
622
- ) {
623
- throw new TokenStoreError(
624
- "Amount to reserve must be greater than zero"
625
- );
626
- }
627
- if (
628
- target.type === "maxOutputCount" &&
629
- (!target.value || target.value === 0)
630
- ) {
631
- throw new TokenStoreError(
632
- "Count to reserve must be greater than zero"
633
- );
634
- }
635
-
636
- const [metadataRows] = await conn.query(
637
- "SELECT * FROM brz_token_metadata WHERE user_id = ? AND identifier = ?",
638
- [this.identity, tokenIdentifier]
639
- );
640
-
641
- if (metadataRows.length === 0) {
642
- throw new TokenStoreError(
643
- `Token outputs not found for identifier: ${tokenIdentifier}`
644
- );
645
- }
646
-
647
- const metadata = this._metadataFromRow(metadataRows[0]);
648
-
649
- const [outputRows] = await conn.query(
650
- `SELECT o.owner_public_key, o.revocation_commitment,
651
- o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
652
- o.token_public_key, o.token_amount, o.token_identifier,
653
- o.prev_tx_hash, o.prev_tx_vout
654
- FROM brz_token_outputs o
655
- WHERE o.user_id = ? AND o.token_identifier = ? AND o.reservation_id IS NULL`,
656
- [this.identity, tokenIdentifier]
657
- );
658
-
659
- let outputs = outputRows.map((row) => this._outputFromRow(row));
660
-
661
- if (preferredOutputs) {
662
- const preferredOutpoints = new Set(
663
- preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
664
- );
665
- outputs = outputs.filter((o) =>
666
- preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
667
- );
668
- }
701
+ _validateTargets(targets);
669
702
 
670
- const selectedOutputs = this._selectOutputs(
671
- outputs,
672
- target,
703
+ return await this._withWriteTransaction(async (conn) => {
704
+ const selected = await this._selectForTargets(
705
+ conn,
706
+ targets,
707
+ preferredOutputs,
673
708
  selectionStrategy
674
709
  );
675
710
 
676
711
  const reservationId = this._generateId();
712
+ await this._insertReservation(conn, reservationId, purpose);
713
+ await this._assignReservation(conn, reservationId, selected.outputs);
677
714
 
678
- await conn.query(
679
- "INSERT INTO brz_token_reservations (user_id, id, purpose, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP(6))",
680
- [this.identity, reservationId, purpose]
681
- );
682
-
683
- if (selectedOutputs.length > 0) {
684
- const pairPlaceholders = selectedOutputs
685
- .map(() => "(?, ?)")
686
- .join(", ");
687
- const params = [reservationId, this.identity];
688
- for (const o of selectedOutputs) {
689
- params.push(o.prevTxHash, o.prevTxVout);
690
- }
691
- await conn.query(
692
- `UPDATE brz_token_outputs SET reservation_id = ? WHERE user_id = ?
693
- AND (prev_tx_hash, prev_tx_vout) IN (${pairPlaceholders})`,
694
- params
695
- );
696
- }
697
-
698
- return {
699
- id: reservationId,
700
- tokenOutputs: { metadata, outputs: selectedOutputs },
701
- };
715
+ return { id: reservationId, tokenOutputs: selected };
702
716
  });
703
717
  } catch (error) {
704
718
  if (error instanceof TokenStoreError) throw error;
@@ -709,7 +723,7 @@ class MysqlTokenStore {
709
723
  }
710
724
  }
711
725
 
712
- _selectOutputs(outputs, target, selectionStrategy) {
726
+ _selectOutputs(tokenIdentifier, outputs, target, selectionStrategy) {
713
727
  if (target.type === "minTotalValue") {
714
728
  const amount = BigInt(target.value);
715
729
  const totalAvailable = outputs.reduce(
@@ -717,7 +731,9 @@ class MysqlTokenStore {
717
731
  0n
718
732
  );
719
733
  if (totalAvailable < amount) {
720
- throw new TokenStoreError("InsufficientFunds");
734
+ throw new TokenStoreError(
735
+ `InsufficientFunds: ${tokenIdentifier}`
736
+ );
721
737
  }
722
738
 
723
739
  const exactMatch = outputs.find(
@@ -747,7 +763,9 @@ class MysqlTokenStore {
747
763
  remaining -= BigInt(output.output.tokenAmount);
748
764
  }
749
765
  if (remaining > 0n) {
750
- throw new TokenStoreError("InsufficientFunds");
766
+ throw new TokenStoreError(
767
+ `InsufficientFunds: ${tokenIdentifier}`
768
+ );
751
769
  }
752
770
  return selected;
753
771
  }
@@ -771,64 +789,24 @@ class MysqlTokenStore {
771
789
  throw new TokenStoreError(`Unknown target type: ${target.type}`);
772
790
  }
773
791
 
774
- async selectTokenOutputs(
775
- tokenIdentifier,
776
- target,
777
- preferredOutputs,
778
- selectionStrategy
779
- ) {
792
+ /**
793
+ * Select outputs covering every target, without reserving them.
794
+ * @param {Array<[string, {type: string, value: string|number}]>} targets - One
795
+ * [tokenIdentifier, target] pair per token to select for
796
+ * @param {Array|null} preferredOutputs
797
+ * @param {string|null} selectionStrategy - "SmallestFirst" or "LargestFirst"
798
+ * @returns {Promise<{metadata: Array<Object>, outputs: Array}>}
799
+ */
800
+ async selectTokenOutputs(targets, preferredOutputs, selectionStrategy) {
780
801
  try {
781
- if (
782
- target.type === "minTotalValue" &&
783
- (!target.value || target.value === "0")
784
- ) {
785
- throw new TokenStoreError("Amount to reserve must be greater than zero");
786
- }
787
- if (
788
- target.type === "maxOutputCount" &&
789
- (!target.value || target.value === 0)
790
- ) {
791
- throw new TokenStoreError("Count to reserve must be greater than zero");
792
- }
793
-
794
- const [metadataRows] = await this.pool.query(
795
- "SELECT * FROM brz_token_metadata WHERE user_id = ? AND identifier = ?",
796
- [this.identity, tokenIdentifier]
797
- );
798
- if (metadataRows.length === 0) {
799
- throw new TokenStoreError(
800
- `Token outputs not found for identifier: ${tokenIdentifier}`
801
- );
802
- }
803
- const metadata = this._metadataFromRow(metadataRows[0]);
804
-
805
- const [outputRows] = await this.pool.query(
806
- `SELECT o.owner_public_key, o.revocation_commitment,
807
- o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
808
- o.token_public_key, o.token_amount, o.token_identifier,
809
- o.prev_tx_hash, o.prev_tx_vout
810
- FROM brz_token_outputs o
811
- WHERE o.user_id = ? AND o.token_identifier = ? AND o.reservation_id IS NULL`,
812
- [this.identity, tokenIdentifier]
813
- );
814
-
815
- let outputs = outputRows.map((row) => this._outputFromRow(row));
816
-
817
- if (preferredOutputs) {
818
- const preferredOutpoints = new Set(
819
- preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
820
- );
821
- outputs = outputs.filter((o) =>
822
- preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
823
- );
824
- }
802
+ _validateTargets(targets);
825
803
 
826
- const selectedOutputs = this._selectOutputs(
827
- outputs,
828
- target,
804
+ return await this._selectForTargets(
805
+ this.pool,
806
+ targets,
807
+ preferredOutputs,
829
808
  selectionStrategy
830
809
  );
831
- return { metadata, outputs: selectedOutputs };
832
810
  } catch (error) {
833
811
  if (error instanceof TokenStoreError) throw error;
834
812
  throw new TokenStoreError(
@@ -838,36 +816,33 @@ class MysqlTokenStore {
838
816
  }
839
817
  }
840
818
 
841
- async reserveTokenOutputsByOutpoints(tokenIdentifier, outpoints, purpose) {
819
+ /**
820
+ * Reserve the outputs at the given outpoints, which may belong to several tokens.
821
+ * @param {Array<{prevTxHash: string, prevTxVout: number}>} outpoints
822
+ * @param {string} purpose - "Payment" or "Swap"
823
+ * @returns {Promise<{id: string, tokenOutputs: {metadata: Array<Object>, outputs: Array}}>}
824
+ */
825
+ async reserveTokenOutputsByOutpoints(outpoints, purpose) {
842
826
  try {
843
827
  if (!outpoints || outpoints.length === 0) {
844
828
  throw new TokenStoreError("No outpoints provided");
845
829
  }
846
830
  return await this._withWriteTransaction(async (conn) => {
847
- const [metadataRows] = await conn.query(
848
- "SELECT * FROM brz_token_metadata WHERE user_id = ? AND identifier = ?",
849
- [this.identity, tokenIdentifier]
850
- );
851
- if (metadataRows.length === 0) {
852
- throw new TokenStoreError(
853
- `Token outputs not found for identifier: ${tokenIdentifier}`
854
- );
855
- }
856
- const metadata = this._metadataFromRow(metadataRows[0]);
857
-
858
- const pairPlaceholders = outpoints.map(() => "(?, ?)").join(", ");
859
- const selectParams = [this.identity, tokenIdentifier];
831
+ const selectParams = [this.identity];
860
832
  for (const o of outpoints) {
861
833
  selectParams.push(o.prevTxHash, o.prevTxVout);
862
834
  }
835
+ // The outpoints are not scoped to a token: they may belong to several.
863
836
  const [outputRows] = await conn.query(
864
837
  `SELECT o.owner_public_key, o.revocation_commitment,
865
838
  o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
866
839
  o.token_public_key, o.token_amount, o.token_identifier,
867
840
  o.prev_tx_hash, o.prev_tx_vout
868
841
  FROM brz_token_outputs o
869
- WHERE o.user_id = ? AND o.token_identifier = ? AND o.reservation_id IS NULL
870
- AND (o.prev_tx_hash, o.prev_tx_vout) IN (${pairPlaceholders})`,
842
+ WHERE o.user_id = ? AND o.reservation_id IS NULL
843
+ AND (o.prev_tx_hash, o.prev_tx_vout) IN (${buildPairPlaceholders(
844
+ outpoints.length
845
+ )})`,
871
846
  selectParams
872
847
  );
873
848
 
@@ -882,24 +857,14 @@ class MysqlTokenStore {
882
857
  throw new TokenStoreError("InsufficientFunds");
883
858
  }
884
859
 
885
- const reservationId = this._generateId();
886
- await conn.query(
887
- "INSERT INTO brz_token_reservations (user_id, id, purpose, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP(6))",
888
- [this.identity, reservationId, purpose]
860
+ const metadata = await this._fetchMetadata(
861
+ conn,
862
+ _tokenIdentifiersOf(selectedOutputs)
889
863
  );
890
864
 
891
- const updatePlaceholders = selectedOutputs
892
- .map(() => "(?, ?)")
893
- .join(", ");
894
- const updateParams = [reservationId, this.identity];
895
- for (const o of selectedOutputs) {
896
- updateParams.push(o.prevTxHash, o.prevTxVout);
897
- }
898
- await conn.query(
899
- `UPDATE brz_token_outputs SET reservation_id = ? WHERE user_id = ?
900
- AND (prev_tx_hash, prev_tx_vout) IN (${updatePlaceholders})`,
901
- updateParams
902
- );
865
+ const reservationId = this._generateId();
866
+ await this._insertReservation(conn, reservationId, purpose);
867
+ await this._assignReservation(conn, reservationId, selectedOutputs);
903
868
 
904
869
  return {
905
870
  id: reservationId,
@@ -1029,6 +994,138 @@ class MysqlTokenStore {
1029
994
 
1030
995
  // ===== Private Helpers =====
1031
996
 
997
+ _log(level, message) {
998
+ if (this.logger && typeof this.logger.log === "function") {
999
+ this.logger.log({ line: message, level });
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Select available outputs covering every target, without reserving them.
1005
+ *
1006
+ * The result carries metadata for every requested token. A target whose token
1007
+ * is unknown to this tenant is an error, and a target that cannot be covered
1008
+ * by the available outputs yields `InsufficientFunds`.
1009
+ * @param {import('mysql2/promise').PoolConnection|import('mysql2/promise').Pool} conn
1010
+ * @returns {Promise<{metadata: Array<Object>, outputs: Array}>}
1011
+ */
1012
+ async _selectForTargets(conn, targets, preferredOutputs, selectionStrategy) {
1013
+ const tokenIdentifiers = Array.from(new Set(targets.map(([id]) => id)));
1014
+
1015
+ const metadata = await this._fetchMetadata(conn, tokenIdentifiers);
1016
+ for (const identifier of tokenIdentifiers) {
1017
+ if (!metadata.some((m) => m.identifier === identifier)) {
1018
+ throw new TokenStoreError(
1019
+ `Token outputs not found for identifier: ${identifier}`
1020
+ );
1021
+ }
1022
+ }
1023
+
1024
+ const [outputRows] = await conn.query(
1025
+ `SELECT o.owner_public_key, o.revocation_commitment,
1026
+ o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
1027
+ o.token_public_key, o.token_amount, o.token_identifier,
1028
+ o.prev_tx_hash, o.prev_tx_vout
1029
+ FROM brz_token_outputs o
1030
+ WHERE o.user_id = ?
1031
+ AND o.token_identifier IN (${buildPlaceholders(
1032
+ tokenIdentifiers.length
1033
+ )})
1034
+ AND o.reservation_id IS NULL`,
1035
+ [this.identity, ...tokenIdentifiers]
1036
+ );
1037
+
1038
+ let available = outputRows.map((row) => this._outputFromRow(row));
1039
+
1040
+ if (preferredOutputs) {
1041
+ const preferredOutpoints = new Set(
1042
+ preferredOutputs.map((p) => `${p.prevTxHash}:${p.prevTxVout}`)
1043
+ );
1044
+ available = available.filter((o) =>
1045
+ preferredOutpoints.has(`${o.prevTxHash}:${o.prevTxVout}`)
1046
+ );
1047
+ }
1048
+
1049
+ const availablePerToken = new Map();
1050
+ for (const output of available) {
1051
+ const identifier = output.output.tokenIdentifier;
1052
+ if (!availablePerToken.has(identifier)) {
1053
+ availablePerToken.set(identifier, []);
1054
+ }
1055
+ availablePerToken.get(identifier).push(output);
1056
+ }
1057
+
1058
+ const outputs = [];
1059
+ for (const [tokenIdentifier, target] of targets) {
1060
+ const candidates = availablePerToken.get(tokenIdentifier) ?? [];
1061
+ const selected = this._selectOutputs(
1062
+ tokenIdentifier,
1063
+ candidates,
1064
+ target,
1065
+ selectionStrategy
1066
+ );
1067
+
1068
+ // Outputs picked for one target are withheld from the next, so repeated
1069
+ // entries for the same token do not select the same output twice.
1070
+ const taken = new Set(
1071
+ selected.map((o) => `${o.prevTxHash}:${o.prevTxVout}`)
1072
+ );
1073
+ availablePerToken.set(
1074
+ tokenIdentifier,
1075
+ candidates.filter((o) => !taken.has(`${o.prevTxHash}:${o.prevTxVout}`))
1076
+ );
1077
+
1078
+ outputs.push(...selected);
1079
+ }
1080
+
1081
+ return { metadata, outputs };
1082
+ }
1083
+
1084
+ /**
1085
+ * Load this tenant's metadata for the given token identifiers. Unknown
1086
+ * identifiers are simply absent from the result.
1087
+ */
1088
+ async _fetchMetadata(conn, tokenIdentifiers) {
1089
+ if (tokenIdentifiers.length === 0) {
1090
+ return [];
1091
+ }
1092
+ const [rows] = await conn.query(
1093
+ `SELECT * FROM brz_token_metadata
1094
+ WHERE user_id = ? AND identifier IN (${buildPlaceholders(
1095
+ tokenIdentifiers.length
1096
+ )})`,
1097
+ [this.identity, ...tokenIdentifiers]
1098
+ );
1099
+ return rows.map((row) => this._metadataFromRow(row));
1100
+ }
1101
+
1102
+ async _insertReservation(conn, reservationId, purpose) {
1103
+ await conn.query(
1104
+ "INSERT INTO brz_token_reservations (user_id, id, purpose, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP(6))",
1105
+ [this.identity, reservationId, purpose]
1106
+ );
1107
+ }
1108
+
1109
+ /**
1110
+ * Point the given outputs at the reservation, addressing them by outpoint.
1111
+ */
1112
+ async _assignReservation(conn, reservationId, outputs) {
1113
+ if (outputs.length === 0) {
1114
+ return;
1115
+ }
1116
+ const params = [reservationId, this.identity];
1117
+ for (const o of outputs) {
1118
+ params.push(o.prevTxHash, o.prevTxVout);
1119
+ }
1120
+ await conn.query(
1121
+ `UPDATE brz_token_outputs SET reservation_id = ? WHERE user_id = ?
1122
+ AND (prev_tx_hash, prev_tx_vout) IN (${buildPairPlaceholders(
1123
+ outputs.length
1124
+ )})`,
1125
+ params
1126
+ );
1127
+ }
1128
+
1032
1129
  _generateId() {
1033
1130
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
1034
1131
  return crypto.randomUUID();