@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
@@ -7,8 +7,16 @@ const OverlayGASPRemote_js_1 = require("./GASP/OverlayGASPRemote.js");
7
7
  const OverlayGASPStorage_js_1 = require("./GASP/OverlayGASPStorage.js");
8
8
  const BASM_js_1 = require("./BASM.js");
9
9
  const BASMRemote_js_1 = require("./BASMRemote.js");
10
+ const SafeLog_js_1 = require("./SafeLog.js");
10
11
  const DEFAULT_GASP_SYNC_LIMIT = 10000;
11
12
  const DEFAULT_BASM_RANGE_LIMIT = 1024;
13
+ function findSpendingInputIndex(tx, output) {
14
+ return tx.inputs.findIndex(input => {
15
+ var _a;
16
+ const realSource = input.sourceTXID || ((_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex'));
17
+ return realSource === output.txid && input.sourceOutputIndex === output.outputIndex;
18
+ });
19
+ }
12
20
  /**
13
21
  * An engine for running BSV Overlay Services (topic managers and lookup services).
14
22
  */
@@ -34,10 +42,11 @@ class Engine {
34
42
  * @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
35
43
  * @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
36
44
  * @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
45
+ * @param {number} maxLookupResults - Maximum lookup formulas hydrated per request. Use -1 to opt out.
37
46
  */
38
- constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new sdk_1.HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true, topicAnchorHeaderResolver, basmSyncEnabled = false, unprovenEvictionBlocks = 144) {
39
- var _a;
40
- var _b;
47
+ constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new sdk_1.HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true, topicAnchorHeaderResolver, basmSyncEnabled = false, unprovenEvictionBlocks = 144, maxLookupResults = 1000) {
48
+ var _a, _b;
49
+ var _c;
41
50
  this.managers = managers;
42
51
  this.lookupServices = lookupServices;
43
52
  this.storage = storage;
@@ -57,14 +66,13 @@ class Engine {
57
66
  this.topicAnchorHeaderResolver = topicAnchorHeaderResolver;
58
67
  this.basmSyncEnabled = basmSyncEnabled;
59
68
  this.unprovenEvictionBlocks = unprovenEvictionBlocks;
69
+ this.maxLookupResults = maxLookupResults;
70
+ if (maxLookupResults !== -1 && (!Number.isSafeInteger(maxLookupResults) || maxLookupResults < 1)) {
71
+ throw new TypeError('maxLookupResults must be -1 or a positive safe integer');
72
+ }
60
73
  // 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'.
61
74
  // For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
62
- if (syncConfiguration === undefined) {
63
- this.syncConfiguration = {};
64
- }
65
- else {
66
- this.syncConfiguration = syncConfiguration;
67
- }
75
+ (_a = this.syncConfiguration) !== null && _a !== void 0 ? _a : (this.syncConfiguration = {});
68
76
  for (const managerName of Object.keys(managers)) {
69
77
  if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
70
78
  // Combine tm_ship trackers with preexisting entries if any
@@ -84,7 +92,7 @@ class Engine {
84
92
  }
85
93
  else {
86
94
  // Set undefined managers to 'SHIP' by default
87
- (_a = (_b = this.syncConfiguration)[managerName]) !== null && _a !== void 0 ? _a : (_b[managerName] = 'SHIP');
95
+ (_b = (_c = this.syncConfiguration)[managerName]) !== null && _b !== void 0 ? _b : (_c[managerName] = 'SHIP');
88
96
  }
89
97
  }
90
98
  }
@@ -124,7 +132,7 @@ class Engine {
124
132
  return header.blockHash;
125
133
  }
126
134
  catch (error) {
127
- this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
135
+ this.logger.warn(`Unable to resolve BASM block hash: height=${(0, SafeLog_js_1.serializeLogValue)(blockHeight)} error=${(0, SafeLog_js_1.serializeErrorForLog)(error)}`);
128
136
  return undefined;
129
137
  }
130
138
  }
@@ -232,7 +240,7 @@ class Engine {
232
240
  }
233
241
  if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
234
242
  // Bound the work per pass; the next trigger resumes from the new tip.
235
- 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`);
243
+ this.logger.warn(`[BASM] capping anchor chain extension: topic=${(0, SafeLog_js_1.serializeLogValue)(topic)} limit=${(0, SafeLog_js_1.serializeLogValue)(DEFAULT_BASM_RANGE_LIMIT)} requestedFrom=${(0, SafeLog_js_1.serializeLogValue)(fromHeight)} requestedTo=${(0, SafeLog_js_1.serializeLogValue)(toHeight)}; will continue on the next pass`);
236
244
  toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
237
245
  }
238
246
  const previousAnchor = fromHeight > 0
@@ -246,7 +254,7 @@ class Engine {
246
254
  // canonical re-resolution from the header resolver instead of reusing it.
247
255
  const blockHash = (_c = (_b = blockHashHints.get(height)) !== null && _b !== void 0 ? _b : (forceResolve ? undefined : existing === null || existing === void 0 ? void 0 : existing.blockHash)) !== null && _c !== void 0 ? _c : await this.resolveBlockHash(height);
248
256
  if (blockHash === undefined) {
249
- this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`);
257
+ this.logger.warn(`[BASM] unable to resolve block hash: topic=${(0, SafeLog_js_1.serializeLogValue)(topic)} height=${(0, SafeLog_js_1.serializeLogValue)(height)}; halting chain extension`);
250
258
  return;
251
259
  }
