@bsv/overlay 2.2.0 → 2.2.7

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 (155) hide show
  1. package/LICENSE.txt +57 -27
  2. package/README.md +102 -266
  3. package/dist/cjs/mod.d.ts +15 -0
  4. package/dist/cjs/mod.d.ts.map +1 -0
  5. package/dist/cjs/mod.js +4 -1
  6. package/dist/cjs/mod.js.map +1 -1
  7. package/dist/cjs/package.json +46 -18
  8. package/dist/cjs/src/Advertisement.d.ts +9 -0
  9. package/dist/cjs/src/Advertisement.d.ts.map +1 -0
  10. package/dist/cjs/src/Advertiser.d.ts +36 -0
  11. package/dist/cjs/src/Advertiser.d.ts.map +1 -0
  12. package/dist/cjs/src/BASM.d.ts +110 -0
  13. package/dist/cjs/src/BASM.d.ts.map +1 -0
  14. package/dist/cjs/src/BASM.js.map +1 -1
  15. package/dist/cjs/src/BASMRemote.d.ts +14 -0
  16. package/dist/cjs/src/BASMRemote.d.ts.map +1 -0
  17. package/dist/cjs/src/BASMRemote.js.map +1 -1
  18. package/dist/cjs/src/Engine.d.ts +377 -0
  19. package/dist/cjs/src/Engine.d.ts.map +1 -0
  20. package/dist/cjs/src/Engine.js +430 -436
  21. package/dist/cjs/src/Engine.js.map +1 -1
  22. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +24 -0
  23. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
  24. package/dist/cjs/src/GASP/OverlayGASPRemote.js +7 -10
  25. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  26. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +103 -0
  27. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
  28. package/dist/cjs/src/GASP/OverlayGASPStorage.js +34 -31
  29. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  30. package/dist/cjs/src/LookupFormula.d.ts +29 -0
  31. package/dist/cjs/src/LookupFormula.d.ts.map +1 -0
  32. package/dist/cjs/src/LookupService.d.ts +85 -0
  33. package/dist/cjs/src/LookupService.d.ts.map +1 -0
  34. package/dist/cjs/src/Output.d.ts +32 -0
  35. package/dist/cjs/src/Output.d.ts.map +1 -0
  36. package/dist/cjs/src/SafeLog.d.ts +14 -0
  37. package/dist/cjs/src/SafeLog.d.ts.map +1 -0
  38. package/dist/cjs/src/SafeLog.js +44 -0
  39. package/dist/cjs/src/SafeLog.js.map +1 -0
  40. package/{src/SyncConfiguration.ts → dist/cjs/src/SyncConfiguration.d.ts} +2 -1
  41. package/dist/cjs/src/SyncConfiguration.d.ts.map +1 -0
  42. package/dist/cjs/src/TopicManager.d.ts +35 -0
  43. package/dist/cjs/src/TopicManager.d.ts.map +1 -0
  44. package/dist/cjs/src/storage/Storage.d.ts +237 -0
  45. package/dist/cjs/src/storage/Storage.d.ts.map +1 -0
  46. package/dist/cjs/src/storage/knex/KnexStorage.d.ts +69 -0
  47. package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -0
  48. package/dist/cjs/src/storage/knex/KnexStorage.js +7 -10
  49. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  50. package/dist/cjs/src/storage/knex/all-migrations.d.ts +11 -0
  51. package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -0
  52. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  53. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts +4 -0
  54. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts.map +1 -0
  55. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.js.map +1 -1
  56. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts +4 -0
  57. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts.map +1 -0
  58. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.js.map +1 -1
  59. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts +4 -0
  60. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -0
  61. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js +36 -12
  62. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
  63. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
  64. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
  65. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -1
  66. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts +4 -0
  67. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -0
  68. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  69. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts +4 -0
  70. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts.map +1 -0
  71. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.js.map +1 -1
  72. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts +4 -0
  73. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts.map +1 -0
  74. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.js.map +1 -1
  75. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
  76. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
  77. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -1
  78. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
  79. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
  80. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -1
  81. package/dist/esm/mod.js +2 -1
  82. package/dist/esm/mod.js.map +1 -1
  83. package/dist/esm/src/BASMRemote.js.map +1 -1
  84. package/dist/esm/src/Engine.js +408 -414
  85. package/dist/esm/src/Engine.js.map +1 -1
  86. package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
  87. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  88. package/dist/esm/src/GASP/OverlayGASPStorage.js +23 -19
  89. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  90. package/dist/esm/src/SafeLog.js +40 -0
  91. package/dist/esm/src/SafeLog.js.map +1 -0
  92. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  93. package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.js +36 -12
  94. package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
  95. package/dist/types/mod.d.ts +5 -4
  96. package/dist/types/mod.d.ts.map +1 -1
  97. package/dist/types/src/BASMRemote.d.ts.map +1 -1
  98. package/dist/types/src/Engine.d.ts +15 -0
  99. package/dist/types/src/Engine.d.ts.map +1 -1
  100. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +2 -2
  101. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  102. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +3 -1
  103. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  104. package/dist/types/src/SafeLog.d.ts +14 -0
  105. package/dist/types/src/SafeLog.d.ts.map +1 -0
  106. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  107. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  108. package/dist/types/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -1
  109. package/package.json +87 -30
  110. package/dist/cjs/tsconfig.cjs.tsbuildinfo +0 -1
  111. package/dist/esm/tsconfig.esm.tsbuildinfo +0 -1
  112. package/dist/types/tsconfig.types.tsbuildinfo +0 -1
  113. package/docs/API.md +0 -944
  114. package/docs/BRC-136-BASM.md +0 -327
  115. package/docs/README.md +0 -16
  116. package/docs/Synchronization.md +0 -212
  117. package/docs/concepts/00-overview.md +0 -85
  118. package/docs/concepts/01-best-practices.md +0 -202
  119. package/docs/concepts/02-query-performance.md +0 -345
  120. package/docs/concepts/03-database-monitoring.md +0 -211
  121. package/docs/concepts/04-pagination-example.md +0 -186
  122. package/docs/concepts/05-recommendations-summary.md +0 -158
  123. package/docs/concepts/README.md +0 -20
  124. package/docs/examples/README.md +0 -15
  125. package/docs/examples/gs-wip.md +0 -111
  126. package/docs/internal/README.md +0 -11
  127. package/mod.ts +0 -40
  128. package/src/Advertisement.ts +0 -8
  129. package/src/Advertiser.ts +0 -41
  130. package/src/BASM.ts +0 -208
  131. package/src/BASMRemote.ts +0 -54
  132. package/src/Engine.ts +0 -2199
  133. package/src/GASP/OverlayGASPRemote.ts +0 -108
  134. package/src/GASP/OverlayGASPStorage.ts +0 -383
  135. package/src/LookupFormula.ts +0 -31
  136. package/src/LookupService.ts +0 -136
  137. package/src/Output.ts +0 -31
  138. package/src/TopicManager.ts +0 -40
  139. package/src/__tests/BASM.test.ts +0 -44
  140. package/src/__tests/BASMChain.test.ts +0 -290
  141. package/src/__tests/Engine.test.ts +0 -1345
  142. package/src/__tests/OverlayGASPRemote.test.ts +0 -135
  143. package/src/__tests/OverlayGASPStorage.test.ts +0 -171
  144. package/src/storage/Storage.ts +0 -260
  145. package/src/storage/knex/KnexStorage.ts +0 -651
  146. package/src/storage/knex/all-migrations.ts +0 -32
  147. package/src/storage/knex/migrations/2024-05-18-001-initial.ts +0 -30
  148. package/src/storage/knex/migrations/2024-07-10-001-block-height.ts +0 -15
  149. package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +0 -40
  150. package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +0 -30
  151. package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +0 -42
  152. package/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.ts +0 -46
  153. package/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.ts +0 -24
  154. package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +0 -18
  155. package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +0 -123
