@bsv/overlay
Version:
BSV Blockchain Overlay Services Engine
999 lines • 89.2 kB
JavaScript
import { Transaction, MerklePath, isBroadcastFailure, SHIPBroadcaster, HTTPSOverlayBroadcastFacilitator, LookupResolver } from '@bsv/sdk';
import { GASP } from '@bsv/gasp';
import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js';
import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js';
import { BASM_ZERO_HASH, computeBasmRoot, computeTac, extractMerkleProofMetadata } from './BASM.js';
import { BASMRemote } from './BASMRemote.js';
import { serializeErrorForLog, serializeLogValue } from './SafeLog.js';
const DEFAULT_GASP_SYNC_LIMIT = 10000;
const DEFAULT_BASM_RANGE_LIMIT = 1024;
function findSpendingInputIndex(tx, output) {
return tx.inputs.findIndex(input => {
const realSource = input.sourceTXID || input.sourceTransaction?.id('hex');
return realSource === output.txid && input.sourceOutputIndex === output.outputIndex;
});
}
/**
* An engine for running BSV Overlay Services (topic managers and lookup services).
*/
export class Engine {
managers;
lookupServices;
storage;
chainTracker;
hostingURL;
shipTrackers;
slapTrackers;
broadcaster;
advertiser;
syncConfiguration;
logTime;
logPrefix;
throwOnBroadcastFailure;
overlayBroadcastFacilitator;
logger;
suppressDefaultSyncAdvertisements;
topicAnchorHeaderResolver;
basmSyncEnabled;
unprovenEvictionBlocks;
maxLookupResults;
/**
* Creates a new Overlay Services Engine
* @param {[key: string]: TopicManager} managers - manages topic admittance
* @param {[key: string]: LookupService} lookupServices - manages UTXO lookups
* @param {Storage} storage - for interacting with internally-managed persistent data
* @param {ChainTracker | 'scripts only'} chainTracker - Verifies SPV data associated with transactions
* @param {string} [hostingURL] - The URL this engine is hosted at. Required if going to support peer-discovery with an advertiser.
* @param {Broadcaster} [Broadcaster] - broadcaster used for broadcasting the incoming transaction
* @param {Advertiser} [Advertiser] - handles SHIP and SLAP advertisements for peer-discovery
* @param {string[]} shipTrackers - SHIP domains we know to bootstrap the system
* @param {string[]} slapTrackers - SLAP domains we know to bootstrap the system
* @param {SyncConfiguration} syncConfiguration — Configuration object describing historical synchronization of topics.
* @param {boolean} logTime - Enables / disables the timing logs for various operations in the Overlay submit route.
* @param {string} logPrefix - Supports overriding the log prefix with a custom string.
* @param {boolean} throwOnBroadcastFailure - Enables / disables throwing an error when a transaction broadcast failure is detected.
* @param {OverlayBroadcastFacilitator} overlayBroadcastFacilitator - Facilitator for propagation to other Overlay Services.
* @param {typeof console} logger - The place where log entries are written.
* @param {boolean} suppressDefaultSyncAdvertisements - Whether to suppress the default (SHIP/SLAP) sync advertisements.
* @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
* @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
* @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
* @param {number} maxLookupResults - Maximum lookup formulas hydrated per request. Use -1 to opt out.
*/
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) {
this.managers = managers;
this.lookupServices = lookupServices;
this.storage = storage;
this.chainTracker = chainTracker;
this.hostingURL = hostingURL;
this.shipTrackers = shipTrackers;
this.slapTrackers = slapTrackers;
this.broadcaster = broadcaster;
this.advertiser = advertiser;
this.syncConfiguration = syncConfiguration;
this.logTime = logTime;
this.logPrefix = logPrefix;
this.throwOnBroadcastFailure = throwOnBroadcastFailure;
this.overlayBroadcastFacilitator = overlayBroadcastFacilitator;
this.logger = logger;
this.suppressDefaultSyncAdvertisements = suppressDefaultSyncAdvertisements;
this.topicAnchorHeaderResolver = topicAnchorHeaderResolver;
this.basmSyncEnabled = basmSyncEnabled;
this.unprovenEvictionBlocks = unprovenEvictionBlocks;
this.maxLookupResults = maxLookupResults;
if (maxLookupResults !== -1 && (!Number.isSafeInteger(maxLookupResults) || maxLookupResults < 1)) {
throw new TypeError('maxLookupResults must be -1 or a positive safe integer');
}
// 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'.
// For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
this.syncConfiguration ??= {};
for (const managerName of Object.keys(managers)) {
if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
// Combine tm_ship trackers with preexisting entries if any
const combinedSet = new Set([
...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
...this.shipTrackers
]);
this.syncConfiguration[managerName] = Array.from(combinedSet);
}
else if (managerName === 'tm_slap' && this.slapTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
// Combine tm_slap trackers with preexisting entries if any
const combinedSet = new Set([
...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
...this.slapTrackers
]);
this.syncConfiguration[managerName] = Array.from(combinedSet);
}
else {
// Set undefined managers to 'SHIP' by default
this.syncConfiguration[managerName] ??= 'SHIP';
}
}
}
// Helper functions for logging timings
startTime(label) {
if (this.logTime) {
this.logger.time(`${this.logPrefix} ${label}`);
}
}
endTime(label) {
if (this.logTime) {
this.logger.timeEnd(`${this.logPrefix} ${label}`);
}
}
async currentHeightOrUndefined() {
if (this.chainTracker === 'scripts only') {
return undefined;
}
try {
return await this.chainTracker.currentHeight();
}
catch (error) {
this.logger.warn(`Unable to resolve current chain height for overlay metadata: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
async resolveBlockHash(blockHeight, merkleRoot) {
try {
const header = await this.topicAnchorHeaderResolver?.(blockHeight);
if (header === undefined) {
return undefined;
}
if (header.merkleRoot !== undefined && merkleRoot !== undefined && header.merkleRoot !== merkleRoot) {
throw new Error(`Header merkle root ${header.merkleRoot} does not match proof root ${merkleRoot} at height ${blockHeight}`);
}
return header.blockHash;
}
catch (error) {
this.logger.warn(`Unable to resolve BASM block hash: height=${serializeLogValue(blockHeight)} error=${serializeErrorForLog(error)}`);
return undefined;
}
}
compactBEEFForStorage(tx, originalBEEF) {
return tx.merklePath === undefined ? originalBEEF : tx.toAtomicBEEF();
}
async recordTransactionData(tx, beef, blockHash) {
if (typeof this.storage.upsertTransactionRecord !== 'function') {
return;
}
const txid = tx.id('hex');
const metadata = extractMerkleProofMetadata(txid, tx.merklePath);
await this.storage.upsertTransactionRecord({
txid,
beef: this.compactBEEFForStorage(tx, beef),
rawTx: Array.from(tx.toBinary()),
merklePath: tx.merklePath?.toBinary(),
blockHeight: metadata?.blockHeight,
blockHash,
blockIndex: metadata?.blockIndex,
merkleRoot: metadata?.merkleRoot
});
}
async buildAppliedTransactionRecord(tx) {
const txid = tx.id('hex');
const metadata = extractMerkleProofMetadata(txid, tx.merklePath);
const [firstSeenHeight, blockHash] = await Promise.all([
this.currentHeightOrUndefined(),
metadata === undefined ? undefined : this.resolveBlockHash(metadata.blockHeight, metadata.merkleRoot)
]);
return {
blockHeight: metadata?.blockHeight,
blockHash,
blockIndex: metadata?.blockIndex,
merkleRoot: metadata?.merkleRoot,
firstSeenHeight: firstSeenHeight ?? metadata?.blockHeight,
proven: metadata !== undefined
};
}
async recomputeTopicBlockAnchor(topic, blockHeight, blockHash) {
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
typeof this.storage.findTopicBlockAnchor !== 'function') {
return undefined;
}
const anchorBlockHash = blockHash ?? (await this.storage.findTopicBlockAnchor(topic, blockHeight))?.blockHash;
if (anchorBlockHash === undefined) {
return undefined;
}
// BRC-136 per-block completeness: establish the chain's genesis at the first
// admitted height, then keep every height from there to the tip contiguous so
// the cumulative TAC never resets across blocks with no admitted transactions.
// We rebuild [fromHeight, toHeight] rather than only the touched height so that
// an out-of-order proof (older height arriving after a newer one) can never
// leave a gap that silently breaks the chain.
const tip = await this.storage.findTopicAnchorTip?.(topic);
const tipHeight = tip !== undefined && tip.blockHeight >= 0 ? tip.blockHeight : undefined;
const fromHeight = tipHeight === undefined ? blockHeight : Math.min(blockHeight, tipHeight + 1);
const toHeight = tipHeight === undefined ? blockHeight : Math.max(blockHeight, tipHeight);
await this.rebuildTopicAnchorChain(topic, fromHeight, toHeight, new Map([[blockHeight, anchorBlockHash]]));
return await this.storage.findTopicBlockAnchor(topic, blockHeight);
}
/**
* Extends every configured topic's anchor chain forward with empty Topic Block
* Anchors (basmRoot = zero hash, admittedCount = 0) up to `toHeight`, so the
* cumulative TAC advances on every block even when a topic admits nothing —
* this is what lets a peer authoritatively confirm "this block contained no
* transactions for this topic". Chains with no first admission yet are left
* unstarted (genesis is the topic's first admitted height).
*/
async advanceTopicAnchorChains(toHeight) {
if (typeof this.storage.findTopicAnchorTip !== 'function' ||
typeof this.storage.upsertTopicBlockAnchor !== 'function') {
return;
}
const targetHeight = toHeight ?? await this.currentHeightOrUndefined();
if (targetHeight === undefined) {
return;
}
for (const topic of Object.keys(this.managers)) {
const tip = await this.storage.findTopicAnchorTip(topic);
if (tip === undefined || tip.blockHeight < 0 || tip.blockHeight >= targetHeight) {
continue;
}
await this.rebuildTopicAnchorChain(topic, tip.blockHeight + 1, targetHeight);
}
}
/**
* Rebuilds a contiguous slice of a topic's anchor chain over [fromHeight,
* toHeight]. Each height uses its admitted transactions (empty -> zero basmRoot)
* and chains the cumulative TAC from the prior height. Missing heights are
* filled rather than skipped, so the chain stays gap-free. If a block hash
* cannot be resolved for some height the extension halts there to preserve
* contiguity instead of leaving a hole.
*/
async rebuildTopicAnchorChain(topic, fromHeight, toHeight, blockHashHints = new Map(), forceResolve = false) {
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
typeof this.storage.findTopicBlockAnchor !== 'function' ||
toHeight < fromHeight) {
return;
}
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
// Bound the work per pass; the next trigger resumes from the new tip.
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`);
toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
}
const previousAnchor = fromHeight > 0
? await this.storage.findTopicBlockAnchor(topic, fromHeight - 1)
: undefined;
let prevTac = previousAnchor?.tac ?? BASM_ZERO_HASH;
for (let height = fromHeight; height <= toHeight; height++) {
const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height);
const existing = await this.storage.findTopicBlockAnchor(topic, height);
// On a reorg rebuild the existing anchor's block hash is stale, so force
// canonical re-resolution from the header resolver instead of reusing it.
const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height);
if (blockHash === undefined) {
this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
return;
}
const basmRoot = computeBasmRoot(admitted);
const tac = computeTac(prevTac, blockHash, basmRoot);
await this.storage.upsertTopicBlockAnchor({
topic,
blockHeight: height,
blockHash,
basmRoot,
admittedCount: admitted.length,
tac
});
prevTac = tac;
}
}
/**
* Reconciles BASM anchors with a blockchain reorganization reported by the
* chain tracker (e.g. go-chaintracks `/v2/reorg/stream`). Proven topic
* transactions whose block was orphaned are demoted to unproven so they leave
* the admitted set, then every topic anchor chain intersecting the affected
* height range is rebuilt over the canonical block hashes. A reorg changes the
* canonical block hash for the affected heights, so topics with no demoted
* transaction are rebuilt too. Idempotent: a clean window demotes nothing and
* reproduces an identical TAC, so this is safe to invoke on every reorg event,
* SSE reconnect, and poll.
*/
async handleReorg(input) {
const report = { perTopic: [] };
if (typeof this.storage.findProvenAppliedTransactionsByBlockHash !== 'function' ||
typeof this.storage.demoteAppliedTransactionToUnproven !== 'function' ||
typeof this.storage.findTopicBlockAnchors !== 'function' ||
typeof this.storage.upsertTopicBlockAnchor !== 'function') {
return report;
}
// 1) Demote proven admissions whose block was orphaned. Hashes are
// normalized to lower-case display hex to match stored block hashes
// (go-sdk chainhash.Hash marshals as reversed display hex).
const demotedByTopic = new Map();
for (const rawHash of input.orphanedBlockHashes) {
const blockHash = rawHash.toLowerCase();
const rows = await this.storage.findProvenAppliedTransactionsByBlockHash(blockHash);
for (const row of rows) {
await this.storage.demoteAppliedTransactionToUnproven(row.txid, row.topic);
const list = demotedByTopic.get(row.topic) ?? [];
list.push(row.txid);
demotedByTopic.set(row.topic, list);
}
}
// 2) Rebuild every topic anchor chain that intersects the reorged range,
// forcing canonical block-hash re-resolution so stale hashes are replaced.
for (const topic of Object.keys(this.managers)) {
const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight);
if (existing.length === 0) {
continue;
}
const startHeight = Math.min(...existing.map(anchor => anchor.blockHeight));
await this.rebuildTopicAnchorChain(topic, startHeight, input.newTipHeight, new Map(), true);
report.perTopic.push({
topic,
demotedTxids: demotedByTopic.get(topic) ?? [],
rebuiltFrom: startHeight,
rebuiltTo: input.newTipHeight
});
}
return report;
}
/**
* Revalidation sweep: the reorg fallback for chain trackers without a reorg
* event stream, and the catch-up step on every reorg-SSE (re)connect (the
* go-chaintracks reorg stream carries no event ids, so a reconnect cannot
* replay events missed while disconnected). Scans proven applied transactions
* in `[tip - depth + 1, tip]`; any whose proof root no longer validates against
* the chain tracker, or whose block hash diverges from the canonical header, is
* treated as orphaned and reconciled via {@link handleReorg}.
*/
async isProvenAnchorStale(row, chainTracker) {
if (row.merkleRoot !== undefined) {
try {
if (!(await chainTracker.isValidRootForHeight(row.merkleRoot, row.blockHeight))) {
return true;
}
}
catch (error) {
this.logger.warn(`[BASM] root validation failed for ${row.txid} at height ${row.blockHeight}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
const canonical = await this.resolveBlockHash(row.blockHeight);
return canonical !== undefined && canonical.toLowerCase() !== row.blockHash?.toLowerCase();
}
async revalidateRecentAnchors(depth = 3) {
const chainTracker = this.chainTracker;
if (chainTracker === 'scripts only') {
this.logger.warn('[BASM] revalidation sweep requires a ChainTracker; skipping');
return undefined;
}
if (typeof this.storage.findProvenAppliedTransactionsInRange !== 'function') {
return undefined;
}
const tip = await this.currentHeightOrUndefined();
if (tip === undefined) {
return undefined;
}
const fromHeight = Math.max(0, tip - depth + 1);
const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip);
const orphaned = new Set();
let minAffected = Number.POSITIVE_INFINITY;
for (const row of rows) {
if (row.blockHash === undefined) {
continue;
}
const stale = await this.isProvenAnchorStale(row, chainTracker);
if (stale === true) {
orphaned.add(row.blockHash.toLowerCase());
minAffected = Math.min(minAffected, row.blockHeight);
}
}
if (orphaned.size === 0) {
return { perTopic: [] };
}
return await this.handleReorg({
orphanedBlockHashes: Array.from(orphaned),
rebuildFromHeight: minAffected,
newTipHeight: tip
});
}
async validateTopicSubmission(topic, context) {
const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
try {
if (this.managers[topic] === undefined || this.managers[topic] === null) {
throw new Error(`This server does not support this topic: ${topic}`);
}
this.startTime(`dupCheck_${txid.substring(0, 10)}`);
const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
this.endTime(`dupCheck_${txid.substring(0, 10)}`);
if (isDupe) {
dupeTopics.add(topic);
return {
topic,
isDupe: true,
previousCoins: [],
previousOutputs: [],
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
};
}
const previousCoins = [];
const outputPromises = tx.inputs.map(async (input, inputIndex) => {
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
if (previousTXID === undefined)
return null;
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
if (output !== undefined && output !== null)
previousCoins.push(inputIndex);
return output ?? null;
});
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
const previousOutputs = await Promise.all(outputPromises);
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
return {
topic,
isDupe: false,
previousCoins,
previousOutputs,
admissibleOutputs
};
}
catch (error) {
this.logger.error(`Error validating topic during submit: topic=${serializeLogValue(topic)} error=${serializeErrorForLog(error)}`);
failedTopics.add(topic);
return {
topic,
isDupe: false,
previousCoins: [],
previousOutputs: [],
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
};
}
}
isTopicSubmissionAccepted(validation, failedTopics) {
return !failedTopics.has(validation.topic) && (validation.isDupe ||
validation.admissibleOutputs.outputsToAdmit.length > 0 ||
validation.admissibleOutputs.coinsToRetain.length > 0 ||
validation.previousCoins.length > 0);
}
async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
this.startTime(`broadcast_${txid.substring(0, 10)}`);
if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
try {
let response;
if (tx.merklePath !== undefined) {
const mp = tx.merklePath;
const leaf = mp.path[0].find(leaf => leaf.hash === txid);
response = {
status: 'success',
txid,
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`
};
}
else {
response = await this.broadcaster.broadcast(tx);
}
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
const error = new Error(`Failed to broadcast transaction! Error: ${response.description}`);
error.more = response.more;
throw error;
}
}
catch (error) {
if (this.throwOnBroadcastFailure)
throw error;
this.logger.error('Error broadcasting transaction:', error);
}
}
this.endTime(`broadcast_${txid.substring(0, 10)}`);
}
async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
if (typeof lookupService.outputSpent !== 'function')
return;
if (lookupService.spendNotificationMode === 'txid') {
await lookupService.outputSpent({
mode: 'txid',
spendingTxid: txid,
txid: output.txid,
outputIndex: output.outputIndex,
topic
});
return;
}
if (lookupService.spendNotificationMode === 'script') {
const inputIndex = findSpendingInputIndex(tx, output);
if (inputIndex === -1)
throw new Error('Could not find input index');
await lookupService.outputSpent({
mode: 'script',
spendingTxid: txid,
inputIndex,
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
unlockingScript: tx.inputs[inputIndex].unlockingScript,
txid: output.txid,
outputIndex: output.outputIndex,
topic,
offChainValues
});
return;
}
if (lookupService.spendNotificationMode === 'whole-tx') {
await lookupService.outputSpent({
mode: 'whole-tx',
spendingAtomicBEEF: tx.toAtomicBEEF(),
txid: output.txid,
outputIndex: output.outputIndex,
topic,
offChainValues
});
return;
}
await lookupService.outputSpent({
mode: 'none',
txid: output.txid,
outputIndex: output.outputIndex,
topic
});
}
async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
if (output === null)
return;
try {
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
try {
await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
}
catch (error) {
this.logger.error('Error in lookup service for outputSpent:', error);
}
}));
}
catch (error) {
this.logger.error('Error marking UTXO as spent:', error);
}
}
async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
await Promise.all(validations.map(async (validation) => {
if (validation.isDupe || failedTopics.has(validation.topic))
return;
await Promise.all(validation.previousOutputs.map(async (output) => {
await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
}));
}));
}
classifyPreviousCoins(tx, validation) {
const outputsConsumed = [];
const outputsToMarkStale = [];
for (const inputIndex of validation.previousCoins) {
const input = tx.inputs[inputIndex];
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
if (typeof previousTXID !== 'string')
continue;
if (validation.admissibleOutputs.coinsToRetain.includes(inputIndex)) {
outputsConsumed.push({
txid: previousTXID,
outputIndex: input.sourceOutputIndex
});
}
else {
outputsToMarkStale.push({
txid: previousTXID,
previousOutputIndex: input.sourceOutputIndex,
inputIndex
});
}
}
return { outputsConsumed, outputsToMarkStale };
}
async removeStaleOutputs(outputs, topic, txid) {
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
await Promise.all(outputs.map(async (coin) => {
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
if (output !== undefined && output !== null)
await this.deleteUTXODeep(output);
}));
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
}
async notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues) {
if (lookupService.admissionMode === 'locking-script') {
if (typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
typeof tx.outputs[outputIndex].satoshis !== 'number')
return;
await lookupService.outputAdmittedByTopic({
mode: 'locking-script',
txid,
outputIndex,
lockingScript: tx.outputs[outputIndex].lockingScript,
satoshis: tx.outputs[outputIndex].satoshis,
topic,
offChainValues
});
return;
}
await lookupService.outputAdmittedByTopic({
mode: 'whole-tx',
atomicBEEF: tx.toAtomicBEEF(),
outputIndex,
topic,
offChainValues
});
}
async admitOutput(outputIndex, context) {
const { tx, txid, beef, topic, outputsConsumed, newUTXOs, offChainValues } = context;
if (typeof tx.outputs[outputIndex].satoshis !== 'number')
return;
this.startTime(`insertNewOutput_${txid.substring(0, 10)}`);
await this.storage.insertOutput({
txid,
outputIndex,
outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
satoshis: tx.outputs[outputIndex].satoshis,
topic,
spent: false,
beef: this.compactBEEFForStorage(tx, beef),
consumedBy: [],
outputsConsumed,
score: Date.now(),
blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
});
this.endTime(`insertNewOutput_${txid.substring(0, 10)}`);
newUTXOs.push({ txid, outputIndex });
this.startTime(`notifyLookupService${txid.substring(0, 10)}`);
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
try {
await this.notifyOutputAdmitted(lookupService, tx, txid, outputIndex, topic, offChainValues);
}
catch (error) {
this.logger.error('Error in lookup service for outputAdmittedByTopic:', error);
}
}));
this.endTime(`notifyLookupService${txid.substring(0, 10)}`);
}
async updateConsumedOutput(output, newUTXOs, topic) {
const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
if (storedOutput === undefined || storedOutput === null)
return;
const consumedBy = [...new Set([...newUTXOs, ...storedOutput.consumedBy])];
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
}
async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
const topic = validation.topic;
const { outputsConsumed, outputsToMarkStale } = this.classifyPreviousCoins(tx, validation);
await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
const newUTXOs = [];
await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
await this.admitOutput(outputIndex, {
tx,
txid,
beef,
topic,
outputsConsumed,
newUTXOs,
offChainValues
});
}));
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
await Promise.all([
...outputsConsumed.map(async (output) => {
await this.updateConsumedOutput(output, newUTXOs, topic);
}),
this.storage.insertAppliedTransaction({ txid, topic, ...appliedRecord })
]);
if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash);
}
this.endTime(`outputConsumed_${txid.substring(0, 10)}`);
}
async applyStorageMutations(validations, context) {
const { dupeTopics, failedTopics, steak, tx, txid, beef, offChainValues } = context;
for (const validation of validations) {
const topic = validation.topic;
if (dupeTopics.has(topic) || failedTopics.has(topic))
continue;
try {
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
}
catch (error) {
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
}
}
}
async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
!dupeTopics.has(topic) &&
(steak[topic].outputsToAdmit.length !== 0 ||
steak[topic].coinsRemoved?.length !== 0));
if (relevantTopics.length === 0) {
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
return;
}
let customBroadcasterConfig;
if (Array.isArray(this.slapTrackers)) {
const resolverConfig = {
slapTrackers: this.slapTrackers
};
customBroadcasterConfig = {
resolver: new LookupResolver(resolverConfig)
};
}
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
try {
await shipBroadcaster.broadcast(tx);
}
catch (error) {
this.logger.error('Error during propagation to other nodes:', error);
}
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
}
/**
* Submits a transaction for processing by Overlay Services.
* @param {TaggedBEEF} taggedBEEF - The transaction to process
* @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
*
* The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
*
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
*/
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
for (const t of taggedBEEF.topics) {
if (this.managers[t] === undefined || this.managers[t] === null) {
throw new Error(`This server does not support this topic: ${t}`);
}
}
// Validate the transaction SPV information
const tx = Transaction.fromBEEF(taggedBEEF.beef);
const txid = tx.id('hex');
this.startTime(`submit_${txid}`);
if (mode !== 'historical-tx-no-spv') {
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
const txValid = await tx.verify(this.chainTracker);
if (!txValid)
throw new Error('Unable to verify SPV information.');
this.endTime(`chainTracker_${txid.substring(0, 10)}`);
}
const steak = {};
const dupeTopics = new Set();
const failedTopics = new Set();
// ===================================================================
// PHASE 1: VALIDATE (read-only, no mutations)
// ===================================================================
const topicValidations = taggedBEEF.topics.map(async (topic) => await this.validateTopicSubmission(topic, {
tx,
txid,
beef: taggedBEEF.beef,
offChainValues,
mode,
dupeTopics,
failedTopics
}));
const validations = await Promise.all(topicValidations);
// Build preliminary STEAK from validation results
for (const validation of validations) {
steak[validation.topic] = validation.admissibleOutputs;
}
// ===================================================================
// PHASE 2: BROADCAST (before any mutations)
// ===================================================================
// Only broadcast when at least one topic actually accepted the
// transaction. For a non-failed topic, acceptance means: previously
// accepted (dupe / client retry), outputs admitted, coins retained, or
// previously-admitted coins consumed (e.g. a consume-only deletion such
// as a KVStore remove, even one that retains nothing). A topic manager
// REJECTS by throwing from identifyAdmissibleOutputs (tracked in
// failedTopics). A transaction every topic rejected must never reach the
// network: submitters treat an empty STEAK as a rejection and
// abort/release their held inputs, so broadcasting it anyway would
// desync their wallets from the chain.
const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
// Call the callback function with STEAK if it is provided (before storage mutations)
if (onSteakReady !== undefined) {
onSteakReady(steak);
}
// ===================================================================
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
// ===================================================================
// Mark previous outputs as spent and notify lookup services
await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
await this.applyStorageMutations(validations, {
dupeTopics,
failedTopics,
steak,
tx,
txid,
beef: taggedBEEF.beef,
offChainValues
});
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
return steak;
}
await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
// Immediately return from the function without waiting for the promises to resolve.
return steak;
}
/**
* Submit a lookup question to the Overlay Services Engine, and receive back a Lookup Answer
* @param LookupQuestion — The question to ask the Overlay Services Engine
* @returns The answer to the question
*/
async lookup(lookupQuestion) {
// Validate a lookup service for the provider is found
const lookupService = this.lookupServices[lookupQuestion.service];
if (lookupService === undefined || lookupService === null)
throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
const lookupResult = await lookupService.lookup(lookupQuestion);
if (this.maxLookupResults !== -1 && lookupResult.length > this.maxLookupResults) {
throw new RangeError(`Lookup returned ${lookupResult.length} results; maximum is ${this.maxLookupResults}`);
}
const hydrationContext = this.createUTXOHistoryHydrationContext();
await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
const hydratedOutputs = (await Promise.all(lookupResult.map(async ({ txid, outputIndex, history, context }) => {
const UTXO = await this.loadOutputWithBEEF(txid, outputIndex, hydrationContext);
if (UTXO === null) {
return null;
}
// Get the history for this utxo and construct a BEEF
const output = await this.getUTXOHistory(UTXO, history, 0, hydrationContext);
if (output?.beef === undefined) {
return null;
}
return {
beef: output.beef,
outputIndex: output.outputIndex,
context
};
})))
.filter((output) => output !== null)
.map(({ beef, outputIndex, context }) => (context === undefined
? { beef, outputIndex }
: { beef, outputIndex, context }));
return {
type: 'output-list',
outputs: hydratedOutputs
};
}
createUTXOHistoryHydrationContext() {
return {
outputCache: new Map()
};
}
toOutputCacheKey(txid, outputIndex) {
return `${txid}:${outputIndex}`;
}
async preloadOutputsWithBEEF(outpoints, context) {
if (outpoints.length === 0) {
return;
}
const deduped = [];
const seen = new Set();
for (const outpoint of outpoints) {
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
if (seen.has(cacheKey)) {
continue;
}
seen.add(cacheKey);
if (!context.outputCache.has(cacheKey)) {
deduped.push(outpoint);
}
}
if (deduped.length === 0) {
return;
}
const findOutputsByOutpoints = this.storage.findOutputsByOutpoints;
if (typeof findOutputsByOutpoints === 'function') {
const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true);
const outputsByKey = new Map();
for (const output of outputs) {
outputsByKey.set(this.toOutputCacheKey(output.txid, output.outputIndex), output);
}
for (const outpoint of deduped) {
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
context.outputCache.set(cacheKey, Promise.resolve(outputsByKey.get(cacheKey) ?? null));
}
return;
}
for (const outpoint of deduped) {
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
context.outputCache.set(cacheKey, this.storage.findOutput(outpoint.txid, outpoint.outputIndex, undefined, undefined, true));
}
}
async loadOutputWithBEEF(txid, outputIndex, context) {
const cacheKey = this.toOutputCacheKey(txid, outputIndex);
let cached = context.outputCache.get(cacheKey);
if (cached === undefined) {
cached = this.storage.findOutput(txid, outputIndex, undefined, undefined, true);
context.outputCache.set(cacheKey, cached);
}
const output = await cached;
return output ?? null;
}
async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context) {
if (output.beef === undefined) {
throw new Error('Output must have associated transaction BEEF!');
}
let shouldTraverseHistory;
if (typeof historySelector === 'number') {
shouldTraverseHistory = currentDepth <= historySelector;
}
else {
shouldTraverseHistory = await historySelector(output.beef, output.outputIndex, currentDepth);
}
if (shouldTraverseHistory === false) {
return undefined;
}
await this.preloadOutputsWithBEEF(output.outputsConsumed, context);
const childNodes = (await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context);
if (childOutput === null) {
return undefined;
}
return await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context);
}))).filter((node) => node !== undefined);
const tx = Transaction.fromBEEF(output.beef);
const inputIndexBySource = new Map();
tx.inputs.forEach((candidateInput, index) => {
const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
? candidateInput.sourceTXID
: candidateInput.sourceTransaction?.id('hex');
if (sourceTXID === undefined) {
return;
}
inputIndexBySource.set(`${sourceTXID}:${candidateInput.sourceOutputIndex}`, index);
});
for (const child of childNodes) {
const inputIndex = inputIndexBySource.get(`${child.output.txid}:${child.output.outputIndex}`);
if (inputIndex === -1 || inputIndex == null) {
continue;
}
const targetInput = tx.inputs[inputIndex];
if (!targetInput) {
this.logger.error(`Input at index ${inputIndex} is undefined, but findIndex found it. Possible sparse array from BEEF parsing.`);
continue;
}
targetInput.sourceTransaction = child.transaction;
}
return { output, transaction: tx };
}
/**
* Ensures alignment between the current SHIP/SLAP advertisements and the
* configured Topic Managers and Lookup Services in the engine.
*
* This method performs the following actions:
* 1. Retrieves the current configuration of topics and services.
* 2. Fetches the existing SHIP advertisements for each configured topic.
* 3. Fetches the existing SLAP advertisements for each configured service.
* 4. Compares the current configuration with the fetched advertisements to determine which advertisements
* need to be created or revoked.
* 5. Creates new SHIP/SLAP advertisements if they do not exist for the configured topics/services.
* 6. Revokes existing SHIP/SLAP advertisements if they are no longer required based on the current configuration.
*
* The function uses the `Advertiser` methods to create or revoke advertisements and ensures the updates are
* submitted to the SHIP/SLAP overlay networks using the engine's `submit()` method.
*
* @throws Will throw an error if there are issues during the advertisement synchronization process.
* @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
*/
async syncAdvertisements() {
if (this.advertiser === undefined ||
typeof this.hostingURL !== 'string' ||
this.hostingURL.length < 1 ||
!this.isValidUrl(this.hostingURL)) {
return;
}
const advertiser = this.advertiser;
// Step 1: Retrieve Current Configuration
let configuredTopics = Object.keys(this.managers);
let configuredServices = Object.keys(this.lookupServices);
// Filter out default SHIP/SLAP topics/services if suppressDefaultSyncAdvertisements is true
if (this.suppressDefaultSyncAdvertisements === true) {
configuredTopics = configuredTopics.filter(topic => topic !== 'tm_ship' && topic !== 'tm_slap');
configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap');
}
// Step 2: Fetch Existing Advertisements
const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP');
const currentSLAPAdvertisements = await advertiser.findAllAdvertisements('SLAP');
// Step 3: Compare and Determine Acti