lotus-sdk
Version:
Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem
946 lines (741 loc) • 27.4 kB
Markdown
# MuSig2 P2P Developer Guide
A comprehensive guide for building applications with the MuSig2 P2P coordination module.
## Table of Contents
1. [Getting Started](#getting-started)
2. [Understanding the Architecture](#understanding-the-architecture)
3. [Creating a Signing Session](#creating-a-signing-session)
4. [Joining an Existing Session](#joining-an-existing-session)
5. [The Signing Protocol](#the-signing-protocol)
6. [Coordinator Election](#coordinator-election)
7. [Using the Discovery System](#using-the-discovery-system)
8. [Security Best Practices](#security-best-practices)
9. [Error Handling](#error-handling)
10. [Advanced Patterns](#advanced-patterns)
---
## Getting Started
### Installation
The MuSig2 P2P module is part of the Lotus SDK:
```typescript
import {
MuSig2P2PCoordinator,
MuSig2Event,
MuSig2MessageType,
TransactionType,
} from 'lotus-sdk/lib/p2p/musig2'
import { PublicKey } from 'lotus-sdk/lib/bitcore/publickey'
import { PrivateKey } from 'lotus-sdk/lib/bitcore/privatekey'
```
### Basic Setup
```typescript
import { MuSig2P2PCoordinator } from 'lotus-sdk/lib/p2p/musig2'
import type { P2PConfig } from 'lotus-sdk/lib/p2p'
// 1. Configure the P2P layer
const p2pConfig: P2PConfig = {
listen: ['/ip4/0.0.0.0/tcp/0'],
bootstrapPeers: ['/dns4/bootstrap.lotusia.org/tcp/4001/p2p/12D3KooW...'],
enableDHT: true,
enableGossipSub: true,
}
// 2. Create the coordinator
const coordinator = new MuSig2P2PCoordinator(p2pConfig)
// 3. Start the coordinator
await coordinator.start()
console.log('MuSig2 coordinator started with peer ID:', coordinator.peerId)
// 4. When done, stop gracefully
// await coordinator.stop()
```
---
## Understanding the Architecture
### Component Overview
The MuSig2 P2P module consists of several interconnected components with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ MuSig2P2PCoordinator │
│ • Creates and manages signing sessions │
│ • Coordinates nonce and signature exchange │
│ • Handles coordinator election and failover │
│ • Emits events for application consumption │
│ • Validates EGRESS payloads (before sending) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Protocol Handler│ │ Security Valid. │ │ Discovery │
│ │ │ │ │ (Optional) │
│ INGRESS valid. │ │ DoS protection │ │ DHT-based │
│ Routes messages │ │ Peer blocking │ │ signer finding │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Validation Architecture
The module uses a clear separation of concerns for validation:
| Module | Responsibility |
| ------------------ | ---------------------------------------------------- |
| **validation.ts** | Pure validator functions (no side effects) |
| **security.ts** | Security constraints only (DoS, blocking, timestamp) |
| **protocol.ts** | Single ingress validation point (uses validation.ts) |
| **coordinator.ts** | Business logic + egress validation |
### Message Flow
1. **GossipSub** - Used for session announcements (discovery)
2. **Direct P2P** - Used for nonce and signature exchange (coordination)
3. **DHT** - Used for signer discovery (optional)
### Session Lifecycle
```
INIT → NONCE_EXCHANGE → PARTIAL_SIG_EXCHANGE → COMPLETE
│ │
└──────────────── ABORTED ◄─────────────────────┘
```
---
## Creating a Signing Session
### Step 1: Prepare Signers
All signers must be known upfront. Public keys should be sorted lexicographically:
```typescript
import { PublicKey } from 'lotus-sdk/lib/bitcore/publickey'
// Collect all signer public keys
const signerPubKeys = [
PublicKey.fromString('02abc...'),
PublicKey.fromString('03def...'),
PublicKey.fromString('02ghi...'),
]
// Sort lexicographically (required for MuSig2)
const sortedSigners = signerPubKeys.sort((a, b) =>
a.toBuffer().compare(b.toBuffer()),
)
```
### Step 2: Create the Session
```typescript
import { PrivateKey } from 'lotus-sdk/lib/bitcore/privatekey'
// Your private key (must correspond to one of the public keys)
const myPrivateKey = PrivateKey.fromWIF('L...')
// The message to sign (typically a transaction hash)
const messageToSign = Buffer.from('transaction_hash_here', 'hex')
// Optional metadata
const metadata = {
purpose: 'CoinJoin round 42',
amount: 1000000,
}
// Create the session
const sessionId = await coordinator.createSession(
sortedSigners,
myPrivateKey,
messageToSign,
metadata,
)
console.log('Session created:', sessionId)
```
### Step 3: Announce the Session
```typescript
// Publish to GossipSub for other participants to discover
await coordinator.announceSession(sessionId)
console.log('Session announced to network')
```
### Step 4: Wait for Participants
```typescript
import { MuSig2Event } from 'lotus-sdk/lib/p2p/musig2'
coordinator.on(MuSig2Event.PARTICIPANT_JOINED, (sessionId, participant) => {
console.log(`Participant joined: ${participant.peerId}`)
console.log(` Signer index: ${participant.signerIndex}`)
})
coordinator.on(MuSig2Event.SESSION_READY, sessionId => {
console.log('All participants joined! Ready to sign.')
// Proceed to nonce exchange
})
```
---
## Joining an Existing Session
### Step 1: Listen for Announcements
```typescript
coordinator.on(MuSig2Event.SESSION_DISCOVERED, announcement => {
console.log('Discovered session:', announcement.sessionId)
console.log(' Coordinator:', announcement.coordinatorPeerId)
console.log(' Required signers:', announcement.requiredSigners)
console.log(' Message hash:', announcement.messageHash)
console.log(' Expires at:', new Date(announcement.expiresAt))
// Check if we should join this session
if (shouldJoinSession(announcement)) {
handleJoinSession(announcement)
}
})
function shouldJoinSession(announcement) {
// Check if our public key is in the signers list
const myPubKeyHex = myPrivateKey.publicKey.toString()
return announcement.signers?.includes(myPubKeyHex)
}
```
### Step 2: Join the Session
Use the `joinSession()` method to send a join request to the coordinator:
```typescript
async function handleJoinSession(announcement) {
try {
// joinSession() does the following:
// 1. Validates your public key is in the signers list
// 2. Creates a local session state
// 3. Sends a SESSION_JOIN message to the coordinator
const sessionId = await coordinator.joinSession(announcement, myPrivateKey)
console.log(`Join request sent for session: ${sessionId}`)
} catch (error) {
console.error('Failed to join session:', error.message)
}
}
```
### Step 3: Handle Join Response
The coordinator will respond with a `SESSION_JOIN_ACK` message:
```typescript
// Handle successful join
coordinator.on(
'session:join-accepted',
({ sessionId, signerIndex, coordinatorPeerId }) => {
console.log(`Successfully joined session ${sessionId}`)
console.log(` My signer index: ${signerIndex}`)
console.log(` Coordinator: ${coordinatorPeerId}`)
// Session is now ready for nonce exchange
// Wait for SESSION_READY event before sharing nonces
},
)
// Handle join rejection
coordinator.on(
'session:join-rejected',
({ sessionId, reason, coordinatorPeerId }) => {
console.error(`Join rejected for session ${sessionId}`)
console.error(` Reason: ${reason}`)
console.error(` Coordinator: ${coordinatorPeerId}`)
// The session has been automatically removed from local state
},
)
```
### Complete Join Flow Diagram
```
Participant Coordinator
│ │
│ 1. Receive SESSION_DISCOVERED event │
│ (via GossipSub) │
│ │
│ 2. Call joinSession(announcement, privateKey)
│ ─────────────────────────────────► │
│ SESSION_JOIN │
│ │
│ 3. Validate: │
│ - Session exists
│ - Is coordinator
│ - Public key valid
│ - Not already joined
│ │
│ ◄───────────────────────────────── │
│ SESSION_JOIN_ACK │
│ │
│ 4. Receive 'session:join-accepted' or │
│ 'session:join-rejected' event │
│ │
```
---
## The Signing Protocol
### Round 1: Nonce Exchange
After all participants have joined, each signer generates and shares nonces:
```typescript
coordinator.on(MuSig2Event.SESSION_READY, async sessionId => {
// Generate and share nonces (ν ≥ 2 nonces per MuSig2 spec)
await coordinator.shareNonces(sessionId, myPrivateKey)
console.log('Nonces shared')
})
// Track nonce reception
coordinator.on(MuSig2Event.NONCE_RECEIVED, (sessionId, signerIndex) => {
console.log(`Received nonce from signer ${signerIndex}`)
})
// All nonces collected
coordinator.on(MuSig2Event.NONCES_COMPLETE, sessionId => {
console.log('All nonces collected! Ready for partial signatures.')
})
```
### Round 2: Partial Signature Exchange
Once all nonces are collected, each signer creates and shares their partial signature:
```typescript
coordinator.on(MuSig2Event.NONCES_COMPLETE, async sessionId => {
// Create and share partial signature
await coordinator.sharePartialSignature(sessionId, myPrivateKey)
console.log('Partial signature shared')
})
// Track partial signature reception
coordinator.on(MuSig2Event.PARTIAL_SIG_RECEIVED, (sessionId, signerIndex) => {
console.log(`Received partial signature from signer ${signerIndex}`)
})
// All partial signatures collected
coordinator.on(MuSig2Event.PARTIAL_SIGS_COMPLETE, sessionId => {
console.log('All partial signatures collected!')
})
```
### Finalization
The coordinator aggregates all partial signatures into the final signature:
```typescript
coordinator.on(MuSig2Event.PARTIAL_SIGS_COMPLETE, async sessionId => {
// Check if we can finalize
if (coordinator.canFinalizeSession(sessionId)) {
// Aggregate signatures
const finalSignature = await coordinator.finalizeSession(sessionId)
console.log('Final signature:', finalSignature.toString('hex'))
// The signature is now ready to be used in a transaction
// broadcastTransaction(finalSignature)
}
})
// Session complete event
coordinator.on(MuSig2Event.SESSION_COMPLETE, (sessionId, signature) => {
console.log(`Session ${sessionId} completed successfully!`)
})
```
---
## Coordinator Election
### How Election Works
The coordinator is deterministically elected based on public keys. No additional communication is needed - all participants compute the same result.
```typescript
import {
electCoordinator,
ElectionMethod,
isCoordinator,
getCoordinatorPriorityList,
} from 'lotus-sdk/lib/p2p/musig2'
// Elect coordinator
const election = electCoordinator(sortedSigners, ElectionMethod.LEXICOGRAPHIC)
console.log('Coordinator index:', election.coordinatorIndex)
console.log('Coordinator pubkey:', election.coordinatorPublicKey.toString())
console.log('Election proof:', election.electionProof)
// Check if you are the coordinator
const myIndex = sortedSigners.findIndex(
pk => pk.toString() === myPrivateKey.publicKey.toString(),
)
const amICoordinator = isCoordinator(sortedSigners, myIndex)
console.log('Am I coordinator?', amICoordinator)
```
### Election Methods
```typescript
// Lexicographic (default) - first in sorted order
electCoordinator(signers, ElectionMethod.LEXICOGRAPHIC)
// Hash-based - pseudo-random but deterministic
electCoordinator(signers, ElectionMethod.HASH_BASED)
// First signer in sorted array
electCoordinator(signers, ElectionMethod.FIRST_SIGNER)
// Last signer in sorted array
electCoordinator(signers, ElectionMethod.LAST_SIGNER)
```
### Coordinator Failover
If the coordinator fails to broadcast, backup coordinators take over:
```typescript
// Get the priority list for failover
const priorityList = getCoordinatorPriorityList(sortedSigners)
console.log('Coordinator priority:', priorityList)
// e.g., [2, 0, 1] means signer 2 is primary, 0 is first backup, etc.
// Listen for failover events
coordinator.on(MuSig2Event.COORDINATOR_FAILED, (sessionId, oldIndex) => {
console.log(`Coordinator ${oldIndex} failed, failover initiated`)
})
coordinator.on(MuSig2Event.SHOULD_BROADCAST, (sessionId, myIndex) => {
console.log('I am now responsible for broadcasting!')
// broadcastTransaction(...)
})
coordinator.on(MuSig2Event.FAILOVER_EXHAUSTED, sessionId => {
console.error('All coordinators failed!')
})
// Notify when broadcast is complete to cancel failover timer
coordinator.notifyBroadcastComplete(sessionId)
```
---
## Using the Discovery System
The discovery system allows signers to advertise their availability and find each other via DHT.
### Enable Discovery
```typescript
import type { MuSig2DiscoveryConfig } from 'lotus-sdk/lib/p2p/musig2'
const discoveryConfig: MuSig2DiscoveryConfig = {
signerTTL: 30 * 60 * 1000, // 30 minutes
requestTTL: 10 * 60 * 1000, // 10 minutes
enableAutoRefresh: true,
signerRefreshInterval: 20 * 60 * 1000,
maxConcurrentRequests: 5,
}
const coordinator = new MuSig2P2PCoordinator(
p2pConfig,
musig2Config,
securityConfig,
discoveryConfig, // Enable discovery
)
await coordinator.start()
// Get the discovery instance
const discovery = coordinator.getDiscovery()
if (discovery) {
console.log('Discovery enabled')
}
```
### Advertise as a Signer
```typescript
import { TransactionType } from 'lotus-sdk/lib/p2p/musig2'
const discovery = coordinator.getDiscovery()
// Advertise your availability
const adId = await discovery.advertiseSigner(
myPrivateKey.publicKey,
[TransactionType.SPEND, TransactionType.COINJOIN],
{
amountRange: {
min: 10000, // Minimum 10,000 satoshis
max: 100000000, // Maximum 1 XPI
},
metadata: {
nickname: 'AliceSigner',
description: 'Fast and reliable signer',
fee: 1000, // Fee in satoshis
averageResponseTime: 5000, // 5 seconds
},
ttl: 30 * 60 * 1000, // 30 minutes
},
)
console.log('Advertised as signer:', adId)
// Listen for advertisement events
coordinator.on(MuSig2Event.SIGNER_ADVERTISED, ad => {
console.log('Successfully advertised')
})
// Withdraw advertisement when done
await discovery.withdrawSigner()
```
### Discover Available Signers
```typescript
// Find signers matching criteria
const signers = await discovery.discoverSigners({
transactionTypes: [TransactionType.COINJOIN],
minAmount: 50000,
maxAmount: 10000000,
})
console.log(`Found ${signers.length} available signers:`)
for (const signer of signers) {
console.log(` - ${signer.signerMetadata?.nickname || 'Anonymous'}`)
console.log(` Public key: ${signer.publicKey.toString().slice(0, 20)}...`)
console.log(` Fee: ${signer.signerMetadata?.fee || 0} sats`)
}
// Listen for discovered signers
coordinator.on(MuSig2Event.SIGNER_DISCOVERED, signer => {
console.log('Discovered signer:', signer.id)
})
```
### Create a Signing Request
```typescript
// Create a signing request for specific public keys
const requestId = await discovery.createSigningRequest(
requiredPublicKeys,
messageHash,
{
metadata: {
transactionType: TransactionType.COINJOIN,
amount: 1000000,
purpose: 'CoinJoin round',
},
ttl: 10 * 60 * 1000, // 10 minutes
},
)
console.log('Created signing request:', requestId)
// Discover signing requests
const requests = await discovery.discoverSigningRequests({
includesPublicKeys: [myPubKeyHex],
transactionType: TransactionType.COINJOIN,
})
// Join a signing request
await discovery.joinSigningRequest(requestId, myPrivateKey.publicKey)
```
---
## Security Best Practices
### 1. Understanding the Validation Architecture
The MuSig2 module uses a layered validation architecture:
```typescript
// INGRESS: Messages from peers are validated automatically:
// 1. security.ts checks DoS protection, timestamp skew, peer blocking
// 2. protocol.ts validates payload structure (single validation point)
// 3. coordinator.ts receives pre-validated payloads (no re-validation)
// EGRESS: Messages you send are validated before transmission:
// coordinator.ts validates payloads before broadcasting
// You can also validate manually if needed:
import {
validateSessionAnnouncementPayload,
validateNonceSharePayload,
} from 'lotus-sdk/lib/p2p/musig2'
try {
validateSessionAnnouncementPayload(announcement)
} catch (error) {
console.error('Invalid announcement:', error.message)
}
```
### 2. Monitor Security Status
```typescript
// Check security status
const status = coordinator.getSecurityStatus()
console.log('Blocked peers:', status.blockedPeers)
console.log('Total violations:', status.totalViolations)
// Check if a specific peer is blocked
if (coordinator.isPeerBlocked(peerId)) {
console.warn('Peer is blocked due to violations')
}
// Manually unblock a peer (use with caution)
coordinator.unblockPeer(peerId)
```
### 3. Handle Timeouts
```typescript
// Configure appropriate timeouts
const musig2Config = {
nonceTimeout: 60 * 1000, // 1 minute for nonce collection
partialSigTimeout: 60 * 1000, // 1 minute for partial sig collection
broadcastTimeout: 5 * 60 * 1000, // 5 minutes for broadcast
}
// Handle timeout events
coordinator.on(MuSig2Event.SESSION_TIMEOUT, (sessionId, phase) => {
console.error(`Session ${sessionId} timed out during ${phase}`)
})
```
### 4. Nonce Security
The coordinator automatically prevents nonce reuse, but be aware:
```typescript
// NEVER reuse nonces across sessions
// NEVER share private nonces
// The coordinator tracks used nonces via SHA256 hash
// If you need to abort and retry, create a NEW session
await coordinator.abortSession(sessionId, 'Retry needed')
const newSessionId = await coordinator.createSession(...)
```
### 5. Limit Concurrent Sessions
```typescript
const musig2Config = {
maxConcurrentSessions: 10, // Prevent resource exhaustion
}
// Check current session count
const count = coordinator.getSessionCount()
if (count >= 10) {
console.warn('Maximum concurrent sessions reached')
}
```
---
## Error Handling
### Listen for Errors
```typescript
// Session errors
coordinator.on(MuSig2Event.SESSION_ERROR, (sessionId, error) => {
console.error(`Session ${sessionId} error:`, error)
// Decide whether to abort
if (error.message.includes('timeout')) {
coordinator.abortSession(sessionId, 'Timeout').catch(console.error)
}
})
// Validation errors
coordinator.on('validation:error', ({ error, message, from }) => {
console.warn(`Validation error from ${from.peerId}:`, error.message)
})
// Deserialization errors
coordinator.on('deserialization:error', ({ error, message, from }) => {
console.warn(`Deserialization error from ${from.peerId}:`, error.message)
})
// Security rejections
coordinator.on('security:rejected', ({ message, from, reason }) => {
console.warn(
`Security rejected ${message.type} from ${from.peerId}: ${reason}`,
)
})
```
### Error Types
```typescript
import {
MuSig2P2PError,
ValidationError,
DeserializationError,
SecurityError,
ProtocolError,
SerializationError,
} from 'lotus-sdk/lib/p2p/musig2'
try {
await coordinator.shareNonces(sessionId, privateKey)
} catch (error) {
if (error instanceof ValidationError) {
console.error('Validation failed:', error.reason)
} else if (error instanceof SecurityError) {
console.error('Security violation:', error.violationType)
} else if (error instanceof ProtocolError) {
console.error('Protocol error:', error.message)
} else {
console.error('Unknown error:', error)
}
}
```
### Graceful Abort
```typescript
// Abort with reason
await coordinator.abortSession(sessionId, 'User cancelled')
// Handle abort events
coordinator.on(MuSig2Event.SESSION_ABORTED, (sessionId, reason) => {
console.log(`Session ${sessionId} aborted: ${reason}`)
})
```
---
## Advanced Patterns
### Pattern 1: Automatic Session Management
```typescript
class MuSig2SessionManager {
private coordinator: MuSig2P2PCoordinator
private activeSessions = new Map<string, SessionState>()
constructor(coordinator: MuSig2P2PCoordinator) {
this.coordinator = coordinator
this.setupEventHandlers()
}
private setupEventHandlers() {
this.coordinator.on(MuSig2Event.SESSION_READY, async sessionId => {
await this.handleSessionReady(sessionId)
})
this.coordinator.on(MuSig2Event.NONCES_COMPLETE, async sessionId => {
await this.handleNoncesComplete(sessionId)
})
this.coordinator.on(MuSig2Event.PARTIAL_SIGS_COMPLETE, async sessionId => {
await this.handlePartialSigsComplete(sessionId)
})
}
private async handleSessionReady(sessionId: string) {
const state = this.activeSessions.get(sessionId)
if (state) {
await this.coordinator.shareNonces(sessionId, state.privateKey)
}
}
private async handleNoncesComplete(sessionId: string) {
const state = this.activeSessions.get(sessionId)
if (state) {
await this.coordinator.sharePartialSignature(sessionId, state.privateKey)
}
}
private async handlePartialSigsComplete(sessionId: string) {
if (this.coordinator.canFinalizeSession(sessionId)) {
const signature = await this.coordinator.finalizeSession(sessionId)
this.activeSessions.delete(sessionId)
// Emit or callback with signature
}
}
}
```
### Pattern 2: CoinJoin Coordinator
```typescript
class CoinJoinCoordinator {
private discovery: MuSig2Discovery
private coordinator: MuSig2P2PCoordinator
private pendingParticipants: PublicKey[] = []
private targetParticipants = 5
async startRound() {
// Advertise as CoinJoin coordinator
await this.discovery.advertiseSigner(
this.myPublicKey,
[TransactionType.COINJOIN],
{ metadata: { role: 'coordinator' } },
)
// Wait for participants
await this.collectParticipants()
// Create signing session
const sessionId = await this.coordinator.createSession(
this.pendingParticipants,
this.myPrivateKey,
this.buildCoinJoinTransaction(),
)
await this.coordinator.announceSession(sessionId)
}
private async collectParticipants() {
return new Promise<void>(resolve => {
this.coordinator.on(MuSig2Event.SIGNING_REQUEST_JOINED, () => {
if (this.pendingParticipants.length >= this.targetParticipants) {
resolve()
}
})
})
}
}
```
### Pattern 3: Retry with Exponential Backoff
```typescript
async function createSessionWithRetry(
coordinator: MuSig2P2PCoordinator,
signers: PublicKey[],
privateKey: PrivateKey,
message: Buffer,
maxRetries = 3,
): Promise<string> {
let lastError: Error | null = null
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const sessionId = await coordinator.createSession(
signers,
privateKey,
message,
)
return sessionId
} catch (error) {
lastError = error as Error
const delay = Math.pow(2, attempt) * 1000 // 1s, 2s, 4s
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw lastError
}
```
### Pattern 4: Session Monitoring Dashboard
```typescript
function getSessionDashboard(coordinator: MuSig2P2PCoordinator) {
const metrics = coordinator.getMetrics()
const security = coordinator.getSecurityStatus()
return {
sessions: {
active: metrics.activeSessions,
created: metrics.sessionsCreated,
completed: metrics.sessionsCompleted,
aborted: metrics.sessionsAborted,
timedOut: metrics.sessionsTimedOut,
},
security: {
blockedPeers: security.blockedPeerCount,
totalViolations: security.totalViolations,
},
nonces: {
tracked: metrics.totalUsedNonces,
},
}
}
```
---
## Troubleshooting
### Session Not Progressing
1. Check if all participants have joined
2. Verify nonce timeout hasn't expired
3. Check for blocked peers
4. Verify network connectivity
```typescript
const session = coordinator.getSession(sessionId)
console.log('Phase:', session.session.phase)
console.log('Participants:', session.participants.size)
console.log('Expected:', session.session.signers.length)
```
### Nonce Errors
1. Ensure you're not reusing sessions
2. Check that private key matches a signer public key
3. Verify session is in correct phase
```typescript
const session = coordinator.getSession(sessionId)
if (session.session.phase !== 'INIT') {
console.error('Cannot share nonces - wrong phase')
}
```
### Discovery Not Finding Signers
1. Verify DHT is enabled in P2P config
2. Check that signers have advertised recently
3. Verify criteria matches advertisements
```typescript
// Check if discovery is enabled
if (!coordinator.hasDiscovery()) {
console.error('Discovery not enabled')
}
```
---
## Next Steps
- Review the [README.md](./README.md) for API reference
- Check the test files for more examples
- Join the Lotusia community for support
---
## License
MIT License - Copyright 2025 The Lotusia Stewardship