@@ -4,8 +4,15 @@ import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js';
4
4
  import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js';
5
5
  import { BASM_ZERO_HASH, computeBasmRoot, computeTac, extractMerkleProofMetadata } from './BASM.js';
6
6
  import { BASMRemote } from './BASMRemote.js';
7
+ import { serializeErrorForLog, serializeLogValue } from './SafeLog.js';
7
8
  const DEFAULT_GASP_SYNC_LIMIT = 10000;
8
9
  const DEFAULT_BASM_RANGE_LIMIT = 1024;
10
+ function findSpendingInputIndex(tx, output) {
11
+ return tx.inputs.findIndex(input => {
12
+ const realSource = input.sourceTXID || input.sourceTransaction?.id('hex');
13
+ return realSource === output.txid && input.sourceOutputIndex === output.outputIndex;
14
+ });
15
+ }
9
16
  /**
10
17
  * An engine for running BSV Overlay Services (topic managers and lookup services).
11
18
  */
@@ -73,12 +80,7 @@ export class Engine {
73
80
  this.unprovenEvictionBlocks = unprovenEvictionBlocks;
74
81
  // To encourage synchronization of overlay services, the SHIP sync strategy is used by default for all overlay topics, except for 'tm_ship' and 'tm_slap'.
75
82
  // For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
76
- if (syncConfiguration === undefined) {
77
- this.syncConfiguration = {};
78
- }
79
- else {
80
- this.syncConfiguration = syncConfiguration;
81
- }
83
+ this.syncConfiguration ??= {};
82
84
  for (const managerName of Object.keys(managers)) {
83
85
  if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
84
86
  // Combine tm_ship trackers with preexisting entries if any
@@ -137,7 +139,7 @@ export class Engine {
137
139
  return header.blockHash;
138
140
  }
139
141
  catch (error) {
140
- this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
142
+ this.logger.warn(`Unable to resolve BASM block hash: height=${serializeLogValue(blockHeight)} error=${serializeErrorForLog(error)}`);
141
143
  return undefined;
142
144
  }
143
145
  }
@@ -242,7 +244,7 @@ export class Engine {
242
244
  }
243
245
  if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
244
246
  // Bound the work per pass; the next trigger resumes from the new tip.
245
- this.logger.warn(`[BASM] capping anchor chain extension for "${topic}" at ${DEFAULT_BASM_RANGE_LIMIT} blocks (requested ${fromHeight}..${toHeight}); will continue on the next pass`);
247
+ this.logger.warn(`[BASM] capping anchor chain extension: topic=${serializeLogValue(topic)} limit=${serializeLogValue(DEFAULT_BASM_RANGE_LIMIT)} requestedFrom=${serializeLogValue(fromHeight)} requestedTo=${serializeLogValue(toHeight)}; will continue on the next pass`);
246
248
  toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
247
249
  }
248
250
  const previousAnchor = fromHeight > 0
