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
JavaScript
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(() => {
});
}
}
}