@bsv/overlay 0.5.3 → 0.6.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.
package/src/Engine.ts CHANGED
@@ -16,7 +16,9 @@ import {
16
16
  HTTPSOverlayBroadcastFacilitator,
17
17
  LookupResolver,
18
18
  LookupResolverConfig,
19
- OverlayBroadcastFacilitator
19
+ OverlayBroadcastFacilitator,
20
+ BroadcastResponse,
21
+ BroadcastFailure
20
22
  } from '@bsv/sdk'
21
23
  import { AdvertisementData, Advertiser } from './Advertiser.js'
22
24
  import { GASP, GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/gasp'
@@ -132,7 +134,7 @@ export class Engine {
132
134
  *
133
135
  * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
134
136
  */
135
- async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void, mode: 'historical-tx' | 'current-tx' = 'current-tx', offChainValues?: number[]): Promise<STEAK> {
137
+ async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void, mode: 'historical-tx' | 'current-tx' | 'historical-tx-no-spv' = 'current-tx', offChainValues?: number[]): Promise<STEAK> {
136
138
  for (const t of taggedBEEF.topics) {
137
139
  if (this.managers[t] === undefined || this.managers[t] === null) {
138
140
  throw new Error(`This server does not support this topic: ${t}`)
@@ -144,10 +146,12 @@ export class Engine {
144
146
  const txid = tx.id('hex')
145
147
 
146
148
  this.startTime(`submit_${txid}`)
147
- this.startTime(`chainTracker_${txid.substring(0, 10)}`)
148
- const txValid = await tx.verify(this.chainTracker)
149
- if (!txValid) throw new Error('Unable to verify SPV information.')
150
- this.endTime(`chainTracker_${txid.substring(0, 10)}`)
149
+ if (mode !== 'historical-tx-no-spv') {
150
+ this.startTime(`chainTracker_${txid.substring(0, 10)}`)
151
+ const txValid = await tx.verify(this.chainTracker)
152
+ if (!txValid) throw new Error('Unable to verify SPV information.')
153
+ this.endTime(`chainTracker_${txid.substring(0, 10)}`)
154
+ }
151
155
 
152
156
  const steak: STEAK = {}
153
157
  const dupeTopics = new Set<string>()
@@ -210,7 +214,8 @@ export class Engine {
210
214
  const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
211
215
  taggedBEEF.beef,
212
216
  previousCoins,
213
- offChainValues
217
+ offChainValues,
218
+ mode
214
219
  )
215
220
  this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
216
221
 
@@ -247,7 +252,21 @@ export class Engine {
247
252
  this.startTime(`broadcast_${txid.substring(0, 10)}`)
248
253
  if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
249
254
  try {
250
- const response = await this.broadcaster.broadcast(tx)
255
+ let response: BroadcastResponse | BroadcastFailure
256
+ if (tx.merklePath !== undefined) {
257
+ // tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
258
+ const txid = tx.id('hex')
259
+ const mp = tx.merklePath
260
+ const leaf = mp.path[0].find(leaf => leaf.hash === txid)
261
+ const r: BroadcastResponse = {
262
+ status: 'success',
263
+ txid: tx.id('hex'),
264
+ message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
265
+ }
266
+ response = r
267
+ } else {
268
+ response = await this.broadcaster.broadcast(tx)
269
+ }
251
270
  if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
252
271
  const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
253
272
  ; (e as any).more = response.more
@@ -483,7 +502,7 @@ export class Engine {
483
502
  }
484
503
 
485
504
  // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
486
- if (this.advertiser === undefined || mode === 'historical-tx') {
505
+ if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
487
506
  return steak
488
507
  }
489
508
 
@@ -21,9 +21,45 @@ export interface GraphNode {
21
21
 
22
22
  export class OverlayGASPStorage implements GASPStorage {
23
23
  readonly temporaryGraphNodeRefs: Record<string, GraphNode> = {}
24
+ private static activeAnchorValidations = 0
25
+ private static readonly anchorValidationQueue: Array<() => void> = []
26
+ private static activeFinalizations = 0
27
+ private static readonly finalizationQueue: Array<() => void> = []
28
+ private static readonly MAX_CONCURRENT_ANCHOR_VALIDATIONS = 4
29
+ private static readonly MAX_CONCURRENT_FINALIZATIONS = 2
24
30
 
25
31
  constructor (public topic: string, public engine: Engine, public maxNodesInGraph?: number) { }
26
32
 
33
+ private static async acquireAnchorValidationSlot (): Promise<void> {
34
+ if (OverlayGASPStorage.activeAnchorValidations >= OverlayGASPStorage.MAX_CONCURRENT_ANCHOR_VALIDATIONS) {
35
+ await new Promise<void>(resolve => { OverlayGASPStorage.anchorValidationQueue.push(resolve) })
36
+ }
37
+ OverlayGASPStorage.activeAnchorValidations++
38
+ }
39
+
40
+ private static releaseAnchorValidationSlot (): void {
41
+ OverlayGASPStorage.activeAnchorValidations--
42
+ const next = OverlayGASPStorage.anchorValidationQueue.shift()
43
+ if (next !== undefined) {
44
+ next()
45
+ }
46
+ }
47
+
48
+ private static async acquireFinalizationSlot (): Promise<void> {
49
+ if (OverlayGASPStorage.activeFinalizations >= OverlayGASPStorage.MAX_CONCURRENT_FINALIZATIONS) {
50
+ await new Promise<void>(resolve => { OverlayGASPStorage.finalizationQueue.push(resolve) })
51
+ }
52
+ OverlayGASPStorage.activeFinalizations++
53
+ }
54
+
55
+ private static releaseFinalizationSlot (): void {
56
+ OverlayGASPStorage.activeFinalizations--
57
+ const next = OverlayGASPStorage.finalizationQueue.shift()
58
+ if (next !== undefined) {
59
+ next()
60
+ }
61
+ }
62
+
27
63
  /**
28
64
  *
29
65
  * @param since
@@ -91,8 +127,12 @@ export class OverlayGASPStorage implements GASPStorage {
91
127
 
92
128
  // Attempt to check if the current transaction is admissible
93
129
  parsedTx.merklePath = MerklePath.fromHex(tx.proof)
94
- const admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTx.toBEEF(), [], typeof tx.txMetadata === 'string' ? Utils.toArray(tx.txMetadata) : undefined)
95
-
130
+ const admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(
131
+ parsedTx.toBEEF(),
132
+ [],
133
+ typeof tx.txMetadata === 'string' ? Utils.toArray(tx.txMetadata) : undefined,
134
+ 'historical-tx'
135
+ )
96
136
  if (admittanceResult.outputsToAdmit.includes(tx.outputIndex)) {
97
137
  // The transaction is admissible, no further inputs are needed
98
138
  } else {
@@ -196,51 +236,61 @@ export class OverlayGASPStorage implements GASPStorage {
196
236
  * @throws If the graph is not well-anchored, according to the rules of Bitcoin or the rules of the Overlay Topic Manager.
197
237
  */
198
238
  async validateGraphAnchor (graphID: string): Promise<void> {
199
- const rootNode = this.temporaryGraphNodeRefs[graphID]
200
- if (rootNode === undefined) {
201
- throw new Error(`Graph node with ID ${graphID} not found`)
202
- }
203
-
204
- // Check that the root node is Bitcoin-valid.
205
- const beef = this.getBEEFForNode(rootNode)
206
- const spvTx = Transaction.fromBEEF(beef)
207
- const isBitcoinValid = await spvTx.verify(this.engine.chainTracker)
208
- if (!isBitcoinValid) {
209
- throw new Error('The graph is not well-anchored according to the rules of Bitcoin.')
210
- }
211
-
212
- // Then, ensure the node is Overlay-valid.
213
- const beefs = this.computeOrderedBEEFsForGraph(graphID)
239
+ await OverlayGASPStorage.acquireAnchorValidationSlot()
240
+ try {
241
+ const rootNode = this.temporaryGraphNodeRefs[graphID]
242
+ if (rootNode === undefined) {
243
+ throw new Error(`Graph node with ID ${graphID} not found`)
244
+ }
214
245
 
215
- // coins: a Set of all historical coins to retain (no need to remove them), used to emulate topical admittance of previous inputs over time.
216
- const coins = new Set<string>()
246
+ // Check that the root node is Bitcoin-valid.
247
+ const beef = this.getBEEFForNode(rootNode)
248
+ const spvTx = Transaction.fromBEEF(beef)
249
+ const isBitcoinValid = await spvTx.verify(this.engine.chainTracker)
250
+ if (!isBitcoinValid) {
251
+ throw new Error('The graph is not well-anchored according to the rules of Bitcoin.')
252
+ }
217
253
 
218
- // Submit all historical BEEFs in order through the topic manager, tracking what would be retained until we submit the root node last.
219
- // If, at the end, the root node is admitted, we have a valid overlay-specific graph.
220
- for (const beef of beefs) {
221
- // For any input to this transaction, see if it's a valid coin that's admitted. If so, it's a previous coin.
222
- const previousCoins: number[] = []
223
- const tx = Transaction.fromBEEF(beef)
224
- for (const [inputIndex, input] of tx.inputs.entries()) {
225
- const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
226
- if (sourceTXID != null && sourceTXID !== '') {
227
- const coin = `${sourceTXID}.${input.sourceOutputIndex}`
228
- if (coins.has(coin)) {
229
- previousCoins.push(Number(inputIndex))
254
+ // Then, ensure the node is Overlay-valid.
255
+ const beefs = this.computeOrderedBEEFsForGraph(graphID)
256
+
257
+ // coins: a Set of all historical coins to retain (no need to remove them), used to emulate topical admittance of previous inputs over time.
258
+ const coins = new Set<string>()
259
+
260
+ // Submit all historical BEEFs in order through the topic manager, tracking what would be retained until we submit the root node last.
261
+ // If, at the end, the root node is admitted, we have a valid overlay-specific graph.
262
+ for (const beef of beefs) {
263
+ // For any input to this transaction, see if it's a valid coin that's admitted. If so, it's a previous coin.
264
+ const previousCoins: number[] = []
265
+ const tx = Transaction.fromBEEF(beef)
266
+ for (const [inputIndex, input] of tx.inputs.entries()) {
267
+ const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
268
+ if (sourceTXID != null && sourceTXID !== '') {
269
+ const coin = `${sourceTXID}.${input.sourceOutputIndex}`
270
+ if (coins.has(coin)) {
271
+ previousCoins.push(Number(inputIndex))
272
+ }
230
273
  }
231
274
  }
275
+ const admittanceInstructions = await this.engine.managers[this.topic].identifyAdmissibleOutputs(
276
+ beef,
277
+ previousCoins,
278
+ undefined,
279
+ 'historical-tx'
280
+ )
281
+ // Every admitted output is now a coin.
282
+ for (const outputIndex of admittanceInstructions.outputsToAdmit) {
283
+ coins.add(`${tx.id('hex')}.${outputIndex}`)
284
+ }
232
285
  }
233
- const admittanceInstructions = await this.engine.managers[this.topic].identifyAdmissibleOutputs(beef, previousCoins)
234
- // Every admitted output is now a coin.
235
- for (const outputIndex of admittanceInstructions.outputsToAdmit) {
236
- coins.add(`${tx.id('hex')}.${outputIndex}`)
286
+ // After sending through all the graph's BEEFs...
287
+ // If the root node is now a coin, we have acceptance by the overlay.
288
+ // Otherwise, throw.
289
+ if (!coins.has(graphID)) {
290
+ throw new Error('This graph did not result in topical admittance of the root node. Rejecting.')
237
291
  }
238
- }
239
- // After sending through all the graph's BEEFs...
240
- // If the root node is now a coin, we have acceptance by the overlay.
241
- // Otherwise, throw.
242
- if (!coins.has(graphID)) {
243
- throw new Error('This graph did not result in topical admittance of the root node. Rejecting.')
292
+ } finally {
293
+ OverlayGASPStorage.releaseAnchorValidationSlot()
244
294
  }
245
295
  }
246
296
 
@@ -263,14 +313,20 @@ export class OverlayGASPStorage implements GASPStorage {
263
313
  * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the root of this graph.
264
314
  */
265
315
  async finalizeGraph (graphID: string): Promise<void> {
266
- const beefs = this.computeOrderedBEEFsForGraph(graphID)
267
-
268
- // Submit all historical BEEFs in order, finalizing the graph for the current UTXO
269
- for (const beef of beefs) {
270
- await this.engine.submit({
271
- beef,
272
- topics: [this.topic]
273
- }, () => { }, 'historical-tx')
316
+ await OverlayGASPStorage.acquireFinalizationSlot()
317
+ try {
318
+ const beefs = this.computeOrderedBEEFsForGraph(graphID)
319
+
320
+ // Submit all historical BEEFs in order, finalizing the graph for the current UTXO.
321
+ // We skip SPV verification here because validateGraphAnchor has already done it.
322
+ for (const beef of beefs) {
323
+ await this.engine.submit({
324
+ beef,
325
+ topics: [this.topic]
326
+ }, () => { }, 'historical-tx-no-spv')
327
+ }
328
+ } finally {
329
+ OverlayGASPStorage.releaseFinalizationSlot()
274
330
  }
275
331
  }
276
332
 
@@ -9,7 +9,12 @@ export interface TopicManager {
9
9
  * Accepts the transaction in BEEF format and an array of those input indices which spend previously-admitted outputs from the same topic.
10
10
  * The transaction's BEEF structure will always contain the transactions associated with previous coins for reference (if any), regardless of whether the current transaction was directly proven.
11
11
  */
12
- identifyAdmissibleOutputs: (beef: number[], previousCoins: number[], offChainValues?: number[]) => Promise<AdmittanceInstructions>
12
+ identifyAdmissibleOutputs: (
13
+ beef: number[],
14
+ previousCoins: number[],
15
+ offChainValues?: number[],
16
+ mode?: 'historical-tx' | 'current-tx' | 'historical-tx-no-spv'
17
+ ) => Promise<AdmittanceInstructions>
13
18
 
14
19
  /**
15
20
  * Identifies and returns the inputs needed to anchor any topical outputs from this transaction to their associated previous history.
@@ -528,12 +528,16 @@ describe('BSV Overlay Services Engine', () => {
528
528
  mockChainTracker
529
529
  )
530
530
 
531
- // Submit the utxo
532
531
  await engine.submit({
533
532
  beef: exampleBeef,
534
533
  topics: ['Hello']
535
534
  })
536
- expect(engine.managers.Hello.identifyAdmissibleOutputs).toHaveBeenCalledWith(exampleBeef, [0], undefined)
535
+ expect(engine.managers.Hello.identifyAdmissibleOutputs).toHaveBeenCalledWith(
536
+ exampleBeef,
537
+ [0],
538
+ undefined,
539
+ 'current-tx'
540
+ )
537
541
  })
538
542
  describe('When previous UTXOs were retained by the topic manager', () => {
539
543
  it('Notifies all lookup services about the output being spent (not deleted, see the comment about this in deleteUTXODeep)', async () => {
@@ -170,28 +170,33 @@ export class KnexStorage implements Storage {
170
170
  }
171
171
 
172
172
  async insertOutput (output: Output): Promise<void> {
173
- const insertPromises = [this.knex('outputs').insert({
174
- txid: output.txid,
175
- outputIndex: Number(output.outputIndex),
176
- outputScript: Buffer.from(output.outputScript),
177
- topic: output.topic,
178
- satoshis: Number(output.satoshis),
179
- outputsConsumed: JSON.stringify(output.outputsConsumed),
180
- consumedBy: JSON.stringify(output.consumedBy),
181
- spent: output.spent,
182
- score: output.score
183
- })]
184
-
185
- if (output.beef !== undefined) {
186
- const insertTransactionPromise = this.knex('transactions').insert({
173
+ await this.knex.transaction(async trx => {
174
+ const existing = await trx('outputs').where({
187
175
  txid: output.txid,
188
- beef: Buffer.from(output.beef)
189
- }).onConflict('txid').ignore()
190
- insertPromises.push(insertTransactionPromise)
191
- }
176
+ outputIndex: Number(output.outputIndex),
177
+ topic: output.topic
178
+ }).first()
179
+
180
+ if (existing === undefined || existing === null) {
181
+ await trx('outputs').insert({
182
+ txid: output.txid,
183
+ outputIndex: Number(output.outputIndex),
184
+ outputScript: Buffer.from(output.outputScript),
185
+ topic: output.topic,
186
+ satoshis: Number(output.satoshis),
187
+ outputsConsumed: JSON.stringify(output.outputsConsumed),
188
+ consumedBy: JSON.stringify(output.consumedBy),
189
+ spent: output.spent,
190
+ score: output.score
191
+ })
192
+ }
192
193
 
193
- await this.knex.transaction(async trx => {
194
- await Promise.all(insertPromises.map(promise => promise.transacting(trx)))
194
+ if (output.beef !== undefined) {
195
+ await trx('transactions').insert({
196
+ txid: output.txid,
197
+ beef: Buffer.from(output.beef)
198
+ }).onConflict('txid').ignore()
199
+ }
195
200
  })
196
201
  }
197
202