UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

376 lines 15.4 kB
import { Transaction } from '../transaction/index.js'; import * as Utils from '../primitives/utils.js'; import LookupResolver from './LookupResolver.js'; import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'; const MAX_SHIP_QUERY_TIMEOUT = 5000; export class HTTPSOverlayBroadcastFacilitator { httpClient; allowHTTP; constructor(httpClient = fetch, allowHTTP = false) { this.httpClient = httpClient; this.allowHTTP = allowHTTP; } async send(url, taggedBEEF) { if (!url.startsWith('https:') && !this.allowHTTP) { throw new Error('HTTPS facilitator can only use URLs that start with "https:"'); } const headers = { 'Content-Type': 'application/octet-stream', 'X-Topics': JSON.stringify(taggedBEEF.topics) }; let body; if (Array.isArray(taggedBEEF.offChainValues)) { headers['x-includes-off-chain-values'] = 'true'; const w = new Utils.Writer(); w.writeVarIntNum(taggedBEEF.beef.length); w.write(taggedBEEF.beef); w.write(taggedBEEF.offChainValues); body = new Uint8Array(w.toArray()); } else { body = new Uint8Array(taggedBEEF.beef); } const response = await fetch(`${url}/submit`, { method: 'POST', headers, body }); if (response.ok) { return await response.json(); } else { throw new Error('Failed to facilitate broadcast'); } } } /** * Broadcasts transactions to one or more overlay topics. */ export default class TopicBroadcaster { topics; facilitator; resolver; requireAcknowledgmentFromAllHostsForTopics; requireAcknowledgmentFromAnyHostForTopics; requireAcknowledgmentFromSpecificHostsForTopics; networkPreset; /** * Constructs an instance of the SHIP broadcaster. * * @param {string[]} topics - The list of SHIP topic names where transactions are to be sent. * @param {SHIPBroadcasterConfig} config - Configuration options for the SHIP broadcaster. */ constructor(topics, config = {}) { if (topics.length === 0) { throw new Error('At least one topic is required for broadcast.'); } if (topics.some((x) => !x.startsWith('tm_'))) { throw new Error('Every topic must start with "tm_".'); } this.topics = topics; this.networkPreset = config.networkPreset ?? 'mainnet'; this.facilitator = config.facilitator ?? new HTTPSOverlayBroadcastFacilitator(undefined, this.networkPreset === 'local'); this.resolver = config.resolver ?? new LookupResolver({ networkPreset: this.networkPreset }); this.requireAcknowledgmentFromAllHostsForTopics = config.requireAcknowledgmentFromAllHostsForTopics ?? []; this.requireAcknowledgmentFromAnyHostForTopics = config.requireAcknowledgmentFromAnyHostForTopics ?? 'all'; this.requireAcknowledgmentFromSpecificHostsForTopics = config.requireAcknowledgmentFromSpecificHostsForTopics ?? {}; } /** * Broadcasts a transaction to Overlay Services via SHIP. * * @param {Transaction} tx - The transaction to be sent. * @returns {Promise<BroadcastResponse | BroadcastFailure>} A promise that resolves to either a success or failure response. */ async broadcast(tx) { let beef; const offChainValues = tx.metadata.get('OffChainValues'); try { beef = tx.toBEEF(); } catch (error) { throw new Error('Transactions sent via SHIP to Overlay Services must be serializable to BEEF format.'); } const interestedHosts = await this.findInterestedHosts(); if (Object.keys(interestedHosts).length === 0) { return { status: 'error', code: 'ERR_NO_HOSTS_INTERESTED', description: `No ${this.networkPreset} hosts are interested in receiving this transaction.` }; } const hostPromises = Object.entries(interestedHosts).map(async ([host, topics]) => { try { const steak = await this.facilitator.send(host, { beef, offChainValues, topics: [...topics] }); if (steak == null || Object.keys(steak).length === 0) { throw new Error('Steak has no topics.'); } return { host, success: true, steak }; } catch (error) { console.error(error); // Log error if needed return { host, success: false, error }; } }); const results = await Promise.all(hostPromises); const successfulHosts = results.filter((result) => result.success); if (successfulHosts.length === 0) { return { status: 'error', code: 'ERR_ALL_HOSTS_REJECTED', description: `All ${this.networkPreset} topical hosts have rejected the transaction.` }; } // Collect host acknowledgments const hostAcknowledgments = {}; for (const result of successfulHosts) { const host = result.host; const steak = result.steak; const acknowledgedTopics = new Set(); for (const [topic, instructions] of Object.entries(steak)) { const outputsToAdmit = instructions.outputsToAdmit; const coinsToRetain = instructions.coinsToRetain; const coinsRemoved = instructions.coinsRemoved; if (outputsToAdmit?.length > 0 || coinsToRetain?.length > 0 || coinsRemoved?.length > 0) { acknowledgedTopics.add(topic); } } hostAcknowledgments[host] = acknowledgedTopics; } // Now, perform the checks // Check requireAcknowledgmentFromAllHostsForTopics let requiredTopicsAllHosts; let requireAllHosts; if (this.requireAcknowledgmentFromAllHostsForTopics === 'all') { requiredTopicsAllHosts = this.topics; requireAllHosts = 'all'; } else if (this.requireAcknowledgmentFromAllHostsForTopics === 'any') { requiredTopicsAllHosts = this.topics; requireAllHosts = 'any'; } else if (Array.isArray(this.requireAcknowledgmentFromAllHostsForTopics)) { requiredTopicsAllHosts = this.requireAcknowledgmentFromAllHostsForTopics; requireAllHosts = 'all'; } else { // Default to 'all' and 'all' requiredTopicsAllHosts = this.topics; requireAllHosts = 'all'; } if (requiredTopicsAllHosts.length > 0) { const allHostsAcknowledged = this.checkAcknowledgmentFromAllHosts(hostAcknowledgments, requiredTopicsAllHosts, requireAllHosts); if (!allHostsAcknowledged) { return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED', description: 'Not all hosts acknowledged the required topics.' }; } } // Check requireAcknowledgmentFromAnyHostForTopics let requiredTopicsAnyHost; let requireAnyHost; if (this.requireAcknowledgmentFromAnyHostForTopics === 'all') { requiredTopicsAnyHost = this.topics; requireAnyHost = 'all'; } else if (this.requireAcknowledgmentFromAnyHostForTopics === 'any') { requiredTopicsAnyHost = this.topics; requireAnyHost = 'any'; } else if (Array.isArray(this.requireAcknowledgmentFromAnyHostForTopics)) { requiredTopicsAnyHost = this.requireAcknowledgmentFromAnyHostForTopics; requireAnyHost = 'all'; } else { // No requirement requiredTopicsAnyHost = []; requireAnyHost = 'all'; } if (requiredTopicsAnyHost.length > 0) { const anyHostAcknowledged = this.checkAcknowledgmentFromAnyHost(hostAcknowledgments, requiredTopicsAnyHost, requireAnyHost); if (!anyHostAcknowledged) { return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED', description: 'No host acknowledged the required topics.' }; } } // Check requireAcknowledgmentFromSpecificHostsForTopics if (Object.keys(this.requireAcknowledgmentFromSpecificHostsForTopics).length > 0) { const specificHostsAcknowledged = this.checkAcknowledgmentFromSpecificHosts(hostAcknowledgments, this.requireAcknowledgmentFromSpecificHostsForTopics); if (!specificHostsAcknowledged) { return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED', description: 'Specific hosts did not acknowledge the required topics.' }; } } // If all checks pass, return success return { status: 'success', txid: tx.id('hex'), message: `Sent to ${successfulHosts.length} Overlay Services ${successfulHosts.length === 1 ? 'host' : 'hosts'}.` }; } checkAcknowledgmentFromAllHosts(hostAcknowledgments, requiredTopics, require) { for (const acknowledgedTopics of Object.values(hostAcknowledgments)) { if (require === 'all') { for (const topic of requiredTopics) { if (!acknowledgedTopics.has(topic)) { return false; } } } else if (require === 'any') { let anyAcknowledged = false; for (const topic of requiredTopics) { if (acknowledgedTopics.has(topic)) { anyAcknowledged = true; break; } } if (!anyAcknowledged) { return false; } } } return true; } checkAcknowledgmentFromAnyHost(hostAcknowledgments, requiredTopics, require) { if (require === 'all') { // All required topics must be acknowledged by at least one host for (const acknowledgedTopics of Object.values(hostAcknowledgments)) { let acknowledgesAllRequiredTopics = true; for (const topic of requiredTopics) { if (!acknowledgedTopics.has(topic)) { acknowledgesAllRequiredTopics = false; break; } } if (acknowledgesAllRequiredTopics) { return true; } } return false; } else { // At least one required topic must be acknowledged by at least one host for (const acknowledgedTopics of Object.values(hostAcknowledgments)) { for (const topic of requiredTopics) { if (acknowledgedTopics.has(topic)) { return true; } } } return false; } } checkAcknowledgmentFromSpecificHosts(hostAcknowledgments, requirements) { for (const [host, requiredTopicsOrAllAny] of Object.entries(requirements)) { const acknowledgedTopics = hostAcknowledgments[host]; if (acknowledgedTopics == null) { // Host did not respond successfully return false; } let requiredTopics; let require; if (requiredTopicsOrAllAny === 'all' || requiredTopicsOrAllAny === 'any') { require = requiredTopicsOrAllAny; requiredTopics = this.topics; } else if (Array.isArray(requiredTopicsOrAllAny)) { requiredTopics = requiredTopicsOrAllAny; require = 'all'; } else { // Invalid configuration continue; } if (require === 'all') { for (const topic of requiredTopics) { if (!acknowledgedTopics.has(topic)) { return false; } } } else if (require === 'any') { let anyAcknowledged = false; for (const topic of requiredTopics) { if (acknowledgedTopics.has(topic)) { anyAcknowledged = true; break; } } if (!anyAcknowledged) { return false; } } } return true; } /** * Finds which hosts are interested in transactions tagged with the given set of topics. * * @returns A mapping of URLs for hosts interested in this transaction. Keys are URLs, values are which of our topics the specific host cares about. */ async findInterestedHosts() { // Handle the local network preset if (this.networkPreset === 'local') { const resultSet = new Set(); for (let i = 0; i < this.topics.length; i++) { resultSet.add(this.topics[i]); } return { 'http://localhost:8080': resultSet }; } // TODO: cache the list of interested hosts to avoid spamming SHIP trackers. // TODO: Monetize the operation of the SHIP tracker system. // TODO: Cache ship/slap lookup with expiry (every 5min) // Find all SHIP advertisements for the topics we care about const results = {}; const answer = await this.resolver.query({ service: 'ls_ship', query: { topics: this.topics } }, MAX_SHIP_QUERY_TIMEOUT); if (answer.type !== 'output-list') { throw new Error('SHIP answer is not an output list.'); } for (const output of answer.outputs) { try { const tx = Transaction.fromBEEF(output.beef); const script = tx.outputs[output.outputIndex].lockingScript; const parsed = OverlayAdminTokenTemplate.decode(script); if (!this.topics.includes(parsed.topicOrService) || parsed.protocol !== 'SHIP') { // This should make us think a LOT less highly of this SHIP tracker if it ever happens... continue; } if (results[parsed.domain] === undefined) { results[parsed.domain] = new Set(); } results[parsed.domain].add(parsed.topicOrService); } catch (e) { continue; } } return results; } } //# sourceMappingURL=SHIPBroadcaster.js.map