@@ -256,7 +258,7 @@ export class Engine {
256
258
  // canonical re-resolution from the header resolver instead of reusing it.
257
259
  const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
258
260
  if (blockHash === undefined) {
259
- this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`);
261
+ this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
260
262
  return;
261
263
  }
262
264
  const basmRoot = computeBasmRoot(admitted);
@@ -383,381 +385,325 @@ export class Engine {
383
385
  newTipHeight: tip
384
386
  });
385
387
  }
386
- /**
387
- * Submits a transaction for processing by Overlay Services.
388
- * @param {TaggedBEEF} taggedBEEF - The transaction to process
389
- * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
390
- * @param {string} mode Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
391
- * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
392
- *
393
- * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
394
- *
395
- * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
396
- */
397
- async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
398
- for (const t of taggedBEEF.topics) {
399
- if (this.managers[t] === undefined || this.managers[t] === null) {
400
- throw new Error(`This server does not support this topic: ${t}`);
401
- }
402
- }
403
- // Validate the transaction SPV information
404
- const tx = Transaction.fromBEEF(taggedBEEF.beef);
405
- const txid = tx.id('hex');
406
- this.startTime(`submit_${txid}`);
407
- if (mode !== 'historical-tx-no-spv') {
408
- this.startTime(`chainTracker_${txid.substring(0, 10)}`);
409
- const txValid = await tx.verify(this.chainTracker);
410
- if (!txValid)
411
- throw new Error('Unable to verify SPV information.');
412
- this.endTime(`chainTracker_${txid.substring(0, 10)}`);
413
- }
414
- const steak = {};
415
- const dupeTopics = new Set();
416
- const failedTopics = new Set();
417
- const topicValidations = taggedBEEF.topics.map(async (topic) => {
418
- try {
419
- if (this.managers[topic] === undefined || this.managers[topic] === null) {
420
- throw new Error(`This server does not support this topic: ${topic}`);
421
- }
422
- // Check for duplicate transactions
423
- this.startTime(`dupCheck_${txid.substring(0, 10)}`);
424
- const dupeCheck = await this.storage.doesAppliedTransactionExist({ txid, topic });
425
- this.endTime(`dupCheck_${txid.substring(0, 10)}`);
426
- if (dupeCheck) {
427
- dupeTopics.add(topic);
428
- return {
429
- topic,
430
- isDupe: true,
431
- previousCoins: [],
432
- previousOutputs: [],
433
- admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
434
- };
435
- }
436
- // Identify previous coins admitted to this specific topic
437
- const previousCoins = [];
438
- const outputPromises = tx.inputs.map(async (input, i) => {
439
- const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
440
- if (previousTXID !== undefined) {
441
- // Check if the previous output was admitted to this specific topic
442
- const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
443
- if (output !== undefined && output !== null) {
444
- previousCoins.push(i);
445
- return output;
446
- }
447
- }
448
- return null;
449
- });
450
- this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
451
- const previousOutputs = await Promise.all(outputPromises);
452
- this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
453
- // Determine which outputs are admissible for this topic (validation only)
454
- this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
455
- const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(taggedBEEF.beef, previousCoins, offChainValues, mode);
456
- this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
457
- return {
458
- topic,
459
- isDupe: false,
460
- previousCoins,
461
- previousOutputs,
462
- admissibleOutputs
463
- };
464
- }
465
- catch (error) {
466
- this.logger.error('Error validating topic during submit:', error);
467
- failedTopics.add(topic);
388
+ async validateTopicSubmission(topic, context) {
389
+ const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
390
+ try {
391
+ if (this.managers[topic] === undefined || this.managers[topic] === null) {
392
+ throw new Error(`This server does not support this topic: ${topic}`);
393
+ }
394
+ this.startTime(`dupCheck_${txid.substring(0, 10)}`);
395
+ const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
396
+ this.endTime(`dupCheck_${txid.substring(0, 10)}`);
397
+ if (isDupe) {
398
+ dupeTopics.add(topic);
468
399
  return {
469
400
  topic,
470
- isDupe: false,
401
+ isDupe: true,
471
402
  previousCoins: [],
472
403
  previousOutputs: [],
473
404
  admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
474
405
  };
475
406
  }
476
- });
477
- const validations = await Promise.all(topicValidations);
478
- // Build preliminary STEAK from validation results
479
- for (const validation of validations) {
480
- steak[validation.topic] = validation.admissibleOutputs;
407
+ const previousCoins = [];
408
+ const outputPromises = tx.inputs.map(async (input, inputIndex) => {
409
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
410
+ if (previousTXID === undefined)
411
+ return null;
412
+ const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
413
+ if (output !== undefined && output !== null)
414
+ previousCoins.push(inputIndex);
415
+ return output ?? null;
416
+ });
417
+ this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
418
+ const previousOutputs = await Promise.all(outputPromises);
419
+ this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
420
+ this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
421
+ const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
422
+ this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
423
+ return {
424
+ topic,
425
+ isDupe: false,
426
+ previousCoins,
427
+ previousOutputs,
428
+ admissibleOutputs
429
+ };
481
430
  }
482
- // ===================================================================
483
- // PHASE 2: BROADCAST (before any mutations)
484
- // ===================================================================
485
- // Only broadcast when at least one topic actually accepted the
486
- // transaction. For a non-failed topic, acceptance means: previously
487
- // accepted (dupe / client retry), outputs admitted, coins retained, or
488
- // previously-admitted coins consumed (e.g. a consume-only deletion such
489
- // as a KVStore remove, even one that retains nothing). A topic manager
490
- // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
491
- // failedTopics). A transaction every topic rejected must never reach the
492
- // network: submitters treat an empty STEAK as a rejection and
493
- // abort/release their held inputs, so broadcasting it anyway would
494
- // desync their wallets from the chain.
495
- const anyTopicAccepted = validations.some(v => !failedTopics.has(v.topic) && (v.isDupe ||
496
- v.admissibleOutputs.outputsToAdmit.length > 0 ||
497
- v.admissibleOutputs.coinsToRetain.length > 0 ||
498
- v.previousCoins.length > 0));
431
+ catch (error) {
432
+ this.logger.error(`Error validating topic during submit: topic=${serializeLogValue(topic)} error=${serializeErrorForLog(error)}`);
433
+ failedTopics.add(topic);
434
+ return {
435
+ topic,
436
+ isDupe: false,
437
+ previousCoins: [],
438
+ previousOutputs: [],
439
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
440
+ };
441
+ }
442
+ }
443
+ isTopicSubmissionAccepted(validation, failedTopics) {
444
+ return !failedTopics.has(validation.topic) && (validation.isDupe ||
445
+ validation.admissibleOutputs.outputsToAdmit.length > 0 ||
446
+ validation.admissibleOutputs.coinsToRetain.length > 0 ||
447
+ validation.previousCoins.length > 0);
448
+ }
449
+ async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
499
450
  this.startTime(`broadcast_${txid.substring(0, 10)}`);
500
451
  if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
501
452
  try {
502
453
  let response;
503
454
  if (tx.merklePath !== undefined) {
504
- // tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
505
- const txid = tx.id('hex');
506
455
  const mp = tx.merklePath;
507
456
  const leaf = mp.path[0].find(leaf => leaf.hash === txid);
508
- const r = {
457
+ response = {
509
458
  status: 'success',
510
- txid: tx.id('hex'),
511
- message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
459
+ txid,
460
+ message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`
512
461
  };
513
- response = r;
514
462
  }
