@bsv/overlay 0.5.2 → 0.5.4

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'
@@ -153,8 +155,18 @@ export class Engine {
153
155
  const dupeTopics = new Set<string>()
154
156
  const failedTopics = new Set<string>()
155
157
 
156
- // Parallelize the topic processing
157
- const topicPromises = taggedBEEF.topics.map(async (topic) => {
158
+ // ===================================================================
159
+ // PHASE 1: VALIDATE (read-only, no mutations)
160
+ // ===================================================================
161
+ type TopicValidation = {
162
+ topic: string
163
+ isDupe: boolean
164
+ previousCoins: number[]
165
+ previousOutputs: Array<Output | null>
166
+ admissibleOutputs: AdmittanceInstructions
167
+ }
168
+
169
+ const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
158
170
  try {
159
171
  if (this.managers[topic] === undefined || this.managers[topic] === null) {
160
172
  throw new Error(`This server does not support this topic: ${topic}`)
@@ -167,8 +179,13 @@ export class Engine {
167
179
 
168
180
  if (dupeCheck) {
169
181
  dupeTopics.add(topic)
170
- steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
171
- return
182
+ return {
183
+ topic,
184
+ isDupe: true,
185
+ previousCoins: [],
186
+ previousOutputs: [],
187
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
188
+ }
172
189
  }
173
190
 
174
191
  // Identify previous coins admitted to this specific topic
@@ -187,108 +204,69 @@ export class Engine {
187
204
  })
188
205
 
189
206
  this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
190
- const outputs = await Promise.all(outputPromises)
207
+ const previousOutputs = await Promise.all(outputPromises)
191
208
  this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
192
209
 
193
- const markSpentPromises = outputs.map(async (output) => {
194
- if (output !== undefined && output !== null) {
195
- try {
196
- await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
197
- await Promise.all(Object.values(this.lookupServices).map(async l => {
198
- try {
199
- if (typeof l.outputSpent === 'function') {
200
- if (l.spendNotificationMode === 'txid') {
201
- await l.outputSpent({
202
- mode: 'txid',
203
- spendingTxid: txid,
204
- txid: output.txid,
205
- outputIndex: output.outputIndex,
206
- topic
207
- })
208
- } else if (l.spendNotificationMode === 'script') {
209
- const inputIndex = tx.inputs.findIndex(i => {
210
- let realSource = i.sourceTXID
211
- if (!realSource) {
212
- realSource = i.sourceTransaction?.id('hex')
213
- }
214
- return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
215
- })
216
- if (inputIndex === -1) {
217
- throw new Error('Could not find input index')
218
- }
219
- await l.outputSpent({
220
- mode: 'script',
221
- spendingTxid: txid,
222
- inputIndex,
223
- sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
224
- unlockingScript: tx.inputs[inputIndex].unlockingScript!,
225
- txid: output.txid,
226
- outputIndex: output.outputIndex,
227
- topic,
228
- offChainValues
229
- })
230
- } else if (l.spendNotificationMode === 'whole-tx') {
231
- await l.outputSpent({
232
- mode: 'whole-tx',
233
- spendingAtomicBEEF: tx.toAtomicBEEF(),
234
- txid: output.txid,
235
- outputIndex: output.outputIndex,
236
- topic,
237
- offChainValues
238
- })
239
- } else { // none
240
- await l.outputSpent({
241
- mode: 'none',
242
- txid: output.txid,
243
- outputIndex: output.outputIndex,
244
- topic
245
- })
246
- }
247
- }
248
- } catch (error) {
249
- this.logger.error('Error in lookup service for outputSpent:', error)
250
- }
251
- }))
252
- } catch (error) {
253
- this.logger.error('Error marking UTXO as spent:', error)
254
- }
255
- }
256
- })
257
-
258
- let admissibleOutputs: AdmittanceInstructions = { outputsToAdmit: [], coinsToRetain: [] }
259
- // Determine which outputs are admissible for this topic
260
- const admissibleOutputsPromise = (async () => {
261
- try {
262
- this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
263
- admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(taggedBEEF.beef, previousCoins, offChainValues)
264
- this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
265
- } catch (_) {
266
- failedTopics.add(topic)
267
- steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
268
- }
269
- })()
210
+ // Determine which outputs are admissible for this topic (validation only)
211
+ this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
212
+ const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
213
+ taggedBEEF.beef,
214
+ previousCoins,
215
+ offChainValues
216
+ )
217
+ this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
270
218
 
271
- // Wait for both tasks to complete
272
- await Promise.all([...markSpentPromises, admissibleOutputsPromise])
273
- // Keep track of what outputs were admitted for what topic
274
- steak[topic] = admissibleOutputs
219
+ return {
220
+ topic,
221
+ isDupe: false,
222
+ previousCoins,
223
+ previousOutputs,
224
+ admissibleOutputs
225
+ }
275
226
  } catch (error) {
276
- this.logger.error('Error processing topic during submit:', error)
227
+ this.logger.error('Error validating topic during submit:', error)
277
228
  failedTopics.add(topic)
278
- steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
229
+ return {
230
+ topic,
231
+ isDupe: false,
232
+ previousCoins: [],
233
+ previousOutputs: [],
234
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
235
+ }
279
236
  }
280
237
  })
281
238
 
282
- await Promise.all(topicPromises)
239
+ const validations = await Promise.all(topicValidations)
240
+
241
+ // Build preliminary STEAK from validation results
242
+ for (const validation of validations) {
243
+ steak[validation.topic] = validation.admissibleOutputs
244
+ }
283
245
 
284
- // Broadcast the transaction if not historical and broadcaster is configured
246
+ // ===================================================================
247
+ // PHASE 2: BROADCAST (before any mutations)
248
+ // ===================================================================
285
249
  this.startTime(`broadcast_${txid.substring(0, 10)}`)
286
250
  if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
287
251
  try {
288
- const response = await this.broadcaster.broadcast(tx)
252
+ let response: BroadcastResponse | BroadcastFailure
253
+ if (tx.merklePath !== undefined) {
254
+ // tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
255
+ const txid = tx.id('hex')
256
+ const mp = tx.merklePath
257
+ const leaf = mp.path[0].find(leaf => leaf.hash === txid)
258
+ const r: BroadcastResponse = {
259
+ status: 'success',
260
+ txid: tx.id('hex'),
261
+ message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
262
+ }
263
+ response = r
264
+ } else {
265
+ response = await this.broadcaster.broadcast(tx)
266
+ }
289
267
  if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
290
268
  const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
291
- ;(e as any).more = response.more
269
+ ; (e as any).more = response.more
292
270
  throw e
293
271
  }
294
272
  } catch (error) {
@@ -300,13 +278,95 @@ export class Engine {
300
278
  }
301
279
  this.endTime(`broadcast_${txid.substring(0, 10)}`)
302
280
 
303
- // Call the callback function if it is provided (moved here to ensure topic processing is complete)
281
+ // Call the callback function with STEAK if it is provided (before storage mutations)
304
282
  if (onSteakReady !== undefined) {
305
283
  onSteakReady(steak)
306
284
  }
307
285
 
308
- // Update storage and notify lookup services
309
- for (const topic of taggedBEEF.topics) {
286
+ // ===================================================================
287
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
288
+ // ===================================================================
289
+ // Mark previous outputs as spent and notify lookup services
290
+ await Promise.all(validations.map(async (validation) => {
291
+ if (validation.isDupe || failedTopics.has(validation.topic)) {
292
+ return
293
+ }
294
+
295
+ const topic = validation.topic
296
+ const previousOutputs = validation.previousOutputs
297
+
298
+ // Mark all previous outputs as spent
299
+ const markSpentPromises = previousOutputs.map(async (output) => {
300
+ if (output !== undefined && output !== null) {
301
+ try {
302
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
303
+ await Promise.all(Object.values(this.lookupServices).map(async l => {
304
+ try {
305
+ if (typeof l.outputSpent === 'function') {
306
+ if (l.spendNotificationMode === 'txid') {
307
+ await l.outputSpent({
308
+ mode: 'txid',
309
+ spendingTxid: txid,
310
+ txid: output.txid,
311
+ outputIndex: output.outputIndex,
312
+ topic
313
+ })
314
+ } else if (l.spendNotificationMode === 'script') {
315
+ const inputIndex = tx.inputs.findIndex(i => {
316
+ let realSource = i.sourceTXID
317
+ if (!realSource) {
318
+ realSource = i.sourceTransaction?.id('hex')
319
+ }
320
+ return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
321
+ })
322
+ if (inputIndex === -1) {
323
+ throw new Error('Could not find input index')
324
+ }
325
+ await l.outputSpent({
326
+ mode: 'script',
327
+ spendingTxid: txid,
328
+ inputIndex,
329
+ sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
330
+ unlockingScript: tx.inputs[inputIndex].unlockingScript!,
331
+ txid: output.txid,
332
+ outputIndex: output.outputIndex,
333
+ topic,
334
+ offChainValues
335
+ })
336
+ } else if (l.spendNotificationMode === 'whole-tx') {
337
+ await l.outputSpent({
338
+ mode: 'whole-tx',
339
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
340
+ txid: output.txid,
341
+ outputIndex: output.outputIndex,
342
+ topic,
343
+ offChainValues
344
+ })
345
+ } else { // none
346
+ await l.outputSpent({
347
+ mode: 'none',
348
+ txid: output.txid,
349
+ outputIndex: output.outputIndex,
350
+ topic
351
+ })
352
+ }
353
+ }
354
+ } catch (error) {
355
+ this.logger.error('Error in lookup service for outputSpent:', error)
356
+ }
357
+ }))
358
+ } catch (error) {
359
+ this.logger.error('Error marking UTXO as spent:', error)
360
+ }
361
+ }
362
+ })
363
+
364
+ await Promise.all(markSpentPromises)
365
+ }))
366
+
367
+ // Continue with storage updates and lookup service notifications
368
+ for (const validation of validations) {
369
+ const topic = validation.topic
310
370
  if (dupeTopics.has(topic)) {
311
371
  continue
312
372
  }
@@ -327,19 +387,10 @@ export class Engine {
327
387
  inputIndex: number
328
388
  }> = []
329
389
 
330
- // Recompute previousCoins for this topic to use in the update logic
331
- const previousCoins: number[] = []
332
- await Promise.all(tx.inputs.map(async (input, i) => {
333
- const previousTXID = input.sourceTXID !== undefined ? input.sourceTXID : input.sourceTransaction?.id('hex')
334
- if (previousTXID !== undefined) {
335
- const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic)
336
- if (output !== undefined && output !== null) {
337
- previousCoins.push(i)
338
- }
339
- }
340
- }))
390
+ // Use previousCoins from validation
391
+ const previousCoins = validation.previousCoins
341
392
 
342
- // For each of the previous UTXOs for this topic, if the UTXO was not included in the list of UTXOs identified for retention, then it will be marked as stale.
393
+ // For each of the previous UTXOs for this topic, if the UTXO was not included in the list of UTXOs identified for retention, then it will be marked as stale.
343
394
  for (const inputIndex of previousCoins) {
344
395
  const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
345
396
  if (typeof previousTXID !== 'string') continue
@@ -358,7 +409,7 @@ export class Engine {
358
409
  }
359
410
  }
360
411
 
361
- // Remove stale outputs recursively
412
+ // Remove stale outputs recursively
362
413
  this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
363
414
  await Promise.all(outputsToMarkStale.map(async coin => {
364
415
  const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
@@ -368,10 +419,10 @@ export class Engine {
368
419
  }))
369
420
  this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
370
421
 
371
- // Update the STEAK to indicate which coins were removed
422
+ // Update the STEAK to indicate which coins were removed
372
423
  steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
373
424
 
374
- // Handle admittance and notification of incoming UTXOs
425
+ // Handle admittance and notification of incoming UTXOs
375
426
  const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
376
427
  await Promise.all(outputsToAdmit.map(async outputIndex => {
377
428
  if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
@@ -427,7 +478,7 @@ export class Engine {
427
478
  }))
428
479
 
429
480
  this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
430
- // Update each output consumed to know who consumed it and insert applied transaction in parallel
481
+ // Update each output consumed to know who consumed it and insert applied transaction in parallel
431
482
  await Promise.all([
432
483
  ...outputsConsumed.map(async output => {
433
484
  const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic)
@@ -6,8 +6,7 @@ import { up as addedIndexesUp, down as addedIndexesDown } from './migrations/202
6
6
  import { up as enlargeUp, down as enlargeDown } from './migrations/2025-05-28-001-enlarge.js'
7
7
  import { up as gaspPaginationSupportUp, down as gaspPaginationSupportDown } from './migrations/2025-06-25-001-gasp-pagination-support.js'
8
8
  import { up as fixScoreColumnTypeUp, down as fixScoreColumnTypeDown } from './migrations/2025-07-22-001-fix-score-column-type.js'
9
-
10
-
9
+ import { up as utxoLookupIndexUp, down as utxoLookupIndexDown } from './migrations/2025-11-11-001-utxo-lookup-index.js'
11
10
 
12
11
  /**
13
12
  * An array of all migrations, in order.
@@ -24,7 +23,8 @@ const allMigrations: Migration[] = [
24
23
  { up: addedIndexesUp, down: addedIndexesDown },
25
24
  { up: enlargeUp, down: enlargeDown },
26
25
  { up: gaspPaginationSupportUp, down: gaspPaginationSupportDown },
27
- { up: fixScoreColumnTypeUp, down: fixScoreColumnTypeDown }
26
+ { up: fixScoreColumnTypeUp, down: fixScoreColumnTypeDown },
27
+ { up: utxoLookupIndexUp, down: utxoLookupIndexDown }
28
28
  ]
29
29
 
30
30
  export default allMigrations
@@ -0,0 +1,18 @@
1
+ import type { Knex } from 'knex'
2
+
3
+ /**
4
+ * Adds optimized index for findUTXOsForTopic queries.
5
+ * This query pattern is: WHERE topic = ? AND spent = false ORDER BY score
6
+ * The composite index (topic, spent, score) enables efficient range scans.
7
+ */
8
+ export async function up (knex: Knex): Promise<void> {
9
+ await knex.schema.table('outputs', function (table) {
10
+ table.index(['topic', 'spent', 'score'], 'idx_outputs_topic_spent_score')
11
+ })
12
+ }
13
+
14
+ export async function down (knex: Knex): Promise<void> {
15
+ await knex.schema.table('outputs', function (table) {
16
+ table.dropIndex(['topic', 'spent', 'score'], 'idx_outputs_topic_spent_score')
17
+ })
18
+ }