@bsv/overlay
Version:
BSV Blockchain Overlay Services Engine
283 lines • 13.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GASP = exports.GASPVersionMismatchError = void 0;
const sdk_1 = require("@bsv/sdk");
class GASPVersionMismatchError extends Error {
constructor(message, currentVersion, foreignVersion) {
super(message);
this.code = 'ERR_GASP_VERSION_MISMATCH';
this.currentVersion = currentVersion;
this.foreignVersion = foreignVersion;
}
}
exports.GASPVersionMismatchError = GASPVersionMismatchError;
/**
* Main class implementing the Graph Aware Sync Protocol.
*/
class GASP {
/**
*
* @param storage The GASP Storage interface to use
* @param remote The GASP Remote interface to use
* @param lastInteraction The timestamp when we last interacted with this remote party
* @param logPrefix Optional prefix for log messages
* @param log Whether to log messages
* @param unidirectional Whether to disable the "reply" side and do pull-only
*/
constructor(storage, remote, lastInteraction = 0, logPrefix = '[GASP] ', log = false, unidirectional = false) {
this.storage = storage;
this.remote = remote;
this.lastInteraction = lastInteraction;
this.version = 1;
this.logPrefix = logPrefix;
this.log = log;
this.unidirectional = unidirectional;
this.validateTimestamp(this.lastInteraction);
this.logData(`GASP initialized with version: ${this.version}, lastInteraction: ${this.lastInteraction}, unidirectional: ${this.unidirectional}`);
}
logData(...data) {
if (this.log) {
console.log(this.logPrefix, ...data);
}
}
validateTimestamp(timestamp) {
if (typeof timestamp !== 'number' || isNaN(timestamp) || timestamp < 0 || !Number.isInteger(timestamp)) {
throw new Error('Invalid timestamp format');
}
}
/**
* Computes a 36-byte structure from a transaction ID and output index.
* @param txid The transaction ID.
* @param index The output index.
* @returns A string representing the 36-byte structure.
*/
compute36ByteStructure(txid, index) {
const result = `${txid}.${index.toString()}`;
this.logData(`Computed 36-byte structure: ${result} from txid: ${txid}, index: ${index}`);
return result;
}
/**
* Deconstructs a 36-byte structure into a transaction ID and output index.
* @param outpoint The 36-byte structure.
* @returns An object containing the transaction ID and output index.
*/
deconstruct36ByteStructure(outpoint) {
const [txid, index] = outpoint.split('.');
const result = {
txid,
outputIndex: parseInt(index, 10)
};
this.logData(`Deconstructed 36-byte structure: ${outpoint} into txid: ${txid}, outputIndex: ${result.outputIndex}`);
return result;
}
/**
* Computes the transaction ID for a given transaction.
* @param tx The transaction string.
* @returns The computed transaction ID.
*/
computeTXID(tx) {
const txid = sdk_1.Transaction.fromHex(tx).id('hex');
this.logData(`Computed TXID: ${txid} from transaction: ${tx}`);
return txid;
}
/**
* Synchronizes the transaction data between the local and remote participants.
*/
async sync() {
this.logData(`Starting sync process. Last interaction timestamp: ${this.lastInteraction}`);
const initialRequest = await this.buildInitialRequest(this.lastInteraction);
const initialResponse = await this.remote.getInitialResponse(initialRequest);
// 1. Pull the remote UTXOs that we don't already have
if (initialResponse.UTXOList.length > 0) {
const foreignUTXOs = await this.storage.findKnownUTXOs(0);
// -- REPLACE Promise.all WITH A FOR LOOP --
const missingUTXOs = initialResponse.UTXOList.filter(x => !foreignUTXOs.some(y => x.txid === y.txid && x.outputIndex === y.outputIndex));
for (const UTXO of missingUTXOs) {
try {
this.logData(`Requesting node for UTXO: ${JSON.stringify(UTXO)}`);
const resolvedNode = await this.remote.requestNode(this.compute36ByteStructure(UTXO.txid, UTXO.outputIndex), UTXO.txid, UTXO.outputIndex, true);
this.logData(`Received unspent graph node from remote: ${JSON.stringify(resolvedNode)}`);
await this.processIncomingNode(resolvedNode);
await this.completeGraph(resolvedNode.graphID);
}
catch (e) {
this.logData(`Error with incoming UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${e.message}`);
}
}
}
// 2. Only do the “reply” half if unidirectional is disabled
if (!this.unidirectional) {
const initialReply = await this.getInitialReply(initialResponse);
this.logData(`Received initial reply: ${JSON.stringify(initialReply)}`);
if (initialReply.UTXOList.length > 0) {
// -- REPLACE Promise.all WITH A FOR LOOP --
for (const UTXO of initialReply.UTXOList) {
try {
this.logData(`Hydrating GASP node for UTXO: ${JSON.stringify(UTXO)}`);
const outgoingNode = await this.storage.hydrateGASPNode(this.compute36ByteStructure(UTXO.txid, UTXO.outputIndex), UTXO.txid, UTXO.outputIndex, true);
this.logData(`Sending unspent graph node for remote: ${JSON.stringify(outgoingNode)}`);
await this.processOutgoingNode(outgoingNode);
}
catch (e) {
this.logData(`Error with outgoing UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${e.message}`);
}
}
}
}
this.logData('Sync completed!');
}
/**
* Builds the initial request for the sync process.
* @returns A promise for the initial request object.
*/
async buildInitialRequest(since) {
const request = {
version: this.version,
since
};
this.logData(`Built initial request: ${JSON.stringify(request)}`);
return request;
}
/**
* Builds the initial response based on the received request.
* @param request The initial request object.
* @returns A promise for an initial response
*/
async getInitialResponse(request) {
this.logData(`Received initial request: ${JSON.stringify(request)}`);
if (request.version !== this.version) {
const error = new GASPVersionMismatchError(`GASP version mismatch. Current version: ${this.version}, foreign version: ${request.version}`, this.version, request.version);
console.error(`GASP version mismatch error: ${error.message}`);
throw error;
}
this.validateTimestamp(request.since);
const response = {
since: this.lastInteraction,
UTXOList: await this.storage.findKnownUTXOs(request.since)
};
this.logData(`Built initial response: ${JSON.stringify(response)}`);
return response;
}
/**
* Builds the initial reply based on the received response.
* @param response The initial response object.
* @returns A promise for an initial reply
*/
async getInitialReply(response) {
this.logData(`Received initial response: ${JSON.stringify(response)}`);
const knownUTXOs = await this.storage.findKnownUTXOs(response.since);
const filteredUTXOs = knownUTXOs.filter(x => !response.UTXOList.some(y => y.txid === x.txid && y.outputIndex === x.outputIndex));
const reply = {
UTXOList: filteredUTXOs
};
this.logData(`Built initial reply: ${JSON.stringify(reply)}`);
return reply;
}
/**
* Provides a requested node to a foreign instance who requested it.
*/
async requestNode(graphID, txid, outputIndex, metadata) {
this.logData(`Remote is requesting node with graphID: ${graphID}, txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`);
const node = await this.storage.hydrateGASPNode(graphID, txid, outputIndex, metadata);
this.logData(`Returning node: ${JSON.stringify(node)}`);
return node;
}
/**
* Provides a set of inputs we care about after processing a new incoming node.
* Also finalizes or discards a graph if no additional data is requested from the foreign instance.
*/
async submitNode(node) {
this.logData(`Remote party is submitting node: ${JSON.stringify(node)}`);
await this.storage.appendToGraph(node);
const requestedInputs = await this.storage.findNeededInputs(node);
this.logData(`Requested inputs: ${JSON.stringify(requestedInputs)}`);
if (!requestedInputs) {
await this.completeGraph(node.graphID);
}
return requestedInputs;
}
/**
* Handles the completion of a newly-synced graph
* @param {string} graphID The ID of the newly-synced graph
*/
async completeGraph(graphID) {
this.logData(`Completing newly-synced graph: ${graphID}`);
try {
await this.storage.validateGraphAnchor(graphID);
this.logData(`Graph validated for node: ${graphID}`);
await this.storage.finalizeGraph(graphID);
this.logData(`Graph finalized for node: ${graphID}`);
}
catch (e) {
this.logData(`Error validating graph: ${e.message}. Discarding graph for node: ${graphID}`);
await this.storage.discardGraph(graphID);
}
}
/**
* Processes an incoming node from the remote participant.
* @param node The incoming GASP node.
* @param spentBy The 36-byte structure of the node that spent this one, if applicable.
*/
async processIncomingNode(node, spentBy, seenNodes = new Set()) {
const nodeId = `${this.computeTXID(node.rawTx)}.${node.outputIndex}`;
this.logData(`Processing incoming node: ${JSON.stringify(node)}, spentBy: ${spentBy}`);
if (seenNodes.has(nodeId)) {
this.logData(`Node ${nodeId} already processed, skipping.`);
return; // Prevent infinite recursion
}
seenNodes.add(nodeId);
await this.storage.appendToGraph(node, spentBy);
const neededInputs = await this.storage.findNeededInputs(node);
this.logData(`Needed inputs for node ${nodeId}: ${JSON.stringify(neededInputs)}`);
if (neededInputs) {
// -- REPLACE Promise.all WITH A FOR LOOP --
for (const [outpoint, { metadata }] of Object.entries(neededInputs.requestedInputs)) {
const { txid, outputIndex } = this.deconstruct36ByteStructure(outpoint);
this.logData(`Requesting new node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`);
const newNode = await this.remote.requestNode(node.graphID, txid, outputIndex, metadata);
this.logData(`Received new node: ${JSON.stringify(newNode)}`);
await this.processIncomingNode(newNode, this.compute36ByteStructure(this.computeTXID(node.rawTx), node.outputIndex), seenNodes);
}
}
}
/**
* Processes an outgoing node to the remote participant.
* @param node The outgoing GASP node.
*/
async processOutgoingNode(node, seenNodes = new Set()) {
if (this.unidirectional) {
this.logData(`Skipping outgoing node processing in unidirectional mode.`);
return;
}
const nodeId = `${this.computeTXID(node.rawTx)}.${node.outputIndex}`;
this.logData(`Processing outgoing node: ${JSON.stringify(node)}`);
if (seenNodes.has(nodeId)) {
this.logData(`Node ${nodeId} already processed, skipping.`);
return; // Prevent infinite recursion
}
seenNodes.add(nodeId);
// Attempt to submit the node to the remote
const response = await this.remote.submitNode(node);
this.logData(`Received response for submitted node: ${JSON.stringify(response)}`);
if (response) {
// Process each requested input sequentially
for (const [outpoint, { metadata }] of Object.entries(response.requestedInputs)) {
const { txid, outputIndex } = this.deconstruct36ByteStructure(outpoint);
try {
this.logData(`Hydrating node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`);
const hydratedNode = await this.storage.hydrateGASPNode(node.graphID, txid, outputIndex, metadata);
this.logData(`Hydrated node: ${JSON.stringify(hydratedNode)}`);
await this.processOutgoingNode(hydratedNode, seenNodes);
}
catch (e) {
this.logData(`Error hydrating node: ${e.message}`);
// If we can't send the outgoing node, we just stop.
// The remote won't validate the anchor, and their temporary graph will be discarded.
return;
}
}
}
}
}
exports.GASP = GASP;
//# sourceMappingURL=GASP.js.map