@bsv/overlay 2.2.1 → 2.3.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.
Files changed (151) hide show
  1. package/LICENSE.txt +57 -27
  2. package/README.md +102 -269
  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 +41 -13
  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 +379 -0
  19. package/dist/cjs/src/Engine.d.ts.map +1 -0
  20. package/dist/cjs/src/Engine.js +425 -418
  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 +32 -30
  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.map +1 -1
  62. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
  63. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
  64. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -1
  65. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts +4 -0
  66. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -0
  67. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  68. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts +4 -0
  69. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts.map +1 -0
  70. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.js.map +1 -1
  71. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts +4 -0
  72. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts.map +1 -0
  73. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.js.map +1 -1
  74. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
  75. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
  76. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -1
  77. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
  78. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
  79. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -1
  80. package/dist/esm/mod.js +2 -1
  81. package/dist/esm/mod.js.map +1 -1
  82. package/dist/esm/src/BASMRemote.js.map +1 -1
  83. package/dist/esm/src/Engine.js +404 -396
  84. package/dist/esm/src/Engine.js.map +1 -1
  85. package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
  86. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  87. package/dist/esm/src/GASP/OverlayGASPStorage.js +21 -18
  88. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  89. package/dist/esm/src/SafeLog.js +40 -0
  90. package/dist/esm/src/SafeLog.js.map +1 -0
  91. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  92. package/dist/types/mod.d.ts +5 -4
  93. package/dist/types/mod.d.ts.map +1 -1
  94. package/dist/types/src/BASMRemote.d.ts.map +1 -1
  95. package/dist/types/src/Engine.d.ts +18 -1
  96. package/dist/types/src/Engine.d.ts.map +1 -1
  97. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +2 -2
  98. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  99. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +3 -1
  100. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  101. package/dist/types/src/SafeLog.d.ts +14 -0
  102. package/dist/types/src/SafeLog.d.ts.map +1 -0
  103. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  104. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  105. package/package.json +85 -28
  106. package/dist/cjs/tsconfig.cjs.tsbuildinfo +0 -1
  107. package/dist/esm/tsconfig.esm.tsbuildinfo +0 -1
  108. package/dist/types/tsconfig.types.tsbuildinfo +0 -1
  109. package/docs/API.md +0 -944
  110. package/docs/BRC-136-BASM.md +0 -327
  111. package/docs/README.md +0 -16
  112. package/docs/Synchronization.md +0 -212
  113. package/docs/concepts/00-overview.md +0 -85
  114. package/docs/concepts/01-best-practices.md +0 -202
  115. package/docs/concepts/02-query-performance.md +0 -345
  116. package/docs/concepts/03-database-monitoring.md +0 -211
  117. package/docs/concepts/04-pagination-example.md +0 -186
  118. package/docs/concepts/05-recommendations-summary.md +0 -158
  119. package/docs/concepts/README.md +0 -20
  120. package/docs/examples/README.md +0 -15
  121. package/docs/examples/gs-wip.md +0 -111
  122. package/docs/internal/README.md +0 -11
  123. package/mod.ts +0 -40
  124. package/src/Advertisement.ts +0 -8
  125. package/src/Advertiser.ts +0 -41
  126. package/src/BASM.ts +0 -208
  127. package/src/BASMRemote.ts +0 -54
  128. package/src/Engine.ts +0 -2194
  129. package/src/GASP/OverlayGASPRemote.ts +0 -108
  130. package/src/GASP/OverlayGASPStorage.ts +0 -384
  131. package/src/LookupFormula.ts +0 -31
  132. package/src/LookupService.ts +0 -136
  133. package/src/Output.ts +0 -31
  134. package/src/TopicManager.ts +0 -40
  135. package/src/__tests/BASM.test.ts +0 -44
  136. package/src/__tests/BASMChain.test.ts +0 -295
  137. package/src/__tests/Engine.test.ts +0 -1381
  138. package/src/__tests/OverlayGASPRemote.test.ts +0 -135
  139. package/src/__tests/OverlayGASPStorage.test.ts +0 -205
  140. package/src/storage/Storage.ts +0 -260
  141. package/src/storage/knex/KnexStorage.ts +0 -651
  142. package/src/storage/knex/all-migrations.ts +0 -32
  143. package/src/storage/knex/migrations/2024-05-18-001-initial.ts +0 -30
  144. package/src/storage/knex/migrations/2024-07-10-001-block-height.ts +0 -15
  145. package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +0 -64
  146. package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +0 -30
  147. package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +0 -42
  148. package/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.ts +0 -46
  149. package/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.ts +0 -24
  150. package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +0 -18
  151. 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
  */
