@bsv/overlay 2.2.1 → 2.2.7
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 +377 -0
- package/dist/cjs/src/Engine.d.ts.map +1 -0
- package/dist/cjs/src/Engine.js +416 -417
- 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 +394 -395
- 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 +15 -0
- 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
|
*/
|
|
@@ -73,12 +80,7 @@ export class Engine {
|
|
|
73
80
|
this.unprovenEvictionBlocks = unprovenEvictionBlocks;
|
|
74
81
|
// 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
82
|
// 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
|
-
}
|
|
83
|
+
this.syncConfiguration ??= {};
|
|
82
84
|
for (const managerName of Object.keys(managers)) {
|
|
83
85
|
if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
|
|
84
86
|
// Combine tm_ship trackers with preexisting entries if any
|
|
@@ -137,7 +139,7 @@ export class Engine {
|
|
|
137
139
|
return header.blockHash;
|
|
138
140
|
}
|
|
139
141
|
catch (error) {
|
|
140
|
-
this.logger.warn(`Unable to resolve BASM block hash
|
|
142
|
+
this.logger.warn(`Unable to resolve BASM block hash: height=${serializeLogValue(blockHeight)} error=${serializeErrorForLog(error)}`);
|
|
141
143
|
return undefined;
|
|
142
144
|
}
|
|
143
145
|
}
|
|
@@ -242,7 +244,7 @@ export class Engine {
|
|
|
242
244
|
}
|
|
243
245
|
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
244
246
|
// Bound the work per pass; the next trigger resumes from the new tip.
|
|
245
|
-
this.logger.warn(`[BASM] capping anchor chain extension
|
|
247
|
+
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
248
|
toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
|
|
247
249
|
}
|
|
248
250
|
const previousAnchor = fromHeight > 0
|
|
@@ -256,7 +258,7 @@ export class Engine {
|
|
|
256
258
|
// canonical re-resolution from the header resolver instead of reusing it.
|
|
257
259
|
const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
|
|
258
260
|
if (blockHash === undefined) {
|
|
259
|
-
this.logger.warn(`[BASM] unable to resolve block hash
|
|
261
|
+
this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
|
|
260
262
|
return;
|
|
261
263
|
}
|
|
262
264
|
const basmRoot = computeBasmRoot(admitted);
|
|
@@ -383,381 +385,325 @@ export class Engine {
|
|
|
383
385
|
newTipHeight: tip
|
|
384
386
|
});
|
|
385
387
|
}
|
|
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);
|
|
388
|
+
async validateTopicSubmission(topic, context) {
|
|
389
|
+
const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
|
|
390
|
+
try {
|
|
391
|
+
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
392
|
+
throw new Error(`This server does not support this topic: ${topic}`);
|
|
393
|
+
}
|
|
394
|
+
this.startTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
395
|
+
const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
|
|
396
|
+
this.endTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
397
|
+
if (isDupe) {
|
|
398
|
+
dupeTopics.add(topic);
|
|
468
399
|
return {
|
|
469
400
|
topic,
|
|
470
|
-
isDupe:
|
|
401
|
+
isDupe: true,
|
|
471
402
|
previousCoins: [],
|
|
472
403
|
previousOutputs: [],
|
|
473
404
|
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
474
405
|
};
|
|
475
406
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
407
|
+
const previousCoins = [];
|
|
408
|
+
const outputPromises = tx.inputs.map(async (input, inputIndex) => {
|
|
409
|
+
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
410
|
+
if (previousTXID === undefined)
|
|
411
|
+
return null;
|
|
412
|
+
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
|
|
413
|
+
if (output !== undefined && output !== null)
|
|
414
|
+
previousCoins.push(inputIndex);
|
|
415
|
+
return output ?? null;
|
|
416
|
+
});
|
|
417
|
+
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
418
|
+
const previousOutputs = await Promise.all(outputPromises);
|
|
419
|
+
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
420
|
+
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
421
|
+
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
|
|
422
|
+
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
423
|
+
return {
|
|
424
|
+
topic,
|
|
425
|
+
isDupe: false,
|
|
426
|
+
previousCoins,
|
|
427
|
+
previousOutputs,
|
|
428
|
+
admissibleOutputs
|
|
429
|
+
};
|
|
481
430
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
431
|
+
catch (error) {
|
|
432
|
+
this.logger.error(`Error validating topic during submit: topic=${serializeLogValue(topic)} error=${serializeErrorForLog(error)}`);
|
|
433
|
+
failedTopics.add(topic);
|
|
434
|
+
return {
|
|
435
|
+
topic,
|
|
436
|
+
isDupe: false,
|
|
437
|
+
previousCoins: [],
|
|
438
|
+
previousOutputs: [],
|
|
439
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
isTopicSubmissionAccepted(validation, failedTopics) {
|
|
444
|
+
return !failedTopics.has(validation.topic) && (validation.isDupe ||
|
|
445
|
+
validation.admissibleOutputs.outputsToAdmit.length > 0 ||
|
|
446
|
+
validation.admissibleOutputs.coinsToRetain.length > 0 ||
|
|
447
|
+
validation.previousCoins.length > 0);
|
|
448
|
+
}
|
|
449
|
+
async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
|
|
499
450
|
this.startTime(`broadcast_${txid.substring(0, 10)}`);
|
|
500
451
|
if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
|
|
501
452
|
try {
|
|
502
453
|
let response;
|
|
503
454
|
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
455
|
const mp = tx.merklePath;
|
|
507
456
|
const leaf = mp.path[0].find(leaf => leaf.hash === txid);
|
|
508
|
-
|
|
457
|
+
response = {
|
|
509
458
|
status: 'success',
|
|
510
|
-
txid
|
|
511
|
-
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}
|
|
459
|
+
txid,
|
|
460
|
+
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`
|
|
512
461
|
};
|
|
513
|
-
response = r;
|
|
514
462
|
}
|
|
515
463
|
else {
|
|
516
464
|
response = await this.broadcaster.broadcast(tx);
|
|
517
465
|
}
|
|
518
466
|
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
throw
|
|
467
|
+
const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
|
|
468
|
+
error.more = response.more;
|
|
469
|
+
throw error;
|
|
522
470
|
}
|
|
523
471
|
}
|
|
524
472
|
catch (error) {
|
|
525
|
-
if (this.throwOnBroadcastFailure)
|
|
473
|
+
if (this.throwOnBroadcastFailure)
|
|
526
474
|
throw error;
|
|
527
|
-
}
|
|
528
475
|
this.logger.error('Error broadcasting transaction:', error);
|
|
529
476
|
}
|
|
530
477
|
}
|
|
531
478
|
this.endTime(`broadcast_${txid.substring(0, 10)}`);
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
479
|
+
}
|
|
480
|
+
async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
|
|
481
|
+
if (typeof lookupService.outputSpent !== 'function')
|
|
482
|
+
return;
|
|
483
|
+
if (lookupService.spendNotificationMode === 'txid') {
|
|
484
|
+
await lookupService.outputSpent({
|
|
485
|
+
mode: 'txid',
|
|
486
|
+
spendingTxid: txid,
|
|
487
|
+
txid: output.txid,
|
|
488
|
+
outputIndex: output.outputIndex,
|
|
489
|
+
topic
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
535
492
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
493
|
+
if (lookupService.spendNotificationMode === 'script') {
|
|
494
|
+
const inputIndex = findSpendingInputIndex(tx, output);
|
|
495
|
+
if (inputIndex === -1)
|
|
496
|
+
throw new Error('Could not find input index');
|
|
497
|
+
await lookupService.outputSpent({
|
|
498
|
+
mode: 'script',
|
|
499
|
+
spendingTxid: txid,
|
|
500
|
+
inputIndex,
|
|
501
|
+
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
502
|
+
unlockingScript: tx.inputs[inputIndex].unlockingScript,
|
|
503
|
+
txid: output.txid,
|
|
504
|
+
outputIndex: output.outputIndex,
|
|
505
|
+
topic,
|
|
506
|
+
offChainValues
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (lookupService.spendNotificationMode === 'whole-tx') {
|
|
511
|
+
await lookupService.outputSpent({
|
|
512
|
+
mode: 'whole-tx',
|
|
513
|
+
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
514
|
+
txid: output.txid,
|
|
515
|
+
outputIndex: output.outputIndex,
|
|
516
|
+
topic,
|
|
517
|
+
offChainValues
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
await lookupService.outputSpent({
|
|
522
|
+
mode: 'none',
|
|
523
|
+
txid: output.txid,
|
|
524
|
+
outputIndex: output.outputIndex,
|
|
525
|
+
topic
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
|
|
529
|
+
if (output === null)
|
|
530
|
+
return;
|
|
531
|
+
try {
|
|
532
|
+
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
|
|
533
|
+
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
|
|
534
|
+
try {
|
|
535
|
+
await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
this.logger.error('Error in lookup service for outputSpent:', error);
|
|
539
|
+
}
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
this.logger.error('Error marking UTXO as spent:', error);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
|
|
540
547
|
await Promise.all(validations.map(async (validation) => {
|
|
541
|
-
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
548
|
+
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
542
549
|
return;
|
|
550
|
+
await Promise.all(validation.previousOutputs.map(async (output) => {
|
|
551
|
+
await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
|
|
552
|
+
}));
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
classifyPreviousCoins(tx, validation) {
|
|
556
|
+
const outputsConsumed = [];
|
|
557
|
+
const outputsToMarkStale = [];
|
|
558
|
+
for (const inputIndex of validation.previousCoins) {
|
|
559
|
+
const input = tx.inputs[inputIndex];
|
|
560
|
+
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
561
|
+
if (typeof previousTXID !== 'string')
|
|
562
|
+
continue;
|
|
563
|
+
if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
|
|
564
|
+
outputsConsumed.push({
|
|
565
|
+
txid: previousTXID,
|
|
566
|
+
outputIndex: input.sourceOutputIndex
|
|
567
|
+
});
|
|
543
568
|
}
|
|
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
|
-
|
|
569
|
+
else {
|
|
570
|
+
outputsToMarkStale.push({
|
|
571
|
+
txid: previousTXID,
|
|
572
|
+
previousOutputIndex: input.sourceOutputIndex,
|
|
573
|
+
inputIndex
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { outputsConsumed, outputsToMarkStale };
|
|
578
|
+
}
|
|
579
|
+
async removeStaleOutputs(outputs, topic, txid) {
|
|
580
|
+
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
581
|
+
await Promise.all(outputs.map(async (coin) => {
|
|
582
|
+
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
|
|
583
|
+
if (output !== undefined && output !== null)
|
|
584
|
+
await this.deleteUTXODeep(output);
|
|
585
|
+
}));
|
|
586
|
+
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
587
|
+
}
|
|
588
|
+
async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
|
|
589
|
+
if (lookupService.admissionMode === 'locking-script') {
|
|
590
|
+
if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
|
|
591
|
+
typeof tx.outputs[outputIndex].satoshis !== 'number')
|
|
592
|
+
return;
|
|
593
|
+
await lookupService.outputAdmittedByTopic({
|
|
594
|
+
mode: 'locking-script',
|
|
595
|
+
txid,
|
|
596
|
+
outputIndex,
|
|
597
|
+
lockingScript: tx.outputs[outputIndex].lockingScript,
|
|
598
|
+
satoshis: tx.outputs[outputIndex].satoshis,
|
|
599
|
+
topic,
|
|
600
|
+
offChainValues
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await lookupService.outputAdmittedByTopic({
|
|
605
|
+
mode: 'whole-tx',
|
|
606
|
+
atomicBEEF: tx.toAtomicBEEF(),
|
|
607
|
+
outputIndex,
|
|
608
|
+
topic,
|
|
609
|
+
offChainValues
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async admitOutput(outputIndex, context) {
|
|
613
|
+
const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
|
|
614
|
+
if (typeof tx.outputs[outputIndex].satoshis !== 'number')
|
|
615
|
+
return;
|
|
616
|
+
this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
617
|
+
await this.storage.insertOutput({
|
|
618
|
+
txid,
|
|
619
|
+
outputIndex,
|
|
620
|
+
outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
|
|
621
|
+
satoshis: tx.outputs[outputIndex].satoshis,
|
|
622
|
+
topic,
|
|
623
|
+
spent: false,
|
|
624
|
+
beef: this.compactBEEFForStorage(tx, beef),
|
|
625
|
+
consumedBy: [],
|
|
626
|
+
outputsConsumed,
|
|
627
|
+
score: Date.now(),
|
|
628
|
+
blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
|
|
629
|
+
});
|
|
630
|
+
this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
|
|
631
|
+
newUTXOs.push({ txid, outputIndex });
|
|
632
|
+
this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
633
|
+
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
|
|
634
|
+
try {
|
|
635
|
+
await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
|
|
639
|
+
}
|
|
640
|
+
}));
|
|
641
|
+
this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
|
|
642
|
+
}
|
|
643
|
+
async updateConsumedOutput(output, newUTXOs, topic) {
|
|
644
|
+
const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
|
|
645
|
+
if (storedOutput === undefined || storedOutput === null)
|
|
646
|
+
return;
|
|
647
|
+
const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
|
|
648
|
+
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
|
|
649
|
+
}
|
|
650
|
+
async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
|
|
651
|
+
const topic = validation.topic;
|
|
652
|
+
const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
|
|
653
|
+
await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
|
|
654
|
+
steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
|
|
655
|
+
const newUTXOs = [];
|
|
656
|
+
await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
|
|
657
|
+
await this.admitOutput(outputIndex, {
|
|
658
|
+
tx,
|
|
659
|
+
txid,
|
|
660
|
+
beef,
|
|
661
|
+
topic,
|
|
662
|
+
outputsConsumed,
|
|
663
|
+
newUTXOs,
|
|
664
|
+
offChainValues
|
|
615
665
|
});
|
|
616
|
-
await Promise.all(markSpentPromises);
|
|
617
666
|
}));
|
|
618
|
-
|
|
667
|
+
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
668
|
+
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
|
|
669
|
+
await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
|
|
670
|
+
await Promise.all([
|
|
671
|
+
...outputsConsumed.map(async (output) => {
|
|
672
|
+
await this.updateConsumedOutput(output, newUTXOs, topic);
|
|
673
|
+
}),
|
|
674
|
+
this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
|
|
675
|
+
]);
|
|
676
|
+
if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
|
|
677
|
+
await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
|
|
678
|
+
}
|
|
679
|
+
this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
680
|
+
}
|
|
681
|
+
async applyStorageMutations(validations, context) {
|
|
682
|
+
const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
|
|
619
683
|
for (const validation of validations) {
|
|
620
684
|
const topic = validation.topic;
|
|
621
|
-
if (dupeTopics.has(topic))
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
if (failedTopics.has(topic)) {
|
|
685
|
+
if (dupeTopics.has(topic) || failedTopics.has(topic))
|
|
625
686
|
continue;
|
|
626
|
-
}
|
|
627
687
|
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)}`);
|
|
688
|
+
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
742
689
|
}
|
|
743
690
|
catch (error) {
|
|
744
691
|
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
|
|
745
692
|
}
|
|
746
693
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
return steak;
|
|
750
|
-
}
|
|
694
|
+
}
|
|
695
|
+
async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
|
|
751
696
|
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
752
|
-
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
697
|
+
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
698
|
+
!dupeTopics.has(topic) &&
|
|
699
|
+
(steak[topic].outputsToAdmit.length !== 0 ||
|
|
700
|
+
steak[topic].coinsRemoved?.length !== 0));
|
|
753
701
|
if (relevantTopics.length === 0) {
|
|
754
702
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
755
|
-
return
|
|
703
|
+
return;
|
|
756
704
|
}
|
|
757
|
-
// Create a SHIPBroadcaster instance
|
|
758
705
|
let customBroadcasterConfig;
|
|
759
706
|
if (Array.isArray(this.slapTrackers)) {
|
|
760
|
-
// Custom SLAP trackers warrant a custom broadcaster config
|
|
761
707
|
const resolverConfig = {
|
|
762
708
|
slapTrackers: this.slapTrackers
|
|
763
709
|
};
|
|
@@ -773,6 +719,93 @@ export class Engine {
|
|
|
773
719
|
this.logger.error('Error during propagation to other nodes:', error);
|
|
774
720
|
}
|
|
775
721
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Submits a transaction for processing by Overlay Services.
|
|
725
|
+
* @param {TaggedBEEF} taggedBEEF - The transaction to process
|
|
726
|
+
* @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
|
|
727
|
+
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
|
|
728
|
+
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
|
|
729
|
+
*
|
|
730
|
+
* The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
|
|
731
|
+
*
|
|
732
|
+
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
|
|
733
|
+
*/
|
|
734
|
+
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
735
|
+
for (const t of taggedBEEF.topics) {
|
|
736
|
+
if (this.managers[t] === undefined || this.managers[t] === null) {
|
|
737
|
+
throw new Error(`This server does not support this topic: ${t}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Validate the transaction SPV information
|
|
741
|
+
const tx = Transaction.fromBEEF(taggedBEEF.beef);
|
|
742
|
+
const txid = tx.id('hex');
|
|
743
|
+
this.startTime(`submit_${txid}`);
|
|
744
|
+
if (mode !== 'historical-tx-no-spv') {
|
|
745
|
+
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
746
|
+
const txValid = await tx.verify(this.chainTracker);
|
|
747
|
+
if (!txValid)
|
|
748
|
+
throw new Error('Unable to verify SPV information.');
|
|
749
|
+
this.endTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
750
|
+
}
|
|
751
|
+
const steak = {};
|
|
752
|
+
const dupeTopics = new Set();
|
|
753
|
+
const failedTopics = new Set();
|
|
754
|
+
// ===================================================================
|
|
755
|
+
// PHASE 1: VALIDATE (read-only, no mutations)
|
|
756
|
+
// ===================================================================
|
|
757
|
+
const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
|
|
758
|
+
tx,
|
|
759
|
+
txid,
|
|
760
|
+
beef: taggedBEEF.beef,
|
|
761
|
+
offChainValues,
|
|
762
|
+
mode,
|
|
763
|
+
dupeTopics,
|
|
764
|
+
failedTopics
|
|
765
|
+
}));
|
|
766
|
+
const validations = await Promise.all(topicValidations);
|
|
767
|
+
// Build preliminary STEAK from validation results
|
|
768
|
+
for (const validation of validations) {
|
|
769
|
+
steak[validation.topic] = validation.admissibleOutputs;
|
|
770
|
+
}
|
|
771
|
+
// ===================================================================
|
|
772
|
+
// PHASE 2: BROADCAST (before any mutations)
|
|
773
|
+
// ===================================================================
|
|
774
|
+
// Only broadcast when at least one topic actually accepted the
|
|
775
|
+
// transaction. For a non-failed topic, acceptance means: previously
|
|
776
|
+
// accepted (dupe / client retry), outputs admitted, coins retained, or
|
|
777
|
+
// previously-admitted coins consumed (e.g. a consume-only deletion such
|
|
778
|
+
// as a KVStore remove, even one that retains nothing). A topic manager
|
|
779
|
+
// REJECTS by throwing from identifyAdmissibleOutputs (tracked in
|
|
780
|
+
// failedTopics). A transaction every topic rejected must never reach the
|
|
781
|
+
// network: submitters treat an empty STEAK as a rejection and
|
|
782
|
+
// abort/release their held inputs, so broadcasting it anyway would
|
|
783
|
+
// desync their wallets from the chain.
|
|
784
|
+
const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
|
|
785
|
+
await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
|
|
786
|
+
// Call the callback function with STEAK if it is provided (before storage mutations)
|
|
787
|
+
if (onSteakReady !== undefined) {
|
|
788
|
+
onSteakReady(steak);
|
|
789
|
+
}
|
|
790
|
+
// ===================================================================
|
|
791
|
+
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
792
|
+
// ===================================================================
|
|
793
|
+
// Mark previous outputs as spent and notify lookup services
|
|
794
|
+
await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
|
|
795
|
+
await this.applyStorageMutations(validations, {
|
|
796
|
+
dupeTopics,
|
|
797
|
+
failedTopics,
|
|
798
|
+
steak,
|
|
799
|
+
tx,
|
|
800
|
+
txid,
|
|
801
|
+
beef: taggedBEEF.beef,
|
|
802
|
+
offChainValues
|
|
803
|
+
});
|
|
804
|
+
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
|
|
805
|
+
if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
|
|
806
|
+
return steak;
|
|
807
|
+
}
|
|
808
|
+
await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
|
|
776
809
|
// Immediately return from the function without waiting for the promises to resolve.
|
|
777
810
|
return steak;
|
|
778
811
|
}
|
|
@@ -1004,71 +1037,37 @@ export class Engine {
|
|
|
1004
1037
|
throw new Error('Overlay Service Engine not configured for topical synchronization!');
|
|
1005
1038
|
}
|
|
1006
1039
|
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) {
|
|
1040
|
+
const configuredEndpoints = this.syncConfiguration[topic];
|
|
1041
|
+
if (configuredEndpoints === false)
|
|
1011
1042
|
continue;
|
|
1043
|
+
if (!Array.isArray(configuredEndpoints) && configuredEndpoints !== 'SHIP')
|
|
1044
|
+
continue;
|
|
1045
|
+
const syncEndpoints = await this.resolveSyncEndpointsForTopic(topic, 'Failed to parse advertisement output:');
|
|
1046
|
+
this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`);
|
|
1047
|
+
// Sync with each endpoint sequentially to avoid parallel locks while
|
|
1048
|
+
// keeping peer failures isolated.
|
|
1049
|
+
for (const endpoint of syncEndpoints) {
|
|
1050
|
+
await this.syncGASPWithPeer(topic, endpoint);
|
|
1012
1051
|
}
|
|
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
|
-
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async syncGASPWithPeer(topic, endpoint) {
|
|
1055
|
+
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
|
|
1056
|
+
try {
|
|
1057
|
+
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
|
|
1058
|
+
const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
|
|
1059
|
+
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
|
|
1060
|
+
if (gasp.lastInteraction > lastInteraction) {
|
|
1061
|
+
await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction);
|
|
1068
1062
|
}
|
|
1063
|
+
this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`);
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
this.logger.error(`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
|
|
1067
|
+
// Continue on to the next endpoint without throwing.
|
|
1069
1068
|
}
|
|
1070
1069
|
}
|
|
1071
|
-
async resolveSyncEndpointsForTopic(topic) {
|
|
1070
|
+
async resolveSyncEndpointsForTopic(topic, advertisementErrorMessage = 'Failed to parse BASM advertisement output:') {
|
|
1072
1071
|
if (this.syncConfiguration === undefined) {
|
|
1073
1072
|
return [];
|
|
1074
1073
|
}
|
|
@@ -1098,7 +1097,7 @@ export class Engine {
|
|
|
1098
1097
|
}
|
|
1099
1098
|
}
|
|
1100
1099
|
catch (error) {
|
|
1101
|
-
this.logger.error(
|
|
1100
|
+
this.logger.error(advertisementErrorMessage, error);
|
|
1102
1101
|
}
|
|
1103
1102
|
});
|
|
1104
1103
|
}
|
|
@@ -1805,7 +1804,7 @@ export class Engine {
|
|
|
1805
1804
|
// If none of the disallowed conditions matched, the URL is valid
|
|
1806
1805
|
return true;
|
|
1807
1806
|
}
|
|
1808
|
-
catch
|
|
1807
|
+
catch {
|
|
1809
1808
|
// URL constructor throws on malformed input — not a valid URL, return false
|
|
1810
1809
|
return false;
|
|
1811
1810
|
}
|