@bsv/sdk
Version:
BSV Blockchain Software Development Kit
500 lines (444 loc) • 18 kB
text/typescript
import { Transaction } from '../transaction/index.js'
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
import * as Utils from '../primitives/utils.js'
import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js'
const defaultFetch: typeof fetch =
typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
? globalThis.fetch.bind(globalThis)
: fetch
/**
* 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
/** Internal cache options. Kept optional to preserve drop-in compatibility. */
interface CacheOptions {
/** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
hostsTtlMs?: number
/** How many distinct services’ hosts to cache before evicting. Default 128. */
hostsMaxEntries?: number
/** How long (ms) to keep txId memoization. Default 10 minutes. */
txMemoTtlMs?: number
}
/** 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[]>
/** Optional cache tuning. */
cache?: CacheOptions
/** Optional storage for host reputation data. */
reputationStorage?: 'localStorage' | { get: (key: string) => string | null | undefined, set: (key: string, value: string) => void }
}
/** 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 = defaultFetch, allowHTTP: boolean = false) {
if (typeof httpClient !== 'function') {
throw new Error(
'HTTPSOverlayLookupFacilitator requires a fetch implementation. ' +
'In environments without fetch, provide a polyfill or custom implementation.'
)
}
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 controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined
const timer = setTimeout(() => {
try { controller?.abort() } catch { /* noop */ }
}, timeout)
try {
const fco: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Aggregation': 'yes'
},
body: JSON.stringify({ service: question.service, query: question.query }),
signal: controller?.signal
}
const response: Response = await this.fetchClient(`${url}/lookup`, fco)
if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
if (response.headers.get('content-type') === 'application/octet-stream') {
const payload = await response.arrayBuffer()
const r = new Utils.Reader([...new Uint8Array(payload)])
const nOutpoints = r.readVarIntNum()
const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = []
for (let i = 0; i < nOutpoints; i++) {
const txid = Utils.toHex(r.read(32))
const outputIndex = r.readVarIntNum()
const contextLength = r.readVarIntNum()
let context
if (contextLength > 0) {
context = r.read(contextLength)
}
outpoints.push({
txid,
outputIndex,
context
})
}
const beef = r.read()
return {
type: 'output-list',
outputs: outpoints.map(x => ({
outputIndex: x.outputIndex,
context: x.context,
beef: Transaction.fromBEEF(beef, x.txid).toBEEF()
}))
}
} else {
return await response.json()
}
} catch (e) {
// Normalize timeouts to a consistent error message
if ((e as any)?.name === 'AbortError') throw new Error('Request timed out')
throw e
} finally {
clearTimeout(timer)
}
}
}
/**
* Represents a Lookup Resolver.
*/
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'
private readonly hostReputation: HostReputationTracker
// ---- Caches / memoization ----
private readonly hostsCache: Map<string, { hosts: string[], expiresAt: number }>
private readonly hostsInFlight: Map<string, Promise<string[]>>
private readonly hostsTtlMs: number
private readonly hostsMaxEntries: number
private readonly txMemo: Map<string, { txId: string, expiresAt: number }>
private readonly txMemoTtlMs: number
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)
const hostOverrides = config.hostOverrides ?? {}
this.assertValidOverrideServices(hostOverrides)
this.hostOverrides = hostOverrides
this.additionalHosts = config.additionalHosts ?? {}
const rs = config.reputationStorage
if (rs === 'localStorage') {
this.hostReputation = new HostReputationTracker()
} else if (typeof rs === 'object' && rs !== null && typeof rs.get === 'function' && typeof rs.set === 'function') {
this.hostReputation = new HostReputationTracker(rs)
} else {
this.hostReputation = getOverlayHostReputationTracker()
}
// cache tuning
this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000 // 5 min
this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128
this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000 // 10 min
this.hostsCache = new Map()
this.hostsInFlight = new Map()
this.txMemo = new Map()
}
/**
* 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.getCompetentHostsCached(question.service)
}
if (this.additionalHosts[question.service]?.length > 0) {
// preserve order: resolved hosts first, then additional (unique)
const extra = this.additionalHosts[question.service]
const seen = new Set(competentHosts)
for (const h of extra) if (!seen.has(h)) competentHosts.push(h)
}
if (competentHosts.length < 1) {
throw new Error(
`No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}`
)
}
const rankedHosts = this.prepareHostsForQuery(
competentHosts,
`lookup service ${question.service}`
)
if (rankedHosts.length < 1) {
throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`)
}
// Fire all hosts with per-host timeout, harvest successful output-list responses
const hostResponses = await Promise.allSettled(
rankedHosts.map(async (host) => {
return await this.lookupHostWithTracking(host, question, timeout)
})
)
const outputsMap = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
// Memo key helper for tx parsing
const beefKey = (beef: number[]): string => {
if (typeof beef !== 'object') return '' // The invalid BEEF has an empty key.
// A fast and deterministic key for memoization; avoids large JSON strings
// since beef is an array of integers, join is safe and compact.
return beef.join(',')
}
for (const result of hostResponses) {
if (result.status !== 'fulfilled') continue
const response = result.value
if (response?.type !== 'output-list' || !Array.isArray(response.outputs)) continue
for (const output of response.outputs) {
const keyForBeef = beefKey(output.beef)
let memo = this.txMemo.get(keyForBeef)
const now = Date.now()
if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
try {
const txId = Transaction.fromBEEF(output.beef).id('hex')
memo = { txId, expiresAt: now + this.txMemoTtlMs }
// prune opportunistically if the map gets too large (cheap heuristic)
if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
this.txMemo.set(keyForBeef, memo)
} catch {
continue
}
}
const uniqKey = `${memo.txId}.${output.outputIndex}`
// last-writer wins is fine here; outputs are identical if uniqKey matches
outputsMap.set(uniqKey, output)
}
}
return {
type: 'output-list',
outputs: Array.from(outputsMap.values())
}
}
/**
* Cached wrapper for competent host discovery with stale-while-revalidate.
*/
private async getCompetentHostsCached (service: string): Promise<string[]> {
const now = Date.now()
const cached = this.hostsCache.get(service)
// if fresh, return immediately
if (typeof cached === 'object' && cached.expiresAt > now) {
return cached.hosts.slice()
}
// if stale but present, kick off a refresh if not already in-flight and return stale
if (typeof cached === 'object' && cached.expiresAt <= now) {
if (!this.hostsInFlight.has(service)) {
this.hostsInFlight.set(service, this.refreshHosts(service).finally(() => {
this.hostsInFlight.delete(service)
}))
}
return cached.hosts.slice()
}
// no cache: coalesce concurrent requests
if (this.hostsInFlight.has(service)) {
try {
const hosts = await this.hostsInFlight.get(service)
if (typeof hosts !== 'object') {
throw new Error('Hosts is not defined.')
}
return hosts.slice()
} catch {
// fall through to a fresh attempt below
}
}
const promise = this.refreshHosts(service).finally(() => {
this.hostsInFlight.delete(service)
})
this.hostsInFlight.set(service, promise)
const hosts = await promise
return hosts.slice()
}
/**
* Actually resolves competent hosts from SLAP trackers and updates cache.
*/
private async refreshHosts (service: string): Promise<string[]> {
const hosts = await this.findCompetentHosts(service)
const expiresAt = Date.now() + this.hostsTtlMs
// bounded cache with simple FIFO eviction
if (!this.hostsCache.has(service) && this.hostsCache.size >= this.hostsMaxEntries) {
const oldestKey = this.hostsCache.keys().next().value
if (oldestKey !== undefined) this.hostsCache.delete(oldestKey)
}
this.hostsCache.set(service, { hosts, expiresAt })
return hosts
}
/**
* 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 }
}
// Query all SLAP trackers; tolerate failures.
const trackerHosts = this.prepareHostsForQuery(
this.slapTrackers,
'SLAP trackers'
)
if (trackerHosts.length === 0) return []
const trackerResponses = await Promise.allSettled(
trackerHosts.map(async (tracker) =>
await this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
)
)
const hosts = new Set<string>()
for (const result of trackerResponses) {
if (result.status !== 'fulfilled') continue
const answer = result.value
if (answer.type !== 'output-list') continue
for (const output of answer.outputs) {
try {
const tx = Transaction.fromBEEF(output.beef)
const script = tx.outputs[output.outputIndex]?.lockingScript
if (typeof script !== 'object' || script === null) continue
const parsed = OverlayAdminTokenTemplate.decode(script)
if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
hosts.add(parsed.domain)
}
} catch {
continue
}
}
}
return [...hosts]
}
/** Evict an arbitrary “oldest” entry from a Map (iteration order). */
private evictOldest<T>(m: Map<string, T>): void {
const firstKey = m.keys().next().value
if (firstKey !== undefined) m.delete(firstKey)
}
private assertValidOverrideServices (overrides: Record<string, string[]>): void {
for (const service of Object.keys(overrides)) {
if (!service.startsWith('ls_')) {
throw new Error(`Host override service names must start with "ls_": ${service}`)
}
}
}
private prepareHostsForQuery (hosts: string[], context: string): string[] {
if (hosts.length === 0) return []
const now = Date.now()
const ranked = this.hostReputation.rankHosts(hosts, now)
const available = ranked.filter((h) => h.backoffUntil <= now).map((h) => h.host)
if (available.length > 0) return available
const soonest = Math.min(...ranked.map((h) => h.backoffUntil))
const waitMs = Math.max(soonest - now, 0)
throw new Error(
`All ${context} hosts are backing off for approximately ${waitMs}ms due to repeated failures.`
)
}
private async lookupHostWithTracking (
host: string,
question: LookupQuestion,
timeout?: number
): Promise<LookupAnswer> {
const startedAt = Date.now()
try {
const answer = await this.facilitator.lookup(host, question, timeout)
const latency = Date.now() - startedAt
const isValid =
typeof answer === 'object' &&
answer !== null &&
answer.type === 'output-list' &&
Array.isArray((answer).outputs)
if (isValid) {
this.hostReputation.recordSuccess(host, latency)
} else {
this.hostReputation.recordFailure(host, 'Invalid lookup response')
}
return answer
} catch (err) {
this.hostReputation.recordFailure(host, err)
throw err
}
}
}