@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
package/src/Engine.ts CHANGED
@@ -25,8 +25,25 @@ import { GASP, GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/ga
25
25
  import { SyncConfiguration } from './SyncConfiguration.js'
26
26
  import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js'
27
27
  import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js'
28
+ import {
29
+ BASM_ZERO_HASH,
30
+ type AdmittedListResponse,
31
+ type BASMPeerSyncReport,
32
+ type CompoundMerklePathResponse,
33
+ type RawTransactionResponse,
34
+ type ReorgReport,
35
+ type TopicAnchorHeaderResolver,
36
+ type TopicAnchorRangeResponse,
37
+ type TopicAnchorTip,
38
+ type TopicBlockAnchor,
39
+ computeBasmRoot,
40
+ computeTac,
41
+ extractMerkleProofMetadata
42
+ } from './BASM.js'
43
+ import { BASMRemote } from './BASMRemote.js'
28
44
 
29
45
  const DEFAULT_GASP_SYNC_LIMIT = 10000
46
+ const DEFAULT_BASM_RANGE_LIMIT = 1024
30
47
 
31
48
  type UTXOHistoryHydrationContext = {
32
49
  outputCache: Map<string, Promise<Output | null>>
@@ -59,6 +76,9 @@ export class Engine {
59
76
  * @param {OverlayBroadcastFacilitator} overlayBroadcastFacilitator - Facilitator for propagation to other Overlay Services.
60
77
  * @param {typeof console} logger - The place where log entries are written.
61
78
  * @param {boolean} suppressDefaultSyncAdvertisements - Whether to suppress the default (SHIP/SLAP) sync advertisements.
79
+ * @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
80
+ * @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
81
+ * @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
62
82
  */
63
83
  constructor(
64
84
  public managers: { [key: string]: TopicManager },
@@ -76,7 +96,10 @@ export class Engine {
76
96
  public throwOnBroadcastFailure = false,
77
97
  public overlayBroadcastFacilitator: OverlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(),
78
98
  public logger: typeof console = console,
79
- public suppressDefaultSyncAdvertisements = true
99
+ public suppressDefaultSyncAdvertisements = true,
100
+ public topicAnchorHeaderResolver?: TopicAnchorHeaderResolver,
101
+ public basmSyncEnabled = false,
102
+ public unprovenEvictionBlocks = 144
80
103
  ) {
81
104
  // 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'.
82
105
  // For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
@@ -103,9 +126,7 @@ export class Engine {
103
126
  this.syncConfiguration[managerName] = Array.from(combinedSet)
104
127
  } else {
105
128
  // Set undefined managers to 'SHIP' by default
106
- if (this.syncConfiguration[managerName] === undefined) {
107
- this.syncConfiguration[managerName] = 'SHIP'
108
- }
129
+ this.syncConfiguration[managerName] ??= 'SHIP'
109
130
  }
110
131
  }
111
132
  }
@@ -123,6 +144,329 @@ export class Engine {
123
144
  }
124
145
  }
125
146
 
147
+ private async currentHeightOrUndefined(): Promise<number | undefined> {
148
+ if (this.chainTracker === 'scripts only') {
149
+ return undefined
150
+ }
151
+ try {
152
+ return await this.chainTracker.currentHeight()
153
+ } catch (error) {
154
+ this.logger.warn(`Unable to resolve current chain height for overlay metadata: ${error instanceof Error ? error.message : String(error)}`)
155
+ return undefined
156
+ }
157
+ }
158
+
159
+ private async resolveBlockHash(blockHeight: number, merkleRoot?: string): Promise<string | undefined> {
160
+ try {
161
+ const header = await this.topicAnchorHeaderResolver?.(blockHeight)
162
+ if (header === undefined) {
163
+ return undefined
164
+ }
165
+ if (header.merkleRoot !== undefined && merkleRoot !== undefined && header.merkleRoot !== merkleRoot) {
166
+ throw new Error(`Header merkle root ${header.merkleRoot} does not match proof root ${merkleRoot} at height ${blockHeight}`)
167
+ }
168
+ return header.blockHash
169
+ } catch (error) {
170
+ this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`)
171
+ return undefined
172
+ }
173
+ }
174
+
175
+ private compactBEEFForStorage(tx: Transaction, originalBEEF: number[]): number[] {
176
+ return tx.merklePath === undefined ? originalBEEF : tx.toAtomicBEEF()
177
+ }
178
+
179
+ private async recordTransactionData(tx: Transaction, beef: number[], blockHash?: string): Promise<void> {
180
+ if (typeof this.storage.upsertTransactionRecord !== 'function') {
181
+ return
182
+ }
183
+
184
+ const txid = tx.id('hex')
185
+ const metadata = extractMerkleProofMetadata(txid, tx.merklePath)
186
+ await this.storage.upsertTransactionRecord({
187
+ txid,
188
+ beef: this.compactBEEFForStorage(tx, beef),
189
+ rawTx: Array.from(tx.toBinary()),
190
+ merklePath: tx.merklePath?.toBinary(),
191
+ blockHeight: metadata?.blockHeight,
192
+ blockHash,
193
+ blockIndex: metadata?.blockIndex,
194
+ merkleRoot: metadata?.merkleRoot
195
+ })
196
+ }
197
+
198
+ private async buildAppliedTransactionRecord(tx: Transaction): Promise<{
199
+ blockHeight?: number
200
+ blockHash?: string
201
+ blockIndex?: number
202
+ merkleRoot?: string
203
+ firstSeenHeight?: number
204
+ proven: boolean
205
+ }> {
206
+ const txid = tx.id('hex')
207
+ const metadata = extractMerkleProofMetadata(txid, tx.merklePath)
208
+ const [firstSeenHeight, blockHash] = await Promise.all([
209
+ this.currentHeightOrUndefined(),
210
+ metadata === undefined ? undefined : this.resolveBlockHash(metadata.blockHeight, metadata.merkleRoot)
211
+ ])
212
+
213
+ return {
214
+ blockHeight: metadata?.blockHeight,
215
+ blockHash,
216
+ blockIndex: metadata?.blockIndex,
217
+ merkleRoot: metadata?.merkleRoot,
218
+ firstSeenHeight: firstSeenHeight ?? metadata?.blockHeight,
219
+ proven: metadata !== undefined
220
+ }
221
+ }
222
+
223
+ private async recomputeTopicBlockAnchor(topic: string, blockHeight: number, blockHash?: string): Promise<TopicBlockAnchor | undefined> {
224
+ if (
225
+ typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
226
+ typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
227
+ typeof this.storage.findTopicBlockAnchor !== 'function'
228
+ ) {
229
+ return undefined
230
+ }
231
+
232
+ const anchorBlockHash = blockHash ?? (await this.storage.findTopicBlockAnchor(topic, blockHeight))?.blockHash
233
+ if (anchorBlockHash === undefined) {
234
+ return undefined
235
+ }
236
+
237
+ // BRC-136 per-block completeness: establish the chain's genesis at the first
238
+ // admitted height, then keep every height from there to the tip contiguous so
239
+ // the cumulative TAC never resets across blocks with no admitted transactions.
240
+ // We rebuild [fromHeight, toHeight] rather than only the touched height so that
241
+ // an out-of-order proof (older height arriving after a newer one) can never
242
+ // leave a gap that silently breaks the chain.
243
+ const tip = await this.storage.findTopicAnchorTip?.(topic)
244
+ const tipHeight = tip !== undefined && tip.blockHeight >= 0 ? tip.blockHeight : undefined
245
+ const fromHeight = tipHeight === undefined ? blockHeight : Math.min(blockHeight, tipHeight + 1)
246
+ const toHeight = tipHeight === undefined ? blockHeight : Math.max(blockHeight, tipHeight)
247
+
248
+ await this.rebuildTopicAnchorChain(topic, fromHeight, toHeight, new Map([[blockHeight, anchorBlockHash]]))
249
+ return await this.storage.findTopicBlockAnchor(topic, blockHeight)
250
+ }
251
+
252
+ /**
253
+ * Extends every configured topic's anchor chain forward with empty Topic Block
254
+ * Anchors (basmRoot = zero hash, admittedCount = 0) up to `toHeight`, so the
255
+ * cumulative TAC advances on every block even when a topic admits nothing —
256
+ * this is what lets a peer authoritatively confirm "this block contained no
257
+ * transactions for this topic". Chains with no first admission yet are left
258
+ * unstarted (genesis is the topic's first admitted height).
259
+ */
260
+ async advanceTopicAnchorChains(toHeight?: number): Promise<void> {
261
+ if (
262
+ typeof this.storage.findTopicAnchorTip !== 'function' ||
263
+ typeof this.storage.upsertTopicBlockAnchor !== 'function'
264
+ ) {
265
+ return
266
+ }
267
+ const targetHeight = toHeight ?? await this.currentHeightOrUndefined()
268
+ if (targetHeight === undefined) {
269
+ return
270
+ }
271
+ for (const topic of Object.keys(this.managers)) {
272
+ const tip = await this.storage.findTopicAnchorTip(topic)
273
+ if (tip === undefined || tip.blockHeight < 0 || tip.blockHeight >= targetHeight) {
274
+ continue
275
+ }
276
+ await this.rebuildTopicAnchorChain(topic, tip.blockHeight + 1, targetHeight)
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Rebuilds a contiguous slice of a topic's anchor chain over [fromHeight,
282
+ * toHeight]. Each height uses its admitted transactions (empty -> zero basmRoot)
283
+ * and chains the cumulative TAC from the prior height. Missing heights are
284
+ * filled rather than skipped, so the chain stays gap-free. If a block hash
285
+ * cannot be resolved for some height the extension halts there to preserve
286
+ * contiguity instead of leaving a hole.
287
+ */
288
+ private async rebuildTopicAnchorChain(
289
+ topic: string,
290
+ fromHeight: number,
291
+ toHeight: number,
292
+ blockHashHints: Map<number, string> = new Map(),
293
+ forceResolve = false
294
+ ): Promise<void> {
295
+ if (
296
+ typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
297
+ typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
298
+ typeof this.storage.findTopicBlockAnchor !== 'function' ||
299
+ toHeight < fromHeight
300
+ ) {
301
+ return
302
+ }
303
+
304
+ if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
305
+ // Bound the work per pass; the next trigger resumes from the new tip.
306
+ 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`)
307
+ toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1
308
+ }
309
+
310
+ const previousAnchor = fromHeight > 0
311
+ ? await this.storage.findTopicBlockAnchor(topic, fromHeight - 1)
312
+ : undefined
313
+ let prevTac = previousAnchor?.tac ?? BASM_ZERO_HASH
314
+
315
+ for (let height = fromHeight; height <= toHeight; height++) {
316
+ const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height)
317
+ const existing = await this.storage.findTopicBlockAnchor(topic, height)
318
+ // On a reorg rebuild the existing anchor's block hash is stale, so force
319
+ // canonical re-resolution from the header resolver instead of reusing it.
320
+ const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height)
321
+ if (blockHash === undefined) {
322
+ this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`)
323
+ return
324
+ }
325
+
326
+ const basmRoot = computeBasmRoot(admitted)
327
+ const tac = computeTac(prevTac, blockHash, basmRoot)
328
+ await this.storage.upsertTopicBlockAnchor({
329
+ topic,
330
+ blockHeight: height,
331
+ blockHash,
332
+ basmRoot,
333
+ admittedCount: admitted.length,
334
+ tac
335
+ })
336
+ prevTac = tac
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Reconciles BASM anchors with a blockchain reorganization reported by the
342
+ * chain tracker (e.g. go-chaintracks `/v2/reorg/stream`). Proven topic
343
+ * transactions whose block was orphaned are demoted to unproven so they leave
344
+ * the admitted set, then every topic anchor chain intersecting the affected
345
+ * height range is rebuilt over the canonical block hashes. A reorg changes the
346
+ * canonical block hash for the affected heights, so topics with no demoted
347
+ * transaction are rebuilt too. Idempotent: a clean window demotes nothing and
348
+ * reproduces an identical TAC, so this is safe to invoke on every reorg event,
349
+ * SSE reconnect, and poll.
350
+ */
351
+ async handleReorg(input: {
352
+ orphanedBlockHashes: string[]
353
+ rebuildFromHeight: number
354
+ newTipHeight: number
355
+ }): Promise<ReorgReport> {
356
+ const report: ReorgReport = { perTopic: [] }
357
+ if (
358
+ typeof this.storage.findProvenAppliedTransactionsByBlockHash !== 'function' ||
359
+ typeof this.storage.demoteAppliedTransactionToUnproven !== 'function' ||
360
+ typeof this.storage.findTopicBlockAnchors !== 'function' ||
361
+ typeof this.storage.upsertTopicBlockAnchor !== 'function'
362
+ ) {
363
+ return report
364
+ }
365
+
366
+ // 1) Demote proven admissions whose block was orphaned. Hashes are
367
+ // normalized to lower-case display hex to match stored block hashes
368
+ // (go-sdk chainhash.Hash marshals as reversed display hex).
369
+ const demotedByTopic = new Map<string, string[]>()
370
+ for (const rawHash of input.orphanedBlockHashes) {
371
+ const blockHash = rawHash.toLowerCase()
372
+ const rows = await this.storage.findProvenAppliedTransactionsByBlockHash(blockHash)
373
+ for (const row of rows) {
374
+ await this.storage.demoteAppliedTransactionToUnproven(row.txid, row.topic)
375
+ const list = demotedByTopic.get(row.topic) ?? []
376
+ list.push(row.txid)
377
+ demotedByTopic.set(row.topic, list)
378
+ }
379
+ }
380
+
381
+ // 2) Rebuild every topic anchor chain that intersects the reorged range,
382
+ // forcing canonical block-hash re-resolution so stale hashes are replaced.
383
+ for (const topic of Object.keys(this.managers)) {
384
+ const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight)
385
+ if (existing.length === 0) {
386
+ continue
387
+ }
388
+ const startHeight = Math.min(...existing.map(anchor => anchor.blockHeight))
389
+ await this.rebuildTopicAnchorChain(topic, startHeight, input.newTipHeight, new Map(), true)
390
+ report.perTopic.push({
391
+ topic,
392
+ demotedTxids: demotedByTopic.get(topic) ?? [],
393
+ rebuiltFrom: startHeight,
394
+ rebuiltTo: input.newTipHeight
395
+ })
396
+ }
397
+
398
+ return report
399
+ }
400
+
401
+ /**
402
+ * Revalidation sweep: the reorg fallback for chain trackers without a reorg
403
+ * event stream, and the catch-up step on every reorg-SSE (re)connect (the
404
+ * go-chaintracks reorg stream carries no event ids, so a reconnect cannot
405
+ * replay events missed while disconnected). Scans proven applied transactions
406
+ * in `[tip - depth + 1, tip]`; any whose proof root no longer validates against
407
+ * the chain tracker, or whose block hash diverges from the canonical header, is
408
+ * treated as orphaned and reconciled via {@link handleReorg}.
409
+ */
410
+ private async isProvenAnchorStale(
411
+ row: { txid: string, blockHeight: number, blockHash?: string, merkleRoot?: string },
412
+ chainTracker: ChainTracker
413
+ ): Promise<boolean | undefined> {
414
+ if (row.merkleRoot !== undefined) {
415
+ try {
416
+ if (!(await chainTracker.isValidRootForHeight(row.merkleRoot, row.blockHeight))) {
417
+ return true
418
+ }
419
+ } catch (error) {
420
+ this.logger.warn(`[BASM] root validation failed for ${row.txid} at height ${row.blockHeight}: ${error instanceof Error ? error.message : String(error)}`)
421
+ return undefined
422
+ }
423
+ }
424
+
425
+ const canonical = await this.resolveBlockHash(row.blockHeight)
426
+ return canonical !== undefined && canonical.toLowerCase() !== row.blockHash?.toLowerCase()
427
+ }
428
+
429
+ async revalidateRecentAnchors(depth = 3): Promise<ReorgReport | undefined> {
430
+ const chainTracker = this.chainTracker
431
+ if (chainTracker === 'scripts only') {
432
+ this.logger.warn('[BASM] revalidation sweep requires a ChainTracker; skipping')
433
+ return undefined
434
+ }
435
+ if (typeof this.storage.findProvenAppliedTransactionsInRange !== 'function') {
436
+ return undefined
437
+ }
438
+ const tip = await this.currentHeightOrUndefined()
439
+ if (tip === undefined) {
440
+ return undefined
441
+ }
442
+
443
+ const fromHeight = Math.max(0, tip - depth + 1)
444
+ const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip)
445
+ const orphaned = new Set<string>()
446
+ let minAffected = Number.POSITIVE_INFINITY
447
+
448
+ for (const row of rows) {
449
+ if (row.blockHash === undefined) {
450
+ continue
451
+ }
452
+ const stale = await this.isProvenAnchorStale(row, chainTracker)
453
+ if (stale === true) {
454
+ orphaned.add(row.blockHash.toLowerCase())
455
+ minAffected = Math.min(minAffected, row.blockHeight)
456
+ }
457
+ }
458
+
459
+ if (orphaned.size === 0) {
460
+ return { perTopic: [] }
461
+ }
462
+
463
+ return await this.handleReorg({
464
+ orphanedBlockHashes: Array.from(orphaned),
465
+ rebuildFromHeight: minAffected,
466
+ newTipHeight: tip
467
+ })
468
+ }
469
+
126
470
  /**
127
471
  * Submits a transaction for processing by Overlay Services.
128
472
  * @param {TaggedBEEF} taggedBEEF - The transaction to process
@@ -193,7 +537,7 @@ export class Engine {
193
537
  // Identify previous coins admitted to this specific topic
194
538
  const previousCoins: number[] = []
195
539
  const outputPromises = tx.inputs.map(async (input, i) => {
196
- const previousTXID = input.sourceTXID !== undefined ? input.sourceTXID : input.sourceTransaction?.id('hex')
540
+ const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
197
541
  if (previousTXID !== undefined) {
198
542
  // Check if the previous output was admitted to this specific topic
199
543
  const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic)
@@ -398,16 +742,16 @@ export class Engine {
398
742
  const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
399
743
  if (typeof previousTXID !== 'string') continue
400
744
  const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex
401
- if (!admissibleOutputs.coinsToRetain.includes(inputIndex)) {
402
- outputsToMarkStale.push({
745
+ if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
746
+ outputsConsumed.push({
403
747
  txid: previousTXID,
404
- previousOutputIndex,
405
- inputIndex
748
+ outputIndex: previousOutputIndex
406
749
  })
407
750
  } else {
408
- outputsConsumed.push({
751
+ outputsToMarkStale.push({
409
752
  txid: previousTXID,
410
- outputIndex: previousOutputIndex
753
+ previousOutputIndex,
754
+ inputIndex
411
755
  })
412
756
  }
413
757
  }
@@ -437,10 +781,11 @@ export class Engine {
437
781
  satoshis: tx.outputs[outputIndex].satoshis,
438
782
  topic,
439
783
  spent: false,
440
- beef: taggedBEEF.beef,
784
+ beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
441
785
  consumedBy: [],
442
786
  outputsConsumed,
443
- score: Date.now()
787
+ score: Date.now(),
788
+ blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
444
789
  })
445
790
  this.endTime(`insertNewOutput_${txid.substring(0, 10)}`)
446
791
  newUTXOs.push({ txid, outputIndex })
@@ -482,6 +827,9 @@ export class Engine {
482
827
 
483
828
  this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
484
829
  // Update each output consumed to know who consumed it and insert applied transaction in parallel
830
+ const appliedRecord = await this.buildAppliedTransactionRecord(tx)
831
+ await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash)
832
+
485
833
  await Promise.all([
486
834
  ...outputsConsumed.map(async output => {
487
835
  const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic)
@@ -492,9 +840,14 @@ export class Engine {
492
840
  }),
493
841
  this.storage.insertAppliedTransaction({
494
842
  txid,
495
- topic
843
+ topic,
844
+ ...appliedRecord
496
845
  })
497
846
  ])
847
+
848
+ if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
849
+ await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash)
850
+ }
498
851
  this.endTime(`outputConsumed_${txid.substring(0, 10)}`)
499
852
  } catch (error) {
500
853
  this.logger.error('Error updating storage and notifying lookup services for topic', topic, error)
@@ -788,11 +1141,11 @@ export class Engine {
788
1141
  if (shipsToCreate.length > 0 || slapsToCreate.length > 0) {
789
1142
  const advertisementData: AdvertisementData[] = [
790
1143
  ...shipsToCreate.map(topic => ({
791
- protocol: 'SHIP' as 'SHIP',
1144
+ protocol: 'SHIP' as const,
792
1145
  topicOrServiceName: topic
793
1146
  })),
794
1147
  ...slapsToCreate.map(service => ({
795
- protocol: 'SLAP' as 'SLAP',
1148
+ protocol: 'SLAP' as const,
796
1149
  topicOrServiceName: service
797
1150
  }))
798
1151
  ]
@@ -858,7 +1211,7 @@ export class Engine {
858
1211
  // Parse out the advertisements using the provided parser
859
1212
  const tx = Transaction.fromBEEF(output.beef)
860
1213
  const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
861
- if (advertisement !== undefined && advertisement !== null && advertisement.protocol === 'SHIP') {
1214
+ if (advertisement?.protocol === 'SHIP') {
862
1215
  endpointSet.add(advertisement.domain)
863
1216
  }
864
1217
  } catch (error) {
@@ -912,6 +1265,336 @@ export class Engine {
912
1265
  }
913
1266
  }
914
1267
 
1268
+ private async resolveSyncEndpointsForTopic(topic: string): Promise<string[]> {
1269
+ if (this.syncConfiguration === undefined) {
1270
+ return []
1271
+ }
1272
+
1273
+ let syncEndpoints: string[] | string | false = this.syncConfiguration[topic]
1274
+ if (syncEndpoints === false || syncEndpoints === undefined) {
1275
+ return []
1276
+ }
1277
+
1278
+ if (syncEndpoints === 'SHIP') {
1279
+ const resolverConfig: LookupResolverConfig = this.slapTrackers
1280
+ ? { slapTrackers: this.slapTrackers }
1281
+ : {}
1282
+ const resolver = new LookupResolver(resolverConfig)
1283
+ const lookupAnswer: LookupAnswer = await resolver.query({
1284
+ service: 'ls_ship',
1285
+ query: {
1286
+ topics: [topic]
1287
+ }
1288
+ })
1289
+
1290
+ const endpointSet = new Set<string>()
1291
+ if (lookupAnswer.type === 'output-list') {
1292
+ lookupAnswer.outputs.forEach(output => {
1293
+ try {
1294
+ const tx = Transaction.fromBEEF(output.beef)
1295
+ const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
1296
+ if (advertisement?.protocol === 'SHIP') {
1297
+ endpointSet.add(advertisement.domain)
1298
+ }
1299
+ } catch (error) {
1300
+ this.logger.error('Failed to parse BASM advertisement output:', error)
1301
+ }
1302
+ })
1303
+ }
1304
+ syncEndpoints = Array.from(endpointSet)
1305
+ }
1306
+
1307
+ if (!Array.isArray(syncEndpoints)) {
1308
+ return []
1309
+ }
1310
+
1311
+ return syncEndpoints.filter(endpoint => endpoint !== this.hostingURL)
1312
+ }
1313
+
1314
+ async provideTopicAnchorTip(topic: string): Promise<TopicAnchorTip> {
1315
+ const tip = await this.storage.findTopicAnchorTip?.(topic)
1316
+ return tip ?? {
1317
+ topic,
1318
+ blockHeight: -1,
1319
+ tac: BASM_ZERO_HASH
1320
+ }
1321
+ }
1322
+
1323
+ async provideTopicAnchorRange(topic: string, fromHeight: number, toHeight: number): Promise<TopicAnchorRangeResponse> {
1324
+ if (typeof this.storage.findTopicBlockAnchors !== 'function') {
1325
+ throw new TypeError('Storage does not support BASM topic anchor ranges')
1326
+ }
1327
+ if (!Number.isInteger(fromHeight) || !Number.isInteger(toHeight) || fromHeight < 0 || toHeight < fromHeight) {
1328
+ throw new Error('Invalid topic anchor range')
1329
+ }
1330
+ if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
1331
+ throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`)
1332
+ }
1333
+
1334
+ return {
1335
+ topic,
1336
+ anchors: await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight)
1337
+ }
1338
+ }
1339
+
1340
+ async provideAdmittedList(topic: string, blockHeight: number, blockHash?: string): Promise<AdmittedListResponse> {
1341
+ if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
1342
+ throw new TypeError('Storage does not support BASM admitted lists')
1343
+ }
1344
+
1345
+ return {
1346
+ topic,
1347
+ blockHeight,
1348
+ blockHash,
1349
+ admitted: await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash)
1350
+ }
1351
+ }
1352
+
1353
+ async provideCompoundMerklePath(topic: string, blockHeight: number, txids: string[]): Promise<CompoundMerklePathResponse> {
1354
+ if (typeof this.storage.findTransactionMerklePaths !== 'function') {
1355
+ throw new TypeError('Storage does not support direct Merkle path lookup')
1356
+ }
1357
+ if (txids.length === 0) {
1358
+ throw new Error('At least one txid is required')
1359
+ }
1360
+
1361
+ const admitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, blockHeight)
1362
+ if (admitted !== undefined) {
1363
+ const admittedSet = new Set(admitted.map(item => item.txid))
1364
+ const missingAdmissions = txids.filter(txid => !admittedSet.has(txid))
1365
+ if (missingAdmissions.length > 0) {
1366
+ throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`)
1367
+ }
1368
+ }
1369
+
1370
+ const proofs = await this.storage.findTransactionMerklePaths(txids)
1371
+ const proofByTxid = new Map(proofs.map(proof => [proof.txid, proof]))
1372
+ const missing = txids.filter(txid => !proofByTxid.has(txid))
1373
+ if (missing.length > 0) {
1374
+ throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`)
1375
+ }
1376
+
1377
+ let compound: MerklePath | undefined
1378
+ for (const txid of txids) {
1379
+ const proof = proofByTxid.get(txid)
1380
+ if (proof === undefined) continue
1381
+ const path = MerklePath.fromHex(proof.merklePath)
1382
+ if (path.blockHeight !== blockHeight) {
1383
+ throw new Error(`Merkle path for ${txid} is at height ${path.blockHeight}, expected ${blockHeight}`)
1384
+ }
1385
+ if (compound === undefined) {
1386
+ compound = path
1387
+ } else {
1388
+ compound.combine(path)
1389
+ }
1390
+ }
1391
+
1392
+ if (compound === undefined) {
1393
+ throw new Error('Unable to build compound Merkle path')
1394
+ }
1395
+
1396
+ return {
1397
+ topic,
1398
+ blockHeight,
1399
+ txids,
1400
+ merklePath: compound.toHex()
1401
+ }
1402
+ }
1403
+
1404
+ async provideRawTransactions(txids: string[]): Promise<RawTransactionResponse> {
1405
+ if (typeof this.storage.findRawTransactions !== 'function') {
1406
+ throw new TypeError('Storage does not support raw transaction lookup')
1407
+ }
1408
+
1409
+ const transactions = await this.storage.findRawTransactions(txids)
1410
+ const found = new Set(transactions.map(tx => tx.txid))
1411
+ return {
1412
+ transactions,
1413
+ missing: txids.filter(txid => !found.has(txid))
1414
+ }
1415
+ }
1416
+
1417
+ async startBASMSync(): Promise<BASMPeerSyncReport[]> {
1418
+ if (this.syncConfiguration === undefined) {
1419
+ throw new Error('Overlay Service Engine not configured for topical synchronization!')
1420
+ }
1421
+
1422
+ const reports: BASMPeerSyncReport[] = []
1423
+ for (const topic of Object.keys(this.syncConfiguration)) {
1424
+ const endpoints = await this.resolveSyncEndpointsForTopic(topic)
1425
+ for (const endpoint of endpoints) {
1426
+ reports.push(await this.reconcileBASMWithPeer(topic, endpoint))
1427
+ }
1428
+ }
1429
+
1430
+ return reports
1431
+ }
1432
+
1433
+ private async reconcileBASMWithPeer(topic: string, endpoint: string): Promise<BASMPeerSyncReport> {
1434
+ const report: BASMPeerSyncReport = {
1435
+ topic,
1436
+ endpoint,
1437
+ status: 'skipped',
1438
+ checkedHeights: [],
1439
+ missingTxids: [],
1440
+ fetchedTxCount: 0
1441
+ }
1442
+
1443
+ try {
1444
+ const remote = new BASMRemote(endpoint, topic)
1445
+ const [localTip, remoteTip] = await Promise.all([
1446
+ this.provideTopicAnchorTip(topic),
1447
+ remote.requestTopicAnchorTip()
1448
+ ])
1449
+ report.localTip = localTip
1450
+ report.remoteTip = remoteTip
1451
+
1452
+ if (localTip.blockHeight >= remoteTip.blockHeight) {
1453
+ report.status = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight ? 'matched' : 'diverged'
1454
+ report.message = report.status === 'matched'
1455
+ ? 'Topic anchor tips match'
1456
+ : 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation'
1457
+ return report
1458
+ }
1459
+
1460
+ const fromHeight = Math.max(localTip.blockHeight + 1, remoteTip.blockHeight - DEFAULT_BASM_RANGE_LIMIT + 1, 0)
1461
+ const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight)
1462
+ for (const remoteAnchor of range.anchors) {
1463
+ await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report)
1464
+ }
1465
+
1466
+ const refreshedTip = await this.provideTopicAnchorTip(topic)
1467
+ report.localTip = refreshedTip
1468
+ report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced'
1469
+ return report
1470
+ } catch (error) {
1471
+ report.status = 'error'
1472
+ report.message = error instanceof Error ? error.message : String(error)
1473
+ this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error)
1474
+ return report
1475
+ }
1476
+ }
1477
+
1478
+ private async reconcileRemoteAnchor(
1479
+ topic: string,
1480
+ remote: BASMRemote,
1481
+ remoteAnchor: TopicBlockAnchor,
1482
+ report: BASMPeerSyncReport
1483
+ ): Promise<void> {
1484
+ report.checkedHeights.push(remoteAnchor.blockHeight)
1485
+ const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash)
1486
+ if (localAnchor?.tac === remoteAnchor.tac) {
1487
+ return
1488
+ }
1489
+
1490
+ const admittedResponse = await remote.requestAdmittedList(remoteAnchor.blockHeight, remoteAnchor.blockHash)
1491
+ const remoteBasmRoot = computeBasmRoot(admittedResponse.admitted)
1492
+ if (
1493
+ remoteBasmRoot !== remoteAnchor.basmRoot ||
1494
+ admittedResponse.admitted.length !== remoteAnchor.admittedCount
1495
+ ) {
1496
+ throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`)
1497
+ }
1498
+
1499
+ const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? []
1500
+ const localTxids = new Set(localAdmitted.map(item => item.txid))
1501
+ const missingTxids = admittedResponse.admitted
1502
+ .map(item => item.txid)
1503
+ .filter(txid => !localTxids.has(txid))
1504
+
1505
+ report.missingTxids.push(...missingTxids)
1506
+ if (missingTxids.length === 0) {
1507
+ report.status = 'diverged'
1508
+ return
1509
+ }
1510
+
1511
+ await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids)
1512
+ report.fetchedTxCount += missingTxids.length
1513
+ }
1514
+
1515
+ private async fetchBASMMissingTransactions(
1516
+ remote: BASMRemote,
1517
+ topic: string,
1518
+ anchor: TopicBlockAnchor,
1519
+ txids: string[]
1520
+ ): Promise<void> {
1521
+ if (this.chainTracker === 'scripts only') {
1522
+ throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs')
1523
+ }
1524
+
1525
+ const proofResponse = await remote.requestCompoundMerklePath(anchor.blockHeight, txids)
1526
+ const compoundPath = MerklePath.fromHex(proofResponse.merklePath)
1527
+ for (const txid of txids) {
1528
+ const valid = await compoundPath.verify(txid, this.chainTracker)
1529
+ if (!valid) {
1530
+ throw new Error(`Peer supplied invalid compound Merkle path for ${txid} at height ${anchor.blockHeight}`)
1531
+ }
1532
+ }
1533
+
1534
+ const rawResponse = await remote.requestRawTransactions(txids)
1535
+ if (rawResponse.missing.length > 0) {
1536
+ throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`)
1537
+ }
1538
+
1539
+ for (const record of rawResponse.transactions) {
1540
+ const tx = Transaction.fromHex(record.rawTx)
1541
+ if (tx.id('hex') !== record.txid) {
1542
+ throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`)
1543
+ }
1544
+ try {
1545
+ tx.merklePath = compoundPath.extract([record.txid])
1546
+ } catch {
1547
+ tx.merklePath = compoundPath
1548
+ }
1549
+ await this.submit({ beef: tx.toBEEF(), topics: [topic] }, undefined, 'historical-tx')
1550
+ }
1551
+ }
1552
+
1553
+ async evictUnprovenTransactions(options: {
1554
+ topic?: string
1555
+ thresholdBlocks?: number
1556
+ } = {}): Promise<{
1557
+ cutoffHeight: number
1558
+ candidates: number
1559
+ evictedTransactions: number
1560
+ evictedOutputs: number
1561
+ }> {
1562
+ if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1563
+ throw new TypeError('Storage does not support unproven transaction eviction')
1564
+ }
1565
+ if (this.chainTracker === 'scripts only') {
1566
+ throw new Error('Unproven eviction requires a ChainTracker to determine block age')
1567
+ }
1568
+
1569
+ const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
1570
+ const currentHeight = await this.chainTracker.currentHeight()
1571
+ const cutoffHeight = currentHeight - thresholdBlocks
1572
+ const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
1573
+ let evictedOutputs = 0
1574
+
1575
+ for (const candidate of candidates) {
1576
+ for (const output of candidate.outputs) {
1577
+ for (const service of Object.values(this.lookupServices)) {
1578
+ try {
1579
+ await service.outputEvicted(output.txid, output.outputIndex)
1580
+ } catch (error) {
1581
+ this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
1582
+ }
1583
+ }
1584
+ await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic)
1585
+ evictedOutputs++
1586
+ }
1587
+ await this.storage.deleteAppliedTransaction?.(candidate.txid, candidate.topic)
1588
+ }
1589
+
1590
+ return {
1591
+ cutoffHeight,
1592
+ candidates: candidates.length,
1593
+ evictedTransactions: candidates.length,
1594
+ evictedOutputs
1595
+ }
1596
+ }
1597
+
915
1598
  /**
916
1599
  * Given a GASP request, create an initial response.
917
1600
  *
@@ -961,10 +1644,10 @@ export class Engine {
961
1644
  // For each input, look it up and recurse.
962
1645
  for (const input of tx.inputs) {
963
1646
  // We should always have a source transaction
964
- if (input.sourceTransaction !== undefined) {
965
- searchInput(input.sourceTransaction)
966
- } else {
1647
+ if (input.sourceTransaction === undefined) {
967
1648
  throw new Error('Incomplete SPV data!')
1649
+ } else {
1650
+ searchInput(input.sourceTransaction)
968
1651
  }
969
1652
  }
970
1653
  }
@@ -972,19 +1655,7 @@ export class Engine {
972
1655
 
973
1656
  searchInput(rootTx)
974
1657
 
975
- if (correctTx !== undefined) {
976
- const rawTx = correctTx.toHex()
977
- const node: GASPNode = {
978
- rawTx,
979
- graphID,
980
- outputIndex
981
- }
982
- if (correctTx.merklePath !== undefined) {
983
- node.proof = correctTx.merklePath.toHex()
984
- }
985
-
986
- return node
987
- } else {
1658
+ if (correctTx === undefined) {
988
1659
  // Recursively try to find a matching output
989
1660
  let foundNode: GASPNode | undefined
990
1661
  for (const currentOutput of output.outputsConsumed) {
@@ -993,12 +1664,26 @@ export class Engine {
993
1664
  foundNode = await hydrator(outputFound)
994
1665
  break
995
1666
  } catch (error) {
1667
+ // Best-effort: output may not be found or hydration fails for this candidate; try the next one
1668
+ this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`)
996
1669
  continue
997
1670
  }
