@relay-federation/bridge 0.3.9 → 0.3.11

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.
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { join } from 'node:path'
4
4
  import { initConfig, loadConfig, configExists, defaultConfigDir } from './lib/config.js'
@@ -11,6 +11,112 @@ import { StatusServer } from './lib/status-server.js'
11
11
 
12
12
  const command = process.argv[2]
13
13
 
14
+ /**
15
+ * Scan historical beacon registrations on startup.
16
+ * Fetches beacon address history from WhatsOnChain and parses each tx
17
+ * to populate the registeredPubkeys Set with all valid registrations.
18
+ *
19
+ * @param {Set<string>} registeredPubkeys - Set to populate with registered pubkeys
20
+ * @param {string} selfPubkey - Our own pubkey (skip self-registration)
21
+ */
22
+ async function backfillBeaconRegistry (registeredPubkeys, selfPubkey) {
23
+ const { BEACON_ADDRESS, PROTOCOL_PREFIX } = await import('@relay-federation/common/protocol')
24
+ const { extractOpReturnData, decodePayload } = await import('@relay-federation/registry/lib/cbor.js')
25
+ const { Transaction } = await import('@bsv/sdk')
26
+
27
+ console.log(` Beacon backfill: scanning ${BEACON_ADDRESS}...`)
28
+
29
+ try {
30
+ // Fetch confirmed history (registrations should be confirmed)
31
+ const historyResp = await fetch(
32
+ `https://api.whatsonchain.com/v1/bsv/main/address/${BEACON_ADDRESS}/confirmed/history`,
33
+ { signal: AbortSignal.timeout(30000) }
34
+ )
35
+ if (!historyResp.ok) {
36
+ console.log(` Beacon backfill: WoC returned ${historyResp.status}, skipping`)
37
+ return
38
+ }
39
+
40
+ const data = await historyResp.json()
41
+ const history = Array.isArray(data) ? data : (data.result || [])
42
+ if (history.length === 0) {
43
+ console.log(` Beacon backfill: no history found`)
44
+ return
45
+ }
46
+
47
+ console.log(` Beacon backfill: found ${history.length} transactions`)
48
+
49
+ // Track registrations and deregistrations to handle order correctly
50
+ // Process oldest first (history is typically newest-first from WoC)
51
+ const sortedHistory = [...history].sort((a, b) => (a.height || 0) - (b.height || 0))
52
+
53
+ let registered = 0
54
+ let deregistered = 0
55
+ let skipped = 0
56
+
57
+ for (const item of sortedHistory) {
58
+ const txid = item.tx_hash
59
+ if (!txid) continue
60
+
61
+ try {
62
+ // Rate limit to avoid WoC throttling
63
+ await new Promise(r => setTimeout(r, 100))
64
+
65
+ const txResp = await fetch(
66
+ `https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`,
67
+ { signal: AbortSignal.timeout(10000) }
68
+ )
69
+ if (!txResp.ok) {
70
+ skipped++
71
+ continue
72
+ }
73
+
74
+ const rawHex = await txResp.text()
75
+ const tx = Transaction.fromHex(rawHex)
76
+
77
+ // Find OP_RETURN output
78
+ const opReturnOutput = tx.outputs.find(out =>
79
+ out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
80
+ )
81
+ if (!opReturnOutput) {
82
+ skipped++
83
+ continue
84
+ }
85
+
86
+ const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
87
+ if (prefix !== PROTOCOL_PREFIX) {
88
+ skipped++
89
+ continue
90
+ }
91
+
92
+ const entry = decodePayload(cborBytes)
93
+ if (!entry || !entry.pubkey) {
94
+ skipped++
95
+ continue
96
+ }
97
+
98
+ const pubHex = Buffer.from(entry.pubkey).toString('hex')
99
+ if (pubHex === selfPubkey) continue // skip self
100
+
101
+ if (entry.action === 'register') {
102
+ registeredPubkeys.add(pubHex)
103
+ registered++
104
+ } else if (entry.action === 'deregister') {
105
+ registeredPubkeys.delete(pubHex)
106
+ deregistered++
107
+ }
108
+ } catch {
109
+ skipped++
110
+ }
111
+ }
112
+
113
+ console.log(` Beacon backfill: +${registered} registered, -${deregistered} deregistered, ${skipped} skipped`)
114
+ console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys after backfill`)
115
+ } catch (err) {
116
+ console.log(` Beacon backfill failed: ${err.message}`)
117
+ }
118
+ }
119
+
14
120
  switch (command) {
15
121
  case 'init':
16
122
  await cmdInit()
@@ -325,7 +431,12 @@ async function cmdStart () {
325
431
  peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
326
432
  peerHealth.recordSeen(pubkeyHex)
327
433
  scorer.setStakeAge(pubkeyHex, 7)
328
- if (endpoint) gossipManager.addSeed({ pubkeyHex, endpoint, meshId: config.meshId })
434
+ if (endpoint) {
435
+ const updated = gossipManager.updatePeerEndpoint({ pubkeyHex, endpoint, meshId: config.meshId })
436
+ if (updated) {
437
+ console.log(` Endpoint updated: ${pubkeyHex.slice(0, 16)}... → ${endpoint}`)
438
+ }
439
+ }
329
440
  })
330
441
 
331
442
  peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
@@ -429,6 +540,9 @@ async function cmdStart () {
429
540
  }
430
541
  console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
431
542
 
543
+ // ── 4a-2. Beacon backfill — scan historical registrations on startup ──
544
+ await backfillBeaconRegistry(registeredPubkeys, config.pubkeyHex)
545
+
432
546
  // ── 4b. Beacon address watcher — detect on-chain registrations ──
433
547
  const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('@relay-federation/registry/lib/cbor.js')
434
548
  const { Transaction: BsvTx } = await import('@bsv/sdk')
@@ -501,17 +615,23 @@ async function cmdStart () {
501
615
  // Tie-break duplicate connections (inbound may have been accepted during handshake)
502
616
  const existing = peerManager.peers.get(result.peerPubkey)
503
617
  if (existing && existing !== conn) {
504
- if (config.pubkeyHex > result.peerPubkey) {
618
+ // If existing connection is dead, prefer new working connection
619
+ if (!existing.connected) {
620
+ console.log(` Replacing dead connection to ${result.peerPubkey.slice(0, 16)}...`)
621
+ existing._shouldReconnect = false
622
+ existing.destroy()
623
+ } else if (config.pubkeyHex > result.peerPubkey) {
505
624
  // Higher pubkey drops outbound — keep existing inbound
506
625
  console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
507
626
  conn._shouldReconnect = false
508
627
  conn.destroy()
509
628
  return
629
+ } else {
630
+ // Lower pubkey keeps outbound — drop existing inbound
631
+ console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
632
+ existing._shouldReconnect = false
633
+ existing.destroy()
510
634
  }
511
- // Lower pubkey keeps outbound — drop existing inbound
512
- console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
513
- existing._shouldReconnect = false
514
- existing.destroy()
515
635
  }
516
636
 
517
637
  peerManager.peers.set(result.peerPubkey, conn)
package/lib/gossip.js CHANGED
@@ -1,266 +1,300 @@
1
- import { EventEmitter } from 'node:events'
2
- import { signHash, verifyHash } from '@relay-federation/common/crypto'
3
-
4
- /**
5
- * GossipManager — peer discovery via WebSocket gossip protocol.
6
- *
7
- * Replaces HTTP-based registry scanning with pure P2P peer discovery.
8
- * Three message types:
9
- *
10
- * getpeers — "tell me who you know"
11
- * peers — response with list of known peers
12
- * announce — "I'm alive" (signed, propagated to all peers)
13
- *
14
- * Announcements are signed with the bridge's private key to prevent
15
- * impersonation. Each announcement includes a timestamp — stale
16
- * announcements (older than maxAge) are discarded.
17
- *
18
- * Events:
19
- * 'peer:discovered' — { pubkeyHex, endpoint, meshId }
20
- * 'peers:response' — { peers: Array }
21
- */
22
- export class GossipManager extends EventEmitter {
23
- /**
24
- * @param {import('./peer-manager.js').PeerManager} peerManager
25
- * @param {object} opts
26
- * @param {import('@bsv/sdk').PrivateKey} opts.privKey — Bridge private key for signing
27
- * @param {string} opts.pubkeyHex — Our compressed pubkey hex
28
- * @param {string} opts.endpoint — Our advertised WSS endpoint
29
- * @param {string} [opts.meshId='70016'] — Mesh identifier
30
- * @param {number} [opts.announceIntervalMs=60000] — Re-announce interval
31
- * @param {number} [opts.maxAge=300000] — Max age for announcements (5 min)
32
- * @param {number} [opts.maxPeersResponse=50] — Max peers in a response
33
- */
34
- constructor (peerManager, opts) {
35
- super()
36
- this.peerManager = peerManager
37
- this._privKey = opts.privKey
38
- this._pubkeyHex = opts.pubkeyHex
39
- this._endpoint = opts.endpoint
40
- this._meshId = opts.meshId || '70016'
41
- this._announceIntervalMs = opts.announceIntervalMs || 60000
42
- this._maxAge = opts.maxAge || 300000
43
- this._maxPeersResponse = opts.maxPeersResponse || 50
44
-
45
- /** @type {Map<string, { pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>} */
46
- this._directory = new Map()
47
-
48
- /** @type {Set<string>} recently seen announce hashes (dedup) */
49
- this._seenAnnounces = new Set()
50
-
51
- this._announceTimer = null
52
-
53
- this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
54
- this._handleMessage(pubkeyHex, message)
55
- })
56
- }
57
-
58
- /**
59
- * Start periodic announcements.
60
- */
61
- start () {
62
- // Announce immediately
63
- this._broadcastAnnounce()
64
- // Then on interval
65
- this._announceTimer = setInterval(() => {
66
- this._broadcastAnnounce()
67
- }, this._announceIntervalMs)
68
- if (this._announceTimer.unref) this._announceTimer.unref()
69
- }
70
-
71
- /**
72
- * Stop periodic announcements.
73
- */
74
- stop () {
75
- if (this._announceTimer) {
76
- clearInterval(this._announceTimer)
77
- this._announceTimer = null
78
- }
79
- }
80
-
81
- /**
82
- * Request peer list from a specific peer.
83
- * @param {string} pubkeyHex — peer to ask
84
- */
85
- requestPeers (pubkeyHex) {
86
- const conn = this.peerManager.peers.get(pubkeyHex)
87
- if (conn) {
88
- conn.send({ type: 'getpeers' })
89
- }
90
- }
91
-
92
- /**
93
- * Request peer lists from all connected peers.
94
- */
95
- requestPeersFromAll () {
96
- this.peerManager.broadcast({ type: 'getpeers' })
97
- }
98
-
99
- /**
100
- * Get the current peer directory.
101
- * @returns {Array<{ pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>}
102
- */
103
- getDirectory () {
104
- const now = Date.now()
105
- const result = []
106
- for (const [, entry] of this._directory) {
107
- if (now - entry.lastSeen < this._maxAge) {
108
- result.push({ ...entry })
109
- }
110
- }
111
- return result
112
- }
113
-
114
- /**
115
- * Get directory size (excluding stale entries).
116
- * @returns {number}
117
- */
118
- directorySize () {
119
- return this.getDirectory().length
120
- }
121
-
122
- /**
123
- * Manually add a peer to the directory (e.g., seed peers from config).
124
- * @param {{ pubkeyHex: string, endpoint: string, meshId?: string }} peer
125
- */
126
- addSeed (peer) {
127
- this._directory.set(peer.pubkeyHex, {
128
- pubkeyHex: peer.pubkeyHex,
129
- endpoint: peer.endpoint,
130
- meshId: peer.meshId || this._meshId,
131
- lastSeen: Date.now()
132
- })
133
- }
134
-
135
- /** @private */
136
- _handleMessage (pubkeyHex, message) {
137
- switch (message.type) {
138
- case 'getpeers':
139
- this._onGetPeers(pubkeyHex)
140
- break
141
- case 'peers':
142
- this._onPeers(pubkeyHex, message)
143
- break
144
- case 'announce':
145
- this._onAnnounce(pubkeyHex, message)
146
- break
147
- }
148
- }
149
-
150
- /** @private */
151
- _onGetPeers (pubkeyHex) {
152
- const peers = this.getDirectory()
153
- .filter(p => p.pubkeyHex !== pubkeyHex) // don't send them back to themselves
154
- .slice(0, this._maxPeersResponse)
155
-
156
- const conn = this.peerManager.peers.get(pubkeyHex)
157
- if (conn) {
158
- conn.send({ type: 'peers', peers })
159
- }
160
- }
161
-
162
- /** @private */
163
- _onPeers (pubkeyHex, message) {
164
- if (!Array.isArray(message.peers)) return
165
-
166
- for (const peer of message.peers) {
167
- if (!peer.pubkeyHex || !peer.endpoint) continue
168
- if (peer.pubkeyHex === this._pubkeyHex) continue // skip self
169
-
170
- const isNew = !this._directory.has(peer.pubkeyHex)
171
-
172
- this._directory.set(peer.pubkeyHex, {
173
- pubkeyHex: peer.pubkeyHex,
174
- endpoint: peer.endpoint,
175
- meshId: peer.meshId || 'unknown',
176
- lastSeen: Date.now()
177
- })
178
-
179
- if (isNew) {
180
- this.emit('peer:discovered', {
181
- pubkeyHex: peer.pubkeyHex,
182
- endpoint: peer.endpoint,
183
- meshId: peer.meshId || 'unknown'
184
- })
185
- }
186
- }
187
-
188
- this.emit('peers:response', { peers: message.peers, from: pubkeyHex })
189
- }
190
-
191
- /** @private */
192
- _onAnnounce (sourcePubkey, message) {
193
- if (!message.pubkeyHex || !message.endpoint || !message.timestamp || !message.signature) return
194
-
195
- // Dedup — don't process the same announcement twice
196
- const announceId = `${message.pubkeyHex}:${message.timestamp}`
197
- if (this._seenAnnounces.has(announceId)) return
198
- this._seenAnnounces.add(announceId)
199
-
200
- // Trim dedup set if it gets too large
201
- if (this._seenAnnounces.size > 10000) {
202
- const arr = [...this._seenAnnounces]
203
- this._seenAnnounces = new Set(arr.slice(arr.length - 5000))
204
- }
205
-
206
- // Check age
207
- const age = Date.now() - message.timestamp
208
- if (age > this._maxAge || age < -30000) return // too old or too far in the future
209
-
210
- // Verify signature
211
- const payload = `${message.pubkeyHex}:${message.endpoint}:${message.meshId || ''}:${message.timestamp}`
212
- const dataHex = Buffer.from(payload, 'utf8').toString('hex')
213
-
214
- try {
215
- const valid = verifyHash(dataHex, message.signature, message.pubkeyHex)
216
- if (!valid) return
217
- } catch {
218
- return // invalid signature format
219
- }
220
-
221
- // Skip self
222
- if (message.pubkeyHex === this._pubkeyHex) return
223
-
224
- const isNew = !this._directory.has(message.pubkeyHex)
225
-
226
- this._directory.set(message.pubkeyHex, {
227
- pubkeyHex: message.pubkeyHex,
228
- endpoint: message.endpoint,
229
- meshId: message.meshId || 'unknown',
230
- lastSeen: Date.now()
231
- })
232
-
233
- if (isNew) {
234
- this.emit('peer:discovered', {
235
- pubkeyHex: message.pubkeyHex,
236
- endpoint: message.endpoint,
237
- meshId: message.meshId || 'unknown'
238
- })
239
- }
240
-
241
- // Re-broadcast to all peers except source
242
- this.peerManager.broadcast(message, sourcePubkey)
243
- }
244
-
245
- /** @private */
246
- _broadcastAnnounce () {
247
- const timestamp = Date.now()
248
- const payload = `${this._pubkeyHex}:${this._endpoint}:${this._meshId}:${timestamp}`
249
- const dataHex = Buffer.from(payload, 'utf8').toString('hex')
250
- const signature = signHash(dataHex, this._privKey)
251
-
252
- const message = {
253
- type: 'announce',
254
- pubkeyHex: this._pubkeyHex,
255
- endpoint: this._endpoint,
256
- meshId: this._meshId,
257
- timestamp,
258
- signature
259
- }
260
-
261
- // Add to our own dedup set
262
- this._seenAnnounces.add(`${this._pubkeyHex}:${timestamp}`)
263
-
264
- this.peerManager.broadcast(message)
265
- }
266
- }
1
+ import { EventEmitter } from 'node:events'
2
+ import { signHash, verifyHash } from '@relay-federation/common/crypto'
3
+
4
+ /**
5
+ * GossipManager — peer discovery via WebSocket gossip protocol.
6
+ *
7
+ * Replaces HTTP-based registry scanning with pure P2P peer discovery.
8
+ * Three message types:
9
+ *
10
+ * getpeers — "tell me who you know"
11
+ * peers — response with list of known peers
12
+ * announce — "I'm alive" (signed, propagated to all peers)
13
+ *
14
+ * Announcements are signed with the bridge's private key to prevent
15
+ * impersonation. Each announcement includes a timestamp — stale
16
+ * announcements (older than maxAge) are discarded.
17
+ *
18
+ * Events:
19
+ * 'peer:discovered' — { pubkeyHex, endpoint, meshId }
20
+ * 'peers:response' — { peers: Array }
21
+ */
22
+ export class GossipManager extends EventEmitter {
23
+ /**
24
+ * @param {import('./peer-manager.js').PeerManager} peerManager
25
+ * @param {object} opts
26
+ * @param {import('@bsv/sdk').PrivateKey} opts.privKey — Bridge private key for signing
27
+ * @param {string} opts.pubkeyHex — Our compressed pubkey hex
28
+ * @param {string} opts.endpoint — Our advertised WSS endpoint
29
+ * @param {string} [opts.meshId='70016'] — Mesh identifier
30
+ * @param {number} [opts.announceIntervalMs=60000] — Re-announce interval
31
+ * @param {number} [opts.maxAge=300000] — Max age for announcements (5 min)
32
+ * @param {number} [opts.maxPeersResponse=50] — Max peers in a response
33
+ */
34
+ constructor (peerManager, opts) {
35
+ super()
36
+ this.peerManager = peerManager
37
+ this._privKey = opts.privKey
38
+ this._pubkeyHex = opts.pubkeyHex
39
+ this._endpoint = opts.endpoint
40
+ this._meshId = opts.meshId || '70016'
41
+ this._announceIntervalMs = opts.announceIntervalMs || 60000
42
+ this._maxAge = opts.maxAge || 300000
43
+ this._maxPeersResponse = opts.maxPeersResponse || 50
44
+
45
+ /** @type {Map<string, { pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>} */
46
+ this._directory = new Map()
47
+
48
+ /** @type {Set<string>} recently seen announce hashes (dedup) */
49
+ this._seenAnnounces = new Set()
50
+
51
+ this._announceTimer = null
52
+
53
+ this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
54
+ this._handleMessage(pubkeyHex, message)
55
+ })
56
+ }
57
+
58
+ /**
59
+ * Start periodic announcements.
60
+ */
61
+ start () {
62
+ // Announce immediately
63
+ this._broadcastAnnounce()
64
+ // Then on interval
65
+ this._announceTimer = setInterval(() => {
66
+ this._broadcastAnnounce()
67
+ }, this._announceIntervalMs)
68
+ if (this._announceTimer.unref) this._announceTimer.unref()
69
+ }
70
+
71
+ /**
72
+ * Stop periodic announcements.
73
+ */
74
+ stop () {
75
+ if (this._announceTimer) {
76
+ clearInterval(this._announceTimer)
77
+ this._announceTimer = null
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Request peer list from a specific peer.
83
+ * @param {string} pubkeyHex — peer to ask
84
+ */
85
+ requestPeers (pubkeyHex) {
86
+ const conn = this.peerManager.peers.get(pubkeyHex)
87
+ if (conn) {
88
+ conn.send({ type: 'getpeers' })
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Request peer lists from all connected peers.
94
+ */
95
+ requestPeersFromAll () {
96
+ this.peerManager.broadcast({ type: 'getpeers' })
97
+ }
98
+
99
+ /**
100
+ * Get the current peer directory.
101
+ * @returns {Array<{ pubkeyHex: string, endpoint: string, meshId: string, lastSeen: number }>}
102
+ */
103
+ getDirectory () {
104
+ const now = Date.now()
105
+ const result = []
106
+ for (const [, entry] of this._directory) {
107
+ if (now - entry.lastSeen < this._maxAge) {
108
+ result.push({ ...entry })
109
+ }
110
+ }
111
+ return result
112
+ }
113
+
114
+ /**
115
+ * Get directory size (excluding stale entries).
116
+ * @returns {number}
117
+ */
118
+ directorySize () {
119
+ return this.getDirectory().length
120
+ }
121
+
122
+ /**
123
+ * Manually add a peer to the directory (e.g., seed peers from config).
124
+ * @param {{ pubkeyHex: string, endpoint: string, meshId?: string }} peer
125
+ */
126
+ addSeed (peer) {
127
+ this._directory.set(peer.pubkeyHex, {
128
+ pubkeyHex: peer.pubkeyHex,
129
+ endpoint: peer.endpoint,
130
+ meshId: peer.meshId || this._meshId,
131
+ lastSeen: Date.now()
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Update a peer's endpoint after successful handshake and broadcast the update.
137
+ *
138
+ * This enables self-healing when a peer changes IP: after they complete a
139
+ * cryptographically verified handshake from their new endpoint, we update
140
+ * our directory and immediately broadcast the change to all connected peers.
141
+ *
142
+ * @param {{ pubkeyHex: string, endpoint: string, meshId?: string }} peer
143
+ * @returns {boolean} true if the endpoint was updated (changed from cached value)
144
+ */
145
+ updatePeerEndpoint (peer) {
146
+ const existing = this._directory.get(peer.pubkeyHex)
147
+ const endpointChanged = !existing || existing.endpoint !== peer.endpoint
148
+
149
+ // Update the directory entry
150
+ this._directory.set(peer.pubkeyHex, {
151
+ pubkeyHex: peer.pubkeyHex,
152
+ endpoint: peer.endpoint,
153
+ meshId: peer.meshId || existing?.meshId || this._meshId,
154
+ lastSeen: Date.now()
155
+ })
156
+
157
+ // If endpoint changed, broadcast the update to all connected peers
158
+ if (endpointChanged) {
159
+ const updatedPeer = this._directory.get(peer.pubkeyHex)
160
+ this.peerManager.broadcast({
161
+ type: 'peers',
162
+ peers: [updatedPeer]
163
+ }, peer.pubkeyHex) // exclude the peer we just updated
164
+ }
165
+
166
+ return endpointChanged
167
+ }
168
+
169
+ /** @private */
170
+ _handleMessage (pubkeyHex, message) {
171
+ switch (message.type) {
172
+ case 'getpeers':
173
+ this._onGetPeers(pubkeyHex)
174
+ break
175
+ case 'peers':
176
+ this._onPeers(pubkeyHex, message)
177
+ break
178
+ case 'announce':
179
+ this._onAnnounce(pubkeyHex, message)
180
+ break
181
+ }
182
+ }
183
+
184
+ /** @private */
185
+ _onGetPeers (pubkeyHex) {
186
+ const peers = this.getDirectory()
187
+ .filter(p => p.pubkeyHex !== pubkeyHex) // don't send them back to themselves
188
+ .slice(0, this._maxPeersResponse)
189
+
190
+ const conn = this.peerManager.peers.get(pubkeyHex)
191
+ if (conn) {
192
+ conn.send({ type: 'peers', peers })
193
+ }
194
+ }
195
+
196
+ /** @private */
197
+ _onPeers (pubkeyHex, message) {
198
+ if (!Array.isArray(message.peers)) return
199
+
200
+ for (const peer of message.peers) {
201
+ if (!peer.pubkeyHex || !peer.endpoint) continue
202
+ if (peer.pubkeyHex === this._pubkeyHex) continue // skip self
203
+
204
+ const isNew = !this._directory.has(peer.pubkeyHex)
205
+
206
+ this._directory.set(peer.pubkeyHex, {
207
+ pubkeyHex: peer.pubkeyHex,
208
+ endpoint: peer.endpoint,
209
+ meshId: peer.meshId || 'unknown',
210
+ lastSeen: Date.now()
211
+ })
212
+
213
+ if (isNew) {
214
+ this.emit('peer:discovered', {
215
+ pubkeyHex: peer.pubkeyHex,
216
+ endpoint: peer.endpoint,
217
+ meshId: peer.meshId || 'unknown'
218
+ })
219
+ }
220
+ }
221
+
222
+ this.emit('peers:response', { peers: message.peers, from: pubkeyHex })
223
+ }
224
+
225
+ /** @private */
226
+ _onAnnounce (sourcePubkey, message) {
227
+ if (!message.pubkeyHex || !message.endpoint || !message.timestamp || !message.signature) return
228
+
229
+ // Dedup don't process the same announcement twice
230
+ const announceId = `${message.pubkeyHex}:${message.timestamp}`
231
+ if (this._seenAnnounces.has(announceId)) return
232
+ this._seenAnnounces.add(announceId)
233
+
234
+ // Trim dedup set if it gets too large
235
+ if (this._seenAnnounces.size > 10000) {
236
+ const arr = [...this._seenAnnounces]
237
+ this._seenAnnounces = new Set(arr.slice(arr.length - 5000))
238
+ }
239
+
240
+ // Check age
241
+ const age = Date.now() - message.timestamp
242
+ if (age > this._maxAge || age < -30000) return // too old or too far in the future
243
+
244
+ // Verify signature
245
+ const payload = `${message.pubkeyHex}:${message.endpoint}:${message.meshId || ''}:${message.timestamp}`
246
+ const dataHex = Buffer.from(payload, 'utf8').toString('hex')
247
+
248
+ try {
249
+ const valid = verifyHash(dataHex, message.signature, message.pubkeyHex)
250
+ if (!valid) return
251
+ } catch {
252
+ return // invalid signature format
253
+ }
254
+
255
+ // Skip self
256
+ if (message.pubkeyHex === this._pubkeyHex) return
257
+
258
+ const isNew = !this._directory.has(message.pubkeyHex)
259
+
260
+ this._directory.set(message.pubkeyHex, {
261
+ pubkeyHex: message.pubkeyHex,
262
+ endpoint: message.endpoint,
263
+ meshId: message.meshId || 'unknown',
264
+ lastSeen: Date.now()
265
+ })
266
+
267
+ if (isNew) {
268
+ this.emit('peer:discovered', {
269
+ pubkeyHex: message.pubkeyHex,
270
+ endpoint: message.endpoint,
271
+ meshId: message.meshId || 'unknown'
272
+ })
273
+ }
274
+
275
+ // Re-broadcast to all peers except source
276
+ this.peerManager.broadcast(message, sourcePubkey)
277
+ }
278
+
279
+ /** @private */
280
+ _broadcastAnnounce () {
281
+ const timestamp = Date.now()
282
+ const payload = `${this._pubkeyHex}:${this._endpoint}:${this._meshId}:${timestamp}`
283
+ const dataHex = Buffer.from(payload, 'utf8').toString('hex')
284
+ const signature = signHash(dataHex, this._privKey)
285
+
286
+ const message = {
287
+ type: 'announce',
288
+ pubkeyHex: this._pubkeyHex,
289
+ endpoint: this._endpoint,
290
+ meshId: this._meshId,
291
+ timestamp,
292
+ signature
293
+ }
294
+
295
+ // Add to our own dedup set
296
+ this._seenAnnounces.add(`${this._pubkeyHex}:${timestamp}`)
297
+
298
+ this.peerManager.broadcast(message)
299
+ }
300
+ }
@@ -1,272 +1,278 @@
1
- import { EventEmitter } from 'node:events'
2
- import { WebSocketServer } from 'ws'
3
- import { PeerConnection } from './peer-connection.js'
4
-
5
- /**
6
- * PeerManager — manages multiple peer connections.
7
- *
8
- * Handles:
9
- * - Outbound connections to discovered peers
10
- * - Inbound connections from other bridges (via WSS server)
11
- * - Broadcasting messages to all connected peers
12
- * - Peer lifecycle (connect, disconnect, reconnect)
13
- *
14
- * Events:
15
- * 'peer:connect' — { pubkeyHex, endpoint }
16
- * 'peer:disconnect' — { pubkeyHex, endpoint }
17
- * 'peer:message' — { pubkeyHex, message }
18
- * 'peer:error' — { pubkeyHex, error }
19
- */
20
- export class PeerManager extends EventEmitter {
21
- /**
22
- * @param {object} [opts]
23
- */
24
- constructor () {
25
- super()
26
- /** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
27
- this.peers = new Map()
28
- this._server = null
29
- }
30
-
31
- /**
32
- * Connect to a discovered peer (outbound).
33
- *
34
- * @param {object} peer - Peer from buildPeerList()
35
- * @param {string} peer.pubkeyHex
36
- * @param {string} peer.endpoint
37
- * @returns {PeerConnection|null} The connection, or null if already connected
38
- */
39
- connectToPeer (peer) {
40
- if (this.peers.has(peer.pubkeyHex)) {
41
- return this.peers.get(peer.pubkeyHex)
42
- }
43
-
44
- const conn = new PeerConnection({
45
- endpoint: peer.endpoint,
46
- pubkeyHex: peer.pubkeyHex
47
- })
48
-
49
- this._attachPeerEvents(conn)
50
- this.peers.set(peer.pubkeyHex, conn)
51
- conn.connect()
52
-
53
- return conn
54
- }
55
-
56
- /**
57
- * Accept an inbound peer connection.
58
- *
59
- * @param {WebSocket} socket - Incoming WebSocket
60
- * @param {string} pubkeyHex - Peer's pubkey (from handshake)
61
- * @param {string} endpoint - Peer's advertised endpoint
62
- * @returns {PeerConnection|null}
63
- */
64
- acceptPeer (socket, pubkeyHex, endpoint) {
65
- if (this.peers.has(pubkeyHex)) {
66
- // Already connected — close the duplicate
67
- socket.close()
68
- return null
69
- }
70
-
71
- const conn = new PeerConnection({
72
- endpoint,
73
- pubkeyHex,
74
- socket
75
- })
76
-
77
- this._attachPeerEvents(conn)
78
- this.peers.set(pubkeyHex, conn)
79
-
80
- return conn
81
- }
82
-
83
- /**
84
- * Disconnect a specific peer.
85
- *
86
- * @param {string} pubkeyHex
87
- */
88
- disconnectPeer (pubkeyHex) {
89
- const conn = this.peers.get(pubkeyHex)
90
- if (conn) {
91
- conn.destroy()
92
- this.peers.delete(pubkeyHex)
93
- }
94
- }
95
-
96
- /**
97
- * Broadcast a message to all connected peers.
98
- *
99
- * @param {object} msg - JSON message with `type` field
100
- * @param {string} [excludePubkey] - Optional pubkey to exclude (e.g. message source)
101
- * @returns {number} Number of peers the message was sent to
102
- */
103
- broadcast (msg, excludePubkey) {
104
- let sent = 0
105
- for (const [pubkeyHex, conn] of this.peers) {
106
- if (pubkeyHex === excludePubkey) continue
107
- if (conn.send(msg)) sent++
108
- }
109
- return sent
110
- }
111
-
112
- /**
113
- * Get count of currently connected peers.
114
- * @returns {number}
115
- */
116
- connectedCount () {
117
- let count = 0
118
- for (const conn of this.peers.values()) {
119
- if (conn.connected) count++
120
- }
121
- return count
122
- }
123
-
124
- /**
125
- * Start a WebSocket server for inbound connections.
126
- *
127
- * @param {object} opts
128
- * @param {number} opts.port - Port to listen on
129
- * @param {string} [opts.host='0.0.0.0'] - Host to bind to
130
- * @returns {Promise<void>}
131
- */
132
- startServer (opts) {
133
- return new Promise((resolve, reject) => {
134
- this._server = new WebSocketServer({
135
- port: opts.port,
136
- host: opts.host || '0.0.0.0'
137
- })
138
-
139
- this._server.on('listening', () => resolve())
140
- this._server.on('error', (err) => reject(err))
141
-
142
- this._server.on('connection', (ws) => {
143
- // Inbound connections: full challenge-response handshake if available,
144
- // otherwise fall back to basic hello exchange.
145
- const timeout = setTimeout(() => {
146
- ws.close() // No hello within 10 seconds
147
- }, 10000)
148
-
149
- ws.once('message', (data) => {
150
- clearTimeout(timeout)
151
- try {
152
- const msg = JSON.parse(data.toString())
153
- if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
154
- ws.close()
155
- return
156
- }
157
-
158
- // If cryptographic handshake is available, use it
159
- if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
160
- const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
161
- const result = opts.handshake.handleHello(msg, isSeed ? null : opts.registeredPubkeys)
162
- if (result.error) {
163
- ws.send(JSON.stringify({ type: 'error', error: result.error }))
164
- ws.close()
165
- return
166
- }
167
-
168
- // Send challenge_response
169
- ws.send(JSON.stringify(result.message))
170
-
171
- // Wait for verify
172
- const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
173
- ws.once('message', (data2) => {
174
- clearTimeout(verifyTimeout)
175
- try {
176
- const verifyMsg = JSON.parse(data2.toString())
177
- const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
178
- if (verifyResult.error) {
179
- ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
180
- ws.close()
181
- return
182
- }
183
-
184
- // Tie-break duplicate connections by pubkey
185
- const existing = this.peers.get(result.peerPubkey)
186
- if (existing) {
187
- if (opts.pubkeyHex < result.peerPubkey) {
188
- // Lower pubkey keeps outbound — reject this inbound
189
- ws.close()
190
- return
191
- }
192
- // Higher pubkey keeps inbound drop existing outbound
193
- existing._shouldReconnect = false
194
- existing.destroy()
195
- this.peers.delete(result.peerPubkey)
196
- }
197
-
198
- // Handshake complete — accept peer
199
- // Learn seed pubkeys so they pass registry check on future connections
200
- if (isSeed && opts.registeredPubkeys && !opts.registeredPubkeys.has(result.peerPubkey)) {
201
- opts.registeredPubkeys.add(result.peerPubkey)
202
- }
203
- const conn = this.acceptPeer(ws, result.peerPubkey, msg.endpoint)
204
- if (conn) {
205
- this.emit('peer:connect', { pubkeyHex: result.peerPubkey, endpoint: msg.endpoint })
206
- conn.emit('message', msg)
207
- }
208
- } catch {
209
- ws.close()
210
- }
211
- })
212
- } else {
213
- // Legacy hello — accept without crypto verification
214
- const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
215
- if (conn) {
216
- if (opts.pubkeyHex && opts.endpoint) {
217
- ws.send(JSON.stringify({
218
- type: 'hello',
219
- pubkey: opts.pubkeyHex,
220
- endpoint: opts.endpoint
221
- }))
222
- }
223
- this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
224
- conn.emit('message', msg)
225
- }
226
- }
227
- } catch {
228
- ws.close()
229
- }
230
- })
231
- })
232
- })
233
- }
234
-
235
- /**
236
- * Stop the WebSocket server and disconnect all peers.
237
- */
238
- async shutdown () {
239
- for (const [pubkeyHex, conn] of this.peers) {
240
- conn.destroy()
241
- }
242
- this.peers.clear()
243
-
244
- if (this._server) {
245
- await new Promise(resolve => this._server.close(resolve))
246
- this._server = null
247
- }
248
- }
249
-
250
- _attachPeerEvents (conn) {
251
- conn.on('message', (msg) => {
252
- this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
253
- })
254
-
255
- conn.on('close', () => {
256
- // Only remove from map if the connection won't auto-reconnect.
257
- // Keeping reconnecting peers in the map prevents gossip from
258
- // creating duplicate PeerConnections for the same pubkey.
259
- if (!conn._shouldReconnect || conn._destroyed) {
260
- // Only delete if the map still holds THIS connection (not a replacement)
261
- if (this.peers.get(conn.pubkeyHex) === conn) {
262
- this.peers.delete(conn.pubkeyHex)
263
- }
264
- }
265
- this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
266
- })
267
-
268
- conn.on('error', (err) => {
269
- this.emit('peer:error', { pubkeyHex: conn.pubkeyHex, error: err })
270
- })
271
- }
272
- }
1
+ import { EventEmitter } from 'node:events'
2
+ import { WebSocketServer } from 'ws'
3
+ import { PeerConnection } from './peer-connection.js'
4
+
5
+ /**
6
+ * PeerManager — manages multiple peer connections.
7
+ *
8
+ * Handles:
9
+ * - Outbound connections to discovered peers
10
+ * - Inbound connections from other bridges (via WSS server)
11
+ * - Broadcasting messages to all connected peers
12
+ * - Peer lifecycle (connect, disconnect, reconnect)
13
+ *
14
+ * Events:
15
+ * 'peer:connect' — { pubkeyHex, endpoint }
16
+ * 'peer:disconnect' — { pubkeyHex, endpoint }
17
+ * 'peer:message' — { pubkeyHex, message }
18
+ * 'peer:error' — { pubkeyHex, error }
19
+ */
20
+ export class PeerManager extends EventEmitter {
21
+ /**
22
+ * @param {object} [opts]
23
+ */
24
+ constructor () {
25
+ super()
26
+ /** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
27
+ this.peers = new Map()
28
+ this._server = null
29
+ }
30
+
31
+ /**
32
+ * Connect to a discovered peer (outbound).
33
+ *
34
+ * @param {object} peer - Peer from buildPeerList()
35
+ * @param {string} peer.pubkeyHex
36
+ * @param {string} peer.endpoint
37
+ * @returns {PeerConnection|null} The connection, or null if already connected
38
+ */
39
+ connectToPeer (peer) {
40
+ if (this.peers.has(peer.pubkeyHex)) {
41
+ return this.peers.get(peer.pubkeyHex)
42
+ }
43
+
44
+ const conn = new PeerConnection({
45
+ endpoint: peer.endpoint,
46
+ pubkeyHex: peer.pubkeyHex
47
+ })
48
+
49
+ this._attachPeerEvents(conn)
50
+ this.peers.set(peer.pubkeyHex, conn)
51
+ conn.connect()
52
+
53
+ return conn
54
+ }
55
+
56
+ /**
57
+ * Accept an inbound peer connection.
58
+ *
59
+ * @param {WebSocket} socket - Incoming WebSocket
60
+ * @param {string} pubkeyHex - Peer's pubkey (from handshake)
61
+ * @param {string} endpoint - Peer's advertised endpoint
62
+ * @returns {PeerConnection|null}
63
+ */
64
+ acceptPeer (socket, pubkeyHex, endpoint) {
65
+ if (this.peers.has(pubkeyHex)) {
66
+ // Already connected — close the duplicate
67
+ socket.close()
68
+ return null
69
+ }
70
+
71
+ const conn = new PeerConnection({
72
+ endpoint,
73
+ pubkeyHex,
74
+ socket
75
+ })
76
+
77
+ this._attachPeerEvents(conn)
78
+ this.peers.set(pubkeyHex, conn)
79
+
80
+ return conn
81
+ }
82
+
83
+ /**
84
+ * Disconnect a specific peer.
85
+ *
86
+ * @param {string} pubkeyHex
87
+ */
88
+ disconnectPeer (pubkeyHex) {
89
+ const conn = this.peers.get(pubkeyHex)
90
+ if (conn) {
91
+ conn.destroy()
92
+ this.peers.delete(pubkeyHex)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Broadcast a message to all connected peers.
98
+ *
99
+ * @param {object} msg - JSON message with `type` field
100
+ * @param {string} [excludePubkey] - Optional pubkey to exclude (e.g. message source)
101
+ * @returns {number} Number of peers the message was sent to
102
+ */
103
+ broadcast (msg, excludePubkey) {
104
+ let sent = 0
105
+ for (const [pubkeyHex, conn] of this.peers) {
106
+ if (pubkeyHex === excludePubkey) continue
107
+ if (conn.send(msg)) sent++
108
+ }
109
+ return sent
110
+ }
111
+
112
+ /**
113
+ * Get count of currently connected peers.
114
+ * @returns {number}
115
+ */
116
+ connectedCount () {
117
+ let count = 0
118
+ for (const conn of this.peers.values()) {
119
+ if (conn.connected) count++
120
+ }
121
+ return count
122
+ }
123
+
124
+ /**
125
+ * Start a WebSocket server for inbound connections.
126
+ *
127
+ * @param {object} opts
128
+ * @param {number} opts.port - Port to listen on
129
+ * @param {string} [opts.host='0.0.0.0'] - Host to bind to
130
+ * @returns {Promise<void>}
131
+ */
132
+ startServer (opts) {
133
+ return new Promise((resolve, reject) => {
134
+ this._server = new WebSocketServer({
135
+ port: opts.port,
136
+ host: opts.host || '0.0.0.0'
137
+ })
138
+
139
+ this._server.on('listening', () => resolve())
140
+ this._server.on('error', (err) => reject(err))
141
+
142
+ this._server.on('connection', (ws) => {
143
+ // Inbound connections: full challenge-response handshake if available,
144
+ // otherwise fall back to basic hello exchange.
145
+ const timeout = setTimeout(() => {
146
+ ws.close() // No hello within 10 seconds
147
+ }, 10000)
148
+
149
+ ws.once('message', (data) => {
150
+ clearTimeout(timeout)
151
+ try {
152
+ const msg = JSON.parse(data.toString())
153
+ if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
154
+ ws.close()
155
+ return
156
+ }
157
+
158
+ // If cryptographic handshake is available, use it
159
+ if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
160
+ const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
161
+ const result = opts.handshake.handleHello(msg, isSeed ? null : opts.registeredPubkeys)
162
+ if (result.error) {
163
+ ws.send(JSON.stringify({ type: 'error', error: result.error }))
164
+ ws.close()
165
+ return
166
+ }
167
+
168
+ // Send challenge_response
169
+ ws.send(JSON.stringify(result.message))
170
+
171
+ // Wait for verify
172
+ const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
173
+ ws.once('message', (data2) => {
174
+ clearTimeout(verifyTimeout)
175
+ try {
176
+ const verifyMsg = JSON.parse(data2.toString())
177
+ const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
178
+ if (verifyResult.error) {
179
+ ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
180
+ ws.close()
181
+ return
182
+ }
183
+
184
+ // Tie-break duplicate connections by pubkey
185
+ const existing = this.peers.get(result.peerPubkey)
186
+ if (existing) {
187
+ // If existing connection is dead (reconnecting), prefer new working connection
188
+ if (!existing.connected) {
189
+ existing._shouldReconnect = false
190
+ existing.destroy()
191
+ this.peers.delete(result.peerPubkey)
192
+ } else if (opts.pubkeyHex < result.peerPubkey) {
193
+ // Lower pubkey keeps outbound — reject this inbound
194
+ ws.close()
195
+ return
196
+ } else {
197
+ // Higher pubkey keeps inbound — drop existing outbound
198
+ existing._shouldReconnect = false
199
+ existing.destroy()
200
+ this.peers.delete(result.peerPubkey)
201
+ }
202
+ }
203
+
204
+ // Handshake complete — accept peer
205
+ // Learn seed pubkeys so they pass registry check on future connections
206
+ if (isSeed && opts.registeredPubkeys && !opts.registeredPubkeys.has(result.peerPubkey)) {
207
+ opts.registeredPubkeys.add(result.peerPubkey)
208
+ }
209
+ const conn = this.acceptPeer(ws, result.peerPubkey, msg.endpoint)
210
+ if (conn) {
211
+ this.emit('peer:connect', { pubkeyHex: result.peerPubkey, endpoint: msg.endpoint })
212
+ conn.emit('message', msg)
213
+ }
214
+ } catch {
215
+ ws.close()
216
+ }
217
+ })
218
+ } else {
219
+ // Legacy hello — accept without crypto verification
220
+ const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
221
+ if (conn) {
222
+ if (opts.pubkeyHex && opts.endpoint) {
223
+ ws.send(JSON.stringify({
224
+ type: 'hello',
225
+ pubkey: opts.pubkeyHex,
226
+ endpoint: opts.endpoint
227
+ }))
228
+ }
229
+ this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
230
+ conn.emit('message', msg)
231
+ }
232
+ }
233
+ } catch {
234
+ ws.close()
235
+ }
236
+ })
237
+ })
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Stop the WebSocket server and disconnect all peers.
243
+ */
244
+ async shutdown () {
245
+ for (const [pubkeyHex, conn] of this.peers) {
246
+ conn.destroy()
247
+ }
248
+ this.peers.clear()
249
+
250
+ if (this._server) {
251
+ await new Promise(resolve => this._server.close(resolve))
252
+ this._server = null
253
+ }
254
+ }
255
+
256
+ _attachPeerEvents (conn) {
257
+ conn.on('message', (msg) => {
258
+ this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
259
+ })
260
+
261
+ conn.on('close', () => {
262
+ // Only remove from map if the connection won't auto-reconnect.
263
+ // Keeping reconnecting peers in the map prevents gossip from
264
+ // creating duplicate PeerConnections for the same pubkey.
265
+ if (!conn._shouldReconnect || conn._destroyed) {
266
+ // Only delete if the map still holds THIS connection (not a replacement)
267
+ if (this.peers.get(conn.pubkeyHex) === conn) {
268
+ this.peers.delete(conn.pubkeyHex)
269
+ }
270
+ }
271
+ this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
272
+ })
273
+
274
+ conn.on('error', (err) => {
275
+ this.emit('peer:error', { pubkeyHex: conn.pubkeyHex, error: err })
276
+ })
277
+ }
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {