@bsv/sdk
Version:
BSV Blockchain Software Development Kit
376 lines • 15.4 kB
JavaScript
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