UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

398 lines 17.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HTTPSOverlayLookupFacilitator = exports.DEFAULT_TESTNET_SLAP_TRACKERS = exports.DEFAULT_SLAP_TRACKERS = void 0; const index_js_1 = require("../transaction/index.js"); const OverlayAdminTokenTemplate_js_1 = __importDefault(require("./OverlayAdminTokenTemplate.js")); const Utils = __importStar(require("../primitives/utils.js")); const HostReputationTracker_js_1 = require("./HostReputationTracker.js"); const defaultFetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : fetch; /** Default SLAP trackers */ exports.DEFAULT_SLAP_TRACKERS = [ // 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 */ exports.DEFAULT_TESTNET_SLAP_TRACKERS = [ // Babbage primary testnet overlay service 'https://testnet-users.bapp.dev' ]; const MAX_TRACKER_WAIT_TIME = 5000; class HTTPSOverlayLookupFacilitator { constructor(httpClient = defaultFetch, allowHTTP = 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, question, timeout = 5000) { 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 = { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Aggregation': 'yes' }, body: JSON.stringify({ service: question.service, query: question.query }), signal: controller?.signal }; const 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 = []; 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: index_js_1.Transaction.fromBEEF(beef, x.txid).toBEEF() })) }; } else { return await response.json(); } } catch (e) { // Normalize timeouts to a consistent error message if (e?.name === 'AbortError') throw new Error('Request timed out'); throw e; } finally { clearTimeout(timer); } } } exports.HTTPSOverlayLookupFacilitator = HTTPSOverlayLookupFacilitator; /** * Represents a Lookup Resolver. */ class LookupResolver { constructor(config = {}) { this.networkPreset = config.networkPreset ?? 'mainnet'; this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local'); this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? exports.DEFAULT_SLAP_TRACKERS : exports.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_js_1.HostReputationTracker(); } else if (typeof rs === 'object' && rs !== null && typeof rs.get === 'function' && typeof rs.set === 'function') { this.hostReputation = new HostReputationTracker_js_1.HostReputationTracker(rs); } else { this.hostReputation = (0, HostReputationTracker_js_1.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, timeout) { let competentHosts = []; 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(); // Memo key helper for tx parsing const beefKey = (beef) => { 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 = index_js_1.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. */ async getCompetentHostsCached(service) { 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. */ async refreshHosts(service) { 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 */ async findCompetentHosts(service) { const query = { 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(); 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 = index_js_1.Transaction.fromBEEF(output.beef); const script = tx.outputs[output.outputIndex]?.lockingScript; if (typeof script !== 'object' || script === null) continue; const parsed = OverlayAdminTokenTemplate_js_1.default.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). */ evictOldest(m) { const firstKey = m.keys().next().value; if (firstKey !== undefined) m.delete(firstKey); } assertValidOverrideServices(overrides) { for (const service of Object.keys(overrides)) { if (!service.startsWith('ls_')) { throw new Error(`Host override service names must start with "ls_": ${service}`); } } } prepareHostsForQuery(hosts, context) { 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.`); } async lookupHostWithTracking(host, question, timeout) { 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; } } } exports.default = LookupResolver; //# sourceMappingURL=LookupResolver.js.map