@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,791 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createConnection } from 'node:net'
3
+ import { createHash, randomBytes } from 'node:crypto'
4
+
5
+ /**
6
+ * BSVPeer — single TCP connection to a BSV full node.
7
+ *
8
+ * Speaks the Bitcoin P2P protocol (version 70016) for:
9
+ * - Header synchronisation via getheaders/headers
10
+ * - Transaction broadcast via inv/getdata/tx (correct 3-step flow)
11
+ * - Transaction fetch via getdata MSG_TX
12
+ * - Keepalive via ping/pong
13
+ *
14
+ * Ported from production Indelible SPV bridge (p2p.js) with:
15
+ * - Protocol version 70016 with protoconf
16
+ * - User agent /Bitcoin SV:1.1.0/ (matches known clients)
17
+ * - Correct inv-based broadcast (not raw tx push)
18
+ * - Settled flag pattern on connect (no double-reject)
19
+ * - ESM (not CJS)
20
+ *
21
+ * Events:
22
+ * 'headers' — { headers: [...], count }
23
+ * 'connected' — { host, port }
24
+ * 'handshake' — { version, userAgent, startHeight }
25
+ * 'disconnected' — { host, port }
26
+ * 'error' — Error
27
+ * 'tx' — { txid, rawHex }
28
+ * 'tx:inv' — { txids }
29
+ */
30
+
31
+ // BSV mainnet magic bytes
32
+ const MAGIC = Buffer.from('e3e1f3e8', 'hex')
33
+ const PROTOCOL_VERSION = 70016
34
+ const USER_AGENT = '/Bitcoin SV:1.1.0/'
35
+ const HEADER_BYTES = 80
36
+ const MSG_HEADER_SIZE = 24
37
+
38
+ /** Double SHA-256 */
39
+ function sha256d (data) {
40
+ const h1 = createHash('sha256').update(data).digest()
41
+ return createHash('sha256').update(h1).digest()
42
+ }
43
+
44
+ /** Reverse a buffer (for hash display conversion) */
45
+ function reverseBuffer (buf) {
46
+ const out = Buffer.allocUnsafe(buf.length)
47
+ for (let i = 0; i < buf.length; i++) {
48
+ out[i] = buf[buf.length - 1 - i]
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** Convert display hash to internal byte order buffer */
54
+ function hashToInternal (hexStr) {
55
+ return reverseBuffer(Buffer.from(hexStr, 'hex'))
56
+ }
57
+
58
+ /** Convert internal byte order buffer to display hash */
59
+ function internalToHash (buf) {
60
+ return reverseBuffer(buf).toString('hex')
61
+ }
62
+
63
+ /** Read a variable-length integer from buffer at offset */
64
+ function readVarInt (buf, offset) {
65
+ const first = buf[offset]
66
+ if (first < 0xfd) return { value: first, size: 1 }
67
+ if (first === 0xfd) return { value: buf.readUInt16LE(offset + 1), size: 3 }
68
+ if (first === 0xfe) return { value: buf.readUInt32LE(offset + 1), size: 5 }
69
+ return { value: Number(buf.readBigUInt64LE(offset + 1)), size: 9 }
70
+ }
71
+
72
+ /** Write a variable-length integer to buffer */
73
+ function writeVarInt (buf, offset, value) {
74
+ if (value < 0xfd) {
75
+ buf[offset] = value
76
+ return 1
77
+ }
78
+ if (value <= 0xffff) {
79
+ buf[offset] = 0xfd
80
+ buf.writeUInt16LE(value, offset + 1)
81
+ return 3
82
+ }
83
+ if (value <= 0xffffffff) {
84
+ buf[offset] = 0xfe
85
+ buf.writeUInt32LE(value, offset + 1)
86
+ return 5
87
+ }
88
+ buf[offset] = 0xff
89
+ buf.writeBigUInt64LE(BigInt(value), offset + 1)
90
+ return 9
91
+ }
92
+
93
+ /** Write a network address (26 bytes) */
94
+ function writeNetAddr (buf, offset, services = 1n, ip = '127.0.0.1', port = 8333) {
95
+ buf.writeBigUInt64LE(services, offset)
96
+ buf.fill(0, offset + 8, offset + 20)
97
+ buf[offset + 18] = 0xff
98
+ buf[offset + 19] = 0xff
99
+ const parts = ip.split('.').map(Number)
100
+ buf[offset + 20] = parts[0] || 0
101
+ buf[offset + 21] = parts[1] || 0
102
+ buf[offset + 22] = parts[2] || 0
103
+ buf[offset + 23] = parts[3] || 0
104
+ buf.writeUInt16BE(port, offset + 24)
105
+ return 26
106
+ }
107
+
108
+ // Default checkpoint: block 930,000
109
+ const DEFAULT_CHECKPOINT = {
110
+ height: 930000,
111
+ hash: '00000000000000001c2e04e4375cfa4b46588aa27795b2c7f8d4d34cb568a382',
112
+ prevHash: '000000000000000015ec9abde40c7537fc422e5af81b6028ac376d7cf23bd0c8'
113
+ }
114
+
115
+ export class BSVPeer extends EventEmitter {
116
+ /**
117
+ * @param {object} [opts]
118
+ * @param {{ height: number, hash: string, prevHash: string }} [opts.checkpoint]
119
+ * @param {number} [opts.syncIntervalMs] — Header sync interval (default 30s)
120
+ * @param {number} [opts.pingIntervalMs] — Keepalive ping interval (default 120s)
121
+ */
122
+ constructor (opts = {}) {
123
+ super()
124
+ this._checkpoint = opts.checkpoint || DEFAULT_CHECKPOINT
125
+ this._syncIntervalMs = opts.syncIntervalMs || 30000
126
+ this._pingIntervalMs = opts.pingIntervalMs || 120000
127
+
128
+ this._socket = null
129
+ this._buffer = Buffer.alloc(0)
130
+ this._connected = false
131
+ this._handshakeComplete = false
132
+ this._destroyed = false
133
+ this._host = null
134
+ this._port = null
135
+
136
+ this._syncTimer = null
137
+ this._pingTimer = null
138
+
139
+ // Header tracking
140
+ this._bestHeight = this._checkpoint.height
141
+ this._bestHash = this._checkpoint.hash
142
+ this._headerHashes = new Map()
143
+ this._headerHashes.set(this._checkpoint.height, this._checkpoint.hash)
144
+ if (this._checkpoint.prevHash) {
145
+ this._headerHashes.set(this._checkpoint.height - 1, this._checkpoint.prevHash)
146
+ }
147
+
148
+ // Peer info
149
+ this._peerVersion = 0
150
+ this._peerUserAgent = ''
151
+ this._peerStartHeight = 0
152
+
153
+ this._syncing = false
154
+
155
+ // Transaction tracking
156
+ this._pendingTxRequests = new Map()
157
+ this._pendingBroadcasts = new Map()
158
+ }
159
+
160
+ /**
161
+ * Connect to a BSV node at host:port.
162
+ * Returns a Promise that resolves on successful handshake.
163
+ * Uses settled flag pattern to prevent double-reject.
164
+ *
165
+ * @param {string} host — IP address
166
+ * @param {number} [port=8333]
167
+ * @returns {Promise<{ version, userAgent, startHeight }>}
168
+ */
169
+ async connect (host, port = 8333) {
170
+ if (this._destroyed) throw new Error('peer destroyed')
171
+ this._host = host
172
+ this._port = port
173
+
174
+ return new Promise((resolve, reject) => {
175
+ this._socket = createConnection({ host, port })
176
+
177
+ this._socket.on('connect', () => {
178
+ this._connected = true
179
+ this.emit('connected', { host, port })
180
+ this._sendVersion()
181
+ })
182
+
183
+ this._socket.on('data', (data) => this._onData(data))
184
+
185
+ // Settled pattern: whichever fires first wins, others are no-ops
186
+ const onError = (err) => {
187
+ clearTimeout(timer)
188
+ this.removeListener('handshake', onHandshake)
189
+ this._connected = false
190
+ reject(err)
191
+ }
192
+
193
+ const onHandshake = (info) => {
194
+ clearTimeout(timer)
195
+ this._socket.removeListener('error', onError)
196
+ // Replace with soft error handler post-handshake
197
+ this._socket.on('error', (err) => {
198
+ this.emit('error', err)
199
+ })
200
+ resolve(info)
201
+ }
202
+
203
+ const onTimeout = () => {
204
+ if (this._socket) this._socket.removeListener('error', onError)
205
+ this.removeListener('handshake', onHandshake)
206
+ this.disconnect()
207
+ reject(new Error('Handshake timeout (10s)'))
208
+ }
209
+
210
+ this._socket.once('error', onError)
211
+ this._socket.on('close', () => this._onDisconnect())
212
+ this.once('handshake', onHandshake)
213
+ const timer = setTimeout(onTimeout, 10000)
214
+ })
215
+ }
216
+
217
+ /**
218
+ * Disconnect and stop all timers.
219
+ */
220
+ disconnect () {
221
+ this._destroyed = true
222
+ clearInterval(this._syncTimer)
223
+ clearInterval(this._pingTimer)
224
+ if (this._socket) {
225
+ this._socket.destroy()
226
+ this._socket = null
227
+ }
228
+ this._connected = false
229
+ this._handshakeComplete = false
230
+ }
231
+
232
+ /**
233
+ * Request header sync from current best height.
234
+ */
235
+ syncHeaders () {
236
+ if (!this._handshakeComplete || this._syncing) return
237
+ this._syncing = true
238
+ const locator = this._buildBlockLocator()
239
+ this._sendGetHeaders(locator)
240
+ }
241
+
242
+ /**
243
+ * Seed a known header hash.
244
+ * @param {number} height
245
+ * @param {string} hash — display-format hex
246
+ */
247
+ seedHeader (height, hash) {
248
+ this._headerHashes.set(height, hash)
249
+ if (height > this._bestHeight) {
250
+ this._bestHeight = height
251
+ this._bestHash = hash
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Request peer addresses from this peer (getaddr P2P message).
257
+ * Peer responds with 'addr' message containing known node IPs.
258
+ */
259
+ requestAddr () {
260
+ if (this._handshakeComplete) {
261
+ this._sendMessage('getaddr', Buffer.alloc(0))
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Fetch a transaction by txid from this peer.
267
+ * @param {string} txid
268
+ * @param {number} [timeoutMs=10000]
269
+ * @returns {Promise<{ txid, rawHex }>}
270
+ */
271
+ getTx (txid, timeoutMs = 10000) {
272
+ if (!this._handshakeComplete) {
273
+ return Promise.reject(new Error('not connected to BSV node'))
274
+ }
275
+ if (this._pendingTxRequests.has(txid)) {
276
+ return Promise.reject(new Error(`already fetching tx ${txid.slice(0, 16)}...`))
277
+ }
278
+
279
+ return new Promise((resolve, reject) => {
280
+ const timer = setTimeout(() => {
281
+ this._pendingTxRequests.delete(txid)
282
+ reject(new Error(`timeout fetching tx ${txid.slice(0, 16)}...`))
283
+ }, timeoutMs)
284
+
285
+ this._pendingTxRequests.set(txid, { resolve, reject, timer })
286
+
287
+ const payload = Buffer.alloc(37)
288
+ payload[0] = 1
289
+ payload.writeUInt32LE(1, 1)
290
+ hashToInternal(txid).copy(payload, 5)
291
+ this._sendMessage('getdata', payload)
292
+ })
293
+ }
294
+
295
+ /**
296
+ * Broadcast a raw transaction using correct inv/getdata/tx flow.
297
+ * Sends inv announcement; peers respond with getdata; we serve the tx.
298
+ *
299
+ * @param {string} rawTxHex
300
+ * @returns {string} txid (display format)
301
+ */
302
+ broadcastTx (rawTxHex) {
303
+ const txBuffer = Buffer.from(rawTxHex, 'hex')
304
+ const txid = internalToHash(sha256d(txBuffer))
305
+
306
+ // Store so we can serve getdata requests from peers
307
+ this._pendingBroadcasts.set(txid, rawTxHex)
308
+ setTimeout(() => this._pendingBroadcasts.delete(txid), 60000)
309
+
310
+ // Send inv to announce we have this tx
311
+ const invPayload = Buffer.alloc(37)
312
+ invPayload[0] = 1 // count = 1
313
+ invPayload.writeUInt32LE(1, 1) // MSG_TX = 1
314
+ hashToInternal(txid).copy(invPayload, 5)
315
+ this._sendMessage('inv', invPayload)
316
+
317
+ return txid
318
+ }
319
+
320
+ /** Current best height */
321
+ get bestHeight () { return this._bestHeight }
322
+ /** Current best hash */
323
+ get bestHash () { return this._bestHash }
324
+ /** Connected host */
325
+ get host () { return this._host }
326
+
327
+ // ── Private: connection management ─────────────────────────
328
+
329
+ _onDisconnect () {
330
+ const host = this._host
331
+ this._connected = false
332
+ this._handshakeComplete = false
333
+ this._syncing = false
334
+ clearInterval(this._syncTimer)
335
+ clearInterval(this._pingTimer)
336
+ this._syncTimer = null
337
+ this._pingTimer = null
338
+ this.emit('disconnected', { host, port: this._port })
339
+ }
340
+
341
+ // ── Private: data parsing ──────────────────────────────────
342
+
343
+ _onData (data) {
344
+ this._buffer = Buffer.concat([this._buffer, data])
345
+
346
+ while (this._buffer.length >= MSG_HEADER_SIZE) {
347
+ const magicIdx = this._findMagic()
348
+ if (magicIdx < 0) {
349
+ this._buffer = Buffer.alloc(0)
350
+ return
351
+ }
352
+ if (magicIdx > 0) {
353
+ this._buffer = this._buffer.subarray(magicIdx)
354
+ }
355
+
356
+ if (this._buffer.length < MSG_HEADER_SIZE) return
357
+
358
+ const command = this._buffer.subarray(4, 16).toString('ascii').replace(/\0/g, '')
359
+ const payloadLen = this._buffer.readUInt32LE(16)
360
+ const checksum = this._buffer.subarray(20, 24)
361
+
362
+ const totalLen = MSG_HEADER_SIZE + payloadLen
363
+ if (this._buffer.length < totalLen) return
364
+
365
+ const payload = this._buffer.subarray(MSG_HEADER_SIZE, totalLen)
366
+
367
+ const computed = sha256d(payload).subarray(0, 4)
368
+ if (!computed.equals(checksum)) {
369
+ this._buffer = this._buffer.subarray(4)
370
+ continue
371
+ }
372
+
373
+ this._buffer = this._buffer.subarray(totalLen)
374
+
375
+ try {
376
+ this._handleMessage(command, payload)
377
+ } catch (err) {
378
+ this.emit('error', err)
379
+ }
380
+ }
381
+ }
382
+
383
+ _findMagic () {
384
+ for (let i = 0; i <= this._buffer.length - 4; i++) {
385
+ if (this._buffer[i] === MAGIC[0] &&
386
+ this._buffer[i + 1] === MAGIC[1] &&
387
+ this._buffer[i + 2] === MAGIC[2] &&
388
+ this._buffer[i + 3] === MAGIC[3]) {
389
+ return i
390
+ }
391
+ }
392
+ return -1
393
+ }
394
+
395
+ // ── Private: message handling ──────────────────────────────
396
+
397
+ _handleMessage (command, payload) {
398
+ switch (command) {
399
+ case 'version':
400
+ this._onVersion(payload)
401
+ break
402
+ case 'verack':
403
+ this._onVerack()
404
+ break
405
+ case 'headers':
406
+ this._onHeaders(payload)
407
+ break
408
+ case 'inv':
409
+ this._onInv(payload)
410
+ break
411
+ case 'ping':
412
+ this._onPing(payload)
413
+ break
414
+ case 'tx':
415
+ this._onTx(payload)
416
+ break
417
+ case 'notfound':
418
+ this._onNotfound(payload)
419
+ break
420
+ case 'getdata':
421
+ this._onGetdata(payload)
422
+ break
423
+ case 'addr':
424
+ this._onAddr(payload)
425
+ break
426
+ case 'sendheaders':
427
+ case 'sendcmpct':
428
+ case 'feefilter':
429
+ case 'protoconf':
430
+ case 'authch':
431
+ case 'authresp':
432
+ case 'extmsg':
433
+ break
434
+ }
435
+ }
436
+
437
+ _onVersion (payload) {
438
+ this._peerVersion = payload.readInt32LE(0)
439
+ const userAgentLen = readVarInt(payload, 80)
440
+ this._peerUserAgent = payload.subarray(80 + userAgentLen.size, 80 + userAgentLen.size + userAgentLen.value).toString('ascii')
441
+ const heightOffset = 80 + userAgentLen.size + userAgentLen.value
442
+ if (heightOffset + 4 <= payload.length) {
443
+ this._peerStartHeight = payload.readInt32LE(heightOffset)
444
+ }
445
+
446
+ // Only connect to BSV nodes — reject BTC/BCH
447
+ if (!this._peerUserAgent.includes('Bitcoin SV')) {
448
+ this.disconnect()
449
+ return
450
+ }
451
+
452
+ this._sendMessage('verack', Buffer.alloc(0))
453
+ }
454
+
455
+ _onVerack () {
456
+ this._handshakeComplete = true
457
+
458
+ // Send protoconf (protocol 70016+) — advertise max payload size
459
+ this._sendProtoconf()
460
+
461
+ this.emit('handshake', {
462
+ version: this._peerVersion,
463
+ userAgent: this._peerUserAgent,
464
+ startHeight: this._peerStartHeight
465
+ })
466
+
467
+ // Start header sync
468
+ this.syncHeaders()
469
+
470
+ // Periodic sync for new blocks
471
+ this._syncTimer = setInterval(() => {
472
+ this.syncHeaders()
473
+ }, this._syncIntervalMs)
474
+ if (this._syncTimer.unref) this._syncTimer.unref()
475
+
476
+ // Keep-alive pings
477
+ this._pingTimer = setInterval(() => {
478
+ this._sendPing()
479
+ }, this._pingIntervalMs)
480
+ if (this._pingTimer.unref) this._pingTimer.unref()
481
+ }
482
+
483
+ _onHeaders (payload) {
484
+ this._syncing = false
485
+
486
+ if (payload.length === 0) return
487
+
488
+ const countInfo = readVarInt(payload, 0)
489
+ const count = countInfo.value
490
+ if (count === 0) return
491
+
492
+ let offset = countInfo.size
493
+ const headers = []
494
+
495
+ for (let i = 0; i < count; i++) {
496
+ if (offset + HEADER_BYTES > payload.length) break
497
+
498
+ const rawHeader = payload.subarray(offset, offset + HEADER_BYTES)
499
+
500
+ const version = rawHeader.readInt32LE(0)
501
+ const prevHashBuf = rawHeader.subarray(4, 36)
502
+ const merkleRootBuf = rawHeader.subarray(36, 68)
503
+ const timestamp = rawHeader.readUInt32LE(68)
504
+ const bits = rawHeader.readUInt32LE(72)
505
+ const nonce = rawHeader.readUInt32LE(76)
506
+
507
+ const blockHash = internalToHash(sha256d(rawHeader))
508
+ const prevHash = internalToHash(prevHashBuf)
509
+
510
+ let height = -1
511
+ for (const [h, hash] of this._headerHashes) {
512
+ if (hash === prevHash) {
513
+ height = h + 1
514
+ break
515
+ }
516
+ }
517
+
518
+ if (height < 0) {
519
+ offset += HEADER_BYTES
520
+ if (offset < payload.length) {
521
+ const txCount = readVarInt(payload, offset)
522
+ offset += txCount.size
523
+ }
524
+ continue
525
+ }
526
+
527
+ this._headerHashes.set(height, blockHash)
528
+ if (height > this._bestHeight) {
529
+ this._bestHeight = height
530
+ this._bestHash = blockHash
531
+ }
532
+
533
+ headers.push({
534
+ height,
535
+ hash: blockHash,
536
+ prevHash,
537
+ timestamp,
538
+ bits,
539
+ nonce,
540
+ version,
541
+ merkleRoot: internalToHash(merkleRootBuf)
542
+ })
543
+
544
+ offset += HEADER_BYTES
545
+
546
+ if (offset < payload.length) {
547
+ const txCount = readVarInt(payload, offset)
548
+ offset += txCount.size
549
+ }
550
+ }
551
+
552
+ if (headers.length > 0) {
553
+ this.emit('headers', { headers, count: headers.length })
554
+
555
+ if (count >= 2000) {
556
+ this.syncHeaders()
557
+ }
558
+ }
559
+ }
560
+
561
+ _onInv (payload) {
562
+ if (payload.length < 1) return
563
+
564
+ const countInfo = readVarInt(payload, 0)
565
+ let offset = countInfo.size
566
+ let hasBlock = false
567
+ const txids = []
568
+
569
+ for (let i = 0; i < countInfo.value; i++) {
570
+ if (offset + 36 > payload.length) break
571
+ const invType = payload.readUInt32LE(offset)
572
+ const hashBuf = payload.subarray(offset + 4, offset + 36)
573
+
574
+ if (invType === 2) {
575
+ hasBlock = true
576
+ } else if (invType === 1) {
577
+ txids.push(internalToHash(hashBuf))
578
+ }
579
+ offset += 36
580
+ }
581
+
582
+ if (hasBlock) {
583
+ this.syncHeaders()
584
+ }
585
+
586
+ if (txids.length > 0) {
587
+ this.emit('tx:inv', { txids })
588
+ }
589
+ }
590
+
591
+ _onPing (payload) {
592
+ this._sendMessage('pong', payload)
593
+ }
594
+
595
+ _onAddr (payload) {
596
+ if (payload.length < 1) return
597
+ const countInfo = readVarInt(payload, 0)
598
+ const count = countInfo.value
599
+ let offset = countInfo.size
600
+ const addrs = []
601
+
602
+ for (let i = 0; i < count && offset + 30 <= payload.length; i++) {
603
+ // 4 bytes timestamp + 8 bytes services + 16 bytes IP + 2 bytes port
604
+ offset += 4 // skip timestamp
605
+ offset += 8 // skip services
606
+
607
+ // IPv4-mapped IPv6: last 4 bytes of 16-byte IP field
608
+ const isIPv4 = payload[offset + 10] === 0xff && payload[offset + 11] === 0xff
609
+ if (isIPv4) {
610
+ const host = `${payload[offset + 12]}.${payload[offset + 13]}.${payload[offset + 14]}.${payload[offset + 15]}`
611
+ offset += 16
612
+ const port = payload.readUInt16BE(offset)
613
+ offset += 2
614
+ if (port === 8333 && host !== '0.0.0.0' && host !== '127.0.0.1') {
615
+ addrs.push({ host, port })
616
+ }
617
+ } else {
618
+ offset += 16 + 2 // skip IPv6 + port
619
+ }
620
+ }
621
+
622
+ if (addrs.length > 0) {
623
+ this.emit('addr', { addrs })
624
+ }
625
+ }
626
+
627
+ _onTx (payload) {
628
+ const txid = internalToHash(sha256d(payload))
629
+ const rawHex = payload.toString('hex')
630
+ this.emit('tx', { txid, rawHex })
631
+
632
+ const pending = this._pendingTxRequests.get(txid)
633
+ if (pending) {
634
+ clearTimeout(pending.timer)
635
+ this._pendingTxRequests.delete(txid)
636
+ pending.resolve({ txid, rawHex })
637
+ }
638
+ }
639
+
640
+ _onNotfound (payload) {
641
+ if (payload.length < 1) return
642
+ const countInfo = readVarInt(payload, 0)
643
+ let offset = countInfo.size
644
+
645
+ for (let i = 0; i < countInfo.value; i++) {
646
+ if (offset + 36 > payload.length) break
647
+ const invType = payload.readUInt32LE(offset)
648
+ const hashBuf = payload.subarray(offset + 4, offset + 36)
649
+ offset += 36
650
+
651
+ if (invType === 1) {
652
+ const txid = internalToHash(hashBuf)
653
+ const pending = this._pendingTxRequests.get(txid)
654
+ if (pending) {
655
+ clearTimeout(pending.timer)
656
+ this._pendingTxRequests.delete(txid)
657
+ pending.reject(new Error(`tx not found: ${txid.slice(0, 16)}...`))
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ _onGetdata (payload) {
664
+ if (payload.length < 1) return
665
+ const countInfo = readVarInt(payload, 0)
666
+ let offset = countInfo.size
667
+
668
+ for (let i = 0; i < countInfo.value; i++) {
669
+ if (offset + 36 > payload.length) break
670
+ const invType = payload.readUInt32LE(offset)
671
+ const hashBuf = payload.subarray(offset + 4, offset + 36)
672
+ offset += 36
673
+
674
+ if (invType === 1) {
675
+ const txid = internalToHash(hashBuf)
676
+ const rawHex = this._pendingBroadcasts.get(txid)
677
+ if (rawHex) {
678
+ this._sendMessage('tx', Buffer.from(rawHex, 'hex'))
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ // ── Private: message building ──────────────────────────────
685
+
686
+ _sendMessage (command, payload) {
687
+ if (!this._socket || !this._connected) return
688
+
689
+ const header = Buffer.alloc(MSG_HEADER_SIZE)
690
+ MAGIC.copy(header, 0)
691
+ const cmdBuf = Buffer.alloc(12)
692
+ cmdBuf.write(command, 'ascii')
693
+ cmdBuf.copy(header, 4)
694
+ header.writeUInt32LE(payload.length, 16)
695
+ const checksum = sha256d(payload).subarray(0, 4)
696
+ checksum.copy(header, 20)
697
+
698
+ this._socket.write(Buffer.concat([header, payload]))
699
+ }
700
+
701
+ _sendVersion () {
702
+ const userAgentBuf = Buffer.from(USER_AGENT, 'ascii')
703
+ const payloadSize = 4 + 8 + 8 + 26 + 26 + 8 + 1 + userAgentBuf.length + 4 + 1
704
+ const payload = Buffer.alloc(payloadSize)
705
+ let offset = 0
706
+
707
+ payload.writeInt32LE(PROTOCOL_VERSION, offset); offset += 4
708
+ payload.writeBigUInt64LE(0n, offset); offset += 8
709
+ const now = BigInt(Math.floor(Date.now() / 1000))
710
+ payload.writeBigUInt64LE(now, offset); offset += 8
711
+ offset += writeNetAddr(payload, offset, 1n, this._host || '127.0.0.1', this._port || 8333)
712
+ offset += writeNetAddr(payload, offset, 0n, '0.0.0.0', 0)
713
+ randomBytes(8).copy(payload, offset); offset += 8
714
+ offset += writeVarInt(payload, offset, userAgentBuf.length)
715
+ userAgentBuf.copy(payload, offset); offset += userAgentBuf.length
716
+ payload.writeInt32LE(this._bestHeight, offset); offset += 4
717
+ payload[offset] = 0; offset += 1
718
+
719
+ this._sendMessage('version', payload.subarray(0, offset))
720
+ }
721
+
722
+ /**
723
+ * Send protoconf (protocol 70016+) — advertise max receive payload size.
724
+ */
725
+ _sendProtoconf () {
726
+ const payload = Buffer.alloc(6)
727
+ let offset = 0
728
+ payload.writeUInt8(2, offset); offset += 1 // numberOfFields
729
+ payload.writeUInt32LE(2 * 1024 * 1024, offset); offset += 4 // 2MB max
730
+ payload.writeUInt8(0, offset) // empty streamPolicies
731
+ this._sendMessage('protoconf', payload)
732
+ }
733
+
734
+ _sendGetHeaders (locatorHashes) {
735
+ const hashCount = locatorHashes.length
736
+ const varIntBuf = Buffer.alloc(9)
737
+ const varIntSize = writeVarInt(varIntBuf, 0, hashCount)
738
+
739
+ const payloadSize = 4 + varIntSize + (hashCount * 32) + 32
740
+ const payload = Buffer.alloc(payloadSize)
741
+ let offset = 0
742
+
743
+ payload.writeUInt32LE(PROTOCOL_VERSION, offset); offset += 4
744
+ varIntBuf.copy(payload, offset, 0, varIntSize); offset += varIntSize
745
+
746
+ for (const hash of locatorHashes) {
747
+ const hashBuf = hashToInternal(hash)
748
+ hashBuf.copy(payload, offset); offset += 32
749
+ }
750
+
751
+ payload.fill(0, offset, offset + 32)
752
+
753
+ this._sendMessage('getheaders', payload)
754
+ }
755
+
756
+ _sendPing () {
757
+ const payload = Buffer.alloc(8)
758
+ randomBytes(8).copy(payload)
759
+ this._sendMessage('ping', payload)
760
+ }
761
+
762
+ // ── Private: block locator ─────────────────────────────────
763
+
764
+ _buildBlockLocator () {
765
+ const hashes = []
766
+ let step = 1
767
+ let height = this._bestHeight
768
+
769
+ while (height >= this._checkpoint.height) {
770
+ const hash = this._headerHashes.get(height)
771
+ if (hash) {
772
+ hashes.push(hash)
773
+ }
774
+ if (height === this._checkpoint.height) break
775
+ height -= step
776
+ if (height < this._checkpoint.height) {
777
+ height = this._checkpoint.height
778
+ }
779
+ if (hashes.length > 10) {
780
+ step *= 2
781
+ }
782
+ }
783
+
784
+ const cpHash = this._headerHashes.get(this._checkpoint.height)
785
+ if (cpHash && hashes[hashes.length - 1] !== cpHash) {
786
+ hashes.push(cpHash)
787
+ }
788
+
789
+ return hashes
790
+ }
791
+ }