@relay-federation/bridge 0.1.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 +336 -0
- package/lib/anchor-manager.js +158 -0
- package/lib/config.js +69 -0
- package/lib/data-validator.js +205 -0
- package/lib/handshake.js +165 -0
- package/lib/header-relay.js +184 -0
- package/lib/peer-connection.js +114 -0
- package/lib/peer-health.js +172 -0
- package/lib/peer-manager.js +214 -0
- package/lib/peer-scorer.js +285 -0
- package/lib/score-actions.js +135 -0
- package/lib/status-server.js +115 -0
- package/lib/tx-relay.js +117 -0
- package/package.json +28 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
// BSV block header is 80 bytes.
|
|
5
|
+
// Timestamp bounds: reject headers more than 2 hours in the future.
|
|
6
|
+
const MAX_FUTURE_SECONDS = 2 * 60 * 60
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate a block header.
|
|
10
|
+
*
|
|
11
|
+
* Checks:
|
|
12
|
+
* - Has required fields (height, hash, prevHash)
|
|
13
|
+
* - Hash is a 64-char hex string
|
|
14
|
+
* - prevHash is a 64-char hex string
|
|
15
|
+
*
|
|
16
|
+
* Note: Full PoW validation requires raw 80-byte header data, which our
|
|
17
|
+
* HeaderRelay doesn't carry (it uses {height, hash, prevHash} objects).
|
|
18
|
+
* We validate format and chain linkage here; PoW validation is deferred
|
|
19
|
+
* to when raw headers are available.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} header — { height, hash, prevHash }
|
|
22
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function validateHeader (header) {
|
|
25
|
+
if (!header || typeof header !== 'object') {
|
|
26
|
+
return { valid: false, reason: 'not_an_object' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof header.height !== 'number' || header.height < 0) {
|
|
30
|
+
return { valid: false, reason: 'invalid_height' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof header.hash !== 'string' || !/^[0-9a-f]{64}$/i.test(header.hash)) {
|
|
34
|
+
return { valid: false, reason: 'invalid_hash' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof header.prevHash !== 'string' || !/^[0-9a-f]{64}$/i.test(header.prevHash)) {
|
|
38
|
+
return { valid: false, reason: 'invalid_prevHash' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { valid: true }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a transaction.
|
|
46
|
+
*
|
|
47
|
+
* Checks:
|
|
48
|
+
* - txid is a valid 64-char hex string
|
|
49
|
+
* - rawHex is a non-empty hex string
|
|
50
|
+
* - rawHex has minimum tx size (at least 10 bytes = 20 hex chars)
|
|
51
|
+
*
|
|
52
|
+
* @param {string} txid
|
|
53
|
+
* @param {string} rawHex
|
|
54
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
55
|
+
*/
|
|
56
|
+
export function validateTx (txid, rawHex) {
|
|
57
|
+
if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/i.test(txid)) {
|
|
58
|
+
return { valid: false, reason: 'invalid_txid' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof rawHex !== 'string' || rawHex.length < 20) {
|
|
62
|
+
return { valid: false, reason: 'invalid_raw_hex' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!/^[0-9a-f]+$/i.test(rawHex)) {
|
|
66
|
+
return { valid: false, reason: 'not_hex' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Verify txid matches hash of raw tx
|
|
70
|
+
const hash1 = createHash('sha256').update(Buffer.from(rawHex, 'hex')).digest()
|
|
71
|
+
const hash2 = createHash('sha256').update(hash1).digest()
|
|
72
|
+
const computedTxid = hash2.reverse().toString('hex')
|
|
73
|
+
|
|
74
|
+
if (computedTxid !== txid.toLowerCase()) {
|
|
75
|
+
return { valid: false, reason: 'txid_mismatch' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: true }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate header chain linkage.
|
|
83
|
+
*
|
|
84
|
+
* Checks that each header's prevHash matches the previous header's hash.
|
|
85
|
+
*
|
|
86
|
+
* @param {Array<{ height: number, hash: string, prevHash: string }>} headers — sorted by height ascending
|
|
87
|
+
* @returns {{ valid: boolean, reason?: string, invalidAt?: number }}
|
|
88
|
+
*/
|
|
89
|
+
export function validateHeaderChain (headers) {
|
|
90
|
+
if (!Array.isArray(headers) || headers.length === 0) {
|
|
91
|
+
return { valid: true } // empty chain is valid
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < headers.length; i++) {
|
|
95
|
+
const check = validateHeader(headers[i])
|
|
96
|
+
if (!check.valid) {
|
|
97
|
+
return { valid: false, reason: `header[${i}]: ${check.reason}`, invalidAt: i }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (let i = 1; i < headers.length; i++) {
|
|
102
|
+
if (headers[i].prevHash !== headers[i - 1].hash) {
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
reason: `chain_break at height ${headers[i].height}: prevHash doesn't match previous hash`,
|
|
106
|
+
invalidAt: i
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { valid: true }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* DataValidator — hooks into PeerManager events and reports
|
|
116
|
+
* data accuracy to PeerScorer.
|
|
117
|
+
*
|
|
118
|
+
* Listens for peer:message events, validates headers and txs,
|
|
119
|
+
* and calls scorer.recordGoodData/recordBadData.
|
|
120
|
+
*
|
|
121
|
+
* Emits:
|
|
122
|
+
* 'validation:fail' — { pubkeyHex, type, reason }
|
|
123
|
+
*/
|
|
124
|
+
export class DataValidator extends EventEmitter {
|
|
125
|
+
/**
|
|
126
|
+
* @param {import('./peer-manager.js').PeerManager} peerManager
|
|
127
|
+
* @param {import('./peer-scorer.js').PeerScorer} scorer
|
|
128
|
+
*/
|
|
129
|
+
constructor (peerManager, scorer) {
|
|
130
|
+
super()
|
|
131
|
+
this.peerManager = peerManager
|
|
132
|
+
this.scorer = scorer
|
|
133
|
+
|
|
134
|
+
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
135
|
+
this._onMessage(pubkeyHex, message)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_onMessage (pubkeyHex, message) {
|
|
140
|
+
switch (message.type) {
|
|
141
|
+
case 'headers':
|
|
142
|
+
this._validateHeaders(pubkeyHex, message)
|
|
143
|
+
break
|
|
144
|
+
case 'tx':
|
|
145
|
+
this._validateTx(pubkeyHex, message)
|
|
146
|
+
break
|
|
147
|
+
case 'header_announce':
|
|
148
|
+
this._validateHeaderAnnounce(pubkeyHex, message)
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_validateHeaders (pubkeyHex, msg) {
|
|
154
|
+
if (!Array.isArray(msg.headers) || msg.headers.length === 0) {
|
|
155
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
156
|
+
this.emit('validation:fail', { pubkeyHex, type: 'headers', reason: 'empty_or_missing' })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const chainCheck = validateHeaderChain(msg.headers)
|
|
161
|
+
if (!chainCheck.valid) {
|
|
162
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
163
|
+
this.emit('validation:fail', { pubkeyHex, type: 'headers', reason: chainCheck.reason })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Each valid header counts as a good data point
|
|
168
|
+
for (let i = 0; i < msg.headers.length; i++) {
|
|
169
|
+
this.scorer.recordGoodData(pubkeyHex)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_validateTx (pubkeyHex, msg) {
|
|
174
|
+
if (!msg.txid || !msg.rawHex) {
|
|
175
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
176
|
+
this.emit('validation:fail', { pubkeyHex, type: 'tx', reason: 'missing_fields' })
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const check = validateTx(msg.txid, msg.rawHex)
|
|
181
|
+
if (!check.valid) {
|
|
182
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
183
|
+
this.emit('validation:fail', { pubkeyHex, type: 'tx', reason: check.reason })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.scorer.recordGoodData(pubkeyHex)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_validateHeaderAnnounce (pubkeyHex, msg) {
|
|
191
|
+
if (typeof msg.height !== 'number' || msg.height < 0) {
|
|
192
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
193
|
+
this.emit('validation:fail', { pubkeyHex, type: 'header_announce', reason: 'invalid_height' })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeof msg.hash !== 'string' || !/^[0-9a-f]{64}$/i.test(msg.hash)) {
|
|
198
|
+
this.scorer.recordBadData(pubkeyHex)
|
|
199
|
+
this.emit('validation:fail', { pubkeyHex, type: 'header_announce', reason: 'invalid_hash' })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.scorer.recordGoodData(pubkeyHex)
|
|
204
|
+
}
|
|
205
|
+
}
|
package/lib/handshake.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { PrivateKey } from '@bsv/sdk'
|
|
3
|
+
import { signHash, verifyHash } from '@relay-federation/common/crypto'
|
|
4
|
+
import { SUPPORTED_VERSIONS, HANDSHAKE_TIMEOUT_MS } from '@relay-federation/common/protocol'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a cryptographic handshake helper.
|
|
8
|
+
*
|
|
9
|
+
* Protocol (2 round-trips):
|
|
10
|
+
* 1. Initiator → Responder: { type: "hello", pubkey, nonce, versions, endpoint }
|
|
11
|
+
* 2. Responder → Initiator: { type: "challenge_response", pubkey, nonce, signature, selected_version }
|
|
12
|
+
* 3. Initiator → Responder: { type: "verify", signature }
|
|
13
|
+
* (Responder verifies → connection established)
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} opts.wif — Our WIF private key
|
|
17
|
+
* @param {string} opts.pubkeyHex — Our compressed pubkey hex
|
|
18
|
+
* @param {string} opts.endpoint — Our advertised WSS endpoint
|
|
19
|
+
* @param {string[]} [opts.versions] — Supported protocol versions
|
|
20
|
+
* @returns {object} Handshake helper with methods
|
|
21
|
+
*/
|
|
22
|
+
export function createHandshake (opts) {
|
|
23
|
+
const privKey = PrivateKey.fromWif(opts.wif)
|
|
24
|
+
const ourPubkeyHex = opts.pubkeyHex
|
|
25
|
+
const ourEndpoint = opts.endpoint
|
|
26
|
+
const ourVersions = opts.versions || SUPPORTED_VERSIONS
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
/**
|
|
30
|
+
* Build the initial hello message (initiator side).
|
|
31
|
+
* @returns {{ message: object, nonce: string }}
|
|
32
|
+
*/
|
|
33
|
+
createHello () {
|
|
34
|
+
const nonce = randomBytes(32).toString('hex')
|
|
35
|
+
return {
|
|
36
|
+
message: {
|
|
37
|
+
type: 'hello',
|
|
38
|
+
pubkey: ourPubkeyHex,
|
|
39
|
+
nonce,
|
|
40
|
+
versions: ourVersions,
|
|
41
|
+
endpoint: ourEndpoint
|
|
42
|
+
},
|
|
43
|
+
nonce
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handle an incoming hello and produce a challenge_response (responder side).
|
|
49
|
+
*
|
|
50
|
+
* @param {object} hello — The received hello message
|
|
51
|
+
* @param {Set<string>|null} [registeredPubkeys] — Set of registered pubkeys (if null, skip registry check)
|
|
52
|
+
* @returns {{ message: object, nonce: string, peerPubkey: string, selectedVersion: string } | { error: string }}
|
|
53
|
+
*/
|
|
54
|
+
handleHello (hello, registeredPubkeys = null) {
|
|
55
|
+
if (!hello || hello.type !== 'hello' || !hello.pubkey || !hello.nonce || !hello.endpoint) {
|
|
56
|
+
return { error: 'invalid_hello' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!Array.isArray(hello.versions) || hello.versions.length === 0) {
|
|
60
|
+
return { error: 'missing_versions' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check registry
|
|
64
|
+
if (registeredPubkeys && !registeredPubkeys.has(hello.pubkey)) {
|
|
65
|
+
return { error: 'not_registered' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Version negotiation — select highest mutual version
|
|
69
|
+
const mutual = hello.versions.filter(v => ourVersions.includes(v))
|
|
70
|
+
if (mutual.length === 0) {
|
|
71
|
+
return { error: 'version_mismatch', supported: ourVersions }
|
|
72
|
+
}
|
|
73
|
+
const selectedVersion = mutual.sort().pop() // highest mutual version
|
|
74
|
+
|
|
75
|
+
// Sign the initiator's nonce to prove our identity
|
|
76
|
+
const signature = signHash(hello.nonce, privKey)
|
|
77
|
+
const responderNonce = randomBytes(32).toString('hex')
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
message: {
|
|
81
|
+
type: 'challenge_response',
|
|
82
|
+
pubkey: ourPubkeyHex,
|
|
83
|
+
nonce: responderNonce,
|
|
84
|
+
signature,
|
|
85
|
+
selected_version: selectedVersion
|
|
86
|
+
},
|
|
87
|
+
nonce: responderNonce,
|
|
88
|
+
peerPubkey: hello.pubkey,
|
|
89
|
+
selectedVersion
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verify the challenge_response and produce the verify message (initiator side).
|
|
95
|
+
*
|
|
96
|
+
* @param {object} response — The received challenge_response message
|
|
97
|
+
* @param {string} ourNonce — The nonce we sent in hello
|
|
98
|
+
* @param {Set<string>|null} [registeredPubkeys] — Set of registered pubkeys
|
|
99
|
+
* @returns {{ message: object, peerPubkey: string, selectedVersion: string } | { error: string }}
|
|
100
|
+
*/
|
|
101
|
+
handleChallengeResponse (response, ourNonce, registeredPubkeys = null) {
|
|
102
|
+
if (!response || response.type !== 'challenge_response' || !response.pubkey || !response.nonce || !response.signature) {
|
|
103
|
+
return { error: 'invalid_challenge_response' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!response.selected_version) {
|
|
107
|
+
return { error: 'missing_version' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check registry
|
|
111
|
+
if (registeredPubkeys && !registeredPubkeys.has(response.pubkey)) {
|
|
112
|
+
return { error: 'not_registered' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Verify responder signed our nonce
|
|
116
|
+
try {
|
|
117
|
+
const valid = verifyHash(ourNonce, response.signature, response.pubkey)
|
|
118
|
+
if (!valid) {
|
|
119
|
+
return { error: 'invalid_signature' }
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
return { error: 'invalid_signature' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sign responder's nonce
|
|
126
|
+
const signature = signHash(response.nonce, privKey)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
message: {
|
|
130
|
+
type: 'verify',
|
|
131
|
+
signature
|
|
132
|
+
},
|
|
133
|
+
peerPubkey: response.pubkey,
|
|
134
|
+
selectedVersion: response.selected_version
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Verify the verify message (responder side — final step).
|
|
140
|
+
*
|
|
141
|
+
* @param {object} verify — The received verify message
|
|
142
|
+
* @param {string} ourNonce — The nonce we sent in challenge_response
|
|
143
|
+
* @param {string} peerPubkeyHex — The initiator's pubkey from hello
|
|
144
|
+
* @returns {{ success: true } | { error: string }}
|
|
145
|
+
*/
|
|
146
|
+
handleVerify (verify, ourNonce, peerPubkeyHex) {
|
|
147
|
+
if (!verify || verify.type !== 'verify' || !verify.signature) {
|
|
148
|
+
return { error: 'invalid_verify' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const valid = verifyHash(ourNonce, verify.signature, peerPubkeyHex)
|
|
153
|
+
if (!valid) {
|
|
154
|
+
return { error: 'invalid_signature' }
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return { error: 'invalid_signature' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { success: true }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { SUPPORTED_VERSIONS, HANDSHAKE_TIMEOUT_MS }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HeaderRelay — syncs block headers between peers.
|
|
5
|
+
*
|
|
6
|
+
* Uses the PeerManager's message infrastructure to:
|
|
7
|
+
* - Announce our best header to new peers (triggered by hello handshake)
|
|
8
|
+
* - Request missing headers from peers that are ahead
|
|
9
|
+
* - Respond to header requests from peers that are behind
|
|
10
|
+
* - Re-announce to all peers after syncing new headers
|
|
11
|
+
*
|
|
12
|
+
* Message types:
|
|
13
|
+
* header_announce — { type, height, hash }
|
|
14
|
+
* header_request — { type, fromHeight }
|
|
15
|
+
* headers — { type, headers: [{ height, hash, prevHash }] }
|
|
16
|
+
*
|
|
17
|
+
* Events:
|
|
18
|
+
* 'header:sync' — { pubkeyHex, added, bestHeight }
|
|
19
|
+
* 'header:behind' — { pubkeyHex, theirHeight, ourHeight }
|
|
20
|
+
*/
|
|
21
|
+
export class HeaderRelay extends EventEmitter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('./peer-manager.js').PeerManager} peerManager
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {number} [opts.maxBatch=500] — Max headers per response
|
|
26
|
+
*/
|
|
27
|
+
constructor (peerManager, opts = {}) {
|
|
28
|
+
super()
|
|
29
|
+
this.peerManager = peerManager
|
|
30
|
+
/** @type {Map<number, { height: number, hash: string, prevHash: string }>} */
|
|
31
|
+
this.headers = new Map()
|
|
32
|
+
this.bestHeight = -1
|
|
33
|
+
this.bestHash = null
|
|
34
|
+
this._maxBatch = opts.maxBatch || 500
|
|
35
|
+
|
|
36
|
+
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
37
|
+
this._handleMessage(pubkeyHex, message)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add a single header to the local store.
|
|
43
|
+
* @returns {boolean} true if added, false if duplicate or invalid
|
|
44
|
+
*/
|
|
45
|
+
addHeader (header) {
|
|
46
|
+
if (this.headers.has(header.height)) return false
|
|
47
|
+
|
|
48
|
+
// Validate prevHash chain if we have the previous header
|
|
49
|
+
if (header.height > 0 && this.headers.has(header.height - 1)) {
|
|
50
|
+
const prev = this.headers.get(header.height - 1)
|
|
51
|
+
if (prev.hash !== header.prevHash) return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.headers.set(header.height, header)
|
|
55
|
+
if (header.height > this.bestHeight) {
|
|
56
|
+
this.bestHeight = header.height
|
|
57
|
+
this.bestHash = header.hash
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Add multiple headers (sorts by height first).
|
|
64
|
+
* @returns {number} count of headers added
|
|
65
|
+
*/
|
|
66
|
+
addHeaders (headers) {
|
|
67
|
+
let added = 0
|
|
68
|
+
const sorted = [...headers].sort((a, b) => a.height - b.height)
|
|
69
|
+
for (const h of sorted) {
|
|
70
|
+
if (this.addHeader(h)) added++
|
|
71
|
+
}
|
|
72
|
+
return added
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get the best (highest) header, or null. */
|
|
76
|
+
getBestHeader () {
|
|
77
|
+
if (this.bestHeight < 0) return null
|
|
78
|
+
return this.headers.get(this.bestHeight)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get header at a specific height, or null. */
|
|
82
|
+
getHeader (height) {
|
|
83
|
+
return this.headers.get(height) || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Announce our best header to all connected peers.
|
|
88
|
+
* @returns {number} peers notified
|
|
89
|
+
*/
|
|
90
|
+
announceToAll () {
|
|
91
|
+
return this.peerManager.broadcast({
|
|
92
|
+
type: 'header_announce',
|
|
93
|
+
height: this.bestHeight,
|
|
94
|
+
hash: this.bestHash
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @private */
|
|
99
|
+
_announceToPeer (pubkeyHex) {
|
|
100
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
101
|
+
if (conn) {
|
|
102
|
+
conn.send({
|
|
103
|
+
type: 'header_announce',
|
|
104
|
+
height: this.bestHeight,
|
|
105
|
+
hash: this.bestHash
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @private */
|
|
111
|
+
_handleMessage (pubkeyHex, message) {
|
|
112
|
+
switch (message.type) {
|
|
113
|
+
case 'hello':
|
|
114
|
+
// Inbound peer completed handshake — announce our best header
|
|
115
|
+
this._announceToPeer(pubkeyHex)
|
|
116
|
+
break
|
|
117
|
+
case 'header_announce':
|
|
118
|
+
this._onHeaderAnnounce(pubkeyHex, message)
|
|
119
|
+
break
|
|
120
|
+
case 'header_request':
|
|
121
|
+
this._onHeaderRequest(pubkeyHex, message)
|
|
122
|
+
break
|
|
123
|
+
case 'headers':
|
|
124
|
+
this._onHeaders(pubkeyHex, message)
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @private */
|
|
130
|
+
_onHeaderAnnounce (pubkeyHex, msg) {
|
|
131
|
+
if (msg.height > this.bestHeight) {
|
|
132
|
+
// We're behind — request missing headers
|
|
133
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
134
|
+
if (conn) {
|
|
135
|
+
conn.send({
|
|
136
|
+
type: 'header_request',
|
|
137
|
+
fromHeight: this.bestHeight + 1
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
this.emit('header:behind', {
|
|
141
|
+
pubkeyHex,
|
|
142
|
+
theirHeight: msg.height,
|
|
143
|
+
ourHeight: this.bestHeight
|
|
144
|
+
})
|
|
145
|
+
} else if (msg.height < this.bestHeight) {
|
|
146
|
+
// We're ahead — announce back so they can sync from us
|
|
147
|
+
this._announceToPeer(pubkeyHex)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @private */
|
|
152
|
+
_onHeaderRequest (pubkeyHex, msg) {
|
|
153
|
+
const headers = []
|
|
154
|
+
for (let h = msg.fromHeight; h <= this.bestHeight && headers.length < this._maxBatch; h++) {
|
|
155
|
+
const header = this.headers.get(h)
|
|
156
|
+
if (header) headers.push(header)
|
|
157
|
+
}
|
|
158
|
+
if (headers.length > 0) {
|
|
159
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
160
|
+
if (conn) {
|
|
161
|
+
conn.send({ type: 'headers', headers })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @private */
|
|
167
|
+
_onHeaders (pubkeyHex, msg) {
|
|
168
|
+
if (!Array.isArray(msg.headers)) return
|
|
169
|
+
const added = this.addHeaders(msg.headers)
|
|
170
|
+
if (added > 0) {
|
|
171
|
+
this.emit('header:sync', {
|
|
172
|
+
pubkeyHex,
|
|
173
|
+
added,
|
|
174
|
+
bestHeight: this.bestHeight
|
|
175
|
+
})
|
|
176
|
+
// Re-announce to all peers except the source
|
|
177
|
+
this.peerManager.broadcast({
|
|
178
|
+
type: 'header_announce',
|
|
179
|
+
height: this.bestHeight,
|
|
180
|
+
hash: this.bestHash
|
|
181
|
+
}, pubkeyHex)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import WebSocket from 'ws'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PeerConnection — wraps a WebSocket connection to a single peer.
|
|
6
|
+
*
|
|
7
|
+
* JSON message framing: all messages are JSON objects with a `type` field.
|
|
8
|
+
* Supports both outbound (we connect to them) and inbound (they connect to us).
|
|
9
|
+
*
|
|
10
|
+
* Events:
|
|
11
|
+
* 'message' — { type, ...payload } parsed JSON message
|
|
12
|
+
* 'open' — connection established
|
|
13
|
+
* 'close' — connection closed
|
|
14
|
+
* 'error' — connection error
|
|
15
|
+
*/
|
|
16
|
+
export class PeerConnection extends EventEmitter {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {string} opts.endpoint - WSS endpoint of the peer
|
|
20
|
+
* @param {string} opts.pubkeyHex - Peer's compressed pubkey (hex)
|
|
21
|
+
* @param {WebSocket} [opts.socket] - Existing socket (for inbound connections)
|
|
22
|
+
*/
|
|
23
|
+
constructor (opts) {
|
|
24
|
+
super()
|
|
25
|
+
this.endpoint = opts.endpoint
|
|
26
|
+
this.pubkeyHex = opts.pubkeyHex
|
|
27
|
+
this.ws = opts.socket || null
|
|
28
|
+
this.connected = false
|
|
29
|
+
this._reconnectTimer = null
|
|
30
|
+
this._reconnectDelay = 5000
|
|
31
|
+
this._maxReconnectDelay = 60000
|
|
32
|
+
this._shouldReconnect = !opts.socket // only auto-reconnect outbound
|
|
33
|
+
this._destroyed = false
|
|
34
|
+
|
|
35
|
+
if (opts.socket) {
|
|
36
|
+
this._attachListeners(opts.socket)
|
|
37
|
+
this.connected = opts.socket.readyState === WebSocket.OPEN
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Connect to the peer (outbound only). */
|
|
42
|
+
connect () {
|
|
43
|
+
if (this._destroyed) return
|
|
44
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
this.ws = new WebSocket(this.endpoint)
|
|
48
|
+
this._attachListeners(this.ws)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
this.emit('error', err)
|
|
51
|
+
this._scheduleReconnect()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send a JSON message to the peer. */
|
|
56
|
+
send (msg) {
|
|
57
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
this.ws.send(JSON.stringify(msg))
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Close the connection permanently (no reconnect). */
|
|
65
|
+
destroy () {
|
|
66
|
+
this._destroyed = true
|
|
67
|
+
this._shouldReconnect = false
|
|
68
|
+
clearTimeout(this._reconnectTimer)
|
|
69
|
+
if (this.ws) {
|
|
70
|
+
this.ws.close()
|
|
71
|
+
this.ws = null
|
|
72
|
+
}
|
|
73
|
+
this.connected = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_attachListeners (ws) {
|
|
77
|
+
ws.on('open', () => {
|
|
78
|
+
this.connected = true
|
|
79
|
+
this._reconnectDelay = 5000 // reset backoff on success
|
|
80
|
+
this.emit('open')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
ws.on('message', (data) => {
|
|
84
|
+
try {
|
|
85
|
+
const msg = JSON.parse(data.toString())
|
|
86
|
+
if (msg && typeof msg.type === 'string') {
|
|
87
|
+
this.emit('message', msg)
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// Ignore non-JSON messages
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
ws.on('close', () => {
|
|
95
|
+
this.connected = false
|
|
96
|
+
this.emit('close')
|
|
97
|
+
this._scheduleReconnect()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
ws.on('error', (err) => {
|
|
101
|
+
this.emit('error', err)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_scheduleReconnect () {
|
|
106
|
+
if (!this._shouldReconnect || this._destroyed) return
|
|
107
|
+
clearTimeout(this._reconnectTimer)
|
|
108
|
+
this._reconnectTimer = setTimeout(() => {
|
|
109
|
+
this.connect()
|
|
110
|
+
}, this._reconnectDelay)
|
|
111
|
+
// Exponential backoff, capped
|
|
112
|
+
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay)
|
|
113
|
+
}
|
|
114
|
+
}
|