@bsv/overlay 2.0.3 → 2.0.4

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 (86) hide show
  1. package/README.md +226 -106
  2. package/dist/cjs/mod.js +6 -1
  3. package/dist/cjs/mod.js.map +1 -1
  4. package/dist/cjs/package.json +13 -11
  5. package/dist/cjs/src/BASM.js +92 -0
  6. package/dist/cjs/src/BASM.js.map +1 -0
  7. package/dist/cjs/src/BASMRemote.js +43 -0
  8. package/dist/cjs/src/BASMRemote.js.map +1 -0
  9. package/dist/cjs/src/Engine.js +649 -47
  10. package/dist/cjs/src/Engine.js.map +1 -1
  11. package/dist/cjs/src/GASP/OverlayGASPRemote.js +2 -2
  12. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  13. package/dist/cjs/src/GASP/OverlayGASPStorage.js +18 -25
  14. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  15. package/dist/cjs/src/storage/knex/KnexStorage.js +329 -12
  16. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  17. package/dist/cjs/src/storage/knex/all-migrations.js +3 -1
  18. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  19. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
  20. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  21. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +114 -0
  22. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
  23. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  24. package/dist/esm/mod.js +1 -0
  25. package/dist/esm/mod.js.map +1 -1
  26. package/dist/esm/src/BASM.js +85 -0
  27. package/dist/esm/src/BASM.js.map +1 -0
  28. package/dist/esm/src/BASMRemote.js +42 -0
  29. package/dist/esm/src/BASMRemote.js.map +1 -0
  30. package/dist/esm/src/Engine.js +637 -46
  31. package/dist/esm/src/Engine.js.map +1 -1
  32. package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
  33. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  34. package/dist/esm/src/GASP/OverlayGASPStorage.js +18 -25
  35. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  36. package/dist/esm/src/storage/knex/KnexStorage.js +323 -12
  37. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  38. package/dist/esm/src/storage/knex/all-migrations.js +3 -1
  39. package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
  40. package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
  41. package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  42. package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +109 -0
  43. package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
  44. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  45. package/dist/types/mod.d.ts +2 -0
  46. package/dist/types/mod.d.ts.map +1 -1
  47. package/dist/types/src/BASM.d.ts +110 -0
  48. package/dist/types/src/BASM.d.ts.map +1 -0
  49. package/dist/types/src/BASMRemote.d.ts +14 -0
  50. package/dist/types/src/BASMRemote.d.ts.map +1 -0
  51. package/dist/types/src/Engine.d.ts +78 -1
  52. package/dist/types/src/Engine.d.ts.map +1 -1
  53. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +1 -1
  54. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  55. package/dist/types/src/LookupService.d.ts.map +1 -1
  56. package/dist/types/src/storage/Storage.d.ts +118 -0
  57. package/dist/types/src/storage/Storage.d.ts.map +1 -1
  58. package/dist/types/src/storage/knex/KnexStorage.d.ts +38 -6
  59. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  60. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  61. package/dist/types/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -1
  62. package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
  63. package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
  64. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  65. package/docs/API.md +72 -580
  66. package/docs/BRC-136-BASM.md +285 -0
  67. package/docs/README.md +2 -0
  68. package/docs/Synchronization.md +10 -1
  69. package/docs/examples/README.md +2 -2
  70. package/docs/examples/gs-wip.md +68 -76
  71. package/mod.ts +25 -1
  72. package/package.json +25 -23
  73. package/src/BASM.ts +208 -0
  74. package/src/BASMRemote.ts +54 -0
  75. package/src/Engine.ts +762 -47
  76. package/src/GASP/OverlayGASPRemote.ts +2 -2
  77. package/src/GASP/OverlayGASPStorage.ts +17 -22
  78. package/src/LookupService.ts +2 -1
  79. package/src/__tests/BASM.test.ts +44 -0
  80. package/src/__tests/BASMChain.test.ts +290 -0
  81. package/src/__tests/Engine.test.ts +3 -4
  82. package/src/storage/Storage.ts +127 -0
  83. package/src/storage/knex/KnexStorage.ts +376 -14
  84. package/src/storage/knex/all-migrations.ts +3 -1
  85. package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +2 -22
  86. package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +123 -0
@@ -1,8 +1,11 @@
1
- import { Transaction, isBroadcastFailure, SHIPBroadcaster, HTTPSOverlayBroadcastFacilitator, LookupResolver } from '@bsv/sdk';
1
+ import { Transaction, MerklePath, isBroadcastFailure, SHIPBroadcaster, HTTPSOverlayBroadcastFacilitator, LookupResolver } from '@bsv/sdk';
2
2
  import { GASP } from '@bsv/gasp';
3
3
  import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js';
4
4
  import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js';
5
+ import { BASM_ZERO_HASH, computeBasmRoot, computeTac, extractMerkleProofMetadata } from './BASM.js';
6
+ import { BASMRemote } from './BASMRemote.js';
5
7
  const DEFAULT_GASP_SYNC_LIMIT = 10000;
8
+ const DEFAULT_BASM_RANGE_LIMIT = 1024;
6
9
  /**
7
10
  * An engine for running BSV Overlay Services (topic managers and lookup services).
8
11
  */
