@bsv/overlay 0.5.2 → 0.5.3

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
@@ -153,8 +153,18 @@ export class Engine {
153
153
  const dupeTopics = new Set<string>()
154
154
  const failedTopics = new Set<string>()
155
155
 
156
- // Parallelize the topic processing
157
- const topicPromises = taggedBEEF.topics.map(async (topic) => {
156
+ // ===================================================================
157
+ // PHASE 1: VALIDATE (read-only, no mutations)
158
+ // ===================================================================
159
+ type TopicValidation = {
160
+ topic: string
161
+ isDupe: boolean
162
+ previousCoins: number[]
163
+ previousOutputs: Array<Output | null>
164
+ admissibleOutputs: AdmittanceInstructions
165
+ }
166
+
167
+ const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
158
168
  try {
159
169
  if (this.managers[topic] === undefined || this.managers[topic] === null) {
160
170
  throw new Error(`This server does not support this topic: ${topic}`)
@@ -167,8 +177,13 @@ export class Engine {
167
177
 
168
178
  if (dupeCheck) {
169
179
  dupeTopics.add(topic)
170
- steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
171
- return
180
+ return {
181
+ topic,
182
+ isDupe: true,
183
+ previousCoins: [],
184
+ previousOutputs: [],
185
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
186
+ }
172
187
  }
173
188
 
174
189
  // Identify previous coins admitted to this specific topic
@@ -187,108 +202,55 @@ export class Engine {
187
202
  })
188
203
 
189
204
  this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
190
- const outputs = await Promise.all(outputPromises)
205
+ const previousOutputs = await Promise.all(outputPromises)
191
206
  this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
192
207
 
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
- })()
208
+ // Determine which outputs are admissible for this topic (validation only)
209
+ this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
210
+ const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
211
+ taggedBEEF.beef,
212
+ previousCoins,
213
+ offChainValues
214
+ )
215
+ this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
270
216
 
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
217
+ return {
218
+ topic,
219
+ isDupe: false,
220
+ previousCoins,
221
+ previousOutputs,
222
+ admissibleOutputs
223
+ }
275
224
  } catch (error) {
276
- this.logger.error('Error processing topic during submit:', error)
225
+ this.logger.error('Error validating topic during submit:', error)
277
226
  failedTopics.add(topic)
278
- steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
227
+ return {
228
+ topic,
229
+ isDupe: false,
230
+ previousCoins: [],
231
+ previousOutputs: [],
232
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
233
+ }
279
234
  }
280
235
  })
281
236
 
282
- await Promise.all(topicPromises)
237
+ const validations = await Promise.all(topicValidations)
283
238
 
284
- // Broadcast the transaction if not historical and broadcaster is configured
239
+ // Build preliminary STEAK from validation results
240
+ for (const validation of validations) {
241
+ steak[validation.topic] = validation.admissibleOutputs
242
+ }
243
+
244
+ // ===================================================================
245
+ // PHASE 2: BROADCAST (before any mutations)
246
+ // ===================================================================
285
247
  this.startTime(`broadcast_${txid.substring(0, 10)}`)
286
248
  if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
287
249
  try {
288
250
  const response = await this.broadcaster.broadcast(tx)
289
251
  if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
290
252
  const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
291
- ;(e as any).more = response.more
253
+ ; (e as any).more = response.more
292
254
  throw e
293
255
  }
