@bsv/overlay 2.0.1 → 2.0.2

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.
package/src/Engine.ts CHANGED
@@ -552,6 +552,10 @@ export class Engine {
552
552
 
553
553
  const lookupResult = await lookupService.lookup(lookupQuestion)
554
554
  const hydrationContext = this.createUTXOHistoryHydrationContext()
555
+ await this.preloadOutputsWithBEEF(
556
+ lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })),
557
+ hydrationContext
558
+ )
555
559
  const hydratedOutputs = (await Promise.all(
556
560
  lookupResult.map(async ({ txid, outputIndex, history, context }) => {
557
561
  const UTXO = await this.loadOutputWithBEEF(txid, outputIndex, hydrationContext)
@@ -590,12 +594,66 @@ export class Engine {
590
594
  }
591
595
  }
592
596
 
597
+ private toOutputCacheKey(txid: string, outputIndex: number): string {
598
+ return `${txid}:${outputIndex}`
599
+ }
600
+
601
+ private async preloadOutputsWithBEEF(
602
+ outpoints: Array<{ txid: string, outputIndex: number }>,
603
+ context: UTXOHistoryHydrationContext
604
+ ): Promise<void> {
605
+ if (outpoints.length === 0) {
606
+ return
607
+ }
608
+
609
+ const deduped: Array<{ txid: string, outputIndex: number }> = []
610
+ const seen = new Set<string>()
611
+
612
+ for (const outpoint of outpoints) {
613
+ const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
614
+ if (seen.has(cacheKey)) {
615
+ continue
616
+ }
617
+ seen.add(cacheKey)
618
+ if (!context.outputCache.has(cacheKey)) {
619
+ deduped.push(outpoint)
620
+ }
621
+ }
622
+
623
+ if (deduped.length === 0) {
624
+ return
625
+ }
626
+
627
+ const findOutputsByOutpoints = this.storage.findOutputsByOutpoints
628
+ if (typeof findOutputsByOutpoints === 'function') {
629
+ const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true)
630
+ const outputsByKey = new Map<string, Output>()
631
+ for (const output of outputs) {
632
+ outputsByKey.set(this.toOutputCacheKey(output.txid, output.outputIndex), output)
633
+ }
634
+
635
+ for (const outpoint of deduped) {
636
+ const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
637
+ context.outputCache.set(cacheKey, Promise.resolve(outputsByKey.get(cacheKey) ?? null))
638
+ }
639
+ return
640
+ }
641
+
642
+ for (const outpoint of deduped) {
643
+ const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
644
+ context.outputCache.set(
645
+ cacheKey,
646
+ this.storage.findOutput(outpoint.txid, outpoint.outputIndex, undefined, undefined, true)
647
+ )
648
+ }
649
+ }
650
+
593
651
  private async loadOutputWithBEEF(
594
652
  txid: string,
595
653
  outputIndex: number,
596
654
  context: UTXOHistoryHydrationContext
597
655
  ): Promise<Output | null> {
598
- const cacheKey = `${txid}:${outputIndex}`
656
+ const cacheKey = this.toOutputCacheKey(txid, outputIndex)
599
657
  let cached = context.outputCache.get(cacheKey)
600
658
  if (cached === undefined) {
601
659
  cached = this.storage.findOutput(txid, outputIndex, undefined, undefined, true)
@@ -626,6 +684,8 @@ export class Engine {
626
684
  return undefined
627
685
  }
628
686
 
687
+ await this.preloadOutputsWithBEEF(output.outputsConsumed, context)
688
+
629
689
  const childNodes = (await Promise.all(
630
690
  output.outputsConsumed.map(async (outputIdentifier) => {
631
691
  const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context)
@@ -638,15 +698,21 @@ export class Engine {
638
698
  )).filter((node): node is HydratedUTXOHistoryNode => node !== undefined)
639
699
 
640
700
  const tx = Transaction.fromBEEF(output.beef)
701
+ const inputIndexBySource = new Map<string, number>()
702
+ tx.inputs.forEach((candidateInput, index) => {
703
+ const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
704
+ ? candidateInput.sourceTXID
705
+ : candidateInput.sourceTransaction?.id('hex')
641
706
 
642
- for (const child of childNodes) {
643
- const inputIndex = tx.inputs.findIndex((candidateInput) => {
644
- const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
645
- ? candidateInput.sourceTXID
646
- : candidateInput.sourceTransaction?.id('hex')
707
+ if (sourceTXID === undefined) {
708
+ return
709
+ }
647
710
 
648
- return sourceTXID === child.output.txid && candidateInput.sourceOutputIndex === child.output.outputIndex
649
- })
711
+ inputIndexBySource.set(`${sourceTXID}:${candidateInput.sourceOutputIndex}`, index)
712
+ })
713
+
714
+ for (const child of childNodes) {
715
+ const inputIndex = inputIndexBySource.get(`${child.output.txid}:${child.output.outputIndex}`)
650
716
 
651
717
  if (inputIndex === -1 || inputIndex == null) {
652
718
  continue
@@ -845,6 +845,34 @@ describe('BSV Overlay Services Engine', () => {
845
845
  query: { name: 'Bob' }
846
846
  })
847
847
  })
848
+ it('Uses batched output loading when the storage engine supports it', async () => {
849
+ mockLookupService.lookup = jest.fn(async () => [{
850
+ txid: 'mockTXID',
851
+ outputIndex: 0,
852
+ history: undefined
853
+ }])
854
+ mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
855
+ const findOutputsByOutpoints = jest.fn(async () => [mockOutput])
856
+ ;(mockStorageEngine).findOutputsByOutpoints = findOutputsByOutpoints
857
+ const engine = new Engine(
858
+ {
859
+ Hello: mockTopicManager
860
+ },
861
+ {
862
+ Hello: mockLookupService
863
+ },
864
+ mockStorageEngine,
865
+ mockChainTracker
866
+ )
867
+
868
+ await engine.lookup({
869
+ service: 'Hello',
870
+ query: { name: 'Bob' }
871
+ })
872
+
873
+ expect(findOutputsByOutpoints).toHaveBeenCalledWith([{ txid: 'mockTXID', outputIndex: 0 }], true)
874
+ expect(mockStorageEngine.findOutput).not.toHaveBeenCalled()
875
+ })
848
876
  describe('For each returned result', () => {
849
877
  it('Finds the identified UTXO by its txid and vout', async () => {
850
878
  // TODO: Make the default storage engine return something...?
@@ -30,6 +30,17 @@ export interface Storage {
30
30
  */
31
31
  findOutput: (txid: string, outputIndex: number, topic?: string, spent?: boolean, includeBEEF?: boolean) => Promise<Output | null>
32
32
 
33
+ /**
34
+ * Finds multiple outputs from storage by txid/output index pairs.
35
+ * Implementations can use this to collapse many point lookups into a single query.
36
+ * @param outpoints — txid/output index pairs to find
37
+ * @param includeBEEF — Whether to include the BEEF data for the outputs (optional)
38
+ */
39
+ findOutputsByOutpoints?: (
40
+ outpoints: Array<{ txid: string, outputIndex: number }>,
41
+ includeBEEF?: boolean
42
+ ) => Promise<Output[]>
43
+
33
44
  /**
34
45
  * Finds outputs with a matching transaction ID from storage
35
46
  * @param txid — TXID of the outputs to find
@@ -2,6 +2,18 @@ import { Storage } from '../Storage.js'
2
2
  import { Knex } from 'knex'
3
3
  import type { Output } from '../../Output.js'
4
4
 
5
+ const OUTPUT_SELECT_FIELDS = [
6
+ 'outputs.txid',
7
+ 'outputs.outputIndex',
8
+ 'outputs.outputScript',
9
+ 'outputs.topic',
10
+ 'outputs.satoshis',
11
+ 'outputs.outputsConsumed',
12
+ 'outputs.spent',
13
+ 'outputs.consumedBy',
14
+ 'outputs.score'
15
+ ] as const
16
+
5
17
  export class KnexStorage implements Storage {
6
18
  knex: Knex
7
19
 
@@ -9,6 +21,48 @@ export class KnexStorage implements Storage {
9
21
  this.knex = knex
10
22
  }
11
23
 
24
+ private parseOutputRelations(
25
+ value: string | Array<{ txid: string, outputIndex: number }>
26
+ ): Array<{ txid: string, outputIndex: number }> {
27
+ if (Array.isArray(value)) {
28
+ return value
29
+ }
30
+ return JSON.parse(value)
31
+ }
32
+
33
+ private parseOutputRecord(
34
+ row: any,
35
+ includeBEEF: boolean,
36
+ beefOverride?: number[]
37
+ ): Output {
38
+ return {
39
+ ...row,
40
+ outputScript: Array.from(row.outputScript),
41
+ beef: includeBEEF ? (beefOverride ?? (row.beef !== undefined ? Array.from(row.beef) : undefined)) : undefined,
42
+ spent: Boolean(row.spent),
43
+ outputsConsumed: this.parseOutputRelations(row.outputsConsumed),
44
+ consumedBy: this.parseOutputRelations(row.consumedBy)
45
+ }
46
+ }
47
+
48
+ private async fetchTransactionBeefMap(txids: string[]): Promise<Map<string, number[]>> {
49
+ if (txids.length === 0) {
50
+ return new Map<string, number[]>()
51
+ }
52
+
53
+ const rows = await this.knex('transactions')
54
+ .whereIn('txid', txids)
55
+ .select(['txid', 'beef'])
56
+
57
+ const beefByTxid = new Map<string, number[]>()
58
+ for (const row of rows) {
59
+ if (row.beef !== undefined) {
60
+ beefByTxid.set(row.txid, Array.from(row.beef))
61
+ }
62
+ }
63
+ return beefByTxid
64
+ }
65
+
12
66
  async findOutput (txid: string, outputIndex: number, topic?: string, spent?: boolean, includeBEEF: boolean = false): Promise<Output | null> {
13
67
  const search: {
14
68
  'outputs.txid': string
@@ -22,21 +76,8 @@ export class KnexStorage implements Storage {
22
76
  if (topic !== undefined) search['outputs.topic'] = topic
23
77
  if (spent !== undefined) search['outputs.spent'] = spent
24
78
 
25
- // Base query to get the output
26
79
  const query = this.knex('outputs').where(search)
27
-
28
- // Select necessary fields from outputs and conditionally include beef from transactions
29
- const selectFields = [
30
- 'outputs.txid',
31
- 'outputs.outputIndex',
32
- 'outputs.outputScript',
33
- 'outputs.topic',
34
- 'outputs.satoshis',
35
- 'outputs.outputsConsumed',
36
- 'outputs.spent',
37
- 'outputs.consumedBy',
38
- 'outputs.score'
39
- ]
80
+ const selectFields: string[] = [...OUTPUT_SELECT_FIELDS]
40
81
 
41
82
  if (includeBEEF) {
42
83
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -50,53 +91,57 @@ export class KnexStorage implements Storage {
50
91
  return null
51
92
  }
52
93
 
53
- return {
54
- ...output,
55
- outputScript: [...output.outputScript],
56
- beef: includeBEEF ? (output.beef !== undefined ? [...output.beef] : undefined) : undefined,
57
- spent: Boolean(output.spent),
58
- outputsConsumed: JSON.parse(output.outputsConsumed),
59
- consumedBy: JSON.parse(output.consumedBy)
60
- }
94
+ return this.parseOutputRecord(output, includeBEEF)
61
95
  }
62
96
 
63
- async findOutputsForTransaction (txid: string, includeBEEF: boolean = false): Promise<Output[]> {
64
- // Base query to get outputs
65
- const query = this.knex('outputs').where({ 'outputs.txid': txid })
66
-
67
- // Select necessary fields from outputs and conditionally include beef from transactions
68
- const selectFields = [
69
- 'outputs.txid',
70
- 'outputs.outputIndex',
71
- 'outputs.outputScript',
72
- 'outputs.topic',
73
- 'outputs.satoshis',
74
- 'outputs.outputsConsumed',
75
- 'outputs.spent',
76
- 'outputs.consumedBy',
77
- 'outputs.score'
78
- ]
97
+ async findOutputsByOutpoints (
98
+ outpoints: Array<{ txid: string, outputIndex: number }>,
99
+ includeBEEF: boolean = false
100
+ ): Promise<Output[]> {
101
+ if (outpoints.length === 0) {
102
+ return []
103
+ }
79
104
 
80
- if (includeBEEF) {
81
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
82
- query.leftJoin('transactions', 'outputs.txid', 'transactions.txid')
83
- selectFields.push('transactions.beef')
105
+ const deduped = new Map<string, { txid: string, outputIndex: number }>()
106
+ for (const outpoint of outpoints) {
107
+ deduped.set(`${outpoint.txid}:${outpoint.outputIndex}`, outpoint)
108
+ }
109
+
110
+ const rows = await this.knex('outputs')
111
+ .whereIn(
112
+ ['outputs.txid', 'outputs.outputIndex'],
113
+ Array.from(deduped.values()).map(outpoint => [outpoint.txid, outpoint.outputIndex])
114
+ )
115
+ .select([...OUTPUT_SELECT_FIELDS])
116
+
117
+ if (rows === undefined || rows.length === 0) {
118
+ return []
119
+ }
120
+
121
+ if (!includeBEEF) {
122
+ return rows.map(row => this.parseOutputRecord(row, false))
84
123
  }
85
124
 
86
- const outputs = await query.select(selectFields)
125
+ const txids = Array.from(new Set(rows.map(row => row.txid)))
126
+ const beefByTxid = await this.fetchTransactionBeefMap(txids)
127
+ return rows.map(row => this.parseOutputRecord(row, true, beefByTxid.get(row.txid)))
128
+ }
129
+
130
+ async findOutputsForTransaction (txid: string, includeBEEF: boolean = false): Promise<Output[]> {
131
+ const outputs = await this.knex('outputs')
132
+ .where({ 'outputs.txid': txid })
133
+ .select([...OUTPUT_SELECT_FIELDS])
87
134
 
88
135
  if (outputs === undefined || outputs.length === 0) {
89
136
  return []
90
137
  }
91
138
 
92
- return outputs.map(output => ({
93
- ...output,
94
- outputScript: [...output.outputScript],
95
- beef: includeBEEF ? (output.beef !== undefined ? [...output.beef] : undefined) : undefined,
96
- spent: Boolean(output.spent),
97
- outputsConsumed: JSON.parse(output.outputsConsumed),
98
- consumedBy: JSON.parse(output.consumedBy)
99
- }))
139
+ if (!includeBEEF) {
140
+ return outputs.map(output => this.parseOutputRecord(output, false))
141
+ }
142
+
143
+ const beefByTxid = await this.fetchTransactionBeefMap([txid])
144
+ return outputs.map(output => this.parseOutputRecord(output, true, beefByTxid.get(output.txid)))
100
145
  }
101
146
 
102
147
  async findUTXOsForTopic (topic: string, since?: number, limit?: number, includeBEEF: boolean = false): Promise<Output[]> {
@@ -113,45 +158,25 @@ export class KnexStorage implements Storage {
113
158
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
114
159
  query.orderBy('outputs.score', 'asc')
115
160
 
116
- // Select necessary fields from outputs and conditionally include beef from transactions
117
- const selectFields = [
118
- 'outputs.txid',
119
- 'outputs.outputIndex',
120
- 'outputs.outputScript',
121
- 'outputs.topic',
122
- 'outputs.satoshis',
123
- 'outputs.outputsConsumed',
124
- 'outputs.spent',
125
- 'outputs.consumedBy',
126
- 'outputs.score'
127
- ]
128
-
129
- if (includeBEEF) {
130
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
131
- query.leftJoin('transactions', 'outputs.txid', 'transactions.txid')
132
- selectFields.push('transactions.beef')
133
- }
134
-
135
161
  // Apply limit if specified
136
162
  if (limit !== undefined && limit > 0) {
137
163
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
138
164
  query.limit(limit)
139
165
  }
140
166
 
141
- const outputs = await query.select(selectFields)
167
+ const outputs = await query.select([...OUTPUT_SELECT_FIELDS])
142
168
 
143
169
  if (outputs === undefined || outputs.length === 0) {
144
170
  return []
145
171
  }
146
172
 
147
- return outputs.map(output => ({
148
- ...output,
149
- outputScript: [...output.outputScript],
150
- beef: includeBEEF ? (output.beef !== undefined ? [...output.beef] : undefined) : undefined,
151
- spent: Boolean(output.spent),
152
- outputsConsumed: JSON.parse(output.outputsConsumed),
153
- consumedBy: JSON.parse(output.consumedBy)
154
- }))
173
+ if (!includeBEEF) {
174
+ return outputs.map(output => this.parseOutputRecord(output, false))
175
+ }
176
+
177
+ const txids = Array.from(new Set(outputs.map(output => output.txid)))
178
+ const beefByTxid = await this.fetchTransactionBeefMap(txids)
179
+ return outputs.map(output => this.parseOutputRecord(output, true, beefByTxid.get(output.txid)))
155
180
  }
156
181
 
157
182
  async deleteOutput (txid: string, outputIndex: number, _: string): Promise<void> {