@bsv/overlay 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +57 -27
- package/README.md +102 -269
- package/dist/cjs/mod.d.ts +15 -0
- package/dist/cjs/mod.d.ts.map +1 -0
- package/dist/cjs/mod.js +4 -1
- package/dist/cjs/mod.js.map +1 -1
- package/dist/cjs/package.json +41 -13
- package/dist/cjs/src/Advertisement.d.ts +9 -0
- package/dist/cjs/src/Advertisement.d.ts.map +1 -0
- package/dist/cjs/src/Advertiser.d.ts +36 -0
- package/dist/cjs/src/Advertiser.d.ts.map +1 -0
- package/dist/cjs/src/BASM.d.ts +110 -0
- package/dist/cjs/src/BASM.d.ts.map +1 -0
- package/dist/cjs/src/BASM.js.map +1 -1
- package/dist/cjs/src/BASMRemote.d.ts +14 -0
- package/dist/cjs/src/BASMRemote.d.ts.map +1 -0
- package/dist/cjs/src/BASMRemote.js.map +1 -1
- package/dist/cjs/src/Engine.d.ts +379 -0
- package/dist/cjs/src/Engine.d.ts.map +1 -0
- package/dist/cjs/src/Engine.js +425 -418
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +24 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.js +7 -10
- package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +103 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +32 -30
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/cjs/src/LookupFormula.d.ts +29 -0
- package/dist/cjs/src/LookupFormula.d.ts.map +1 -0
- package/dist/cjs/src/LookupService.d.ts +85 -0
- package/dist/cjs/src/LookupService.d.ts.map +1 -0
- package/dist/cjs/src/Output.d.ts +32 -0
- package/dist/cjs/src/Output.d.ts.map +1 -0
- package/dist/cjs/src/SafeLog.d.ts +14 -0
- package/dist/cjs/src/SafeLog.d.ts.map +1 -0
- package/dist/cjs/src/SafeLog.js +44 -0
- package/dist/cjs/src/SafeLog.js.map +1 -0
- package/{src/SyncConfiguration.ts → dist/cjs/src/SyncConfiguration.d.ts} +2 -1
- package/dist/cjs/src/SyncConfiguration.d.ts.map +1 -0
- package/dist/cjs/src/TopicManager.d.ts +35 -0
- package/dist/cjs/src/TopicManager.d.ts.map +1 -0
- package/dist/cjs/src/storage/Storage.d.ts +237 -0
- package/dist/cjs/src/storage/Storage.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts +69 -0
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/KnexStorage.js +7 -10
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.d.ts +11 -0
- package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -1
- package/dist/esm/mod.js +2 -1
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/src/BASMRemote.js.map +1 -1
- package/dist/esm/src/Engine.js +404 -396
- 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 +21 -18
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/esm/src/SafeLog.js +40 -0
- package/dist/esm/src/SafeLog.js.map +1 -0
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/types/mod.d.ts +5 -4
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/BASMRemote.d.ts.map +1 -1
- package/dist/types/src/Engine.d.ts +18 -1
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts +2 -2
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts +3 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/types/src/SafeLog.d.ts +14 -0
- package/dist/types/src/SafeLog.d.ts.map +1 -0
- 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/package.json +85 -28
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +0 -1
- package/dist/types/tsconfig.types.tsbuildinfo +0 -1
- package/docs/API.md +0 -944
- package/docs/BRC-136-BASM.md +0 -327
- package/docs/README.md +0 -16
- package/docs/Synchronization.md +0 -212
- package/docs/concepts/00-overview.md +0 -85
- package/docs/concepts/01-best-practices.md +0 -202
- package/docs/concepts/02-query-performance.md +0 -345
- package/docs/concepts/03-database-monitoring.md +0 -211
- package/docs/concepts/04-pagination-example.md +0 -186
- package/docs/concepts/05-recommendations-summary.md +0 -158
- package/docs/concepts/README.md +0 -20
- package/docs/examples/README.md +0 -15
- package/docs/examples/gs-wip.md +0 -111
- package/docs/internal/README.md +0 -11
- package/mod.ts +0 -40
- package/src/Advertisement.ts +0 -8
- package/src/Advertiser.ts +0 -41
- package/src/BASM.ts +0 -208
- package/src/BASMRemote.ts +0 -54
- package/src/Engine.ts +0 -2194
- package/src/GASP/OverlayGASPRemote.ts +0 -108
- package/src/GASP/OverlayGASPStorage.ts +0 -384
- package/src/LookupFormula.ts +0 -31
- package/src/LookupService.ts +0 -136
- package/src/Output.ts +0 -31
- package/src/TopicManager.ts +0 -40
- package/src/__tests/BASM.test.ts +0 -44
- package/src/__tests/BASMChain.test.ts +0 -295
- package/src/__tests/Engine.test.ts +0 -1381
- package/src/__tests/OverlayGASPRemote.test.ts +0 -135
- package/src/__tests/OverlayGASPStorage.test.ts +0 -205
- package/src/storage/Storage.ts +0 -260
- package/src/storage/knex/KnexStorage.ts +0 -651
- package/src/storage/knex/all-migrations.ts +0 -32
- package/src/storage/knex/migrations/2024-05-18-001-initial.ts +0 -30
- package/src/storage/knex/migrations/2024-07-10-001-block-height.ts +0 -15
- package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +0 -64
- package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +0 -30
- package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +0 -42
- package/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.ts +0 -46
- package/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.ts +0 -24
- package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +0 -18
- package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +0 -123
package/dist/esm/src/Engine.js
CHANGED
|
@@ -4,8 +4,15 @@ import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js';
|
|
|
4
4
|
import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js';
|
|
5
5
|
import { BASM_ZERO_HASH, computeBasmRoot, computeTac, extractMerkleProofMetadata } from './BASM.js';
|
|
6
6
|
import { BASMRemote } from './BASMRemote.js';
|
|
7
|
+
import { serializeErrorForLog, serializeLogValue } from './SafeLog.js';
|
|
7
8
|
const DEFAULT_GASP_SYNC_LIMIT = 10000;
|
|
8
9
|
const DEFAULT_BASM_RANGE_LIMIT = 1024;
|
|
10
|
+
function findSpendingInputIndex(tx, output) {
|
|
11
|
+
return tx.inputs.findIndex(input => {
|
|
12
|
+
const realSource = input.sourceTXID || input.sourceTransaction?.id('hex');
|
|
13
|
+
return realSource === output.txid && input.sourceOutputIndex === output.outputIndex;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
9
16
|
/**
|
|
10
17
|
* An engine for running BSV Overlay Services (topic managers and lookup services).
|
|
11
18
|
*/
|
|
@@ -29,6 +36,7 @@ export class Engine {
|
|
|
29
36
|
topicAnchorHeaderResolver;
|
|
30
37
|
basmSyncEnabled;
|
|
31
38
|
unprovenEvictionBlocks;
|
|
39
|
+
maxLookupResults;
|
|
32
40
|
/**
|
|
33
41
|
* Creates a new Overlay Services Engine
|
|
34
42
|
* @param {[key: string]: TopicManager} managers - manages topic admittance
|
|
@@ -50,8 +58,9 @@ export class Engine {
|
|
|
50
58
|
* @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
|
|
51
59
|
* @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
|
|
52
60
|
* @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
|
|
61
|
+
* @param {number} maxLookupResults - Maximum lookup formulas hydrated per request. Use -1 to opt out.
|
|
53
62
|
*/
|
|
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) {
|
|
63
|
+
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, maxLookupResults = 1000) {
|
|
55
64
|
this.managers = managers;
|
|
56
65
|
this.lookupServices = lookupServices;
|
|
57
66
|
this.storage = storage;
|
|
@@ -71,14 +80,13 @@ export class Engine {
|
|
|
71
80
|
this.topicAnchorHeaderResolver = topicAnchorHeaderResolver;
|
|
72
81
|
this.basmSyncEnabled = basmSyncEnabled;
|
|
73
82
|
this.unprovenEvictionBlocks = unprovenEvictionBlocks;
|
|
83
|
+
this.maxLookupResults = maxLookupResults;
|
|
84
|
+
if (maxLookupResults !== -1 && (!Number.isSafeInteger(maxLookupResults) || maxLookupResults < 1)) {
|
|
85
|
+
throw new TypeError('maxLookupResults must be -1 or a positive safe integer');
|
|
86
|
+
}
|
|
74
87
|
// 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'.
|
|
75
88
|
// For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
|
|
76
|
-
|
|
77
|
-
this.syncConfiguration = {};
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
this.syncConfiguration = syncConfiguration;
|
|
81
|
-
}
|
|
89
|
+
this.syncConfiguration ??= {};
|
|
82
90
|
for (const managerName of Object.keys(managers)) {
|
|
83
91
|
if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
|
|
84
92
|
// Combine tm_ship trackers with preexisting entries if any
|
|
@@ -137,7 +145,7 @@ export class Engine {
|
|
|
137
145
|
return header.blockHash;
|
|
138
146
|
}
|
|
139
147
|
catch (error) {
|
|
140
|
-
this.logger.warn(`Unable to resolve BASM block hash
|
|
148
|
+
this.logger.warn(`Unable to resolve BASM block hash: height=${serializeLogValue(blockHeight)} error=${serializeErrorForLog(error)}`);
|
|
141
149
|
return undefined;
|
|
142
150
|
}
|
|
143
151
|
}
|
|
@@ -242,7 +250,7 @@ export class Engine {
|
|
|
242
250
|
}
|
|
243
251
|
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
244
252
|
// Bound the work per pass; the next trigger resumes from the new tip.
|
|
245
|
-
this.logger.warn(`[BASM] capping anchor chain extension
|
|
253
|
+
this.logger.warn(`[BASM] capping anchor chain extension: topic=${serializeLogValue(topic)} limit=${serializeLogValue(DEFAULT_BASM_RANGE_LIMIT)} requestedFrom=${serializeLogValue(fromHeight)} requestedTo=${serializeLogValue(toHeight)}; will continue on the next pass`);
|
|
246
254
|
toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
|
|
247
255
|
}
|
|
248
256
|
const previousAnchor = fromHeight > 0
|
|
@@ -256,7 +264,7 @@ export class Engine {
|
|
|
256
264
|
// canonical re-resolution from the header resolver instead of reusing it.
|
|
257
265
|
const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
|
|
258
266
|
if (blockHash === undefined) {
|
|
259
|
-
this.logger.warn(`[BASM] unable to resolve block hash
|
|
267
|
+
this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
|
|
260
268
|
return;
|
|
261
269
|
}
|
|
262
270
|
const basmRoot = computeBasmRoot(admitted);
|
|
@@ -383,381 +391,325 @@ export class Engine {
|
|
|
383
391
|
newTipHeight: tip
|
|
384
392
|
});
|
|
385
393
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
398
|
-
for (const t of taggedBEEF.topics) {
|
|
399
|
-
if (this.managers[t] === undefined || this.managers[t] === null) {
|
|
400
|
-
throw new Error(`This server does not support this topic: ${t}`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
// Validate the transaction SPV information
|
|
404
|
-
const tx = Transaction.fromBEEF(taggedBEEF.beef);
|
|
405
|
-
const txid = tx.id('hex');
|
|
406
|
-
this.startTime(`submit_${txid}`);
|
|
407
|
-
if (mode !== 'historical-tx-no-spv') {
|
|
408
|
-
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
409
|
-
const txValid = await tx.verify(this.chainTracker);
|
|
410
|
-
if (!txValid)
|
|
411
|
-
throw new Error('Unable to verify SPV information.');
|
|
412
|
-
this.endTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
413
|
-
}
|
|
414
|
-
const steak = {};
|
|
415
|
-
const dupeTopics = new Set();
|
|
416
|
-
const failedTopics = new Set();
|
|
417
|
-
const topicValidations = taggedBEEF.topics.map(async (topic) => {
|
|
418
|
-
try {
|
|
419
|
-
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
420
|
-
throw new Error(`This server does not support this topic: ${topic}`);
|
|
421
|
-
}
|
|
422
|
-
// Check for duplicate transactions
|
|
423
|
-
this.startTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
424
|
-
const dupeCheck = await this.storage.doesAppliedTransactionExist({ txid, topic });
|
|
425
|
-
this.endTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
426
|
-
if (dupeCheck) {
|
|
427
|
-
dupeTopics.add(topic);
|
|
428
|
-
return {
|
|
429
|
-
topic,
|
|
430
|
-
isDupe: true,
|
|
431
|
-
previousCoins: [],
|
|
432
|
-
previousOutputs: [],
|
|
433
|
-
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
// Identify previous coins admitted to this specific topic
|
|
437
|
-
const previousCoins = [];
|
|
438
|
-
const outputPromises = tx.inputs.map(async (input, i) => {
|
|
439
|
-
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
440
|
-
if (previousTXID !== undefined) {
|
|
441
|
-
// Check if the previous output was admitted to this specific topic
|
|
442
|
-
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
|
|
443
|
-
if (output !== undefined && output !== null) {
|
|
444
|
-
previousCoins.push(i);
|
|
445
|
-
return output;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return null;
|
|
449
|
-
});
|
|
450
|
-
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
451
|
-
const previousOutputs = await Promise.all(outputPromises);
|
|
452
|
-
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
453
|
-
// Determine which outputs are admissible for this topic (validation only)
|
|
454
|
-
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
455
|
-
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(taggedBEEF.beef, previousCoins, offChainValues, mode);
|
|
456
|
-
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
457
|
-
return {
|
|
458
|
-
topic,
|
|
459
|
-
isDupe: false,
|
|
460
|
-
previousCoins,
|
|
461
|
-
previousOutputs,
|
|
462
|
-
admissibleOutputs
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
catch (error) {
|
|
466
|
-
this.logger.error('Error validating topic during submit:', error);
|
|
467
|
-
failedTopics.add(topic);
|
|
394
|
+
async validateTopicSubmission(topic, context) {
|
|
395
|
+
const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
|
|
396
|
+
try {
|
|
397
|
+
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
398
|
+
throw new Error(`This server does not support this topic: ${topic}`);
|
|
399
|
+
}
|
|
400
|
+
this.startTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
401
|
+
const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
|
|
402
|
+
this.endTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
403
|
+
if (isDupe) {
|
|
404
|
+
dupeTopics.add(topic);
|
|
468
405
|
return {
|
|
469
406
|
topic,
|
|
470
|
-
isDupe:
|
|
407
|
+
isDupe: true,
|
|
471
408
|
previousCoins: [],
|
|
472
409
|
previousOutputs: [],
|
|
473
410
|
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
474
411
|
};
|
|
475
412
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
413
|
+
const previousCoins = [];
|
|
414
|
+
const outputPromises = tx.inputs.map(async (input, inputIndex) => {
|
|
415
|
+
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
416
|
+
if (previousTXID === undefined)
|
|
417
|
+
return null;
|
|
418
|
+
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
|
|
419
|
+
if (output !== undefined && output !== null)
|
|
420
|
+
previousCoins.push(inputIndex);
|
|
421
|
+
return output ?? null;
|
|
422
|
+
});
|
|
423
|
+
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
424
|
+
const previousOutputs = await Promise.all(outputPromises);
|
|
425
|
+
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
426
|
+
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
427
|
+
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
|
|
428
|
+
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
429
|
+
return {
|
|
430
|
+
topic,
|
|
431
|
+
isDupe: false,
|
|
432
|
+
previousCoins,
|
|
433
|
+
previousOutputs,
|
|
434
|
+
admissibleOutputs
|
|
435
|
+
};
|
|
481
436
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
437
|
+
catch (error) {
|
|
438
|
+
this.logger.error(`Error validating topic during submit: topic=${serializeLogValue(topic)} error=${serializeErrorForLog(error)}`);
|
|
439
|
+
failedTopics.add(topic);
|
|
440
|
+
return {
|
|
441
|
+
topic,
|
|
442
|
+
isDupe: false,
|
|
443
|
+
previousCoins: [],
|
|
444
|
+
previousOutputs: [],
|
|
445
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
isTopicSubmissionAccepted(validation, failedTopics) {
|
|
450
|
+
return !failedTopics.has(validation.topic) && (validation.isDupe ||
|
|
451
|
+
validation.admissibleOutputs.outputsToAdmit.length > 0 ||
|
|
452
|
+
validation.admissibleOutputs.coinsToRetain.length > 0 ||
|
|
453
|
+
validation.previousCoins.length > 0);
|
|
454
|
+
}
|
|
455
|
+
async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
|
|
499
456
|
this.startTime(`broadcast_${txid.substring(0, 10)}`);
|
|
500
457
|
if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
|
|
501
458
|
try {
|
|
502
459
|
let response;
|
|
503
460
|
if (tx.merklePath !== undefined) {
|
|
504
|
-
// tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
|
|
505
|
-
const txid = tx.id('hex');
|
|
506
461
|
const mp = tx.merklePath;
|
|
507
462
|
const leaf = mp.path[0].find(leaf => leaf.hash === txid);
|
|
508
|
-
|
|
463
|
+
response = {
|
|
509
464
|
status: 'success',
|
|
510
|
-
txid
|
|
511
|
-
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}
|
|
465
|
+
txid,
|
|
466
|
+
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`
|
|
512
467
|
};
|
|
513
|
-
response = r;
|
|
514
468
|
}
|
|
515
469
|
else {
|
|
516
470
|
response = await this.broadcaster.broadcast(tx);
|
|
517
471
|
}
|
|
518
472
|
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
throw
|
|
473
|
+
const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
|
|
474
|
+
error.more = response.more;
|
|
475
|
+
throw error;
|
|
522
476
|
}
|
|
523
477
|
}
|
|
524
478
|
catch (error) {
|
|
525
|
-
if (this.throwOnBroadcastFailure)
|
|
479
|
+
if (this.throwOnBroadcastFailure)
|
|
526
480
|
throw error;
|
|
527
|
-
}
|
|
528
481
|
this.logger.error('Error broadcasting transaction:', error);
|
|
529
482
|
}
|
|
530
483
|
}
|
|
531
484
|
this.endTime(`broadcast_${txid.substring(0, 10)}`);
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
485
|
+
}
|
|
486
|
+
async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
|
|
487
|
+
if (typeof lookupService.outputSpent !== 'function')
|
|
488
|
+
return;
|
|
489
|
+
if (lookupService.spendNotificationMode === 'txid') {
|
|
490
|
+
await lookupService.outputSpent({
|
|
491
|
+
mode: 'txid',
|
|
492
|
+
spendingTxid: txid,
|
|
493
|
+
txid: output.txid,
|
|
494
|
+
outputIndex: output.outputIndex,
|
|
495
|
+
topic
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
535
498
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
499
|
+
if (lookupService.spendNotificationMode === 'script') {
|
|
500
|
+
const inputIndex = findSpendingInputIndex(tx, output);
|
|
501
|
+
if (inputIndex === -1)
|
|
502
|
+
throw new Error('Could not find input index');
|
|
503
|
+
await lookupService.outputSpent({
|
|
504
|
+
mode: 'script',
|
|
505
|
+
spendingTxid: txid,
|
|
506
|
+
inputIndex,
|
|
507
|
+
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
508
|
+
unlockingScript: tx.inputs[inputIndex].unlockingScript,
|
|
509
|
+
txid: output.txid,
|
|
510
|
+
outputIndex: output.outputIndex,
|
|
511
|
+
topic,
|
|
512
|
+
offChainValues
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (lookupService.spendNotificationMode === 'whole-tx') {
|
|
517
|
+
await lookupService.outputSpent({
|
|
518
|
+
mode: 'whole-tx',
|
|
519
|
+
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
520
|
+
txid: output.txid,
|
|
521
|
+
outputIndex: output.outputIndex,
|
|
522
|
+
topic,
|
|
523
|
+
offChainValues
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
await lookupService.outputSpent({
|
|
528
|
+
mode: 'none',
|
|
529
|
+
txid: output.txid,
|
|
530
|
+
outputIndex: output.outputIndex,
|
|
531
|
+
topic
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
|
|
535
|
+
if (output === null)
|
|
536
|
+
return;
|
|
537
|
+
try {
|
|
538
|
+
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
|
|
539
|
+
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
|
|
540
|
+
try {
|
|
541
|
+
await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
this.logger.error('Error in lookup service for outputSpent:', error);
|
|
545
|
+
}
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
this.logger.error('Error marking UTXO as spent:', error);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
|
|
540
553
|
await Promise.all(validations.map(async (validation) => {
|
|
541
|
-
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
554
|
+
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
542
555
|
return;
|
|
556
|
+
await Promise.all(validation.previousOutputs.map(async (output) => {
|
|
557
|
+
await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
|
|
558
|
+
}));
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
classifyPreviousCoins(tx, validation) {
|
|
562
|
+
const outputsConsumed = [];
|
|
563
|
+
const outputsToMarkStale = [];
|
|
564
|
+
for (const inputIndex of validation.previousCoins) {
|
|
565
|
+
const input = tx.inputs[inputIndex];
|
|
566
|
+
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
567
|
+
if (typeof previousTXID !== 'string')
|
|
568
|
+
continue;
|
|
569
|
+
if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
|
|
570
|
+
outputsConsumed.push({
|
|
571
|
+
txid: previousTXID,
|
|
572
|
+
outputIndex: input.sourceOutputIndex
|
|
573
|
+
});
|
|
543
574
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
575
|
+
else {
|
|
576
|
+
outputsToMarkStale.push({
|
|
577
|
+
txid: previousTXID,
|
|
578
|
+
previousOutputIndex: input.sourceOutputIndex,
|
|
579
|
+
inputIndex
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { outputsConsumed, outputsToMarkStale };
|
|
584
|
+
}
|
|
585
|
+
async removeStaleOutputs(outputs, topic, txid) {
|
|
586
|
+
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
587
|
+
await Promise.all(outputs.map(async (coin) => {
|
|
588
|
+
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
|
|
589
|
+
if (output !== undefined && output !== null)
|
|
590
|
+
await this.deleteUTXODeep(output);
|
|
591
|
+
}));
|
|
592
|
+
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
593
|
+
}
|
|
594
|
+
async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
|
|
595
|
+
if (lookupService.admissionMode === 'locking-script') {
|
|
596
|
+
if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
|
|
597
|
+
typeof tx.outputs[outputIndex].satoshis !== 'number')
|
|
598
|
+
return;
|
|
599
|
+
await lookupService.outputAdmittedByTopic({
|
|
600
|
+
mode: 'locking-script',
|
|
601
|
+
txid,
|
|
602
|
+
outputIndex,
|
|
603
|
+
lockingScript: tx.outputs[outputIndex].lockingScript,
|
|
604
|
+
satoshis: tx.outputs[outputIndex].satoshis,
|
|
605
|
+
topic,
|
|
606
|
+
offChainValues
|
|
607
|
+
});
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
await lookupService.outputAdmittedByTopic({
|
|
611
|
+
mode: 'whole-tx',
|
|
612
|
+
atomicBEEF: tx.toAtomicBEEF(),
|
|
613
|
+
outputIndex,
|
|
614
|
+
topic,
|
|
615
|
+
offChainValues
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async admitOutput(outputIndex, context) {
|
|
619
|
+
const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
|
|
620
|
+
if (typeof tx.outputs[outputIndex].satoshis !== 'number')
|
|
621
|
+
return;
|
|
622
|
+
this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
623
|
+
await this.storage.insertOutput({
|
|
624
|
+
txid,
|
|
625
|
+
outputIndex,
|
|
626
|
+
outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
|
|
627
|
+
satoshis: tx.outputs[outputIndex].satoshis,
|
|
628
|
+
topic,
|
|
629
|
+
spent: false,
|
|
630
|
+
beef: this.compactBEEFForStorage(tx, beef),
|
|
631
|
+
consumedBy: [],
|
|
632
|
+
outputsConsumed,
|
|
633
|
+
score: Date.now(),
|
|
634
|
+
blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
|
|
635
|
+
});
|
|
636
|
+
this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
637
|
+
newUTXOs.push({ txid, outputIndex });
|
|
638
|
+
this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
639
|
+
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
|
|
640
|
+
try {
|
|
641
|
+
await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
|
|
645
|
+
}
|
|
646
|
+
}));
|
|
647
|
+
this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
648
|
+
}
|
|
649
|
+
async updateConsumedOutput(output, newUTXOs, topic) {
|
|
650
|
+
const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
|
|
651
|
+
if (storedOutput === undefined || storedOutput === null)
|
|
652
|
+
return;
|
|
653
|
+
const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
|
|
654
|
+
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
|
|
655
|
+
}
|
|
656
|
+
async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
|
|
657
|
+
const topic = validation.topic;
|
|
658
|
+
const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
|
|
659
|
+
await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
|
|
660
|
+
steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
|
|
661
|
+
const newUTXOs = [];
|
|
662
|
+
await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
|
|
663
|
+
await this.admitOutput(outputIndex, {
|
|
664
|
+
tx,
|
|
665
|
+
txid,
|
|
666
|
+
beef,
|
|
667
|
+
topic,
|
|
668
|
+
outputsConsumed,
|
|
669
|
+
newUTXOs,
|
|
670
|
+
offChainValues
|
|
615
671
|
});
|
|
616
|
-
await Promise.all(markSpentPromises);
|
|
617
672
|
}));
|
|
618
|
-
|
|
673
|
+
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
674
|
+
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
|
|
675
|
+
await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
|
|
676
|
+
await Promise.all([
|
|
677
|
+
...outputsConsumed.map(async (output) => {
|
|
678
|
+
await this.updateConsumedOutput(output, newUTXOs, topic);
|
|
679
|
+
}),
|
|
680
|
+
this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
|
|
681
|
+
]);
|
|
682
|
+
if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
|
|
683
|
+
await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
|
|
684
|
+
}
|
|
685
|
+
this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
686
|
+
}
|
|
687
|
+
async applyStorageMutations(validations, context) {
|
|
688
|
+
const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
|
|
619
689
|
for (const validation of validations) {
|
|
620
690
|
const topic = validation.topic;
|
|
621
|
-
if (dupeTopics.has(topic))
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
if (failedTopics.has(topic)) {
|
|
691
|
+
if (dupeTopics.has(topic) || failedTopics.has(topic))
|
|
625
692
|
continue;
|
|
626
|
-
}
|
|
627
693
|
try {
|
|
628
|
-
|
|
629
|
-
const outputsToAdmit = admissibleOutputs.outputsToAdmit;
|
|
630
|
-
const outputsConsumed = [];
|
|
631
|
-
const outputsToMarkStale = [];
|
|
632
|
-
// Use previousCoins from validation
|
|
633
|
-
const previousCoins = validation.previousCoins;
|
|
634
|
-
// For each of the previous UTXOs for this topic, if the UTXO was not included in the list of UTXOs identified for retention, then it will be marked as stale.
|
|
635
|
-
for (const inputIndex of previousCoins) {
|
|
636
|
-
const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex');
|
|
637
|
-
if (typeof previousTXID !== 'string')
|
|
638
|
-
continue;
|
|
639
|
-
const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex;
|
|
640
|
-
if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
|
|
641
|
-
outputsConsumed.push({
|
|
642
|
-
txid: previousTXID,
|
|
643
|
-
outputIndex: previousOutputIndex
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
outputsToMarkStale.push({
|
|
648
|
-
txid: previousTXID,
|
|
649
|
-
previousOutputIndex,
|
|
650
|
-
inputIndex
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
// Remove stale outputs recursively
|
|
655
|
-
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
656
|
-
await Promise.all(outputsToMarkStale.map(async (coin) => {
|
|
657
|
-
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
|
|
658
|
-
if (output !== undefined && output !== null) {
|
|
659
|
-
await this.deleteUTXODeep(output);
|
|
660
|
-
}
|
|
661
|
-
}));
|
|
662
|
-
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
663
|
-
// Update the STEAK to indicate which coins were removed
|
|
664
|
-
steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex);
|
|
665
|
-
// Handle admittance and notification of incoming UTXOs
|
|
666
|
-
const newUTXOs = [];
|
|
667
|
-
await Promise.all(outputsToAdmit.map(async (outputIndex) => {
|
|
668
|
-
if (typeof tx.outputs[outputIndex].satoshis !== 'number')
|
|
669
|
-
return;
|
|
670
|
-
this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
671
|
-
await this.storage.insertOutput({
|
|
672
|
-
txid,
|
|
673
|
-
outputIndex,
|
|
674
|
-
outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
|
|
675
|
-
satoshis: tx.outputs[outputIndex].satoshis,
|
|
676
|
-
topic,
|
|
677
|
-
spent: false,
|
|
678
|
-
beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
|
|
679
|
-
consumedBy: [],
|
|
680
|
-
outputsConsumed,
|
|
681
|
-
score: Date.now(),
|
|
682
|
-
blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
|
|
683
|
-
});
|
|
684
|
-
this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
685
|
-
newUTXOs.push({ txid, outputIndex });
|
|
686
|
-
this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
687
|
-
await Promise.all(Object.values(this.lookupServices).map(async (l) => {
|
|
688
|
-
try {
|
|
689
|
-
if (l.admissionMode === 'locking-script') {
|
|
690
|
-
if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
|
|
691
|
-
typeof tx.outputs[outputIndex].satoshis !== 'number') {
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
await l.outputAdmittedByTopic({
|
|
695
|
-
mode: 'locking-script',
|
|
696
|
-
txid,
|
|
697
|
-
outputIndex,
|
|
698
|
-
lockingScript: tx.outputs[outputIndex].lockingScript,
|
|
699
|
-
satoshis: tx.outputs[outputIndex].satoshis,
|
|
700
|
-
topic,
|
|
701
|
-
offChainValues
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
await l.outputAdmittedByTopic({
|
|
706
|
-
mode: 'whole-tx',
|
|
707
|
-
atomicBEEF: tx.toAtomicBEEF(),
|
|
708
|
-
outputIndex,
|
|
709
|
-
topic,
|
|
710
|
-
offChainValues
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
catch (error) {
|
|
715
|
-
this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
|
|
716
|
-
}
|
|
717
|
-
}));
|
|
718
|
-
this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
719
|
-
}));
|
|
720
|
-
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
721
|
-
// Update each output consumed to know who consumed it and insert applied transaction in parallel
|
|
722
|
-
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
|
|
723
|
-
await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash);
|
|
724
|
-
await Promise.all([
|
|
725
|
-
...outputsConsumed.map(async (output) => {
|
|
726
|
-
const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic);
|
|
727
|
-
if (outputToUpdate !== undefined && outputToUpdate !== null) {
|
|
728
|
-
const newConsumedBy = [...new Set([...newUTXOs, ...outputToUpdate.consumedBy])];
|
|
729
|
-
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, newConsumedBy);
|
|
730
|
-
}
|
|
731
|
-
}),
|
|
732
|
-
this.storage.insertAppliedTransaction({
|
|
733
|
-
txid,
|
|
734
|
-
topic,
|
|
735
|
-
...appliedRecord
|
|
736
|
-
})
|
|
737
|
-
]);
|
|
738
|
-
if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
|
|
739
|
-
await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
|
|
740
|
-
}
|
|
741
|
-
this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
694
|
+
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
742
695
|
}
|
|
743
696
|
catch (error) {
|
|
744
697
|
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
|
|
745
698
|
}
|
|
746
699
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
return steak;
|
|
750
|
-
}
|
|
700
|
+
}
|
|
701
|
+
async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
|
|
751
702
|
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
752
|
-
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
703
|
+
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
704
|
+
!dupeTopics.has(topic) &&
|
|
705
|
+
(steak[topic].outputsToAdmit.length !== 0 ||
|
|
706
|
+
steak[topic].coinsRemoved?.length !== 0));
|
|
753
707
|
if (relevantTopics.length === 0) {
|
|
754
708
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
755
|
-
return
|
|
709
|
+
return;
|
|
756
710
|
}
|
|
757
|
-
// Create a SHIPBroadcaster instance
|
|
758
711
|
let customBroadcasterConfig;
|
|
759
712
|
if (Array.isArray(this.slapTrackers)) {
|
|
760
|
-
// Custom SLAP trackers warrant a custom broadcaster config
|
|
761
713
|
const resolverConfig = {
|
|
762
714
|
slapTrackers: this.slapTrackers
|
|
763
715
|
};
|
|
@@ -773,6 +725,93 @@ export class Engine {
|
|
|
773
725
|
this.logger.error('Error during propagation to other nodes:', error);
|
|
774
726
|
}
|
|
775
727
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Submits a transaction for processing by Overlay Services.
|
|
731
|
+
* @param {TaggedBEEF} taggedBEEF - The transaction to process
|
|
732
|
+
* @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
|
|
733
|
+
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
|
|
734
|
+
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
|
|
735
|
+
*
|
|
736
|
+
* The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
|
|
737
|
+
*
|
|
738
|
+
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
|
|
739
|
+
*/
|
|
740
|
+
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
741
|
+
for (const t of taggedBEEF.topics) {
|
|
742
|
+
if (this.managers[t] === undefined || this.managers[t] === null) {
|
|
743
|
+
throw new Error(`This server does not support this topic: ${t}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Validate the transaction SPV information
|
|
747
|
+
const tx = Transaction.fromBEEF(taggedBEEF.beef);
|
|
748
|
+
const txid = tx.id('hex');
|
|
749
|
+
this.startTime(`submit_${txid}`);
|
|
750
|
+
if (mode !== 'historical-tx-no-spv') {
|
|
751
|
+
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
752
|
+
const txValid = await tx.verify(this.chainTracker);
|
|
753
|
+
if (!txValid)
|
|
754
|
+
throw new Error('Unable to verify SPV information.');
|
|
755
|
+
this.endTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
756
|
+
}
|
|
757
|
+
const steak = {};
|
|
758
|
+
const dupeTopics = new Set();
|
|
759
|
+
const failedTopics = new Set();
|
|
760
|
+
// ===================================================================
|
|
761
|
+
// PHASE 1: VALIDATE (read-only, no mutations)
|
|
762
|
+
// ===================================================================
|
|
763
|
+
const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
|
|
764
|
+
tx,
|
|
765
|
+
txid,
|
|
766
|
+
beef: taggedBEEF.beef,
|
|
767
|
+
offChainValues,
|
|
768
|
+
mode,
|
|
769
|
+
dupeTopics,
|
|
770
|
+
failedTopics
|
|
771
|
+
}));
|
|
772
|
+
const validations = await Promise.all(topicValidations);
|
|
773
|
+
// Build preliminary STEAK from validation results
|
|
774
|
+
for (const validation of validations) {
|
|
775
|
+
steak[validation.topic] = validation.admissibleOutputs;
|
|
776
|
+
}
|
|
777
|
+
// ===================================================================
|
|
778
|
+
// PHASE 2: BROADCAST (before any mutations)
|
|
779
|
+
// ===================================================================
|
|
780
|
+
// Only broadcast when at least one topic actually accepted the
|
|
781
|
+
// transaction. For a non-failed topic, acceptance means: previously
|
|
782
|
+
// accepted (dupe / client retry), outputs admitted, coins retained, or
|
|
783
|
+
// previously-admitted coins consumed (e.g. a consume-only deletion such
|
|
784
|
+
// as a KVStore remove, even one that retains nothing). A topic manager
|
|
785
|
+
// REJECTS by throwing from identifyAdmissibleOutputs (tracked in
|
|
786
|
+
// failedTopics). A transaction every topic rejected must never reach the
|
|
787
|
+
// network: submitters treat an empty STEAK as a rejection and
|
|
788
|
+
// abort/release their held inputs, so broadcasting it anyway would
|
|
789
|
+
// desync their wallets from the chain.
|
|
790
|
+
const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
|
|
791
|
+
await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
|
|
792
|
+
// Call the callback function with STEAK if it is provided (before storage mutations)
|
|
793
|
+
if (onSteakReady !== undefined) {
|
|
794
|
+
onSteakReady(steak);
|
|
795
|
+
}
|
|
796
|
+
// ===================================================================
|
|
797
|
+
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
798
|
+
// ===================================================================
|
|
799
|
+
// Mark previous outputs as spent and notify lookup services
|
|
800
|
+
await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
|
|
801
|
+
await this.applyStorageMutations(validations, {
|
|
802
|
+
dupeTopics,
|
|
803
|
+
failedTopics,
|
|
804
|
+
steak,
|
|
805
|
+
tx,
|
|
806
|
+
txid,
|
|
807
|
+
beef: taggedBEEF.beef,
|
|
808
|
+
offChainValues
|
|
809
|
+
});
|
|
810
|
+
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
|
|
811
|
+
if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
|
|
812
|
+
return steak;
|
|
813
|
+
}
|
|
814
|
+
await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
|
|
776
815
|
// Immediately return from the function without waiting for the promises to resolve.
|
|
777
816
|
return steak;
|
|
778
817
|
}
|
|
@@ -787,6 +826,9 @@ export class Engine {
|
|
|
787
826
|
if (lookupService === undefined || lookupService === null)
|
|
788
827
|
throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
|
|
789
828
|
const lookupResult = await lookupService.lookup(lookupQuestion);
|
|
829
|
+
if (this.maxLookupResults !== -1 && lookupResult.length > this.maxLookupResults) {
|
|
830
|
+
throw new RangeError(`Lookup returned ${lookupResult.length} results; maximum is ${this.maxLookupResults}`);
|
|
831
|
+
}
|
|
790
832
|
const hydrationContext = this.createUTXOHistoryHydrationContext();
|
|
791
833
|
await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
|
|
792
834
|
const hydratedOutputs = (await Promise.all(lookupResult.map(async ({ txid, outputIndex, history, context }) => {
|
|
@@ -1004,71 +1046,37 @@ export class Engine {
|
|
|
1004
1046
|
throw new Error('Overlay Service Engine not configured for topical synchronization!');
|
|
1005
1047
|
}
|
|
1006
1048
|
for (const topic of Object.keys(this.syncConfiguration)) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
// Check if this topic has been configured NOT to sync
|
|
1010
|
-
if (syncEndpoints === false) {
|
|
1049
|
+
const configuredEndpoints = this.syncConfiguration[topic];
|
|
1050
|
+
if (configuredEndpoints === false)
|
|
1011
1051
|
continue;
|
|
1052
|
+
if (!Array.isArray(configuredEndpoints) && configuredEndpoints !== 'SHIP')
|
|
1053
|
+
continue;
|
|
1054
|
+
const syncEndpoints = await this.resolveSyncEndpointsForTopic(topic, 'Failed to parse advertisement output:');
|
|
1055
|
+
this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
|
|
1056
|
+
// Sync with each endpoint sequentially to avoid parallel locks while
|
|
1057
|
+
// keeping peer failures isolated.
|
|
1058
|
+
for (const endpoint of syncEndpoints) {
|
|
1059
|
+
await this.syncGASPWithPeer(topic, endpoint);
|
|
1012
1060
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1024
|
-
});
|
|
1025
|
-
// Lookup will currently always return type output-list
|
|
1026
|
-
if (lookupAnswer.type === 'output-list') {
|
|
1027
|
-
const endpointSet = new Set();
|
|
1028
|
-
lookupAnswer.outputs.forEach(output => {
|
|
1029
|
-
try {
|
|
1030
|
-
// Parse out the advertisements using the provided parser
|
|
1031
|
-
const tx = Transaction.fromBEEF(output.beef);
|
|
1032
|
-
const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
|
|
1033
|
-
if (advertisement?.protocol === 'SHIP') {
|
|
1034
|
-
endpointSet.add(advertisement.domain);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
catch (error) {
|
|
1038
|
-
this.logger.error('Failed to parse advertisement output:', error);
|
|
1039
|
-
}
|
|
1040
|
-
});
|
|
1041
|
-
syncEndpoints = Array.from(endpointSet);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
// Now syncEndpoints is guaranteed to be an array of strings without duplicates
|
|
1045
|
-
if (Array.isArray(syncEndpoints)) {
|
|
1046
|
-
// Remove our own hosting URL so we don't sync with ourselves
|
|
1047
|
-
syncEndpoints = syncEndpoints.filter((endpoint) => endpoint !== this.hostingURL);
|
|
1048
|
-
this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
|
|
1049
|
-
// Sync with each endpoint individually to avoid parallel locks and let failures be isolated
|
|
1050
|
-
for (const endpoint of syncEndpoints) {
|
|
1051
|
-
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
|
|
1052
|
-
try {
|
|
1053
|
-
// Read the last interaction score from storage
|
|
1054
|
-
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
|
|
1055
|
-
const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
|
|
1056
|
-
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
|
|
1057
|
-
// Save the updated last interaction score
|
|
1058
|
-
if (gasp.lastInteraction > lastInteraction) {
|
|
1059
|
-
await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
|
|
1060
|
-
}
|
|
1061
|
-
this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
|
|
1062
|
-
}
|
|
1063
|
-
catch (err) {
|
|
1064
|
-
this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, err);
|
|
1065
|
-
// Continue on to the next endpoint without throwing
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async syncGASPWithPeer(topic, endpoint) {
|
|
1064
|
+
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
|
|
1065
|
+
try {
|
|
1066
|
+
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
|
|
1067
|
+
const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
|
|
1068
|
+
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
|
|
1069
|
+
if (gasp.lastInteraction > lastInteraction) {
|
|
1070
|
+
await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
|
|
1068
1071
|
}
|
|
1072
|
+
this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
|
|
1076
|
+
// Continue on to the next endpoint without throwing.
|
|
1069
1077
|
}
|
|
1070
1078
|
}
|
|
1071
|
-
async resolveSyncEndpointsForTopic(topic) {
|
|
1079
|
+
async resolveSyncEndpointsForTopic(topic, advertisementErrorMessage = 'Failed to parse BASM advertisement output:') {
|
|
1072
1080
|
if (this.syncConfiguration === undefined) {
|
|
1073
1081
|
return [];
|
|
1074
1082
|
}
|
|
@@ -1098,7 +1106,7 @@ export class Engine {
|
|
|
1098
1106
|
}
|
|
1099
1107
|
}
|
|
1100
1108
|
catch (error) {
|
|
1101
|
-
this.logger.error(
|
|
1109
|
+
this.logger.error(advertisementErrorMessage, error);
|
|
1102
1110
|
}
|
|
1103
1111
|
});
|
|
1104
1112
|
}
|
|
@@ -1805,7 +1813,7 @@ export class Engine {
|
|
|
1805
1813
|
// If none of the disallowed conditions matched, the URL is valid
|
|
1806
1814
|
return true;
|
|
1807
1815
|
}
|
|
1808
|
-
catch
|
|
1816
|
+
catch {
|
|
1809
1817
|
// URL constructor throws on malformed input — not a valid URL, return false
|
|
1810
1818
|
return false;
|
|
1811
1819
|
}
|