@bsv/overlay 2.2.1 → 2.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +377 -0
  19. package/dist/cjs/src/Engine.d.ts.map +1 -0
  20. package/dist/cjs/src/Engine.js +416 -417
  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 +394 -395
  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 +15 -0
  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
  */
@@ -36,8 +44,8 @@ class Engine {
36
44
  * @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
37
45
  */
38
46
  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
+ var _a, _b;
48
+ var _c;
41
49
  this.managers = managers;
42
50
  this.lookupServices = lookupServices;
43
51
  this.storage = storage;
@@ -59,12 +67,7 @@ class Engine {
59
67
  this.unprovenEvictionBlocks = unprovenEvictionBlocks;
60
68
  // 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
69
  // 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
- }
70
+ (_a = this.syncConfiguration) !== null && _a !== void 0 ? _a : (this.syncConfiguration = {});
68
71
  for (const managerName of Object.keys(managers)) {
69
72
  if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
70
73
  // Combine tm_ship trackers with preexisting entries if any
@@ -84,7 +87,7 @@ class Engine {
84
87
  }
85
88
  else {
86
89
  // Set undefined managers to 'SHIP' by default
87
- (_a = (_b = this.syncConfiguration)[managerName]) !== null && _a !== void 0 ? _a : (_b[managerName] = 'SHIP');
90
+ (_b = (_c = this.syncConfiguration)[managerName]) !== null && _b !== void 0 ? _b : (_c[managerName] = 'SHIP');
88
91
  }
89
92
  }
90
93
  }
@@ -124,7 +127,7 @@ class Engine {
124
127
  return header.blockHash;
125
128
  }
126
129
  catch (error) {
127
- this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
130
+ this.logger.warn(`Unable to resolve BASM block hash: height=${(0, SafeLog_js_1.serializeLogValue)(blockHeight)} error=${(0, SafeLog_js_1.serializeErrorForLog)(error)}`);
128
131
  return undefined;
129
132
  }
130
133
  }
@@ -232,7 +235,7 @@ class Engine {
232
235
  }
233
236
  if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
234
237
  // 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`);
238
+ 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
239
  toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
237
240
  }
238
241
  const previousAnchor = fromHeight > 0
@@ -246,7 +249,7 @@ class Engine {
246
249
  // canonical re-resolution from the header resolver instead of reusing it.
247
250
  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
251
  if (blockHash === undefined) {
249
- this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`);
252
+ 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
253
  return;
251
254
  }
252
255
  const basmRoot = (0, BASM_js_1.computeBasmRoot)(admitted);
@@ -375,386 +378,331 @@ class Engine {
375
378
  newTipHeight: tip
376
379
  });
377
380
  }
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);
381
+ async validateTopicSubmission(topic, context) {
382
+ const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
383
+ try {
384
+ if (this.managers[topic] === undefined || this.managers[topic] === null) {
385
+ throw new Error(`This server does not support this topic: ${topic}`);
386
+ }
387
+ this.startTime(`dupCheck_${txid.substring(0, 10)}`);
388
+ const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
389
+ this.endTime(`dupCheck_${txid.substring(0, 10)}`);
390
+ if (isDupe) {
391
+ dupeTopics.add(topic);
462
392
  return {
463
393
  topic,
464
- isDupe: false,
394
+ isDupe: true,
465
395
  previousCoins: [],
466
396
  previousOutputs: [],
467
397
  admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
468
398
  };
469
399
  }
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;
400
+ const previousCoins = [];
401
+ const outputPromises = tx.inputs.map(async (input, inputIndex) => {
402
+ var _a;
403
+ var _b;
404
+ const previousTXID = (_b = input.sourceTXID) !== null && _b !== void 0 ? _b : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex');
405
+ if (previousTXID === undefined)
406
+ return null;
407
+ const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
408
+ if (output !== undefined && output !== null)
409
+ previousCoins.push(inputIndex);
410
+ return output !== null && output !== void 0 ? output : null;
411
+ });
412
+ this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
413
+ const previousOutputs = await Promise.all(outputPromises);
414
+ this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
415
+ this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
416
+ const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
417
+ this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
418
+ return {
419
+ topic,
420
+ isDupe: false,
421
+ previousCoins,
422
+ previousOutputs,
423
+ admissibleOutputs
424
+ };
475
425
  }
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));
426
+ catch (error) {
427
+ this.logger.error(`Error validating topic during submit: topic=${(0, SafeLog_js_1.serializeLogValue)(topic)} error=${(0, SafeLog_js_1.serializeErrorForLog)(error)}`);
428
+ failedTopics.add(topic);
429
+ return {
430
+ topic,
431
+ isDupe: false,
432
+ previousCoins: [],
433
+ previousOutputs: [],
434
+ admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
435
+ };
436
+ }
437
+ }
438
+ isTopicSubmissionAccepted(validation, failedTopics) {
439
+ return !failedTopics.has(validation.topic) && (validation.isDupe ||
440
+ validation.admissibleOutputs.outputsToAdmit.length > 0 ||
441
+ validation.admissibleOutputs.coinsToRetain.length > 0 ||
442
+ validation.previousCoins.length > 0);
443
+ }
444
+ async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
493
445
  this.startTime(`broadcast_${txid.substring(0, 10)}`);
494
446
  if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
495
447
  try {
496
448
  let response;
497
449
  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
450
  const mp = tx.merklePath;
501
451
  const leaf = mp.path[0].find(leaf => leaf.hash === txid);
502
- const r = {
452
+ response = {
503
453
  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}`,
454
+ txid,
455
+ message: `In block at height ${mp.blockHeight} index ${leaf === null || leaf === void 0 ? void 0 : leaf.offset}`
506
456
  };
507
- response = r;
508
457
  }
