UNPKG

@bsv/overlay

Version:
283 lines 13.5 kB
"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