@@ -29,6 +36,7 @@ export class Engine {
29
36
  topicAnchorHeaderResolver;
30
37
  basmSyncEnabled;
31
38
  unprovenEvictionBlocks;
39
+ maxLookupResults;
32
40
  /**
33
41
  * Creates a new Overlay Services Engine
34
42
  * @param {[key: string]: TopicManager} managers - manages topic admittance
@@ -50,8 +58,9 @@ export class Engine {
50
58
  * @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
51
59
  * @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
52
60
  * @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
61
+ * @param {number} maxLookupResults - Maximum lookup formulas hydrated per request. Use -1 to opt out.
53
62
  */
54
- constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true, topicAnchorHeaderResolver, basmSyncEnabled = false, unprovenEvictionBlocks = 144) {
63
+ constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true, topicAnchorHeaderResolver, basmSyncEnabled = false, unprovenEvictionBlocks = 144, maxLookupResults = 1000) {
55
64
  this.managers = managers;
56
65
  this.lookupServices = lookupServices;
57
66
  this.storage = storage;
@@ -71,14 +80,13 @@ export class Engine {
71
80
  this.topicAnchorHeaderResolver = topicAnchorHeaderResolver;
72
81
  this.basmSyncEnabled = basmSyncEnabled;
73
82
  this.unprovenEvictionBlocks = unprovenEvictionBlocks;
83
+ this.maxLookupResults = maxLookupResults;
84
+ if (maxLookupResults !== -1 && (!Number.isSafeInteger(maxLookupResults) || maxLookupResults < 1)) {
85
+ throw new TypeError('maxLookupResults must be -1 or a positive safe integer');
86
+ }
74
87
  // 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
88
  // 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
- }
89
+ this.syncConfiguration ??= {};
82
90
  for (const managerName of Object.keys(managers)) {
83
91
  if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
84
92
  // Combine tm_ship trackers with preexisting entries if any
@@ -137,7 +145,7 @@ export class Engine {
137
145
  return header.blockHash;
138
146
  }
139
147
  catch (error) {
140
- this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
148
+ this.logger.warn(`Unable to resolve BASM block hash: height=${serializeLogValue(blockHeight)} error=${serializeErrorForLog(error)}`);
141
149
  return undefined;
142
150
  }
143
151
  }
@@ -242,7 +250,7 @@ export class Engine {
242
250
  }
243
251
  if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
244
252
  // 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`);
253
+ 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
254
  toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
247
255
  }
248
256
  const previousAnchor = fromHeight > 0