@@ -23,6 +26,9 @@ export class Engine {
23
26
  overlayBroadcastFacilitator;
24
27
  logger;
25
28
  suppressDefaultSyncAdvertisements;
29
+ topicAnchorHeaderResolver;
30
+ basmSyncEnabled;
31
+ unprovenEvictionBlocks;
26
32
  /**
27
33
  * Creates a new Overlay Services Engine
28
34
  * @param {[key: string]: TopicManager} managers - manages topic admittance
@@ -41,8 +47,11 @@ export class Engine {
41
47
  * @param {OverlayBroadcastFacilitator} overlayBroadcastFacilitator - Facilitator for propagation to other Overlay Services.
42
48
  * @param {typeof console} logger - The place where log entries are written.
43
49
  * @param {boolean} suppressDefaultSyncAdvertisements - Whether to suppress the default (SHIP/SLAP) sync advertisements.
50
+ * @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
51
+ * @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
52
+ * @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
44
53
  */
45
- constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true) {
54
+ constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration, logTime = false, logPrefix = '[OVERLAY_ENGINE] ', throwOnBroadcastFailure = false, overlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(), logger = console, suppressDefaultSyncAdvertisements = true, topicAnchorHeaderResolver, basmSyncEnabled = false, unprovenEvictionBlocks = 144) {
46
55
  this.managers = managers;
47
56
  this.lookupServices = lookupServices;
48
57
  this.storage = storage;
@@ -59,6 +68,9 @@ export class Engine {
59
68
  this.overlayBroadcastFacilitator = overlayBroadcastFacilitator;
60
69
  this.logger = logger;
61
70
  this.suppressDefaultSyncAdvertisements = suppressDefaultSyncAdvertisements;
71
+ this.topicAnchorHeaderResolver = topicAnchorHeaderResolver;
72
+ this.basmSyncEnabled = basmSyncEnabled;
73
+ this.unprovenEvictionBlocks = unprovenEvictionBlocks;
62
74
  // 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'.
63
75
  // For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
64
76
  if (syncConfiguration === undefined) {
@@ -86,9 +98,7 @@ export class Engine {
86
98
  }
87
99
  else {
88
100
  // Set undefined managers to 'SHIP' by default
89
- if (this.syncConfiguration[managerName] === undefined) {
90
- this.syncConfiguration[managerName] = 'SHIP';
91
- }
101
+ this.syncConfiguration[managerName] ??= 'SHIP';
92
102
  }
93
103
  }
94
104
  }
@@ -103,6 +113,276 @@ export class Engine {
103
113
  this.logger.timeEnd(`${this.logPrefix} ${label}`);
104
114
  }
105
115
  }
