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