@bsv/overlay 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE.txt +57 -27
  2. package/README.md +102 -269
  3. package/dist/cjs/mod.d.ts +15 -0
  4. package/dist/cjs/mod.d.ts.map +1 -0
  5. package/dist/cjs/mod.js +4 -1
  6. package/dist/cjs/mod.js.map +1 -1
  7. package/dist/cjs/package.json +41 -13
  8. package/dist/cjs/src/Advertisement.d.ts +9 -0
  9. package/dist/cjs/src/Advertisement.d.ts.map +1 -0
  10. package/dist/cjs/src/Advertiser.d.ts +36 -0
  11. package/dist/cjs/src/Advertiser.d.ts.map +1 -0
  12. package/dist/cjs/src/BASM.d.ts +110 -0
  13. package/dist/cjs/src/BASM.d.ts.map +1 -0
  14. package/dist/cjs/src/BASM.js.map +1 -1
  15. package/dist/cjs/src/BASMRemote.d.ts +14 -0
  16. package/dist/cjs/src/BASMRemote.d.ts.map +1 -0
  17. package/dist/cjs/src/BASMRemote.js.map +1 -1
  18. package/dist/cjs/src/Engine.d.ts +379 -0
  19. package/dist/cjs/src/Engine.d.ts.map +1 -0
  20. package/dist/cjs/src/Engine.js +425 -418
  21. package/dist/cjs/src/Engine.js.map +1 -1
  22. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +24 -0
  23. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
  24. package/dist/cjs/src/GASP/OverlayGASPRemote.js +7 -10
  25. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  26. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +103 -0
  27. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
  28. package/dist/cjs/src/GASP/OverlayGASPStorage.js +32 -30
  29. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  30. package/dist/cjs/src/LookupFormula.d.ts +29 -0
  31. package/dist/cjs/src/LookupFormula.d.ts.map +1 -0
  32. package/dist/cjs/src/LookupService.d.ts +85 -0
  33. package/dist/cjs/src/LookupService.d.ts.map +1 -0
  34. package/dist/cjs/src/Output.d.ts +32 -0
  35. package/dist/cjs/src/Output.d.ts.map +1 -0
  36. package/dist/cjs/src/SafeLog.d.ts +14 -0
  37. package/dist/cjs/src/SafeLog.d.ts.map +1 -0
  38. package/dist/cjs/src/SafeLog.js +44 -0
  39. package/dist/cjs/src/SafeLog.js.map +1 -0
  40. package/{src/SyncConfiguration.ts → dist/cjs/src/SyncConfiguration.d.ts} +2 -1
  41. package/dist/cjs/src/SyncConfiguration.d.ts.map +1 -0
  42. package/dist/cjs/src/TopicManager.d.ts +35 -0
  43. package/dist/cjs/src/TopicManager.d.ts.map +1 -0
  44. package/dist/cjs/src/storage/Storage.d.ts +237 -0
  45. package/dist/cjs/src/storage/Storage.d.ts.map +1 -0
  46. package/dist/cjs/src/storage/knex/KnexStorage.d.ts +69 -0
  47. package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -0
  48. package/dist/cjs/src/storage/knex/KnexStorage.js +7 -10
  49. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  50. package/dist/cjs/src/storage/knex/all-migrations.d.ts +11 -0
  51. package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -0
  52. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  53. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts +4 -0
  54. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts.map +1 -0
  55. package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.js.map +1 -1
  56. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts +4 -0
  57. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts.map +1 -0
  58. package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.js.map +1 -1
  59. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts +4 -0
  60. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -0
  61. package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
  62. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
  63. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
  64. package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -1
  65. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts +4 -0
  66. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -0
  67. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  68. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts +4 -0
  69. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts.map +1 -0
  70. package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.js.map +1 -1
  71. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts +4 -0
  72. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts.map +1 -0
  73. package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.js.map +1 -1
  74. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
  75. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
  76. package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -1
  77. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
  78. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
  79. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -1
  80. package/dist/esm/mod.js +2 -1
  81. package/dist/esm/mod.js.map +1 -1
  82. package/dist/esm/src/BASMRemote.js.map +1 -1
  83. package/dist/esm/src/Engine.js +404 -396
  84. package/dist/esm/src/Engine.js.map +1 -1
  85. package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
  86. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  87. package/dist/esm/src/GASP/OverlayGASPStorage.js +21 -18
  88. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  89. package/dist/esm/src/SafeLog.js +40 -0
  90. package/dist/esm/src/SafeLog.js.map +1 -0
  91. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  92. package/dist/types/mod.d.ts +5 -4
  93. package/dist/types/mod.d.ts.map +1 -1
  94. package/dist/types/src/BASMRemote.d.ts.map +1 -1
  95. package/dist/types/src/Engine.d.ts +18 -1
  96. package/dist/types/src/Engine.d.ts.map +1 -1
  97. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +2 -2
  98. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  99. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +3 -1
  100. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  101. package/dist/types/src/SafeLog.d.ts +14 -0
  102. package/dist/types/src/SafeLog.d.ts.map +1 -0
  103. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  104. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  105. package/package.json +85 -28
  106. package/dist/cjs/tsconfig.cjs.tsbuildinfo +0 -1
  107. package/dist/esm/tsconfig.esm.tsbuildinfo +0 -1
  108. package/dist/types/tsconfig.types.tsbuildinfo +0 -1
  109. package/docs/API.md +0 -944
  110. package/docs/BRC-136-BASM.md +0 -327
  111. package/docs/README.md +0 -16
  112. package/docs/Synchronization.md +0 -212
  113. package/docs/concepts/00-overview.md +0 -85
  114. package/docs/concepts/01-best-practices.md +0 -202
  115. package/docs/concepts/02-query-performance.md +0 -345
  116. package/docs/concepts/03-database-monitoring.md +0 -211
  117. package/docs/concepts/04-pagination-example.md +0 -186
  118. package/docs/concepts/05-recommendations-summary.md +0 -158
  119. package/docs/concepts/README.md +0 -20
  120. package/docs/examples/README.md +0 -15
  121. package/docs/examples/gs-wip.md +0 -111
  122. package/docs/internal/README.md +0 -11
  123. package/mod.ts +0 -40
  124. package/src/Advertisement.ts +0 -8
  125. package/src/Advertiser.ts +0 -41
  126. package/src/BASM.ts +0 -208
  127. package/src/BASMRemote.ts +0 -54
  128. package/src/Engine.ts +0 -2194
  129. package/src/GASP/OverlayGASPRemote.ts +0 -108
  130. package/src/GASP/OverlayGASPStorage.ts +0 -384
  131. package/src/LookupFormula.ts +0 -31
  132. package/src/LookupService.ts +0 -136
  133. package/src/Output.ts +0 -31
  134. package/src/TopicManager.ts +0 -40
  135. package/src/__tests/BASM.test.ts +0 -44
  136. package/src/__tests/BASMChain.test.ts +0 -295
  137. package/src/__tests/Engine.test.ts +0 -1381
  138. package/src/__tests/OverlayGASPRemote.test.ts +0 -135
  139. package/src/__tests/OverlayGASPStorage.test.ts +0 -205
  140. package/src/storage/Storage.ts +0 -260
  141. package/src/storage/knex/KnexStorage.ts +0 -651
  142. package/src/storage/knex/all-migrations.ts +0 -32
  143. package/src/storage/knex/migrations/2024-05-18-001-initial.ts +0 -30
  144. package/src/storage/knex/migrations/2024-07-10-001-block-height.ts +0 -15
  145. package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +0 -64
  146. package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +0 -30
  147. package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +0 -42
  148. package/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.ts +0 -46
  149. package/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.ts +0 -24
  150. package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +0 -18
  151. package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +0 -123