116
+ async currentHeightOrUndefined() {
117
+ if (this.chainTracker === 'scripts only') {
118
+ return undefined;
119
+ }
120
+ try {
121
+ return await this.chainTracker.currentHeight();
122
+ }
123
+ catch (error) {
124
+ this.logger.warn(`Unable to resolve current chain height for overlay metadata: ${error instanceof Error ? error.message : String(error)}`);
125
+ return undefined;
126
+ }
127
+ }
128
+ async resolveBlockHash(blockHeight, merkleRoot) {
129
+ try {
130
+ const header = await this.topicAnchorHeaderResolver?.(blockHeight);
131
+ if (header === undefined) {
132
+ return undefined;
133
+ }
134
+ if (header.merkleRoot !== undefined && merkleRoot !== undefined && header.merkleRoot !== merkleRoot) {
135
+ throw new Error(`Header merkle root ${header.merkleRoot} does not match proof root ${merkleRoot} at height ${blockHeight}`);
136
+ }
137
+ return header.blockHash;
138
+ }
139
+ catch (error) {
140
+ this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
141
+ return undefined;
142
+ }
143
+ }
144
+ compactBEEFForStorage(tx, originalBEEF) {
145
+ return tx.merklePath === undefined ? originalBEEF : tx.toAtomicBEEF();
146
+ }
147
+ async recordTransactionData(tx, beef, blockHash) {
148
+ if (typeof this.storage.upsertTransactionRecord !== 'function') {
149
+ return;
150
+ }
151
+ const txid = tx.id('hex');
152
+ const metadata = extractMerkleProofMetadata(txid, tx.merklePath);
153
+ await this.storage.upsertTransactionRecord({
154
+ txid,
155
+ beef: this.compactBEEFForStorage(tx, beef),
156
+ rawTx: Array.from(tx.toBinary()),
157
+ merklePath: tx.merklePath?.toBinary(),
158
+ blockHeight: metadata?.blockHeight,
159
+ blockHash,
160
+ blockIndex: metadata?.blockIndex,
161
+ merkleRoot: metadata?.merkleRoot
162
+ });
163
+ }
164
+ async buildAppliedTransactionRecord(tx) {
165
+ const txid = tx.id('hex');
166
+ const metadata = extractMerkleProofMetadata(txid, tx.merklePath);
167
+ const [firstSeenHeight, blockHash] = await Promise.all([
168
+ this.currentHeightOrUndefined(),
169
+ metadata === undefined ? undefined : this.resolveBlockHash(metadata.blockHeight, metadata.merkleRoot)
170
+ ]);
171
+ return {
172
+ blockHeight: metadata?.blockHeight,
173
+ blockHash,
174
+ blockIndex: metadata?.blockIndex,
175
+ merkleRoot: metadata?.merkleRoot,
176
+ firstSeenHeight: firstSeenHeight ?? metadata?.blockHeight,
177
+ proven: metadata !== undefined
178
+ };
179
+ }
180
+ async recomputeTopicBlockAnchor(topic, blockHeight, blockHash) {
181
+ if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
182
+ typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
183
+ typeof this.storage.findTopicBlockAnchor !== 'function') {
184
+ return undefined;
185
+ }
186
+ const anchorBlockHash = blockHash ?? (await this.storage.findTopicBlockAnchor(topic, blockHeight))?.blockHash;
187
+ if (anchorBlockHash === undefined) {
188
+ return undefined;
189
+ }
190
+ // BRC-136 per-block completeness: establish the chain's genesis at the first
191
+ // admitted height, then keep every height from there to the tip contiguous so
192
+ // the cumulative TAC never resets across blocks with no admitted transactions.
193
+ // We rebuild [fromHeight, toHeight] rather than only the touched height so that
194
+ // an out-of-order proof (older height arriving after a newer one) can never
195
+ // leave a gap that silently breaks the chain.
196
+ const tip = await this.storage.findTopicAnchorTip?.(topic);
197
+ const tipHeight = tip !== undefined && tip.blockHeight >= 0 ? tip.blockHeight : undefined;
198
+ const fromHeight = tipHeight === undefined ? blockHeight : Math.min(blockHeight, tipHeight + 1);
199
+ const toHeight = tipHeight === undefined ? blockHeight : Math.max(blockHeight, tipHeight);
200
+ await this.rebuildTopicAnchorChain(topic, fromHeight, toHeight, new Map([[blockHeight, anchorBlockHash]]));
201
+ return await this.storage.findTopicBlockAnchor(topic, blockHeight);
202
+ }
203
+ /**
204
+ * Extends every configured topic's anchor chain forward with empty Topic Block
205
+ * Anchors (basmRoot = zero hash, admittedCount = 0) up to `toHeight`, so the
206
+ * cumulative TAC advances on every block even when a topic admits nothing —
207
+ * this is what lets a peer authoritatively confirm "this block contained no
208
+ * transactions for this topic". Chains with no first admission yet are left
209
+ * unstarted (genesis is the topic's first admitted height).
210
+ */
211
+ async advanceTopicAnchorChains(toHeight) {
212
+ if (typeof this.storage.findTopicAnchorTip !== 'function' ||
213
+ typeof this.storage.upsertTopicBlockAnchor !== 'function') {
214
+ return;
215
+ }
216
+ const targetHeight = toHeight ?? await this.currentHeightOrUndefined();
217
+ if (targetHeight === undefined) {
218
+ return;
219
+ }
220
+ for (const topic of Object.keys(this.managers)) {
221
+ const tip = await this.storage.findTopicAnchorTip(topic);
222
+ if (tip === undefined || tip.blockHeight < 0 || tip.blockHeight >= targetHeight) {
223
+ continue;
224
+ }
225
+ await this.rebuildTopicAnchorChain(topic, tip.blockHeight + 1, targetHeight);
226
+ }
227
+ }
228
+ /**
229
+ * Rebuilds a contiguous slice of a topic's anchor chain over [fromHeight,
230
+ * toHeight]. Each height uses its admitted transactions (empty -> zero basmRoot)
231
+ * and chains the cumulative TAC from the prior height. Missing heights are
232
+ * filled rather than skipped, so the chain stays gap-free. If a block hash
233
+ * cannot be resolved for some height the extension halts there to preserve
234
+ * contiguity instead of leaving a hole.
235
+ */
236
+ async rebuildTopicAnchorChain(topic, fromHeight, toHeight, blockHashHints = new Map(), forceResolve = false) {
237
+ if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
238
+ typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
239
+ typeof this.storage.findTopicBlockAnchor !== 'function' ||
240
+ toHeight < fromHeight) {
241
+ return;
242
+ }
243
+ if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
244
+ // Bound the work per pass; the next trigger resumes from the new tip.
245
+ this.logger.warn(`[BASM] capping anchor chain extension for "${topic}" at ${DEFAULT_BASM_RANGE_LIMIT} blocks (requested ${fromHeight}..${toHeight}); will continue on the next pass`);
246
+ toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
247
+ }
248
+ const previousAnchor = fromHeight > 0
249
+ ? await this.storage.findTopicBlockAnchor(topic, fromHeight - 1)
250
+ : undefined;
251
+ let prevTac = previousAnchor?.tac ?? BASM_ZERO_HASH;
252
+ for (let height = fromHeight; height <= toHeight; height++) {
253
+ const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height);
254
+ const existing = await this.storage.findTopicBlockAnchor(topic, height);
255
+ // On a reorg rebuild the existing anchor's block hash is stale, so force
256
+ // canonical re-resolution from the header resolver instead of reusing it.
257
+ const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
258
+ if (blockHash === undefined) {
259
+ this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`);
260
+ return;
261
+ }
262
+ const basmRoot = computeBasmRoot(admitted);
263
+ const tac = computeTac(prevTac, blockHash, basmRoot);
264
+ await this.storage.upsertTopicBlockAnchor({
265
+ topic,
266
+ blockHeight: height,
267
+ blockHash,
268
+ basmRoot,
269
+ admittedCount: admitted.length,
270
+ tac
271
+ });
272
+ prevTac = tac;
273
+ }
274
+ }
275
+ /**
276
+ * Reconciles BASM anchors with a blockchain reorganization reported by the
277
+ * chain tracker (e.g. go-chaintracks `/v2/reorg/stream`). Proven topic
278
+ * transactions whose block was orphaned are demoted to unproven so they leave
279
+ * the admitted set, then every topic anchor chain intersecting the affected
280
+ * height range is rebuilt over the canonical block hashes. A reorg changes the
281
+ * canonical block hash for the affected heights, so topics with no demoted
282
+ * transaction are rebuilt too. Idempotent: a clean window demotes nothing and
283
+ * reproduces an identical TAC, so this is safe to invoke on every reorg event,
284
+ * SSE reconnect, and poll.
285
+ */
286
+ async handleReorg(input) {
287
+ const report = { perTopic: [] };
288
+ if (typeof this.storage.findProvenAppliedTransactionsByBlockHash !== 'function' ||
289
+ typeof this.storage.demoteAppliedTransactionToUnproven !== 'function' ||
290
+ typeof this.storage.findTopicBlockAnchors !== 'function' ||
291
+ typeof this.storage.upsertTopicBlockAnchor !== 'function') {
292
+ return report;
293
+ }
294
+ // 1) Demote proven admissions whose block was orphaned. Hashes are
295
+ // normalized to lower-case display hex to match stored block hashes
296
+ // (go-sdk chainhash.Hash marshals as reversed display hex).
297
+ const demotedByTopic = new Map();
298
+ for (const rawHash of input.orphanedBlockHashes) {
299
+ const blockHash = rawHash.toLowerCase();
300
+ const rows = await this.storage.findProvenAppliedTransactionsByBlockHash(blockHash);
301
+ for (const row of rows) {
302
+ await this.storage.demoteAppliedTransactionToUnproven(row.txid, row.topic);
303
+ const list = demotedByTopic.get(row.topic) ?? [];
304
+ list.push(row.txid);
305
+ demotedByTopic.set(row.topic, list);
306
+ }
307
+ }
308
+ // 2) Rebuild every topic anchor chain that intersects the reorged range,
309
+ // forcing canonical block-hash re-resolution so stale hashes are replaced.
310
+ for (const topic of Object.keys(this.managers)) {
311
+ const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight);
312
+ if (existing.length === 0) {
313
+ continue;
314
+ }
315
+ const startHeight = Math.min(...existing.map(anchor => anchor.blockHeight));
316
+ await this.rebuildTopicAnchorChain(topic, startHeight, input.newTipHeight, new Map(), true);
317
+ report.perTopic.push({
318
+ topic,
319
+ demotedTxids: demotedByTopic.get(topic) ?? [],
320
+ rebuiltFrom: startHeight,
321
+ rebuiltTo: input.newTipHeight
322
+ });
323
+ }
324
+ return report;
325
+ }
326
+ /**
327
+ * Revalidation sweep: the reorg fallback for chain trackers without a reorg
328
+ * event stream, and the catch-up step on every reorg-SSE (re)connect (the
329
+ * go-chaintracks reorg stream carries no event ids, so a reconnect cannot
330
+ * replay events missed while disconnected). Scans proven applied transactions
331
+ * in `[tip - depth + 1, tip]`; any whose proof root no longer validates against
332
+ * the chain tracker, or whose block hash diverges from the canonical header, is
333
+ * treated as orphaned and reconciled via {@link handleReorg}.
334
+ */
335
+ async isProvenAnchorStale(row, chainTracker) {
336
+ if (row.merkleRoot !== undefined) {
337
+ try {
338
+ if (!(await chainTracker.isValidRootForHeight(row.merkleRoot, row.blockHeight))) {
339
+ return true;
340
+ }
341
+ }
342
+ catch (error) {
343
+ this.logger.warn(`[BASM] root validation failed for ${row.txid} at height ${row.blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
344
+ return undefined;
345
+ }
346
+ }
347
+ const canonical = await this.resolveBlockHash(row.blockHeight);
348
+ return canonical !== undefined && canonical.toLowerCase() !== row.blockHash?.toLowerCase();
349
+ }
350
+ async revalidateRecentAnchors(depth = 3) {
351
+ const chainTracker = this.chainTracker;
352
+ if (chainTracker === 'scripts only') {
353
+ this.logger.warn('[BASM] revalidation sweep requires a ChainTracker; skipping');
354
+ return undefined;
355
+ }
356
+ if (typeof this.storage.findProvenAppliedTransactionsInRange !== 'function') {
357
+ return undefined;
358
+ }
359
+ const tip = await this.currentHeightOrUndefined();
360
+ if (tip === undefined) {
361
+ return undefined;
362
+ }
363
+ const fromHeight = Math.max(0, tip - depth + 1);
364
+ const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip);
365
+ const orphaned = new Set();
366
+ let minAffected = Number.POSITIVE_INFINITY;
367
+ for (const row of rows) {
368
+ if (row.blockHash === undefined) {
369
+ continue;
370
+ }
371
+ const stale = await this.isProvenAnchorStale(row, chainTracker);
372
+ if (stale === true) {
373
+ orphaned.add(row.blockHash.toLowerCase());
374
+ minAffected = Math.min(minAffected, row.blockHeight);
375
+ }
376
+ }
377
+ if (orphaned.size === 0) {
378
+ return { perTopic: [] };
379
+ }
380
+ return await this.handleReorg({
381
+ orphanedBlockHashes: Array.from(orphaned),
382
+ rebuildFromHeight: minAffected,
383
+ newTipHeight: tip
384
+ });
385
+ }
106
386
  /**
107
387
  * Submits a transaction for processing by Overlay Services.
108
388
  * @param {TaggedBEEF} taggedBEEF - The transaction to process
@@ -156,7 +436,7 @@ export class Engine {
156
436
  // Identify previous coins admitted to this specific topic
157
437
  const previousCoins = [];
158
438
  const outputPromises = tx.inputs.map(async (input, i) => {
159
- const previousTXID = input.sourceTXID !== undefined ? input.sourceTXID : input.sourceTransaction?.id('hex');
439
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
160
440
  if (previousTXID !== undefined) {
161
441
  // Check if the previous output was admitted to this specific topic
162
442
  const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
@@ -343,17 +623,17 @@ export class Engine {
343
623
  if (typeof previousTXID !== 'string')
344
624
  continue;
345
625
  const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex;
346
- if (!admissibleOutputs.coinsToRetain.includes(inputIndex)) {
347
- outputsToMarkStale.push({
626
+ if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
627
+ outputsConsumed.push({
348
628
  txid: previousTXID,
349
- previousOutputIndex,
350
- inputIndex
629
+ outputIndex: previousOutputIndex
351
630
  });
352
631
  }
353
632
  else {
354
- outputsConsumed.push({
633
+ outputsToMarkStale.push({
355
634
  txid: previousTXID,
356
- outputIndex: previousOutputIndex
635
+ previousOutputIndex,
636
+ inputIndex
357
637
  });
358
638
  }
359
639
  }
@@ -381,10 +661,11 @@ export class Engine {
381
661
  satoshis: tx.outputs[outputIndex].satoshis,
382
662
  topic,
383
663
  spent: false,
384
- beef: taggedBEEF.beef,
664
+ beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
385
665
  consumedBy: [],
386
666
  outputsConsumed,
387
- score: Date.now()
667
+ score: Date.now(),
668
+ blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
388
669
  });
389
670
  this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
390
671
  newUTXOs.push({ txid, outputIndex });
@@ -424,6 +705,8 @@ export class Engine {
424
705
  }));
425
706
  this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
426
707
  // Update each output consumed to know who consumed it and insert applied transaction in parallel
708
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx);
709
+ await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash);
427
710
  await Promise.all([
428
711
  ...outputsConsumed.map(async (output) => {
429
712
  const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic);
@@ -434,9 +717,13 @@ export class Engine {
434
717
  }),
435
718
  this.storage.insertAppliedTransaction({
436
719
  txid,
437
- topic
720
+ topic,
721
+ ...appliedRecord
438
722
  })
439
723
  ]);
724
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
725
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
726
+ }
440
727
  this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
441
728
  }
442
729
  catch (error) {
@@ -729,7 +1016,7 @@ export class Engine {
729
1016
  // Parse out the advertisements using the provided parser
730
1017
  const tx = Transaction.fromBEEF(output.beef);
731
1018
  const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
732
- if (advertisement !== undefined && advertisement !== null && advertisement.protocol === 'SHIP') {
1019
+ if (advertisement?.protocol === 'SHIP') {
733
1020
  endpointSet.add(advertisement.domain);
734
1021
  }
735
1022
  }
@@ -767,6 +1054,282 @@ export class Engine {
767
1054
  }
768
1055
  }
769
1056
  }
1057
+ async resolveSyncEndpointsForTopic(topic) {
1058
+ if (this.syncConfiguration === undefined) {
1059
+ return [];
1060
+ }
1061
+ let syncEndpoints = this.syncConfiguration[topic];
1062
+ if (syncEndpoints === false || syncEndpoints === undefined) {
1063
+ return [];
1064
+ }
1065
+ if (syncEndpoints === 'SHIP') {
1066
+ const resolverConfig = this.slapTrackers
1067
+ ? { slapTrackers: this.slapTrackers }
1068
+ : {};
1069
+ const resolver = new LookupResolver(resolverConfig);
1070
+ const lookupAnswer = await resolver.query({
1071
+ service: 'ls_ship',
1072
+ query: {
1073
+ topics: [topic]
1074
+ }
1075
+ });
1076
+ const endpointSet = new Set();
1077
+ if (lookupAnswer.type === 'output-list') {
1078
+ lookupAnswer.outputs.forEach(output => {
1079
+ try {
1080
+ const tx = Transaction.fromBEEF(output.beef);
1081
+ const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
1082
+ if (advertisement?.protocol === 'SHIP') {
1083
+ endpointSet.add(advertisement.domain);
1084
+ }
1085
+ }
1086
+ catch (error) {
1087
+ this.logger.error('Failed to parse BASM advertisement output:', error);
1088
+ }
1089
+ });
1090
+ }
1091
+ syncEndpoints = Array.from(endpointSet);
1092
+ }
1093
+ if (!Array.isArray(syncEndpoints)) {
1094
+ return [];
1095
+ }
1096
+ return syncEndpoints.filter(endpoint => endpoint !== this.hostingURL);
1097
+ }
1098
+ async provideTopicAnchorTip(topic) {
1099
+ const tip = await this.storage.findTopicAnchorTip?.(topic);
1100
+ return tip ?? {
1101
+ topic,
1102
+ blockHeight: -1,
1103
+ tac: BASM_ZERO_HASH
1104
+ };
1105
+ }
1106
+ async provideTopicAnchorRange(topic, fromHeight, toHeight) {
1107
+ if (typeof this.storage.findTopicBlockAnchors !== 'function') {
1108
+ throw new TypeError('Storage does not support BASM topic anchor ranges');
1109
+ }
1110
+ if (!Number.isInteger(fromHeight) || !Number.isInteger(toHeight) || fromHeight < 0 || toHeight < fromHeight) {
1111
+ throw new Error('Invalid topic anchor range');
1112
+ }
1113
+ if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
1114
+ throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`);
1115
+ }
1116
+ return {
1117
+ topic,
1118
+ anchors: await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight)
1119
+ };
1120
+ }
1121
+ async provideAdmittedList(topic, blockHeight, blockHash) {
1122
+ if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
1123
+ throw new TypeError('Storage does not support BASM admitted lists');
1124
+ }
1125
+ return {
1126
+ topic,
1127
+ blockHeight,
1128
+ blockHash,
1129
+ admitted: await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash)
1130
+ };
1131
+ }
1132
+ async provideCompoundMerklePath(topic, blockHeight, txids) {
1133
+ if (typeof this.storage.findTransactionMerklePaths !== 'function') {
1134
+ throw new TypeError('Storage does not support direct Merkle path lookup');
1135
+ }
1136
+ if (txids.length === 0) {
1137
+ throw new Error('At least one txid is required');
1138
+ }
1139
+ const admitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, blockHeight);
1140
+ if (admitted !== undefined) {
1141
+ const admittedSet = new Set(admitted.map(item => item.txid));
1142
+ const missingAdmissions = txids.filter(txid => !admittedSet.has(txid));
1143
+ if (missingAdmissions.length > 0) {
1144
+ throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`);
1145
+ }
1146
+ }
1147
+ const proofs = await this.storage.findTransactionMerklePaths(txids);
1148
+ const proofByTxid = new Map(proofs.map(proof => [proof.txid, proof]));
1149
+ const missing = txids.filter(txid => !proofByTxid.has(txid));
1150
+ if (missing.length > 0) {
1151
+ throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`);
1152
+ }
1153
+ let compound;
1154
+ for (const txid of txids) {
1155
+ const proof = proofByTxid.get(txid);
1156
+ if (proof === undefined)
1157
+ continue;
1158
+ const path = MerklePath.fromHex(proof.merklePath);
1159
+ if (path.blockHeight !== blockHeight) {
1160
+ throw new Error(`Merkle path for ${txid} is at height ${path.blockHeight}, expected ${blockHeight}`);
1161
+ }
1162
+ if (compound === undefined) {
1163
+ compound = path;
1164
+ }
1165
+ else {
1166
+ compound.combine(path);
1167
+ }
1168
+ }
1169
+ if (compound === undefined) {
1170
+ throw new Error('Unable to build compound Merkle path');
1171
+ }
1172
+ return {
1173
+ topic,
1174
+ blockHeight,
1175
+ txids,
1176
+ merklePath: compound.toHex()
1177
+ };
1178
+ }
1179
+ async provideRawTransactions(txids) {
1180
+ if (typeof this.storage.findRawTransactions !== 'function') {
1181
+ throw new TypeError('Storage does not support raw transaction lookup');
1182
+ }
1183
+ const transactions = await this.storage.findRawTransactions(txids);
1184
+ const found = new Set(transactions.map(tx => tx.txid));
1185
+ return {
1186
+ transactions,
1187
+ missing: txids.filter(txid => !found.has(txid))
1188
+ };
1189
+ }
1190
+ async startBASMSync() {
1191
+ if (this.syncConfiguration === undefined) {
1192
+ throw new Error('Overlay Service Engine not configured for topical synchronization!');
1193
+ }
1194
+ const reports = [];
1195
+ for (const topic of Object.keys(this.syncConfiguration)) {
1196
+ const endpoints = await this.resolveSyncEndpointsForTopic(topic);
1197
+ for (const endpoint of endpoints) {
1198
+ reports.push(await this.reconcileBASMWithPeer(topic, endpoint));
1199
+ }
1200
+ }
1201
+ return reports;
1202
+ }
1203
+ async reconcileBASMWithPeer(topic, endpoint) {
1204
+ const report = {
1205
+ topic,
1206
+ endpoint,
1207
+ status: 'skipped',
1208
+ checkedHeights: [],
1209
+ missingTxids: [],
1210
+ fetchedTxCount: 0
1211
+ };
1212
+ try {
1213
+ const remote = new BASMRemote(endpoint, topic);
1214
+ const [localTip, remoteTip] = await Promise.all([
1215
+ this.provideTopicAnchorTip(topic),
1216
+ remote.requestTopicAnchorTip()
1217
+ ]);
1218
+ report.localTip = localTip;
1219
+ report.remoteTip = remoteTip;
1220
+ if (localTip.blockHeight >= remoteTip.blockHeight) {
1221
+ report.status = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight ? 'matched' : 'diverged';
1222
+ report.message = report.status === 'matched'
1223
+ ? 'Topic anchor tips match'
1224
+ : 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation';
1225
+ return report;
1226
+ }
1227
+ const fromHeight = Math.max(localTip.blockHeight + 1, remoteTip.blockHeight - DEFAULT_BASM_RANGE_LIMIT + 1, 0);
1228
+ const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight);
1229
+ for (const remoteAnchor of range.anchors) {
1230
+ await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report);
1231
+ }
1232
+ const refreshedTip = await this.provideTopicAnchorTip(topic);
1233
+ report.localTip = refreshedTip;
1234
+ report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced';
1235
+ return report;
1236
+ }
1237
+ catch (error) {
1238
+ report.status = 'error';
1239
+ report.message = error instanceof Error ? error.message : String(error);
1240
+ this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
1241
+ return report;
1242
+ }
1243
+ }
1244
+ async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
1245
+ report.checkedHeights.push(remoteAnchor.blockHeight);
1246
+ const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash);
1247
+ if (localAnchor?.tac === remoteAnchor.tac) {
1248
+ return;
1249
+ }
1250
+ const admittedResponse = await remote.requestAdmittedList(remoteAnchor.blockHeight, remoteAnchor.blockHash);
1251
+ const remoteBasmRoot = computeBasmRoot(admittedResponse.admitted);
1252
+ if (remoteBasmRoot !== remoteAnchor.basmRoot ||
1253
+ admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
1254
+ throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
1255
+ }
1256
+ const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? [];
1257
+ const localTxids = new Set(localAdmitted.map(item => item.txid));
1258
+ const missingTxids = admittedResponse.admitted
1259
+ .map(item => item.txid)
1260
+ .filter(txid => !localTxids.has(txid));
1261
+ report.missingTxids.push(...missingTxids);
1262
+ if (missingTxids.length === 0) {
1263
+ report.status = 'diverged';
1264
+ return;
1265
+ }
1266
+ await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids);
1267
+ report.fetchedTxCount += missingTxids.length;
1268
+ }
1269
+ async fetchBASMMissingTransactions(remote, topic, anchor, txids) {
1270
+ if (this.chainTracker === 'scripts only') {
1271
+ throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs');
1272
+ }
1273
+ const proofResponse = await remote.requestCompoundMerklePath(anchor.blockHeight, txids);
1274
+ const compoundPath = MerklePath.fromHex(proofResponse.merklePath);
1275
+ for (const txid of txids) {
1276
+ const valid = await compoundPath.verify(txid, this.chainTracker);
1277
+ if (!valid) {
1278
+ throw new Error(`Peer supplied invalid compound Merkle path for ${txid} at height ${anchor.blockHeight}`);
1279
+ }
1280
+ }
1281
+ const rawResponse = await remote.requestRawTransactions(txids);
1282
+ if (rawResponse.missing.length > 0) {
1283
+ throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`);
1284
+ }
1285
+ for (const record of rawResponse.transactions) {
1286
+ const tx = Transaction.fromHex(record.rawTx);
1287
+ if (tx.id('hex') !== record.txid) {
1288
+ throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`);
1289
+ }
1290
+ try {
1291
+ tx.merklePath = compoundPath.extract([record.txid]);
1292
+ }
1293
+ catch {
1294
+ tx.merklePath = compoundPath;
1295
+ }
1296
+ await this.submit({ beef: tx.toBEEF(), topics: [topic] }, undefined, 'historical-tx');
1297
+ }
1298
+ }
1299
+ async evictUnprovenTransactions(options = {}) {
1300
+ if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1301
+ throw new TypeError('Storage does not support unproven transaction eviction');
1302
+ }
1303
+ if (this.chainTracker === 'scripts only') {
1304
+ throw new Error('Unproven eviction requires a ChainTracker to determine block age');
1305
+ }
1306
+ const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
1307
+ const currentHeight = await this.chainTracker.currentHeight();
1308
+ const cutoffHeight = currentHeight - thresholdBlocks;
1309
+ const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
1310
+ let evictedOutputs = 0;
1311
+ for (const candidate of candidates) {
1312
+ for (const output of candidate.outputs) {
1313
+ for (const service of Object.values(this.lookupServices)) {
1314
+ try {
1315
+ await service.outputEvicted(output.txid, output.outputIndex);
1316
+ }
1317
+ catch (error) {
1318
+ this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`);
1319
+ }
1320
+ }
1321
+ await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
1322
+ evictedOutputs++;
1323
+ }
1324
+ await this.storage.deleteAppliedTransaction?.(candidate.txid, candidate.topic);
1325
+ }
1326
+ return {
1327
+ cutoffHeight,
1328
+ candidates: candidates.length,
1329
+ evictedTransactions: candidates.length,
1330
+ evictedOutputs
1331
+ };
1332
+ }
770
1333
  /**
771
1334
  * Given a GASP request, create an initial response.
772
1335
  *
@@ -813,29 +1376,17 @@ export class Engine {
813
1376
  // For each input, look it up and recurse.
814
1377
  for (const input of tx.inputs) {
815
1378
  // We should always have a source transaction
816
- if (input.sourceTransaction !== undefined) {
817
- searchInput(input.sourceTransaction);
1379
+ if (input.sourceTransaction === undefined) {
1380
+ throw new Error('Incomplete SPV data!');
818
1381
  }
819
1382
  else {
820
- throw new Error('Incomplete SPV data!');
1383
+ searchInput(input.sourceTransaction);
821
1384
  }
822
1385
  }
823
1386
  }
824
1387
  };
825
1388
  searchInput(rootTx);
826
- if (correctTx !== undefined) {
827
- const rawTx = correctTx.toHex();
828
- const node = {
829
- rawTx,
830
- graphID,
831
- outputIndex
832
- };
833
- if (correctTx.merklePath !== undefined) {
834
- node.proof = correctTx.merklePath.toHex();
835
- }
836
- return node;
837
- }
838
- else {
1389
+ if (correctTx === undefined) {
839
1390
  // Recursively try to find a matching output
840
1391
  let foundNode;
841
1392
  for (const currentOutput of output.outputsConsumed) {
@@ -845,6 +1396,8 @@ export class Engine {
845
1396
  break;
846
1397
  }
847
1398
  catch (error) {
1399
+ // Best-effort: output may not be found or hydration fails for this candidate; try the next one
1400
+ this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`);
848
1401
  continue;
849
1402
  }
850
1403
  }
@@ -852,6 +1405,18 @@ export class Engine {
852
1405
  return foundNode;
853
1406
  }
854
1407
  }
1408
+ else {
1409
+ const rawTx = correctTx.toHex();
1410
+ const node = {
1411
+ rawTx,
1412
+ graphID,
1413
+ outputIndex
1414
+ };
1415
+ if (correctTx.merklePath !== undefined) {
1416
+ node.proof = correctTx.merklePath.toHex();
1417
+ }
1418
+ return node;
1419
+ }
855
1420
  throw new Error('Unable to find output associated with your request!');
856
1421
  };
857
1422
  const [rootTxid, rootOutputIndex] = graphID.split('.');
@@ -875,7 +1440,7 @@ export class Engine {
875
1440
  */
