@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.
- package/cli.js +753 -73
- 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 +32 -4
- 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 +965 -14
- package/package.json +4 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { resolve4 } from 'node:dns/promises'
|
|
3
|
+
import { BSVPeer } from './bsv-peer.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BSVNodeClient — multi-peer pool manager for BSV P2P connections.
|
|
7
|
+
*
|
|
8
|
+
* Manages a pool of BSVPeer connections for redundancy:
|
|
9
|
+
* - DNS-only peer discovery (3 seeds, no WoC dependency)
|
|
10
|
+
* - Connects to multiple BSV nodes simultaneously
|
|
11
|
+
* - Broadcasts transactions to ALL connected peers
|
|
12
|
+
* - Fetches transactions from first available peer
|
|
13
|
+
* - Maintains peer pool with periodic health checks
|
|
14
|
+
*
|
|
15
|
+
* Ported from production Indelible SPV bridge (spv-client.js)
|
|
16
|
+
* peer management, adapted for the open protocol (no third-party APIs).
|
|
17
|
+
*
|
|
18
|
+
* Events (proxied from all peers):
|
|
19
|
+
* 'headers' — { headers, count }
|
|
20
|
+
* 'connected' — { host, port }
|
|
21
|
+
* 'handshake' — { version, userAgent, startHeight }
|
|
22
|
+
* 'disconnected' — { host, port }
|
|
23
|
+
* 'error' — Error
|
|
24
|
+
* 'tx' — { txid, rawHex }
|
|
25
|
+
* 'tx:inv' — { txids }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DEFAULT_SEEDS = [
|
|
29
|
+
'seed.bitcoinsv.io',
|
|
30
|
+
'seed.satoshisvision.network',
|
|
31
|
+
'seed.cascharia.com'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const DEFAULT_PORT = 8333
|
|
35
|
+
const MAINTAIN_INTERVAL_MS = 60000
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CHECKPOINT = {
|
|
38
|
+
height: 930000,
|
|
39
|
+
hash: '00000000000000001c2e04e4375cfa4b46588aa27795b2c7f8d4d34cb568a382',
|
|
40
|
+
prevHash: '000000000000000015ec9abde40c7537fc422e5af81b6028ac376d7cf23bd0c8'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class BSVNodeClient extends EventEmitter {
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {string[]} [opts.seeds] — DNS seeds (default: 3 BSV seeds)
|
|
47
|
+
* @param {number} [opts.port] — BSV node port (default 8333)
|
|
48
|
+
* @param {{ height, hash, prevHash }} [opts.checkpoint] — Starting checkpoint
|
|
49
|
+
* @param {number} [opts.syncIntervalMs] — Header sync interval (default 30s)
|
|
50
|
+
* @param {number} [opts.pingIntervalMs] — Keepalive interval (default 120s)
|
|
51
|
+
*/
|
|
52
|
+
constructor (opts = {}) {
|
|
53
|
+
super()
|
|
54
|
+
this._seeds = opts.seeds || DEFAULT_SEEDS
|
|
55
|
+
this._port = opts.port || DEFAULT_PORT
|
|
56
|
+
this._checkpoint = opts.checkpoint || DEFAULT_CHECKPOINT
|
|
57
|
+
this._syncIntervalMs = opts.syncIntervalMs || 30000
|
|
58
|
+
this._pingIntervalMs = opts.pingIntervalMs || 120000
|
|
59
|
+
|
|
60
|
+
/** @type {Map<string, BSVPeer>} host → peer */
|
|
61
|
+
this._peers = new Map()
|
|
62
|
+
this._destroyed = false
|
|
63
|
+
this._maintainTimer = null
|
|
64
|
+
|
|
65
|
+
// Track best height across all peers
|
|
66
|
+
this._bestHeight = this._checkpoint.height
|
|
67
|
+
this._bestHash = this._checkpoint.hash
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Discover BSV nodes via DNS seeds and connect to all discovered peers.
|
|
72
|
+
* Emits 'connected' and 'handshake' events as peers come online.
|
|
73
|
+
* Does not block — connections established in background.
|
|
74
|
+
*/
|
|
75
|
+
async connect () {
|
|
76
|
+
if (this._destroyed) return
|
|
77
|
+
|
|
78
|
+
const addresses = await this._discoverPeers()
|
|
79
|
+
|
|
80
|
+
// Shuffle for load distribution
|
|
81
|
+
for (let i = addresses.length - 1; i > 0; i--) {
|
|
82
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
83
|
+
[addresses[i], addresses[j]] = [addresses[j], addresses[i]]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Connect to all discovered peers
|
|
87
|
+
for (const addr of addresses) {
|
|
88
|
+
this._connectToPeer(addr.host, addr.port)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Start maintenance timer
|
|
92
|
+
this._maintainTimer = setInterval(() => this._maintainPeers(), MAINTAIN_INTERVAL_MS)
|
|
93
|
+
if (this._maintainTimer.unref) this._maintainTimer.unref()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Disconnect all peers and stop maintenance.
|
|
98
|
+
*/
|
|
99
|
+
disconnect () {
|
|
100
|
+
this._destroyed = true
|
|
101
|
+
clearInterval(this._maintainTimer)
|
|
102
|
+
for (const peer of this._peers.values()) {
|
|
103
|
+
peer.disconnect()
|
|
104
|
+
}
|
|
105
|
+
this._peers.clear()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Broadcast a raw transaction to ALL connected peers.
|
|
110
|
+
* @param {string} rawTxHex
|
|
111
|
+
* @returns {string} txid
|
|
112
|
+
*/
|
|
113
|
+
broadcastTx (rawTxHex) {
|
|
114
|
+
let txid = null
|
|
115
|
+
for (const peer of this._peers.values()) {
|
|
116
|
+
if (peer._handshakeComplete) {
|
|
117
|
+
txid = peer.broadcastTx(rawTxHex)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return txid
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fetch a transaction from the first available peer.
|
|
125
|
+
* @param {string} txid
|
|
126
|
+
* @param {number} [timeoutMs=10000]
|
|
127
|
+
* @returns {Promise<{ txid, rawHex }>}
|
|
128
|
+
*/
|
|
129
|
+
getTx (txid, timeoutMs = 10000) {
|
|
130
|
+
for (const peer of this._peers.values()) {
|
|
131
|
+
if (peer._handshakeComplete) {
|
|
132
|
+
return peer.getTx(txid, timeoutMs)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Promise.reject(new Error('not connected to BSV node'))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Trigger header sync on connected peers.
|
|
140
|
+
*/
|
|
141
|
+
syncHeaders () {
|
|
142
|
+
for (const peer of this._peers.values()) {
|
|
143
|
+
if (peer._handshakeComplete) {
|
|
144
|
+
peer.syncHeaders()
|
|
145
|
+
break // sync from one peer at a time
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Seed a header hash to all peers.
|
|
152
|
+
* @param {number} height
|
|
153
|
+
* @param {string} hash
|
|
154
|
+
*/
|
|
155
|
+
seedHeader (height, hash) {
|
|
156
|
+
for (const peer of this._peers.values()) {
|
|
157
|
+
peer.seedHeader(height, hash)
|
|
158
|
+
}
|
|
159
|
+
if (height > this._bestHeight) {
|
|
160
|
+
this._bestHeight = height
|
|
161
|
+
this._bestHash = hash
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Best synced height across all peers */
|
|
166
|
+
get bestHeight () { return this._bestHeight }
|
|
167
|
+
/** Best synced hash */
|
|
168
|
+
get bestHash () { return this._bestHash }
|
|
169
|
+
|
|
170
|
+
/** Number of peers with completed handshake */
|
|
171
|
+
get connectedCount () {
|
|
172
|
+
let count = 0
|
|
173
|
+
for (const peer of this._peers.values()) {
|
|
174
|
+
if (peer._handshakeComplete) count++
|
|
175
|
+
}
|
|
176
|
+
return count
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** List of connected peers with status info */
|
|
180
|
+
get peerList () {
|
|
181
|
+
const list = []
|
|
182
|
+
for (const [host, peer] of this._peers) {
|
|
183
|
+
list.push({
|
|
184
|
+
host,
|
|
185
|
+
connected: peer._connected,
|
|
186
|
+
handshake: peer._handshakeComplete,
|
|
187
|
+
bestHeight: peer._bestHeight,
|
|
188
|
+
userAgent: peer._peerUserAgent
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return list
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Private: peer discovery ────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Discover BSV node IPs from DNS seeds.
|
|
198
|
+
* No WoC, no third-party APIs — pure DNS.
|
|
199
|
+
*/
|
|
200
|
+
async _discoverPeers () {
|
|
201
|
+
const seen = new Set()
|
|
202
|
+
const peers = []
|
|
203
|
+
|
|
204
|
+
for (const seed of this._seeds) {
|
|
205
|
+
try {
|
|
206
|
+
const addrs = await resolve4(seed)
|
|
207
|
+
for (const addr of addrs) {
|
|
208
|
+
if (!seen.has(addr)) {
|
|
209
|
+
seen.add(addr)
|
|
210
|
+
peers.push({ host: addr, port: this._port })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// DNS resolution failed for this seed — try others
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return peers
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Private: peer management ───────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Connect to a single BSV peer. Fire-and-forget.
|
|
225
|
+
* @param {string} host
|
|
226
|
+
* @param {number} port
|
|
227
|
+
*/
|
|
228
|
+
async _connectToPeer (host, port) {
|
|
229
|
+
if (this._peers.has(host) || this._destroyed) return
|
|
230
|
+
|
|
231
|
+
const peer = new BSVPeer({
|
|
232
|
+
checkpoint: this._checkpoint,
|
|
233
|
+
syncIntervalMs: this._syncIntervalMs,
|
|
234
|
+
pingIntervalMs: this._pingIntervalMs
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
this._peers.set(host, peer)
|
|
238
|
+
|
|
239
|
+
// Wire events — proxy to callers
|
|
240
|
+
peer.on('headers', (data) => {
|
|
241
|
+
// Update pool best height
|
|
242
|
+
for (const h of data.headers) {
|
|
243
|
+
if (h.height > this._bestHeight) {
|
|
244
|
+
this._bestHeight = h.height
|
|
245
|
+
this._bestHash = h.hash
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.emit('headers', data)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
peer.on('connected', (data) => this.emit('connected', data))
|
|
252
|
+
peer.on('handshake', (data) => {
|
|
253
|
+
// Ask this peer for addresses of other nodes it knows
|
|
254
|
+
peer.requestAddr()
|
|
255
|
+
this.emit('handshake', data)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
peer.on('addr', ({ addrs }) => {
|
|
259
|
+
for (const addr of addrs) {
|
|
260
|
+
if (!this._peers.has(addr.host) && !this._destroyed) {
|
|
261
|
+
this._connectToPeer(addr.host, addr.port)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
peer.on('disconnected', (data) => {
|
|
267
|
+
this._peers.delete(host)
|
|
268
|
+
this.emit('disconnected', data)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
peer.on('error', (err) => {
|
|
272
|
+
// Don't crash the pool — just log
|
|
273
|
+
this.emit('error', err)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
peer.on('tx', (data) => this.emit('tx', data))
|
|
277
|
+
peer.on('tx:inv', (data) => this.emit('tx:inv', data))
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await peer.connect(host, port)
|
|
281
|
+
} catch {
|
|
282
|
+
// Connection or handshake failed — remove from pool
|
|
283
|
+
this._peers.delete(host)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Periodic maintenance: clean dead peers, reconnect if below target.
|
|
289
|
+
*/
|
|
290
|
+
async _maintainPeers () {
|
|
291
|
+
if (this._destroyed) return
|
|
292
|
+
|
|
293
|
+
// Clean disconnected peers
|
|
294
|
+
for (const [host, peer] of this._peers) {
|
|
295
|
+
if (!peer._connected) {
|
|
296
|
+
this._peers.delete(host)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Reconnect to any new peers discovered
|
|
301
|
+
try {
|
|
302
|
+
const addresses = await this._discoverPeers()
|
|
303
|
+
const newAddrs = addresses.filter(a => !this._peers.has(a.host))
|
|
304
|
+
for (const addr of newAddrs) {
|
|
305
|
+
this._connectToPeer(addr.host, addr.port)
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// DNS failed during maintenance — try again next cycle
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|