@bsv/overlay 0.1.7 → 0.1.9

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.
Files changed (83) hide show
  1. package/dist/cjs/package.json +2 -2
  2. package/dist/cjs/src/Engine.js +185 -496
  3. package/dist/cjs/src/Engine.js.map +1 -1
  4. package/dist/cjs/src/GASP/OverlayGASPRemote.js +6 -4
  5. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  6. package/dist/cjs/src/GASP/OverlayGASPStorage.js +152 -77
  7. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  8. package/dist/cjs/src/storage/knex/KnexStorage.js +106 -38
  9. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  10. package/dist/cjs/src/storage/knex/all-migrations.js +5 -1
  11. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  12. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js +39 -0
  13. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -0
  14. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js +34 -0
  15. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -0
  16. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  17. package/dist/esm/src/Engine.js +183 -495
  18. package/dist/esm/src/Engine.js.map +1 -1
  19. package/dist/esm/src/GASP/OverlayGASPRemote.js +7 -4
  20. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  21. package/dist/esm/src/GASP/OverlayGASPStorage.js +151 -77
  22. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  23. package/dist/esm/src/storage/knex/KnexStorage.js +106 -38
  24. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  25. package/dist/esm/src/storage/knex/all-migrations.js +5 -1
  26. package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
  27. package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.js +34 -0
  28. package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -0
  29. package/dist/esm/src/storage/knex/migrations/2024-07-18-001-indexes.js +29 -0
  30. package/dist/esm/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -0
  31. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  32. package/dist/types/src/Engine.d.ts +9 -116
  33. package/dist/types/src/Engine.d.ts.map +1 -1
  34. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +3 -2
  35. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  36. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +29 -3
  37. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  38. package/dist/types/src/Output.d.ts +2 -2
  39. package/dist/types/src/Output.d.ts.map +1 -1
  40. package/dist/types/src/SyncConfiguration.d.ts +1 -1
  41. package/dist/types/src/SyncConfiguration.d.ts.map +1 -1
  42. package/dist/types/src/storage/Storage.d.ts +6 -8
  43. package/dist/types/src/storage/Storage.d.ts.map +1 -1
  44. package/dist/types/src/storage/knex/KnexStorage.d.ts +4 -4
  45. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  46. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  47. package/dist/types/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts +4 -0
  48. package/dist/types/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -0
  49. package/dist/types/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
  50. package/dist/types/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
  51. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  52. package/package.json +2 -2
  53. package/src/Engine.ts +191 -555
  54. package/src/GASP/OverlayGASPRemote.ts +107 -0
  55. package/src/GASP/OverlayGASPStorage.ts +331 -0
  56. package/src/Output.ts +2 -2
  57. package/src/SyncConfiguration.ts +1 -1
  58. package/src/__tests/Engine.test.ts +105 -26
  59. package/src/__tests/OverlayGASPRemote.test.ts +1 -1
  60. package/src/__tests/OverlayGASPStorage.test.ts +4 -3
  61. package/src/storage/Storage.ts +6 -8
  62. package/src/storage/knex/KnexStorage.ts +119 -41
  63. package/src/storage/knex/all-migrations.ts +10 -6
  64. package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +40 -0
  65. package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +30 -0
  66. package/dist/cjs/src/GASP.js +0 -275
  67. package/dist/cjs/src/GASP.js.map +0 -1
  68. package/dist/cjs/src/SHIPAdvertisement.js +0 -3
  69. package/dist/cjs/src/SHIPAdvertisement.js.map +0 -1
  70. package/dist/cjs/src/SLAPAdvertisement.js +0 -3
  71. package/dist/cjs/src/SLAPAdvertisement.js.map +0 -1
  72. package/dist/esm/src/GASP.js +0 -279
  73. package/dist/esm/src/GASP.js.map +0 -1
  74. package/dist/esm/src/SHIPAdvertisement.js +0 -2
  75. package/dist/esm/src/SHIPAdvertisement.js.map +0 -1
  76. package/dist/esm/src/SLAPAdvertisement.js +0 -2
  77. package/dist/esm/src/SLAPAdvertisement.js.map +0 -1
  78. package/dist/types/src/GASP.d.ts +0 -215
  79. package/dist/types/src/GASP.d.ts.map +0 -1
  80. package/dist/types/src/SHIPAdvertisement.d.ts +0 -9
  81. package/dist/types/src/SHIPAdvertisement.d.ts.map +0 -1
  82. package/dist/types/src/SLAPAdvertisement.d.ts +0 -9
  83. package/dist/types/src/SLAPAdvertisement.d.ts.map +0 -1
