@bsv/sdk
Version:
BSV Blockchain Software Development Kit
505 lines (468 loc) • 17.7 kB
text/typescript
import {
Transaction,
BroadcastResponse,
BroadcastFailure,
Broadcaster
} from '../transaction/index.js'
import * as Utils from '../primitives/utils.js'
import LookupResolver from './LookupResolver.js'
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
/**
* Tagged BEEF
*
* @description
* Tagged BEEF ([Background Evaluation Extended Format](https://brc.dev/62)) structure. Comprises a transaction, its SPV information, and the overlay topics where its inclusion is requested.
*/
export interface TaggedBEEF {
beef: number[]
topics: string[]
offChainValues?: number[]
}
/**
* Instructs the Overlay Services Engine about which outputs to admit and which previous outputs to retain. Returned by a Topic Manager.
*/
export interface AdmittanceInstructions {
/**
* The indices of all admissible outputs into the managed topic from the provided transaction.
*/
outputsToAdmit: number[]
/**
* The indices of all inputs from the provided transaction which spend previously-admitted outputs that should be retained for historical record-keeping.
*/
coinsToRetain: number[]
/**
* The indices of all inputs from the provided transaction which reference previously-admitted outputs,
* which are now considered spent and have been removed from the managed topic.
*/
coinsRemoved?: number[]
}
/**
* Submitted Transaction Execution AcKnowledgment
*
* @description
* Comprises the topics where a transaction was submitted, and for each one, the output indices for the UTXOs newly admitted into the topics, and the coins retained.
* An object whose keys are topic names and whose values are topical admittance instructions denoting the state of the submitted transaction with respect to the associated topic.
*/
export type STEAK = Record<string, AdmittanceInstructions>
/** Configuration options for the SHIP broadcaster. */
export interface SHIPBroadcasterConfig {
/**
* The network preset to use, unless other options override it.
* - mainnet: use mainnet resolver and HTTPS facilitator
* - testnet: use testnet resolver and HTTPS facilitator
* - local: directly send to localhost:8080 and a facilitator that permits plain HTTP
*/
networkPreset?: 'mainnet' | 'testnet' | 'local'
/** The facilitator used to make requests to Overlay Services hosts. */
facilitator?: OverlayBroadcastFacilitator
/** The resolver used to locate suitable hosts with SHIP */
resolver?: LookupResolver
/** Determines which topics (all, any, or a specific list) must be present within all STEAKs received from every host for the broadcast to be considered a success. By default, all hosts must acknowledge all topics. */
requireAcknowledgmentFromAllHostsForTopics?: 'all' | 'any' | string[]
/** Determines which topics (all, any, or a specific list) must be present within STEAK received from at least one host for the broadcast to be considered a success. */
requireAcknowledgmentFromAnyHostForTopics?: 'all' | 'any' | string[]
/** Determines a mapping whose keys are specific hosts and whose values are the topics (all, any, or a specific list) that must be present within the STEAK received by the given hosts, in order for the broadcast to be considered a success. */
requireAcknowledgmentFromSpecificHostsForTopics?: Record<string, 'all' | 'any' | string[]>
}
/** Facilitates transaction broadcasts that return STEAK. */
export interface OverlayBroadcastFacilitator {
send: (url: string, taggedBEEF: TaggedBEEF) => Promise<STEAK>
}
const MAX_SHIP_QUERY_TIMEOUT = 5000
export class HTTPSOverlayBroadcastFacilitator implements OverlayBroadcastFacilitator {
httpClient: typeof fetch
allowHTTP: boolean
constructor (httpClient = fetch, allowHTTP: boolean = false) {
this.httpClient = httpClient
this.allowHTTP = allowHTTP
}
async send (url: string, taggedBEEF: TaggedBEEF): Promise<STEAK> {
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 implements Broadcaster {
private readonly topics: string[]
private readonly facilitator: OverlayBroadcastFacilitator
private readonly resolver: LookupResolver
private readonly requireAcknowledgmentFromAllHostsForTopics: | 'all' | 'any' | string[]
private readonly requireAcknowledgmentFromAnyHostForTopics: | 'all' | 'any' | string[]
private readonly requireAcknowledgmentFromSpecificHostsForTopics: Record<string, 'all' | 'any' | string[]>
private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
/**
* 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: string[], config: SHIPBroadcasterConfig = {}) {
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: Transaction
): Promise<BroadcastResponse | BroadcastFailure> {
let beef: number[]
const offChainValues = tx.metadata.get('OffChainValues') as number[]
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: Record<string, Set<string>> = {}
for (const result of successfulHosts) {
const host = result.host
const steak = result.steak as STEAK
const acknowledgedTopics = new Set<string>()
for (const [topic, instructions] of Object.entries(steak)) {
const outputsToAdmit = instructions.outputsToAdmit
const coinsToRetain = instructions.coinsToRetain
const coinsRemoved = instructions.coinsRemoved as number[]
if (
outputsToAdmit?.length > 0 ||
coinsToRetain?.length > 0 ||
coinsRemoved?.length > 0
) {
acknowledgedTopics.add(topic)
}
}
hostAcknowledgments[host] = acknowledgedTopics
}
// Now, perform the checks
// Check requireAcknowledgmentFromAllHostsForTopics
let requiredTopicsAllHosts: string[]
let requireAllHosts: 'all' | 'any'
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: string[]
let requireAnyHost: 'all' | 'any'
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'}.`
}
}
private checkAcknowledgmentFromAllHosts (
hostAcknowledgments: Record<string, Set<string>>,
requiredTopics: string[],
require: 'all' | 'any'
): boolean {
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
}
private checkAcknowledgmentFromAnyHost (
hostAcknowledgments: Record<string, Set<string>>,
requiredTopics: string[],
require: 'all' | 'any'
): boolean {
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
}
}
private checkAcknowledgmentFromSpecificHosts (
hostAcknowledgments: Record<string, Set<string>>,
requirements: Record<string, 'all' | 'any' | string[]>
): boolean {
for (const [host, requiredTopicsOrAllAny] of Object.entries(requirements)) {
const acknowledgedTopics = hostAcknowledgments[host]
if (acknowledgedTopics == null) {
// Host did not respond successfully
return false
}
let requiredTopics: string[]
let require: 'all' | 'any'
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.
*/
private async findInterestedHosts (): Promise<Record<string, Set<string>>> {
// Handle the local network preset
if (this.networkPreset === 'local') {
const resultSet = new Set<string>()
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: Record<string, Set<string>> = {}
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
}
}