lotus-sdk
Version:
Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem
324 lines (323 loc) • 13.2 kB
JavaScript
import { EventEmitter } from 'events';
import { DHTAdvertiser } from '../discovery/dht-advertiser.js';
import { DHTDiscoverer } from '../discovery/dht-discoverer.js';
import { DiscoveryError, DiscoveryErrorType, } from '../discovery/types.js';
import { DEFAULT_MUSIG2_DISCOVERY_CONFIG, isValidSignerAdvertisement, isValidSigningRequestAdvertisement, publicKeyToHex, } from './discovery-types.js';
import { MuSig2Event } from './types.js';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
export class MuSig2Discovery extends EventEmitter {
advertiser;
discoverer;
config;
coordinator;
activeSignerAd = null;
activeRequestAds = new Map();
refreshTimer = null;
constructor(coordinator, config) {
super();
this.coordinator = coordinator;
this.config = { ...DEFAULT_MUSIG2_DISCOVERY_CONFIG, ...config };
this.advertiser = new DHTAdvertiser(coordinator);
this.discoverer = new DHTDiscoverer(coordinator);
}
async start() {
await this.advertiser.start();
await this.discoverer.start();
if (this.config.enableAutoRefresh &&
this.config.signerRefreshInterval > 0) {
this.refreshTimer = setInterval(() => {
this.refreshActiveAdvertisements().catch(err => {
this.emit('error', new DiscoveryError(DiscoveryErrorType.CONFIGURATION_ERROR, `Auto-refresh failed: ${err.message}`, err));
});
}, this.config.signerRefreshInterval);
}
}
async stop() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
await this.withdrawAllAdvertisements();
await this.advertiser.stop();
await this.discoverer.stop();
}
async advertiseSigner(publicKey, transactionTypes, options) {
if (this.activeSignerAd) {
throw new DiscoveryError(DiscoveryErrorType.INVALID_ADVERTISEMENT, 'Signer already advertised. Withdraw first before re-advertising.');
}
const publicKeyHex = publicKeyToHex(publicKey);
const advertisementId = this.generateSignerAdId(publicKeyHex);
const advertisement = {
id: advertisementId,
protocol: 'musig2',
peerInfo: {
peerId: this.coordinator.peerId,
multiaddrs: this.coordinator.libp2pNode
.getMultiaddrs()
.map(ma => ma.toString()),
},
publicKey,
transactionTypes,
amountRange: options?.amountRange,
signerMetadata: options?.metadata,
capabilities: ['musig2-signer', ...transactionTypes],
createdAt: Date.now(),
expiresAt: Date.now() + (options?.ttl || this.config.signerTTL),
reputation: 50,
customCriteria: {
transactionTypes,
publicKeyHex,
},
};
const discoveryOptions = {
ttl: options?.ttl || this.config.signerTTL,
autoRefresh: this.config.enableAutoRefresh,
refreshInterval: this.config.signerRefreshInterval,
};
const adId = await this.advertiser.advertise(advertisement, discoveryOptions);
this.activeSignerAd = adId;
this.emit(MuSig2Event.SIGNER_ADVERTISED, advertisement);
return adId;
}
async withdrawSigner() {
if (!this.activeSignerAd) {
return;
}
await this.advertiser.withdraw(this.activeSignerAd);
this.activeSignerAd = null;
this.emit(MuSig2Event.SIGNER_WITHDRAWN);
}
async discoverSigners(criteria = {}, options) {
const fullCriteria = {
protocol: 'musig2',
...criteria,
};
const results = await this.discoverer.discover(fullCriteria, options);
const signerAds = [];
for (const ad of results) {
if (this.isSignerAdvertisement(ad)) {
if (this.matchesSignerCriteria(ad, fullCriteria)) {
signerAds.push(ad);
this.emit(MuSig2Event.SIGNER_DISCOVERED, ad);
}
}
}
return signerAds;
}
async createSigningRequest(requiredPublicKeys, messageHash, options) {
if (this.activeRequestAds.size >= this.config.maxConcurrentRequests) {
throw new DiscoveryError(DiscoveryErrorType.RATE_LIMIT_EXCEEDED, `Maximum concurrent requests (${this.config.maxConcurrentRequests}) exceeded`);
}
const requestId = this.generateRequestId(requiredPublicKeys, messageHash);
const peerId = this.coordinator.peerId;
const creatorPublicKey = requiredPublicKeys[0];
const advertisement = {
id: requestId,
requestId,
protocol: 'musig2-request',
peerInfo: {
peerId,
multiaddrs: this.coordinator.libp2pNode
.getMultiaddrs()
.map(ma => ma.toString()),
},
requiredPublicKeys: requiredPublicKeys.map(pk => publicKeyToHex(pk)),
messageHash,
creatorPeerId: peerId,
creatorPublicKey: publicKeyToHex(creatorPublicKey),
requestMetadata: options?.metadata,
creatorSignature: options?.creatorSignature || Buffer.alloc(0),
capabilities: ['musig2-signing-request'],
createdAt: Date.now(),
expiresAt: Date.now() + (options?.ttl || this.config.requestTTL),
reputation: 50,
customCriteria: {
requiredPublicKeys: requiredPublicKeys.map(pk => publicKeyToHex(pk)),
messageHash,
},
};
const discoveryOptions = {
ttl: options?.ttl || this.config.requestTTL,
autoRefresh: false,
};
const adId = await this.advertiser.advertise(advertisement, discoveryOptions);
this.activeRequestAds.set(requestId, adId);
this.emit(MuSig2Event.SIGNING_REQUEST_CREATED, advertisement);
return requestId;
}
async withdrawSigningRequest(requestId) {
const adId = this.activeRequestAds.get(requestId);
if (!adId) {
return;
}
await this.advertiser.withdraw(adId);
this.activeRequestAds.delete(requestId);
}
async discoverSigningRequests(criteria = {}, options) {
const fullCriteria = {
protocol: 'musig2-request',
...criteria,
};
const results = await this.discoverer.discover(fullCriteria, options);
const requestAds = [];
for (const ad of results) {
if (this.isSigningRequestAdvertisement(ad)) {
if (this.matchesRequestCriteria(ad, fullCriteria)) {
requestAds.push(ad);
this.emit(MuSig2Event.SIGNING_REQUEST_RECEIVED, ad);
}
}
}
return requestAds;
}
async joinSigningRequest(requestId, publicKey) {
const requests = await this.discoverSigningRequests({ maxResults: 100 });
const request = requests.find(r => r.requestId === requestId);
if (!request) {
throw new DiscoveryError(DiscoveryErrorType.INVALID_CRITERIA, `Signing request ${requestId} not found`);
}
const publicKeyHex = publicKeyToHex(publicKey);
if (!request.requiredPublicKeys.includes(publicKeyHex)) {
throw new DiscoveryError(DiscoveryErrorType.SECURITY_VALIDATION_FAILED, `Public key ${publicKeyHex} not in required signers list`);
}
this.emit(MuSig2Event.SIGNING_REQUEST_JOINED, requestId);
}
generateSignerAdId(publicKeyHex) {
const data = `musig2:signer:${publicKeyHex}${Date.now().toString()}`;
const hash = bytesToHex(sha256(new TextEncoder().encode(data)));
return `${this.config.signerKeyPrefix}${hash.substring(0, 32)}`;
}
generateRequestId(requiredPublicKeys, messageHash) {
const keysStr = requiredPublicKeys
.map(pk => publicKeyToHex(pk))
.sort()
.join(':');
const data = `musig2:request:${messageHash}${keysStr}${Date.now().toString()}`;
const hash = bytesToHex(sha256(new TextEncoder().encode(data)));
return `${this.config.requestKeyPrefix}${hash.substring(0, 32)}`;
}
isSignerAdvertisement(ad) {
return isValidSignerAdvertisement(ad);
}
isSigningRequestAdvertisement(ad) {
return isValidSigningRequestAdvertisement(ad);
}
matchesSignerCriteria(ad, criteria) {
if (criteria.transactionTypes) {
const hasMatchingType = criteria.transactionTypes.some(type => ad.transactionTypes.includes(type));
if (!hasMatchingType)
return false;
}
if (criteria.minAmount !== undefined && ad.amountRange?.max !== undefined) {
if (ad.amountRange.max < criteria.minAmount)
return false;
}
if (criteria.maxAmount !== undefined && ad.amountRange?.min !== undefined) {
if (ad.amountRange.min > criteria.maxAmount)
return false;
}
if (criteria.requiredPublicKeys) {
const publicKeyHex = publicKeyToHex(ad.publicKey);
if (!criteria.requiredPublicKeys.includes(publicKeyHex))
return false;
}
if (criteria.minMaturation !== undefined &&
ad.signerMetadata?.identity?.maturationBlocks !== undefined) {
if (ad.signerMetadata.identity.maturationBlocks < criteria.minMaturation)
return false;
}
if (criteria.minTotalBurned !== undefined &&
ad.signerMetadata?.identity?.totalBurned !== undefined) {
if (ad.signerMetadata.identity.totalBurned < criteria.minTotalBurned)
return false;
}
return true;
}
matchesRequestCriteria(ad, criteria) {
if (criteria.transactionType &&
ad.requestMetadata?.transactionType !== criteria.transactionType) {
return false;
}
if (criteria.amountRange) {
const amount = ad.requestMetadata?.amount;
if (amount === undefined)
return false;
if (criteria.amountRange.min !== undefined &&
amount < criteria.amountRange.min)
return false;
if (criteria.amountRange.max !== undefined &&
amount > criteria.amountRange.max)
return false;
}
if (criteria.includesPublicKeys) {
const hasAllKeys = criteria.includesPublicKeys.every(key => ad.requiredPublicKeys.includes(key));
if (!hasAllKeys)
return false;
}
if (criteria.creatorPeerIds) {
if (!criteria.creatorPeerIds.includes(ad.creatorPeerId))
return false;
}
if (criteria.expiresAfter !== undefined &&
ad.expiresAt < criteria.expiresAfter) {
return false;
}
return true;
}
async refreshActiveAdvertisements() {
if (this.activeSignerAd) {
await this.advertiser.refresh(this.activeSignerAd);
}
for (const adId of this.activeRequestAds.values()) {
await this.advertiser.refresh(adId);
}
}
async withdrawAllAdvertisements() {
if (this.activeSignerAd) {
await this.withdrawSigner();
}
for (const requestId of Array.from(this.activeRequestAds.keys())) {
await this.withdrawSigningRequest(requestId);
}
}
async subscribeToSigners(criteria = {}, callback) {
const fullCriteria = {
protocol: 'musig2',
...criteria,
};
const subscription = await this.discoverer.subscribe(fullCriteria, (ad) => {
if (this.isSignerAdvertisement(ad)) {
if (this.matchesSignerCriteria(ad, fullCriteria)) {
this.emit(MuSig2Event.SIGNER_DISCOVERED, ad);
callback(ad);
}
}
}, {
fetchExisting: true,
deduplicate: true,
});
return subscription.id;
}
async subscribeToSigningRequests(criteria = {}, callback) {
const fullCriteria = {
protocol: 'musig2-request',
...criteria,
};
const subscription = await this.discoverer.subscribe(fullCriteria, (ad) => {
if (this.isSigningRequestAdvertisement(ad)) {
if (this.matchesRequestCriteria(ad, fullCriteria)) {
this.emit(MuSig2Event.SIGNING_REQUEST_RECEIVED, ad);
callback(ad);
}
}
}, {
fetchExisting: true,
deduplicate: true,
});
return subscription.id;
}
async unsubscribe(subscriptionId) {
await this.discoverer.unsubscribe(subscriptionId);
}
}