@relay-federation/bridge 0.1.2 → 0.3.0
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 +753 -74
- package/dashboard/index.html +2212 -0
- package/lib/actions.js +373 -0
- package/lib/address-scanner.js +169 -0
- package/lib/address-watcher.js +161 -0
- package/lib/bsv-node-client.js +311 -0
- package/lib/bsv-peer.js +791 -0
- package/lib/config.js +17 -2
- package/lib/data-validator.js +6 -0
- package/lib/endpoint-probe.js +45 -0
- package/lib/gossip.js +266 -0
- package/lib/header-relay.js +6 -1
- package/lib/ip-diversity.js +88 -0
- package/lib/output-parser.js +494 -0
- package/lib/peer-manager.js +81 -12
- package/lib/persistent-store.js +708 -0
- package/lib/status-server.js +963 -14
- package/package.json +4 -2
package/lib/bsv-peer.js
ADDED
|
@@ -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
|
+
}
|