@@ -256,7 +264,7 @@ export class Engine {
256
264
  // canonical re-resolution from the header resolver instead of reusing it.
257
265
  const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
258
266
  if (blockHash === undefined) {
259
- this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`);
267
+ this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
260
268
  return;
261
269
  }
262
270
  const basmRoot = computeBasmRoot(admitted);
@@ -383,381 +391,325 @@ export class Engine {
383
391
  newTipHeight: tip
384
392
  });
385
393
  }
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);
394
+ async validateTopicSubmission(topic, context) {
395
+ const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
396
+ try {
397
+ if (this.managers[topic] === undefined || this.managers[topic] === null) {
398
+ throw new Error(`This server does not support this topic: ${topic}`);
399
+ }
400
+ this.startTime(`dupCheck_${txid.substring(0, 10)}`);
401
+ const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
402
+ this.endTime(`dupCheck_${txid.substring(0, 10)}`);
403
+ if (isDupe) {
404
+ dupeTopics.add(topic);
468
405
  return {
469
406
  topic,
470
- isDupe: false,
407
+ isDupe: true,
471
408
  previousCoins: [],
472
409
  previousOutputs: [],
473
410
  admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
474
411
  };
475
412
  }
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;
413
+ const previousCoins = [];
414
+ const outputPromises = tx.inputs.map(async (input, inputIndex) => {
415
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
416
+ if (previousTXID === undefined)
417
+ return null;
418
+ const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
419
+ if (output !== undefined && output !== null)
420
+ previousCoins.push(inputIndex);
421
+ return output ?? null;
422
+ });
423
+ this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
424
+ const previousOutputs = await Promise.all(outputPromises);
425
+ this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
426
+ this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
427
+ const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
428
+ this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
429
+ return {
430
+ topic,
431
+ isDupe: false,
432
+ previousCoins,
433
+ previousOutputs,
434
+ admissibleOutputs
435
+ };
481
436
  }
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));
437
+ catch (error) {
438
+ this.logger.error(`Error validating topic during submit: topic=${serializeLogValue(topic)} error=${serializeErrorForLog(error)}`);
439
+ failedTopics.add(topic);
440
+ return {
441
+ topic,
442
+ isDupe: false,
443
+ previousCoins: [],
444
+ previousOutputs: [],
445
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
446
+ };
447
+ }
448
+ }
449
+ isTopicSubmissionAccepted(validation, failedTopics) {
450
+ return !failedTopics.has(validation.topic) && (validation.isDupe ||
451
+ validation.admissibleOutputs.outputsToAdmit.length > 0 ||
452
+ validation.admissibleOutputs.coinsToRetain.length > 0 ||
453
+ validation.previousCoins.length > 0);
454
+ }
455
+ async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
499
456
  this.startTime(`broadcast_${txid.substring(0, 10)}`);
500
457
  if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
501
458
  try {
502
459
  let response;
503
460
  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
461
  const mp = tx.merklePath;
507
462
  const leaf = mp.path[0].find(leaf => leaf.hash === txid);
508
- const r = {
463
+ response = {
509
464
  status: 'success',
510
- txid: tx.id('hex'),
511
- message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
465
+ txid,
466
+ message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`
512
467
  };
513
- response = r;
514
468
  }
515
469
  else {
516
470
  response = await this.broadcaster.broadcast(tx);
517
471
  }
518
472
  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;
473
+ const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
474
+ error.more = response.more;
475
+ throw error;
522
476
  }
523
477
  }
524
478
  catch (error) {
525
- if (this.throwOnBroadcastFailure) {
479
+ if (this.throwOnBroadcastFailure)
526
480
  throw error;
527
- }
528
481
  this.logger.error('Error broadcasting transaction:', error);
529
482
  }
530
483
  }
531
484
  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);
485
+ }
486
+ async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
487
+ if (typeof lookupService.outputSpent !== 'function')
488
+ return;
489
+ if (lookupService.spendNotificationMode === 'txid') {
490
+ await lookupService.outputSpent({
491
+ mode: 'txid',
492
+ spendingTxid: txid,
493
+ txid: output.txid,
494
+ outputIndex: output.outputIndex,
495
+ topic
496
+ });
497
+ return;
535
498
  }
