@bsv/sdk
Version:
BSV Blockchain Software Development Kit
289 lines (258 loc) • 9.45 kB
text/typescript
import { Transaction } from '../transaction/index.js'
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
/**
* The question asked to the Overlay Services Engine when a consumer of state wishes to look up information.
*/
export interface LookupQuestion {
/**
* The identifier for a Lookup Service which the person asking the question wishes to use.
*/
service: string
/**
* The query which will be forwarded to the Lookup Service.
* Its type depends on that prescribed by the Lookup Service employed.
*/
query: unknown
}
/**
* How the Overlay Services Engine responds to a Lookup Question.
* It may comprise either an output list or a freeform response from the Lookup Service.
*/
export type LookupAnswer =
| {
type: 'output-list'
outputs: Array<{
beef: number[]
outputIndex: number
context?: number[]
}>
}
/** Default SLAP trackers */
export const DEFAULT_SLAP_TRACKERS: string[] = [
// BSVA clusters
'https://overlay-us-1.bsvb.tech',
'https://overlay-eu-1.bsvb.tech',
'https://overlay-ap-1.bsvb.tech',
// Babbage primary overlay service
'https://users.bapp.dev'
// NOTE: Other entities may submit pull requests to the library if they maintain SLAP overlay services.
// Additional trackers run by different entities contribute to greater network resiliency.
// It also generally doesn't hurt to have more trackers in this list.
// DISCLAIMER:
// Trackers known to host invalid or illegal records will be removed at the discretion of the BSV Association.
]
/** Default testnet SLAP trackers */
export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
// Babbage primary testnet overlay service
'https://testnet-users.bapp.dev'
]
const MAX_TRACKER_WAIT_TIME = 5000
/** Configuration options for the Lookup resolver. */
export interface LookupResolverConfig {
/**
* The network preset to use, unless other options override it.
* - mainnet: use mainnet SLAP trackers and HTTPS facilitator
* - testnet: use testnet SLAP trackers and HTTPS facilitator
* - local: directly query from localhost:8080 and a facilitator that permits plain HTTP
*/
networkPreset?: 'mainnet' | 'testnet' | 'local'
/** The facilitator used to make requests to Overlay Services hosts. */
facilitator?: OverlayLookupFacilitator
/** The list of SLAP trackers queried to resolve Overlay Services hosts for a given lookup service. */
slapTrackers?: string[]
/** Map of lookup service names to arrays of hosts to use in place of resolving via SLAP. */
hostOverrides?: Record<string, string[]>
/** Map of lookup service names to arrays of hosts to use in addition to resolving via SLAP. */
additionalHosts?: Record<string, string[]>
}
/** Facilitates lookups to URLs that return answers. */
export interface OverlayLookupFacilitator {
/**
* Returns a lookup answer for a lookup question
* @param url - Overlay Service URL to send the lookup question to.
* @param question - Lookup question to find an answer to.
* @param timeout - Specifics how long to wait for a lookup answer in milliseconds.
* @returns
*/
lookup: (
url: string,
question: LookupQuestion,
timeout?: number
) => Promise<LookupAnswer>
}
export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
fetchClient: typeof fetch
allowHTTP: boolean
constructor (httpClient = fetch, allowHTTP: boolean = false) {
this.fetchClient = httpClient
this.allowHTTP = allowHTTP
}
async lookup (
url: string,
question: LookupQuestion,
timeout: number = 5000
): Promise<LookupAnswer> {
if (!url.startsWith('https:') && !this.allowHTTP) {
throw new Error(
'HTTPS facilitator can only use URLs that start with "https:"'
)
}
const timeoutPromise = new Promise((resolve, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeout)
)
const fetchPromise = fetch(`${url}/lookup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
service: question.service,
query: question.query
})
})
const response: Response = (await Promise.race([
fetchPromise,
timeoutPromise
])) as Response
if (response.ok) {
return await response.json()
} else {
throw new Error('Failed to facilitate lookup')
}
}
}
/**
* Represents an SHIP transaction broadcaster.
*/
export default class LookupResolver {
private readonly facilitator: OverlayLookupFacilitator
private readonly slapTrackers: string[]
private readonly hostOverrides: Record<string, string[]>
private readonly additionalHosts: Record<string, string[]>
private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
constructor (config: LookupResolverConfig = {}) {
this.networkPreset = config.networkPreset ?? 'mainnet'
this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local')
this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS)
this.hostOverrides = config.hostOverrides ?? {}
this.additionalHosts = config.additionalHosts ?? {}
}
/**
* Given a LookupQuestion, returns a LookupAnswer. Aggregates across multiple services and supports resiliency.
*/
async query (
question: LookupQuestion,
timeout?: number
): Promise<LookupAnswer> {
let competentHosts: string[] = []
if (question.service === 'ls_slap') {
competentHosts = this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers
} else if (this.hostOverrides[question.service] != null) {
competentHosts = this.hostOverrides[question.service]
} else if (this.networkPreset === 'local') {
competentHosts = ['http://localhost:8080']
} else {
competentHosts = await this.findCompetentHosts(question.service)
}
if (this.additionalHosts[question.service]?.length > 0) {
competentHosts = [
...competentHosts,
...this.additionalHosts[question.service]
]
}
if (competentHosts.length < 1) {
throw new Error(
`No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}`
)
}
// Use Promise.allSettled to handle individual host failures
const hostResponses = await Promise.allSettled(
competentHosts.map(
async (host) => await this.facilitator.lookup(host, question, timeout)
)
)
const successfulResponses = hostResponses
.filter((result): result is PromiseFulfilledResult<LookupAnswer> => result.status === 'fulfilled')
.map((result) => result.value)
if (successfulResponses.length === 0) {
throw new Error('No successful responses from any hosts')
}
// Process the successful responses
// Aggregate outputs from all successful responses
const outputs = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
for (const response of successfulResponses) {
if (response.type !== 'output-list') {
continue
}
try {
for (const output of response.outputs) {
try {
const txId: string = Transaction.fromBEEF(output.beef).id('hex') // !! This is STUPIDLY inefficient.
const key = `${txId}.${output.outputIndex}`
outputs.set(key, output)
} catch {
continue
}
}
} catch (_) {
// Error processing output, proceed.
}
}
return {
type: 'output-list',
outputs: Array.from(outputs.values())
}
}
/**
* Returns a list of competent hosts for a given lookup service.
* @param service Service for which competent hosts are to be returned
* @returns Array of hosts competent for resolving queries
*/
private async findCompetentHosts (service: string): Promise<string[]> {
const query: LookupQuestion = {
service: 'ls_slap',
query: {
service
}
}
// Use Promise.allSettled to handle individual SLAP tracker failures
const trackerResponses = await Promise.allSettled(
this.slapTrackers.map(
async (tracker) =>
await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
)
)
const hosts = new Set<string>()
for (const result of trackerResponses) {
if (result.status === 'fulfilled') {
const answer = result.value
if (answer.type !== 'output-list') {
// Log invalid response and continue
continue
}
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 (
parsed.topicOrService !== service ||
parsed.protocol !== 'SLAP'
) {
// Invalid advertisement, skip
continue
}
hosts.add(parsed.domain)
} catch {
// Invalid output, skip
continue
}
}
} else {
// Log tracker failure and continue
continue
}
}
return [...hosts]
}
}