294
256
  } catch (error) {
@@ -300,13 +262,95 @@ export class Engine {
300
262
  }
301
263
  this.endTime(`broadcast_${txid.substring(0, 10)}`)
302
264
 
303
- // Call the callback function if it is provided (moved here to ensure topic processing is complete)
265
+ // Call the callback function with STEAK if it is provided (before storage mutations)
304
266
  if (onSteakReady !== undefined) {
305
267
  onSteakReady(steak)
306
268
  }
307
269
 
308
- // Update storage and notify lookup services
309
- for (const topic of taggedBEEF.topics) {
270
+ // ===================================================================
271
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
272
+ // ===================================================================
273
+ // Mark previous outputs as spent and notify lookup services
274
+ await Promise.all(validations.map(async (validation) => {
275
+ if (validation.isDupe || failedTopics.has(validation.topic)) {
276
+ return
277
+ }
278
+
279
+ const topic = validation.topic
280
+ const previousOutputs = validation.previousOutputs
281
+
282
+ // Mark all previous outputs as spent
283
+ const markSpentPromises = previousOutputs.map(async (output) => {
284
+ if (output !== undefined && output !== null) {
285
+ try {
286
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
287
+ await Promise.all(Object.values(this.lookupServices).map(async l => {
288
+ try {
289
+ if (typeof l.outputSpent === 'function') {
290
+ if (l.spendNotificationMode === 'txid') {
291
+ await l.outputSpent({
292
+ mode: 'txid',
293
+ spendingTxid: txid,
294
+ txid: output.txid,
295
+ outputIndex: output.outputIndex,
296
+ topic
297
+ })
298
+ } else if (l.spendNotificationMode === 'script') {
299
+ const inputIndex = tx.inputs.findIndex(i => {
300
+ let realSource = i.sourceTXID
301
+ if (!realSource) {
302
+ realSource = i.sourceTransaction?.id('hex')
303
+ }
304
+ return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
305
+ })
306
+ if (inputIndex === -1) {
307
+ throw new Error('Could not find input index')
308
+ }
309
+ await l.outputSpent({
310
+ mode: 'script',
311
+ spendingTxid: txid,
312
+ inputIndex,
313
+ sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
314
+ unlockingScript: tx.inputs[inputIndex].unlockingScript!,
315
+ txid: output.txid,
316
+ outputIndex: output.outputIndex,
317
+ topic,
318
+ offChainValues
319
+ })
320
+ } else if (l.spendNotificationMode === 'whole-tx') {
321
+ await l.outputSpent({
322
+ mode: 'whole-tx',
323
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
324
+ txid: output.txid,
325
+ outputIndex: output.outputIndex,
326
+ topic,
327
+ offChainValues
328
+ })
329
+ } else { // none
330
+ await l.outputSpent({
331
+ mode: 'none',
332
+ txid: output.txid,
333
+ outputIndex: output.outputIndex,
334
+ topic
335
+ })
336
+ }
337
+ }
338
+ } catch (error) {
339
+ this.logger.error('Error in lookup service for outputSpent:', error)
340
+ }
341
+ }))
342
+ } catch (error) {
343
+ this.logger.error('Error marking UTXO as spent:', error)
344
+ }
345
+ }
346
+ })
347
+
348
+ await Promise.all(markSpentPromises)
349
+ }))
350
+
351
+ // Continue with storage updates and lookup service notifications
352
+ for (const validation of validations) {
353
+ const topic = validation.topic
310
354
  if (dupeTopics.has(topic)) {
311
355
  continue
312
356
  }
@@ -327,19 +371,10 @@ export class Engine {
327
371
  inputIndex: number
328
372
  }> = []
329
373
 
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
- }))
374
+ // Use previousCoins from validation
375
+ const previousCoins = validation.previousCoins
341
376
 
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.
377
+ // 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
378
  for (const inputIndex of previousCoins) {
344
379
  const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
345
380
  if (typeof previousTXID !== 'string') continue
@@ -358,7 +393,7 @@ export class Engine {
358
393
  }
359
394
  }
360
395
 
361
- // Remove stale outputs recursively
396
+ // Remove stale outputs recursively
362
397
  this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
363
398
  await Promise.all(outputsToMarkStale.map(async coin => {
364
399
  const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
@@ -368,10 +403,10 @@ export class Engine {
368
403
  }))
369
404
  this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
370
405
 
371
- // Update the STEAK to indicate which coins were removed
406
+ // Update the STEAK to indicate which coins were removed
372
407
  steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
373
408
 
374
- // Handle admittance and notification of incoming UTXOs
409
+ // Handle admittance and notification of incoming UTXOs
375
410
  const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
376
411
  await Promise.all(outputsToAdmit.map(async outputIndex => {
377
412
  if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
@@ -427,7 +462,7 @@ export class Engine {
427
462
  }))
428
463
 
429
464
  this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
430
- // Update each output consumed to know who consumed it and insert applied transaction in parallel
465
+ // Update each output consumed to know who consumed it and insert applied transaction in parallel
431
466
  await Promise.all([
432
467
  ...outputsConsumed.map(async output => {
433
468
  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
+ }