UNPKG

@bsv/teranode-listener

Version:

An npm package to subscribe to Teranode P2P topics in a private DHT network and log messages

371 lines (370 loc) 15.4 kB
import { createLibp2p } from 'libp2p'; import { tcp } from '@libp2p/tcp'; import { noise } from '@chainsafe/libp2p-noise'; import { yamux } from '@chainsafe/libp2p-yamux'; import { bootstrap } from '@libp2p/bootstrap'; import { kadDHT } from '@libp2p/kad-dht'; import { gossipsub } from '@chainsafe/libp2p-gossipsub'; import { preSharedKey } from '@libp2p/pnet'; import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery'; import { identify } from '@libp2p/identify'; import { ping } from '@libp2p/ping'; import { multiaddr } from '@multiformats/multiaddr'; import { generateKeyPair } from '@libp2p/crypto/keys'; /** * TeranodeListener provides a callback-based API for subscribing to Teranode P2P topics. * Each topic can have its own callback function for handling messages. */ export class TeranodeListener { node = null; topicCallbacks; config; reconnectionInterval; /** * Creates a new TeranodeListener instance. * @param topicCallbacks - Object mapping topic names to callback functions * @param config - Optional configuration (uses Teranode mainnet defaults) */ constructor(topicCallbacks, config = {}) { this.topicCallbacks = topicCallbacks; this.config = config; // Start the listener automatically this.start().catch(console.error); } /** * Start the P2P listener and subscribe to topics */ async start() { if (this.node) { console.warn('TeranodeListener is already started'); return; } const topics = Object.keys(this.topicCallbacks); const fullConfig = { ...this.config, topics }; // Create the libp2p node using the same logic as startSubscriber const { bootstrapPeers = ['/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK'], staticPeers = [ '/dns4/teranode-mainnet-peer.taal.com/tcp/9905/p2p/12D3KooWJGPdPPw72GU6gFF4LqUjeFF7qmPCS2bZK8ywMvybYfXD', '/dns4/teranode-mainnet-us-01.bsvb.tech/tcp/9905/p2p/12D3KooWPJAHHaNy5BsViK1B5iTQmz5cLaUheAKEuNkHqMbwZ8jd', '/dns4/teranode-eks-mainnet-us-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWFjGChbwVteGsqH6NfHtKbtdW5XgnvmQRpem2kUAQjsGq', '/dns4/bsva-ovh-teranode-eu-1.bsvb.tech/tcp/9905/p2p/12D3KooWAdBeSVue71DTmfMEKyBG2s1hg91zJnze85rt2uKCZWbW', '/dns4/teranode-eks-mainnet-eu-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWRioUF2AYvC6ofiXhjE5V3MLiVrRKMAEyHiz5iYQgnB5f' ], sharedKey = '285b49e6d910726a70f205086c39cbac6d8dcc47839053a21b1f614773bbc137', dhtProtocolID = '/teranode', listenAddresses = ['/ip4/127.0.0.1/tcp/9901'], usePrivateDHT = true, } = fullConfig; // Format the PSK const pskText = `/key/swarm/psk/1.0.0/\n/base16/\n${sharedKey}`; const psk = new TextEncoder().encode(pskText); const connectionProtector = preSharedKey({ psk }); const privateKey = await generateKeyPair('Ed25519'); this.node = await createLibp2p({ privateKey, addresses: { listen: listenAddresses, }, transports: [tcp()], connectionEncrypters: [noise()], streamMuxers: [yamux()], connectionProtector, peerDiscovery: [ bootstrap({ list: bootstrapPeers }), pubsubPeerDiscovery({ topics, interval: 5000, }), ], services: { dht: kadDHT({ protocol: `${dhtProtocolID}/kad/1.0.0`, clientMode: false, validators: {}, selectors: {}, }), pubsub: gossipsub({ allowPublishToZeroTopicPeers: true, emitSelf: false, fallbackToFloodsub: true, floodPublish: true, doPX: true, }), identify: identify(), ping: ping(), }, }); await this.node.start(); console.log('TeranodeListener started with Peer ID:', this.node.peerId.toString()); // Set up event listeners this.setupEventListeners(); // Subscribe to topics with callbacks this.setupTopicSubscriptions(); // Connect to static peers if provided if (staticPeers.length > 0) { await this.connectToStaticPeers(staticPeers); this.reconnectionInterval = this.startStaticPeerMonitoring(staticPeers); } // Handle graceful shutdown process.on('SIGINT', () => this.stop()); } /** * Stop the P2P listener */ async stop() { if (!this.node) { return; } console.log('Stopping TeranodeListener...'); if (this.reconnectionInterval) { clearInterval(this.reconnectionInterval); } await this.node.stop(); this.node = null; console.log('TeranodeListener stopped'); } /** * Add a new topic callback */ addTopicCallback(topic, callback) { this.topicCallbacks[topic] = callback; if (this.node) { this.node.services.pubsub.subscribe(topic); console.log(`Subscribed to new topic: ${topic}`); } } /** * Remove a topic callback */ removeTopicCallback(topic) { delete this.topicCallbacks[topic]; if (this.node) { this.node.services.pubsub.unsubscribe(topic); console.log(`Unsubscribed from topic: ${topic}`); } } /** * Get the current libp2p node instance */ getNode() { return this.node; } /** * Get connected peer count */ getConnectedPeerCount() { return this.node ? this.node.getPeers().length : 0; } setupEventListeners() { if (!this.node) return; this.node.addEventListener('peer:discovery', (evt) => { console.log('Peer discovered:', evt.detail.id.toString()); }); this.node.addEventListener('peer:connect', (evt) => { console.log('✅ Peer connected:', evt.detail.toString()); console.log('Total connected peers:', this.node.getPeers().length); }); this.node.addEventListener('peer:disconnect', (evt) => { console.log('❌ Peer disconnected:', evt.detail.toString()); console.log('Remaining connected peers:', this.node.getPeers().length); }); } setupTopicSubscriptions() { if (!this.node) return; // Subscribe to topics and handle messages with callbacks this.node.services.pubsub.addEventListener('gossipsub:message', (evt) => { const msg = evt.detail.msg; const topicKey = msg.topic; const callback = this.topicCallbacks[topicKey]; if (callback) { try { callback(msg.data, topicKey, evt.detail.propagationSource.toString()); } catch (error) { console.error(`Error in callback for topic ${topicKey}:`, error); } } else { console.log(`Received message on unhandled topic "${msg.topic}"`); } }); // Subscribe to all topics for (const topic of Object.keys(this.topicCallbacks)) { this.node.services.pubsub.subscribe(topic); console.log(`Subscribed to topic: ${topic}`); } } async connectToStaticPeers(staticPeers) { if (!this.node) return; const connectionPromises = staticPeers.map(async (peerAddr) => { try { console.log(`Attempting to connect to static peer: ${peerAddr}`); await this.node.dial(multiaddr(peerAddr)); console.log(`✅ Successfully connected to static peer: ${peerAddr}`); } catch (error) { console.error(`❌ Failed to connect to static peer ${peerAddr}:`, error); } }); await Promise.allSettled(connectionPromises); console.log(`Static peer connection complete. Total connected peers: ${this.node.getPeers().length}`); } startStaticPeerMonitoring(staticPeers) { return setInterval(async () => { if (!this.node) return; const connectedPeerIds = this.node.getPeers().map(p => p.toString()); const disconnectedStaticPeers = []; for (const staticPeer of staticPeers) { try { const peerIdMatch = staticPeer.match(/\/p2p\/([^/]+)$/); if (peerIdMatch) { const peerId = peerIdMatch[1]; if (!connectedPeerIds.includes(peerId)) { disconnectedStaticPeers.push(staticPeer); } } } catch (error) { console.error(`Error checking static peer ${staticPeer}:`, error); } } if (disconnectedStaticPeers.length > 0) { console.log(`Reconnecting to ${disconnectedStaticPeers.length} disconnected static peers...`); await this.connectToStaticPeers(disconnectedStaticPeers); } }, 30000); // 30 seconds } } export async function startSubscriber(config = {}) { const { bootstrapPeers = ['/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK'], staticPeers = [ // Active Teranode peers discovered from Go implementation '/dns4/teranode-mainnet-peer.taal.com/tcp/9905/p2p/12D3KooWJGPdPPw72GU6gFF4LqUjeFF7qmPCS2bZK8ywMvybYfXD', '/dns4/teranode-mainnet-us-01.bsvb.tech/tcp/9905/p2p/12D3KooWPJAHHaNy5BsViK1B5iTQmz5cLaUheAKEuNkHqMbwZ8jd', '/dns4/teranode-eks-mainnet-us-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWFjGChbwVteGsqH6NfHtKbtdW5XgnvmQRpem2kUAQjsGq', '/dns4/bsva-ovh-teranode-eu-1.bsvb.tech/tcp/9905/p2p/12D3KooWAdBeSVue71DTmfMEKyBG2s1hg91zJnze85rt2uKCZWbW', '/dns4/teranode-eks-mainnet-eu-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWRioUF2AYvC6ofiXhjE5V3MLiVrRKMAEyHiz5iYQgnB5f' ], sharedKey = '285b49e6d910726a70f205086c39cbac6d8dcc47839053a21b1f614773bbc137', dhtProtocolID = '/teranode', topics = [ 'bitcoin/mainnet-bestblock', 'bitcoin/mainnet-block', 'bitcoin/mainnet-subtree', 'bitcoin/mainnet-mining_on', 'bitcoin/mainnet-handshake', 'bitcoin/mainnet-rejected_tx' ], listenAddresses = ['/ip4/127.0.0.1/tcp/9901'], usePrivateDHT = true, } = config; // Format the PSK const pskText = `/key/swarm/psk/1.0.0/\n/base16/\n${sharedKey}`; const psk = new TextEncoder().encode(pskText); const connectionProtector = preSharedKey({ psk }); const privateKey = await generateKeyPair('Ed25519'); const node = await createLibp2p({ privateKey, addresses: { listen: listenAddresses, }, transports: [tcp()], connectionEncrypters: [noise()], streamMuxers: [yamux()], connectionProtector, peerDiscovery: [ bootstrap({ list: bootstrapPeers }), pubsubPeerDiscovery({ topics, interval: 5000, }), ], services: { dht: kadDHT({ protocol: `${dhtProtocolID}/kad/1.0.0`, clientMode: false, validators: {}, selectors: {}, }), pubsub: gossipsub({ allowPublishToZeroTopicPeers: true, emitSelf: false, fallbackToFloodsub: true, floodPublish: true, doPX: true, }), identify: identify(), ping: ping(), }, }); await node.start(); console.log('Libp2p node started with Peer ID:', node.peerId.toString()); // Event listeners for logging node.addEventListener('peer:discovery', (evt) => { console.log('Peer discovered:', evt.detail.id.toString()); console.log('Peer multiaddrs:', evt.detail.multiaddrs.map(ma => ma.toString())); }); node.addEventListener('peer:connect', (evt) => { console.log('✅ Peer connected:', evt.detail.toString()); console.log('Total connected peers:', node.getPeers().length); }); node.addEventListener('peer:disconnect', (evt) => { console.log('❌ Peer disconnected:', evt.detail.toString()); console.log('Remaining connected peers:', node.getPeers().length); }); // Subscribe to topics and handle messages // node.services.pubsub.addEventListener('gossipsub:message', (evt) => { // const msg = evt.detail.msg; // console.log(`[${msg.topic}] ${msg.data} - from: ${evt.detail.propagationSource}`); // }); for (const topic of topics) { node.services.pubsub.subscribe(topic); console.log(`Subscribed to topic: ${topic}`); } // Connect to static peers if provided let reconnectionInterval; if (staticPeers.length > 0) { await connectToStaticPeers(node, staticPeers); reconnectionInterval = startStaticPeerMonitoring(node, staticPeers); } // Handle graceful shutdown process.on('SIGINT', async () => { console.log('Shutting down...'); if (reconnectionInterval) clearInterval(reconnectionInterval); await node.stop(); process.exit(0); }); } async function connectToStaticPeers(node, staticPeers) { const connectionPromises = staticPeers.map(async (peerAddr) => { try { console.log(`Attempting to connect to static peer: ${peerAddr}`); await node.dial(multiaddr(peerAddr)); console.log(`✅ Successfully connected to static peer: ${peerAddr}`); } catch (error) { console.error(`❌ Failed to connect to static peer ${peerAddr}:`, error); } }); await Promise.allSettled(connectionPromises); console.log(`Static peer connection complete. Total connected peers: ${node.getPeers().length}`); } function startStaticPeerMonitoring(node, staticPeers) { return setInterval(async () => { const connectedPeerIds = node.getPeers().map(p => p.toString()); const disconnectedStaticPeers = []; for (const staticPeer of staticPeers) { try { const peerIdMatch = staticPeer.match(/\/p2p\/([^/]+)$/); if (peerIdMatch) { const peerId = peerIdMatch[1]; if (!connectedPeerIds.includes(peerId)) { disconnectedStaticPeers.push(staticPeer); } } } catch (error) { console.error(`Error checking static peer ${staticPeer}:`, error); } } if (disconnectedStaticPeers.length > 0) { console.log(`Reconnecting to ${disconnectedStaticPeers.length} disconnected static peers...`); await connectToStaticPeers(node, disconnectedStaticPeers); } }, 30000); // 30 seconds }