509
458
  else {
510
459
  response = await this.broadcaster.broadcast(tx);
511
460
  }
512
461
  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;
462
+ const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
463
+ error.more = response.more;
464
+ throw error;
516
465
  }
517
466
  }
518
467
  catch (error) {
519
- if (this.throwOnBroadcastFailure) {
468
+ if (this.throwOnBroadcastFailure)
520
469
  throw error;
521
- }
522
470
  this.logger.error('Error broadcasting transaction:', error);
523
471
  }
524
472
  }
525
473
  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);
474
+ }
475
+ async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
476
+ var _a;
477
+ if (typeof lookupService.outputSpent !== 'function')
478
+ return;
479
+ if (lookupService.spendNotificationMode === 'txid') {
480
+ await lookupService.outputSpent({
481
+ mode: 'txid',
482
+ spendingTxid: txid,
483
+ txid: output.txid,
484
+ outputIndex: output.outputIndex,
485
+ topic
486
+ });
487
+ return;
529
488
  }
530
- // ===================================================================
531
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
532
- // ===================================================================
533
- // Mark previous outputs as spent and notify lookup services
489
+ if (lookupService.spendNotificationMode === 'script') {
490
+ const inputIndex = findSpendingInputIndex(tx, output);
491
+ if (inputIndex === -1)
492
+ throw new Error('Could not find input index');
493
+ await lookupService.outputSpent({
494
+ mode: 'script',
495
+ spendingTxid: txid,
496
+ inputIndex,
497
+ sequenceNumber: (_a = tx.inputs[inputIndex].sequence) !== null && _a !== void 0 ? _a : 0xffffffff,
498
+ unlockingScript: tx.inputs[inputIndex].unlockingScript,
499
+ txid: output.txid,
500
+ outputIndex: output.outputIndex,
501
+ topic,
502
+ offChainValues
503
+ });
504
+ return;
505
+ }
506
+ if (lookupService.spendNotificationMode === 'whole-tx') {
507
+ await lookupService.outputSpent({
508
+ mode: 'whole-tx',
509
+ spendingAtomicBEEF: tx.toAtomicBEEF(),
510
+ txid: output.txid,
511
+ outputIndex: output.outputIndex,
512
+ topic,
513
+ offChainValues
514
+ });
515
+ return;
516
+ }
517
+ await lookupService.outputSpent({
518
+ mode: 'none',
519
+ txid: output.txid,
520
+ outputIndex: output.outputIndex,
521
+ topic
522
+ });
523
+ }
524
+ async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
525
+ if (output === null)
526
+ return;
527
+ try {
528
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
529
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
530
+ try {
531
+ await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
532
+ }
533
+ catch (error) {
534
+ this.logger.error('Error in lookup service for outputSpent:', error);
535
+ }
536
+ }));
537
+ }
538
+ catch (error) {
539
+ this.logger.error('Error marking UTXO as spent:', error);
540
+ }
541
+ }
542
+ async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
534
543
  await Promise.all(validations.map(async (validation) => {
535
- if (validation.isDupe || failedTopics.has(validation.topic)) {
544
+ if (validation.isDupe || failedTopics.has(validation.topic))
536
545
  return;
546
+ await Promise.all(validation.previousOutputs.map(async (output) => {
547
+ await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
548
+ }));
549
+ }));
550
+ }
551
+ classifyPreviousCoins(tx, validation) {
552
+ var _a;
553
+ var _b;
554
+ const outputsConsumed = [];
555
+ const outputsToMarkStale = [];
556
+ for (const inputIndex of validation.previousCoins) {
557
+ const input = tx.inputs[inputIndex];
558
+ const previousTXID = (_b = input.sourceTXID) !== null && _b !== void 0 ? _b : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex');
559
+ if (typeof previousTXID !== 'string')
560
+ continue;
561
+ if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
562
+ outputsConsumed.push({
563
+ txid: previousTXID,
564
+ outputIndex: input.sourceOutputIndex
565
+ });
537
566
  }
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
- }
567
+ else {
568
+ outputsToMarkStale.push({
569
+ txid: previousTXID,
570
+ previousOutputIndex: input.sourceOutputIndex,
571
+ inputIndex
572
+ });
573
+ }
574
+ }
575
+ return { outputsConsumed, outputsToMarkStale };
576
+ }
577
+ async removeStaleOutputs(outputs, topic, txid) {
578
+ this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
579
+ await Promise.all(outputs.map(async (coin) => {
580
+ const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
581
+ if (output !== undefined && output !== null)
582
+ await this.deleteUTXODeep(output);
583
+ }));
584
+ this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
585
+ }
586
+ async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
587
+ if (lookupService.admissionMode === 'locking-script') {
588
+ if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
589
+ typeof tx.outputs[outputIndex].satoshis !== 'number')
590
+ return;
591
+ await lookupService.outputAdmittedByTopic({
592
+ mode: 'locking-script',
593
+ txid,
594
+ outputIndex,
595
+ lockingScript: tx.outputs[outputIndex].lockingScript,
596
+ satoshis: tx.outputs[outputIndex].satoshis,
597
+ topic,
598
+ offChainValues
599
+ });
600
+ return;
601
+ }
602
+ await lookupService.outputAdmittedByTopic({
603
+ mode: 'whole-tx',
604
+ atomicBEEF: tx.toAtomicBEEF(),
605
+ outputIndex,
606
+ topic,
607
+ offChainValues
608
+ });
609
+ }
610
+ async admitOutput(outputIndex, context) {
611
+ var _a;
612
+ const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
613
+ if (typeof tx.outputs[outputIndex].satoshis !== 'number')
614
+ return;
615
+ this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
616
+ await this.storage.insertOutput({
617
+ txid,
618
+ outputIndex,
619
+ outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
620
+ satoshis: tx.outputs[outputIndex].satoshis,
621
+ topic,
622
+ spent: false,
623
+ beef: this.compactBEEFForStorage(tx, beef),
624
+ consumedBy: [],
625
+ outputsConsumed,
626
+ score: Date.now(),
627
+ blockHeight: (_a = (0, BASM_js_1.extractMerkleProofMetadata)(txid, tx.merklePath)) === null || _a === void 0 ? void 0 : _a.blockHeight
628
+ });
629
+ this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
630
+ newUTXOs.push({ txid, outputIndex });
631
+ this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
632
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
633
+ try {
634
+ await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
635
+ }
636
+ catch (error) {
637
+ this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
638
+ }
639
+ }));
640
+ this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
641
+ }
642
+ async updateConsumedOutput(output, newUTXOs, topic) {
643
+ const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
644
+ if (storedOutput === undefined || storedOutput === null)
645
+ return;
646
+ const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
647
+ await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
648
+ }
649
+ async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
650
+ const topic = validation.topic;
651
+ const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
652
+ await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
653
+ steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
654
+ const newUTXOs = [];
655
+ await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
656
+ await this.admitOutput(outputIndex, {
657
+ tx,
658
+ txid,
659
+ beef,
660
+ topic,
661
+ outputsConsumed,
662
+ newUTXOs,
663
+ offChainValues
611
664
  });