@@ -0,0 +1,107 @@
1
+ import { GASPInitialReply, GASPInitialRequest, GASPInitialResponse, GASPNode, GASPNodeResponse, GASPRemote } from "@bsv/gasp"
2
+
3
+ export class OverlayGASPRemote implements GASPRemote {
4
+ constructor(public endpointURL: string, public topic: string) { }
5
+
6
+ /**
7
+ * Given an outgoing initial request, sends the request to the foreign instance and obtains their initial response.
8
+ * @param request
9
+ * @returns
10
+ */
11
+ async getInitialResponse(request: GASPInitialRequest): Promise<GASPInitialResponse> {
12
+ // Send out an HTTP request to the URL (current host for topic)
13
+ // Include the topic in the request
14
+ // Parse out response and return correct format
15
+ const url = `${this.endpointURL}/requestSyncResponse`
16
+ const response = await fetch(url, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ 'X-BSV-Topic': this.topic
21
+ },
22
+ body: JSON.stringify(request)
23
+ })
24
+
25
+ if (!response.ok) {
26
+ throw new Error(`HTTP error! Status: ${response.status}`)
27
+ }
28
+
29
+ const result: GASPInitialResponse = await response.json()
30
+
31
+ // Validate and return the response in the correct format
32
+ if (!Array.isArray(result.UTXOList) || typeof result.since !== 'number') {
33
+ throw new Error('Invalid response format')
34
+ }
35
+
36
+ return {
37
+ UTXOList: result.UTXOList.map((utxo: any) => ({
38
+ txid: utxo.txid,
39
+ outputIndex: utxo.outputIndex
40
+ })),
41
+ since: result.since
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Given an outgoing txid, outputIndex and optional metadata, request the associated GASP node from the foreign instance.
47
+ * @param graphID
48
+ * @param txid
49
+ * @param outputIndex
50
+ * @param metadata
51
+ * @returns
52
+ */
53
+ async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise<GASPNode> {
54
+ // Send an HTTP request with the provided info and get back a gaspNode
55
+ const url = `${this.endpointURL}/requestForeignGASPNode`
56
+ const body = {
57
+ graphID,
58
+ txid,
59
+ outputIndex,
60
+ metadata
61
+ }
62
+
63
+ const response = await fetch(url, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json'
67
+ },
68
+ body: JSON.stringify(body)
69
+ })
70
+
71
+ if (!response.ok) {
72
+ throw new Error(`HTTP error! Status: ${response.status}`)
73
+ }
74
+
75
+ const result = await response.json()
76
+
77
+ // Validate and return the response in the correct format
78
+ if (typeof result.graphID !== 'string' || typeof result.rawTx !== 'string' || typeof result.outputIndex !== 'number') {
79
+ throw new Error('Invalid response format')
80
+ }
81
+
82
+ const gaspNode: GASPNode = {
83
+ graphID: result.graphID,
84
+ rawTx: result.rawTx,
85
+ outputIndex: result.outputIndex,
86
+ proof: result.proof,
87
+ txMetadata: result.txMetadata,
88
+ outputMetadata: result.outputMetadata,
89
+ inputs: result.inputs
90
+ }
91
+
92
+ return gaspNode
93
+ }
94
+
95
+ // ---- Now optional methods ----
96
+
97
+ // When are only syncing to them
98
+ async getInitialReply(response: GASPInitialResponse): Promise<GASPInitialReply> {
99
+ throw new Error('Function not supported!')
100
+ }
101
+
102
+ // Only used when supporting bidirectional sync.
103
+ // Overlay services does not support this.
104
+ async submitNode(node: GASPNode): Promise<void | GASPNodeResponse> {
105
+ throw new Error('Node submission not supported!')
106
+ }
107
+ }
@@ -0,0 +1,331 @@
1
+ import { GASPNode, GASPNodeResponse, GASPStorage } from '@bsv/gasp'
2
+ import { MerklePath, Transaction } from '@bsv/sdk'
3
+ import { Engine } from '../Engine.js'
4
+
5
+ /**
6
+ * Represents a node in the temporary graph.
7
+ */
8
+ export interface GraphNode {
9
+ txid: string
10
+ graphID: string
11
+ rawTx: string
12
+ outputIndex: number
13
+ spentBy?: string
14
+ proof?: string
15
+ txMetadata?: string
16
+ outputMetadata?: string
17
+ inputs?: Record<string, { hash: string }> | undefined
18
+ children: GraphNode[]
19
+ parent?: GraphNode
20
+ }
21
+
22
+ export class OverlayGASPStorage implements GASPStorage {
23
+ readonly temporaryGraphNodeRefs: Record<string, GraphNode> = {}
24
+
25
+ constructor(public topic: string, public engine: Engine, public maxNodesInGraph?: number) { }
26
+
27
+ /**
28
+ *
29
+ * @param since
30
+ * @returns
31
+ */
32
+ async findKnownUTXOs(since: number): Promise<Array<{ txid: string, outputIndex: number }>> {
33
+ const UTXOs = await this.engine.storage.findUTXOsForTopic(this.topic, since)
34
+ return UTXOs.map(output => ({
35
+ txid: output.txid,
36
+ outputIndex: output.outputIndex
37
+ }))
38
+ }
39
+
40
+ /**
41
+ * For a given txid and output index, returns the associated transaction, a merkle proof if the transaction is in a block, and metadata if if requested. If no metadata is requested, metadata hashes on inputs are not returned.
42
+ * @param graphID
43
+ * @param txid
44
+ * @param outputIndex
45
+ * @param metadata
46
+ * @returns
47
+ */
48
+ async hydrateGASPNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise<GASPNode> {
49
+ const output = await this.engine.storage.findOutput(txid, outputIndex, undefined, undefined, true)
50
+
51
+ if (output?.beef === undefined) {
52
+ throw new Error('No matching output found!')
53
+ }
54
+
55
+ const tx = Transaction.fromBEEF(output.beef)
56
+ const rawTx = tx.toHex()
57
+
58
+ const node: GASPNode = {
59
+ rawTx,
60
+ graphID,
61
+ outputIndex
62
+ }
63
+ if (tx.merklePath !== undefined) {
64
+ node.proof = tx.merklePath.toHex()
65
+ }
66
+
67
+ return node
68
+ }
69
+
70
+ /**
71
+ * For a given node, returns the inputs needed to complete the graph, including whether updated metadata is requested for those inputs.
72
+ * @param tx The node for which needed inputs should be found.
73
+ * @returns A promise for a mapping of requested input transactions and whether metadata should be provided for each.
74
+ */
75
+ async findNeededInputs(tx: GASPNode): Promise<GASPNodeResponse | undefined> {
76
+ // If there is no Merkle proof, we always need the inputs
77
+ const response: GASPNodeResponse = {
78
+ requestedInputs: {}
79
+ }
80
+ const parsedTx = Transaction.fromHex(tx.rawTx)
81
+ if (tx.proof === undefined) {
82
+ for (const input of parsedTx.inputs) {
83
+ response.requestedInputs[`${input.sourceTXID}.${input.sourceOutputIndex}`] = {
84
+ metadata: false
85
+ }
86
+ }
87
+
88
+ return await this.stripAlreadyKnownInputs(response)
89
+ }
90
+
91
+ // Attempt to check if the current transaction is admissible
92
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof)
93
+ const admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTx.toBEEF(), [])
94
+
95
+ if (admittanceResult.outputsToAdmit.includes(tx.outputIndex)) {
96
+ // The transaction is admissible, no further inputs are needed
97
+ } else {
98
+ // The transaction is not admissible, get inputs needed for further verification
99
+ // TopicManagers should implement a function to identify which inputs are needed.
100
+ if (this.engine.managers[this.topic] !== undefined && typeof this.engine.managers[this.topic].identifyNeededInputs === 'function') {
101
+ try {
102
+ const neededInputs = await this.engine.managers[this.topic].identifyNeededInputs?.(parsedTx.toBEEF()) ?? []
103
+ for (const input of neededInputs) {
104
+ response.requestedInputs[`${input.txid}.${input.outputIndex}`] = {
105
+ metadata: false
106
+ }
107
+ }
108
+ return await this.stripAlreadyKnownInputs(response)
109
+ } catch (e) {
110
+ console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}!`)
111
+ // Cut off the graph in case of an error here.
112
+ }
113
+ }
114
+ // By default, if the topic manager isn't able to stipulate needed inputs, only the inputs necessary for SPV are requested.
115
+ }
116
+ // Everything else falls through to returning undefined/void, which will terminate the synchronization at this point.
117
+ }
118
+
119
+ /**
120
+ * Ensures that no inputs are requested from foreign nodes before sending any GASP response
121
+ * Also terminates graphs if the response would be empty.
122
+ */
123
+ private async stripAlreadyKnownInputs(response: GASPNodeResponse | undefined): Promise<GASPNodeResponse | undefined> {
124
+ if (typeof response === 'undefined') {
125
+ return response
126
+ }
127
+ for (const inputNodeId of Object.keys(response.requestedInputs)) {
128
+ const [txid, outputIndex] = inputNodeId.split('.')
129
+ const found = await this.engine.storage.findOutput(txid, Number(outputIndex), this.topic)
130
+ if (found !== null && found !== undefined) {
131
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
132
+ delete response.requestedInputs[inputNodeId]
133
+ }
134
+ }
135
+ if (Object.keys(response.requestedInputs).length === 0) {
136
+ return undefined
137
+ }
138
+ return response
139
+ }
140
+
141
+ /**
142
+ * Appends a new node to a temporary graph.
143
+ * @param tx The node to append to this graph.
144
+ * @param spentBy Unless this is the same node identified by the graph ID, denotes the TXID and input index for the node which spent this one, in 36-byte format.
145
+ * @throws If the node cannot be appended to the graph, either because the graph ID is for a graph the recipient does not want or because the graph has grown to be too large before being finalized.
146
+ */
147
+ async appendToGraph(tx: GASPNode, spentBy?: string | undefined): Promise<void> {
148
+ if (this.maxNodesInGraph !== undefined && Object.keys(this.temporaryGraphNodeRefs).length >= this.maxNodesInGraph) {
149
+ throw new Error('The max number of nodes in transaction graph has been reached!')
150
+ }
151
+
152
+ const parsedTx = Transaction.fromHex(tx.rawTx)
153
+ const txid = parsedTx.id('hex')
154
+ if (tx.proof !== undefined) {
155
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof)
156
+ }
157
+
158
+ // Given the passed in node, append to the temp graph
159
+ // Use the spentBy param which should be a txid.inputIndex for the node which spent this one in 36-byte format
160
+ const newGraphNode: GraphNode = {
161
+ txid,
162
+ graphID: tx.graphID,
163
+ rawTx: tx.rawTx,
164
+ outputIndex: tx.outputIndex,
165
+ proof: tx.proof,
166
+ txMetadata: tx.txMetadata,
167
+ outputMetadata: tx.outputMetadata,
168
+ inputs: tx.inputs,
169
+ children: []
170
+ }
171
+
172
+ // If spentBy is undefined, then we know it's the root node.
173
+ if (spentBy === undefined) {
174
+ this.temporaryGraphNodeRefs[tx.graphID] = newGraphNode
175
+ } else {
176
+ // Find the parent node based on spentBy
177
+ const parentNode = this.temporaryGraphNodeRefs[spentBy]
178
+
179
+ if (parentNode !== undefined) {
180
+ // Set parent-child relationship
181
+ parentNode.children.push(newGraphNode)
182
+ newGraphNode.parent = parentNode
183
+ this.temporaryGraphNodeRefs[`${newGraphNode.txid}.${newGraphNode.outputIndex}`] = newGraphNode
184
+ } else {
185
+ throw new Error(`Parent node with GraphID ${spentBy} not found`)
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Checks whether the given graph, in its current state, makes reference only to transactions that are proven in the blockchain, or already known by the recipient to be valid.
192
+ * Additionally, in a breadth-first manner (ensuring that all inputs for any given node are processed before nodes that spend them), it ensures that the root node remains valid according to the rules of the overlay's topic manager,
193
+ * while considering any coins which the Manager had previously indicated were either valid or invalid.
194
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
195
+ * @throws If the graph is not well-anchored, according to the rules of Bitcoin or the rules of the Overlay Topic Manager.
196
+ */
197
+ async validateGraphAnchor(graphID: string): Promise<void> {
198
+ const rootNode = this.temporaryGraphNodeRefs[graphID]
199
+ if (rootNode === undefined) {
200
+ throw new Error(`Graph node with ID ${graphID} not found`)
201
+ }
202
+
203
+ // Check that the root node is Bitcoin-valid.
204
+ const beef = this.getBEEFForNode(rootNode)
205
+ const spvTx = Transaction.fromBEEF(beef)
206
+ const isBitcoinValid = await spvTx.verify(this.engine.chainTracker)
207
+ if (!isBitcoinValid) {
208
+ throw new Error('The graph is not well-anchored according to the rules of Bitcoin.')
209
+ }
210
+
211
+ // Then, ensure the node is Overlay-valid.
212
+ const beefs = this.computeOrderedBEEFsForGraph(graphID)
213
+
214
+ // coins: a Set of all historical coins to retain (no need to remove them), used to emulate topical admittance of previous inputs over time.
215
+ const coins = new Set<string>()
216
+
217
+ // Submit all historical BEEFs in order through the topic manager, tracking what would be retained until we submit the root node last.
218
+ // If, at the end, the root node is admitted, we have a valid overlay-specific graph.
219
+ for (const beef of beefs) {
220
+ // For any input to this transaction, see if it's a valid coin that's admitted. If so, it's a previous coin.
221
+ const previousCoins: number[] = []
222
+ const tx = Transaction.fromBEEF(beef)
223
+ for (const inputIndex in tx.inputs) {
224
+ const input = tx.inputs[inputIndex]
225
+ const sourceTXID = input.sourceTXID || input.sourceTransaction?.id('hex')
226
+ const coin = `${sourceTXID}.${input.sourceOutputIndex}`
227
+ if (coins.has(coin)) {
228
+ previousCoins.push(Number(inputIndex))
229
+ }
230
+ }
231
+ const admittanceInstructions = await this.engine.managers[this.topic].identifyAdmissibleOutputs(beef, previousCoins)
232
+ // Every admitted output is now a coin.
233
+ for (const outputIndex of admittanceInstructions.outputsToAdmit) {
234
+ coins.add(`${tx.id('hex')}.${outputIndex}`)
235
+ }
236
+ }
237
+ // After sending through all the graph's BEEFs...
238
+ // If the root node is now a coin, we have acceptance by the overlay.
239
+ // Otherwise, throw.
240
+ if (!coins.has(graphID)) {
241
+ throw new Error('This graph did not result in topical admittance of the root node. Rejecting.')
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Deletes all data associated with a temporary graph that has failed to sync, if the graph exists.
247
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
248
+ */
249
+ async discardGraph(graphID: string): Promise<void> {
250
+ for (const [nodeId, graphRef] of Object.entries(this.temporaryGraphNodeRefs)) {
251
+ if (graphRef.graphID === graphID) {
252
+ // Delete child node
253
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
254
+ delete this.temporaryGraphNodeRefs[nodeId]
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Finalizes a graph, solidifying the new UTXO and its ancestors so that it will appear in the list of known UTXOs.
261
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the root of this graph.
262
+ */
263
+ async finalizeGraph(graphID: string): Promise<void> {
264
+ const beefs = this.computeOrderedBEEFsForGraph(graphID)
265
+
266
+ // Submit all historical BEEFs in order, finalizing the graph for the current UTXO
267
+ for (const beef of beefs) {
268
+ await this.engine.submit({
269
+ beef,
270
+ topics: [this.topic]
271
+ }, () => { }, 'historical-tx')
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Computes an ordered set of BEEFs for the graph with the given graph IDs
277
+ * @param {string} graphID — The ID of the graph for which BEEFs are required
278
+ * @returns Ordered BEEFs for the graph
279
+ */
280
+ private computeOrderedBEEFsForGraph(graphID: string): number[][] {
281
+ const beefs: number[][] = []
282
+ const hydrator = (node: GraphNode): void => {
283
+ const currentBEEF = this.getBEEFForNode(node)
284
+ if (beefs.indexOf(currentBEEF) === -1) {
285
+ beefs.unshift(currentBEEF)
286
+ }
287
+
288
+ for (const child of node.children) {
289
+ // Continue backwards to the earliest nodes, adding them onto the beginning
290
+ hydrator(child)
291
+ }
292
+ }
293
+
294
+ // Start the hydrator with the root node
295
+ const foundRoot = this.temporaryGraphNodeRefs[graphID]
296
+ if (!foundRoot) {
297
+ throw new Error('Unable to find root node in graph for finalization!')
298
+ }
299
+ hydrator(foundRoot)
300
+ return beefs
301
+ }
302
+
303
+ /**
304
+ * Computes a full BEEF for a given graph node, based on the temporary graph store.
305
+ * @param node Graph node for which BEEF is needed.
306
+ * @returns BEEF array, including all proofs on inputs.
307
+ */
308
+ private getBEEFForNode(node: GraphNode): number[] {
309
+ // Given a node, hydrate its merkle proof or all inputs, returning a reference to the hydrated node's Transaction object
310
+ const hydrator = (node: GraphNode): Transaction => {
311
+ const tx = Transaction.fromHex(node.rawTx)
312
+ if (node.proof) {
313
+ tx.merklePath = MerklePath.fromHex(node.proof)
314
+ return tx // Transaction with proof, end of the line.
315
+ }
316
+ // For each input, look it up and recurse.
317
+ for (const inputIndex in tx.inputs) {
318
+ const input = tx.inputs[inputIndex]
319
+ const foundNode = this.temporaryGraphNodeRefs[`${input.sourceTXID}.${input.sourceOutputIndex}`]
320
+ if (!foundNode) {
321
+ throw new Error('Required input node for unproven parent not found in temporary graph store. Ensure, for every parent of any given already-proven node (kept for Overlay-specific historical reasons), that a proof is also provided on those inputs. While implicitly they are valid by virtue of their descendents being proven in the blockchain, BEEF serialization will still fail when winding forward the topical UTXO set histories during sync.')
322
+ }
323
+ tx.inputs[inputIndex].sourceTransaction = hydrator(foundNode)
324
+ }
325
+ return tx
326
+ }
327
+
328
+ const finalTX = hydrator(node)
329
+ return finalTX.toBEEF()
330
+ }
331
+ }
package/src/Output.ts CHANGED
@@ -14,8 +14,6 @@ export type Output = {
14
14
  topic: string
15
15
  /** Whether the output is spent */
16
16
  spent: boolean
17
- /** The transaction data for the output */
18
- beef: number[]
19
17
  /** Outputs consumed by the transaction associated with the output */
20
18
  outputsConsumed: Array<{
21
19
  txid: string
@@ -26,5 +24,7 @@ export type Output = {
26
24
  txid: string
27
25
  outputIndex: number
28
26
  }>
27
+ /** The transaction data for the output */
28
+ beef?: number[]
29
29
  blockHeight?: number
30
30
  }
@@ -16,4 +16,4 @@
16
16
  * "topicManager2": "SHIP"
17
17
  * }
18
18
  */
19
- export type SyncConfiguration = Record<string, string[] | 'SHIP'>
19
+ export type SyncConfiguration = Record<string, string[] | 'SHIP' | false>
@@ -1,10 +1,12 @@
1
- import { Engine } from '../Engine'
1
+ // Note: References Engine from dist due to issues with compiled code references in Engine.ts
2
+ import { Engine } from '../../dist/cjs/src/Engine'
2
3
  import { LookupService } from '../LookupService'
3
4
  import { TopicManager } from '../TopicManager'
4
5
  import { AdmittanceInstructions } from '../AdmittanceInstructions'
5
6
  import { Storage } from '../storage/Storage'
6
7
  import { Transaction, Utils } from '@bsv/sdk'
7
8
  import { Output } from '../Output'
9
+ import { SyncConfiguration } from '../SyncConfiguration'
8
10
 
9
11
  const mockChainTracker = {
10
12
  isValidRootForHeight: jest.fn(async () => true)
@@ -59,11 +61,88 @@ describe('BSV Overlay Services Engine', () => {
59
61
  findOutputsForTransaction: jest.fn(async () => []),
60
62
  markUTXOAsSpent: jest.fn(),
61
63
  updateConsumedBy: jest.fn(),
62
- updateOutputBeef: jest.fn(),
64
+ updateTransactionBEEF: jest.fn(),
63
65
  deleteOutput: jest.fn(),
64
66
  findUTXOsForTopic: jest.fn()
65
67
  }
66
68
  })
69
+ it('Uses SHIP sync configuration by default if no syncConfiguration was provided', () => {
70
+ const engine = new Engine(
71
+ { tm_helloworld: mockTopicManager },
72
+ { ls_helloworld: mockLookupService },
73
+ mockStorageEngine,
74
+ mockChainTracker,
75
+ undefined, // hostingURL
76
+ ['tracker1'], // shipTrackers
77
+ ['tracker2'], // slapTrackers
78
+ undefined,
79
+ undefined,
80
+ undefined
81
+ )
82
+
83
+ expect(engine.syncConfiguration).toEqual({ tm_helloworld: 'SHIP' })
84
+ })
85
+
86
+ it('Does not set sync method to "SHIP" for topic managers set to false in the syncConfiguration', () => {
87
+ const syncConfiguration: SyncConfiguration = { tm_helloworld: false }
88
+ const engine = new Engine(
89
+ { tm_helloworld: mockTopicManager },
90
+ { ls_helloworld: mockLookupService },
91
+ mockStorageEngine,
92
+ mockChainTracker,
93
+ undefined, // hostingURL
94
+ ['tracker1'], // shipTrackers
95
+ ['tracker2'], // slapTrackers
96
+ undefined,
97
+ undefined,
98
+ syncConfiguration
99
+ )
100
+
101
+ expect(engine.syncConfiguration).toEqual({ tm_helloworld: false })
102
+ })
103
+
104
+ it('Combines existing trackers with provided shipTrackers and slapTrackers, ensuring no duplicates', () => {
105
+ const syncConfiguration: SyncConfiguration = { tm_ship: ['existingTracker1'], tm_slap: ['existingTracker2'] }
106
+ const engine = new Engine(
107
+ { tm_ship: mockTopicManager, tm_slap: mockTopicManager },
108
+ { ls_ship: mockLookupService, ls_slap: mockLookupService },
109
+ mockStorageEngine,
110
+ mockChainTracker,
111
+ undefined, // hostingURL
112
+ ['tracker1', 'existingTracker1'], // shipTrackers
113
+ ['tracker2', 'existingTracker2'], // slapTrackers
114
+ undefined,
115
+ undefined,
116
+ syncConfiguration
117
+ )
118
+
119
+ expect(engine.syncConfiguration).toEqual({
120
+ tm_ship: ['existingTracker1', 'tracker1'],
121
+ tm_slap: ['existingTracker2', 'tracker2']
122
+ })
123
+ })
124
+
125
+ it('Sets undefined topic managers in syncConfiguration to sync method of "SHIP" by default', () => {
126
+ const syncConfiguration: SyncConfiguration = { tm_helloworld: 'SHIP' }
127
+ const engine = new Engine(
128
+ { tm_helloworld: mockTopicManager, tm_ship: mockTopicManager, tm_slap: mockTopicManager },
129
+ { ls_helloworld: mockLookupService, ls_ship: mockLookupService, ls_slap: mockLookupService },
130
+ mockStorageEngine,
131
+ mockChainTracker,
132
+ undefined, // hostingURL
133
+ ['tracker1'], // shipTrackers
134
+ ['tracker2'], // slapTrackers
135
+ undefined,
136
+ undefined,
137
+ syncConfiguration
138
+ )
139
+
140
+ expect(engine.syncConfiguration).toEqual({
141
+ tm_helloworld: 'SHIP',
142
+ tm_ship: ['tracker1'],
143
+ tm_slap: ['tracker2']
144
+ })
145
+ })
67
146
  describe('handleNewMerkleProof tests', () => {
68
147
  const mockOutput: Output = {
69
148
  txid: exampleTXID,
@@ -101,7 +180,7 @@ describe('BSV Overlay Services Engine', () => {
101
180
  let newBEEF: number[] = []
102
181
  mockStorageEngine.findOutput = jest.fn(async () => output27c8f_0)
103
182
  mockStorageEngine.findOutputsForTransaction = jest.fn(async () => [output27c8f_0])
104
- mockStorageEngine.updateOutputBeef = jest.fn(async (txid: string, outputIndex: number, topic: string, beef: number[]) => {
183
+ mockStorageEngine.updateTransactionBEEF = jest.fn(async (txid: string, beef: number[]) => {
105
184
  newBEEF = beef
106
185
  })
107
186
  const engine = new Engine(
@@ -123,7 +202,7 @@ describe('BSV Overlay Services Engine', () => {
123
202
 
124
203
  it('1 recurse proof', async () => {
125
204
  const outputs: Output[] = []
126
- const findOutput = (txid: string, outputIndex: number): Output => {
205
+ const findOutput = (txid: string, outputIndex: number, includeBEEF?: boolean): Output => {
127
206
  const i = outputs.findIndex(o => o.txid === txid && o.outputIndex === outputIndex)
128
207
  if (i < 0) throw new Error(`missing output ${txid} ${outputIndex}`)
129
208
  return outputs[i]
@@ -152,15 +231,15 @@ describe('BSV Overlay Services Engine', () => {
152
231
 
153
232
  mockLookupService.lookup = jest.fn(async () => [{ txid: txid17d182, outputIndex: 0, history: 1 }])
154
233
  const newBEEF: Record<string, string> = {}
155
- mockStorageEngine.findOutput = jest.fn(async (txid: string, outputIndex: number, topic?: string, spent?: boolean) => {
156
- return findOutput(txid, outputIndex)
234
+ mockStorageEngine.findOutput = jest.fn(async (txid: string, outputIndex: number, topic?: string, spent?: boolean, includeBEEF?: boolean) => {
235
+ return findOutput(txid, outputIndex, true)
157
236
  })
158
- mockStorageEngine.findOutputsForTransaction = jest.fn(async (txid: string) => {
237
+ mockStorageEngine.findOutputsForTransaction = jest.fn(async (txid: string, includeBEEF?: boolean) => {
159
238
  const os = outputs.filter(o => o.txid === txid)
160
239
  return os
161
240
  })
162
- mockStorageEngine.updateOutputBeef = jest.fn(async (txid: string, outputIndex: number, topic: string, beef: number[]) => {
163
- newBEEF[`${txid}_${outputIndex}`] = Utils.toHex(beef)
241
+ mockStorageEngine.updateTransactionBEEF = jest.fn(async (txid: string, beef: number[]) => {
242
+ newBEEF[txid] = Utils.toHex(beef)
164
243
  })
165
244
  const engine = new Engine({ Hello: mockTopicManager }, { Hello: mockLookupService }, mockStorageEngine, mockChainTracker)
166
245
 
@@ -192,34 +271,34 @@ describe('BSV Overlay Services Engine', () => {
192
271
 
193
272
  const mp877734 = Transaction.fromHexBEEF(beef877734_0).merklePath
194
273
  await engine.handleNewMerkleProof(txid877734, mp877734)
195
- expect(newBEEF[`${txid877734}_0`]).toBe(beef877734_0)
196
- expect(newBEEF[`${txid509f5e}_0`].length).toBeGreaterThan(beef509f5e_0.length)
197
- expect(newBEEF[`${txid942620}_0`].length).toBeGreaterThan(beef942620_0.length)
198
- expect(newBEEF[`${txid17d182}_0`].length).toBeGreaterThan(beef17d182_0.length)
274
+ expect(newBEEF[`${txid877734}`]).toBe(beef877734_0)
275
+ expect(newBEEF[`${txid509f5e}`].length).toBeGreaterThan(beef509f5e_0.length)
276
+ expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef942620_0.length)
277
+ expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d182_0.length)
199
278
  expect(Object.keys(newBEEF).length).toBe(4)
200
279
 
201
280
  const mp509f5e = Transaction.fromHexBEEF(beef509f5e_0).merklePath
202
281
  await engine.handleNewMerkleProof(txid509f5e, mp509f5e)
203
- expect(newBEEF[`${txid877734}_0`]).toBe(beef877734_0)
204
- expect(newBEEF[`${txid509f5e}_0`]).toBe(beef509f5e_0)
205
- expect(newBEEF[`${txid942620}_0`].length).toBeGreaterThan(beef942620_0.length)
206
- expect(newBEEF[`${txid17d182}_0`].length).toBeGreaterThan(beef17d182_0.length)
282
+ expect(newBEEF[`${txid877734}`]).toBe(beef877734_0)
283
+ expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e_0)
284
+ expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef942620_0.length)
285
+ expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d182_0.length)
207
286
  expect(Object.keys(newBEEF).length).toBe(4)
208
287
 
209
288
  const mp942620 = Transaction.fromHexBEEF(beef942620_0).merklePath
210
289
  await engine.handleNewMerkleProof(txid942620, mp942620)
211
- expect(newBEEF[`${txid877734}_0`]).toBe(beef877734_0)
212
- expect(newBEEF[`${txid509f5e}_0`]).toBe(beef509f5e_0)
213
- expect(newBEEF[`${txid942620}_0`]).toBe(beef942620_0)
214
- expect(newBEEF[`${txid17d182}_0`].length).toBeGreaterThan(beef17d182_0.length)
290
+ expect(newBEEF[`${txid877734}`]).toBe(beef877734_0)
291
+ expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e_0)
292
+ expect(newBEEF[`${txid942620}`]).toBe(beef942620_0)
293
+ expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d182_0.length)
215
294
  expect(Object.keys(newBEEF).length).toBe(4)
216
295
 
217
296
  const mp17d182 = Transaction.fromHexBEEF(beef17d182_0).merklePath
218
297
  await engine.handleNewMerkleProof(txid17d182, mp17d182)
219
- expect(newBEEF[`${txid877734}_0`]).toBe(beef877734_0)
220
- expect(newBEEF[`${txid509f5e}_0`]).toBe(beef509f5e_0)
221
- expect(newBEEF[`${txid942620}_0`]).toBe(beef942620_0)
222
- expect(newBEEF[`${txid17d182}_0`]).toBe(beef17d182_0)
298
+ expect(newBEEF[`${txid877734}`]).toBe(beef877734_0)
299
+ expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e_0)
300
+ expect(newBEEF[`${txid942620}`]).toBe(beef942620_0)
301
+ expect(newBEEF[`${txid17d182}`]).toBe(beef17d182_0)
223
302
  expect(Object.keys(newBEEF).length).toBe(4)
224
303
  })
225
304
  })
@@ -685,7 +764,7 @@ describe('BSV Overlay Services Engine', () => {
685
764
  query: { name: 'Bob' }
686
765
  })
687
766
  expect(mockStorageEngine.findOutput).toHaveBeenCalledWith(
688
- 'mockTXID', 0, undefined, false
767
+ 'mockTXID', 0, undefined, undefined, true
689
768
  )
690
769
  })
691
770
  it('Calls getUTXOHistory with the correct UTXO and history parameters', async () => {
@@ -1,4 +1,4 @@
1
- import { OverlayGASPRemote } from '../Engine'
1
+ import { OverlayGASPRemote } from '../GASP/OverlayGASPRemote'
2
2
  import { GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/gasp'
3
3
 
4
4
  global.fetch = jest.fn(async () => {