lotus-sdk
Version:
Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem
421 lines (420 loc) • 15.8 kB
JavaScript
import { DiscoveryError, DiscoveryErrorType, DEFAULT_DISCOVERY_OPTIONS, } from './types.js';
import { DiscoverySecurityValidator } from './security.js';
const DISCOVERY_TOPIC_PREFIX = 'lotus/discovery';
const DEFAULT_SUBSCRIPTION_OPTIONS = {
fetchExisting: true,
fetchTimeout: 5000,
deduplicate: true,
};
export class DHTDiscoverer {
coordinator;
cache = new Map();
subscriptions = new Map();
rateLimitTracker = new Map();
started = false;
cleanupTimer;
cacheStats = {
size: 0,
hits: 0,
misses: 0,
hitRate: 0,
};
constructor(coordinator) {
this.coordinator = coordinator;
}
async discover(criteria, options) {
if (!this.started) {
throw new DiscoveryError(DiscoveryErrorType.CONFIGURATION_ERROR, 'Discoverer not started');
}
const opts = { ...DEFAULT_DISCOVERY_OPTIONS, ...options };
this.checkRateLimits(criteria.protocol, opts);
const keys = this.getDHTKeys(criteria);
const results = [];
const seenIds = new Set();
for (const key of keys) {
try {
const announcement = await this.coordinator.discoverResource('discovery:advertisement', key, 5000);
if (announcement && announcement.data) {
const advertisement = announcement.data;
if (seenIds.has(advertisement.id)) {
continue;
}
seenIds.add(advertisement.id);
if (this.isValidAdvertisement(advertisement, Date.now())) {
if (this.matchesCriteria(advertisement, criteria)) {
const securityResult = await this.validateAdvertisementSecurity(advertisement);
if (securityResult.valid && securityResult.securityScore >= 50) {
results.push(advertisement);
this.addToCache(advertisement);
}
}
}
}
}
catch (error) {
console.error(`DHT discovery failed for key ${key}:`, error);
}
}
results.sort((a, b) => {
const cacheEntryA = this.cache.get(a.id);
const cacheEntryB = this.cache.get(b.id);
if (a.reputation !== b.reputation) {
return b.reputation - a.reputation;
}
return (cacheEntryB?.accessCount || 0) - (cacheEntryA?.accessCount || 0);
});
if (criteria.maxResults && results.length > criteria.maxResults) {
return results.slice(0, criteria.maxResults);
}
return results;
}
async subscribe(criteria, callback, subscriptionOptions) {
if (!this.started) {
throw new DiscoveryError(DiscoveryErrorType.CONFIGURATION_ERROR, 'Discoverer not started');
}
const opts = { ...DEFAULT_SUBSCRIPTION_OPTIONS, ...subscriptionOptions };
const subscriptionId = this.generateSubscriptionId(criteria);
const topic = this.criteriaToTopic(criteria);
const subscription = {
id: subscriptionId,
criteria,
topic,
handler: callback,
active: true,
createdAt: Date.now(),
lastUpdate: Date.now(),
seenAdvertisements: new Set(),
options: opts,
};
this.subscriptions.set(subscriptionId, subscription);
await this.coordinator.subscribeToTopic(topic, (data) => {
this.handleGossipSubMessage(subscriptionId, data);
});
console.log(`[Discovery] Subscribed to GossipSub topic: ${topic} (subscription: ${subscriptionId})`);
if (opts.fetchExisting) {
try {
const fetchCriteria = {
...criteria,
timeout: opts.fetchTimeout,
};
const existing = await this.discover(fetchCriteria);
for (const advertisement of existing) {
if (!opts.deduplicate ||
!subscription.seenAdvertisements.has(advertisement.id)) {
subscription.seenAdvertisements.add(advertisement.id);
subscription.lastUpdate = Date.now();
setImmediate(() => {
if (subscription.active) {
callback(advertisement);
}
});
}
}
}
catch (error) {
console.warn(`[Discovery] Failed to fetch existing advertisements for subscription ${subscriptionId}:`, error);
}
}
return {
id: subscriptionId,
criteria,
callback,
active: true,
createdAt: subscription.createdAt,
lastActivity: subscription.lastUpdate,
topic,
};
}
async unsubscribe(subscriptionId) {
const subscription = this.subscriptions.get(subscriptionId);
if (!subscription) {
return;
}
subscription.active = false;
try {
await this.coordinator.unsubscribeFromTopic(subscription.topic);
console.log(`[Discovery] Unsubscribed from GossipSub topic: ${subscription.topic}`);
}
catch (error) {
console.warn(`[Discovery] Failed to unsubscribe from topic ${subscription.topic}:`, error);
}
this.subscriptions.delete(subscriptionId);
}
getActiveSubscriptions() {
return Array.from(this.subscriptions.entries())
.filter(([, record]) => record.active)
.map(([id]) => id);
}
clearCache(protocol) {
if (protocol) {
for (const [key, entry] of Array.from(this.cache.entries())) {
if (entry.advertisement.protocol === protocol) {
this.cache.delete(key);
}
}
}
else {
this.cache.clear();
}
this.updateCacheStats();
}
getCacheStats() {
this.updateCacheStats();
return { ...this.cacheStats };
}
async start() {
if (this.started) {
return;
}
this.started = true;
this.cleanupTimer = setInterval(() => {
this.cleanupCache();
this.cleanupSubscriptions();
}, 5 * 60 * 1000);
}
async stop() {
if (!this.started) {
return;
}
this.started = false;
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
const subscriptionIds = Array.from(this.subscriptions.keys());
await Promise.allSettled(subscriptionIds.map(id => this.unsubscribe(id)));
}
handleGossipSubMessage(subscriptionId, data) {
const subscription = this.subscriptions.get(subscriptionId);
if (!subscription || !subscription.active) {
return;
}
try {
const messageStr = new TextDecoder().decode(data);
const advertisement = JSON.parse(messageStr);
if (!this.isValidAdvertisement(advertisement, Date.now())) {
console.warn(`[Discovery] Invalid advertisement received on topic ${subscription.topic}`);
return;
}
if (!this.matchesCriteria(advertisement, subscription.criteria)) {
return;
}
if (subscription.options.deduplicate &&
subscription.seenAdvertisements.has(advertisement.id)) {
return;
}
subscription.seenAdvertisements.add(advertisement.id);
subscription.lastUpdate = Date.now();
this.addToCache(advertisement);
subscription.handler(advertisement);
}
catch (error) {
console.error(`[Discovery] Error processing GossipSub message for subscription ${subscriptionId}:`, error);
}
}
criteriaToTopic(criteria) {
return `${DISCOVERY_TOPIC_PREFIX}/${criteria.protocol}`;
}
getDHTKeys(criteria) {
const keys = [];
keys.push(`discovery:${criteria.protocol}:all`);
if (criteria.capabilities) {
for (const capability of criteria.capabilities) {
keys.push(`discovery:${criteria.protocol}:capability:${capability}`);
}
}
if (criteria.location) {
const latGrid = Math.floor(criteria.location.latitude / 5) * 5;
const lonGrid = Math.floor(criteria.location.longitude / 5) * 5;
keys.push(`discovery:${criteria.protocol}:location:${latGrid}:${lonGrid}`);
}
return keys;
}
isValidAdvertisement(advertisement, now) {
if (!advertisement.id ||
!advertisement.protocol ||
!advertisement.peerInfo) {
return false;
}
if (advertisement.expiresAt <= now) {
return false;
}
if (!advertisement.peerInfo.peerId ||
!advertisement.peerInfo.multiaddrs?.length) {
return false;
}
return true;
}
matchesCriteria(advertisement, criteria) {
if (advertisement.protocol !== criteria.protocol) {
return false;
}
if (criteria.capabilities) {
const hasAllCapabilities = criteria.capabilities.every(cap => advertisement.capabilities.includes(cap));
if (!hasAllCapabilities) {
return false;
}
}
if (criteria.minReputation &&
advertisement.reputation < criteria.minReputation) {
return false;
}
if (criteria.location && advertisement.location) {
const distance = this.calculateDistance(criteria.location.latitude, criteria.location.longitude, advertisement.location.latitude, advertisement.location.longitude);
if (distance > criteria.location.radiusKm) {
return false;
}
}
if (criteria.customCriteria) {
if (!advertisement.customCriteria) {
return false;
}
for (const [key, expectedValue] of Object.entries(criteria.customCriteria)) {
const actualValue = advertisement.customCriteria[key];
if (actualValue !== expectedValue) {
return false;
}
}
}
return true;
}
async validateAdvertisementSecurity(advertisement) {
const now = Date.now();
const result = {
valid: true,
securityScore: 100,
details: {
signatureValid: true,
notExpired: advertisement.expiresAt > now,
reputationAcceptable: advertisement.reputation >= 50,
criteriaMatch: true,
customValidation: true,
},
};
if (!result.details.notExpired) {
result.valid = false;
result.error = 'Advertisement expired';
result.securityScore -= 50;
}
if (!result.details.reputationAcceptable) {
result.valid = false;
result.error = 'Reputation too low';
result.securityScore -= 30;
}
if (advertisement.signature) {
try {
const securityValidator = new DiscoverySecurityValidator(this.coordinator, {
enableSignatureVerification: true,
enableReplayPrevention: true,
enableRateLimiting: false,
rateLimits: {
maxAdvertisementsPerPeer: 0,
maxDiscoveryQueriesPerPeer: 0,
windowSizeMs: 0,
},
minReputation: 0,
maxAdvertisementAge: 24 * 60 * 60 * 1000,
customValidators: [],
});
const validation = await securityValidator.validateAdvertisement(advertisement, {});
result.details.signatureValid = validation.details.signatureValid;
}
catch (error) {
result.details.signatureValid = false;
result.securityScore -= 20;
}
}
return result;
}
addToCache(advertisement) {
const existing = this.cache.get(advertisement.id);
if (existing) {
existing.timestamp = Date.now();
existing.accessCount++;
existing.lastAccess = Date.now();
}
else {
this.cache.set(advertisement.id, {
advertisement,
timestamp: Date.now(),
accessCount: 1,
lastAccess: Date.now(),
});
}
this.updateCacheStats();
}
checkRateLimits(protocol, options) {
if (!options.enableCache) {
return;
}
const now = Date.now();
const windowStart = now - options.cacheTTL;
const key = `discover:${protocol}`;
let tracker = this.rateLimitTracker.get(key);
if (!tracker || tracker.windowStart < windowStart) {
tracker = { count: 0, windowStart: now };
this.rateLimitTracker.set(key, tracker);
}
if (tracker.count >= 100) {
throw new DiscoveryError(DiscoveryErrorType.RATE_LIMIT_EXCEEDED, 'Discovery rate limit exceeded');
}
tracker.count++;
}
cleanupCache() {
const now = Date.now();
const maxAge = 30 * 60 * 1000;
for (const [key, entry] of Array.from(this.cache.entries())) {
if (now - entry.timestamp > maxAge) {
this.cache.delete(key);
}
}
this.updateCacheStats();
}
cleanupSubscriptions() {
const now = Date.now();
const maxAge = 60 * 60 * 1000;
const inactiveIds = [];
for (const [id, subscription] of Array.from(this.subscriptions.entries())) {
if (!subscription.active || now - subscription.lastUpdate > maxAge) {
inactiveIds.push(id);
}
}
for (const id of inactiveIds) {
this.unsubscribe(id).catch(() => {
});
}
}
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = this.toRadians(lat2 - lat1);
const dLon = this.toRadians(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) *
Math.cos(this.toRadians(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
toRadians(degrees) {
return degrees * (Math.PI / 180);
}
generateSubscriptionId(criteria) {
const hash = this.simpleHash(JSON.stringify(criteria) + Date.now());
return `sub:${hash}`;
}
simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
updateCacheStats() {
this.cacheStats.size = this.cache.size;
const total = this.cacheStats.hits + this.cacheStats.misses;
this.cacheStats.hitRate = total > 0 ? this.cacheStats.hits / total : 0;
}
}