@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/dist/cjs/package.json +1 -1
- package/dist/cjs/src/Engine.js +56 -8
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/storage/knex/KnexStorage.js +80 -75
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +54 -7
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/storage/knex/KnexStorage.js +80 -75
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/Engine.d.ts +2 -0
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/storage/Storage.d.ts +10 -0
- package/dist/types/src/storage/Storage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/KnexStorage.d.ts +7 -0
- package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/Engine.ts +74 -8
- package/src/__tests/Engine.test.ts +28 -0
- package/src/storage/Storage.ts +11 -0
- package/src/storage/knex/KnexStorage.ts +104 -79
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 =
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
? candidateInput.sourceTXID
|
|
646
|
-
: candidateInput.sourceTransaction?.id('hex')
|
|
707
|
+
if (sourceTXID === undefined) {
|
|
708
|
+
return
|
|
709
|
+
}
|
|
647
710
|
|
|
648
|
-
|
|
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...?
|
package/src/storage/Storage.ts
CHANGED
|
@@ -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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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> {
|