515
463
  else {
516
464
  response = await this.broadcaster.broadcast(tx);
517
465
  }
518
466
  if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
519
- const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
520
- e.more = response.more;
521
- throw e;
467
+ const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
468
+ error.more = response.more;
469
+ throw error;
522
470
  }
523
471
  }
524
472
  catch (error) {
525
- if (this.throwOnBroadcastFailure) {
473
+ if (this.throwOnBroadcastFailure)
526
474
  throw error;
527
- }
528
475
  this.logger.error('Error broadcasting transaction:', error);
529
476
  }
530
477
  }
531
478
  this.endTime(`broadcast_${txid.substring(0, 10)}`);
532
- // Call the callback function with STEAK if it is provided (before storage mutations)
533
- if (onSteakReady !== undefined) {
534
- onSteakReady(steak);
479
+ }
480
+ async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
481
+ if (typeof lookupService.outputSpent !== 'function')
482
+ return;
483
+ if (lookupService.spendNotificationMode === 'txid') {
484
+ await lookupService.outputSpent({
485
+ mode: 'txid',
486
+ spendingTxid: txid,
487
+ txid: output.txid,
488
+ outputIndex: output.outputIndex,
489
+ topic
490
+ });
491
+ return;
535
492
  }
536
- // ===================================================================
537
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
538
- // ===================================================================
539
- // Mark previous outputs as spent and notify lookup services
493
+ if (lookupService.spendNotificationMode === 'script') {
494
+ const inputIndex = findSpendingInputIndex(tx, output);
495
+ if (inputIndex === -1)
496
+ throw new Error('Could not find input index');
497
+ await lookupService.outputSpent({
498
+ mode: 'script',
499
+ spendingTxid: txid,
500
+ inputIndex,
501
+ sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
502
+ unlockingScript: tx.inputs[inputIndex].unlockingScript,
503
+ txid: output.txid,
504
+ outputIndex: output.outputIndex,
505
+ topic,
506
+ offChainValues
507
+ });
508
+ return;
509
+ }
510
+ if (lookupService.spendNotificationMode === 'whole-tx') {
511
+ await lookupService.outputSpent({
512
+ mode: 'whole-tx',
513
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
514
+ txid: output.txid,
515
+ outputIndex: output.outputIndex,
516
+ topic,
517
+ offChainValues
518
+ });
519
+ return;
520
+ }
521
+ await lookupService.outputSpent({
522
+ mode: 'none',
523
+ txid: output.txid,
524
+ outputIndex: output.outputIndex,
525
+ topic
526
+ });
527
+ }
528
+ async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
529
+ if (output === null)
530
+ return;
531
+ try {
532
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
533
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
534
+ try {
535
+ await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
536
+ }
537
+ catch (error) {
538
+ this.logger.error('Error in lookup service for outputSpent:', error);
539
+ }
540
+ }));
541
+ }
542
+ catch (error) {
543
+ this.logger.error('Error marking UTXO as spent:', error);
544
+ }
545
+ }
546
+ async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
540
547
  await Promise.all(validations.map(async (validation) => {
541
- if (validation.isDupe || failedTopics.has(validation.topic)) {
548
+ if (validation.isDupe || failedTopics.has(validation.topic))
542
549
  return;
550
+ await Promise.all(validation.previousOutputs.map(async (output) => {
551
+ await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
552
+ }));
553
+ }));
554
+ }
555
+ classifyPreviousCoins(tx, validation) {
556
+ const outputsConsumed = [];
557
+ const outputsToMarkStale = [];
558
+ for (const inputIndex of validation.previousCoins) {
559
+ const input = tx.inputs[inputIndex];
560
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
561
+ if (typeof previousTXID !== 'string')
562
+ continue;
563
+ if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
564
+ outputsConsumed.push({
565
+ txid: previousTXID,
566
+ outputIndex: input.sourceOutputIndex
567
+ });
543
568
  }
544
- const topic = validation.topic;
545
- const previousOutputs = validation.previousOutputs;
546
- // Mark all previous outputs as spent
547
- const markSpentPromises = previousOutputs.map(async (output) => {
548
- if (output !== undefined && output !== null) {
549
- try {
550
- await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
551
- await Promise.all(Object.values(this.lookupServices).map(async (l) => {
552
- try {
553
- if (typeof l.outputSpent === 'function') {
554
- if (l.spendNotificationMode === 'txid') {
555
- await l.outputSpent({
556
- mode: 'txid',
557
- spendingTxid: txid,
558
- txid: output.txid,
559
- outputIndex: output.outputIndex,
560
- topic
561
- });
562
- }
563
- else if (l.spendNotificationMode === 'script') {
564
- const inputIndex = tx.inputs.findIndex(i => {
565
- let realSource = i.sourceTXID;
566
- if (!realSource) {
567
- realSource = i.sourceTransaction?.id('hex');
568
- }
569
- return realSource === output.txid && i.sourceOutputIndex === output.outputIndex;
570
- });
571
- if (inputIndex === -1) {
572
- throw new Error('Could not find input index');
573
- }
574
- await l.outputSpent({
575
- mode: 'script',
576
- spendingTxid: txid,
577
- inputIndex,
578
- sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
579
- unlockingScript: tx.inputs[inputIndex].unlockingScript,
580
- txid: output.txid,
581
- outputIndex: output.outputIndex,
582
- topic,
583
- offChainValues
584
- });
585
- }
586
- else if (l.spendNotificationMode === 'whole-tx') {
587
- await l.outputSpent({
588
- mode: 'whole-tx',
589
- spendingAtomicBEEF: tx.toAtomicBEEF(),
590
- txid: output.txid,
591
- outputIndex: output.outputIndex,
592
- topic,
593
- offChainValues
594
- });
595
- }
596
- else { // none
597
- await l.outputSpent({
598
- mode: 'none',
599
- txid: output.txid,
600
- outputIndex: output.outputIndex,
601
- topic
602
- });
603
- }
604
- }
605
- }
606
- catch (error) {
607
- this.logger.error('Error in lookup service for outputSpent:', error);
608
- }
609
- }));
610
- }
611
- catch (error) {
612
- this.logger.error('Error marking UTXO as spent:', error);
613
- }
614
- }
569
+ else {
570
+ outputsToMarkStale.push({
571
+ txid: previousTXID,
572
+ previousOutputIndex: input.sourceOutputIndex,
573
+ inputIndex
574
+ });
575
+ }
576
+ }
577
+ return { outputsConsumed, outputsToMarkStale };
578
+ }
579
+ async removeStaleOutputs(outputs, topic, txid) {
580
+ this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
581
+ await Promise.all(outputs.map(async (coin) => {
582
+ const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
583
+ if (output !== undefined && output !== null)
584
+ await this.deleteUTXODeep(output);
585
+ }));
586
+ this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
587
+ }
588
+ async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
589
+ if (lookupService.admissionMode === 'locking-script') {
590
+ if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
591
+ typeof tx.outputs[outputIndex].satoshis !== 'number')
592
+ return;
593
+ await lookupService.outputAdmittedByTopic({
594
+ mode: 'locking-script',
595
+ txid,
596
+ outputIndex,
597
+ lockingScript: tx.outputs[outputIndex].lockingScript,
598
+ satoshis: tx.outputs[outputIndex].satoshis,
599
+ topic,
600
+ offChainValues
601
+ });
602
+ return;
603
+ }
604
+ await lookupService.outputAdmittedByTopic({
605
+ mode: 'whole-tx',
606
+ atomicBEEF: tx.toAtomicBEEF(),
607
+ outputIndex,
608
+ topic,
609
+ offChainValues
610
+ });
611
+ }
612
+ async admitOutput(outputIndex, context) {
613
+ const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
614
+ if (typeof tx.outputs[outputIndex].satoshis !== 'number')
615
+ return;
616
+ this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
617
+ await this.storage.insertOutput({
618
+ txid,
619
+ outputIndex,
620
+ outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
621
+ satoshis: tx.outputs[outputIndex].satoshis,
622
+ topic,
623
+ spent: false,
624
+ beef: this.compactBEEFForStorage(tx, beef),
625
+ consumedBy: [],
626
+ outputsConsumed,
627
+ score: Date.now(),
628
+ blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
629
+ });
630
+ this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
631
+ newUTXOs.push({ txid, outputIndex });
632
+ this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
633
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
634
+ try {
635
+ await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
636
+ }
637
+ catch (error) {
638
+ this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
639
+ }
640
+ }));
641
+ this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
642
+ }
643
+ async updateConsumedOutput(output, newUTXOs, topic) {
644
+ const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
645
+ if (storedOutput === undefined || storedOutput === null)
646
+ return;
647
+ const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
648
+ await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
649
+ }
650
+ async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
651
+ const topic = validation.topic;
652
+ const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
653
+ await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
654
+ steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
655
+ const newUTXOs = [];
656
+ await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
657
+ await this.admitOutput(outputIndex, {
658
+ tx,
659
+ txid,
660
+ beef,
661
+ topic,
662
+ outputsConsumed,
663
+ newUTXOs,
664
+ offChainValues
615
665
  });