536
- // ===================================================================
537
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
538
- // ===================================================================
539
- // Mark previous outputs as spent and notify lookup services
499
+ if (lookupService.spendNotificationMode === 'script') {
500
+ const inputIndex = findSpendingInputIndex(tx, output);
501
+ if (inputIndex === -1)
502
+ throw new Error('Could not find input index');
503
+ await lookupService.outputSpent({
504
+ mode: 'script',
505
+ spendingTxid: txid,
506
+ inputIndex,
507
+ sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
508
+ unlockingScript: tx.inputs[inputIndex].unlockingScript,
509
+ txid: output.txid,
510
+ outputIndex: output.outputIndex,
511
+ topic,
512
+ offChainValues
513
+ });
514
+ return;
515
+ }
516
+ if (lookupService.spendNotificationMode === 'whole-tx') {
517
+ await lookupService.outputSpent({
518
+ mode: 'whole-tx',
519
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
520
+ txid: output.txid,
521
+ outputIndex: output.outputIndex,
522
+ topic,
523
+ offChainValues
524
+ });
525
+ return;
526
+ }
527
+ await lookupService.outputSpent({
528
+ mode: 'none',
529
+ txid: output.txid,
530
+ outputIndex: output.outputIndex,
531
+ topic
532
+ });
533
+ }
534
+ async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
535
+ if (output === null)
536
+ return;
537
+ try {
538
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
539
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
540
+ try {
541
+ await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
542
+ }
543
+ catch (error) {
544
+ this.logger.error('Error in lookup service for outputSpent:', error);
545
+ }
546
+ }));
547
+ }
548
+ catch (error) {
549
+ this.logger.error('Error marking UTXO as spent:', error);
550
+ }
551
+ }
552
+ async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
540
553
  await Promise.all(validations.map(async (validation) => {
541
- if (validation.isDupe || failedTopics.has(validation.topic)) {
554
+ if (validation.isDupe || failedTopics.has(validation.topic))
542
555
  return;
556
+ await Promise.all(validation.previousOutputs.map(async (output) => {
557
+ await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
558
+ }));
559
+ }));
560
+ }
561
+ classifyPreviousCoins(tx, validation) {
562
+ const outputsConsumed = [];
563
+ const outputsToMarkStale = [];
564
+ for (const inputIndex of validation.previousCoins) {
565
+ const input = tx.inputs[inputIndex];
566
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
567
+ if (typeof previousTXID !== 'string')
568
+ continue;
569
+ if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
570
+ outputsConsumed.push({
571
+ txid: previousTXID,
572
+ outputIndex: input.sourceOutputIndex
573
+ });
543
574
  }
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
- }
575
+ else {
576
+ outputsToMarkStale.push({
577
+ txid: previousTXID,
578
+ previousOutputIndex: input.sourceOutputIndex,
579
+ inputIndex
580
+ });
581
+ }
582
+ }
583
+ return { outputsConsumed, outputsToMarkStale };
584
+ }
585
+ async removeStaleOutputs(outputs, topic, txid) {
586
+ this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
587
+ await Promise.all(outputs.map(async (coin) => {
588
+ const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
589
+ if (output !== undefined && output !== null)
590
+ await this.deleteUTXODeep(output);
591
+ }));
592
+ this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
593
+ }
594
+ async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
595
+ if (lookupService.admissionMode === 'locking-script') {
596
+ if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
597
+ typeof tx.outputs[outputIndex].satoshis !== 'number')
598
+ return;
599
+ await lookupService.outputAdmittedByTopic({
600
+ mode: 'locking-script',
601
+ txid,
602
+ outputIndex,
603
+ lockingScript: tx.outputs[outputIndex].lockingScript,
604
+ satoshis: tx.outputs[outputIndex].satoshis,
605
+ topic,
606
+ offChainValues
607
+ });
608
+ return;
609
+ }
610
+ await lookupService.outputAdmittedByTopic({
611
+ mode: 'whole-tx',
612
+ atomicBEEF: tx.toAtomicBEEF(),
613
+ outputIndex,
614
+ topic,
615
+ offChainValues
616
+ });
617
+ }
618
+ async admitOutput(outputIndex, context) {
619
+ const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
620
+ if (typeof tx.outputs[outputIndex].satoshis !== 'number')
621
+ return;
622
+ this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
623
+ await this.storage.insertOutput({
624
+ txid,
625
+ outputIndex,
626
+ outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
627
+ satoshis: tx.outputs[outputIndex].satoshis,
628
+ topic,
629
+ spent: false,
630
+ beef: this.compactBEEFForStorage(tx, beef),
631
+ consumedBy: [],
632
+ outputsConsumed,
633
+ score: Date.now(),
634
+ blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
635
+ });
636
+ this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
637
+ newUTXOs.push({ txid, outputIndex });
638
+ this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
639
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
640
+ try {
641
+ await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
642
+ }
643
+ catch (error) {
644
+ this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
645
+ }
646
+ }));
647
+ this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
648
+ }
649
+ async updateConsumedOutput(output, newUTXOs, topic) {
650
+ const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
651
+ if (storedOutput === undefined || storedOutput === null)
652
+ return;
653
+ const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
654
+ await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
655
+ }
656
+ async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
657
+ const topic = validation.topic;
658
+ const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
659
+ await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
660
+ steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
661
+ const newUTXOs = [];
662
+ await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
663
+ await this.admitOutput(outputIndex, {
664
+ tx,
665
+ txid,
666
+ beef,
667
+ topic,
668
+ outputsConsumed,
669
+ newUTXOs,
670
+ offChainValues
615
671
  });
