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