package/src/Engine.ts DELETED
@@ -1,2194 +0,0 @@
1
- import { TopicManager } from './TopicManager.js'
2
- import { LookupService } from './LookupService.js'
3
- import { Storage } from './storage/Storage.js'
4
- import type { Output } from './Output.js'
5
- import {
6
- Transaction,
7
- ChainTracker,
8
- MerklePath,
9
- Broadcaster,
10
- isBroadcastFailure,
11
- TaggedBEEF, STEAK,
12
- LookupQuestion,
13
- LookupAnswer,
14
- AdmittanceInstructions,
15
- SHIPBroadcaster,
16
- HTTPSOverlayBroadcastFacilitator,
17
- LookupResolver,
18
- LookupResolverConfig,
19
- OverlayBroadcastFacilitator,
20
- BroadcastResponse,
21
- BroadcastFailure
22
- } from '@bsv/sdk'
23
- import { AdvertisementData, Advertiser } from './Advertiser.js'
24
- import { GASP, GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/gasp'
25
- import { SyncConfiguration } from './SyncConfiguration.js'
26
- import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js'
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'
44
-
45
- const DEFAULT_GASP_SYNC_LIMIT = 10000
46
- const DEFAULT_BASM_RANGE_LIMIT = 1024
47
-
48
- type UTXOHistoryHydrationContext = {
49
- outputCache: Map<string, Promise<Output | null>>
50
- }
51
-
52
- type HydratedUTXOHistoryNode = {
53
- output: Output
54
- transaction: Transaction
55
- }
56
-
57
- /**
58
- * An engine for running BSV Overlay Services (topic managers and lookup services).
59
- */
60
- export class Engine {
61
- /**
62
- * Creates a new Overlay Services Engine
63
- * @param {[key: string]: TopicManager} managers - manages topic admittance
64
- * @param {[key: string]: LookupService} lookupServices - manages UTXO lookups
65
- * @param {Storage} storage - for interacting with internally-managed persistent data
66
- * @param {ChainTracker | 'scripts only'} chainTracker - Verifies SPV data associated with transactions
67
- * @param {string} [hostingURL] - The URL this engine is hosted at. Required if going to support peer-discovery with an advertiser.
68
- * @param {Broadcaster} [Broadcaster] - broadcaster used for broadcasting the incoming transaction
69
- * @param {Advertiser} [Advertiser] - handles SHIP and SLAP advertisements for peer-discovery
70
- * @param {string[]} shipTrackers - SHIP domains we know to bootstrap the system
71
- * @param {string[]} slapTrackers - SLAP domains we know to bootstrap the system
72
- * @param {SyncConfiguration} syncConfiguration — Configuration object describing historical synchronization of topics.
73
- * @param {boolean} logTime - Enables / disables the timing logs for various operations in the Overlay submit route.
74
- * @param {string} logPrefix - Supports overriding the log prefix with a custom string.
75
- * @param {boolean} throwOnBroadcastFailure - Enables / disables throwing an error when a transaction broadcast failure is detected.
76
- * @param {OverlayBroadcastFacilitator} overlayBroadcastFacilitator - Facilitator for propagation to other Overlay Services.
77
- * @param {typeof console} logger - The place where log entries are written.
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.
82
- */
83
- constructor(
84
- public managers: { [key: string]: TopicManager },
85
- public lookupServices: { [key: string]: LookupService },
86
- public storage: Storage,
87
- public chainTracker: ChainTracker | 'scripts only',
88
- public hostingURL?: string,
89
- public shipTrackers?: string[],
90
- public slapTrackers?: string[],
91
- public broadcaster?: Broadcaster,
92
- public advertiser?: Advertiser,
93
- public syncConfiguration?: SyncConfiguration,
94
- public logTime = false,
95
- public logPrefix = '[OVERLAY_ENGINE] ',
96
- public throwOnBroadcastFailure = false,
97
- public overlayBroadcastFacilitator: OverlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(),
98
- public logger: typeof console = console,
99
- public suppressDefaultSyncAdvertisements = true,
100
- public topicAnchorHeaderResolver?: TopicAnchorHeaderResolver,
101
- public basmSyncEnabled = false,
102
- public unprovenEvictionBlocks = 144
103
- ) {
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'.
105
- // For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
106
- if (syncConfiguration === undefined) {
107
- this.syncConfiguration = {}
108
- } else {
109
- this.syncConfiguration = syncConfiguration
110
- }
111
-
112
- for (const managerName of Object.keys(managers)) {
113
- if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
114
- // Combine tm_ship trackers with preexisting entries if any
115
- const combinedSet = new Set([
116
- ...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
117
- ...this.shipTrackers
118
- ])
119
- this.syncConfiguration[managerName] = Array.from(combinedSet)
120
- } else if (managerName === 'tm_slap' && this.slapTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
121
- // Combine tm_slap trackers with preexisting entries if any
122
- const combinedSet = new Set([
123
- ...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
124
- ...this.slapTrackers
125
- ])
126
- this.syncConfiguration[managerName] = Array.from(combinedSet)
127
- } else {
128
- // Set undefined managers to 'SHIP' by default
129
- this.syncConfiguration[managerName] ??= 'SHIP'
130
- }
131
- }
132
- }
133
-
134
- // Helper functions for logging timings
135
- private startTime(label: string): void {
136
- if (this.logTime) {
137
- this.logger.time(`${this.logPrefix} ${label}`)
138
- }
139
- }
140
-
141
- private endTime(label: string): void {
142
- if (this.logTime) {
143
- this.logger.timeEnd(`${this.logPrefix} ${label}`)
144
- }
145
- }
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
-
470
- /**
471
- * Submits a transaction for processing by Overlay Services.
472
- * @param {TaggedBEEF} taggedBEEF - The transaction to process
473
- * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
474
- * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
475
- * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
476
- *
477
- * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
478
- *
479
- * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
480
- */
481
- async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void, mode: 'historical-tx' | 'current-tx' | 'historical-tx-no-spv' = 'current-tx', offChainValues?: number[]): Promise<STEAK> {
482
- for (const t of taggedBEEF.topics) {
483
- if (this.managers[t] === undefined || this.managers[t] === null) {
484
- throw new Error(`This server does not support this topic: ${t}`)
485
- }
486
- }
487
-
488
- // Validate the transaction SPV information
489
- const tx = Transaction.fromBEEF(taggedBEEF.beef)
490
- const txid = tx.id('hex')
491
-
492
- this.startTime(`submit_${txid}`)
493
- if (mode !== 'historical-tx-no-spv') {
494
- this.startTime(`chainTracker_${txid.substring(0, 10)}`)
495
- const txValid = await tx.verify(this.chainTracker)
496
- if (!txValid) throw new Error('Unable to verify SPV information.')
497
- this.endTime(`chainTracker_${txid.substring(0, 10)}`)
498
- }
499
-
500
- const steak: STEAK = {}
501
- const dupeTopics = new Set<string>()
502
- const failedTopics = new Set<string>()
503
-
504
- // ===================================================================
505
- // PHASE 1: VALIDATE (read-only, no mutations)
506
- // ===================================================================
507
- type TopicValidation = {
508
- topic: string
509
- isDupe: boolean
510
- previousCoins: number[]
511
- previousOutputs: Array<Output | null>
512
- admissibleOutputs: AdmittanceInstructions
513
- }
514
-
515
- const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
516
- try {
517
- if (this.managers[topic] === undefined || this.managers[topic] === null) {
518
- throw new Error(`This server does not support this topic: ${topic}`)
519
- }
520
-
521
- // Check for duplicate transactions
522
- this.startTime(`dupCheck_${txid.substring(0, 10)}`)
523
- const dupeCheck = await this.storage.doesAppliedTransactionExist({ txid, topic })
524
- this.endTime(`dupCheck_${txid.substring(0, 10)}`)
525
-
526
- if (dupeCheck) {
527
- dupeTopics.add(topic)
528
- return {
529
- topic,
530
- isDupe: true,
531
- previousCoins: [],
532
- previousOutputs: [],
533
- admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
534
- }
535
- }
536
-
537
- // Identify previous coins admitted to this specific topic
538
- const previousCoins: number[] = []
539
- const outputPromises = tx.inputs.map(async (input, i) => {
540
- const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
541
- if (previousTXID !== undefined) {
542
- // Check if the previous output was admitted to this specific topic
543
- const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic)
544
- if (output !== undefined && output !== null) {
545
- previousCoins.push(i)
546
- return output
547
- }
548
- }
549
- return null
550
- })
551
-
552
- this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
553
- const previousOutputs = await Promise.all(outputPromises)
554
- this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
555
-
556
- // Determine which outputs are admissible for this topic (validation only)
557
- this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
558
- const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
559
- taggedBEEF.beef,
560
- previousCoins,
561
- offChainValues,
562
- mode
563
- )
564
- this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
565
-
566
- return {
567
- topic,
568
- isDupe: false,
569
- previousCoins,
570
- previousOutputs,
571
- admissibleOutputs
572
- }
573
- } catch (error) {
574
- this.logger.error('Error validating topic during submit:', error)
575
- failedTopics.add(topic)
576
- return {
577
- topic,
578
- isDupe: false,
579
- previousCoins: [],
580
- previousOutputs: [],
581
- admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
582
- }
583
- }
584
- })
585
-
586
- const validations = await Promise.all(topicValidations)
587
-
588
- // Build preliminary STEAK from validation results
589
- for (const validation of validations) {
590
- steak[validation.topic] = validation.admissibleOutputs
591
- }
592
-
593
- // ===================================================================
594
- // PHASE 2: BROADCAST (before any mutations)
595
- // ===================================================================
596
- // Only broadcast when at least one topic actually accepted the
597
- // transaction. For a non-failed topic, acceptance means: previously
598
- // accepted (dupe / client retry), outputs admitted, coins retained, or
599
- // previously-admitted coins consumed (e.g. a consume-only deletion such
600
- // as a KVStore remove, even one that retains nothing). A topic manager
601
- // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
602
- // failedTopics). A transaction every topic rejected must never reach the
603
- // network: submitters treat an empty STEAK as a rejection and
604
- // abort/release their held inputs, so broadcasting it anyway would
605
- // desync their wallets from the chain.
606
- const anyTopicAccepted = validations.some(v =>
607
- !failedTopics.has(v.topic) && (
608
- v.isDupe ||
609
- v.admissibleOutputs.outputsToAdmit.length > 0 ||
610
- v.admissibleOutputs.coinsToRetain.length > 0 ||
611
- v.previousCoins.length > 0
612
- )
613
- )
614
- this.startTime(`broadcast_${txid.substring(0, 10)}`)
615
- if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
616
- try {
617
- let response: BroadcastResponse | BroadcastFailure
618
- if (tx.merklePath !== undefined) {
619
- // tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
620
- const txid = tx.id('hex')
621
- const mp = tx.merklePath
622
- const leaf = mp.path[0].find(leaf => leaf.hash === txid)
623
- const r: BroadcastResponse = {
624
- status: 'success',
625
- txid: tx.id('hex'),
626
- message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
627
- }
628
- response = r
629
- } else {
630
- response = await this.broadcaster.broadcast(tx)
631
- }
632
- if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
633
- const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
634
- ; (e as any).more = response.more
635
- throw e
636
- }
637
- } catch (error) {
638
- if (this.throwOnBroadcastFailure) {
639
- throw error
640
- }
641
- this.logger.error('Error broadcasting transaction:', error)
642
- }
643
- }
644
- this.endTime(`broadcast_${txid.substring(0, 10)}`)
645
-
646
- // Call the callback function with STEAK if it is provided (before storage mutations)
647
- if (onSteakReady !== undefined) {
648
- onSteakReady(steak)
649
- }
650
-
651
- // ===================================================================
652
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
653
- // ===================================================================
654
- // Mark previous outputs as spent and notify lookup services
655
- await Promise.all(validations.map(async (validation) => {
656
- if (validation.isDupe || failedTopics.has(validation.topic)) {
657
- return
658
- }
659
-
660
- const topic = validation.topic
661
- const previousOutputs = validation.previousOutputs
662
-
663
- // Mark all previous outputs as spent
664
- const markSpentPromises = previousOutputs.map(async (output) => {
665
- if (output !== undefined && output !== null) {
666
- try {
667
- await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
668
- await Promise.all(Object.values(this.lookupServices).map(async l => {
669
- try {
670
- if (typeof l.outputSpent === 'function') {
671
- if (l.spendNotificationMode === 'txid') {
672
- await l.outputSpent({
673
- mode: 'txid',
674
- spendingTxid: txid,
675
- txid: output.txid,
676
- outputIndex: output.outputIndex,
677
- topic
678
- })
679
- } else if (l.spendNotificationMode === 'script') {
680
- const inputIndex = tx.inputs.findIndex(i => {
681
- let realSource = i.sourceTXID
682
- if (!realSource) {
683
- realSource = i.sourceTransaction?.id('hex')
684
- }
685
- return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
686
- })
687
- if (inputIndex === -1) {
688
- throw new Error('Could not find input index')
689
- }
690
- await l.outputSpent({
691
- mode: 'script',
692
- spendingTxid: txid,
693
- inputIndex,
694
- sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
695
- unlockingScript: tx.inputs[inputIndex].unlockingScript!,
696
- txid: output.txid,
697
- outputIndex: output.outputIndex,
698
- topic,
699
- offChainValues
700
- })
701
- } else if (l.spendNotificationMode === 'whole-tx') {
702
- await l.outputSpent({
703
- mode: 'whole-tx',
704
- spendingAtomicBEEF: tx.toAtomicBEEF(),
705
- txid: output.txid,
706
- outputIndex: output.outputIndex,
707
- topic,
708
- offChainValues
709
- })
710
- } else { // none
711
- await l.outputSpent({
712
- mode: 'none',
713
- txid: output.txid,
714
- outputIndex: output.outputIndex,
715
- topic
716
- })
717
- }
718
- }
719
- } catch (error) {
720
- this.logger.error('Error in lookup service for outputSpent:', error)
721
- }
722
- }))
723
- } catch (error) {
724
- this.logger.error('Error marking UTXO as spent:', error)
725
- }
726
- }
727
- })
728
-
729
- await Promise.all(markSpentPromises)
730
- }))
731
-
732
- // Continue with storage updates and lookup service notifications
733
- for (const validation of validations) {
734
- const topic = validation.topic
735
- if (dupeTopics.has(topic)) {
736
- continue
737
- }
738
- if (failedTopics.has(topic)) {
739
- continue
740
- }
741
- try {
742
- const admissibleOutputs = steak[topic]
743
- const outputsToAdmit: number[] = admissibleOutputs.outputsToAdmit
744
- const outputsConsumed: Array<{
745
- txid: string
746
- outputIndex: number
747
- }> = []
748
-
749
- const outputsToMarkStale: Array<{
750
- txid: string
751
- previousOutputIndex: number
752
- inputIndex: number
753
- }> = []
754
-
755
- // Use previousCoins from validation
756
- const previousCoins = validation.previousCoins
757
-
758
- // For each of the previous UTXOs for this topic, if the UTXO was not included in the list of UTXOs identified for retention, then it will be marked as stale.
759
- for (const inputIndex of previousCoins) {
760
- const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
761
- if (typeof previousTXID !== 'string') continue
762
- const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex
763
- if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
764
- outputsConsumed.push({
765
- txid: previousTXID,
766
- outputIndex: previousOutputIndex
767
- })
768
- } else {
769
- outputsToMarkStale.push({
770
- txid: previousTXID,
771
- previousOutputIndex,
772
- inputIndex
773
- })
774
- }
775
- }
776
-
777
- // Remove stale outputs recursively
778
- this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
779
- await Promise.all(outputsToMarkStale.map(async coin => {
780
- const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
781
- if (output !== undefined && output !== null) {
782
- await this.deleteUTXODeep(output)
783
- }
784
- }))
785
- this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
786
-
787
- // Update the STEAK to indicate which coins were removed
788
- steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
789
-
790
- // Handle admittance and notification of incoming UTXOs
791
- const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
792
- await Promise.all(outputsToAdmit.map(async outputIndex => {
793
- if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
794
- this.startTime(`insertNewOutput_${txid.substring(0, 10)}`)
795
- await this.storage.insertOutput({
796
- txid,
797
- outputIndex,
798
- outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
799
- satoshis: tx.outputs[outputIndex].satoshis,
800
- topic,
801
- spent: false,
802
- beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
803
- consumedBy: [],
804
- outputsConsumed,
805
- score: Date.now(),
806
- blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
807
- })
808
- this.endTime(`insertNewOutput_${txid.substring(0, 10)}`)
809
- newUTXOs.push({ txid, outputIndex })
810
-
811
- this.startTime(`notifyLookupService${txid.substring(0, 10)}`)
812
- await Promise.all(Object.values(this.lookupServices).map(async l => {
813
- try {
814
- if (l.admissionMode === 'locking-script') {
815
- if (
816
- typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
817
- typeof tx.outputs[outputIndex].satoshis !== 'number'
818
- ) {
819
- return
820
- }
821
- await l.outputAdmittedByTopic({
822
- mode: 'locking-script',
823
- txid,
824
- outputIndex,
825
- lockingScript: tx.outputs[outputIndex].lockingScript,
826
- satoshis: tx.outputs[outputIndex].satoshis,
827
- topic,
828
- offChainValues
829
- })
830
- } else {
831
- await l.outputAdmittedByTopic({
832
- mode: 'whole-tx',
833
- atomicBEEF: tx.toAtomicBEEF(),
834
- outputIndex,
835
- topic,
836
- offChainValues
837
- })
838
- }
839
- } catch (error) {
840
- this.logger.error('Error in lookup service for outputAdmittedByTopic:', error)
841
- }
842
- }))
843
- this.endTime(`notifyLookupService${txid.substring(0, 10)}`)
844
- }))
845
-
846
- this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
847
- // Update each output consumed to know who consumed it and insert applied transaction in parallel
848
- const appliedRecord = await this.buildAppliedTransactionRecord(tx)
849
- await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash)
850
-
851
- await Promise.all([
852
- ...outputsConsumed.map(async output => {
853
- const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic)
854
- if (outputToUpdate !== undefined && outputToUpdate !== null) {
855
- const newConsumedBy = [...new Set([...newUTXOs, ...outputToUpdate.consumedBy])]
856
- await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, newConsumedBy)
857
- }
858
- }),
859
- this.storage.insertAppliedTransaction({
860
- txid,
861
- topic,
862
- ...appliedRecord
863
- })
864
- ])
865
-
866
- if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
867
- await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash)
868
- }
869
- this.endTime(`outputConsumed_${txid.substring(0, 10)}`)
870
- } catch (error) {
871
- this.logger.error('Error updating storage and notifying lookup services for topic', topic, error)
872
- }
873
- }
874
-
875
- // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
876
- if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
877
- return steak
878
- }
879
-
880
- this.startTime(`transactionPropagation_${txid.substring(0, 10)}`)
881
- const relevantTopics = taggedBEEF.topics.filter(topic =>
882
- steak[topic] !== undefined && !dupeTopics.has(topic) && (steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0)
883
- )
884
-
885
- if (relevantTopics.length === 0) {
886
- this.endTime(`transactionPropagation_${txid.substring(0, 10)}`)
887
- return steak
888
- }
889
-
890
- // Create a SHIPBroadcaster instance
891
- let customBroadcasterConfig
892
- if (Array.isArray(this.slapTrackers)) {
893
- // Custom SLAP trackers warrant a custom broadcaster config
894
- const resolverConfig: LookupResolverConfig = {
895
- slapTrackers: this.slapTrackers
896
- }
897
- customBroadcasterConfig = {
898
- resolver: new LookupResolver(resolverConfig)
899
- }
900
- }
901
- const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig)
902
-
903
- try {
904
- await shipBroadcaster.broadcast(tx)
905
- } catch (error) {
906
- this.logger.error('Error during propagation to other nodes:', error)
907
- }
908
- this.endTime(`transactionPropagation_${txid.substring(0, 10)}`)
909
-
910
- // Immediately return from the function without waiting for the promises to resolve.
911
- return steak
912
- }
913
-
914
- /**
915
- * Submit a lookup question to the Overlay Services Engine, and receive back a Lookup Answer
916
- * @param LookupQuestion — The question to ask the Overlay Services Engine
917
- * @returns The answer to the question
918
- */
919
- async lookup(lookupQuestion: LookupQuestion): Promise<LookupAnswer> {
920
- // Validate a lookup service for the provider is found
921
- const lookupService = this.lookupServices[lookupQuestion.service]
922
- if (lookupService === undefined || lookupService === null) throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`)
923
-
924
- const lookupResult = await lookupService.lookup(lookupQuestion)
925
- const hydrationContext = this.createUTXOHistoryHydrationContext()
926
- await this.preloadOutputsWithBEEF(
927
- lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })),
928
- hydrationContext
929
- )
930
- const hydratedOutputs = (await Promise.all(
931
- lookupResult.map(async ({ txid, outputIndex, history, context }) => {
932
- const UTXO = await this.loadOutputWithBEEF(txid, outputIndex, hydrationContext)
933
- if (UTXO === null) {
934
- return null
935
- }
936
-
937
- // Get the history for this utxo and construct a BEEF
938
- const output = await this.getUTXOHistory(UTXO, history, 0, hydrationContext)
939
- if (output?.beef === undefined) {
940
- return null
941
- }
942
-
943
- return {
944
- beef: output.beef,
945
- outputIndex: output.outputIndex,
946
- context
947
- }
948
- })
949
- ))
950
- .filter((output): output is { beef: number[], outputIndex: number, context: number[] | undefined } => output !== null)
951
- .map(({ beef, outputIndex, context }) => (
952
- context === undefined
953
- ? { beef, outputIndex }
954
- : { beef, outputIndex, context }
955
- ))
956
- return {
957
- type: 'output-list',
958
- outputs: hydratedOutputs
959
- }
960
- }
961
-
962
- private createUTXOHistoryHydrationContext(): UTXOHistoryHydrationContext {
963
- return {
964
- outputCache: new Map<string, Promise<Output | null>>()
965
- }
966
- }
967
-
968
- private toOutputCacheKey(txid: string, outputIndex: number): string {
969
- return `${txid}:${outputIndex}`
970
- }
971
-
972
- private async preloadOutputsWithBEEF(
973
- outpoints: Array<{ txid: string, outputIndex: number }>,
974
- context: UTXOHistoryHydrationContext
975
- ): Promise<void> {
976
- if (outpoints.length === 0) {
977
- return
978
- }
979
-
980
- const deduped: Array<{ txid: string, outputIndex: number }> = []
981
- const seen = new Set<string>()
982
-
983
- for (const outpoint of outpoints) {
984
- const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
985
- if (seen.has(cacheKey)) {
986
- continue
987
- }
988
- seen.add(cacheKey)
989
- if (!context.outputCache.has(cacheKey)) {
990
- deduped.push(outpoint)
991
- }
992
- }
993
-
994
- if (deduped.length === 0) {
995
- return
996
- }
997
-
998
- const findOutputsByOutpoints = this.storage.findOutputsByOutpoints
999
- if (typeof findOutputsByOutpoints === 'function') {
1000
- const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true)
1001
- const outputsByKey = new Map<string, Output>()
1002
- for (const output of outputs) {
1003
- outputsByKey.set(this.toOutputCacheKey(output.txid, output.outputIndex), output)
1004
- }
1005
-
1006
- for (const outpoint of deduped) {
1007
- const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
1008
- context.outputCache.set(cacheKey, Promise.resolve(outputsByKey.get(cacheKey) ?? null))
1009
- }
1010
- return
1011
- }
1012
-
1013
- for (const outpoint of deduped) {
1014
- const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
1015
- context.outputCache.set(
1016
- cacheKey,
1017
- this.storage.findOutput(outpoint.txid, outpoint.outputIndex, undefined, undefined, true)
1018
- )
1019
- }
1020
- }
1021
-
1022
- private async loadOutputWithBEEF(
1023
- txid: string,
1024
- outputIndex: number,
1025
- context: UTXOHistoryHydrationContext
1026
- ): Promise<Output | null> {
1027
- const cacheKey = this.toOutputCacheKey(txid, outputIndex)
1028
- let cached = context.outputCache.get(cacheKey)
1029
- if (cached === undefined) {
1030
- cached = this.storage.findOutput(txid, outputIndex, undefined, undefined, true)
1031
- context.outputCache.set(cacheKey, cached)
1032
- }
1033
- const output = await cached
1034
- return output ?? null
1035
- }
1036
-
1037
- private async hydrateUTXOHistoryNode(
1038
- output: Output,
1039
- historySelector: ((beef: number[], outputIndex: number, currentDepth: number) => Promise<boolean>) | number,
1040
- currentDepth: number,
1041
- context: UTXOHistoryHydrationContext
1042
- ): Promise<HydratedUTXOHistoryNode | undefined> {
1043
- if (output.beef === undefined) {
1044
- throw new Error('Output must have associated transaction BEEF!')
1045
- }
1046
-
1047
- let shouldTraverseHistory: boolean
1048
- if (typeof historySelector === 'number') {
1049
- shouldTraverseHistory = currentDepth <= historySelector
1050
- } else {
1051
- shouldTraverseHistory = await historySelector(output.beef, output.outputIndex, currentDepth)
1052
- }
1053
-
1054
- if (shouldTraverseHistory === false) {
1055
- return undefined
1056
- }
1057
-
1058
- await this.preloadOutputsWithBEEF(output.outputsConsumed, context)
1059
-
1060
- const childNodes = (await Promise.all(
1061
- output.outputsConsumed.map(async (outputIdentifier) => {
1062
- const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context)
1063
- if (childOutput === null) {
1064
- return undefined
1065
- }
1066
-
1067
- return await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context)
1068
- })
1069
- )).filter((node): node is HydratedUTXOHistoryNode => node !== undefined)
1070
-
1071
- const tx = Transaction.fromBEEF(output.beef)
1072
- const inputIndexBySource = new Map<string, number>()
1073
- tx.inputs.forEach((candidateInput, index) => {
1074
- const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
1075
- ? candidateInput.sourceTXID
1076
- : candidateInput.sourceTransaction?.id('hex')
1077
-
1078
- if (sourceTXID === undefined) {
1079
- return
1080
- }
1081
-
1082
- inputIndexBySource.set(`${sourceTXID}:${candidateInput.sourceOutputIndex}`, index)
1083
- })
1084
-
1085
- for (const child of childNodes) {
1086
- const inputIndex = inputIndexBySource.get(`${child.output.txid}:${child.output.outputIndex}`)
1087
-
1088
- if (inputIndex === -1 || inputIndex == null) {
1089
- continue
1090
- }
1091
-
1092
- const targetInput = tx.inputs[inputIndex]
1093
- if (!targetInput) {
1094
- this.logger.error(`Input at index ${inputIndex} is undefined, but findIndex found it. Possible sparse array from BEEF parsing.`)
1095
- continue
1096
- }
1097
-
1098
- targetInput.sourceTransaction = child.transaction
1099
- }
1100
-
1101
- return { output, transaction: tx }
1102
- }
1103
-
1104
- /**
1105
- * Ensures alignment between the current SHIP/SLAP advertisements and the
1106
- * configured Topic Managers and Lookup Services in the engine.
1107
- *
1108
- * This method performs the following actions:
1109
- * 1. Retrieves the current configuration of topics and services.
1110
- * 2. Fetches the existing SHIP advertisements for each configured topic.
1111
- * 3. Fetches the existing SLAP advertisements for each configured service.
1112
- * 4. Compares the current configuration with the fetched advertisements to determine which advertisements
1113
- * need to be created or revoked.
1114
- * 5. Creates new SHIP/SLAP advertisements if they do not exist for the configured topics/services.
1115
- * 6. Revokes existing SHIP/SLAP advertisements if they are no longer required based on the current configuration.
1116
- *
1117
- * The function uses the `Advertiser` methods to create or revoke advertisements and ensures the updates are
1118
- * submitted to the SHIP/SLAP overlay networks using the engine's `submit()` method.
1119
- *
1120
- * @throws Will throw an error if there are issues during the advertisement synchronization process.
1121
- * @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
1122
- */
1123
- async syncAdvertisements(): Promise<void> {
1124
- if (
1125
- this.advertiser === undefined ||
1126
- typeof this.hostingURL !== 'string' ||
1127
- this.hostingURL.length < 1 ||
1128
- !this.isValidUrl(this.hostingURL)
1129
- ) {
1130
- return
1131
- }
1132
- const advertiser = this.advertiser
1133
-
1134
- // Step 1: Retrieve Current Configuration
1135
- let configuredTopics = Object.keys(this.managers)
1136
- let configuredServices = Object.keys(this.lookupServices)
1137
-
1138
- // Filter out default SHIP/SLAP topics/services if suppressDefaultSyncAdvertisements is true
1139
- if (this.suppressDefaultSyncAdvertisements === true) {
1140
- configuredTopics = configuredTopics.filter(topic => topic !== 'tm_ship' && topic !== 'tm_slap')
1141
- configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap')
1142
- }
1143
-
1144
- // Step 2: Fetch Existing Advertisements
1145
- const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP')
1146
- const currentSLAPAdvertisements = await advertiser.findAllAdvertisements('SLAP')
1147
-
1148
- // Step 3: Compare and Determine Actions
1149
- const requiredSHIPAdvertisements = new Set(configuredTopics)
1150
- const requiredSLAPAdvertisements = new Set(configuredServices)
1151
-
1152
- const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === this.hostingURL))
1153
- const slapsToCreate = Array.from(requiredSLAPAdvertisements).filter(topicOrService => !currentSLAPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === this.hostingURL))
1154
- const shipsToRevoke = currentSHIPAdvertisements.filter(ad => !requiredSHIPAdvertisements.has(ad.topicOrService))
1155
- const slapsToRevoke = currentSLAPAdvertisements.filter(ad => !requiredSLAPAdvertisements.has(ad.topicOrService))
1156
-
1157
- // Create needed SHIP/SLAP advertisements
1158
- try {
1159
- if (shipsToCreate.length > 0 || slapsToCreate.length > 0) {
1160
- const advertisementData: AdvertisementData[] = [
1161
- ...shipsToCreate.map(topic => ({
1162
- protocol: 'SHIP' as const,
1163
- topicOrServiceName: topic
1164
- })),
1165
- ...slapsToCreate.map(service => ({
1166
- protocol: 'SLAP' as const,
1167
- topicOrServiceName: service
1168
- }))
1169
- ]
1170
- const taggedBEEF = await advertiser.createAdvertisements(advertisementData)
1171
- await this.submit(taggedBEEF)
1172
- }
1173
- } catch (error) {
1174
- this.logger.error('Failed to create SHIP advertisement:', error)
1175
- }
1176
-
1177
- // Revoke all advertisements to revoke
1178
- try {
1179
- if (shipsToRevoke.length > 0 || slapsToRevoke.length > 0) {
1180
- const taggedBEEF = await advertiser.revokeAdvertisements([...shipsToRevoke, ...slapsToRevoke])
1181
- await this.submit(taggedBEEF)
1182
- }
1183
- } catch (error) {
1184
- this.logger.error('Failed to revoke SHIP/SLAP advertisements:', error)
1185
- }
1186
- }
1187
-
1188
- /**
1189
- * This method goes through each topic that we support syncing and attempts to sync with each endpoint
1190
- * associated with that topic. If the sync configuration is 'SHIP', it will sync to all peers that support
1191
- * the topic.
1192
- *
1193
- * @throws Error if the overlay service engine is not configured for topical synchronization.
1194
- */
1195
- async startGASPSync(): Promise<void> {
1196
- if (this.syncConfiguration === undefined) {
1197
- throw new Error('Overlay Service Engine not configured for topical synchronization!')
1198
- }
1199
-
1200
- for (const topic of Object.keys(this.syncConfiguration)) {
1201
- // Make sure syncEndpoints is an array or SHIP
1202
- let syncEndpoints: string[] | string | false = this.syncConfiguration[topic]
1203
-
1204
- // Check if this topic has been configured NOT to sync
1205
- if (syncEndpoints === false) {
1206
- continue
1207
- }
1208
-
1209
- if (syncEndpoints === 'SHIP') {
1210
- // Perform lookup and find ship advertisements to set syncEndpoints for topic
1211
- const resolverConfig: LookupResolverConfig = this.slapTrackers
1212
- ? { slapTrackers: this.slapTrackers }
1213
- : {}
1214
-
1215
- const resolver = new LookupResolver(resolverConfig)
1216
- const lookupAnswer: LookupAnswer = await resolver.query({
1217
- service: 'ls_ship',
1218
- query: {
1219
- topics: [topic]
1220
- }
1221
- })
1222
-
1223
- // Lookup will currently always return type output-list
1224
- if (lookupAnswer.type === 'output-list') {
1225
- const endpointSet = new Set<string>()
1226
-
1227
- lookupAnswer.outputs.forEach(output => {
1228
- try {
1229
- // Parse out the advertisements using the provided parser
1230
- const tx = Transaction.fromBEEF(output.beef)
1231
- const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
1232
- if (advertisement?.protocol === 'SHIP') {
1233
- endpointSet.add(advertisement.domain)
1234
- }
1235
- } catch (error) {
1236
- this.logger.error('Failed to parse advertisement output:', error)
1237
- }
1238
- })
1239
-
1240
- syncEndpoints = Array.from(endpointSet)
1241
- }
1242
- }
1243
-
1244
- // Now syncEndpoints is guaranteed to be an array of strings without duplicates
1245
- if (Array.isArray(syncEndpoints)) {
1246
- // Remove our own hosting URL so we don't sync with ourselves
1247
- syncEndpoints = syncEndpoints.filter((endpoint) => endpoint !== this.hostingURL)
1248
-
1249
- this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`)
1250
- // Sync with each endpoint individually to avoid parallel locks and let failures be isolated
1251
- for (const endpoint of syncEndpoints) {
1252
- this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`)
1253
-
1254
- try {
1255
- // Read the last interaction score from storage
1256
- const lastInteraction = await this.storage.getLastInteraction(endpoint, topic)
1257
-
1258
- const gasp = new GASP(
1259
- new OverlayGASPStorage(topic, this),
1260
- new OverlayGASPRemote(endpoint, topic),
1261
- lastInteraction,
1262
- `[GASP Sync of ${topic} with ${endpoint}]`,
1263
- true,
1264
- true
1265
- )
1266
- await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT)
1267
-
1268
- // Save the updated last interaction score
1269
- if (gasp.lastInteraction > lastInteraction) {
1270
- await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction)
1271
- }
1272
-
1273
- this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`)
1274
- } catch (err) {
1275
- this.logger.error(
1276
- `[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`,
1277
- err
1278
- )
1279
- // Continue on to the next endpoint without throwing
1280
- }
1281
- }
1282
- }
1283
- }
1284
- }
1285
-
1286
- private async resolveSyncEndpointsForTopic(topic: string): Promise<string[]> {
1287
- if (this.syncConfiguration === undefined) {
1288
- return []
1289
- }
1290
-
1291
- let syncEndpoints: string[] | string | false = this.syncConfiguration[topic]
1292
- if (syncEndpoints === false || syncEndpoints === undefined) {
1293
- return []
1294
- }
1295
-
1296
- if (syncEndpoints === 'SHIP') {
1297
- const resolverConfig: LookupResolverConfig = this.slapTrackers
1298
- ? { slapTrackers: this.slapTrackers }
1299
- : {}
1300
- const resolver = new LookupResolver(resolverConfig)
1301
- const lookupAnswer: LookupAnswer = await resolver.query({
1302
- service: 'ls_ship',
1303
- query: {
1304
- topics: [topic]
1305
- }
1306
- })
1307
-
1308
- const endpointSet = new Set<string>()
1309
- if (lookupAnswer.type === 'output-list') {
1310
- lookupAnswer.outputs.forEach(output => {
1311
- try {
1312
- const tx = Transaction.fromBEEF(output.beef)
1313
- const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
1314
- if (advertisement?.protocol === 'SHIP') {
1315
- endpointSet.add(advertisement.domain)
1316
- }
1317
- } catch (error) {
1318
- this.logger.error('Failed to parse BASM advertisement output:', error)
1319
- }
1320
- })
1321
- }
1322
- syncEndpoints = Array.from(endpointSet)
1323
- }
1324
-
1325
- if (!Array.isArray(syncEndpoints)) {
1326
- return []
1327
- }
1328
-
1329
- return syncEndpoints.filter(endpoint => endpoint !== this.hostingURL)
1330
- }
1331
-
1332
- async provideTopicAnchorTip(topic: string): Promise<TopicAnchorTip> {
1333
- const tip = await this.storage.findTopicAnchorTip?.(topic)
1334
- return tip ?? {
1335
- topic,
1336
- blockHeight: -1,
1337
- tac: BASM_ZERO_HASH
1338
- }
1339
- }
1340
-
1341
- async provideTopicAnchorRange(topic: string, fromHeight: number, toHeight: number): Promise<TopicAnchorRangeResponse> {
1342
- if (typeof this.storage.findTopicBlockAnchors !== 'function') {
1343
- throw new TypeError('Storage does not support BASM topic anchor ranges')
1344
- }
1345
- if (!Number.isInteger(fromHeight) || !Number.isInteger(toHeight) || fromHeight < 0 || toHeight < fromHeight) {
1346
- throw new Error('Invalid topic anchor range')
1347
- }
1348
- if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
1349
- throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`)
1350
- }
1351
-
1352
- return {
1353
- topic,
1354
- anchors: await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight)
1355
- }
1356
- }
1357
-
1358
- async provideAdmittedList(topic: string, blockHeight: number, blockHash?: string): Promise<AdmittedListResponse> {
1359
- if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
1360
- throw new TypeError('Storage does not support BASM admitted lists')
1361
- }
1362
-
1363
- return {
1364
- topic,
1365
- blockHeight,
1366
- blockHash,
1367
- admitted: await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash)
1368
- }
1369
- }
1370
-
1371
- async provideCompoundMerklePath(topic: string, blockHeight: number, txids: string[]): Promise<CompoundMerklePathResponse> {
1372
- if (typeof this.storage.findTransactionMerklePaths !== 'function') {
1373
- throw new TypeError('Storage does not support direct Merkle path lookup')
1374
- }
1375
- if (txids.length === 0) {
1376
- throw new Error('At least one txid is required')
1377
- }
1378
-
1379
- const admitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, blockHeight)
1380
- if (admitted !== undefined) {
1381
- const admittedSet = new Set(admitted.map(item => item.txid))
1382
- const missingAdmissions = txids.filter(txid => !admittedSet.has(txid))
1383
- if (missingAdmissions.length > 0) {
1384
- throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`)
1385
- }
1386
- }
1387
-
1388
- const proofs = await this.storage.findTransactionMerklePaths(txids)
1389
- const proofByTxid = new Map(proofs.map(proof => [proof.txid, proof]))
1390
- const missing = txids.filter(txid => !proofByTxid.has(txid))
1391
- if (missing.length > 0) {
1392
- throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`)
1393
- }
1394
-
1395
- let compound: MerklePath | undefined
1396
- for (const txid of txids) {
1397
- const proof = proofByTxid.get(txid)
1398
- if (proof === undefined) continue
1399
- const path = MerklePath.fromHex(proof.merklePath)
1400
- if (path.blockHeight !== blockHeight) {
1401
- throw new Error(`Merkle path for ${txid} is at height ${path.blockHeight}, expected ${blockHeight}`)
1402
- }
1403
- if (compound === undefined) {
1404
- compound = path
1405
- } else {
1406
- compound.combine(path)
1407
- }
1408
- }
1409
-
1410
- if (compound === undefined) {
1411
- throw new Error('Unable to build compound Merkle path')
1412
- }
1413
-
1414
- return {
1415
- topic,
1416
- blockHeight,
1417
- txids,
1418
- merklePath: compound.toHex()
1419
- }
1420
- }
1421
-
1422
- async provideRawTransactions(txids: string[]): Promise<RawTransactionResponse> {
1423
- if (typeof this.storage.findRawTransactions !== 'function') {
1424
- throw new TypeError('Storage does not support raw transaction lookup')
1425
- }
1426
-
1427
- const transactions = await this.storage.findRawTransactions(txids)
1428
- const found = new Set(transactions.map(tx => tx.txid))
1429
- return {
1430
- transactions,
1431
- missing: txids.filter(txid => !found.has(txid))
1432
- }
1433
- }
1434
-
1435
- async startBASMSync(): Promise<BASMPeerSyncReport[]> {
1436
- if (this.syncConfiguration === undefined) {
1437
- throw new Error('Overlay Service Engine not configured for topical synchronization!')
1438
- }
1439
-
1440
- const reports: BASMPeerSyncReport[] = []
1441
- for (const topic of Object.keys(this.syncConfiguration)) {
1442
- const endpoints = await this.resolveSyncEndpointsForTopic(topic)
1443
- for (const endpoint of endpoints) {
1444
- reports.push(await this.reconcileBASMWithPeer(topic, endpoint))
1445
- }
1446
- }
1447
-
1448
- return reports
1449
- }
1450
-
1451
- private async reconcileBASMWithPeer(topic: string, endpoint: string): Promise<BASMPeerSyncReport> {
1452
- const report: BASMPeerSyncReport = {
1453
- topic,
1454
- endpoint,
1455
- status: 'skipped',
1456
- checkedHeights: [],
1457
- missingTxids: [],
1458
- fetchedTxCount: 0
1459
- }
1460
-
1461
- try {
1462
- const remote = new BASMRemote(endpoint, topic)
1463
- const [localTip, remoteTip] = await Promise.all([
1464
- this.provideTopicAnchorTip(topic),
1465
- remote.requestTopicAnchorTip()
1466
- ])
1467
- report.localTip = localTip
1468
- report.remoteTip = remoteTip
1469
-
1470
- if (localTip.blockHeight >= remoteTip.blockHeight) {
1471
- report.status = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight ? 'matched' : 'diverged'
1472
- report.message = report.status === 'matched'
1473
- ? 'Topic anchor tips match'
1474
- : 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation'
1475
- return report
1476
- }
1477
-
1478
- const fromHeight = Math.max(localTip.blockHeight + 1, remoteTip.blockHeight - DEFAULT_BASM_RANGE_LIMIT + 1, 0)
1479
- const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight)
1480
- for (const remoteAnchor of range.anchors) {
1481
- await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report)
1482
- }
1483
-
1484
- const refreshedTip = await this.provideTopicAnchorTip(topic)
1485
- report.localTip = refreshedTip
1486
- report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced'
1487
- return report
1488
- } catch (error) {
1489
- report.status = 'error'
1490
- report.message = error instanceof Error ? error.message : String(error)
1491
- this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error)
1492
- return report
1493
- }
1494
- }
1495
-
1496
- private async reconcileRemoteAnchor(
1497
- topic: string,
1498
- remote: BASMRemote,
1499
- remoteAnchor: TopicBlockAnchor,
1500
- report: BASMPeerSyncReport
1501
- ): Promise<void> {
1502
- report.checkedHeights.push(remoteAnchor.blockHeight)
1503
- const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash)
1504
- if (localAnchor?.tac === remoteAnchor.tac) {
1505
- return
1506
- }
1507
-
1508
- const admittedResponse = await remote.requestAdmittedList(remoteAnchor.blockHeight, remoteAnchor.blockHash)
1509
- const remoteBasmRoot = computeBasmRoot(admittedResponse.admitted)
1510
- if (
1511
- remoteBasmRoot !== remoteAnchor.basmRoot ||
1512
- admittedResponse.admitted.length !== remoteAnchor.admittedCount
1513
- ) {
1514
- throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`)
1515
- }
1516
-
1517
- const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? []
1518
- const localTxids = new Set(localAdmitted.map(item => item.txid))
1519
- const missingTxids = admittedResponse.admitted
1520
- .map(item => item.txid)
1521
- .filter(txid => !localTxids.has(txid))
1522
-
1523
- report.missingTxids.push(...missingTxids)
1524
- if (missingTxids.length === 0) {
1525
- report.status = 'diverged'
1526
- return
1527
- }
1528
-
1529
- await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids)
1530
- report.fetchedTxCount += missingTxids.length
1531
- }
1532
-
1533
- private async fetchBASMMissingTransactions(
1534
- remote: BASMRemote,
1535
- topic: string,
1536
- anchor: TopicBlockAnchor,
1537
- txids: string[]
1538
- ): Promise<void> {
1539
- if (this.chainTracker === 'scripts only') {
1540
- throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs')
1541
- }
1542
-
1543
- const proofResponse = await remote.requestCompoundMerklePath(anchor.blockHeight, txids)
1544
- const compoundPath = MerklePath.fromHex(proofResponse.merklePath)
1545
- for (const txid of txids) {
1546
- const valid = await compoundPath.verify(txid, this.chainTracker)
1547
- if (!valid) {
1548
- throw new Error(`Peer supplied invalid compound Merkle path for ${txid} at height ${anchor.blockHeight}`)
1549
- }
1550
- }
1551
-
1552
- const rawResponse = await remote.requestRawTransactions(txids)
1553
- if (rawResponse.missing.length > 0) {
1554
- throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`)
1555
- }
1556
-
1557
- for (const record of rawResponse.transactions) {
1558
- const tx = Transaction.fromHex(record.rawTx)
1559
- if (tx.id('hex') !== record.txid) {
1560
- throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`)
1561
- }
1562
- try {
1563
- tx.merklePath = compoundPath.extract([record.txid])
1564
- } catch {
1565
- tx.merklePath = compoundPath
1566
- }
1567
- await this.submit({ beef: tx.toBEEF(), topics: [topic] }, undefined, 'historical-tx')
1568
- }
1569
- }
1570
-
1571
- async evictUnprovenTransactions(options: {
1572
- topic?: string
1573
- thresholdBlocks?: number
1574
- } = {}): Promise<{
1575
- cutoffHeight: number
1576
- candidates: number
1577
- evictedTransactions: number
1578
- evictedOutputs: number
1579
- }> {
1580
- if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1581
- throw new TypeError('Storage does not support unproven transaction eviction')
1582
- }
1583
- if (this.chainTracker === 'scripts only') {
1584
- throw new Error('Unproven eviction requires a ChainTracker to determine block age')
1585
- }
1586
-
1587
- const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
1588
- const currentHeight = await this.chainTracker.currentHeight()
1589
- const cutoffHeight = currentHeight - thresholdBlocks
1590
- const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
1591
- let evictedOutputs = 0
1592
-
1593
- for (const candidate of candidates) {
1594
- for (const output of candidate.outputs) {
1595
- for (const service of Object.values(this.lookupServices)) {
1596
- try {
1597
- await service.outputEvicted(output.txid, output.outputIndex)
1598
- } catch (error) {
1599
- this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
1600
- }
1601
- }
1602
- await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic)
1603
- evictedOutputs++
1604
- }
1605
- await this.storage.deleteAppliedTransaction?.(candidate.txid, candidate.topic)
1606
- }
1607
-
1608
- return {
1609
- cutoffHeight,
1610
- candidates: candidates.length,
1611
- evictedTransactions: candidates.length,
1612
- evictedOutputs
1613
- }
1614
- }
1615
-
1616
- async refreshUnprovenTransactionProofs(options: {
1617
- topic?: string
1618
- thresholdBlocks?: number
1619
- proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
1620
- }): Promise<{
1621
- cutoffHeight: number
1622
- candidates: number
1623
- refreshedTransactions: number
1624
- missingProofs: number
1625
- failedProofs: number
1626
- failures: Array<{ txid: string, error: string }>
1627
- }> {
1628
- if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1629
- throw new TypeError('Storage does not support unproven transaction lookup')
1630
- }
1631
- if (this.chainTracker === 'scripts only') {
1632
- throw new Error('Unproven proof refresh requires a ChainTracker to determine block age')
1633
- }
1634
-
1635
- const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
1636
- const currentHeight = await this.chainTracker.currentHeight()
1637
- const cutoffHeight = currentHeight - thresholdBlocks
1638
- const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
1639
- const txids = [...new Set(candidates.map(candidate => candidate.txid))]
1640
- let refreshedTransactions = 0
1641
- let missingProofs = 0
1642
- let failedProofs = 0
1643
- const failures: Array<{ txid: string, error: string }> = []
1644
-
1645
- for (const txid of txids) {
1646
- try {
1647
- const proof = await options.proofProvider(txid)
1648
- if (proof === undefined) {
1649
- missingProofs++
1650
- continue
1651
- }
1652
- await this.handleNewMerkleProof(txid, proof.merklePath, proof.blockHeight)
1653
- refreshedTransactions++
1654
- } catch (error) {
1655
- failedProofs++
1656
- failures.push({
1657
- txid,
1658
- error: error instanceof Error ? error.message : String(error)
1659
- })
1660
- }
1661
- }
1662
-
1663
- return {
1664
- cutoffHeight,
1665
- candidates: candidates.length,
1666
- refreshedTransactions,
1667
- missingProofs,
1668
- failedProofs,
1669
- failures
1670
- }
1671
- }
1672
-
1673
- async maintainUnprovenTransactions(options: {
1674
- topic?: string
1675
- thresholdBlocks?: number
1676
- proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
1677
- }): Promise<{
1678
- refresh: {
1679
- cutoffHeight: number
1680
- candidates: number
1681
- refreshedTransactions: number
1682
- missingProofs: number
1683
- failedProofs: number
1684
- failures: Array<{ txid: string, error: string }>
1685
- }
1686
- eviction: {
1687
- cutoffHeight: number
1688
- candidates: number
1689
- evictedTransactions: number
1690
- evictedOutputs: number
1691
- }
1692
- }> {
1693
- const refresh = await this.refreshUnprovenTransactionProofs(options)
1694
- const eviction = await this.evictUnprovenTransactions({
1695
- topic: options.topic,
1696
- thresholdBlocks: options.thresholdBlocks
1697
- })
1698
- return { refresh, eviction }
1699
- }
1700
-
1701
- async evictAppliedTransaction(txid: string, options: {
1702
- topic?: string
1703
- reason?: string
1704
- } = {}): Promise<{
1705
- txid: string
1706
- reason?: string
1707
- evictedTransactions: number
1708
- evictedOutputs: number
1709
- }> {
1710
- if (typeof this.storage.deleteAppliedTransaction !== 'function') {
1711
- throw new TypeError('Storage does not support applied transaction eviction')
1712
- }
1713
-
1714
- const outputs = await this.storage.findOutputsForTransaction(txid)
1715
- const filtered = options.topic === undefined
1716
- ? outputs
1717
- : outputs.filter(output => output.topic === options.topic)
1718
- const topics = [...new Set(filtered.map(output => output.topic))]
1719
- let evictedOutputs = 0
1720
-
1721
- for (const output of filtered) {
1722
- for (const service of Object.values(this.lookupServices)) {
1723
- try {
1724
- await service.outputEvicted(output.txid, output.outputIndex)
1725
- } catch (error) {
1726
- this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
1727
- }
1728
- }
1729
- await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic)
1730
- evictedOutputs++
1731
- }
1732
-
1733
- for (const topic of topics) {
1734
- await this.storage.deleteAppliedTransaction(txid, topic)
1735
- }
1736
-
1737
- return {
1738
- txid,
1739
- reason: options.reason,
1740
- evictedTransactions: topics.length,
1741
- evictedOutputs
1742
- }
1743
- }
1744
-
1745
- /**
1746
- * Given a GASP request, create an initial response.
1747
- *
1748
- * This method processes an initial synchronization request by finding the relevant UTXOs for the given topic
1749
- * since the provided block height in the request. It constructs a response that includes a list of these UTXOs
1750
- * and the min block height from the initial request.
1751
- *
1752
- * @param initialRequest - The GASP initial request containing the version and the block height since the last sync.
1753
- * @param topic - The topic for which UTXOs are being requested.
1754
- * @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided min block height.
1755
- */
1756
- async provideForeignSyncResponse(initialRequest: GASPInitialRequest, topic: string): Promise<GASPInitialResponse> {
1757
- const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, initialRequest.limit)
1758
-
1759
- return {
1760
- UTXOList: outputs.map(output => ({
1761
- txid: output.txid,
1762
- outputIndex: output.outputIndex,
1763
- score: output.score ?? 0
1764
- })),
1765
- since: initialRequest.since
1766
- }
1767
- }
1768
-
1769
- /**
1770
- * Provides a GASPNode for the given graphID, transaction ID, and output index.
1771
- *
1772
- * @param graphID - The identifier for the graph to which this node belongs (in the format txid.outputIndex).
1773
- * @param txid - The transaction ID for the requested output from somewhere within the graph's history.
1774
- * @param outputIndex - The index of the output in the transaction.
1775
- * @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
1776
- * @throws An error if no output is found for the given transaction ID and output index.
1777
- */
1778
- async provideForeignGASPNode(graphID: string, txid: string, outputIndex: number): Promise<GASPNode> {
1779
- const hydrator = async (output: Output | null): Promise<GASPNode> => {
1780
- if (output?.beef === undefined) {
1781
- throw new Error('No matching output found!')
1782
- }
1783
-
1784
- const rootTx = Transaction.fromBEEF(output.beef)
1785
- let correctTx: Transaction | undefined
1786
-
1787
- const searchInput = (tx: Transaction): void => {
1788
- if (tx.id('hex') === txid) {
1789
- correctTx = tx
1790
- } else {
1791
- // For each input, look it up and recurse.
1792
- for (const input of tx.inputs) {
1793
- // We should always have a source transaction
1794
- if (input.sourceTransaction === undefined) {
1795
- throw new Error('Incomplete SPV data!')
1796
- } else {
1797
- searchInput(input.sourceTransaction)
1798
- }
1799
- }
1800
- }
1801
- }
1802
-
1803
- searchInput(rootTx)
1804
-
1805
- if (correctTx === undefined) {
1806
- // Recursively try to find a matching output
1807
- let foundNode: GASPNode | undefined
1808
- for (const currentOutput of output.outputsConsumed) {
1809
- try {
1810
- const outputFound = await this.storage.findOutput(currentOutput.txid, currentOutput.outputIndex, undefined, undefined, true)
1811
- foundNode = await hydrator(outputFound)
1812
- break
1813
- } catch (error) {
1814
- // Best-effort: output may not be found or hydration fails for this candidate; try the next one
1815
- this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`)
1816
- continue
1817
- }
1818
- }
1819
- if (foundNode !== undefined) {
1820
- return foundNode
1821
- }
1822
- } else {
1823
- const rawTx = correctTx.toHex()
1824
- const node: GASPNode = {
1825
- rawTx,
1826
- graphID,
1827
- outputIndex
1828
- }
1829
- if (correctTx.merklePath !== undefined) {
1830
- node.proof = correctTx.merklePath.toHex()
1831
- }
1832
-
1833
- return node
1834
- }
1835
- throw new Error('Unable to find output associated with your request!')
1836
- }
1837
-
1838
- const [rootTxid, rootOutputIndex] = graphID.split('.')
1839
- const output = await this.storage.findOutput(rootTxid, Number(rootOutputIndex), undefined, undefined, true)
1840
- return await hydrator(output)
1841
- }
1842
-
1843
- /**
1844
- * Traverse and return the history of a UTXO.
1845
- *
1846
- * This method traverses the history of a given Unspent Transaction Output (UTXO) and returns
1847
- * its historical data based on the provided history selector and current depth.
1848
- *
1849
- * @param output - The UTXO to traverse the history for.
1850
- * @param historySelector - Optionally directs the history traversal:
1851
- * - If a number, denotes how many previous spends (in terms of chain depth) to include.
1852
- * - If a function, accepts a BEEF-formatted transaction, an output index, and the current depth as parameters,
1853
- * returning a promise that resolves to a boolean indicating whether to include the output in the history.
1854
- * @param {number} [currentDepth=0] - The current depth of the traversal relative to the top-level UTXO.
1855
- *
1856
- * @returns {Promise<Output | undefined>} - A promise that resolves to the output history if found, or undefined if not.
1857
- */
1858
- async getUTXOHistory(
1859
- output: Output,
1860
- historySelector?: ((beef: number[], outputIndex: number, currentDepth: number) => Promise<boolean>) | number,
1861
- currentDepth = 0,
1862
- context: UTXOHistoryHydrationContext = this.createUTXOHistoryHydrationContext()
1863
- ): Promise<Output | undefined> {
1864
- // If we have an output but no history selector, just return the output.
1865
- if (historySelector === undefined) {
1866
- return output
1867
- }
1868
-
1869
- try {
1870
- if (output.beef === undefined) {
1871
- throw new Error('Output must have associated transaction BEEF!')
1872
- }
1873
-
1874
- const hydratedNode = await this.hydrateUTXOHistoryNode(output, historySelector, currentDepth, context)
1875
- if (hydratedNode === undefined) {
1876
- return undefined
1877
- }
1878
-
1879
- return {
1880
- ...hydratedNode.output,
1881
- beef: hydratedNode.transaction.toBEEF()
1882
- }
1883
- } catch (e) {
1884
- // Handle any errors that occurred
1885
- // Note: Test this!
1886
- this.logger.error(`Error retrieving UTXO history: ${e} `)
1887
- // return []
1888
- throw new Error(`Error retrieving UTXO history: ${e} `)
1889
- }
1890
- }
1891
-
1892
- /**
1893
- * Delete a UTXO and all stale consumed inputs.
1894
- * @param output - The UTXO to be deleted.
1895
- * @returns {Promise<void>} - A promise that resolves when the deletion process is complete.
1896
- */
1897
- private async deleteUTXODeep(output: Output): Promise<void> {
1898
- try {
1899
- // Delete the current output IFF there are no references to it
1900
- if (output.consumedBy.length === 0) {
1901
- await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic)
1902
-
1903
- // Notify the lookup services of the UTXO being deleted
1904
- for (const l of Object.values(this.lookupServices)) {
1905
- try {
1906
- await l.outputNoLongerRetainedInHistory?.(
1907
- output.txid,
1908
- output.outputIndex,
1909
- output.topic
1910
- )
1911
- } catch (e) {
1912
- // Best-effort notification; lookup service failure must not abort UTXO deletion
1913
- this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`)
1914
- }
1915
- }
1916
- }
1917
-
1918
- // If there are no more consumed utxos, return
1919
- if (output.outputsConsumed.length === 0) {
1920
- return
1921
- }
1922
-
1923
- // Delete any stale outputs that were consumed as inputs
1924
- await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
1925
- const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic)
1926
-
1927
- // Make sure an output was found
1928
- if (staleOutput === null || staleOutput === undefined) {
1929
- return undefined
1930
- }
1931
-
1932
- // Parse out the existing data, then concat the new outputs with no duplicates
1933
- if (staleOutput.consumedBy.length !== 0) {
1934
- staleOutput.consumedBy = staleOutput.consumedBy.filter(x => x.txid !== output.txid && x.outputIndex !== output.outputIndex)
1935
- // Update with the new consumedBy data
1936
- await this.storage.updateConsumedBy(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic, staleOutput.consumedBy)
1937
- }
1938
-
1939
- // Find previousUTXO history
1940
- return await this.deleteUTXODeep(staleOutput)
1941
- }))
1942
- } catch (error) {
1943
- throw new Error(`Failed to delete all stale outputs: ${error as string} `)
1944
- }
1945
- }
1946
-
1947
- /**
1948
- * Given a new transaction proof (txid, proof),
1949
- * update tx.merklePath if appropriate,
1950
- * and if not, recurse through all input sourceTransactions.
1951
- *
1952
- * @param tx transaction which may benefit from new proof.
1953
- * @param txid BE hex string double hash of transaction proven by proof.
1954
- * @param proof for txid
1955
- */
1956
- private updateInputProofs(tx: Transaction, txid: string, proof: MerklePath): void {
1957
- if (tx.id('hex') === txid) {
1958
- // Update the merkle path to handle potential reorgs
1959
- tx.merklePath = proof
1960
- return
1961
- }
1962
- // A mined transaction's source graph is no longer part of its BEEF. Do not
1963
- // replace an unrelated transaction's proof with the proof for an ancestor.
1964
- if (tx.merklePath !== undefined) return
1965
-
1966
- for (const input of tx.inputs) {
1967
- // All inputs must have sourceTransactions
1968
- const stx = input.sourceTransaction
1969
- if (typeof stx !== 'object') continue
1970
- this.updateInputProofs(stx, txid, proof)
1971
- }
1972
- }
1973
-
1974
- /**
1975
- * Recursively updates beefs (merkle proofs) of this output and its consumedBy lineage.
1976
- *
1977
- * @param output - An output derived from txid which may benefit from new proof.
1978
- * @param txid - The txid for which proof is a valid merkle path.
1979
- * @param proof - The merklePath proving txid is a mined transaction hash
1980
- */
1981
- private async updateMerkleProof(output: Output, txid: string, proof: MerklePath): Promise<void> {
1982
- if (output.beef === undefined) {
1983
- throw new Error('Output must have associated transaction BEEF!')
1984
- }
1985
-
1986
- const tx = Transaction.fromBEEF(output.beef)
1987
- // Update this transaction, or recursively update the matching source
1988
- // transaction. This also persists replacement proofs after a reorg.
1989
- this.updateInputProofs(tx, txid, proof)
1990
-
1991
- // Update the output's BEEF in the storage DB
1992
- await this.storage.updateTransactionBEEF(output.txid, tx.toBEEF())
1993
-
1994
- // Recursively update the consumedBy outputs
1995
- for (const consumingOutput of output.consumedBy) {
1996
- const consumedOutputs = await this.storage.findOutputsForTransaction(consumingOutput.txid, true)
1997
- for (const consumedOutput of consumedOutputs) {
1998
- await this.updateMerkleProof(consumedOutput, txid, proof)
1999
- }
2000
- }
2001
- }
2002
-
2003
- /**
2004
- * Recursively prune UTXOs when an incoming Merkle Proof is received.
2005
- *
2006
- * @param txid - Transaction ID of the associated outputs to prune.
2007
- * @param proof - Merkle proof containing the Merkle path and other relevant data to verify the transaction.
2008
- * @param blockHeight - The block height associated with the incoming merkle proof.
2009
- */
2010
- async handleNewMerkleProof(txid: string, proof: MerklePath, blockHeight?: number): Promise<void> {
2011
- const outputs = await this.storage.findOutputsForTransaction(txid, true)
2012
-
2013
- if (outputs === undefined || outputs.length === 0) {
2014
- throw new Error('Could not find matching transaction outputs for proof ingest!')
2015
- }
2016
-
2017
- const proofMetadata = extractMerkleProofMetadata(txid, proof)
2018
- const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight
2019
- const resolvedBlockHash = resolvedBlockHeight === undefined
2020
- ? undefined
2021
- : await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot)
2022
-
2023
- for (const output of outputs) {
2024
- await this.updateMerkleProof(output, txid, proof)
2025
-
2026
- // Add the associated blockHeight
2027
- if (resolvedBlockHeight !== undefined) {
2028
- output.blockHeight = resolvedBlockHeight
2029
- await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, resolvedBlockHeight)
2030
- }
2031
-
2032
- if (output.beef !== undefined) {
2033
- const tx = Transaction.fromBEEF(output.beef)
2034
- this.updateInputProofs(tx, txid, proof)
2035
- await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash)
2036
- }
2037
-
2038
- if (resolvedBlockHeight !== undefined) {
2039
- await this.storage.updateAppliedTransactionProof?.({
2040
- txid,
2041
- topic: output.topic,
2042
- blockHeight: resolvedBlockHeight,
2043
- blockHash: resolvedBlockHash,
2044
- blockIndex: proofMetadata?.blockIndex,
2045
- merkleRoot: proofMetadata?.merkleRoot
2046
- })
2047
- }
2048
-
2049
- if (resolvedBlockHeight !== undefined && resolvedBlockHash !== undefined) {
2050
- await this.recomputeTopicBlockAnchor(output.topic, resolvedBlockHeight, resolvedBlockHash)
2051
- }
2052
- }
2053
- }
2054
-
2055
- /**
2056
- * Find a list of supported topic managers
2057
- * @public
2058
- * @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported topic managers and their metadata
2059
- */
2060
- async listTopicManagers(): Promise<Record<string, {
2061
- name: string
2062
- shortDescription: string
2063
- iconURL?: string
2064
- version?: string
2065
- informationURL?: string
2066
- }>> {
2067
- const result: Record<string, {
2068
- name: string
2069
- shortDescription: string
2070
- iconURL?: string
2071
- version?: string
2072
- informationURL?: string
2073
- }> = {}
2074
- for (const t in this.managers) {
2075
- try {
2076
- result[t] = await this.managers[t].getMetaData()
2077
- } catch (e) {
2078
- this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`)
2079
- result[t] = {
2080
- name: t,
2081
- shortDescription: 'No topical tagline.'
2082
- }
2083
- }
2084
- }
2085
- return result
2086
- }
2087
-
2088
- /**
2089
- * Find a list of supported lookup services
2090
- * @public
2091
- * @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported lookup services and their metadata
2092
- */
2093
- async listLookupServiceProviders(): Promise<Record<string, {
2094
- name: string
2095
- shortDescription: string
2096
- iconURL?: string
2097
- version?: string
2098
- informationURL?: string
2099
- }>> {
2100
- const result: Record<string, {
2101
- name: string
2102
- shortDescription: string
2103
- iconURL?: string
2104
- version?: string
2105
- informationURL?: string
2106
- }> = {}
2107
- for (const ls in this.lookupServices) {
2108
- try {
2109
- result[ls] = await this.lookupServices[ls].getMetaData()
2110
- } catch (e) {
2111
- this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`)
2112
- result[ls] = {
2113
- name: ls,
2114
- shortDescription: 'No lookup service tagline.'
2115
- }
2116
- }
2117
- }
2118
- return result
2119
- }
2120
-
2121
- /**
2122
- * Run a query to get the documentation for a particular topic manager
2123
- * @public
2124
- * @returns {Promise<string>} - the documentation for the topic manager
2125
- */
2126
- async getDocumentationForTopicManager(manager: any): Promise<string> {
2127
- const documentation = await this.managers[manager]?.getDocumentation?.()
2128
- return documentation ?? 'No documentation found!'
2129
- }
2130
-
2131
- /**
2132
- * Run a query to get the documentation for a particular lookup service
2133
- * @public
2134
- * @returns {Promise<string>} - the documentation for the lookup service
2135
- */
2136
- async getDocumentationForLookupServiceProvider(provider: any): Promise<string> {
2137
- const documentation = await this.lookupServices[provider]?.getDocumentation?.()
2138
- return documentation ?? 'No documentation found!'
2139
- }
2140
-
2141
- /**
2142
- * Validates a URL to ensure it does not match disallowed patterns:
2143
- * - Contains "http:" protocol
2144
- * - Contains "localhost" (with or without a port)
2145
- * - Internal or non-routable IP addresses (e.g., 192.168.x.x, 10.x.x.x, 172.16.x.x to 172.31.x.x)
2146
- * - Non-routable IPs like 127.x.x.x, 0.0.0.0, or IPv6 loopback (::1)
2147
- *
2148
- * @param url - The URL string to validate
2149
- * @returns {boolean} - Returns `false` if the URL violates any of the conditions `true` otherwise
2150
- */
2151
- private isValidUrl(url: string): boolean {
2152
- try {
2153
- const parsedUrl = new URL(url)
2154
-
2155
- // Disallow http:
2156
- if (parsedUrl.protocol === 'http:') {
2157
- return false
2158
- }
2159
-
2160
- // Disallow localhost with or without a port
2161
- if (/^localhost(:\d+)?$/i.test(parsedUrl.hostname)) {
2162
- return false
2163
- }
2164
-
2165
- // Disallow internal and non-routable IP addresses
2166
- const ipAddress = parsedUrl.hostname
2167
-
2168
- // Regex for non-routable IPv4 IPs
2169
- const nonRoutableIpv4Patterns = [
2170
- /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
2171
- /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.x.x.x private IPs
2172
- /^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.x.x private IPs
2173
- /^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
2174
- /^0\.0\.0\.0$/ // Non-routable address
2175
- ]
2176
-
2177
- // Check for IPv4 matches
2178
- if (nonRoutableIpv4Patterns.some((pattern) => pattern.test(ipAddress))) {
2179
- return false
2180
- }
2181
-
2182
- // Check for non-routable IPv6 addresses explicitly
2183
- if (ipAddress === '[::1]') {
2184
- return false
2185
- }
2186
-
2187
- // If none of the disallowed conditions matched, the URL is valid
2188
- return true
2189
- } catch (_e) {
2190
- // URL constructor throws on malformed input — not a valid URL, return false
2191
- return false
2192
- }
2193
- }
2194
- }