616
- await Promise.all(markSpentPromises);
617
666
  }));
618
- // Continue with storage updates and lookup service notifications
667
+ this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
668
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx);
669
+ await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
670
+ await Promise.all([
671
+ ...outputsConsumed.map(async (output) => {
672
+ await this.updateConsumedOutput(output, newUTXOs, topic);
673
+ }),
674
+ this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
675
+ ]);
676
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
677
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
678
+ }
679
+ this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
680
+ }
681
+ async applyStorageMutations(validations, context) {
682
+ const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
619
683
  for (const validation of validations) {
620
684
  const topic = validation.topic;
621
- if (dupeTopics.has(topic)) {
622
- continue;
623
- }
624
- if (failedTopics.has(topic)) {
685
+ if (dupeTopics.has(topic) || failedTopics.has(topic))
625
686
  continue;
626
- }
627
687
  try {
628
- const admissibleOutputs = steak[topic];
629
- const outputsToAdmit = admissibleOutputs.outputsToAdmit;
630
- const outputsConsumed = [];
631
- const outputsToMarkStale = [];
632
- // Use previousCoins from validation
633
- const previousCoins = validation.previousCoins;
634
- // 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.
635
- for (const inputIndex of previousCoins) {
636
- const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex');
637
- if (typeof previousTXID !== 'string')
638
- continue;
639
- const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex;
640
- if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
641
- outputsConsumed.push({
642
- txid: previousTXID,
643
- outputIndex: previousOutputIndex
644
- });
645
- }
646
- else {
647
- outputsToMarkStale.push({
648
- txid: previousTXID,
649
- previousOutputIndex,
650
- inputIndex
651
- });
652
- }
653
- }
654
- // Remove stale outputs recursively
655
- this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
656
- await Promise.all(outputsToMarkStale.map(async (coin) => {
657
- const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
658
- if (output !== undefined && output !== null) {
659
- await this.deleteUTXODeep(output);
660
- }
661
- }));
662
- this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
663
- // Update the STEAK to indicate which coins were removed
664
- steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex);
665
- // Handle admittance and notification of incoming UTXOs
666
- const newUTXOs = [];
667
- await Promise.all(outputsToAdmit.map(async (outputIndex) => {
668
- if (typeof tx.outputs[outputIndex].satoshis !== 'number')
669
- return;
670
- this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
671
- await this.storage.insertOutput({
672
- txid,
673
- outputIndex,
674
- outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
675
- satoshis: tx.outputs[outputIndex].satoshis,
676
- topic,
677
- spent: false,
678
- beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
679
- consumedBy: [],
680
- outputsConsumed,
681
- score: Date.now(),
682
- blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
683
- });
684
- this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
685
- newUTXOs.push({ txid, outputIndex });
686
- this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
687
- await Promise.all(Object.values(this.lookupServices).map(async (l) => {
688
- try {
689
- if (l.admissionMode === 'locking-script') {
690
- if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
691
- typeof tx.outputs[outputIndex].satoshis !== 'number') {
692
- return;
693
- }
694
- await l.outputAdmittedByTopic({
695
- mode: 'locking-script',
696
- txid,
697
- outputIndex,
698
- lockingScript: tx.outputs[outputIndex].lockingScript,
699
- satoshis: tx.outputs[outputIndex].satoshis,
700
- topic,
701
- offChainValues
702
- });
703
- }
704
- else {
705
- await l.outputAdmittedByTopic({
706
- mode: 'whole-tx',
707
- atomicBEEF: tx.toAtomicBEEF(),
708
- outputIndex,
709
- topic,
710
- offChainValues
711
- });
712
- }
713
- }
714
- catch (error) {
715
- this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
716
- }
717
- }));
718
- this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
719
- }));
720
- this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
721
- // Update each output consumed to know who consumed it and insert applied transaction in parallel
722
- const appliedRecord = await this.buildAppliedTransactionRecord(tx);
723
- await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash);
724
- await Promise.all([
725
- ...outputsConsumed.map(async (output) => {
726
- const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic);
727
- if (outputToUpdate !== undefined && outputToUpdate !== null) {
728
- const newConsumedBy = [...new Set([...newUTXOs, ...outputToUpdate.consumedBy])];
729
- await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, newConsumedBy);
730
- }
731
- }),
732
- this.storage.insertAppliedTransaction({
733
- txid,
734
- topic,
735
- ...appliedRecord
736
- })
737
- ]);
738
- if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
739
- await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
740
- }
741
- this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
688
+ await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
742
689
  }