252
260
  const basmRoot = (0, BASM_js_1.computeBasmRoot)(admitted);
@@ -375,386 +383,331 @@ class Engine {
375
383
  newTipHeight: tip
376
384
  });
377
385
  }
378
- /**
379
- * Submits a transaction for processing by Overlay Services.
380
- * @param {TaggedBEEF} taggedBEEF - The transaction to process
381
- * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
382
- * @param {string} mode Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
383
- * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
384
- *
385
- * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
386
- *
387
- * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
388
- */
389
- async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
390
- var _a, _b;
391
- for (const t of taggedBEEF.topics) {
392
- if (this.managers[t] === undefined || this.managers[t] === null) {
393
- throw new Error(`This server does not support this topic: ${t}`);
394
- }
395
- }
396
- // Validate the transaction SPV information
397
- const tx = sdk_1.Transaction.fromBEEF(taggedBEEF.beef);
398
- const txid = tx.id('hex');
399
- this.startTime(`submit_${txid}`);
400
- if (mode !== 'historical-tx-no-spv') {
401
- this.startTime(`chainTracker_${txid.substring(0, 10)}`);
402
- const txValid = await tx.verify(this.chainTracker);
403
- if (!txValid)
404
- throw new Error('Unable to verify SPV information.');
405
- this.endTime(`chainTracker_${txid.substring(0, 10)}`);
406
- }
407
- const steak = {};
408
- const dupeTopics = new Set();
409
- const failedTopics = new Set();
410
- const topicValidations = taggedBEEF.topics.map(async (topic) => {
411
- try {
412
- if (this.managers[topic] === undefined || this.managers[topic] === null) {
413
- throw new Error(`This server does not support this topic: ${topic}`);
414
- }
415
- // Check for duplicate transactions
416
- this.startTime(`dupCheck_${txid.substring(0, 10)}`);
417
- const dupeCheck = await this.storage.doesAppliedTransactionExist({ txid, topic });
418
- this.endTime(`dupCheck_${txid.substring(0, 10)}`);
419
- if (dupeCheck) {
420
- dupeTopics.add(topic);
421
- return {
422
- topic,
423
- isDupe: true,
424
- previousCoins: [],
425
- previousOutputs: [],
426
- admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
427
- };
428
- }
429
- // Identify previous coins admitted to this specific topic
430
- const previousCoins = [];
431
- const outputPromises = tx.inputs.map(async (input, i) => {
432
- var _a, _b;
433
- const previousTXID = (_a = input.sourceTXID) !== null && _a !== void 0 ? _a : (_b = input.sourceTransaction) === null || _b === void 0 ? void 0 : _b.id('hex');
434
- if (previousTXID !== undefined) {
435
- // Check if the previous output was admitted to this specific topic
436
- const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
437
- if (output !== undefined && output !== null) {
438
- previousCoins.push(i);
439
- return output;
440
- }
441
- }
442
- return null;
443
- });
444
- this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
445
- const previousOutputs = await Promise.all(outputPromises);
446
- this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
447
- // Determine which outputs are admissible for this topic (validation only)
448
- this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
449
- const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(taggedBEEF.beef, previousCoins, offChainValues, mode);
450
- this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
451
- return {
452
- topic,
453
- isDupe: false,
454
- previousCoins,
455
- previousOutputs,
456
- admissibleOutputs
457
- };
458
- }
459
- catch (error) {
460
- this.logger.error('Error validating topic during submit:', error);
461
- failedTopics.add(topic);
386
+ async validateTopicSubmission(topic, context) {
387
+ const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
388
+ try {
389
+ if (this.managers[topic] === undefined || this.managers[topic] === null) {
390
+ throw new Error(`This server does not support this topic: ${topic}`);
391
+ }
392
+ this.startTime(`dupCheck_${txid.substring(0, 10)}`);
393
+ const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
394
+ this.endTime(`dupCheck_${txid.substring(0, 10)}`);
395
+ if (isDupe) {
396
+ dupeTopics.add(topic);
462
397
  return {
463
398
  topic,
464
- isDupe: false,
399
+ isDupe: true,
465
400
  previousCoins: [],
466
401
  previousOutputs: [],
467
402
  admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
468
403
  };
469
404
  }
470
- });
471
- const validations = await Promise.all(topicValidations);
472
- // Build preliminary STEAK from validation results
473
- for (const validation of validations) {
474
- steak[validation.topic] = validation.admissibleOutputs;
405
+ const previousCoins = [];
406
+ const outputPromises = tx.inputs.map(async (input, inputIndex) => {
407
+ var _a;
408
+ var _b;
409
+ const previousTXID = (_b = input.sourceTXID) !== null && _b !== void 0 ? _b : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.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 && output !== void 0 ? 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
+ };
475
430
  }
476
- // ===================================================================
477
- // PHASE 2: BROADCAST (before any mutations)
478
- // ===================================================================
479
- // Only broadcast when at least one topic actually accepted the
480
- // transaction. For a non-failed topic, acceptance means: previously
481
- // accepted (dupe / client retry), outputs admitted, coins retained, or
482
- // previously-admitted coins consumed (e.g. a consume-only deletion such
483
- // as a KVStore remove, even one that retains nothing). A topic manager
484
- // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
485
- // failedTopics). A transaction every topic rejected must never reach the
486
- // network: submitters treat an empty STEAK as a rejection and
487
- // abort/release their held inputs, so broadcasting it anyway would
488
- // desync their wallets from the chain.
489
- const anyTopicAccepted = validations.some(v => !failedTopics.has(v.topic) && (v.isDupe ||
490
- v.admissibleOutputs.outputsToAdmit.length > 0 ||
491
- v.admissibleOutputs.coinsToRetain.length > 0 ||
492
- v.previousCoins.length > 0));
431
+ catch (error) {
432
+ this.logger.error(`Error validating topic during submit: topic=${(0, SafeLog_js_1.serializeLogValue)(topic)} error=${(0, SafeLog_js_1.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) {
493
450
  this.startTime(`broadcast_${txid.substring(0, 10)}`);
494
451
  if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
495
452
  try {
496
453
  let response;
497
454
  if (tx.merklePath !== undefined) {
498
- // tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
499
- const txid = tx.id('hex');
500
455
  const mp = tx.merklePath;
501
456
  const leaf = mp.path[0].find(leaf => leaf.hash === txid);
502
- const r = {
457
+ response = {
503
458
  status: 'success',
504
- txid: tx.id('hex'),
505
- message: `In block at height ${mp.blockHeight} index ${leaf === null || leaf === void 0 ? void 0 : leaf.offset}`,
459
+ txid,
460
+ message: `In block at height ${mp.blockHeight} index ${leaf === null || leaf === void 0 ? void 0 : leaf.offset}`
506
461
  };
507
- response = r;
508
462
  }
509
463
  else {
510
464
  response = await this.broadcaster.broadcast(tx);
511
465
  }
512
466
  if ((0, sdk_1.isBroadcastFailure)(response) && this.throwOnBroadcastFailure) {
513
- const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
514
- e.more = response.more;
515
- throw e;
467
+ const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
468
+ error.more = response.more;
469
+ throw error;
516
470
  }
517
471
  }
518
472
  catch (error) {
519
- if (this.throwOnBroadcastFailure) {
473
+ if (this.throwOnBroadcastFailure)
520
474
  throw error;
521
- }
522
475
  this.logger.error('Error broadcasting transaction:', error);
523
476
  }
524
477
  }
525
478
  this.endTime(`broadcast_${txid.substring(0, 10)}`);
526
- // Call the callback function with STEAK if it is provided (before storage mutations)
527
- if (onSteakReady !== undefined) {
528
- onSteakReady(steak);
479
+ }
480
+ async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
481
+ var _a;
482
+ if (typeof lookupService.outputSpent !== 'function')
483
+ return;
484
+ if (lookupService.spendNotificationMode === 'txid') {
485
+ await lookupService.outputSpent({
486
+ mode: 'txid',
487
+ spendingTxid: txid,
488
+ txid: output.txid,
489
+ outputIndex: output.outputIndex,
490
+ topic
491
+ });
492
+ return;
529
493
  }
530
- // ===================================================================
531
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
532
- // ===================================================================
533
- // Mark previous outputs as spent and notify lookup services
494
+ if (lookupService.spendNotificationMode === 'script') {
495
+ const inputIndex = findSpendingInputIndex(tx, output);
496
+ if (inputIndex === -1)
497
+ throw new Error('Could not find input index');
498
+ await lookupService.outputSpent({
499
+ mode: 'script',
500
+ spendingTxid: txid,
501
+ inputIndex,
502
+ sequenceNumber: (_a = tx.inputs[inputIndex].sequence) !== null && _a !== void 0 ? _a : 0xffffffff,
503
+ unlockingScript: tx.inputs[inputIndex].unlockingScript,
504
+ txid: output.txid,
505
+ outputIndex: output.outputIndex,
506
+ topic,
507
+ offChainValues
508
+ });
509
+ return;
510
+ }
511
+ if (lookupService.spendNotificationMode === 'whole-tx') {
512
+ await lookupService.outputSpent({
513
+ mode: 'whole-tx',
514
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
515
+ txid: output.txid,
516
+ outputIndex: output.outputIndex,
517
+ topic,
518
+ offChainValues
519
+ });
520
+ return;
521
+ }
522
+ await lookupService.outputSpent({
523
+ mode: 'none',
524
+ txid: output.txid,
525
+ outputIndex: output.outputIndex,
526
+ topic
527
+ });
528
+ }
529
+ async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
530
+ if (output === null)
531
+ return;
532
+ try {
533
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
534
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
535
+ try {
536
+ await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
537
+ }
538
+ catch (error) {
539
+ this.logger.error('Error in lookup service for outputSpent:', error);
540
+ }
541
+ }));
542
+ }
543
+ catch (error) {
544
+ this.logger.error('Error marking UTXO as spent:', error);
545
+ }
546
+ }
547
+ async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
534
548
  await Promise.all(validations.map(async (validation) => {
535
- if (validation.isDupe || failedTopics.has(validation.topic)) {
549
+ if (validation.isDupe || failedTopics.has(validation.topic))
536
550
  return;
551
+ await Promise.all(validation.previousOutputs.map(async (output) => {
552
+ await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
553
+ }));
554
+ }));
555
+ }
556
+ classifyPreviousCoins(tx, validation) {
557
+ var _a;
558
+ var _b;
559
+ const outputsConsumed = [];
560
+ const outputsToMarkStale = [];
561
+ for (const inputIndex of validation.previousCoins) {
562
+ const input = tx.inputs[inputIndex];
563
+ const previousTXID = (_b = input.sourceTXID) !== null && _b !== void 0 ? _b : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex');
564
+ if (typeof previousTXID !== 'string')
565
+ continue;
566
+ if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
567
+ outputsConsumed.push({
568
+ txid: previousTXID,
569
+ outputIndex: input.sourceOutputIndex
570
+ });
537
571
  }
538
- const topic = validation.topic;
539
- const previousOutputs = validation.previousOutputs;
540
- // Mark all previous outputs as spent
541
- const markSpentPromises = previousOutputs.map(async (output) => {
542
- if (output !== undefined && output !== null) {
543
- try {
544
- await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
545
- await Promise.all(Object.values(this.lookupServices).map(async (l) => {
546
- var _a;
547
- try {
548
- if (typeof l.outputSpent === 'function') {
549
- if (l.spendNotificationMode === 'txid') {
550
- await l.outputSpent({
551
- mode: 'txid',
552
- spendingTxid: txid,
553
- txid: output.txid,
554
- outputIndex: output.outputIndex,
555
- topic
556
- });
557
- }
558
- else if (l.spendNotificationMode === 'script') {
559
- const inputIndex = tx.inputs.findIndex(i => {
560
- var _a;
561
- let realSource = i.sourceTXID;
562
- if (!realSource) {
563
- realSource = (_a = i.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex');
564
- }
565
- return realSource === output.txid && i.sourceOutputIndex === output.outputIndex;
566
- });
567
- if (inputIndex === -1) {
568
- throw new Error('Could not find input index');
569
- }
570
- await l.outputSpent({
571
- mode: 'script',
572
- spendingTxid: txid,
573
- inputIndex,
574
- sequenceNumber: (_a = tx.inputs[inputIndex].sequence) !== null && _a !== void 0 ? _a : 0xffffffff,
575
- unlockingScript: tx.inputs[inputIndex].unlockingScript,
576
- txid: output.txid,
577
- outputIndex: output.outputIndex,
578
- topic,
579
- offChainValues
580
- });
581
- }
582
- else if (l.spendNotificationMode === 'whole-tx') {
583
- await l.outputSpent({
584
- mode: 'whole-tx',
585
- spendingAtomicBEEF: tx.toAtomicBEEF(),
586
- txid: output.txid,
587
- outputIndex: output.outputIndex,
588
- topic,
589
- offChainValues
590
- });
591
- }
592
- else { // none
593
- await l.outputSpent({
594
- mode: 'none',
595
- txid: output.txid,
596
- outputIndex: output.outputIndex,
597
- topic
598
- });
599
- }
600
- }
601
- }
602
- catch (error) {
603
- this.logger.error('Error in lookup service for outputSpent:', error);
604
- }
605
- }));
606
- }
607
- catch (error) {
608
- this.logger.error('Error marking UTXO as spent:', error);
609
- }
610
- }
572
+ else {
573
+ outputsToMarkStale.push({
574
+ txid: previousTXID,
575
+ previousOutputIndex: input.sourceOutputIndex,
576
+ inputIndex
577
+ });
578
+ }
579
+ }
580
+ return { outputsConsumed, outputsToMarkStale };
581
+ }
582
+ async removeStaleOutputs(outputs, topic, txid) {
583
+ this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
584
+ await Promise.all(outputs.map(async (coin) => {
585
+ const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
586
+ if (output !== undefined && output !== null)
587
+ await this.deleteUTXODeep(output);
588
+ }));
589
+ this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
590
+ }
591
+ async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
592
+ if (lookupService.admissionMode === 'locking-script') {
593
+ if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
594
+ typeof tx.outputs[outputIndex].satoshis !== 'number')
595
+ return;
596
+ await lookupService.outputAdmittedByTopic({
597
+ mode: 'locking-script',
598
+ txid,
599
+ outputIndex,
600
+ lockingScript: tx.outputs[outputIndex].lockingScript,
601
+ satoshis: tx.outputs[outputIndex].satoshis,
602
+ topic,
603
+ offChainValues
611
604
  });
612
- await Promise.all(markSpentPromises);
605
+ return;
606
+ }
607
+ await lookupService.outputAdmittedByTopic({
608
+ mode: 'whole-tx',
609
+ atomicBEEF: tx.toAtomicBEEF(),
610
+ outputIndex,
611
+ topic,
612
+ offChainValues
613
+ });
614
+ }
615
+ async admitOutput(outputIndex, context) {
616
+ var _a;
617
+ const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
618
+ if (typeof tx.outputs[outputIndex].satoshis !== 'number')
619
+ return;
620
+ this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
621
+ await this.storage.insertOutput({
622
+ txid,
623
+ outputIndex,
624
+ outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
625
+ satoshis: tx.outputs[outputIndex].satoshis,
626
+ topic,
627
+ spent: false,
628
+ beef: this.compactBEEFForStorage(tx, beef),
629
+ consumedBy: [],
630
+ outputsConsumed,
631
+ score: Date.now(),
632
+ blockHeight: (_a = (0, BASM_js_1.extractMerkleProofMetadata)(txid, tx.merklePath)) === null || _a === void 0 ? void 0 : _a.blockHeight
633
+ });
634
+ this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
635
+ newUTXOs.push({ txid, outputIndex });
636
+ this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
637
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
638
+ try {
639
+ await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
640
+ }
641
+ catch (error) {
642
+ this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
643
+ }
613
644
  }));
614
- // Continue with storage updates and lookup service notifications
645
+ this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
646
+ }
647
+ async updateConsumedOutput(output, newUTXOs, topic) {
648
+ const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
649
+ if (storedOutput === undefined || storedOutput === null)
650
+ return;
651
+ const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
652
+ await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
653
+ }
654
+ async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
655
+ const topic = validation.topic;
656
+ const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
657
+ await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
658
+ steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
659
+ const newUTXOs = [];
660
+ await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
661
+ await this.admitOutput(outputIndex, {
662
+ tx,
663
+ txid,
664
+ beef,
665
+ topic,
666
+ outputsConsumed,
667
+ newUTXOs,
668
+ offChainValues
669
+ });
670
+ }));
671
+ this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
672
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx);
673
+ await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
674
+ await Promise.all([
675
+ ...outputsConsumed.map(async (output) => {
676
+ await this.updateConsumedOutput(output, newUTXOs, topic);
677
+ }),
678
+ this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
679
+ ]);
680
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
681
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
682
+ }
683
+ this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
684
+ }
685
+ async applyStorageMutations(validations, context) {
686
+ const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
615
687
  for (const validation of validations) {
616
688
  const topic = validation.topic;
617
- if (dupeTopics.has(topic)) {
618
- continue;
619
- }
620
- if (failedTopics.has(topic)) {
689
+ if (dupeTopics.has(topic) || failedTopics.has(topic))
621
690
  continue;
622
- }
623
691
  try {
624
- const admissibleOutputs = steak[topic];
625
- const outputsToAdmit = admissibleOutputs.outputsToAdmit;
626
- const outputsConsumed = [];
627
- const outputsToMarkStale = [];
628
- // Use previousCoins from validation
629
- const previousCoins = validation.previousCoins;
630
- // 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.
631
- for (const inputIndex of previousCoins) {
632
- const previousTXID = (_a = tx.inputs[inputIndex].sourceTXID) !== null && _a !== void 0 ? _a : (_b = tx.inputs[inputIndex].sourceTransaction) === null || _b === void 0 ? void 0 : _b.id('hex');
633
- if (typeof previousTXID !== 'string')
634
- continue;
635
- const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex;
636
- if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
637
- outputsConsumed.push({
638
- txid: previousTXID,
639
- outputIndex: previousOutputIndex
640
- });
641
- }
642
- else {
643
- outputsToMarkStale.push({
644
- txid: previousTXID,
645
- previousOutputIndex,
646
- inputIndex
647
- });
648
- }
649
- }
650
- // Remove stale outputs recursively
651
- this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
652
- await Promise.all(outputsToMarkStale.map(async (coin) => {
653
- const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
654
- if (output !== undefined && output !== null) {
655
- await this.deleteUTXODeep(output);
656
- }
657
- }));
658
- this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
659
- // Update the STEAK to indicate which coins were removed
660
- steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex);
661
- // Handle admittance and notification of incoming UTXOs
662
- const newUTXOs = [];
663
- await Promise.all(outputsToAdmit.map(async (outputIndex) => {
664
- var _a;
665
- if (typeof tx.outputs[outputIndex].satoshis !== 'number')
666
- return;
667
- this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
668
- await this.storage.insertOutput({
669
- txid,
670
- outputIndex,
671
- outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
672
- satoshis: tx.outputs[outputIndex].satoshis,
673
- topic,
674
- spent: false,
675
- beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
676
- consumedBy: [],
677
- outputsConsumed,
678
- score: Date.now(),
679
- blockHeight: (_a = (0, BASM_js_1.extractMerkleProofMetadata)(txid, tx.merklePath)) === null || _a === void 0 ? void 0 : _a.blockHeight
680
- });
681
- this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
682
- newUTXOs.push({ txid, outputIndex });
683
- this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
684
- await Promise.all(Object.values(this.lookupServices).map(async (l) => {
685
- try {
686
- if (l.admissionMode === 'locking-script') {
687
- if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
688
- typeof tx.outputs[outputIndex].satoshis !== 'number') {
689
- return;
690
- }
691
- await l.outputAdmittedByTopic({
692
- mode: 'locking-script',
693
- txid,
694
- outputIndex,
695
- lockingScript: tx.outputs[outputIndex].lockingScript,
696
- satoshis: tx.outputs[outputIndex].satoshis,
697
- topic,
698
- offChainValues
699
- });
700
- }
701
- else {
702
- await l.outputAdmittedByTopic({
703
- mode: 'whole-tx',
704
- atomicBEEF: tx.toAtomicBEEF(),
705
- outputIndex,
706
- topic,
707
- offChainValues
708
- });
709
- }
710
- }
711
- catch (error) {
712
- this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
713
- }
714
- }));
715
- this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
716
- }));
717
- this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
718
- // Update each output consumed to know who consumed it and insert applied transaction in parallel
719
- const appliedRecord = await this.buildAppliedTransactionRecord(tx);
720
- await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash);
721
- await Promise.all([
722
- ...outputsConsumed.map(async (output) => {
723
- const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic);
724
- if (outputToUpdate !== undefined && outputToUpdate !== null) {
725
- const newConsumedBy = [...new Set([...newUTXOs, ...outputToUpdate.consumedBy])];
726
- await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, newConsumedBy);
727
- }
728
- }),
729
- this.storage.insertAppliedTransaction({
730
- txid,
731
- topic,
732
- ...appliedRecord
733
- })
734
- ]);
735
- if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
736
- await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
737
- }
738
- this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
692
+ await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
739
693
  }
740
694
  catch (error) {
741
695
  this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
742
696
  }
743
697
  }
744
- // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
745
- if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
746
- return steak;
747
- }
698
+ }
699
+ async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
748
700
  this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
749
- const relevantTopics = taggedBEEF.topics.filter(topic => { var _a; return steak[topic] !== undefined && !dupeTopics.has(topic) && (steak[topic].outputsToAdmit.length !== 0 || ((_a = steak[topic].coinsRemoved) === null || _a === void 0 ? void 0 : _a.length) !== 0); });
701
+ const relevantTopics = taggedBEEF.topics.filter(topic => { var _a; return steak[topic] !== undefined &&
702
+ !dupeTopics.has(topic) &&
703
+ (steak[topic].outputsToAdmit.length !== 0 ||
704
+ ((_a = steak[topic].coinsRemoved) === null || _a === void 0 ? void 0 : _a.length) !== 0); });
750
705
  if (relevantTopics.length === 0) {
751
706
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
752
- return steak;
707
+ return;
753
708
  }
754
- // Create a SHIPBroadcaster instance
755
709
  let customBroadcasterConfig;
756
710
  if (Array.isArray(this.slapTrackers)) {
757
- // Custom SLAP trackers warrant a custom broadcaster config
758
711
  const resolverConfig = {
759
712
  slapTrackers: this.slapTrackers
760
713
  };
@@ -770,6 +723,93 @@ class Engine {
770
723
  this.logger.error('Error during propagation to other nodes:', error);
771
724
  }
772
725
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
726
+ }
727
+ /**
728
+ * Submits a transaction for processing by Overlay Services.
729
+ * @param {TaggedBEEF} taggedBEEF - The transaction to process
730
+ * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
731
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
732
+ * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
733
+ *
734
+ * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
735
+ *
736
+ * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
737
+ */
738
+ async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
739
+ for (const t of taggedBEEF.topics) {
740
+ if (this.managers[t] === undefined || this.managers[t] === null) {
741
+ throw new Error(`This server does not support this topic: ${t}`);
742
+ }
743
+ }
744
+ // Validate the transaction SPV information
745
+ const tx = sdk_1.Transaction.fromBEEF(taggedBEEF.beef);
746
+ const txid = tx.id('hex');
747
+ this.startTime(`submit_${txid}`);
748
+ if (mode !== 'historical-tx-no-spv') {
749
+ this.startTime(`chainTracker_${txid.substring(0, 10)}`);
750
+ const txValid = await tx.verify(this.chainTracker);
751
+ if (!txValid)
752
+ throw new Error('Unable to verify SPV information.');
753
+ this.endTime(`chainTracker_${txid.substring(0, 10)}`);
754
+ }
755
+ const steak = {};
756
+ const dupeTopics = new Set();
757
+ const failedTopics = new Set();
758
+ // ===================================================================
759
+ // PHASE 1: VALIDATE (read-only, no mutations)
760
+ // ===================================================================
761
+ const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
762
+ tx,
763
+ txid,
764
+ beef: taggedBEEF.beef,
765
+ offChainValues,
766
+ mode,
767
+ dupeTopics,
768
+ failedTopics
769
+ }));
770
+ const validations = await Promise.all(topicValidations);
771
+ // Build preliminary STEAK from validation results
772
+ for (const validation of validations) {
773
+ steak[validation.topic] = validation.admissibleOutputs;
774
+ }
775
+ // ===================================================================
776
+ // PHASE 2: BROADCAST (before any mutations)
777
+ // ===================================================================
778
+ // Only broadcast when at least one topic actually accepted the
779
+ // transaction. For a non-failed topic, acceptance means: previously
780
+ // accepted (dupe / client retry), outputs admitted, coins retained, or
781
+ // previously-admitted coins consumed (e.g. a consume-only deletion such
782
+ // as a KVStore remove, even one that retains nothing). A topic manager
783
+ // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
784
+ // failedTopics). A transaction every topic rejected must never reach the
785
+ // network: submitters treat an empty STEAK as a rejection and
786
+ // abort/release their held inputs, so broadcasting it anyway would
787
+ // desync their wallets from the chain.
788
+ const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
789
+ await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
790
+ // Call the callback function with STEAK if it is provided (before storage mutations)
791
+ if (onSteakReady !== undefined) {
792
+ onSteakReady(steak);
793
+ }
794
+ // ===================================================================
795
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
796
+ // ===================================================================
797
+ // Mark previous outputs as spent and notify lookup services
798
+ await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
799
+ await this.applyStorageMutations(validations, {
800
+ dupeTopics,
801
+ failedTopics,
802
+ steak,
803
+ tx,
804
+ txid,
805
+ beef: taggedBEEF.beef,
806
+ offChainValues
807
+ });
808
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
809
+ if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
810
+ return steak;
811
+ }
812
+ await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
773
813
  // Immediately return from the function without waiting for the promises to resolve.
774
814
  return steak;
775
815
  }
@@ -784,6 +824,9 @@ class Engine {
784
824
  if (lookupService === undefined || lookupService === null)
785
825
  throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
786
826
  const lookupResult = await lookupService.lookup(lookupQuestion);
827
+ if (this.maxLookupResults !== -1 && lookupResult.length > this.maxLookupResults) {
828
+ throw new RangeError(`Lookup returned ${lookupResult.length} results; maximum is ${this.maxLookupResults}`);
829
+ }
787
830
  const hydrationContext = this.createUTXOHistoryHydrationContext();
788
831
  await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
789
832
  const hydratedOutputs = (await Promise.all(lookupResult.map(async ({ txid, outputIndex, history, context }) => {
@@ -1003,72 +1046,37 @@ class Engine {
1003
1046
  throw new Error('Overlay Service Engine not configured for topical synchronization!');
1004
1047
  }
1005
1048
  for (const topic of Object.keys(this.syncConfiguration)) {
1006
- // Make sure syncEndpoints is an array or SHIP
1007
- let syncEndpoints = this.syncConfiguration[topic];
1008
- // Check if this topic has been configured NOT to sync
1009
- if (syncEndpoints === false) {
1049
+ const configuredEndpoints = this.syncConfiguration[topic];
1050
+ if (configuredEndpoints === false)
1010
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);
1011
1060
  }
1012
- if (syncEndpoints === 'SHIP') {
1013
- // Perform lookup and find ship advertisements to set syncEndpoints for topic
1014
- const resolverConfig = this.slapTrackers
1015
- ? { slapTrackers: this.slapTrackers }
1016
- : {};
1017
- const resolver = new sdk_1.LookupResolver(resolverConfig);
1018
- const lookupAnswer = await resolver.query({
1019
- service: 'ls_ship',
1020
- query: {
1021
- topics: [topic]
1022
- }
1023
- });
1024
- // Lookup will currently always return type output-list
1025
- if (lookupAnswer.type === 'output-list') {
1026
- const endpointSet = new Set();
1027
- lookupAnswer.outputs.forEach(output => {
1028
- var _a;
1029
- try {
1030
- // Parse out the advertisements using the provided parser
1031
- const tx = sdk_1.Transaction.fromBEEF(output.beef);
1032
- const advertisement = (_a = this.advertiser) === null || _a === void 0 ? void 0 : _a.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
1033
- if ((advertisement === null || advertisement === void 0 ? void 0 : 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_1.GASP(new OverlayGASPStorage_js_1.OverlayGASPStorage(topic, this), new OverlayGASPRemote_js_1.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_1.GASP(new OverlayGASPStorage_js_1.OverlayGASPStorage(topic, this), new OverlayGASPRemote_js_1.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
  }
@@ -1099,7 +1107,7 @@ class Engine {
1099
1107
  }
1100
1108
  }
1101
1109
  catch (error) {
1102
- this.logger.error('Failed to parse BASM advertisement output:', error);
1110
+ this.logger.error(advertisementErrorMessage, error);
1103
1111
  }
1104
1112
  });
1105
1113
  }
@@ -1259,7 +1267,8 @@ class Engine {
1259
1267
  }
1260
1268
  }
1261
1269
  async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
1262
- var _a, _b, _c, _d, _f;
1270
+ var _a, _b, _c, _d;
1271
+ var _e;
1263
1272
  report.checkedHeights.push(remoteAnchor.blockHeight);
1264
1273
  const localAnchor = await ((_b = (_a = this.storage).findTopicBlockAnchor) === null || _b === void 0 ? void 0 : _b.call(_a, topic, remoteAnchor.blockHeight, remoteAnchor.blockHash));
1265
1274
  if ((localAnchor === null || localAnchor === void 0 ? void 0 : localAnchor.tac) === remoteAnchor.tac) {
@@ -1271,7 +1280,7 @@ class Engine {
1271
1280
  admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
1272
1281
  throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
1273
1282
  }
1274
- const localAdmitted = (_f = await ((_d = (_c = this.storage).findAdmittedTransactionsForBlock) === null || _d === void 0 ? void 0 : _d.call(_c, topic, remoteAnchor.blockHeight, remoteAnchor.blockHash))) !== null && _f !== void 0 ? _f : [];
1283
+ const localAdmitted = (_e = await ((_d = (_c = this.storage).findAdmittedTransactionsForBlock) === null || _d === void 0 ? void 0 : _d.call(_c, topic, remoteAnchor.blockHeight, remoteAnchor.blockHash))) !== null && _e !== void 0 ? _e : [];
1275
1284
  const localTxids = new Set(localAdmitted.map(item => item.txid));
1276
1285
  const missingTxids = admittedResponse.admitted
1277
1286
  .map(item => item.txid)
@@ -1315,14 +1324,15 @@ class Engine {
1315
1324
  }
1316
1325
  }
1317
1326
  async evictUnprovenTransactions(options = {}) {
1318
- var _a, _b, _c;
1327
+ var _a, _b;
1328
+ var _c;
1319
1329
  if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1320
1330
  throw new TypeError('Storage does not support unproven transaction eviction');
1321
1331
  }
1322
1332
  if (this.chainTracker === 'scripts only') {
1323
1333
  throw new Error('Unproven eviction requires a ChainTracker to determine block age');
1324
1334
  }
1325
- const thresholdBlocks = (_a = options.thresholdBlocks) !== null && _a !== void 0 ? _a : this.unprovenEvictionBlocks;
1335
+ const thresholdBlocks = (_c = options.thresholdBlocks) !== null && _c !== void 0 ? _c : this.unprovenEvictionBlocks;
1326
1336
  const currentHeight = await this.chainTracker.currentHeight();
1327
1337
  const cutoffHeight = currentHeight - thresholdBlocks;
1328
1338
  const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
@@ -1340,7 +1350,7 @@ class Engine {
1340
1350
  await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
1341
1351
  evictedOutputs++;
1342
1352
  }
1343
- await ((_c = (_b = this.storage).deleteAppliedTransaction) === null || _c === void 0 ? void 0 : _c.call(_b, candidate.txid, candidate.topic));
1353
+ await ((_b = (_a = this.storage).deleteAppliedTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, candidate.txid, candidate.topic));
1344
1354
  }
1345
1355
  return {
1346
1356
  cutoffHeight,
@@ -1447,14 +1457,11 @@ class Engine {
1447
1457
  async provideForeignSyncResponse(initialRequest, topic) {
1448
1458
  const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, initialRequest.limit);
1449
1459
  return {
1450
- UTXOList: outputs.map(output => {
1451
- var _a;
1452
- return ({
1453
- txid: output.txid,
1454
- outputIndex: output.outputIndex,
1455
- score: (_a = output.score) !== null && _a !== void 0 ? _a : 0
1456
- });
1457
- }),
1460
+ UTXOList: outputs.map(output => { var _a; return ({
1461
+ txid: output.txid,
1462
+ outputIndex: output.outputIndex,
1463
+ score: (_a = output.score) !== null && _a !== void 0 ? _a : 0
1464
+ }); }),
1458
1465
  since: initialRequest.since
1459
1466
  };
1460
1467
  }
@@ -1818,7 +1825,7 @@ class Engine {
1818
1825
  // If none of the disallowed conditions matched, the URL is valid
1819
1826
  return true;
1820
1827
  }
1821
- catch (_e) {
1828
+ catch {
1822
1829
  // URL constructor throws on malformed input — not a valid URL, return false
1823
1830
  return false;
1824
1831
  }