@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,494 @@
1
+ import { Transaction, Hash, PublicKey, P2PKH } from '@bsv/sdk'
2
+ import { createHash } from 'node:crypto'
3
+
4
+ /**
5
+ * OutputParser — extracts addresses and script info from raw transactions.
6
+ *
7
+ * Parses raw transaction hex and inspects each output to determine:
8
+ * - Whether it's a standard P2PKH output
9
+ * - The hash160 (pubkey hash) it pays to
10
+ * - The satoshi value
11
+ *
12
+ * Also inspects inputs to determine which UTXOs are being spent.
13
+ *
14
+ * This module does NOT depend on any network calls — pure local parsing.
15
+ */
16
+
17
+ /**
18
+ * Parse a raw transaction hex into structured output info.
19
+ * @param {string} rawHex — raw transaction hex string
20
+ * @returns {{ txid: string, inputs: Array<{ prevTxid: string, prevVout: number }>, outputs: Array<{ vout: number, satoshis: number, scriptHex: string, hash160: string|null, isP2PKH: boolean }> }}
21
+ */
22
+ export function parseTx (rawHex) {
23
+ const tx = Transaction.fromHex(rawHex)
24
+ const txid = tx.id('hex')
25
+
26
+ const inputs = tx.inputs.map(input => ({
27
+ prevTxid: typeof input.sourceTXID === 'string'
28
+ ? input.sourceTXID
29
+ : Buffer.from(input.sourceTXID).toString('hex'),
30
+ prevVout: input.sourceOutputIndex
31
+ }))
32
+
33
+ const outputs = tx.outputs.map((output, vout) => {
34
+ const scriptHex = output.lockingScript.toHex()
35
+ const satoshis = output.satoshis
36
+ const parsed = parseOutputScript(scriptHex)
37
+ return {
38
+ vout,
39
+ satoshis,
40
+ scriptHex,
41
+ scriptHash: createHash('sha256').update(Buffer.from(scriptHex, 'hex')).digest('hex'),
42
+ hash160: parsed.hash160,
43
+ isP2PKH: parsed.isP2PKH,
44
+ type: parsed.type,
45
+ data: parsed.data,
46
+ protocol: parsed.protocol,
47
+ parsed: parsed.parsed
48
+ }
49
+ })
50
+
51
+ return { txid, inputs, outputs }
52
+ }
53
+
54
+ /**
55
+ * Parse a locking script hex to extract hash160 if it's P2PKH.
56
+ *
57
+ * Standard P2PKH script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
58
+ * Hex pattern: 76a914{40 hex chars}88ac
59
+ *
60
+ * @param {string} scriptHex
61
+ * @returns {{ isP2PKH: boolean, hash160: string|null }}
62
+ */
63
+ export function parseOutputScript (scriptHex) {
64
+ const base = { isP2PKH: false, hash160: null, type: 'unknown', data: null, protocol: null, parsed: null }
65
+
66
+ // 1. P2PKH: 76 a9 14 <20 bytes hash160> 88 ac (fast path — most common)
67
+ if (scriptHex.length === 50 &&
68
+ scriptHex.startsWith('76a914') &&
69
+ scriptHex.endsWith('88ac')) {
70
+ return { ...base, type: 'p2pkh', isP2PKH: true, hash160: scriptHex.slice(6, 46) }
71
+ }
72
+
73
+ // 2. OP_RETURN / OP_FALSE OP_RETURN
74
+ if (scriptHex.startsWith('6a') || scriptHex.startsWith('006a')) {
75
+ const opReturn = parseOpReturn(scriptHex)
76
+ if (opReturn) {
77
+ const { protocol, parsed } = detectProtocol(opReturn.pushes)
78
+ return { ...base, type: 'op_return', data: opReturn.pushes, protocol, parsed }
79
+ }
80
+ }
81
+
82
+ // 3. Ordinal inscription (OP_FALSE OP_IF OP_PUSH3 "ord" ...)
83
+ if (scriptHex.includes(ORD_ENVELOPE)) {
84
+ const ord = parseOrdinal(scriptHex)
85
+ if (ord) {
86
+ // Extract hash160 from P2PKH wrapper if present
87
+ let hash160 = null
88
+ if (scriptHex.startsWith('76a914') && scriptHex.length > 50) {
89
+ hash160 = scriptHex.slice(6, 46)
90
+ }
91
+ return {
92
+ ...base,
93
+ type: 'ordinal',
94
+ isP2PKH: !!hash160,
95
+ hash160,
96
+ protocol: ord.isBsv20 ? 'bsv-20' : 'ordinal',
97
+ parsed: ord
98
+ }
99
+ }
100
+ }
101
+
102
+ // 4. P2SH: a9 14 <20 bytes> 87
103
+ const p2sh = parseP2SH(scriptHex)
104
+ if (p2sh) {
105
+ return { ...base, type: 'p2sh', parsed: p2sh }
106
+ }
107
+
108
+ // 5. Bare multisig: OP_m <pubkeys> OP_n OP_CHECKMULTISIG
109
+ if (scriptHex.endsWith('ae')) {
110
+ const multi = parseMultisig(scriptHex)
111
+ if (multi) {
112
+ return { ...base, type: 'multisig', parsed: multi }
113
+ }
114
+ }
115
+
116
+ // 6. Unknown script type
117
+ return base
118
+ }
119
+
120
+ /**
121
+ * Compute the hash160 of a compressed public key hex.
122
+ * hash160 = RIPEMD160(SHA256(pubkey))
123
+ *
124
+ * @param {string} pubkeyHex — 33-byte compressed public key as hex
125
+ * @returns {string} 20-byte hash160 as hex
126
+ */
127
+ export function pubkeyToHash160 (pubkeyHex) {
128
+ const pubkeyBytes = Buffer.from(pubkeyHex, 'hex')
129
+ const sha = Hash.sha256(pubkeyBytes)
130
+ const h160 = Hash.ripemd160(sha)
131
+ return Buffer.from(h160).toString('hex')
132
+ }
133
+
134
+ /**
135
+ * Convert a BSV address to its hash160 (pubkey hash).
136
+ * Uses P2PKH locking script to extract the hash160.
137
+ *
138
+ * @param {string} address — BSV address (e.g. '1KhH4V...')
139
+ * @returns {string} 20-byte hash160 as hex
140
+ */
141
+ export function addressToHash160 (address) {
142
+ const script = new P2PKH().lock(address).toHex()
143
+ // P2PKH script: 76a914{hash160}88ac
144
+ return script.slice(6, 46)
145
+ }
146
+
147
+ /**
148
+ * Check if a transaction has any outputs paying to a set of watched hash160s.
149
+ *
150
+ * @param {string} rawHex — raw transaction hex
151
+ * @param {Set<string>} watchedHash160s — set of hash160 hex strings to watch
152
+ * @returns {{ txid: string, matches: Array<{ vout: number, satoshis: number, scriptHex: string, hash160: string }>, spends: Array<{ prevTxid: string, prevVout: number }> }}
153
+ */
154
+ export function checkTxForWatched (rawHex, watchedHash160s) {
155
+ const parsed = parseTx(rawHex)
156
+
157
+ const matches = parsed.outputs
158
+ .filter(o => o.isP2PKH && watchedHash160s.has(o.hash160))
159
+ .map(o => ({
160
+ vout: o.vout,
161
+ satoshis: o.satoshis,
162
+ scriptHex: o.scriptHex,
163
+ hash160: o.hash160
164
+ }))
165
+
166
+ return {
167
+ txid: parsed.txid,
168
+ matches,
169
+ spends: parsed.inputs
170
+ }
171
+ }
172
+
173
+ // --- Protocol constants ---
174
+
175
+ const B_PREFIX = '19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut'
176
+ const BCAT_PREFIX = '15DHFxWZJT58f9nhyGnsRBqrgwK4W6h4Up'
177
+ const BCAT_PART_PREFIX = '1ChDHzdd1H4wSjgGMHyndZm6qxEDGjqpJL'
178
+ const MAP_PREFIX = '1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5'
179
+ const METANET_MAGIC = '6d657461' // "meta" as hex
180
+
181
+ // Ordinal envelope: OP_FALSE OP_IF OP_PUSH3 "ord"
182
+ const ORD_ENVELOPE = '0063036f7264'
183
+
184
+ // --- Script reading utilities ---
185
+
186
+ /**
187
+ * Read a pushdata segment from a script hex string at the given offset.
188
+ * Handles direct-length (1-75), OP_PUSHDATA1 (0x4c), OP_PUSHDATA2 (0x4d).
189
+ *
190
+ * @param {string} hex — script as hex string
191
+ * @param {number} offset — byte offset (in hex chars, so multiply by 2)
192
+ * @returns {{ data: string, newOffset: number }} data as hex string
193
+ */
194
+ function readPushData (hex, offset) {
195
+ const opByte = parseInt(hex.slice(offset, offset + 2), 16)
196
+ offset += 2
197
+
198
+ let dataLen
199
+ if (opByte >= 1 && opByte <= 75) {
200
+ dataLen = opByte
201
+ } else if (opByte === 0x4c) { // OP_PUSHDATA1
202
+ dataLen = parseInt(hex.slice(offset, offset + 2), 16)
203
+ offset += 2
204
+ } else if (opByte === 0x4d) { // OP_PUSHDATA2
205
+ dataLen = parseInt(hex.slice(offset, offset + 2), 16) +
206
+ parseInt(hex.slice(offset + 2, offset + 4), 16) * 256
207
+ offset += 4
208
+ } else if (opByte === 0x4e) { // OP_PUSHDATA4
209
+ dataLen = parseInt(hex.slice(offset, offset + 2), 16) +
210
+ parseInt(hex.slice(offset + 2, offset + 4), 16) * 256 +
211
+ parseInt(hex.slice(offset + 4, offset + 6), 16) * 65536 +
212
+ parseInt(hex.slice(offset + 6, offset + 8), 16) * 16777216
213
+ offset += 8
214
+ } else {
215
+ return { data: null, opByte, newOffset: offset }
216
+ }
217
+
218
+ const dataHex = hex.slice(offset, offset + dataLen * 2)
219
+ return { data: dataHex, newOffset: offset + dataLen * 2 }
220
+ }
221
+
222
+ /**
223
+ * Extract all push data segments from an OP_RETURN script.
224
+ * Skips the OP_RETURN (and optional OP_FALSE) prefix.
225
+ *
226
+ * @param {string} scriptHex
227
+ * @returns {{ pushes: string[], isFalseReturn: boolean }}
228
+ */
229
+ export function parseOpReturn (scriptHex) {
230
+ let offset = 0
231
+ let isFalseReturn = false
232
+
233
+ if (scriptHex.startsWith('006a')) {
234
+ offset = 4 // skip OP_FALSE OP_RETURN
235
+ isFalseReturn = true
236
+ } else if (scriptHex.startsWith('6a')) {
237
+ offset = 2 // skip OP_RETURN
238
+ } else {
239
+ return null
240
+ }
241
+
242
+ const pushes = []
243
+ while (offset < scriptHex.length) {
244
+ const byte = parseInt(scriptHex.slice(offset, offset + 2), 16)
245
+
246
+ // OP_0 pushes empty data
247
+ if (byte === 0x00) {
248
+ pushes.push('')
249
+ offset += 2
250
+ continue
251
+ }
252
+ // OP_1 through OP_16 push their number
253
+ if (byte >= 0x51 && byte <= 0x60) {
254
+ pushes.push((byte - 0x50).toString(16).padStart(2, '0'))
255
+ offset += 2
256
+ continue
257
+ }
258
+
259
+ const result = readPushData(scriptHex, offset)
260
+ if (result.data === null) break // unknown opcode, stop
261
+ pushes.push(result.data)
262
+ offset = result.newOffset
263
+ }
264
+
265
+ return { pushes, isFalseReturn }
266
+ }
267
+
268
+ /**
269
+ * Detect which protocol an OP_RETURN output uses based on its first push.
270
+ *
271
+ * @param {string[]} pushes — array of hex push data
272
+ * @returns {{ protocol: string|null, parsed: object|null }}
273
+ */
274
+ function detectProtocol (pushes) {
275
+ if (!pushes || pushes.length === 0) return { protocol: null, parsed: null }
276
+
277
+ const firstPush = hexToUtf8(pushes[0])
278
+
279
+ if (firstPush === B_PREFIX) {
280
+ return { protocol: 'b', parsed: parseBProtocol(pushes) }
281
+ }
282
+ if (firstPush === BCAT_PREFIX) {
283
+ return { protocol: 'bcat', parsed: parseBCATLinker(pushes) }
284
+ }
285
+ if (firstPush === BCAT_PART_PREFIX) {
286
+ return { protocol: 'bcat-part', parsed: parseBCATPart(pushes) }
287
+ }
288
+ if (firstPush === MAP_PREFIX) {
289
+ return { protocol: 'map', parsed: parseMAP(pushes) }
290
+ }
291
+ if (pushes[0] === METANET_MAGIC) {
292
+ return { protocol: 'metanet', parsed: parseMetaNet(pushes) }
293
+ }
294
+
295
+ return { protocol: null, parsed: null }
296
+ }
297
+
298
+ // --- Protocol parsers ---
299
+
300
+ /**
301
+ * Parse B:// protocol fields.
302
+ * Format: <B_PREFIX> <data> <mimeType> [encoding] [filename]
303
+ */
304
+ function parseBProtocol (pushes) {
305
+ return {
306
+ data: pushes[1] || null,
307
+ mimeType: pushes[2] ? hexToUtf8(pushes[2]) : null,
308
+ encoding: pushes[3] ? hexToUtf8(pushes[3]) : null,
309
+ filename: pushes[4] ? hexToUtf8(pushes[4]) : null
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Parse BCAT linker fields.
315
+ * Format: <BCAT_PREFIX> <info> <mimeType> <charset> <filename> <flag> <txid1> <txid2> ...
316
+ */
317
+ function parseBCATLinker (pushes) {
318
+ const chunkTxids = []
319
+ for (let i = 6; i < pushes.length; i++) {
320
+ if (pushes[i] && pushes[i].length === 64) {
321
+ chunkTxids.push(pushes[i])
322
+ }
323
+ }
324
+ return {
325
+ info: pushes[1] ? hexToUtf8(pushes[1]) : null,
326
+ mimeType: pushes[2] ? hexToUtf8(pushes[2]) : null,
327
+ charset: pushes[3] ? hexToUtf8(pushes[3]) : null,
328
+ filename: pushes[4] ? hexToUtf8(pushes[4]) : null,
329
+ flag: pushes[5] ? hexToUtf8(pushes[5]) : null,
330
+ chunkTxids
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Parse BCAT part (chunk) fields.
336
+ * Format: <BCAT_PART_PREFIX> <raw data>
337
+ */
338
+ function parseBCATPart (pushes) {
339
+ return {
340
+ data: pushes[1] || null
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Parse MAP protocol fields.
346
+ * Format: <MAP_PREFIX> SET <key> <value> <key> <value> ...
347
+ */
348
+ function parseMAP (pushes) {
349
+ const action = pushes[1] ? hexToUtf8(pushes[1]) : null
350
+ const pairs = {}
351
+ for (let i = 2; i < pushes.length - 1; i += 2) {
352
+ const key = hexToUtf8(pushes[i])
353
+ const value = hexToUtf8(pushes[i + 1] || '')
354
+ if (key) pairs[key] = value
355
+ }
356
+ return { action, pairs }
357
+ }
358
+
359
+ /**
360
+ * Parse MetaNet protocol fields.
361
+ * Format: <"meta" 4 bytes> <nodeAddress> <parentTxid>
362
+ */
363
+ function parseMetaNet (pushes) {
364
+ return {
365
+ nodeAddress: pushes[1] ? hexToUtf8(pushes[1]) : null,
366
+ parentTxid: pushes[2] || null
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Parse a 1Sat Ordinal inscription from a script hex.
372
+ * Scans for the envelope: OP_FALSE OP_IF OP_PUSH3 "ord" ... OP_ENDIF
373
+ *
374
+ * @param {string} scriptHex
375
+ * @returns {{ contentType: string|null, content: string|null, isBsv20: boolean, bsv20: object|null }|null}
376
+ */
377
+ export function parseOrdinal (scriptHex) {
378
+ const envIdx = scriptHex.indexOf(ORD_ENVELOPE)
379
+ if (envIdx === -1) return null
380
+
381
+ // Start after the "ord" push: skip OP_FALSE(00) OP_IF(63) OP_PUSH3(03) "ord"(6f7264)
382
+ let offset = envIdx + ORD_ENVELOPE.length
383
+ let contentType = null
384
+ let content = null
385
+
386
+ while (offset < scriptHex.length) {
387
+ const byte = parseInt(scriptHex.slice(offset, offset + 2), 16)
388
+ offset += 2
389
+
390
+ if (byte === 0x68) break // OP_ENDIF
391
+
392
+ // Field tag
393
+ if (byte === 0x51) {
394
+ // OP_1 = content type field, next push is the mime type
395
+ const result = readPushData(scriptHex, offset)
396
+ if (result.data !== null) {
397
+ contentType = hexToUtf8(result.data)
398
+ offset = result.newOffset
399
+ }
400
+ } else if (byte === 0x00) {
401
+ // OP_0 = content body field, next push is the data
402
+ const result = readPushData(scriptHex, offset)
403
+ if (result.data !== null) {
404
+ content = result.data
405
+ offset = result.newOffset
406
+ }
407
+ } else if (byte >= 0x01 && byte <= 0x4b) {
408
+ // Direct push — skip this data (unknown field)
409
+ offset += byte * 2
410
+ } else if (byte === 0x4c) {
411
+ const len = parseInt(scriptHex.slice(offset, offset + 2), 16)
412
+ offset += 2 + len * 2
413
+ } else if (byte === 0x4d) {
414
+ const len = parseInt(scriptHex.slice(offset, offset + 2), 16) +
415
+ parseInt(scriptHex.slice(offset + 2, offset + 4), 16) * 256
416
+ offset += 4 + len * 2
417
+ }
418
+ }
419
+
420
+ let isBsv20 = false
421
+ let bsv20 = null
422
+ if (contentType === 'application/bsv-20' && content) {
423
+ isBsv20 = true
424
+ try {
425
+ bsv20 = JSON.parse(hexToUtf8(content))
426
+ } catch { /* invalid JSON */ }
427
+ }
428
+
429
+ return { contentType, content, isBsv20, bsv20 }
430
+ }
431
+
432
+ /**
433
+ * Detect P2SH script: OP_HASH160 <20 bytes> OP_EQUAL
434
+ * Hex pattern: a914{40 hex chars}87
435
+ * Deprecated on BSV since Genesis (Feb 2020) but exists in history.
436
+ *
437
+ * @param {string} scriptHex
438
+ * @returns {{ scriptHash: string }|null}
439
+ */
440
+ function parseP2SH (scriptHex) {
441
+ if (scriptHex.length === 46 &&
442
+ scriptHex.startsWith('a914') &&
443
+ scriptHex.endsWith('87')) {
444
+ return { scriptHash: scriptHex.slice(4, 44) }
445
+ }
446
+ return null
447
+ }
448
+
449
+ /**
450
+ * Detect bare multisig: OP_m <pubkeys> OP_n OP_CHECKMULTISIG
451
+ *
452
+ * @param {string} scriptHex
453
+ * @returns {{ m: number, n: number, pubkeys: string[] }|null}
454
+ */
455
+ function parseMultisig (scriptHex) {
456
+ // Must end with OP_CHECKMULTISIG (ae)
457
+ if (!scriptHex.endsWith('ae')) return null
458
+
459
+ const firstByte = parseInt(scriptHex.slice(0, 2), 16)
460
+ if (firstByte < 0x51 || firstByte > 0x60) return null // not OP_1..OP_16
461
+ const m = firstByte - 0x50
462
+
463
+ // Read public keys
464
+ const pubkeys = []
465
+ let offset = 2
466
+ while (offset < scriptHex.length - 4) { // -4 for OP_n + OP_CHECKMULTISIG
467
+ const pushLen = parseInt(scriptHex.slice(offset, offset + 2), 16)
468
+ if (pushLen !== 0x21 && pushLen !== 0x41) break // not 33 or 65 byte key
469
+ offset += 2
470
+ pubkeys.push(scriptHex.slice(offset, offset + pushLen * 2))
471
+ offset += pushLen * 2
472
+ }
473
+
474
+ if (pubkeys.length === 0) return null
475
+
476
+ const nByte = parseInt(scriptHex.slice(offset, offset + 2), 16)
477
+ if (nByte < 0x51 || nByte > 0x60) return null
478
+ const n = nByte - 0x50
479
+
480
+ if (n !== pubkeys.length || m > n) return null
481
+
482
+ return { m, n, pubkeys }
483
+ }
484
+
485
+ // --- Utility ---
486
+
487
+ function hexToUtf8 (hex) {
488
+ if (!hex) return ''
489
+ const bytes = []
490
+ for (let i = 0; i < hex.length; i += 2) {
491
+ bytes.push(parseInt(hex.slice(i, i + 2), 16))
492
+ }
493
+ return Buffer.from(bytes).toString('utf8')
494
+ }
@@ -71,7 +71,7 @@ export class PeerManager extends EventEmitter {
71
71
  if (this.peers.has(pubkeyHex)) {
72
72
  // Already connected — close the duplicate
73
73
  socket.close()
74
- return this.peers.get(pubkeyHex)
74
+ return null
75
75
  }
76
76
 
77
77
  if (this.peers.size >= this.maxPeers) {
@@ -151,8 +151,8 @@ export class PeerManager extends EventEmitter {
151
151
  this._server.on('error', (err) => reject(err))
152
152
 
153
153
  this._server.on('connection', (ws) => {
154
- // Inbound connections need to identify themselves via handshake.
155
- // For now, we hold the socket and wait for a 'hello' message.
154
+ // Inbound connections: full challenge-response handshake if available,
155
+ // otherwise fall back to basic hello exchange.
156
156
  const timeout = setTimeout(() => {
157
157
  ws.close() // No hello within 10 seconds
158
158
  }, 10000)
@@ -161,18 +161,82 @@ export class PeerManager extends EventEmitter {
161
161
  clearTimeout(timeout)
162
162
  try {
163
163
  const msg = JSON.parse(data.toString())
164
- if (msg.type === 'hello' && msg.pubkey && msg.endpoint) {
164
+ if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
165
+ ws.close()
166
+ return
167
+ }
168
+
169
+ // If cryptographic handshake is available, use it
170
+ if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
171
+ const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
172
+ const result = opts.handshake.handleHello(msg, isSeed ? null : (opts.registeredPubkeys || null))
173
+ if (result.error) {
174
+ ws.send(JSON.stringify({ type: 'error', error: result.error }))
175
+ ws.close()
176
+ return
177
+ }
178
+
179
+ // Send challenge_response
180
+ ws.send(JSON.stringify(result.message))
181
+
182
+ // Wait for verify
183
+ const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
184
+ ws.once('message', (data2) => {
185
+ clearTimeout(verifyTimeout)
186
+ try {
187
+ const verifyMsg = JSON.parse(data2.toString())
188
+ const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
189
+ if (verifyResult.error) {
190
+ ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
191
+ ws.close()
192
+ return
193
+ }
194
+
195
+ // Tie-break duplicate connections by pubkey
196
+ const existing = this.peers.get(result.peerPubkey)
197
+ if (existing) {
198
+ if (opts.pubkeyHex < result.peerPubkey) {
199
+ // Lower pubkey keeps outbound — reject this inbound
200
+ ws.close()
201
+ return
202
+ }
203
+ // Higher pubkey keeps inbound — drop existing outbound
204
+ existing._shouldReconnect = false
205
+ existing.destroy()
206
+ this.peers.delete(result.peerPubkey)
207
+ }
208
+
209
+ // Handshake complete — accept peer
210
+ // Learn seed pubkeys so they pass registry check on future connections
211
+ if (isSeed && opts.registeredPubkeys && !opts.registeredPubkeys.has(result.peerPubkey)) {
212
+ opts.registeredPubkeys.add(result.peerPubkey)
213
+ }
214
+ const conn = this.acceptPeer(ws, result.peerPubkey, msg.endpoint)
215
+ if (conn) {
216
+ this.emit('peer:connect', { pubkeyHex: result.peerPubkey, endpoint: msg.endpoint })
217
+ conn.emit('message', msg)
218
+ }
219
+ } catch {
220
+ ws.close()
221
+ }
222
+ })
223
+ } else {
224
+ // Legacy hello — accept without crypto verification
165
225
  const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
166
226
  if (conn) {
227
+ if (opts.pubkeyHex && opts.endpoint) {
228
+ ws.send(JSON.stringify({
229
+ type: 'hello',
230
+ pubkey: opts.pubkeyHex,
231
+ endpoint: opts.endpoint
232
+ }))
233
+ }
167
234
  this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
168
- // Forward the hello as a regular message too
169
235
  conn.emit('message', msg)
170
236
  }
171
- } else {
172
- ws.close() // Invalid hello
173
237
  }
174
238
  } catch {
175
- ws.close() // Invalid JSON
239
+ ws.close()
176
240
  }
177
241
  })
178
242
  })
@@ -195,15 +259,20 @@ export class PeerManager extends EventEmitter {
195
259
  }
196
260
 
197
261
  _attachPeerEvents (conn) {
198
- conn.on('open', () => {
199
- this.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
200
- })
201
-
202
262
  conn.on('message', (msg) => {
203
263
  this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
204
264
  })
205
265
 
206
266
  conn.on('close', () => {
267
+ // Only remove from map if the connection won't auto-reconnect.
268
+ // Keeping reconnecting peers in the map prevents gossip from
269
+ // creating duplicate PeerConnections for the same pubkey.
270
+ if (!conn._shouldReconnect || conn._destroyed) {
271
+ // Only delete if the map still holds THIS connection (not a replacement)
272
+ if (this.peers.get(conn.pubkeyHex) === conn) {
273
+ this.peers.delete(conn.pubkeyHex)
274
+ }
275
+ }
207
276
  this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
208
277
  })
209
278