743
690
  catch (error) {
744
691
  this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
745
692
  }
746
693
  }
747
- // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
748
- if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
749
- return steak;
750
- }
694
+ }
695
+ async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
751
696
  this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
752
- const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined && !dupeTopics.has(topic) && (steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0));
697
+ const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
698
+ !dupeTopics.has(topic) &&
699
+ (steak[topic].outputsToAdmit.length !== 0 ||
700
+ steak[topic].coinsRemoved?.length !== 0));
753
701
  if (relevantTopics.length === 0) {
754
702
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
755
- return steak;
703
+ return;
756
704
  }
757
- // Create a SHIPBroadcaster instance
758
705
  let customBroadcasterConfig;
759
706
  if (Array.isArray(this.slapTrackers)) {
760
- // Custom SLAP trackers warrant a custom broadcaster config
761
707
  const resolverConfig = {
762
708
  slapTrackers: this.slapTrackers
763
709
  };
@@ -773,6 +719,93 @@ export class Engine {
773
719
  this.logger.error('Error during propagation to other nodes:', error);
774
720
  }
775
721
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
722
+ }
723
+ /**
724
+ * Submits a transaction for processing by Overlay Services.
725
+ * @param {TaggedBEEF} taggedBEEF - The transaction to process
726
+ * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
727
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
728
+ * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
729
+ *
730
+ * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
731
+ *
732
+ * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
733
+ */
734
+ async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
735
+ for (const t of taggedBEEF.topics) {
736
+ if (this.managers[t] === undefined || this.managers[t] === null) {
737
+ throw new Error(`This server does not support this topic: ${t}`);
738
+ }
739
+ }
740
+ // Validate the transaction SPV information
741
+ const tx = Transaction.fromBEEF(taggedBEEF.beef);
742
+ const txid = tx.id('hex');
743
+ this.startTime(`submit_${txid}`);
744
+ if (mode !== 'historical-tx-no-spv') {
745
+ this.startTime(`chainTracker_${txid.substring(0, 10)}`);
746
+ const txValid = await tx.verify(this.chainTracker);
747
+ if (!txValid)
748
+ throw new Error('Unable to verify SPV information.');
749
+ this.endTime(`chainTracker_${txid.substring(0, 10)}`);
750
+ }
751
+ const steak = {};
752
+ const dupeTopics = new Set();
753
+ const failedTopics = new Set();
754
+ // ===================================================================
755
+ // PHASE 1: VALIDATE (read-only, no mutations)
756
+ // ===================================================================
757
+ const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
758
+ tx,
759
+ txid,
760
+ beef: taggedBEEF.beef,
761
+ offChainValues,
762
+ mode,
763
+ dupeTopics,
764
+ failedTopics
765
+ }));
766
+ const validations = await Promise.all(topicValidations);
767
+ // Build preliminary STEAK from validation results
768
+ for (const validation of validations) {
769
+ steak[validation.topic] = validation.admissibleOutputs;
770
+ }
771
+ // ===================================================================
772
+ // PHASE 2: BROADCAST (before any mutations)
773
+ // ===================================================================
774
+ // Only broadcast when at least one topic actually accepted the
775
+ // transaction. For a non-failed topic, acceptance means: previously
776
+ // accepted (dupe / client retry), outputs admitted, coins retained, or
777
+ // previously-admitted coins consumed (e.g. a consume-only deletion such
778
+ // as a KVStore remove, even one that retains nothing). A topic manager
779
+ // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
780
+ // failedTopics). A transaction every topic rejected must never reach the
781
+ // network: submitters treat an empty STEAK as a rejection and
782
+ // abort/release their held inputs, so broadcasting it anyway would
783
+ // desync their wallets from the chain.
784
+ const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
785
+ await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
786
+ // Call the callback function with STEAK if it is provided (before storage mutations)
787
+ if (onSteakReady !== undefined) {
788
+ onSteakReady(steak);
789
+ }
790
+ // ===================================================================
791
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
792
+ // ===================================================================
793
+ // Mark previous outputs as spent and notify lookup services
794
+ await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
795
+ await this.applyStorageMutations(validations, {
796
+ dupeTopics,
797
+ failedTopics,
798
+ steak,
799
+ tx,
800
+ txid,
801
+ beef: taggedBEEF.beef,
802
+ offChainValues
803
+ });
804
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
805
+ if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
806
+ return steak;
807
+ }
808
+ await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
776
809
  // Immediately return from the function without waiting for the promises to resolve.