876
1441
  async getUTXOHistory(output, historySelector, currentDepth = 0, context = this.createUTXOHistoryHydrationContext()) {
877
1442
  // If we have an output but no history selector, just return the output.
878
- if (typeof historySelector === 'undefined') {
1443
+ if (historySelector === undefined) {
879
1444
  return output;
880
1445
  }
881
1446
  try {
@@ -914,7 +1479,10 @@ export class Engine {
914
1479
  try {
915
1480
  await l.outputNoLongerRetainedInHistory?.(output.txid, output.outputIndex, output.topic);
916
1481
  }
917
- catch (_) { }
1482
+ catch (e) {
1483
+ // Best-effort notification; lookup service failure must not abort UTXO deletion
1484
+ this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`);
1485
+ }
918
1486
  }
919
1487
  }
920
1488
  // If there are no more consumed utxos, return
@@ -922,7 +1490,7 @@ export class Engine {
922
1490
  return;
923
1491
  }
924
1492
  // Delete any stale outputs that were consumed as inputs
925
- output.outputsConsumed.map(async (outputIdentifier) => {
1493
+ await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
926
1494
  const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic);
927
1495
  // Make sure an output was found
928
1496
  if (staleOutput === null || staleOutput === undefined) {
@@ -936,7 +1504,7 @@ export class Engine {
936
1504
  }
937
1505
  // Find previousUTXO history
938
1506
  return await this.deleteUTXODeep(staleOutput);
939
- });
1507
+ }));
940
1508
  }
941
1509
  catch (error) {
942
1510
  throw new Error(`Failed to delete all stale outputs: ${error} `);
@@ -1011,12 +1579,35 @@ export class Engine {
1011
1579
  if (outputs === undefined || outputs.length === 0) {
1012
1580
  throw new Error('Could not find matching transaction outputs for proof ingest!');
1013
1581
  }
1582
+ const proofMetadata = extractMerkleProofMetadata(txid, proof);
1583
+ const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight;
1584
+ const resolvedBlockHash = resolvedBlockHeight === undefined
1585
+ ? undefined
1586
+ : await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot);
1014
1587
  for (const output of outputs) {
1015
1588
  await this.updateMerkleProof(output, txid, proof);
1016
1589
  // Add the associated blockHeight
1017
- if (blockHeight !== undefined) {
1018
- output.blockHeight = blockHeight;
1019
- await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, blockHeight);
1590
+ if (resolvedBlockHeight !== undefined) {
1591
+ output.blockHeight = resolvedBlockHeight;
1592
+ await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, resolvedBlockHeight);
1593
+ }
1594
+ if (output.beef !== undefined) {
1595
+ const tx = Transaction.fromBEEF(output.beef);
1596
+ this.updateInputProofs(tx, txid, proof);
1597
+ await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash);
1598
+ }
1599
+ if (resolvedBlockHeight !== undefined) {
1600
+ await this.storage.updateAppliedTransactionProof?.({
1601
+ txid,
1602
+ topic: output.topic,
1603
+ blockHeight: resolvedBlockHeight,
1604
+ blockHash: resolvedBlockHash,
1605
+ blockIndex: proofMetadata?.blockIndex,
1606
+ merkleRoot: proofMetadata?.merkleRoot
1607
+ });
1608
+ }
1609
+ if (resolvedBlockHeight !== undefined && resolvedBlockHash !== undefined) {
1610
+ await this.recomputeTopicBlockAnchor(output.topic, resolvedBlockHeight, resolvedBlockHash);
1020
1611
  }
1021
1612
  }
1022
1613
  }
@@ -1032,7 +1623,7 @@ export class Engine {
1032
1623
  result[t] = await this.managers[t].getMetaData();
1033
1624
  }
1034
1625
  catch (e) {
1035
- this.logger.warn(`Unable to get metadata for topic manager: ${t}`);
1626
+ this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`);
1036
1627
  result[t] = {
1037
1628
  name: t,
1038
1629
  shortDescription: 'No topical tagline.'
@@ -1053,7 +1644,7 @@ export class Engine {
1053
1644
  result[ls] = await this.lookupServices[ls].getMetaData();
1054
1645
  }
1055
1646
  catch (e) {
1056
- this.logger.warn(`Unable to get metadata for lookup service: ${ls}`);
1647
+ this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`);
1057
1648
  result[ls] = {
1058
1649
  name: ls,
1059
1650
  shortDescription: 'No lookup service tagline.'
@@ -1069,7 +1660,7 @@ export class Engine {
1069
1660
  */
1070
1661
  async getDocumentationForTopicManager(manager) {
1071
1662
  const documentation = await this.managers[manager]?.getDocumentation?.();
1072
- return documentation !== undefined ? documentation : 'No documentation found!';
1663
+ return documentation ?? 'No documentation found!';
1073
1664
  }
1074
1665
  /**
1075
1666
  * Run a query to get the documentation for a particular lookup service
@@ -1078,7 +1669,7 @@ export class Engine {
1078
1669
  */
1079
1670
  async getDocumentationForLookupServiceProvider(provider) {
1080
1671
  const documentation = await this.lookupServices[provider]?.getDocumentation?.();
1081
- return documentation !== undefined ? documentation : 'No documentation found!';
1672
+ return documentation ?? 'No documentation found!';
1082
1673
  }
1083
1674
  /**
1084
1675
  * Validates a URL to ensure it does not match disallowed patterns:
@@ -1108,7 +1699,7 @@ export class Engine {
1108
1699
  /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
1109
1700
  /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.x.x.x private IPs
1110
1701
  /^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.x.x private IPs
1111
- /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/, // 172.16.x.x to 172.31.x.x private IPs
1702
+ /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, // 172.16.x.x to 172.31.x.x private IPs
1112
1703
  /^0\.0\.0\.0$/ // Non-routable address
1113
1704
  ];
1114
1705
  // Check for IPv4 matches
@@ -1122,8 +1713,8 @@ export class Engine {
1122
1713
  // If none of the disallowed conditions matched, the URL is valid
1123
1714
  return true;
1124
1715
  }
1125
- catch (error) {
1126
- // If the input is not a valid URL, return false
1716
+ catch (_e) {
1717
+ // URL constructor throws on malformed input not a valid URL, return false
1127
1718
  return false;
1128
1719
  }
1129
1720
  }