616
- await Promise.all(markSpentPromises);
617
672
  }));
618
- // Continue with storage updates and lookup service notifications
673
+ this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
674
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx);
675
+ await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
676
+ await Promise.all([
677
+ ...outputsConsumed.map(async (output) => {
678
+ await this.updateConsumedOutput(output, newUTXOs, topic);
679
+ }),
680
+ this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
681
+ ]);
682
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
683
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
684
+ }
685
+ this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
686
+ }
687
+ async applyStorageMutations(validations, context) {
688
+ const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
619
689
  for (const validation of validations) {
620
690
  const topic = validation.topic;
621
- if (dupeTopics.has(topic)) {
622
- continue;
623
- }
624
- if (failedTopics.has(topic)) {
691
+ if (dupeTopics.has(topic) || failedTopics.has(topic))
625
692
  continue;
626
- }
627
693
  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)}`);
694
+ await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
742
695
  }
743
696
  catch (error) {
744
697
  this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
745
698
  }
746
699
  }
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
- }
700
+ }
701
+ async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
751
702
  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));
703
+ const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
704
+ !dupeTopics.has(topic) &&
705
+ (steak[topic].outputsToAdmit.length !== 0 ||
706
+ steak[topic].coinsRemoved?.length !== 0));
753
707
  if (relevantTopics.length === 0) {
754
708
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
755
- return steak;
709
+ return;
756
710
  }
757
- // Create a SHIPBroadcaster instance
758
711
  let customBroadcasterConfig;
759
712
  if (Array.isArray(this.slapTrackers)) {
760
- // Custom SLAP trackers warrant a custom broadcaster config
761
713
  const resolverConfig = {
762
714
  slapTrackers: this.slapTrackers
763
715
  };
@@ -773,6 +725,93 @@ export class Engine {
773
725
  this.logger.error('Error during propagation to other nodes:', error);
774
726
  }
775
727
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
728
+ }
729
+ /**
730
+ * Submits a transaction for processing by Overlay Services.
731
+ * @param {TaggedBEEF} taggedBEEF - The transaction to process
732
+ * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
733
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
734
+ * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
735
+ *
736
+ * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
737
+ *
738
+ * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
739
+ */
740
+ async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
741
+ for (const t of taggedBEEF.topics) {
742
+ if (this.managers[t] === undefined || this.managers[t] === null) {
743
+ throw new Error(`This server does not support this topic: ${t}`);
744
+ }
745
+ }
746
+ // Validate the transaction SPV information
747
+ const tx = Transaction.fromBEEF(taggedBEEF.beef);
748
+ const txid = tx.id('hex');
749
+ this.startTime(`submit_${txid}`);
750
+ if (mode !== 'historical-tx-no-spv') {
751
+ this.startTime(`chainTracker_${txid.substring(0, 10)}`);
752
+ const txValid = await tx.verify(this.chainTracker);
753
+ if (!txValid)
754
+ throw new Error('Unable to verify SPV information.');
755
+ this.endTime(`chainTracker_${txid.substring(0, 10)}`);
756
+ }
757
+ const steak = {};
758
+ const dupeTopics = new Set();
759
+ const failedTopics = new Set();
760
+ // ===================================================================
761
+ // PHASE 1: VALIDATE (read-only, no mutations)
762
+ // ===================================================================
763
+ const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
764
+ tx,
765
+ txid,
766
+ beef: taggedBEEF.beef,
767
+ offChainValues,
768
+ mode,
769
+ dupeTopics,
770
+ failedTopics
771
+ }));
772
+ const validations = await Promise.all(topicValidations);
773
+ // Build preliminary STEAK from validation results
774
+ for (const validation of validations) {
775
+ steak[validation.topic] = validation.admissibleOutputs;
776
+ }
777
+ // ===================================================================
778
+ // PHASE 2: BROADCAST (before any mutations)
779
+ // ===================================================================
780
+ // Only broadcast when at least one topic actually accepted the
781
+ // transaction. For a non-failed topic, acceptance means: previously
782
+ // accepted (dupe / client retry), outputs admitted, coins retained, or
783
+ // previously-admitted coins consumed (e.g. a consume-only deletion such
784
+ // as a KVStore remove, even one that retains nothing). A topic manager
785
+ // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
786
+ // failedTopics). A transaction every topic rejected must never reach the
787
+ // network: submitters treat an empty STEAK as a rejection and
788
+ // abort/release their held inputs, so broadcasting it anyway would
789
+ // desync their wallets from the chain.
790
+ const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
791
+ await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
792
+ // Call the callback function with STEAK if it is provided (before storage mutations)
793
+ if (onSteakReady !== undefined) {
794
+ onSteakReady(steak);
795
+ }
796
+ // ===================================================================
797
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
798
+ // ===================================================================
799
+ // Mark previous outputs as spent and notify lookup services
800
+ await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
801
+ await this.applyStorageMutations(validations, {
802
+ dupeTopics,
803
+ failedTopics,
804
+ steak,
805
+ tx,
806
+ txid,
807
+ beef: taggedBEEF.beef,
808
+ offChainValues
809
+ });
810
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
811
+ if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
812
+ return steak;
813
+ }
814
+ await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
776
815
  // Immediately return from the function without waiting for the promises to resolve.
777
816
  return steak;
778
817
  }
@@ -787,6 +826,9 @@ export class Engine {
787
826
  if (lookupService === undefined || lookupService === null)
788
827
  throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
789
828
  const lookupResult = await lookupService.lookup(lookupQuestion);
829
+ if (this.maxLookupResults !== -1 && lookupResult.length > this.maxLookupResults) {
830
+ throw new RangeError(`Lookup returned ${lookupResult.length} results; maximum is ${this.maxLookupResults}`);
831
+ }
790
832
  const hydrationContext = this.createUTXOHistoryHydrationContext();
791
833
  await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
792
834
  const hydratedOutputs = (await Promise.all(lookupResult.map(async ({ txid, outputIndex, history, context }) => {
@@ -1004,71 +1046,37 @@ export class Engine {
1004
1046
  throw new Error('Overlay Service Engine not configured for topical synchronization!');
1005
1047
  }
1006
1048
  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) {
1049
+ const configuredEndpoints = this.syncConfiguration[topic];
1050
+ if (configuredEndpoints === false)
1011
1051
  continue;
1052
+ if (!Array.isArray(configuredEndpoints) && configuredEndpoints !== 'SHIP')
1053
+ continue;
1054
+ const syncEndpoints = await this.resolveSyncEndpointsForTopic(topic, 'Failed to parse advertisement output:');
1055
+ this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
1056
+ // Sync with each endpoint sequentially to avoid parallel locks while
1057
+ // keeping peer failures isolated.
1058
+ for (const endpoint of syncEndpoints) {
1059
+ await this.syncGASPWithPeer(topic, endpoint);
1012
1060
  }
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
- }
1061
+ }
1062
+ }
1063
+ async syncGASPWithPeer(topic, endpoint) {
1064
+ this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
1065
+ try {
1066
+ const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
1067
+ const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
1068
+ await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
1069
+ if (gasp.lastInteraction > lastInteraction) {
1070
+ await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
1068
1071
  }
1072
+ this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
1073
+ }
1074
+ catch (error) {
1075
+ this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
1076
+ // Continue on to the next endpoint without throwing.
1069
1077
  }
1070
1078
  }
1071
- async resolveSyncEndpointsForTopic(topic) {
1079
+ async resolveSyncEndpointsForTopic(topic, advertisementErrorMessage = 'Failed to parse BASM advertisement output:') {
1072
1080
  if (this.syncConfiguration === undefined) {
1073
1081
  return [];
1074
1082
  }
@@ -1098,7 +1106,7 @@ export class Engine {
1098
1106
  }
1099
1107
  }
1100
1108
  catch (error) {
1101
- this.logger.error('Failed to parse BASM advertisement output:', error);
1109
+ this.logger.error(advertisementErrorMessage, error);
1102
1110
  }
1103
1111
  });
1104
1112
  }
@@ -1805,7 +1813,7 @@ export class Engine {
1805
1813
  // If none of the disallowed conditions matched, the URL is valid
1806
1814
  return true;
1807
1815
  }
1808
- catch (_e) {
1816
+ catch {
1809
1817
  // URL constructor throws on malformed input — not a valid URL, return false
1810
1818
  return false;
1811
1819
  }