777
810
  return steak;
778
811
  }
@@ -785,7 +818,7 @@ export class Engine {
785
818
  // Validate a lookup service for the provider is found
786
819
  const lookupService = this.lookupServices[lookupQuestion.service];
787
820
  if (lookupService === undefined || lookupService === null)
788
- throw new Error(`Lookup service not found for provider: ${lookupQuestion.service} `);
821
+ throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
789
822
  const lookupResult = await lookupService.lookup(lookupQuestion);
790
823
  const hydrationContext = this.createUTXOHistoryHydrationContext();
791
824
  await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
@@ -1004,71 +1037,37 @@ export class Engine {
1004
1037
  throw new Error('Overlay Service Engine not configured for topical synchronization!');
1005
1038
  }
1006
1039
  for (const topic of Object.keys(this.syncConfiguration)) {
1007
- // Make sure syncEndpoints is an array or SHIP
1008
- let syncEndpoints = this.syncConfiguration[topic];
1009
- // Check if this topic has been configured NOT to sync
1010
- if (syncEndpoints === false) {
1040
+ const configuredEndpoints = this.syncConfiguration[topic];
1041
+ if (configuredEndpoints === false)
1011
1042
  continue;
1043
+ if (!Array.isArray(configuredEndpoints) && configuredEndpoints !== 'SHIP')
1044
+ continue;
1045
+ const syncEndpoints = await this.resolveSyncEndpointsForTopic(topic, 'Failed to parse advertisement output:');
1046
+ this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
1047
+ // Sync with each endpoint sequentially to avoid parallel locks while
1048
+ // keeping peer failures isolated.
1049
+ for (const endpoint of syncEndpoints) {
1050
+ await this.syncGASPWithPeer(topic, endpoint);
1012
1051
  }
1013
- if (syncEndpoints === 'SHIP') {
1014
- // Perform lookup and find ship advertisements to set syncEndpoints for topic
1015
- const resolverConfig = this.slapTrackers
1016
- ? { slapTrackers: this.slapTrackers }
1017
- : {};
1018
- const resolver = new LookupResolver(resolverConfig);
1019
- const lookupAnswer = await resolver.query({
1020
- service: 'ls_ship',
1021
- query: {
1022
- topics: [topic]
1023
- }
1024
- });
1025
- // Lookup will currently always return type output-list
1026
- if (lookupAnswer.type === 'output-list') {
1027
- const endpointSet = new Set();
1028
- lookupAnswer.outputs.forEach(output => {
1029
- try {
1030
- // Parse out the advertisements using the provided parser
1031
- const tx = Transaction.fromBEEF(output.beef);
1032
- const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
1033
- if (advertisement?.protocol === 'SHIP') {
1034
- endpointSet.add(advertisement.domain);
1035
- }
1036
- }
1037
- catch (error) {
1038
- this.logger.error('Failed to parse advertisement output:', error);
1039
- }
1040
- });
1041
- syncEndpoints = Array.from(endpointSet);
1042
- }
1043
- }
1044
- // Now syncEndpoints is guaranteed to be an array of strings without duplicates
1045
- if (Array.isArray(syncEndpoints)) {
1046
- // Remove our own hosting URL so we don't sync with ourselves
1047
- syncEndpoints = syncEndpoints.filter((endpoint) => endpoint !== this.hostingURL);
1048
- this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
1049
- // Sync with each endpoint individually to avoid parallel locks and let failures be isolated
1050
- for (const endpoint of syncEndpoints) {
1051
- this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
1052
- try {
1053
- // Read the last interaction score from storage
1054
- const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
1055
- const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
1056
- await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
1057
- // Save the updated last interaction score
1058
- if (gasp.lastInteraction > lastInteraction) {
1059
- await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
1060
- }
1061
- this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
1062
- }
1063
- catch (err) {
1064
- this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, err);
1065
- // Continue on to the next endpoint without throwing
1066
- }
1067
- }
1052
+ }
1053
+ }
1054
+ async syncGASPWithPeer(topic, endpoint) {
1055
+ this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
1056
+ try {
1057
+ const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
1058
+ const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
1059
+ await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
1060
+ if (gasp.lastInteraction > lastInteraction) {
1061
+ await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
1068
1062
  }
1063
+ this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
1064
+ }
1065
+ catch (error) {
1066
+ this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
1067
+ // Continue on to the next endpoint without throwing.
1069
1068
  }
1070
1069
  }
1071
- async resolveSyncEndpointsForTopic(topic) {
1070
+ async resolveSyncEndpointsForTopic(topic, advertisementErrorMessage = 'Failed to parse BASM advertisement output:') {
1072
1071
  if (this.syncConfiguration === undefined) {
1073
1072
  return [];
1074
1073
  }
@@ -1098,7 +1097,7 @@ export class Engine {
1098
1097
  }
1099
1098
  }
1100
1099
  catch (error) {
1101
- this.logger.error('Failed to parse BASM advertisement output:', error);
1100
+ this.logger.error(advertisementErrorMessage, error);
1102
1101
  }
1103
1102
  });
