@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,172 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
4
|
+
const DEFAULT_INACTIVE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Peer health tracker — monitors peer connectivity and applies
|
|
8
|
+
* grace period logic + auto-deregistration detection.
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* - Tracks last-seen timestamp for each peer
|
|
12
|
+
* - Grace period (24h): short outages don't count against peer score
|
|
13
|
+
* - Inactive threshold (7d): peers unreachable for 7+ days are flagged inactive
|
|
14
|
+
* - Inactive peers should be excluded from peer lists locally
|
|
15
|
+
*
|
|
16
|
+
* Emits:
|
|
17
|
+
* 'peer:grace' — { pubkeyHex, offlineSince } — peer entered grace period
|
|
18
|
+
* 'peer:inactive' — { pubkeyHex, offlineSince } — peer flagged as inactive (7+ days offline)
|
|
19
|
+
* 'peer:recovered' — { pubkeyHex } — peer came back online
|
|
20
|
+
*/
|
|
21
|
+
export class PeerHealth extends EventEmitter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {number} [opts.gracePeriodMs=86400000] — 24 hours
|
|
25
|
+
* @param {number} [opts.inactiveThresholdMs=604800000] — 7 days
|
|
26
|
+
*/
|
|
27
|
+
constructor (opts = {}) {
|
|
28
|
+
super()
|
|
29
|
+
this._gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS
|
|
30
|
+
this._inactiveThresholdMs = opts.inactiveThresholdMs ?? DEFAULT_INACTIVE_THRESHOLD_MS
|
|
31
|
+
|
|
32
|
+
/** @type {Map<string, { lastSeen: number, offlineSince: number|null, status: string }>} */
|
|
33
|
+
this._peers = new Map()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Record that we successfully communicated with a peer.
|
|
38
|
+
* Resets offline tracking.
|
|
39
|
+
* @param {string} pubkeyHex
|
|
40
|
+
*/
|
|
41
|
+
recordSeen (pubkeyHex) {
|
|
42
|
+
const prev = this._peers.get(pubkeyHex)
|
|
43
|
+
const wasOffline = prev && prev.offlineSince !== null
|
|
44
|
+
|
|
45
|
+
this._peers.set(pubkeyHex, {
|
|
46
|
+
lastSeen: Date.now(),
|
|
47
|
+
offlineSince: null,
|
|
48
|
+
status: 'online'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (wasOffline) {
|
|
52
|
+
this.emit('peer:recovered', { pubkeyHex })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record that a peer has disconnected or failed to respond.
|
|
58
|
+
* Starts the offline timer if not already started.
|
|
59
|
+
* @param {string} pubkeyHex
|
|
60
|
+
*/
|
|
61
|
+
recordOffline (pubkeyHex) {
|
|
62
|
+
const existing = this._peers.get(pubkeyHex)
|
|
63
|
+
|
|
64
|
+
if (existing && existing.offlineSince !== null) {
|
|
65
|
+
// Already tracking offline — don't reset the timer
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this._peers.set(pubkeyHex, {
|
|
70
|
+
lastSeen: existing ? existing.lastSeen : 0,
|
|
71
|
+
offlineSince: Date.now(),
|
|
72
|
+
status: 'offline'
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check the health status of a peer.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} pubkeyHex
|
|
80
|
+
* @returns {'online'|'grace'|'inactive'|'unknown'}
|
|
81
|
+
*/
|
|
82
|
+
getStatus (pubkeyHex) {
|
|
83
|
+
const peer = this._peers.get(pubkeyHex)
|
|
84
|
+
if (!peer) return 'unknown'
|
|
85
|
+
if (peer.offlineSince === null) return 'online'
|
|
86
|
+
|
|
87
|
+
const offlineDuration = Date.now() - peer.offlineSince
|
|
88
|
+
|
|
89
|
+
if (offlineDuration >= this._inactiveThresholdMs) {
|
|
90
|
+
return 'inactive'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (offlineDuration >= this._gracePeriodMs) {
|
|
94
|
+
return 'offline'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return 'grace'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check all peers and emit events for status changes.
|
|
102
|
+
* Call this periodically (e.g. every 10 minutes).
|
|
103
|
+
*
|
|
104
|
+
* @returns {{ grace: string[], inactive: string[] }} Lists of pubkeys in each state
|
|
105
|
+
*/
|
|
106
|
+
checkAll () {
|
|
107
|
+
const grace = []
|
|
108
|
+
const inactive = []
|
|
109
|
+
|
|
110
|
+
for (const [pubkeyHex, peer] of this._peers) {
|
|
111
|
+
if (peer.offlineSince === null) continue
|
|
112
|
+
|
|
113
|
+
const status = this.getStatus(pubkeyHex)
|
|
114
|
+
const prevStatus = peer.status
|
|
115
|
+
|
|
116
|
+
if (status === 'inactive' && prevStatus !== 'inactive') {
|
|
117
|
+
peer.status = 'inactive'
|
|
118
|
+
inactive.push(pubkeyHex)
|
|
119
|
+
this.emit('peer:inactive', { pubkeyHex, offlineSince: peer.offlineSince })
|
|
120
|
+
} else if (status === 'grace' && prevStatus !== 'grace') {
|
|
121
|
+
peer.status = 'grace'
|
|
122
|
+
grace.push(pubkeyHex)
|
|
123
|
+
this.emit('peer:grace', { pubkeyHex, offlineSince: peer.offlineSince })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { grace, inactive }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get the last-seen timestamp for a peer.
|
|
132
|
+
* @param {string} pubkeyHex
|
|
133
|
+
* @returns {number|null} Unix timestamp in ms, or null if unknown
|
|
134
|
+
*/
|
|
135
|
+
getLastSeen (pubkeyHex) {
|
|
136
|
+
const peer = this._peers.get(pubkeyHex)
|
|
137
|
+
return peer ? peer.lastSeen : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get all peers flagged as inactive (7+ days offline).
|
|
142
|
+
* @returns {string[]} Array of pubkeyHex strings
|
|
143
|
+
*/
|
|
144
|
+
getInactivePeers () {
|
|
145
|
+
const result = []
|
|
146
|
+
for (const [pubkeyHex] of this._peers) {
|
|
147
|
+
if (this.getStatus(pubkeyHex) === 'inactive') {
|
|
148
|
+
result.push(pubkeyHex)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if scoring impact should be suppressed (grace period active).
|
|
156
|
+
* During grace period, bad pings should NOT count against the peer.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} pubkeyHex
|
|
159
|
+
* @returns {boolean} true if peer is in grace period (suppress scoring)
|
|
160
|
+
*/
|
|
161
|
+
isInGracePeriod (pubkeyHex) {
|
|
162
|
+
return this.getStatus(pubkeyHex) === 'grace'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove a peer from health tracking.
|
|
167
|
+
* @param {string} pubkeyHex
|
|
168
|
+
*/
|
|
169
|
+
removePeer (pubkeyHex) {
|
|
170
|
+
this._peers.delete(pubkeyHex)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { WebSocketServer } from 'ws'
|
|
3
|
+
import { PeerConnection } from './peer-connection.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PeerManager — manages multiple peer connections.
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Outbound connections to discovered peers
|
|
10
|
+
* - Inbound connections from other bridges (via WSS server)
|
|
11
|
+
* - Broadcasting messages to all connected peers
|
|
12
|
+
* - Peer lifecycle (connect, disconnect, reconnect)
|
|
13
|
+
*
|
|
14
|
+
* Events:
|
|
15
|
+
* 'peer:connect' — { pubkeyHex, endpoint }
|
|
16
|
+
* 'peer:disconnect' — { pubkeyHex, endpoint }
|
|
17
|
+
* 'peer:message' — { pubkeyHex, message }
|
|
18
|
+
* 'peer:error' — { pubkeyHex, error }
|
|
19
|
+
*/
|
|
20
|
+
export class PeerManager extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {number} [opts.maxPeers=20] - Maximum number of peer connections
|
|
24
|
+
*/
|
|
25
|
+
constructor (opts = {}) {
|
|
26
|
+
super()
|
|
27
|
+
this.maxPeers = opts.maxPeers || 20
|
|
28
|
+
/** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
|
|
29
|
+
this.peers = new Map()
|
|
30
|
+
this._server = null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Connect to a discovered peer (outbound).
|
|
35
|
+
*
|
|
36
|
+
* @param {object} peer - Peer from buildPeerList()
|
|
37
|
+
* @param {string} peer.pubkeyHex
|
|
38
|
+
* @param {string} peer.endpoint
|
|
39
|
+
* @returns {PeerConnection|null} The connection, or null if at capacity
|
|
40
|
+
*/
|
|
41
|
+
connectToPeer (peer) {
|
|
42
|
+
if (this.peers.has(peer.pubkeyHex)) {
|
|
43
|
+
return this.peers.get(peer.pubkeyHex)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.peers.size >= this.maxPeers) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const conn = new PeerConnection({
|
|
51
|
+
endpoint: peer.endpoint,
|
|
52
|
+
pubkeyHex: peer.pubkeyHex
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
this._attachPeerEvents(conn)
|
|
56
|
+
this.peers.set(peer.pubkeyHex, conn)
|
|
57
|
+
conn.connect()
|
|
58
|
+
|
|
59
|
+
return conn
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Accept an inbound peer connection.
|
|
64
|
+
*
|
|
65
|
+
* @param {WebSocket} socket - Incoming WebSocket
|
|
66
|
+
* @param {string} pubkeyHex - Peer's pubkey (from handshake)
|
|
67
|
+
* @param {string} endpoint - Peer's advertised endpoint
|
|
68
|
+
* @returns {PeerConnection|null}
|
|
69
|
+
*/
|
|
70
|
+
acceptPeer (socket, pubkeyHex, endpoint) {
|
|
71
|
+
if (this.peers.has(pubkeyHex)) {
|
|
72
|
+
// Already connected — close the duplicate
|
|
73
|
+
socket.close()
|
|
74
|
+
return this.peers.get(pubkeyHex)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (this.peers.size >= this.maxPeers) {
|
|
78
|
+
socket.close()
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const conn = new PeerConnection({
|
|
83
|
+
endpoint,
|
|
84
|
+
pubkeyHex,
|
|
85
|
+
socket
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
this._attachPeerEvents(conn)
|
|
89
|
+
this.peers.set(pubkeyHex, conn)
|
|
90
|
+
|
|
91
|
+
return conn
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Disconnect a specific peer.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} pubkeyHex
|
|
98
|
+
*/
|
|
99
|
+
disconnectPeer (pubkeyHex) {
|
|
100
|
+
const conn = this.peers.get(pubkeyHex)
|
|
101
|
+
if (conn) {
|
|
102
|
+
conn.destroy()
|
|
103
|
+
this.peers.delete(pubkeyHex)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Broadcast a message to all connected peers.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} msg - JSON message with `type` field
|
|
111
|
+
* @param {string} [excludePubkey] - Optional pubkey to exclude (e.g. message source)
|
|
112
|
+
* @returns {number} Number of peers the message was sent to
|
|
113
|
+
*/
|
|
114
|
+
broadcast (msg, excludePubkey) {
|
|
115
|
+
let sent = 0
|
|
116
|
+
for (const [pubkeyHex, conn] of this.peers) {
|
|
117
|
+
if (pubkeyHex === excludePubkey) continue
|
|
118
|
+
if (conn.send(msg)) sent++
|
|
119
|
+
}
|
|
120
|
+
return sent
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get count of currently connected peers.
|
|
125
|
+
* @returns {number}
|
|
126
|
+
*/
|
|
127
|
+
connectedCount () {
|
|
128
|
+
let count = 0
|
|
129
|
+
for (const conn of this.peers.values()) {
|
|
130
|
+
if (conn.connected) count++
|
|
131
|
+
}
|
|
132
|
+
return count
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Start a WebSocket server for inbound connections.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {number} opts.port - Port to listen on
|
|
140
|
+
* @param {string} [opts.host='0.0.0.0'] - Host to bind to
|
|
141
|
+
* @returns {Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
startServer (opts) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
this._server = new WebSocketServer({
|
|
146
|
+
port: opts.port,
|
|
147
|
+
host: opts.host || '0.0.0.0'
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
this._server.on('listening', () => resolve())
|
|
151
|
+
this._server.on('error', (err) => reject(err))
|
|
152
|
+
|
|
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.
|
|
156
|
+
const timeout = setTimeout(() => {
|
|
157
|
+
ws.close() // No hello within 10 seconds
|
|
158
|
+
}, 10000)
|
|
159
|
+
|
|
160
|
+
ws.once('message', (data) => {
|
|
161
|
+
clearTimeout(timeout)
|
|
162
|
+
try {
|
|
163
|
+
const msg = JSON.parse(data.toString())
|
|
164
|
+
if (msg.type === 'hello' && msg.pubkey && msg.endpoint) {
|
|
165
|
+
const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
|
|
166
|
+
if (conn) {
|
|
167
|
+
this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
|
|
168
|
+
// Forward the hello as a regular message too
|
|
169
|
+
conn.emit('message', msg)
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
ws.close() // Invalid hello
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
ws.close() // Invalid JSON
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stop the WebSocket server and disconnect all peers.
|
|
184
|
+
*/
|
|
185
|
+
async shutdown () {
|
|
186
|
+
for (const [pubkeyHex, conn] of this.peers) {
|
|
187
|
+
conn.destroy()
|
|
188
|
+
}
|
|
189
|
+
this.peers.clear()
|
|
190
|
+
|
|
191
|
+
if (this._server) {
|
|
192
|
+
await new Promise(resolve => this._server.close(resolve))
|
|
193
|
+
this._server = null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_attachPeerEvents (conn) {
|
|
198
|
+
conn.on('open', () => {
|
|
199
|
+
this.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
conn.on('message', (msg) => {
|
|
203
|
+
this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
conn.on('close', () => {
|
|
207
|
+
this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
conn.on('error', (err) => {
|
|
211
|
+
this.emit('peer:error', { pubkeyHex: conn.pubkeyHex, error: err })
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
const WEIGHTS = {
|
|
4
|
+
uptime: 0.3,
|
|
5
|
+
responseTime: 0.2,
|
|
6
|
+
dataAccuracy: 0.4,
|
|
7
|
+
stakeAge: 0.1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Response time normalization: 1.0 = <100ms, 0.0 = >5000ms
|
|
11
|
+
const RT_FLOOR = 100
|
|
12
|
+
const RT_CEIL = 5000
|
|
13
|
+
|
|
14
|
+
// Stake age normalization: log2(days) / 10, capped at 1.0
|
|
15
|
+
const STAKE_AGE_DIVISOR = 10
|
|
16
|
+
|
|
17
|
+
// Rolling windows
|
|
18
|
+
const DEFAULT_ACCURACY_WINDOW = 1000
|
|
19
|
+
const DEFAULT_UPTIME_WINDOW = 1000 // ~7 days at 10-min ping intervals
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-peer metrics bucket.
|
|
23
|
+
*/
|
|
24
|
+
function createMetrics () {
|
|
25
|
+
return {
|
|
26
|
+
// Uptime tracking
|
|
27
|
+
pings: 0,
|
|
28
|
+
pongs: 0,
|
|
29
|
+
|
|
30
|
+
// Response time — rolling average
|
|
31
|
+
latencies: [],
|
|
32
|
+
|
|
33
|
+
// Data accuracy — rolling window of booleans (true = good)
|
|
34
|
+
accuracyLog: [],
|
|
35
|
+
|
|
36
|
+
// Stake age in days
|
|
37
|
+
stakeAgeDays: 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Peer scoring engine.
|
|
43
|
+
*
|
|
44
|
+
* Computes local reputation scores for each connected peer.
|
|
45
|
+
* Formula: 0.3 * uptime + 0.2 * response_time + 0.4 * data_accuracy + 0.1 * stake_age
|
|
46
|
+
*
|
|
47
|
+
* Emits:
|
|
48
|
+
* 'score:update' — { pubkeyHex, score, metrics }
|
|
49
|
+
*/
|
|
50
|
+
export class PeerScorer extends EventEmitter {
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} [opts]
|
|
53
|
+
* @param {number} [opts.accuracyWindow=1000] — rolling window size for data accuracy
|
|
54
|
+
* @param {number} [opts.uptimeWindow=1000] — rolling window size for uptime pings
|
|
55
|
+
* @param {number} [opts.latencyWindow=100] — rolling window size for latency samples
|
|
56
|
+
*/
|
|
57
|
+
constructor (opts = {}) {
|
|
58
|
+
super()
|
|
59
|
+
this._peers = new Map() // pubkeyHex → metrics
|
|
60
|
+
this._accuracyWindow = opts.accuracyWindow || DEFAULT_ACCURACY_WINDOW
|
|
61
|
+
this._uptimeWindow = opts.uptimeWindow || DEFAULT_UPTIME_WINDOW
|
|
62
|
+
this._latencyWindow = opts.latencyWindow || 100
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get or create metrics for a peer.
|
|
67
|
+
* @param {string} pubkeyHex
|
|
68
|
+
* @returns {object}
|
|
69
|
+
*/
|
|
70
|
+
_getMetrics (pubkeyHex) {
|
|
71
|
+
if (!this._peers.has(pubkeyHex)) {
|
|
72
|
+
this._peers.set(pubkeyHex, createMetrics())
|
|
73
|
+
}
|
|
74
|
+
return this._peers.get(pubkeyHex)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Record a successful ping response.
|
|
79
|
+
* @param {string} pubkeyHex
|
|
80
|
+
* @param {number} latencyMs — round-trip time in milliseconds
|
|
81
|
+
*/
|
|
82
|
+
recordPing (pubkeyHex, latencyMs) {
|
|
83
|
+
const m = this._getMetrics(pubkeyHex)
|
|
84
|
+
m.pings++
|
|
85
|
+
m.pongs++
|
|
86
|
+
|
|
87
|
+
// Trim uptime window
|
|
88
|
+
if (m.pings > this._uptimeWindow) {
|
|
89
|
+
// Approximate: scale down proportionally
|
|
90
|
+
const ratio = m.pongs / m.pings
|
|
91
|
+
m.pings = this._uptimeWindow
|
|
92
|
+
m.pongs = Math.round(ratio * this._uptimeWindow)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Record latency
|
|
96
|
+
m.latencies.push(latencyMs)
|
|
97
|
+
if (m.latencies.length > this._latencyWindow) {
|
|
98
|
+
m.latencies.shift()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this._emitUpdate(pubkeyHex)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Record a ping timeout (no response).
|
|
106
|
+
* @param {string} pubkeyHex
|
|
107
|
+
*/
|
|
108
|
+
recordPingTimeout (pubkeyHex) {
|
|
109
|
+
const m = this._getMetrics(pubkeyHex)
|
|
110
|
+
m.pings++
|
|
111
|
+
|
|
112
|
+
// Trim uptime window
|
|
113
|
+
if (m.pings > this._uptimeWindow) {
|
|
114
|
+
const ratio = m.pongs / m.pings
|
|
115
|
+
m.pings = this._uptimeWindow
|
|
116
|
+
m.pongs = Math.round(ratio * this._uptimeWindow)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._emitUpdate(pubkeyHex)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Record a valid data relay (good header or tx).
|
|
124
|
+
* @param {string} pubkeyHex
|
|
125
|
+
*/
|
|
126
|
+
recordGoodData (pubkeyHex) {
|
|
127
|
+
const m = this._getMetrics(pubkeyHex)
|
|
128
|
+
m.accuracyLog.push(true)
|
|
129
|
+
if (m.accuracyLog.length > this._accuracyWindow) {
|
|
130
|
+
m.accuracyLog.shift()
|
|
131
|
+
}
|
|
132
|
+
this._emitUpdate(pubkeyHex)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Record an invalid data relay (bad header or tx).
|
|
137
|
+
* @param {string} pubkeyHex
|
|
138
|
+
*/
|
|
139
|
+
recordBadData (pubkeyHex) {
|
|
140
|
+
const m = this._getMetrics(pubkeyHex)
|
|
141
|
+
m.accuracyLog.push(false)
|
|
142
|
+
if (m.accuracyLog.length > this._accuracyWindow) {
|
|
143
|
+
m.accuracyLog.shift()
|
|
144
|
+
}
|
|
145
|
+
this._emitUpdate(pubkeyHex)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Set the stake bond age for a peer.
|
|
150
|
+
* @param {string} pubkeyHex
|
|
151
|
+
* @param {number} days — age of stake bond in days
|
|
152
|
+
*/
|
|
153
|
+
setStakeAge (pubkeyHex, days) {
|
|
154
|
+
const m = this._getMetrics(pubkeyHex)
|
|
155
|
+
m.stakeAgeDays = days
|
|
156
|
+
this._emitUpdate(pubkeyHex)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compute the uptime sub-score (0-1).
|
|
161
|
+
* @param {object} m — metrics
|
|
162
|
+
* @returns {number}
|
|
163
|
+
*/
|
|
164
|
+
_computeUptime (m) {
|
|
165
|
+
if (m.pings === 0) return 0.5 // neutral when no data
|
|
166
|
+
return m.pongs / m.pings
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compute the response time sub-score (0-1).
|
|
171
|
+
* 1.0 = <= 100ms, 0.0 = >= 5000ms, linear between.
|
|
172
|
+
* @param {object} m — metrics
|
|
173
|
+
* @returns {number}
|
|
174
|
+
*/
|
|
175
|
+
_computeResponseTime (m) {
|
|
176
|
+
if (m.latencies.length === 0) return 0.5 // neutral when no data
|
|
177
|
+
const avg = m.latencies.reduce((a, b) => a + b, 0) / m.latencies.length
|
|
178
|
+
if (avg <= RT_FLOOR) return 1.0
|
|
179
|
+
if (avg >= RT_CEIL) return 0.0
|
|
180
|
+
return 1.0 - (avg - RT_FLOOR) / (RT_CEIL - RT_FLOOR)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Compute the data accuracy sub-score (0-1).
|
|
185
|
+
* @param {object} m — metrics
|
|
186
|
+
* @returns {number}
|
|
187
|
+
*/
|
|
188
|
+
_computeDataAccuracy (m) {
|
|
189
|
+
if (m.accuracyLog.length === 0) return 0.5 // neutral when no data
|
|
190
|
+
const good = m.accuracyLog.filter(Boolean).length
|
|
191
|
+
return good / m.accuracyLog.length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compute the stake age sub-score (0-1).
|
|
196
|
+
* Formula: log2(days) / 10, capped at 1.0.
|
|
197
|
+
* @param {object} m — metrics
|
|
198
|
+
* @returns {number}
|
|
199
|
+
*/
|
|
200
|
+
_computeStakeAge (m) {
|
|
201
|
+
if (m.stakeAgeDays <= 0) return 0
|
|
202
|
+
return Math.min(1.0, Math.log2(m.stakeAgeDays) / STAKE_AGE_DIVISOR)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the composite score for a peer.
|
|
207
|
+
* @param {string} pubkeyHex
|
|
208
|
+
* @returns {number} 0-1
|
|
209
|
+
*/
|
|
210
|
+
getScore (pubkeyHex) {
|
|
211
|
+
if (!this._peers.has(pubkeyHex)) return 0.5 // unknown peer = neutral
|
|
212
|
+
|
|
213
|
+
const m = this._peers.get(pubkeyHex)
|
|
214
|
+
const uptime = this._computeUptime(m)
|
|
215
|
+
const responseTime = this._computeResponseTime(m)
|
|
216
|
+
const dataAccuracy = this._computeDataAccuracy(m)
|
|
217
|
+
const stakeAge = this._computeStakeAge(m)
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
WEIGHTS.uptime * uptime +
|
|
221
|
+
WEIGHTS.responseTime * responseTime +
|
|
222
|
+
WEIGHTS.dataAccuracy * dataAccuracy +
|
|
223
|
+
WEIGHTS.stakeAge * stakeAge
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get all sub-scores and raw metrics for a peer.
|
|
229
|
+
* @param {string} pubkeyHex
|
|
230
|
+
* @returns {object|null}
|
|
231
|
+
*/
|
|
232
|
+
getMetrics (pubkeyHex) {
|
|
233
|
+
if (!this._peers.has(pubkeyHex)) return null
|
|
234
|
+
|
|
235
|
+
const m = this._peers.get(pubkeyHex)
|
|
236
|
+
return {
|
|
237
|
+
uptime: this._computeUptime(m),
|
|
238
|
+
responseTime: this._computeResponseTime(m),
|
|
239
|
+
dataAccuracy: this._computeDataAccuracy(m),
|
|
240
|
+
stakeAge: this._computeStakeAge(m),
|
|
241
|
+
score: this.getScore(pubkeyHex),
|
|
242
|
+
raw: {
|
|
243
|
+
pings: m.pings,
|
|
244
|
+
pongs: m.pongs,
|
|
245
|
+
latencySamples: m.latencies.length,
|
|
246
|
+
avgLatencyMs: m.latencies.length > 0
|
|
247
|
+
? Math.round(m.latencies.reduce((a, b) => a + b, 0) / m.latencies.length)
|
|
248
|
+
: null,
|
|
249
|
+
accuracySamples: m.accuracyLog.length,
|
|
250
|
+
stakeAgeDays: m.stakeAgeDays
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get scores for all tracked peers.
|
|
257
|
+
* @returns {Map<string, number>}
|
|
258
|
+
*/
|
|
259
|
+
getAllScores () {
|
|
260
|
+
const scores = new Map()
|
|
261
|
+
for (const pubkeyHex of this._peers.keys()) {
|
|
262
|
+
scores.set(pubkeyHex, this.getScore(pubkeyHex))
|
|
263
|
+
}
|
|
264
|
+
return scores
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Remove a peer from tracking.
|
|
269
|
+
* @param {string} pubkeyHex
|
|
270
|
+
*/
|
|
271
|
+
removePeer (pubkeyHex) {
|
|
272
|
+
this._peers.delete(pubkeyHex)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Emit a score update event.
|
|
277
|
+
* @param {string} pubkeyHex
|
|
278
|
+
*/
|
|
279
|
+
_emitUpdate (pubkeyHex) {
|
|
280
|
+
this.emit('score:update', {
|
|
281
|
+
pubkeyHex,
|
|
282
|
+
score: this.getScore(pubkeyHex)
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|