@relay-federation/bridge 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { resolve4 } from 'node:dns/promises'
3
+ import { BSVPeer } from './bsv-peer.js'
4
+
5
+ /**
6
+ * BSVNodeClient — multi-peer pool manager for BSV P2P connections.
7
+ *
8
+ * Manages a pool of BSVPeer connections for redundancy:
9
+ * - DNS-only peer discovery (3 seeds, no WoC dependency)
10
+ * - Connects to multiple BSV nodes simultaneously
11
+ * - Broadcasts transactions to ALL connected peers
12
+ * - Fetches transactions from first available peer
13
+ * - Maintains peer pool with periodic health checks
14
+ *
15
+ * Ported from production Indelible SPV bridge (spv-client.js)
16
+ * peer management, adapted for the open protocol (no third-party APIs).
17
+ *
18
+ * Events (proxied from all peers):
19
+ * 'headers' — { headers, count }
20
+ * 'connected' — { host, port }
21
+ * 'handshake' — { version, userAgent, startHeight }
22
+ * 'disconnected' — { host, port }
23
+ * 'error' — Error
24
+ * 'tx' — { txid, rawHex }
25
+ * 'tx:inv' — { txids }
26
+ */
27
+
28
+ const DEFAULT_SEEDS = [
29
+ 'seed.bitcoinsv.io',
30
+ 'seed.satoshisvision.network',
31
+ 'seed.cascharia.com'
32
+ ]
33
+
34
+ const DEFAULT_PORT = 8333
35
+ const MAINTAIN_INTERVAL_MS = 60000
36
+
37
+ const DEFAULT_CHECKPOINT = {
38
+ height: 930000,
39
+ hash: '00000000000000001c2e04e4375cfa4b46588aa27795b2c7f8d4d34cb568a382',
40
+ prevHash: '000000000000000015ec9abde40c7537fc422e5af81b6028ac376d7cf23bd0c8'
41
+ }
42
+
43
+ export class BSVNodeClient extends EventEmitter {
44
+ /**
45
+ * @param {object} [opts]
46
+ * @param {string[]} [opts.seeds] — DNS seeds (default: 3 BSV seeds)
47
+ * @param {number} [opts.port] — BSV node port (default 8333)
48
+ * @param {{ height, hash, prevHash }} [opts.checkpoint] — Starting checkpoint
49
+ * @param {number} [opts.syncIntervalMs] — Header sync interval (default 30s)
50
+ * @param {number} [opts.pingIntervalMs] — Keepalive interval (default 120s)
51
+ */
52
+ constructor (opts = {}) {
53
+ super()
54
+ this._seeds = opts.seeds || DEFAULT_SEEDS
55
+ this._port = opts.port || DEFAULT_PORT
56
+ this._checkpoint = opts.checkpoint || DEFAULT_CHECKPOINT
57
+ this._syncIntervalMs = opts.syncIntervalMs || 30000
58
+ this._pingIntervalMs = opts.pingIntervalMs || 120000
59
+
60
+ /** @type {Map<string, BSVPeer>} host → peer */
61
+ this._peers = new Map()
62
+ this._destroyed = false
63
+ this._maintainTimer = null
64
+
65
+ // Track best height across all peers
66
+ this._bestHeight = this._checkpoint.height
67
+ this._bestHash = this._checkpoint.hash
68
+ }
69
+
70
+ /**
71
+ * Discover BSV nodes via DNS seeds and connect to all discovered peers.
72
+ * Emits 'connected' and 'handshake' events as peers come online.
73
+ * Does not block — connections established in background.
74
+ */
75
+ async connect () {
76
+ if (this._destroyed) return
77
+
78
+ const addresses = await this._discoverPeers()
79
+
80
+ // Shuffle for load distribution
81
+ for (let i = addresses.length - 1; i > 0; i--) {
82
+ const j = Math.floor(Math.random() * (i + 1));
83
+ [addresses[i], addresses[j]] = [addresses[j], addresses[i]]
84
+ }
85
+
86
+ // Connect to all discovered peers
87
+ for (const addr of addresses) {
88
+ this._connectToPeer(addr.host, addr.port)
89
+ }
90
+
91
+ // Start maintenance timer
92
+ this._maintainTimer = setInterval(() => this._maintainPeers(), MAINTAIN_INTERVAL_MS)
93
+ if (this._maintainTimer.unref) this._maintainTimer.unref()
94
+ }
95
+
96
+ /**
97
+ * Disconnect all peers and stop maintenance.
98
+ */
99
+ disconnect () {
100
+ this._destroyed = true
101
+ clearInterval(this._maintainTimer)
102
+ for (const peer of this._peers.values()) {
103
+ peer.disconnect()
104
+ }
105
+ this._peers.clear()
106
+ }
107
+
108
+ /**
109
+ * Broadcast a raw transaction to ALL connected peers.
110
+ * @param {string} rawTxHex
111
+ * @returns {string} txid
112
+ */
113
+ broadcastTx (rawTxHex) {
114
+ let txid = null
115
+ for (const peer of this._peers.values()) {
116
+ if (peer._handshakeComplete) {
117
+ txid = peer.broadcastTx(rawTxHex)
118
+ }
119
+ }
120
+ return txid
121
+ }
122
+
123
+ /**
124
+ * Fetch a transaction from the first available peer.
125
+ * @param {string} txid
126
+ * @param {number} [timeoutMs=10000]
127
+ * @returns {Promise<{ txid, rawHex }>}
128
+ */
129
+ getTx (txid, timeoutMs = 10000) {
130
+ for (const peer of this._peers.values()) {
131
+ if (peer._handshakeComplete) {
132
+ return peer.getTx(txid, timeoutMs)
133
+ }
134
+ }
135
+ return Promise.reject(new Error('not connected to BSV node'))
136
+ }
137
+
138
+ /**
139
+ * Trigger header sync on connected peers.
140
+ */
141
+ syncHeaders () {
142
+ for (const peer of this._peers.values()) {
143
+ if (peer._handshakeComplete) {
144
+ peer.syncHeaders()
145
+ break // sync from one peer at a time
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Seed a header hash to all peers.
152
+ * @param {number} height
153
+ * @param {string} hash
154
+ */
155
+ seedHeader (height, hash) {
156
+ for (const peer of this._peers.values()) {
157
+ peer.seedHeader(height, hash)
158
+ }
159
+ if (height > this._bestHeight) {
160
+ this._bestHeight = height
161
+ this._bestHash = hash
162
+ }
163
+ }
164
+
165
+ /** Best synced height across all peers */
166
+ get bestHeight () { return this._bestHeight }
167
+ /** Best synced hash */
168
+ get bestHash () { return this._bestHash }
169
+
170
+ /** Number of peers with completed handshake */
171
+ get connectedCount () {
172
+ let count = 0
173
+ for (const peer of this._peers.values()) {
174
+ if (peer._handshakeComplete) count++
175
+ }
176
+ return count
177
+ }
178
+
179
+ /** List of connected peers with status info */
180
+ get peerList () {
181
+ const list = []
182
+ for (const [host, peer] of this._peers) {
183
+ list.push({
184
+ host,
185
+ connected: peer._connected,
186
+ handshake: peer._handshakeComplete,
187
+ bestHeight: peer._bestHeight,
188
+ userAgent: peer._peerUserAgent
189
+ })
190
+ }
191
+ return list
192
+ }
193
+
194
+ // ── Private: peer discovery ────────────────────────────────
195
+
196
+ /**
197
+ * Discover BSV node IPs from DNS seeds.
198
+ * No WoC, no third-party APIs — pure DNS.
199
+ */
200
+ async _discoverPeers () {
201
+ const seen = new Set()
202
+ const peers = []
203
+
204
+ for (const seed of this._seeds) {
205
+ try {
206
+ const addrs = await resolve4(seed)
207
+ for (const addr of addrs) {
208
+ if (!seen.has(addr)) {
209
+ seen.add(addr)
210
+ peers.push({ host: addr, port: this._port })
211
+ }
212
+ }
213
+ } catch {
214
+ // DNS resolution failed for this seed — try others
215
+ }
216
+ }
217
+
218
+ return peers
219
+ }
220
+
221
+ // ── Private: peer management ───────────────────────────────
222
+
223
+ /**
224
+ * Connect to a single BSV peer. Fire-and-forget.
225
+ * @param {string} host
226
+ * @param {number} port
227
+ */
228
+ async _connectToPeer (host, port) {
229
+ if (this._peers.has(host) || this._destroyed) return
230
+
231
+ const peer = new BSVPeer({
232
+ checkpoint: this._checkpoint,
233
+ syncIntervalMs: this._syncIntervalMs,
234
+ pingIntervalMs: this._pingIntervalMs
235
+ })
236
+
237
+ this._peers.set(host, peer)
238
+
239
+ // Wire events — proxy to callers
240
+ peer.on('headers', (data) => {
241
+ // Update pool best height
242
+ for (const h of data.headers) {
243
+ if (h.height > this._bestHeight) {
244
+ this._bestHeight = h.height
245
+ this._bestHash = h.hash
246
+ }
247
+ }
248
+ this.emit('headers', data)
249
+ })
250
+
251
+ peer.on('connected', (data) => this.emit('connected', data))
252
+ peer.on('handshake', (data) => {
253
+ // Ask this peer for addresses of other nodes it knows
254
+ peer.requestAddr()
255
+ this.emit('handshake', data)
256
+ })
257
+
258
+ peer.on('addr', ({ addrs }) => {
259
+ for (const addr of addrs) {
260
+ if (!this._peers.has(addr.host) && !this._destroyed) {
261
+ this._connectToPeer(addr.host, addr.port)
262
+ }
263
+ }
264
+ })
265
+
266
+ peer.on('disconnected', (data) => {
267
+ this._peers.delete(host)
268
+ this.emit('disconnected', data)
269
+ })
270
+
271
+ peer.on('error', (err) => {
272
+ // Don't crash the pool — just log
273
+ this.emit('error', err)
274
+ })
275
+
276
+ peer.on('tx', (data) => this.emit('tx', data))
277
+ peer.on('tx:inv', (data) => this.emit('tx:inv', data))
278
+
279
+ try {
280
+ await peer.connect(host, port)
281
+ } catch {
282
+ // Connection or handshake failed — remove from pool
283
+ this._peers.delete(host)
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Periodic maintenance: clean dead peers, reconnect if below target.
289
+ */
290
+ async _maintainPeers () {
291
+ if (this._destroyed) return
292
+
293
+ // Clean disconnected peers
294
+ for (const [host, peer] of this._peers) {
295
+ if (!peer._connected) {
296
+ this._peers.delete(host)
297
+ }
298
+ }
299
+
300
+ // Reconnect to any new peers discovered
301
+ try {
302
+ const addresses = await this._discoverPeers()
303
+ const newAddrs = addresses.filter(a => !this._peers.has(a.host))
304
+ for (const addr of newAddrs) {
305
+ this._connectToPeer(addr.host, addr.port)
306
+ }
307
+ } catch {
308
+ // DNS failed during maintenance — try again next cycle
309
+ }
310
+ }
311
+ }