612
- await Promise.all(markSpentPromises);
613
665
  }));
614
- // Continue with storage updates and lookup service notifications
666
+ this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
667
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx);
668
+ await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
669
+ await Promise.all([
670
+ ...outputsConsumed.map(async (output) => {
671
+ await this.updateConsumedOutput(output, newUTXOs, topic);
672
+ }),
673
+ this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
674
+ ]);
675
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
676
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
677
+ }
678
+ this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
679
+ }
680
+ async applyStorageMutations(validations, context) {
681
+ const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
615
682
  for (const validation of validations) {
616
683
  const topic = validation.topic;
617
- if (dupeTopics.has(topic)) {
618
- continue;
619
- }
620
- if (failedTopics.has(topic)) {
684
+ if (dupeTopics.has(topic) || failedTopics.has(topic))
621
685
  continue;
622
- }
623
686
  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)}`);
687
+ await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
739
688
  }
740
689
  catch (error) {
741
690
  this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
742
691
  }
743
692
  }
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
- }
693
+ }
694
+ async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
748
695
  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); });
696
+ const relevantTopics = taggedBEEF.topics.filter(topic => { var _a; return steak[topic] !== undefined &&
697
+ !dupeTopics.has(topic) &&
698
+ (steak[topic].outputsToAdmit.length !== 0 ||
699
+ ((_a = steak[topic].coinsRemoved) === null || _a === void 0 ? void 0 : _a.length) !== 0); });
750
700
  if (relevantTopics.length === 0) {
751
701
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
752
- return steak;
702
+ return;
753
703
  }
754
- // Create a SHIPBroadcaster instance
755
704
  let customBroadcasterConfig;
756
705
  if (Array.isArray(this.slapTrackers)) {
757
- // Custom SLAP trackers warrant a custom broadcaster config
758
706
  const resolverConfig = {
759
707
  slapTrackers: this.slapTrackers
760
708
  };
@@ -770,6 +718,93 @@ class Engine {
770
718
  this.logger.error('Error during propagation to other nodes:', error);
771
719
  }
772
720
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
721
+ }
722
+ /**
723
+ * Submits a transaction for processing by Overlay Services.
724
+ * @param {TaggedBEEF} taggedBEEF - The transaction to process
725
+ * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
726
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
727
+ * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
728
+ *
729
+ * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
730
+ *
731
+ * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
732
+ */
733
+ async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
734
+ for (const t of taggedBEEF.topics) {
735
+ if (this.managers[t] === undefined || this.managers[t] === null) {
736
+ throw new Error(`This server does not support this topic: ${t}`);
737
+ }
738
+ }
739
+ // Validate the transaction SPV information
740
+ const tx = sdk_1.Transaction.fromBEEF(taggedBEEF.beef);
741
+ const txid = tx.id('hex');
742
+ this.startTime(`submit_${txid}`);
743
+ if (mode !== 'historical-tx-no-spv') {
744
+ this.startTime(`chainTracker_${txid.substring(0, 10)}`);
745
+ const txValid = await tx.verify(this.chainTracker);
746
+ if (!txValid)
747
+ throw new Error('Unable to verify SPV information.');
748
+ this.endTime(`chainTracker_${txid.substring(0, 10)}`);
749
+ }
750
+ const steak = {};
751
+ const dupeTopics = new Set();
752
+ const failedTopics = new Set();
753
+ // ===================================================================
754
+ // PHASE 1: VALIDATE (read-only, no mutations)
755
+ // ===================================================================
756
+ const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
757
+ tx,
758
+ txid,
759
+ beef: taggedBEEF.beef,
760
+ offChainValues,
761
+ mode,
762
+ dupeTopics,
763
+ failedTopics
764
+ }));
765
+ const validations = await Promise.all(topicValidations);
766
+ // Build preliminary STEAK from validation results
767
+ for (const validation of validations) {
768
+ steak[validation.topic] = validation.admissibleOutputs;
769
+ }
770
+ // ===================================================================
771
+ // PHASE 2: BROADCAST (before any mutations)
772
+ // ===================================================================
773
+ // Only broadcast when at least one topic actually accepted the
774
+ // transaction. For a non-failed topic, acceptance means: previously
775
+ // accepted (dupe / client retry), outputs admitted, coins retained, or
776
+ // previously-admitted coins consumed (e.g. a consume-only deletion such
777
+ // as a KVStore remove, even one that retains nothing). A topic manager
778
+ // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
779
+ // failedTopics). A transaction every topic rejected must never reach the
780
+ // network: submitters treat an empty STEAK as a rejection and
781
+ // abort/release their held inputs, so broadcasting it anyway would
782
+ // desync their wallets from the chain.
783
+ const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
784
+ await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
785
+ // Call the callback function with STEAK if it is provided (before storage mutations)
786
+ if (onSteakReady !== undefined) {
787
+ onSteakReady(steak);
788
+ }
789
+ // ===================================================================
790
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
791
+ // ===================================================================
792
+ // Mark previous outputs as spent and notify lookup services
793
+ await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
794
+ await this.applyStorageMutations(validations, {
795
+ dupeTopics,
796
+ failedTopics,
797
+ steak,
798
+ tx,
799
+ txid,
800
+ beef: taggedBEEF.beef,
801
+ offChainValues
802
+ });
803
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
804
+ if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
805
+ return steak;
806
+ }
807
+ await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
773
808
  // Immediately return from the function without waiting for the promises to resolve.
774
809
  return steak;
775
810
  }
@@ -1003,72 +1038,37 @@ class Engine {
1003
1038
  throw new Error('Overlay Service Engine not configured for topical synchronization!');
1004
1039
  }
1005
1040
  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) {
1041
+ const configuredEndpoints = this.syncConfiguration[topic];
1042
+ if (configuredEndpoints === false)
1010
1043
  continue;
1044
+ if (!Array.isArray(configuredEndpoints) && configuredEndpoints !== 'SHIP')
1045
+ continue;
1046
+ const syncEndpoints = await this.resolveSyncEndpointsForTopic(topic, 'Failed to parse advertisement output:');
1047
+ this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
1048
+ // Sync with each endpoint sequentially to avoid parallel locks while
1049
+ // keeping peer failures isolated.
1050
+ for (const endpoint of syncEndpoints) {
1051
+ await this.syncGASPWithPeer(topic, endpoint);
1011
1052
  }
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
- }
1053
+ }
1054
+ }
1055
+ async syncGASPWithPeer(topic, endpoint) {
1056
+ this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
1057
+ try {
1058
+ const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
1059
+ 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);
1060
+ await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
1061
+ if (gasp.lastInteraction > lastInteraction) {
1062
+ await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
1068
1063
  }
1064
+ this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
1065
+ }
1066
+ catch (error) {
1067
+ this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
1068
+ // Continue on to the next endpoint without throwing.
1069
1069
  }
1070
1070
  }
1071
- async resolveSyncEndpointsForTopic(topic) {
1071
+ async resolveSyncEndpointsForTopic(topic, advertisementErrorMessage = 'Failed to parse BASM advertisement output:') {
1072
1072
  if (this.syncConfiguration === undefined) {
1073
1073
  return [];
1074
1074
  }
@@ -1099,7 +1099,7 @@ class Engine {
1099
1099
  }
1100
1100
  }
1101
1101
  catch (error) {
1102
- this.logger.error('Failed to parse BASM advertisement output:', error);
1102
+ this.logger.error(advertisementErrorMessage, error);
1103
1103
  }
1104
1104
  });
1105
1105
  }
@@ -1259,7 +1259,8 @@ class Engine {
1259
1259
  }
1260
1260
  }
1261
1261
  async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
1262
- var _a, _b, _c, _d, _f;
1262
+ var _a, _b, _c, _d;
1263
+ var _e;
1263
1264
  report.checkedHeights.push(remoteAnchor.blockHeight);
1264
1265
  const localAnchor = await ((_b = (_a = this.storage).findTopicBlockAnchor) === null || _b === void 0 ? void 0 : _b.call(_a, topic, remoteAnchor.blockHeight, remoteAnchor.blockHash));
1265
1266
  if ((localAnchor === null || localAnchor === void 0 ? void 0 : localAnchor.tac) === remoteAnchor.tac) {
@@ -1271,7 +1272,7 @@ class Engine {
1271
1272
  admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
1272
1273
  throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
1273
1274
  }
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 : [];
1275
+ 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
1276
  const localTxids = new Set(localAdmitted.map(item => item.txid));
1276
1277
  const missingTxids = admittedResponse.admitted
1277
1278
  .map(item => item.txid)
@@ -1315,14 +1316,15 @@ class Engine {
1315
1316
  }
1316
1317
  }
1317
1318
  async evictUnprovenTransactions(options = {}) {
1318
- var _a, _b, _c;
1319
+ var _a, _b;
1320
+ var _c;
1319
1321
  if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1320
1322
  throw new TypeError('Storage does not support unproven transaction eviction');
1321
1323
  }
1322
1324
  if (this.chainTracker === 'scripts only') {
1323
1325
  throw new Error('Unproven eviction requires a ChainTracker to determine block age');
1324
1326
  }
1325
- const thresholdBlocks = (_a = options.thresholdBlocks) !== null && _a !== void 0 ? _a : this.unprovenEvictionBlocks;
1327
+ const thresholdBlocks = (_c = options.thresholdBlocks) !== null && _c !== void 0 ? _c : this.unprovenEvictionBlocks;
1326
1328
  const currentHeight = await this.chainTracker.currentHeight();
1327
1329
  const cutoffHeight = currentHeight - thresholdBlocks;
1328
1330
  const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
@@ -1340,7 +1342,7 @@ class Engine {
1340
1342
  await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
1341
1343
  evictedOutputs++;
1342
1344
  }
1343
- await ((_c = (_b = this.storage).deleteAppliedTransaction) === null || _c === void 0 ? void 0 : _c.call(_b, candidate.txid, candidate.topic));
1345
+ await ((_b = (_a = this.storage).deleteAppliedTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, candidate.txid, candidate.topic));
1344
1346
  }
1345
1347
  return {
1346
1348
  cutoffHeight,
@@ -1447,14 +1449,11 @@ class Engine {
1447
1449
  async provideForeignSyncResponse(initialRequest, topic) {
1448
1450
  const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, initialRequest.limit);
1449
1451
  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
- }),
1452
+ UTXOList: outputs.map(output => { var _a; return ({
1453
+ txid: output.txid,
1454
+ outputIndex: output.outputIndex,
1455
+ score: (_a = output.score) !== null && _a !== void 0 ? _a : 0
1456
+ }); }),
1458
1457
  since: initialRequest.since
1459
1458
  };
1460
1459
  }
@@ -1818,7 +1817,7 @@ class Engine {
1818
1817
  // If none of the disallowed conditions matched, the URL is valid
1819
1818
  return true;
1820
1819
  }
1821
- catch (_e) {
1820
+ catch {
1822
1821
  // URL constructor throws on malformed input — not a valid URL, return false
1823
1822
  return false;
1824
1823
  }