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