UNPKG

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
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; } }