UNPKG

lotus-sdk

Version:

Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem

271 lines (270 loc) 11 kB
import { Hash } from '../../bitcore/crypto/hash.js'; import { Schnorr } from '../../bitcore/crypto/schnorr.js'; import { DiscoveryError, DiscoveryErrorType, DEFAULT_DISCOVERY_OPTIONS, } from './types.js'; export class DHTAdvertiser { coordinator; activeAdvertisements = new Map(); rateLimitTracker = new Map(); started = false; cleanupTimer; signingKey; constructor(coordinator, signingKey) { this.coordinator = coordinator; this.signingKey = signingKey; } async advertise(advertisement, options = {}) { if (!this.started) { throw new DiscoveryError(DiscoveryErrorType.CONFIGURATION_ERROR, 'Advertiser not started'); } const opts = { ...DEFAULT_DISCOVERY_OPTIONS, ...options }; await this.validateAdvertisement(advertisement); if (this.signingKey) { advertisement = await this.signAdvertisement(advertisement); } this.checkRateLimits(advertisement.peerInfo.peerId, opts); const record = { advertisement, options: opts, lastRefresh: Date.now(), retryCount: 0, withdrawing: false, }; this.activeAdvertisements.set(advertisement.id, record); try { await this.publishToDHT(advertisement, opts); if (opts.autoRefresh) { this.setupRefreshTimer(advertisement.id, record); } return advertisement.id; } catch (error) { this.activeAdvertisements.delete(advertisement.id); throw new DiscoveryError(DiscoveryErrorType.DHT_OPERATION_FAILED, `Failed to advertise: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined); } } async withdraw(advertisementId) { const record = this.activeAdvertisements.get(advertisementId); if (!record) { return; } record.withdrawing = true; if (record.refreshTimer) { clearTimeout(record.refreshTimer); record.refreshTimer = undefined; } try { await this.removeFromDHT(advertisementId); } catch (error) { throw new DiscoveryError(DiscoveryErrorType.DHT_OPERATION_FAILED, `Failed to withdraw advertisement: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined); } finally { this.activeAdvertisements.delete(advertisementId); } } async refresh(advertisementId, options = {}) { const record = this.activeAdvertisements.get(advertisementId); if (!record) { throw new DiscoveryError(DiscoveryErrorType.INVALID_ADVERTISEMENT, 'Advertisement not found'); } if (record.withdrawing) { throw new DiscoveryError(DiscoveryErrorType.INVALID_ADVERTISEMENT, 'Advertisement is being withdrawn'); } const opts = { ...record.options, ...options }; try { const now = Date.now(); record.advertisement.expiresAt = now + opts.ttl; record.lastRefresh = now; record.retryCount = 0; await this.publishToDHT(record.advertisement, opts); if (opts.autoRefresh && record.refreshTimer) { clearTimeout(record.refreshTimer); this.setupRefreshTimer(advertisementId, record); } } catch (error) { record.retryCount++; if (record.retryCount < opts.maxRetries) { setTimeout(() => { this.refresh(advertisementId, options).catch(() => { }); }, opts.retryDelay * record.retryCount); } else { await this.withdraw(advertisementId).catch(() => { }); } throw new DiscoveryError(DiscoveryErrorType.DHT_OPERATION_FAILED, `Failed to refresh advertisement: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined); } } getActiveAdvertisements() { return Array.from(this.activeAdvertisements.keys()); } isAdvertisementActive(advertisementId) { const record = this.activeAdvertisements.get(advertisementId); return record !== undefined && !record.withdrawing; } async start() { if (this.started) { return; } this.started = true; this.cleanupTimer = setInterval(() => { this.cleanupExpiredAdvertisements(); }, 60 * 1000); } async stop() { if (!this.started) { return; } this.started = false; if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = undefined; } const withdrawalPromises = Array.from(this.activeAdvertisements.keys()).map(id => this.withdraw(id).catch(() => { })); await Promise.allSettled(withdrawalPromises); } async signAdvertisement(advertisement) { if (!this.signingKey) { throw new DiscoveryError(DiscoveryErrorType.CONFIGURATION_ERROR, 'No signing key available'); } try { const messageData = this.constructSignedMessage(advertisement); const messageHash = Hash.sha256(messageData); const signature = Schnorr.sign(messageHash, this.signingKey, 'big'); const signedAdvertisement = { ...advertisement }; signedAdvertisement.signature = signature.toBuffer('schnorr'); if (!signedAdvertisement.peerInfo.publicKey) { signedAdvertisement.peerInfo.publicKey = this.signingKey.publicKey; } return signedAdvertisement; } catch (error) { throw new DiscoveryError(DiscoveryErrorType.SIGNATURE_ERROR, `Failed to sign advertisement: ${error instanceof Error ? error.message : 'Unknown error'}`); } } constructSignedMessage(advertisement) { const parts = []; parts.push(Buffer.from(advertisement.peerInfo.peerId)); if (advertisement.peerInfo.multiaddrs) { parts.push(Buffer.from(JSON.stringify(advertisement.peerInfo.multiaddrs))); } parts.push(Buffer.from(advertisement.protocol)); if (advertisement.capabilities) { parts.push(Buffer.from(JSON.stringify(advertisement.capabilities))); } parts.push(Buffer.from(advertisement.createdAt.toString())); parts.push(Buffer.from(advertisement.expiresAt.toString())); if (advertisement.customCriteria) { parts.push(Buffer.from(JSON.stringify(advertisement.customCriteria))); } return Buffer.concat(parts); } setSigningKey(signingKey) { this.signingKey = signingKey; } getSigningKey() { return this.signingKey; } async validateAdvertisement(advertisement) { if (!advertisement.id || !advertisement.protocol || !advertisement.peerInfo) { throw new DiscoveryError(DiscoveryErrorType.INVALID_ADVERTISEMENT, 'Missing required advertisement fields'); } if (advertisement.expiresAt <= Date.now()) { throw new DiscoveryError(DiscoveryErrorType.ADVERTISEMENT_EXPIRED, 'Advertisement already expired'); } if (!advertisement.peerInfo.peerId || !advertisement.peerInfo.multiaddrs?.length) { throw new DiscoveryError(DiscoveryErrorType.INVALID_ADVERTISEMENT, 'Invalid peer information'); } if (advertisement.signature) { } } checkRateLimits(peerId, options) { if (!options.enableRateLimit) { return; } const now = Date.now(); const windowStart = now - options.rateLimitWindow; let totalOperations = 0; for (const [pid, tracker] of Array.from(this.rateLimitTracker.entries())) { if (tracker.windowStart < windowStart) { this.rateLimitTracker.delete(pid); } else { totalOperations += tracker.count; } } if (totalOperations >= options.maxOperationsPerWindow) { throw new DiscoveryError(DiscoveryErrorType.RATE_LIMIT_EXCEEDED, 'Global rate limit exceeded'); } const tracker = this.rateLimitTracker.get(peerId) || { count: 0, windowStart: now, }; if (tracker.count >= 5) { throw new DiscoveryError(DiscoveryErrorType.RATE_LIMIT_EXCEEDED, 'Peer rate limit exceeded'); } const currentPeer = this.coordinator.peerId; const currentTracker = this.rateLimitTracker.get(currentPeer) || { count: 0, windowStart: now, }; currentTracker.count++; this.rateLimitTracker.set(currentPeer, currentTracker); } async publishToDHT(advertisement, options) { await this.coordinator.announceResource('discovery:advertisement', advertisement.id, advertisement, { ttl: options.ttl, expiresAt: advertisement.expiresAt, }); const topic = this.getGossipSubTopic(advertisement); try { await this.coordinator.publishToTopic(topic, advertisement); console.log(`[Discovery] Published advertisement to GossipSub topic: ${topic}`); } catch (error) { console.warn(`[Discovery] Failed to publish to GossipSub topic ${topic}:`, error); } } getGossipSubTopic(advertisement) { return `lotus/discovery/${advertisement.protocol}`; } async removeFromDHT(advertisementId) { const dhtKey = `discovery:advertisement:${advertisementId}`; } getDHTKey(advertisement) { return `discovery:advertisement:${advertisement.protocol}:${advertisement.id}`; } setupRefreshTimer(advertisementId, record) { if (record.refreshTimer) { clearTimeout(record.refreshTimer); } const refreshInterval = record.options.refreshInterval; record.refreshTimer = setTimeout(async () => { try { await this.refresh(advertisementId); } catch (error) { console.error('Advertisement refresh failed:', error); } }, refreshInterval); } cleanupExpiredAdvertisements() { const now = Date.now(); const expiredIds = []; for (const [id, record] of Array.from(this.activeAdvertisements.entries())) { if (record.advertisement.expiresAt <= now || record.withdrawing) { expiredIds.push(id); } } for (const id of expiredIds) { this.withdraw(id).catch(() => { }); } } }