1104
1103
  }
@@ -1617,22 +1616,21 @@ export class Engine {
1617
1616
  * @param proof for txid
1618
1617
  */
1619
1618
  updateInputProofs(tx, txid, proof) {
1620
- if (tx.merklePath !== undefined) {
1619
+ if (tx.id('hex') === txid) {
1621
1620
  // Update the merkle path to handle potential reorgs
1622
1621
  tx.merklePath = proof;
1623
1622
  return;
1624
1623
  }
1625
- if (tx.id('hex') === txid) {
1626
- tx.merklePath = proof;
1627
- }
1628
- else {
1629
- for (const input of tx.inputs) {
1630
- // All inputs must have sourceTransactions
1631
- const stx = input.sourceTransaction;
1632
- if (typeof stx !== 'object')
1633
- continue;
1634
- this.updateInputProofs(stx, txid, proof);
1635
- }
1624
+ // A mined transaction's source graph is no longer part of its BEEF. Do not
1625
+ // replace an unrelated transaction's proof with the proof for an ancestor.
1626
+ if (tx.merklePath !== undefined)
1627
+ return;
1628
+ for (const input of tx.inputs) {
1629
+ // All inputs must have sourceTransactions
1630
+ const stx = input.sourceTransaction;
1631
+ if (typeof stx !== 'object')
1632
+ continue;
1633
+ this.updateInputProofs(stx, txid, proof);
1636
1634
  }
1637
1635
  }
1638
1636
  /**
@@ -1647,12 +1645,8 @@ export class Engine {
1647
1645
  throw new Error('Output must have associated transaction BEEF!');
1648
1646
  }
1649
1647
  const tx = Transaction.fromBEEF(output.beef);
1650
- if (tx.merklePath !== undefined) {
1651
- // Update the merkle path to handle potential reorgs
1652
- tx.merklePath = proof;
1653
- return;
1654
- }
1655
- // recursively update all sourceTransactions proven by (txid,proof)
1648
+ // Update this transaction, or recursively update the matching source
1649
+ // transaction. This also persists replacement proofs after a reorg.
1656
1650
  this.updateInputProofs(tx, txid, proof);
1657
1651
  // Update the output's BEEF in the storage DB
1658
1652
  await this.storage.updateTransactionBEEF(output.txid, tx.toBEEF());
@@ -1810,7 +1804,7 @@ export class Engine {
1810
1804
  // If none of the disallowed conditions matched, the URL is valid
1811
1805
  return true;
1812
1806
  }
1813
- catch (_e) {
1807
+ catch {
1814
1808
  // URL constructor throws on malformed input — not a valid URL, return false
1815
1809
  return false;
1816
1810
  }