998
1671
  }
999
1672
  if (foundNode !== undefined) {
1000
1673
  return foundNode
1001
1674
  }
1675
+ } else {
1676
+ const rawTx = correctTx.toHex()
1677
+ const node: GASPNode = {
1678
+ rawTx,
1679
+ graphID,
1680
+ outputIndex
1681
+ }
1682
+ if (correctTx.merklePath !== undefined) {
1683
+ node.proof = correctTx.merklePath.toHex()
1684
+ }
1685
+
1686
+ return node
1002
1687
  }
1003
1688
  throw new Error('Unable to find output associated with your request!')
1004
1689
  }
@@ -1030,7 +1715,7 @@ export class Engine {
1030
1715
  context: UTXOHistoryHydrationContext = this.createUTXOHistoryHydrationContext()
1031
1716
  ): Promise<Output | undefined> {
1032
1717
  // If we have an output but no history selector, just return the output.
1033
- if (typeof historySelector === 'undefined') {
1718
+ if (historySelector === undefined) {
1034
1719
  return output
1035
1720
  }
1036
1721
 
@@ -1076,7 +1761,10 @@ export class Engine {
1076
1761
  output.outputIndex,
1077
1762
  output.topic
1078
1763
  )
1079
- } catch (_) { }
1764
+ } catch (e) {
1765
+ // Best-effort notification; lookup service failure must not abort UTXO deletion
1766
+ this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`)
1767
+ }
1080
1768
  }
1081
1769
  }
1082
1770
 
@@ -1086,7 +1774,7 @@ export class Engine {
1086
1774
  }
1087
1775
 
1088
1776
  // Delete any stale outputs that were consumed as inputs
1089
- output.outputsConsumed.map(async (outputIdentifier) => {
1777
+ await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
1090
1778
  const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic)
1091
1779
 
1092
1780
  // Make sure an output was found
@@ -1103,7 +1791,7 @@ export class Engine {
1103
1791
 
1104
1792
  // Find previousUTXO history
1105
1793
  return await this.deleteUTXODeep(staleOutput)
1106
- })
1794
+ }))
1107
1795
  } catch (error) {
1108
1796
  throw new Error(`Failed to delete all stale outputs: ${error as string} `)
1109
1797
  }
@@ -1184,13 +1872,40 @@ export class Engine {
1184
1872
  throw new Error('Could not find matching transaction outputs for proof ingest!')
1185
1873
  }
1186
1874
 
1875
+ const proofMetadata = extractMerkleProofMetadata(txid, proof)
1876
+ const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight
1877
+ const resolvedBlockHash = resolvedBlockHeight === undefined
1878
+ ? undefined
1879
+ : await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot)
1880
+
1187
1881
  for (const output of outputs) {
1188
1882
  await this.updateMerkleProof(output, txid, proof)
1189
1883
 
1190
1884
  // Add the associated blockHeight
1191
- if (blockHeight !== undefined) {
1192
- output.blockHeight = blockHeight
1193
- await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, blockHeight)
1885
+ if (resolvedBlockHeight !== undefined) {
1886
+ output.blockHeight = resolvedBlockHeight
1887
+ await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, resolvedBlockHeight)
1888
+ }
1889
+
1890
+ if (output.beef !== undefined) {
1891
+ const tx = Transaction.fromBEEF(output.beef)
1892
+ this.updateInputProofs(tx, txid, proof)
1893
+ await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash)
1894
+ }
1895
+
1896
+ if (resolvedBlockHeight !== undefined) {
1897
+ await this.storage.updateAppliedTransactionProof?.({
1898
+ txid,
1899
+ topic: output.topic,
1900
+ blockHeight: resolvedBlockHeight,
1901
+ blockHash: resolvedBlockHash,
1902
+ blockIndex: proofMetadata?.blockIndex,
1903
+ merkleRoot: proofMetadata?.merkleRoot
1904
+ })
1905
+ }
1906
+
1907
+ if (resolvedBlockHeight !== undefined && resolvedBlockHash !== undefined) {
1908
+ await this.recomputeTopicBlockAnchor(output.topic, resolvedBlockHeight, resolvedBlockHash)
1194
1909
  }
1195
1910
  }
1196
1911
  }
@@ -1218,7 +1933,7 @@ export class Engine {
1218
1933
  try {
1219
1934
  result[t] = await this.managers[t].getMetaData()
1220
1935
  } catch (e) {
1221
- this.logger.warn(`Unable to get metadata for topic manager: ${t}`)
1936
+ this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`)
1222
1937
  result[t] = {
1223
1938
  name: t,
1224
1939
  shortDescription: 'No topical tagline.'
@@ -1251,7 +1966,7 @@ export class Engine {
1251
1966
  try {
1252
1967
  result[ls] = await this.lookupServices[ls].getMetaData()
1253
1968
  } catch (e) {
1254
- this.logger.warn(`Unable to get metadata for lookup service: ${ls}`)
1969
+ this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`)
1255
1970
  result[ls] = {
1256
1971
  name: ls,
1257
1972
  shortDescription: 'No lookup service tagline.'
@@ -1268,7 +1983,7 @@ export class Engine {
1268
1983
  */
1269
1984
  async getDocumentationForTopicManager(manager: any): Promise<string> {
1270
1985
  const documentation = await this.managers[manager]?.getDocumentation?.()
1271
- return documentation !== undefined ? documentation : 'No documentation found!'
1986
+ return documentation ?? 'No documentation found!'
1272
1987
  }
1273
1988
 
1274
1989
  /**
@@ -1278,7 +1993,7 @@ export class Engine {
1278
1993
  */
1279
1994
  async getDocumentationForLookupServiceProvider(provider: any): Promise<string> {
1280
1995
  const documentation = await this.lookupServices[provider]?.getDocumentation?.()
1281
- return documentation !== undefined ? documentation : 'No documentation found!'
1996
+ return documentation ?? 'No documentation found!'
1282
1997
  }
1283
1998
 
1284
1999
  /**
@@ -1313,7 +2028,7 @@ export class Engine {
1313
2028
  /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
1314
2029
  /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.x.x.x private IPs
1315
2030
  /^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.x.x private IPs
1316
- /^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
2031
+ /^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
1317
2032
  /^0\.0\.0\.0$/ // Non-routable address
1318
2033
  ]
1319
2034
 
@@ -1329,8 +2044,8 @@ export class Engine {
1329
2044
 
1330
2045
  // If none of the disallowed conditions matched, the URL is valid
1331
2046
  return true
1332
- } catch (error) {
1333
- // If the input is not a valid URL, return false
2047
+ } catch (_e) {
2048
+ // URL constructor throws on malformed input not a valid URL, return false
1334
2049
  return false
1335
2050
  }
1336
2051
  }