@relay-federation/bridge 0.3.5 → 0.3.8

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 CHANGED
@@ -1,1064 +1,1073 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { join } from 'node:path'
4
- import { initConfig, loadConfig, configExists, defaultConfigDir } from './lib/config.js'
5
- import { PeerManager } from './lib/peer-manager.js'
6
- import { HeaderRelay } from './lib/header-relay.js'
7
- import { TxRelay } from './lib/tx-relay.js'
8
- import { StatusServer } from './lib/status-server.js'
9
- // network.js import removed register/deregister now use local UTXOs + P2P broadcast
10
-
11
- const command = process.argv[2]
12
-
13
- switch (command) {
14
- case 'init':
15
- await cmdInit()
16
- break
17
- case 'register':
18
- await cmdRegister()
19
- break
20
- case 'start':
21
- await cmdStart()
22
- break
23
- case 'status':
24
- await cmdStatus()
25
- break
26
- case 'fund':
27
- await cmdFund()
28
- break
29
- case 'deregister':
30
- await cmdDeregister()
31
- break
32
- case 'secret':
33
- await cmdSecret()
34
- break
35
- case 'backfill':
36
- await cmdBackfill()
37
- break
38
- default:
39
- console.log('relay-bridge — Federated SPV relay mesh bridge\n')
40
- console.log('Commands:')
41
- console.log(' init Generate bridge identity and config')
42
- console.log(' register Register this bridge on-chain')
43
- console.log(' start Start the bridge server')
44
- console.log(' status Show running bridge status')
45
- console.log(' fund Import a funding transaction (raw hex)')
46
- console.log(' deregister Deregister this bridge from the mesh')
47
- console.log(' secret Show your operator secret for dashboard login')
48
- console.log(' backfill Scan historical blocks for inscriptions/tokens')
49
- console.log('')
50
- console.log('Usage: relay-bridge <command> [options]')
51
- process.exit(command ? 1 : 0)
52
- }
53
-
54
- async function cmdInit () {
55
- const dir = defaultConfigDir()
56
-
57
- if (await configExists(dir)) {
58
- console.log(`Config already exists at ${dir}/config.json`)
59
- console.log('To re-initialize, delete the existing config first.')
60
- process.exit(1)
61
- }
62
-
63
- const config = await initConfig(dir)
64
-
65
- console.log('Bridge initialized!\n')
66
- console.log(` Name: ${config.name}`)
67
- console.log(` Config: ${dir}/config.json`)
68
- console.log(` Endpoint: ${config.endpoint}`)
69
- console.log(` Pubkey: ${config.pubkeyHex}`)
70
- console.log(` Address: ${config.address}`)
71
- console.log(` Secret: ${config.statusSecret}`)
72
- console.log('')
73
- console.log(' Save your operator secret! You need it to log into the dashboard.')
74
- console.log('')
75
- console.log('Next steps:')
76
- console.log(` 1. Fund your bridge: send BSV to ${config.address}`)
77
- console.log(' 2. Import the funding tx: relay-bridge fund <rawTxHex>')
78
- console.log(' 3. Run: relay-bridge register')
79
- }
80
-
81
- async function cmdSecret () {
82
- const dir = defaultConfigDir()
83
-
84
- if (!(await configExists(dir))) {
85
- console.log('No config found. Run: relay-bridge init')
86
- process.exit(1)
87
- }
88
-
89
- const config = await loadConfig(dir)
90
-
91
- if (!config.statusSecret) {
92
- console.log('No operator secret found in config.')
93
- console.log('Add "statusSecret" to your config.json or re-initialize.')
94
- process.exit(1)
95
- }
96
-
97
- console.log(`Operator secret: ${config.statusSecret}`)
98
- console.log('')
99
- console.log('Use this to log into the dashboard operator panel.')
100
- }
101
-
102
- async function cmdBackfill () {
103
- const dir = defaultConfigDir()
104
-
105
- if (!(await configExists(dir))) {
106
- console.log('No config found. Run: relay-bridge init')
107
- process.exit(1)
108
- }
109
-
110
- const config = await loadConfig(dir)
111
- const { PersistentStore } = await import('./lib/persistent-store.js')
112
- const { parseTx } = await import('./lib/output-parser.js')
113
-
114
- const dataDir = config.dataDir || join(dir, 'data')
115
- const store = new PersistentStore(dataDir)
116
- await store.open()
117
-
118
- // Parse CLI args: --from=HEIGHT --to=HEIGHT
119
- const args = {}
120
- for (const arg of process.argv.slice(3)) {
121
- const [k, v] = arg.replace(/^--/, '').split('=')
122
- if (k && v) args[k] = v
123
- }
124
-
125
- const fromHeight = parseInt(args.from || '800000', 10)
126
- const toHeight = args.to === 'latest' || !args.to ? null : parseInt(args.to, 10)
127
- const resumeHeight = await store.getMeta('backfill_height', null)
128
- const startHeight = resumeHeight ? resumeHeight + 1 : fromHeight
129
-
130
- console.log(`Backfill: scanning from block ${startHeight}${toHeight ? ' to ' + toHeight : ' to tip'}`)
131
- if (resumeHeight) console.log(` Resuming from height ${resumeHeight + 1}`)
132
-
133
- let indexed = 0
134
- let blocksScanned = 0
135
- let height = startHeight
136
-
137
- try {
138
- while (true) {
139
- // Get block hash for this height
140
- const hashResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/height/${height}`)
141
- if (!hashResp.ok) {
142
- if (hashResp.status === 404) {
143
- console.log(` Height ${height} not found — reached chain tip`)
144
- break
145
- }
146
- console.log(` WoC error at height ${height}: ${hashResp.status}, retrying in 5s...`)
147
- await new Promise(r => setTimeout(r, 5000))
148
- continue
149
- }
150
- const blockInfo = await hashResp.json()
151
- const blockHash = blockInfo.hash || blockInfo
152
- const blockTime = blockInfo.time || 0
153
-
154
- // Get block txid list
155
- await new Promise(r => setTimeout(r, 350)) // rate limit
156
- const txListResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/${blockHash}/page/1`)
157
- if (!txListResp.ok) {
158
- console.log(` Failed to get tx list for block ${height}, skipping`)
159
- height++
160
- continue
161
- }
162
- const txids = await txListResp.json()
163
-
164
- // For each txid, check if already applied, then fetch + parse
165
- for (const txid of txids) {
166
- // Idempotency: skip if already processed
167
- const applied = await store.getMeta(`applied!${txid}`, null)
168
- if (applied) continue
169
-
170
- // Check if we already have this tx
171
- let rawHex = await store.getTx(txid)
172
- if (!rawHex) {
173
- await new Promise(r => setTimeout(r, 350)) // rate limit
174
- const txResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
175
- if (!txResp.ok) continue
176
- rawHex = await txResp.text()
177
- await store.putTx(txid, rawHex)
178
- }
179
-
180
- // Parse and check for inscriptions/BSV-20
181
- const parsed = parseTx(rawHex)
182
- let hasInterest = false
183
-
184
- for (const output of parsed.outputs) {
185
- if (output.type === 'ordinal' && output.parsed) {
186
- await store.putInscription({
187
- txid,
188
- vout: output.vout,
189
- contentType: output.parsed.contentType || null,
190
- contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
191
- content: output.parsed.content || null,
192
- isBsv20: output.parsed.isBsv20 || false,
193
- bsv20: output.parsed.bsv20 || null,
194
- timestamp: (blockTime || 0) * 1000,
195
- address: output.hash160 || null
196
- })
197
- indexed++
198
- hasInterest = true
199
- }
200
- }
201
-
202
- // Mark tx as confirmed (trusting WoC block placement)
203
- await store.updateTxStatus(txid, 'confirmed', { blockHash, height, source: 'backfill' })
204
- await store.putMeta(`applied!${txid}`, { height, blockHash })
205
- }
206
-
207
- await store.putMeta('backfill_height', height)
208
- blocksScanned++
209
-
210
- if (blocksScanned % 100 === 0) {
211
- console.log(` Block ${height} ${blocksScanned} scanned, ${indexed} inscriptions indexed`)
212
- }
213
-
214
- if (toHeight && height >= toHeight) break
215
- height++
216
- }
217
- } catch (err) {
218
- console.log(` Backfill error at height ${height}: ${err.message}`)
219
- console.log(` Progress saved resume with: relay-bridge backfill`)
220
- } finally {
221
- await store.close()
222
- }
223
-
224
- console.log(`Backfill complete: ${blocksScanned} blocks scanned, ${indexed} inscriptions indexed`)
225
- }
226
-
227
- async function cmdRegister () {
228
- const dir = defaultConfigDir()
229
-
230
- if (!(await configExists(dir))) {
231
- console.log('No config found. Run: relay-bridge init')
232
- process.exit(1)
233
- }
234
-
235
- const config = await loadConfig(dir)
236
-
237
- if (config.endpoint === 'wss://your-bridge.example.com:8333') {
238
- console.log('Error: Update your endpoint in config.json before registering.')
239
- process.exit(1)
240
- }
241
-
242
- const { PersistentStore } = await import('./lib/persistent-store.js')
243
- const { runRegister } = await import('./lib/actions.js')
244
-
245
- const dataDir = config.dataDir || join(dir, 'data')
246
- const store = new PersistentStore(dataDir)
247
- await store.open()
248
-
249
- console.log('Registration details:\n')
250
-
251
- try {
252
- const result = await runRegister({
253
- config,
254
- store,
255
- log: (type, msg) => console.log(type === 'done' ? msg : ` ${msg}`)
256
- })
257
- console.log('')
258
- console.log('Your bridge will appear in peer lists on next scan cycle.')
259
- } catch (err) {
260
- console.log(`Registration failed: ${err.message}`)
261
- await store.close()
262
- process.exit(1)
263
- }
264
-
265
- await store.close()
266
- }
267
-
268
- async function cmdStart () {
269
- const dir = defaultConfigDir()
270
-
271
- if (!(await configExists(dir))) {
272
- console.log('No config found. Run: relay-bridge init')
273
- process.exit(1)
274
- }
275
-
276
- const config = await loadConfig(dir)
277
- const rawPeerArg = process.argv[3] // optional: ws://host:port
278
- const peerArg = (rawPeerArg && !rawPeerArg.startsWith('-')) ? rawPeerArg : null
279
-
280
- // ── 1. Open persistent store ──────────────────────────────
281
- const { PersistentStore } = await import('./lib/persistent-store.js')
282
- const { PrivateKey } = await import('@bsv/sdk')
283
-
284
- const dataDir = config.dataDir || join(dir, 'data')
285
- const store = new PersistentStore(dataDir)
286
- await store.open()
287
- console.log(`Database opened: ${dataDir}`)
288
-
289
- // Load persisted balance
290
- const balance = await store.getBalance()
291
- if (balance > 0) {
292
- console.log(` Wallet balance: ${balance} satoshis`)
293
- }
294
-
295
- // ── 2. Core components ────────────────────────────────────
296
- const peerManager = new PeerManager()
297
- const headerRelay = new HeaderRelay(peerManager)
298
- const txRelay = new TxRelay(peerManager)
299
-
300
- // ── 2b. Phase 2: Security layer ────────────────────────────
301
- const { PeerScorer } = await import('./lib/peer-scorer.js')
302
- const { ScoreActions } = await import('./lib/score-actions.js')
303
- const { DataValidator } = await import('./lib/data-validator.js')
304
- const { PeerHealth } = await import('./lib/peer-health.js')
305
- const { AnchorManager } = await import('./lib/anchor-manager.js')
306
- const { createHandshake } = await import('./lib/handshake.js')
307
-
308
- const scorer = new PeerScorer()
309
- const scoreActions = new ScoreActions(scorer, peerManager)
310
- const dataValidator = new DataValidator(peerManager, scorer)
311
- const peerHealth = new PeerHealth()
312
- const anchorManager = new AnchorManager(peerManager, {
313
- anchors: config.anchorBridges || []
314
- })
315
-
316
- const handshake = createHandshake({
317
- wif: config.wif,
318
- pubkeyHex: config.pubkeyHex,
319
- endpoint: config.endpoint
320
- })
321
-
322
- // Wire peer health tracking
323
- peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
324
- peerHealth.recordSeen(pubkeyHex)
325
- scorer.setStakeAge(pubkeyHex, 7)
326
- if (endpoint) gossipManager.addSeed({ pubkeyHex, endpoint, meshId: config.meshId })
327
- })
328
-
329
- peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
330
- peerHealth.recordOffline(pubkeyHex)
331
- })
332
-
333
- // Wire peer:message → health.recordSeen (any message = peer is alive)
334
- peerManager.on('peer:message', ({ pubkeyHex }) => {
335
- peerHealth.recordSeen(pubkeyHex)
336
- })
337
-
338
- // Ping infrastructure — 60s interval, measures latency for scoring
339
- const PING_INTERVAL_MS = 60000
340
- const pendingPings = new Map() // pubkeyHex timestamp
341
-
342
- peerManager.on('peer:message', ({ pubkeyHex, message }) => {
343
- if (message.type === 'ping') {
344
- // Respond with pong
345
- const conn = peerManager.peers.get(pubkeyHex)
346
- if (conn) conn.send({ type: 'pong', nonce: message.nonce })
347
- } else if (message.type === 'pong') {
348
- // Record latency
349
- const sentAt = pendingPings.get(pubkeyHex)
350
- if (sentAt) {
351
- const latency = Date.now() - sentAt
352
- pendingPings.delete(pubkeyHex)
353
- if (!peerHealth.isInGracePeriod(pubkeyHex)) {
354
- scorer.recordPing(pubkeyHex, latency)
355
- }
356
- }
357
- }
358
- })
359
-
360
- const pingTimer = setInterval(() => {
361
- const nonce = Date.now().toString(36)
362
- for (const [pubkeyHex, conn] of peerManager.peers) {
363
- if (conn.connected) {
364
- // Check for timed-out previous pings
365
- if (pendingPings.has(pubkeyHex)) {
366
- if (!peerHealth.isInGracePeriod(pubkeyHex)) {
367
- scorer.recordPingTimeout(pubkeyHex)
368
- }
369
- pendingPings.delete(pubkeyHex)
370
- }
371
- pendingPings.set(pubkeyHex, Date.now())
372
- conn.send({ type: 'ping', nonce })
373
- }
374
- }
375
- }, PING_INTERVAL_MS)
376
- if (pingTimer.unref) pingTimer.unref()
377
-
378
- // Health check — every 10 minutes, detect inactive peers
379
- const HEALTH_CHECK_INTERVAL_MS = 10 * 60 * 1000
380
- const healthTimer = setInterval(() => {
381
- const { grace, inactive } = peerHealth.checkAll()
382
- for (const pk of inactive) {
383
- console.log(`Peer inactive (7d+): ${pk.slice(0, 16)}...`)
384
- }
385
- }, HEALTH_CHECK_INTERVAL_MS)
386
- if (healthTimer.unref) healthTimer.unref()
387
-
388
- // Start anchor monitoring
389
- anchorManager.startMonitoring()
390
-
391
- console.log(` Security: scoring, validation, health, anchors active`)
392
-
393
- // ── 3. Address watcher watch our own address + beacon ──
394
- const { AddressWatcher } = await import('./lib/address-watcher.js')
395
- const { addressToHash160 } = await import('./lib/output-parser.js')
396
- const { BEACON_ADDRESS } = await import('@relay-federation/common/protocol')
397
- const watcher = new AddressWatcher(txRelay, store)
398
- watcher.watchPubkey(config.pubkeyHex, 'self')
399
- const beaconHash160 = addressToHash160(BEACON_ADDRESS)
400
- watcher.watchHash160(beaconHash160, 'beacon')
401
- console.log(` Watching own address + beacon (${BEACON_ADDRESS})`)
402
-
403
- // ── 4. Gossip manager P2P peer discovery ────────────────
404
- const { GossipManager } = await import('./lib/gossip.js')
405
- const privKey = PrivateKey.fromWif(config.wif)
406
- const gossipManager = new GossipManager(peerManager, {
407
- privKey,
408
- pubkeyHex: config.pubkeyHex,
409
- endpoint: config.endpoint,
410
- meshId: config.meshId
411
- })
412
-
413
- // Add seed peers to gossip directory
414
- const seedPeers = config.seedPeers || []
415
- for (const seed of seedPeers) {
416
- gossipManager.addSeed(seed)
417
- }
418
-
419
- // ── 4a. Registry — track registered pubkeys for handshake gating ──
420
- const registeredPubkeys = new Set()
421
- registeredPubkeys.add(config.pubkeyHex) // always trust self
422
- const seedEndpoints = new Set()
423
- for (const seed of seedPeers) {
424
- if (seed.pubkeyHex) registeredPubkeys.add(seed.pubkeyHex)
425
- const ep = typeof seed === 'string' ? seed : seed.endpoint
426
- if (ep) seedEndpoints.add(ep)
427
- }
428
- console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
429
-
430
- // ── 4b. Beacon address watcher detect on-chain registrations ──
431
- const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('@relay-federation/registry/lib/cbor.js')
432
- const { Transaction: BsvTx } = await import('@bsv/sdk')
433
-
434
- // Registry bootstrapped via discoverNewPeers() after server start (no WoC dependency)
435
-
436
- watcher.on('utxo:received', async ({ txid, hash160 }) => {
437
- if (hash160 !== beaconHash160) return
438
-
439
- try {
440
- const rawHex = await store.getTx(txid)
441
- if (!rawHex) return
442
-
443
- const tx = BsvTx.fromHex(rawHex)
444
- const opReturnOutput = tx.outputs.find(out =>
445
- out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
446
- )
447
- if (!opReturnOutput) return
448
-
449
- const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
450
- if (prefix !== PROTOCOL_PREFIX) return
451
-
452
- const entry = decodePayload(cborBytes)
453
-
454
- if (entry.action === 'register') {
455
- const pubHex = Buffer.from(entry.pubkey).toString('hex')
456
- if (pubHex === config.pubkeyHex) return // skip self
457
-
458
- registeredPubkeys.add(pubHex)
459
- gossipManager.addSeed({
460
- pubkeyHex: pubHex,
461
- endpoint: entry.endpoint,
462
- meshId: entry.mesh_id
463
- })
464
- console.log(`Beacon: new registration detected — ${pubHex.slice(0, 16)}... @ ${entry.endpoint}`)
465
- const stakeAgeDays = Math.max(0, (Date.now() / 1000 - entry.timestamp) / 86400)
466
- scorer.setStakeAge(pubHex, stakeAgeDays)
467
- } else if (entry.action === 'deregister') {
468
- const pubHex = Buffer.from(entry.pubkey).toString('hex')
469
- registeredPubkeys.delete(pubHex)
470
- console.log(`Beacon: deregistration detected — ${pubHex.slice(0, 16)}...`)
471
- }
472
- } catch {
473
- // Skip unparseable beacon txs
474
- }
475
- })
476
-
477
-
478
- // ── Outbound handshake helper ──────────────────────────────
479
- function performOutboundHandshake (conn) {
480
- const { message: helloMsg, nonce } = handshake.createHello()
481
- conn.send(helloMsg)
482
-
483
- const onMessage = (msg) => {
484
- if (msg.type === 'challenge_response') {
485
- conn.removeListener('message', onMessage)
486
- clearTimeout(timeout)
487
- const result = handshake.handleChallengeResponse(msg, nonce, conn.isSeed ? null : registeredPubkeys)
488
- if (result.error) {
489
- console.log(`Handshake failed with ${conn.pubkeyHex.slice(0, 16)}...: ${result.error}`)
490
- conn.destroy()
491
- return
492
- }
493
- // Re-key if we didn't know their real pubkey
494
- if (result.peerPubkey !== conn.pubkeyHex) {
495
- peerManager.peers.delete(conn.pubkeyHex)
496
- conn.pubkeyHex = result.peerPubkey
497
- }
498
-
499
- // Tie-break duplicate connections (inbound may have been accepted during handshake)
500
- const existing = peerManager.peers.get(result.peerPubkey)
501
- if (existing && existing !== conn) {
502
- if (config.pubkeyHex > result.peerPubkey) {
503
- // Higher pubkey drops outbound keep existing inbound
504
- console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
505
- conn._shouldReconnect = false
506
- conn.destroy()
507
- return
508
- }
509
- // Lower pubkey keeps outbound — drop existing inbound
510
- console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
511
- existing._shouldReconnect = false
512
- existing.destroy()
513
- }
514
-
515
- peerManager.peers.set(result.peerPubkey, conn)
516
- // Learn seed pubkeys so future inbound connections from them pass registry check
517
- if (conn.isSeed && !registeredPubkeys.has(result.peerPubkey)) {
518
- registeredPubkeys.add(result.peerPubkey)
519
- console.log(` Seed pubkey learned: ${result.peerPubkey.slice(0, 16)}...`)
520
- }
521
- console.log(` Peer identified: ${result.peerPubkey.slice(0, 16)}... (v${result.selectedVersion})`)
522
-
523
- // Send verify to complete handshake
524
- conn.send(result.message)
525
- // Handshake complete — now safe to announce peer:connect
526
- peerManager.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
527
- }
528
- }
529
- conn.on('message', onMessage)
530
-
531
- // Timeout: if no challenge_response within 10s, drop
532
- const timeout = setTimeout(() => {
533
- conn.removeListener('message', onMessage)
534
- if (!conn.connected) return
535
- console.log(`Handshake timeout: ${conn.pubkeyHex.slice(0, 16)}...`)
536
- conn.destroy()
537
- }, 10000)
538
- if (timeout.unref) timeout.unref()
539
-
540
- // Clean up on close — prevent stale handler on reconnect
541
- conn.once('close', () => {
542
- clearTimeout(timeout)
543
- conn.removeListener('message', onMessage)
544
- })
545
- }
546
-
547
- // ── 5. Start server ───────────────────────────────────────
548
- await peerManager.startServer({ port: config.port, host: '0.0.0.0', pubkeyHex: config.pubkeyHex, endpoint: config.endpoint, handshake, registeredPubkeys, seedEndpoints })
549
- console.log(`Bridge listening on port ${config.port}`)
550
- console.log(` Pubkey: ${config.pubkeyHex}`)
551
- console.log(` Mesh: ${config.meshId}`)
552
-
553
- // ── 6. Persistence layer — save headers/txs to LevelDB ───
554
- headerRelay.on('header:new', async (header) => {
555
- try { await store.putHeader(header) } catch {}
556
- })
557
-
558
- headerRelay.on('header:sync', async ({ headers }) => {
559
- if (headers && headers.length) {
560
- try { await store.putHeaders(headers) } catch {}
561
- }
562
- })
563
-
564
- txRelay.on('tx:new', async ({ txid, rawHex }) => {
565
- try { await store.putTx(txid, rawHex) } catch {}
566
- try { await store.updateTxStatus(txid, 'mempool', { source: 'p2p' }) } catch {}
567
- // Index inscriptions
568
- try {
569
- const { parseTx } = await import('./lib/output-parser.js')
570
- const parsed = parseTx(rawHex)
571
- for (const output of parsed.outputs) {
572
- if (output.type === 'ordinal' && output.parsed) {
573
- await store.putInscription({
574
- txid,
575
- vout: output.vout,
576
- contentType: output.parsed.contentType || null,
577
- contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
578
- isBsv20: output.parsed.isBsv20 || false,
579
- bsv20: output.parsed.bsv20 || null,
580
- timestamp: Date.now(),
581
- address: output.hash160 || null
582
- })
583
- }
584
- }
585
- } catch {}
586
- })
587
-
588
- // ── 6b. BSV P2P header sync — connect to BSV nodes ──────
589
- const { BSVNodeClient } = await import('./lib/bsv-node-client.js')
590
- const bsvNode = new BSVNodeClient()
591
-
592
- bsvNode.on('headers', async ({ headers, count }) => {
593
- // Feed into HeaderRelay for peer propagation
594
- const added = headerRelay.addHeaders(headers)
595
- if (added > 0) {
596
- console.log(`BSV P2P: synced ${added} headers (height: ${headerRelay.bestHeight})`)
597
- // Persist to LevelDB
598
- try { await store.putHeaders(headers) } catch {}
599
- // Announce to federation peers
600
- headerRelay.announceToAll()
601
- }
602
- })
603
-
604
- bsvNode.on('connected', ({ host }) => {
605
- console.log(`BSV P2P: connected to ${host}:8333`)
606
- })
607
-
608
- bsvNode.on('handshake', ({ userAgent, startHeight }) => {
609
- console.log(`BSV P2P: handshake complete (${userAgent}, height: ${startHeight})`)
610
- })
611
-
612
- bsvNode.on('disconnected', () => {
613
- console.log('BSV P2P: disconnected, will reconnect...')
614
- })
615
-
616
- bsvNode.on('error', (err) => {
617
- // Don't crash — just log
618
- if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') {
619
- console.log(`BSV P2P: ${err.message}`)
620
- }
621
- })
622
-
623
- // Auto-detect incoming payments: request txs announced via INV
624
- bsvNode.on('tx:inv', ({ txids }) => {
625
- for (const txid of txids) {
626
- if (txRelay.seen.has(txid)) continue
627
- bsvNode.getTx(txid, 10000).then(({ txid: id, rawHex }) => {
628
- txRelay.broadcastTx(id, rawHex)
629
- }).catch(() => {}) // ignore fetch failures
630
- }
631
- })
632
-
633
- // Feed raw txs from BSV P2P into the mesh relay + address watcher
634
- bsvNode.on('tx', ({ txid, rawHex }) => {
635
- if (!txRelay.seen.has(txid)) {
636
- txRelay.broadcastTx(txid, rawHex)
637
- }
638
- })
639
-
640
- // Start the BSV P2P connection
641
- bsvNode.connect()
642
-
643
- // ── 7. Connect to peers ───────────────────────────────────
644
- let gossipStarted = false
645
-
646
- // Start gossip after first peer connection completes.
647
- // Delay 5s so all seed handshakes finish before gossip broadcasts
648
- // (immediate broadcast would send announce/getpeers through connections
649
- // whose inbound side is still waiting for verify breaking the handshake).
650
- peerManager.on('peer:connect', () => {
651
- if (!gossipStarted) {
652
- gossipStarted = true
653
- setTimeout(() => {
654
- gossipManager.start()
655
- gossipManager.requestPeersFromAll()
656
- console.log('Gossip started')
657
- }, 5000)
658
-
659
- // Periodic peer refresh — re-request peer lists every 10 minutes
660
- // Catches registrations missed during downtime or initial gossip
661
- const PEER_REFRESH_MS = 10 * 60 * 1000
662
- const refreshTimer = setInterval(() => {
663
- gossipManager.requestPeersFromAll()
664
- }, PEER_REFRESH_MS)
665
- if (refreshTimer.unref) refreshTimer.unref()
666
- }
667
- })
668
-
669
- // Auto-connect to newly discovered peers (with reachability probe + IP diversity)
670
- const { probeEndpoint } = await import('./lib/endpoint-probe.js')
671
- const { checkIpDiversity } = await import('./lib/ip-diversity.js')
672
-
673
- gossipManager.on('peer:discovered', async ({ pubkeyHex, endpoint }) => {
674
- if (peerManager.peers.has(pubkeyHex) || pubkeyHex === config.pubkeyHex) return
675
-
676
- // IP diversity check prevent all peers clustering in one datacenter
677
- const connectedEndpoints = [...peerManager.peers.values()]
678
- .filter(c => c.endpoint).map(c => c.endpoint)
679
- const diversity = checkIpDiversity(connectedEndpoints, endpoint)
680
- if (!diversity.allowed) {
681
- console.log(`IP diversity blocked: ${pubkeyHex.slice(0, 16)}... — ${diversity.reason}`)
682
- return
683
- }
684
-
685
- const reachable = await probeEndpoint(endpoint)
686
- if (!reachable) {
687
- console.log(`Probe failed: ${pubkeyHex.slice(0, 16)}... ${endpoint} — skipping`)
688
- return
689
- }
690
-
691
- console.log(`Discovered peer via gossip: ${pubkeyHex.slice(0, 16)}... ${endpoint}`)
692
- const conn = peerManager.connectToPeer({ pubkeyHex, endpoint })
693
- if (conn) {
694
- conn.on('open', () => performOutboundHandshake(conn))
695
- }
696
- })
697
-
698
- if (peerArg) {
699
- // Manual peer connection
700
- console.log(`Connecting to peer: ${peerArg}`)
701
- const conn = peerManager.connectToPeer({
702
- pubkeyHex: 'manual_peer',
703
- endpoint: peerArg
704
- })
705
- if (conn) {
706
- conn.on('open', () => performOutboundHandshake(conn))
707
- }
708
- } else if (seedPeers.length > 0) {
709
- // Connect to seed peers (accept both string URLs and {pubkeyHex, endpoint} objects)
710
- console.log(`Connecting to ${seedPeers.length} seed peer(s)...`)
711
- for (let i = 0; i < seedPeers.length; i++) {
712
- if (i > 0) await new Promise(r => setTimeout(r, 2000)) // stagger to avoid handshake races
713
- const seed = seedPeers[i]
714
- const endpoint = typeof seed === 'string' ? seed : seed.endpoint
715
- const pubkey = typeof seed === 'string' ? `seed_${i}` : seed.pubkeyHex
716
- const conn = peerManager.connectToPeer({ pubkeyHex: pubkey, endpoint })
717
- if (conn) {
718
- conn.isSeed = true
719
- conn.on('open', () => performOutboundHandshake(conn))
720
- }
721
- }
722
- } else if (config.apiKey) {
723
- // Fallback: scan chain for peers (legacy mode)
724
- console.log('No seed peers configured. Scanning chain for peers...')
725
- try {
726
- const { scanRegistry } = await import('@relay-federation/registry/lib/scanner.js')
727
- const { buildPeerList, excludeSelf } = await import('@relay-federation/registry/lib/discovery.js')
728
- const { savePeerCache, loadPeerCache } = await import('@relay-federation/registry/lib/peer-cache.js')
729
-
730
- const cachePath = join(dir, 'cache', 'peers.json')
731
- let peers = await loadPeerCache(cachePath)
732
-
733
- if (!peers) {
734
- const entries = await scanRegistry({
735
- spvEndpoint: config.spvEndpoint,
736
- apiKey: config.apiKey
737
- })
738
- peers = buildPeerList(entries)
739
- peers = excludeSelf(peers, config.pubkeyHex)
740
- await savePeerCache(peers, cachePath)
741
- }
742
-
743
- console.log(`Found ${peers.length} peers`)
744
- for (const peer of peers) {
745
- const conn = peerManager.connectToPeer(peer)
746
- if (conn) {
747
- conn.on('open', () => performOutboundHandshake(conn))
748
- }
749
- }
750
- } catch (err) {
751
- console.log(`Peer scan failed: ${err.message}`)
752
- console.log('Start with manual peer: relay-bridge start ws://peer:port')
753
- }
754
- } else {
755
- console.log('No seed peers, no manual peer, and no API key configured.')
756
- console.log('Usage: relay-bridge start ws://peer:port')
757
- console.log(' or: Add seedPeers to config.json')
758
- }
759
-
760
- // ── 8. Status server ──────────────────────────────────────
761
- const statusPort = config.statusPort || 9333
762
- const statusServer = new StatusServer({
763
- port: statusPort,
764
- peerManager,
765
- headerRelay,
766
- txRelay,
767
- config,
768
- scorer,
769
- peerHealth,
770
- bsvNodeClient: bsvNode,
771
- store,
772
- performOutboundHandshake,
773
- registeredPubkeys,
774
- gossipManager
775
- })
776
- await statusServer.start()
777
- statusServer.startAppMonitoring()
778
- console.log(` Status: http://127.0.0.1:${statusPort}/status`)
779
-
780
- // ── Peer discovery — bootstrap registry from seed peers, then periodic refresh ──
781
- const knownEndpoints = new Set()
782
- for (const sp of (config.seedPeers || [])) knownEndpoints.add(sp.endpoint)
783
- knownEndpoints.add(config.endpoint)
784
-
785
- async function discoverNewPeers () {
786
- const peersToQuery = [...(config.seedPeers || [])]
787
- for (const [, conn] of peerManager.peers) {
788
- if (conn.endpoint && conn.readyState === 1) peersToQuery.push({ endpoint: conn.endpoint })
789
- }
790
- for (const peer of peersToQuery) {
791
- try {
792
- const ep = peer.endpoint || ''
793
- const u = new URL(ep)
794
- const statusUrl = 'http://' + u.hostname + ':' + (parseInt(u.port, 10) + 1000) + '/discover'
795
- const res = await fetch(statusUrl, { signal: AbortSignal.timeout(5000) })
796
- if (!res.ok) continue
797
- const data = await res.json()
798
- if (!data.bridges) continue
799
- for (const b of data.bridges) {
800
- if (!b.endpoint) continue
801
- if (b.pubkeyHex) registeredPubkeys.add(b.pubkeyHex)
802
- seedEndpoints.add(b.endpoint)
803
- if (knownEndpoints.has(b.endpoint)) continue
804
- knownEndpoints.add(b.endpoint)
805
- const conn = peerManager.connectToPeer({ endpoint: b.endpoint, pubkeyHex: b.pubkeyHex })
806
- if (conn) {
807
- conn.on('open', () => performOutboundHandshake(conn))
808
- const msg = `Discovered new peer: ${b.name || b.pubkeyHex?.slice(0, 16) || b.endpoint}`
809
- console.log(msg)
810
- statusServer.addLog(msg)
811
- }
812
- }
813
- } catch {}
814
- }
815
- }
816
- await discoverNewPeers()
817
- console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys after peer discovery`)
818
- setTimeout(discoverNewPeers, 5000)
819
- setInterval(discoverNewPeers, 300000)
820
-
821
- // ── 9. Log events (dual: console + status server ring buffer) ──
822
- peerManager.on('peer:connect', ({ pubkeyHex }) => {
823
- const msg = `Peer connected: ${pubkeyHex.slice(0, 16)}...`
824
- console.log(msg)
825
- statusServer.addLog(msg)
826
- })
827
-
828
- peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
829
- const msg = `Peer disconnected: ${pubkeyHex ? pubkeyHex.slice(0, 16) + '...' : 'unknown'}`
830
- console.log(msg)
831
- statusServer.addLog(msg)
832
- })
833
-
834
- headerRelay.on('header:sync', ({ pubkeyHex, added, bestHeight }) => {
835
- const msg = `Synced ${added} headers from ${pubkeyHex.slice(0, 16)}... (height: ${bestHeight})`
836
- console.log(msg)
837
- statusServer.addLog(msg)
838
- })
839
-
840
- txRelay.on('tx:new', ({ txid }) => {
841
- const msg = `New tx: ${txid}`
842
- console.log(msg)
843
- statusServer.addLog(msg)
844
- })
845
-
846
- watcher.on('utxo:received', ({ txid, vout, satoshis }) => {
847
- const msg = `UTXO received: ${txid}:${vout} (${satoshis} sat)`
848
- console.log(msg)
849
- statusServer.addLog(msg)
850
- })
851
-
852
- watcher.on('utxo:spent', ({ txid, vout, spentByTxid }) => {
853
- const msg = `UTXO spent: ${txid.slice(0, 16)}...:${vout} by ${spentByTxid.slice(0, 16)}...`
854
- console.log(msg)
855
- statusServer.addLog(msg)
856
- })
857
-
858
- // Phase 2 events
859
- scoreActions.on('peer:disconnected', ({ pubkeyHex, score }) => {
860
- console.log(`Score disconnect: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)})`)
861
- })
862
-
863
- scoreActions.on('peer:blacklisted', ({ pubkeyHex, score }) => {
864
- console.log(`Blacklisted: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)}, 24h)`)
865
- })
866
-
867
- dataValidator.on('validation:fail', ({ pubkeyHex, type, reason }) => {
868
- console.log(`Bad data from ${pubkeyHex.slice(0, 16)}...: ${type} ${reason}`)
869
- })
870
-
871
- anchorManager.on('anchor:disconnect', ({ pubkeyHex }) => {
872
- console.log(`Anchor disconnected: ${pubkeyHex.slice(0, 16)}...`)
873
- })
874
-
875
- anchorManager.on('anchor:low_score', ({ pubkeyHex, score }) => {
876
- console.log(`Anchor low score: ${pubkeyHex.slice(0, 16)}... (${score.toFixed(2)})`)
877
- })
878
-
879
- peerHealth.on('peer:recovered', ({ pubkeyHex }) => {
880
- console.log(`Peer recovered: ${pubkeyHex.slice(0, 16)}...`)
881
- })
882
-
883
- // ── 10. Graceful shutdown ─────────────────────────────────
884
- const shutdown = async () => {
885
- console.log('\nShutting down...')
886
- clearInterval(pingTimer)
887
- clearInterval(healthTimer)
888
- anchorManager.stopMonitoring()
889
- bsvNode.disconnect()
890
- gossipManager.stop()
891
- await statusServer.stop()
892
- await peerManager.shutdown()
893
- await store.close()
894
- console.log('Database closed.')
895
- process.exit(0)
896
- }
897
-
898
- process.on('SIGINT', shutdown)
899
- process.on('SIGTERM', shutdown)
900
- }
901
-
902
- async function cmdStatus () {
903
- const dir = defaultConfigDir()
904
-
905
- if (!(await configExists(dir))) {
906
- console.log('No config found. Run: relay-bridge init')
907
- process.exit(1)
908
- }
909
-
910
- const config = await loadConfig(dir)
911
- const statusPort = config.statusPort || 9333
912
-
913
- let status
914
- try {
915
- const res = await fetch(`http://127.0.0.1:${statusPort}/status`)
916
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
917
- status = await res.json()
918
- } catch {
919
- console.log('Bridge is not running.')
920
- console.log(` (expected status server on port ${statusPort})`)
921
- console.log(' Start it with: relay-bridge start')
922
- process.exit(1)
923
- }
924
-
925
- console.log('Bridge Status\n')
926
-
927
- // Bridge identity
928
- console.log(' Bridge')
929
- console.log(` Pubkey: ${status.bridge.pubkeyHex}`)
930
- console.log(` Endpoint: ${status.bridge.endpoint}`)
931
- console.log(` Mesh: ${status.bridge.meshId}`)
932
- console.log(` Uptime: ${formatUptime(status.bridge.uptimeSeconds)}`)
933
-
934
- // Peers
935
- console.log('')
936
- console.log(` Peers (${status.peers.connected}/${status.peers.max})`)
937
- if (status.peers.list.length === 0) {
938
- console.log(' (no peers)')
939
- } else {
940
- for (const peer of status.peers.list) {
941
- const tag = peer.connected ? 'connected' : 'disconnected'
942
- console.log(` ${peer.pubkeyHex.slice(0, 16)}... ${peer.endpoint} (${tag})`)
943
- }
944
- }
945
-
946
- // Headers
947
- console.log('')
948
- console.log(' Headers')
949
- console.log(` Best Height: ${status.headers.bestHeight}`)
950
- console.log(` Best Hash: ${status.headers.bestHash || '(none)'}`)
951
- console.log(` Stored: ${status.headers.count}`)
952
-
953
- // Transactions
954
- console.log('')
955
- console.log(' Transactions')
956
- console.log(` Mempool: ${status.txs.mempool}`)
957
- console.log(` Seen: ${status.txs.seen}`)
958
-
959
- // BSV Node
960
- if (status.bsvNode) {
961
- console.log('')
962
- console.log(' BSV Node')
963
- console.log(` Status: ${status.bsvNode.connected ? 'Connected' : 'Disconnected'}`)
964
- console.log(` Host: ${status.bsvNode.host || '-'}`)
965
- console.log(` Height: ${status.bsvNode.height || '-'}`)
966
- }
967
-
968
- // Wallet
969
- if (status.wallet) {
970
- console.log('')
971
- console.log(' Wallet')
972
- console.log(` Balance: ${status.wallet.balanceSats !== null ? status.wallet.balanceSats + ' sats' : '-'}`)
973
- }
974
- }
975
-
976
- async function cmdFund () {
977
- const rawHex = process.argv[3]
978
- if (!rawHex) {
979
- console.log('Usage: relay-bridge fund <rawTxHex>')
980
- console.log('')
981
- console.log(' Provide the raw hex of a transaction that pays to this bridge.')
982
- console.log(' Get the raw hex from your wallet or a block explorer after sending BSV.')
983
- process.exit(1)
984
- }
985
-
986
- const dir = defaultConfigDir()
987
-
988
- if (!(await configExists(dir))) {
989
- console.log('No config found. Run: relay-bridge init')
990
- process.exit(1)
991
- }
992
-
993
- const config = await loadConfig(dir)
994
- const { PersistentStore } = await import('./lib/persistent-store.js')
995
- const { runFund } = await import('./lib/actions.js')
996
-
997
- const dataDir = config.dataDir || join(dir, 'data')
998
- const store = new PersistentStore(dataDir)
999
- await store.open()
1000
-
1001
- try {
1002
- await runFund({
1003
- config,
1004
- store,
1005
- rawHex,
1006
- log: (type, msg) => console.log(` ${msg}`)
1007
- })
1008
- } catch (err) {
1009
- console.log(`Fund failed: ${err.message}`)
1010
- process.exit(1)
1011
- } finally {
1012
- await store.close()
1013
- }
1014
- }
1015
-
1016
- async function cmdDeregister () {
1017
- const dir = defaultConfigDir()
1018
-
1019
- if (!(await configExists(dir))) {
1020
- console.log('No config found. Run: relay-bridge init')
1021
- process.exit(1)
1022
- }
1023
-
1024
- const config = await loadConfig(dir)
1025
- const reason = process.argv[3] || 'shutdown'
1026
-
1027
- const { PersistentStore } = await import('./lib/persistent-store.js')
1028
- const { runDeregister } = await import('./lib/actions.js')
1029
-
1030
- const dataDir = config.dataDir || join(dir, 'data')
1031
- const store = new PersistentStore(dataDir)
1032
- await store.open()
1033
-
1034
- console.log('Deregistration details:\n')
1035
-
1036
- try {
1037
- await runDeregister({
1038
- config,
1039
- store,
1040
- reason,
1041
- log: (type, msg) => console.log(type === 'done' ? msg : ` ${msg}`)
1042
- })
1043
- console.log('')
1044
- console.log('Your bridge will be removed from peer lists on next scan cycle.')
1045
- } catch (err) {
1046
- console.log(`Deregistration failed: ${err.message}`)
1047
- await store.close()
1048
- process.exit(1)
1049
- }
1050
-
1051
- await store.close()
1052
- }
1053
-
1054
- function formatUptime (seconds) {
1055
- const days = Math.floor(seconds / 86400)
1056
- const hours = Math.floor((seconds % 86400) / 3600)
1057
- const minutes = Math.floor((seconds % 3600) / 60)
1058
- const secs = seconds % 60
1059
-
1060
- if (days > 0) return `${days}d ${hours}h ${minutes}m`
1061
- if (hours > 0) return `${hours}h ${minutes}m ${secs}s`
1062
- if (minutes > 0) return `${minutes}m ${secs}s`
1063
- return `${secs}s`
1064
- }
2
+
3
+ import { join } from 'node:path'
4
+ import { initConfig, loadConfig, configExists, defaultConfigDir } from './lib/config.js'
5
+ import { PeerManager } from './lib/peer-manager.js'
6
+ import { HeaderRelay } from './lib/header-relay.js'
7
+ import { TxRelay } from './lib/tx-relay.js'
8
+ import { DataRelay } from './lib/data-relay.js'
9
+ import { StatusServer } from './lib/status-server.js'
10
+ // network.js import removed — register/deregister now use local UTXOs + P2P broadcast
11
+
12
+ const command = process.argv[2]
13
+
14
+ switch (command) {
15
+ case 'init':
16
+ await cmdInit()
17
+ break
18
+ case 'register':
19
+ await cmdRegister()
20
+ break
21
+ case 'start':
22
+ await cmdStart()
23
+ break
24
+ case 'status':
25
+ await cmdStatus()
26
+ break
27
+ case 'fund':
28
+ await cmdFund()
29
+ break
30
+ case 'deregister':
31
+ await cmdDeregister()
32
+ break
33
+ case 'secret':
34
+ await cmdSecret()
35
+ break
36
+ case 'backfill':
37
+ await cmdBackfill()
38
+ break
39
+ default:
40
+ console.log('relay-bridge — Federated SPV relay mesh bridge\n')
41
+ console.log('Commands:')
42
+ console.log(' init Generate bridge identity and config')
43
+ console.log(' register Register this bridge on-chain')
44
+ console.log(' start Start the bridge server')
45
+ console.log(' status Show running bridge status')
46
+ console.log(' fund Import a funding transaction (raw hex)')
47
+ console.log(' deregister Deregister this bridge from the mesh')
48
+ console.log(' secret Show your operator secret for dashboard login')
49
+ console.log(' backfill Scan historical blocks for inscriptions/tokens')
50
+ console.log('')
51
+ console.log('Usage: relay-bridge <command> [options]')
52
+ process.exit(command ? 1 : 0)
53
+ }
54
+
55
+ async function cmdInit () {
56
+ const dir = defaultConfigDir()
57
+
58
+ if (await configExists(dir)) {
59
+ console.log(`Config already exists at ${dir}/config.json`)
60
+ console.log('To re-initialize, delete the existing config first.')
61
+ process.exit(1)
62
+ }
63
+
64
+ const config = await initConfig(dir)
65
+
66
+ console.log('Bridge initialized!\n')
67
+ console.log(` Name: ${config.name}`)
68
+ console.log(` Config: ${dir}/config.json`)
69
+ console.log(` Endpoint: ${config.endpoint}`)
70
+ console.log(` Pubkey: ${config.pubkeyHex}`)
71
+ console.log(` Address: ${config.address}`)
72
+ console.log(` Secret: ${config.statusSecret}`)
73
+ console.log('')
74
+ console.log(' Save your operator secret! You need it to log into the dashboard.')
75
+ console.log('')
76
+ console.log('Next steps:')
77
+ console.log(` 1. Fund your bridge: send BSV to ${config.address}`)
78
+ console.log(' 2. Import the funding tx: relay-bridge fund <rawTxHex>')
79
+ console.log(' 3. Run: relay-bridge register')
80
+ }
81
+
82
+ async function cmdSecret () {
83
+ const dir = defaultConfigDir()
84
+
85
+ if (!(await configExists(dir))) {
86
+ console.log('No config found. Run: relay-bridge init')
87
+ process.exit(1)
88
+ }
89
+
90
+ const config = await loadConfig(dir)
91
+
92
+ if (!config.statusSecret) {
93
+ console.log('No operator secret found in config.')
94
+ console.log('Add "statusSecret" to your config.json or re-initialize.')
95
+ process.exit(1)
96
+ }
97
+
98
+ console.log(`Operator secret: ${config.statusSecret}`)
99
+ console.log('')
100
+ console.log('Use this to log into the dashboard operator panel.')
101
+ }
102
+
103
+ async function cmdBackfill () {
104
+ const dir = defaultConfigDir()
105
+
106
+ if (!(await configExists(dir))) {
107
+ console.log('No config found. Run: relay-bridge init')
108
+ process.exit(1)
109
+ }
110
+
111
+ const config = await loadConfig(dir)
112
+ const { PersistentStore } = await import('./lib/persistent-store.js')
113
+ const { parseTx } = await import('./lib/output-parser.js')
114
+
115
+ const dataDir = config.dataDir || join(dir, 'data')
116
+ const store = new PersistentStore(dataDir)
117
+ await store.open()
118
+
119
+ // Parse CLI args: --from=HEIGHT --to=HEIGHT
120
+ const args = {}
121
+ for (const arg of process.argv.slice(3)) {
122
+ const [k, v] = arg.replace(/^--/, '').split('=')
123
+ if (k && v) args[k] = v
124
+ }
125
+
126
+ const fromHeight = parseInt(args.from || '800000', 10)
127
+ const toHeight = args.to === 'latest' || !args.to ? null : parseInt(args.to, 10)
128
+ const resumeHeight = await store.getMeta('backfill_height', null)
129
+ const startHeight = resumeHeight ? resumeHeight + 1 : fromHeight
130
+
131
+ console.log(`Backfill: scanning from block ${startHeight}${toHeight ? ' to ' + toHeight : ' to tip'}`)
132
+ if (resumeHeight) console.log(` Resuming from height ${resumeHeight + 1}`)
133
+
134
+ let indexed = 0
135
+ let blocksScanned = 0
136
+ let height = startHeight
137
+
138
+ try {
139
+ while (true) {
140
+ // Get block hash for this height
141
+ const hashResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/height/${height}`)
142
+ if (!hashResp.ok) {
143
+ if (hashResp.status === 404) {
144
+ console.log(` Height ${height} not found — reached chain tip`)
145
+ break
146
+ }
147
+ console.log(` WoC error at height ${height}: ${hashResp.status}, retrying in 5s...`)
148
+ await new Promise(r => setTimeout(r, 5000))
149
+ continue
150
+ }
151
+ const blockInfo = await hashResp.json()
152
+ const blockHash = blockInfo.hash || blockInfo
153
+ const blockTime = blockInfo.time || 0
154
+
155
+ // Get block txid list
156
+ await new Promise(r => setTimeout(r, 350)) // rate limit
157
+ const txListResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/${blockHash}/page/1`)
158
+ if (!txListResp.ok) {
159
+ console.log(` Failed to get tx list for block ${height}, skipping`)
160
+ height++
161
+ continue
162
+ }
163
+ const txids = await txListResp.json()
164
+
165
+ // For each txid, check if already applied, then fetch + parse
166
+ for (const txid of txids) {
167
+ // Idempotency: skip if already processed
168
+ const applied = await store.getMeta(`applied!${txid}`, null)
169
+ if (applied) continue
170
+
171
+ // Check if we already have this tx
172
+ let rawHex = await store.getTx(txid)
173
+ if (!rawHex) {
174
+ await new Promise(r => setTimeout(r, 350)) // rate limit
175
+ const txResp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
176
+ if (!txResp.ok) continue
177
+ rawHex = await txResp.text()
178
+ await store.putTx(txid, rawHex)
179
+ }
180
+
181
+ // Parse and check for inscriptions/BSV-20
182
+ const parsed = parseTx(rawHex)
183
+ let hasInterest = false
184
+
185
+ for (const output of parsed.outputs) {
186
+ if (output.type === 'ordinal' && output.parsed) {
187
+ await store.putInscription({
188
+ txid,
189
+ vout: output.vout,
190
+ contentType: output.parsed.contentType || null,
191
+ contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
192
+ content: output.parsed.content || null,
193
+ isBsv20: output.parsed.isBsv20 || false,
194
+ bsv20: output.parsed.bsv20 || null,
195
+ timestamp: (blockTime || 0) * 1000,
196
+ address: output.hash160 || null
197
+ })
198
+ indexed++
199
+ hasInterest = true
200
+ }
201
+ }
202
+
203
+ // Mark tx as confirmed (trusting WoC block placement)
204
+ await store.updateTxStatus(txid, 'confirmed', { blockHash, height, source: 'backfill' })
205
+ await store.putMeta(`applied!${txid}`, { height, blockHash })
206
+ }
207
+
208
+ await store.putMeta('backfill_height', height)
209
+ blocksScanned++
210
+
211
+ if (blocksScanned % 100 === 0) {
212
+ console.log(` Block ${height} — ${blocksScanned} scanned, ${indexed} inscriptions indexed`)
213
+ }
214
+
215
+ if (toHeight && height >= toHeight) break
216
+ height++
217
+ }
218
+ } catch (err) {
219
+ console.log(` Backfill error at height ${height}: ${err.message}`)
220
+ console.log(` Progress saved — resume with: relay-bridge backfill`)
221
+ } finally {
222
+ await store.close()
223
+ }
224
+
225
+ console.log(`Backfill complete: ${blocksScanned} blocks scanned, ${indexed} inscriptions indexed`)
226
+ }
227
+
228
+ async function cmdRegister () {
229
+ const dir = defaultConfigDir()
230
+
231
+ if (!(await configExists(dir))) {
232
+ console.log('No config found. Run: relay-bridge init')
233
+ process.exit(1)
234
+ }
235
+
236
+ const config = await loadConfig(dir)
237
+
238
+ if (config.endpoint === 'wss://your-bridge.example.com:8333') {
239
+ console.log('Error: Update your endpoint in config.json before registering.')
240
+ process.exit(1)
241
+ }
242
+
243
+ const { PersistentStore } = await import('./lib/persistent-store.js')
244
+ const { runRegister } = await import('./lib/actions.js')
245
+
246
+ const dataDir = config.dataDir || join(dir, 'data')
247
+ const store = new PersistentStore(dataDir)
248
+ await store.open()
249
+
250
+ console.log('Registration details:\n')
251
+
252
+ try {
253
+ const result = await runRegister({
254
+ config,
255
+ store,
256
+ log: (type, msg) => console.log(type === 'done' ? msg : ` ${msg}`)
257
+ })
258
+ console.log('')
259
+ console.log('Your bridge will appear in peer lists on next scan cycle.')
260
+ } catch (err) {
261
+ console.log(`Registration failed: ${err.message}`)
262
+ await store.close()
263
+ process.exit(1)
264
+ }
265
+
266
+ await store.close()
267
+ }
268
+
269
+ async function cmdStart () {
270
+ const dir = defaultConfigDir()
271
+
272
+ if (!(await configExists(dir))) {
273
+ console.log('No config found. Run: relay-bridge init')
274
+ process.exit(1)
275
+ }
276
+
277
+ const config = await loadConfig(dir)
278
+ const rawPeerArg = process.argv[3] // optional: ws://host:port
279
+ const peerArg = (rawPeerArg && !rawPeerArg.startsWith('-')) ? rawPeerArg : null
280
+
281
+ // ── 1. Open persistent store ──────────────────────────────
282
+ const { PersistentStore } = await import('./lib/persistent-store.js')
283
+ const { PrivateKey } = await import('@bsv/sdk')
284
+
285
+ const dataDir = config.dataDir || join(dir, 'data')
286
+ const store = new PersistentStore(dataDir)
287
+ await store.open()
288
+ console.log(`Database opened: ${dataDir}`)
289
+
290
+ // Load persisted balance
291
+ const balance = await store.getBalance()
292
+ if (balance > 0) {
293
+ console.log(` Wallet balance: ${balance} satoshis`)
294
+ }
295
+
296
+ // ── 2. Core components ────────────────────────────────────
297
+ const peerManager = new PeerManager()
298
+ const headerRelay = new HeaderRelay(peerManager)
299
+ const txRelay = new TxRelay(peerManager)
300
+ const dataRelay = new DataRelay(peerManager)
301
+
302
+ // ── 2b. Phase 2: Security layer ────────────────────────────
303
+ const { PeerScorer } = await import('./lib/peer-scorer.js')
304
+ const { ScoreActions } = await import('./lib/score-actions.js')
305
+ const { DataValidator } = await import('./lib/data-validator.js')
306
+ const { PeerHealth } = await import('./lib/peer-health.js')
307
+ const { AnchorManager } = await import('./lib/anchor-manager.js')
308
+ const { createHandshake } = await import('./lib/handshake.js')
309
+
310
+ const scorer = new PeerScorer()
311
+ const scoreActions = new ScoreActions(scorer, peerManager)
312
+ const dataValidator = new DataValidator(peerManager, scorer)
313
+ const peerHealth = new PeerHealth()
314
+ const anchorManager = new AnchorManager(peerManager, {
315
+ anchors: config.anchorBridges || []
316
+ })
317
+
318
+ const handshake = createHandshake({
319
+ wif: config.wif,
320
+ pubkeyHex: config.pubkeyHex,
321
+ endpoint: config.endpoint
322
+ })
323
+
324
+ // Wire peer health tracking
325
+ peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
326
+ peerHealth.recordSeen(pubkeyHex)
327
+ scorer.setStakeAge(pubkeyHex, 7)
328
+ if (endpoint) gossipManager.addSeed({ pubkeyHex, endpoint, meshId: config.meshId })
329
+ })
330
+
331
+ peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
332
+ peerHealth.recordOffline(pubkeyHex)
333
+ })
334
+
335
+ // Wire peer:message → health.recordSeen (any message = peer is alive)
336
+ peerManager.on('peer:message', ({ pubkeyHex }) => {
337
+ peerHealth.recordSeen(pubkeyHex)
338
+ })
339
+
340
+ // Ping infrastructure 60s interval, measures latency for scoring
341
+ const PING_INTERVAL_MS = 60000
342
+ const pendingPings = new Map() // pubkeyHex → timestamp
343
+
344
+ peerManager.on('peer:message', ({ pubkeyHex, message }) => {
345
+ if (message.type === 'ping') {
346
+ // Respond with pong
347
+ const conn = peerManager.peers.get(pubkeyHex)
348
+ if (conn) conn.send({ type: 'pong', nonce: message.nonce })
349
+ } else if (message.type === 'pong') {
350
+ // Record latency
351
+ const sentAt = pendingPings.get(pubkeyHex)
352
+ if (sentAt) {
353
+ const latency = Date.now() - sentAt
354
+ pendingPings.delete(pubkeyHex)
355
+ if (!peerHealth.isInGracePeriod(pubkeyHex)) {
356
+ scorer.recordPing(pubkeyHex, latency)
357
+ }
358
+ }
359
+ }
360
+ })
361
+
362
+ const pingTimer = setInterval(() => {
363
+ const nonce = Date.now().toString(36)
364
+ for (const [pubkeyHex, conn] of peerManager.peers) {
365
+ if (conn.connected) {
366
+ // Check for timed-out previous pings
367
+ if (pendingPings.has(pubkeyHex)) {
368
+ if (!peerHealth.isInGracePeriod(pubkeyHex)) {
369
+ scorer.recordPingTimeout(pubkeyHex)
370
+ }
371
+ pendingPings.delete(pubkeyHex)
372
+ }
373
+ pendingPings.set(pubkeyHex, Date.now())
374
+ conn.send({ type: 'ping', nonce })
375
+ }
376
+ }
377
+ }, PING_INTERVAL_MS)
378
+ if (pingTimer.unref) pingTimer.unref()
379
+
380
+ // Health check every 10 minutes, detect inactive peers
381
+ const HEALTH_CHECK_INTERVAL_MS = 10 * 60 * 1000
382
+ const healthTimer = setInterval(() => {
383
+ const { grace, inactive } = peerHealth.checkAll()
384
+ for (const pk of inactive) {
385
+ console.log(`Peer inactive (7d+): ${pk.slice(0, 16)}...`)
386
+ }
387
+ }, HEALTH_CHECK_INTERVAL_MS)
388
+ if (healthTimer.unref) healthTimer.unref()
389
+
390
+ // Start anchor monitoring
391
+ anchorManager.startMonitoring()
392
+
393
+ console.log(` Security: scoring, validation, health, anchors active`)
394
+
395
+ // ── 3. Address watcher watch our own address + beacon ──
396
+ const { AddressWatcher } = await import('./lib/address-watcher.js')
397
+ const { addressToHash160 } = await import('./lib/output-parser.js')
398
+ const { BEACON_ADDRESS } = await import('@relay-federation/common/protocol')
399
+ const watcher = new AddressWatcher(txRelay, store)
400
+ watcher.watchPubkey(config.pubkeyHex, 'self')
401
+ const beaconHash160 = addressToHash160(BEACON_ADDRESS)
402
+ watcher.watchHash160(beaconHash160, 'beacon')
403
+ console.log(` Watching own address + beacon (${BEACON_ADDRESS})`)
404
+
405
+ // ── 4. Gossip manager — P2P peer discovery ────────────────
406
+ const { GossipManager } = await import('./lib/gossip.js')
407
+ const privKey = PrivateKey.fromWif(config.wif)
408
+ const gossipManager = new GossipManager(peerManager, {
409
+ privKey,
410
+ pubkeyHex: config.pubkeyHex,
411
+ endpoint: config.endpoint,
412
+ meshId: config.meshId
413
+ })
414
+
415
+ // Add seed peers to gossip directory
416
+ const seedPeers = config.seedPeers || []
417
+ for (const seed of seedPeers) {
418
+ gossipManager.addSeed(seed)
419
+ }
420
+
421
+ // ── 4a. Registry track registered pubkeys for handshake gating ──
422
+ const registeredPubkeys = new Set()
423
+ registeredPubkeys.add(config.pubkeyHex) // always trust self
424
+ const seedEndpoints = new Set()
425
+ for (const seed of seedPeers) {
426
+ if (seed.pubkeyHex) registeredPubkeys.add(seed.pubkeyHex)
427
+ const ep = typeof seed === 'string' ? seed : seed.endpoint
428
+ if (ep) seedEndpoints.add(ep)
429
+ }
430
+ console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
431
+
432
+ // ── 4b. Beacon address watcher detect on-chain registrations ──
433
+ const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('@relay-federation/registry/lib/cbor.js')
434
+ const { Transaction: BsvTx } = await import('@bsv/sdk')
435
+
436
+ // Registry bootstrapped via discoverNewPeers() after server start (no WoC dependency)
437
+
438
+ watcher.on('utxo:received', async ({ txid, hash160 }) => {
439
+ if (hash160 !== beaconHash160) return
440
+
441
+ try {
442
+ const rawHex = await store.getTx(txid)
443
+ if (!rawHex) return
444
+
445
+ const tx = BsvTx.fromHex(rawHex)
446
+ const opReturnOutput = tx.outputs.find(out =>
447
+ out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
448
+ )
449
+ if (!opReturnOutput) return
450
+
451
+ const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
452
+ if (prefix !== PROTOCOL_PREFIX) return
453
+
454
+ const entry = decodePayload(cborBytes)
455
+
456
+ if (entry.action === 'register') {
457
+ const pubHex = Buffer.from(entry.pubkey).toString('hex')
458
+ if (pubHex === config.pubkeyHex) return // skip self
459
+
460
+ registeredPubkeys.add(pubHex)
461
+ gossipManager.addSeed({
462
+ pubkeyHex: pubHex,
463
+ endpoint: entry.endpoint,
464
+ meshId: entry.mesh_id
465
+ })
466
+ console.log(`Beacon: new registration detected — ${pubHex.slice(0, 16)}... @ ${entry.endpoint}`)
467
+ const stakeAgeDays = Math.max(0, (Date.now() / 1000 - entry.timestamp) / 86400)
468
+ scorer.setStakeAge(pubHex, stakeAgeDays)
469
+ } else if (entry.action === 'deregister') {
470
+ const pubHex = Buffer.from(entry.pubkey).toString('hex')
471
+ registeredPubkeys.delete(pubHex)
472
+ console.log(`Beacon: deregistration detected — ${pubHex.slice(0, 16)}...`)
473
+ }
474
+ } catch {
475
+ // Skip unparseable beacon txs
476
+ }
477
+ })
478
+
479
+
480
+ // ── Outbound handshake helper ──────────────────────────────
481
+ function performOutboundHandshake (conn) {
482
+ const { message: helloMsg, nonce } = handshake.createHello()
483
+ conn.send(helloMsg)
484
+
485
+ const onMessage = (msg) => {
486
+ if (msg.type === 'challenge_response') {
487
+ conn.removeListener('message', onMessage)
488
+ clearTimeout(timeout)
489
+ const result = handshake.handleChallengeResponse(msg, nonce, conn.isSeed ? null : registeredPubkeys)
490
+ if (result.error) {
491
+ console.log(`Handshake failed with ${conn.pubkeyHex.slice(0, 16)}...: ${result.error}`)
492
+ conn.destroy()
493
+ return
494
+ }
495
+ // Re-key if we didn't know their real pubkey
496
+ if (result.peerPubkey !== conn.pubkeyHex) {
497
+ peerManager.peers.delete(conn.pubkeyHex)
498
+ conn.pubkeyHex = result.peerPubkey
499
+ }
500
+
501
+ // Tie-break duplicate connections (inbound may have been accepted during handshake)
502
+ const existing = peerManager.peers.get(result.peerPubkey)
503
+ if (existing && existing !== conn) {
504
+ if (config.pubkeyHex > result.peerPubkey) {
505
+ // Higher pubkey drops outbound — keep existing inbound
506
+ console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
507
+ conn._shouldReconnect = false
508
+ conn.destroy()
509
+ return
510
+ }
511
+ // Lower pubkey keeps outbound — drop existing inbound
512
+ console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
513
+ existing._shouldReconnect = false
514
+ existing.destroy()
515
+ }
516
+
517
+ peerManager.peers.set(result.peerPubkey, conn)
518
+ // Learn seed pubkeys so future inbound connections from them pass registry check
519
+ if (conn.isSeed && !registeredPubkeys.has(result.peerPubkey)) {
520
+ registeredPubkeys.add(result.peerPubkey)
521
+ console.log(` Seed pubkey learned: ${result.peerPubkey.slice(0, 16)}...`)
522
+ }
523
+ console.log(` Peer identified: ${result.peerPubkey.slice(0, 16)}... (v${result.selectedVersion})`)
524
+
525
+ // Send verify to complete handshake
526
+ conn.send(result.message)
527
+ // Handshake complete — now safe to announce peer:connect
528
+ peerManager.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
529
+ }
530
+ }
531
+ conn.on('message', onMessage)
532
+
533
+ // Timeout: if no challenge_response within 10s, drop
534
+ const timeout = setTimeout(() => {
535
+ conn.removeListener('message', onMessage)
536
+ if (!conn.connected) return
537
+ console.log(`Handshake timeout: ${conn.pubkeyHex.slice(0, 16)}...`)
538
+ conn.destroy()
539
+ }, 10000)
540
+ if (timeout.unref) timeout.unref()
541
+
542
+ // Clean up on close — prevent stale handler on reconnect
543
+ conn.once('close', () => {
544
+ clearTimeout(timeout)
545
+ conn.removeListener('message', onMessage)
546
+ })
547
+ }
548
+
549
+ // ── 5. Start server ───────────────────────────────────────
550
+ await peerManager.startServer({ port: config.port, host: '0.0.0.0', pubkeyHex: config.pubkeyHex, endpoint: config.endpoint, handshake, registeredPubkeys, seedEndpoints })
551
+ console.log(`Bridge listening on port ${config.port}`)
552
+ console.log(` Pubkey: ${config.pubkeyHex}`)
553
+ console.log(` Mesh: ${config.meshId}`)
554
+
555
+ // ── 6. Persistence layer — save headers/txs to LevelDB ───
556
+ headerRelay.on('header:new', async (header) => {
557
+ try { await store.putHeader(header) } catch {}
558
+ })
559
+
560
+ headerRelay.on('header:sync', async ({ headers }) => {
561
+ if (headers && headers.length) {
562
+ try { await store.putHeaders(headers) } catch {}
563
+ }
564
+ })
565
+
566
+ txRelay.on('tx:new', async ({ txid, rawHex }) => {
567
+ try { await store.putTx(txid, rawHex) } catch {}
568
+ try { await store.updateTxStatus(txid, 'mempool', { source: 'p2p' }) } catch {}
569
+ // Index inscriptions
570
+ try {
571
+ const { parseTx } = await import('./lib/output-parser.js')
572
+ const parsed = parseTx(rawHex)
573
+ for (const output of parsed.outputs) {
574
+ if (output.type === 'ordinal' && output.parsed) {
575
+ await store.putInscription({
576
+ txid,
577
+ vout: output.vout,
578
+ contentType: output.parsed.contentType || null,
579
+ contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
580
+ isBsv20: output.parsed.isBsv20 || false,
581
+ bsv20: output.parsed.bsv20 || null,
582
+ timestamp: Date.now(),
583
+ address: output.hash160 || null
584
+ })
585
+ }
586
+ }
587
+ } catch {}
588
+ })
589
+
590
+ // ── 6b. BSV P2P header sync — connect to BSV nodes ──────
591
+ const { BSVNodeClient } = await import('./lib/bsv-node-client.js')
592
+ const bsvNode = new BSVNodeClient()
593
+
594
+ bsvNode.on('headers', async ({ headers, count }) => {
595
+ // Feed into HeaderRelay for peer propagation
596
+ const added = headerRelay.addHeaders(headers)
597
+ if (added > 0) {
598
+ console.log(`BSV P2P: synced ${added} headers (height: ${headerRelay.bestHeight})`)
599
+ // Persist to LevelDB
600
+ try { await store.putHeaders(headers) } catch {}
601
+ // Announce to federation peers
602
+ headerRelay.announceToAll()
603
+ }
604
+ })
605
+
606
+ bsvNode.on('connected', ({ host }) => {
607
+ console.log(`BSV P2P: connected to ${host}:8333`)
608
+ })
609
+
610
+ bsvNode.on('handshake', ({ userAgent, startHeight }) => {
611
+ console.log(`BSV P2P: handshake complete (${userAgent}, height: ${startHeight})`)
612
+ })
613
+
614
+ bsvNode.on('disconnected', () => {
615
+ console.log('BSV P2P: disconnected, will reconnect...')
616
+ })
617
+
618
+ bsvNode.on('error', (err) => {
619
+ // Don't crash — just log
620
+ if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') {
621
+ console.log(`BSV P2P: ${err.message}`)
622
+ }
623
+ })
624
+
625
+ // Auto-detect incoming payments: request txs announced via INV
626
+ bsvNode.on('tx:inv', ({ txids }) => {
627
+ for (const txid of txids) {
628
+ if (txRelay.seen.has(txid)) continue
629
+ bsvNode.getTx(txid, 10000).then(({ txid: id, rawHex }) => {
630
+ txRelay.broadcastTx(id, rawHex)
631
+ }).catch(() => {}) // ignore fetch failures
632
+ }
633
+ })
634
+
635
+ // Feed raw txs from BSV P2P into the mesh relay + address watcher
636
+ bsvNode.on('tx', ({ txid, rawHex }) => {
637
+ if (!txRelay.seen.has(txid)) {
638
+ txRelay.broadcastTx(txid, rawHex)
639
+ }
640
+ })
641
+
642
+ // Start the BSV P2P connection
643
+ bsvNode.connect()
644
+
645
+ // ── 7. Connect to peers ───────────────────────────────────
646
+ let gossipStarted = false
647
+
648
+ // Start gossip after first peer connection completes.
649
+ // Delay 5s so all seed handshakes finish before gossip broadcasts
650
+ // (immediate broadcast would send announce/getpeers through connections
651
+ // whose inbound side is still waiting for verify — breaking the handshake).
652
+ peerManager.on('peer:connect', () => {
653
+ if (!gossipStarted) {
654
+ gossipStarted = true
655
+ setTimeout(() => {
656
+ gossipManager.start()
657
+ gossipManager.requestPeersFromAll()
658
+ console.log('Gossip started')
659
+ }, 5000)
660
+
661
+ // Periodic peer refresh re-request peer lists every 10 minutes
662
+ // Catches registrations missed during downtime or initial gossip
663
+ const PEER_REFRESH_MS = 10 * 60 * 1000
664
+ const refreshTimer = setInterval(() => {
665
+ gossipManager.requestPeersFromAll()
666
+ }, PEER_REFRESH_MS)
667
+ if (refreshTimer.unref) refreshTimer.unref()
668
+ }
669
+ })
670
+
671
+ // Auto-connect to newly discovered peers (with reachability probe + IP diversity)
672
+ const { probeEndpoint } = await import('./lib/endpoint-probe.js')
673
+ const { checkIpDiversity } = await import('./lib/ip-diversity.js')
674
+
675
+ gossipManager.on('peer:discovered', async ({ pubkeyHex, endpoint }) => {
676
+ if (peerManager.peers.has(pubkeyHex) || pubkeyHex === config.pubkeyHex) return
677
+
678
+ // IP diversity check — prevent all peers clustering in one datacenter
679
+ const connectedEndpoints = [...peerManager.peers.values()]
680
+ .filter(c => c.endpoint).map(c => c.endpoint)
681
+ const diversity = checkIpDiversity(connectedEndpoints, endpoint)
682
+ if (!diversity.allowed) {
683
+ console.log(`IP diversity blocked: ${pubkeyHex.slice(0, 16)}... — ${diversity.reason}`)
684
+ return
685
+ }
686
+
687
+ const reachable = await probeEndpoint(endpoint)
688
+ if (!reachable) {
689
+ console.log(`Probe failed: ${pubkeyHex.slice(0, 16)}... ${endpoint} — skipping`)
690
+ return
691
+ }
692
+
693
+ console.log(`Discovered peer via gossip: ${pubkeyHex.slice(0, 16)}... ${endpoint}`)
694
+ const conn = peerManager.connectToPeer({ pubkeyHex, endpoint })
695
+ if (conn) {
696
+ conn.on('open', () => performOutboundHandshake(conn))
697
+ }
698
+ })
699
+
700
+ if (peerArg) {
701
+ // Manual peer connection
702
+ console.log(`Connecting to peer: ${peerArg}`)
703
+ const conn = peerManager.connectToPeer({
704
+ pubkeyHex: 'manual_peer',
705
+ endpoint: peerArg
706
+ })
707
+ if (conn) {
708
+ conn.on('open', () => performOutboundHandshake(conn))
709
+ }
710
+ } else if (seedPeers.length > 0) {
711
+ // Connect to seed peers (accept both string URLs and {pubkeyHex, endpoint} objects)
712
+ console.log(`Connecting to ${seedPeers.length} seed peer(s)...`)
713
+ for (let i = 0; i < seedPeers.length; i++) {
714
+ if (i > 0) await new Promise(r => setTimeout(r, 2000)) // stagger to avoid handshake races
715
+ const seed = seedPeers[i]
716
+ const endpoint = typeof seed === 'string' ? seed : seed.endpoint
717
+ const pubkey = typeof seed === 'string' ? `seed_${i}` : seed.pubkeyHex
718
+ const conn = peerManager.connectToPeer({ pubkeyHex: pubkey, endpoint })
719
+ if (conn) {
720
+ conn.isSeed = true
721
+ conn.on('open', () => performOutboundHandshake(conn))
722
+ }
723
+ }
724
+ } else if (config.apiKey) {
725
+ // Fallback: scan chain for peers (legacy mode)
726
+ console.log('No seed peers configured. Scanning chain for peers...')
727
+ try {
728
+ const { scanRegistry } = await import('@relay-federation/registry/lib/scanner.js')
729
+ const { buildPeerList, excludeSelf } = await import('@relay-federation/registry/lib/discovery.js')
730
+ const { savePeerCache, loadPeerCache } = await import('@relay-federation/registry/lib/peer-cache.js')
731
+
732
+ const cachePath = join(dir, 'cache', 'peers.json')
733
+ let peers = await loadPeerCache(cachePath)
734
+
735
+ if (!peers) {
736
+ const entries = await scanRegistry({
737
+ spvEndpoint: config.spvEndpoint,
738
+ apiKey: config.apiKey
739
+ })
740
+ peers = buildPeerList(entries)
741
+ peers = excludeSelf(peers, config.pubkeyHex)
742
+ await savePeerCache(peers, cachePath)
743
+ }
744
+
745
+ console.log(`Found ${peers.length} peers`)
746
+ for (const peer of peers) {
747
+ const conn = peerManager.connectToPeer(peer)
748
+ if (conn) {
749
+ conn.on('open', () => performOutboundHandshake(conn))
750
+ }
751
+ }
752
+ } catch (err) {
753
+ console.log(`Peer scan failed: ${err.message}`)
754
+ console.log('Start with manual peer: relay-bridge start ws://peer:port')
755
+ }
756
+ } else {
757
+ console.log('No seed peers, no manual peer, and no API key configured.')
758
+ console.log('Usage: relay-bridge start ws://peer:port')
759
+ console.log(' or: Add seedPeers to config.json')
760
+ }
761
+
762
+ // ── 8. Status server ──────────────────────────────────────
763
+ const statusPort = config.statusPort || 9333
764
+ const statusServer = new StatusServer({
765
+ port: statusPort,
766
+ peerManager,
767
+ headerRelay,
768
+ txRelay,
769
+ dataRelay,
770
+ config,
771
+ scorer,
772
+ peerHealth,
773
+ bsvNodeClient: bsvNode,
774
+ store,
775
+ performOutboundHandshake,
776
+ registeredPubkeys,
777
+ gossipManager
778
+ })
779
+ await statusServer.start()
780
+ statusServer.startAppMonitoring()
781
+ console.log(` Status: http://127.0.0.1:${statusPort}/status`)
782
+
783
+ // ── Peer discovery — bootstrap registry from seed peers, then periodic refresh ──
784
+ const knownEndpoints = new Set()
785
+ for (const sp of (config.seedPeers || [])) knownEndpoints.add(sp.endpoint)
786
+ knownEndpoints.add(config.endpoint)
787
+
788
+ async function discoverNewPeers () {
789
+ const peersToQuery = [...(config.seedPeers || [])]
790
+ for (const [, conn] of peerManager.peers) {
791
+ if (conn.endpoint && conn.readyState === 1) peersToQuery.push({ endpoint: conn.endpoint })
792
+ }
793
+ for (const peer of peersToQuery) {
794
+ try {
795
+ const ep = peer.endpoint || ''
796
+ const u = new URL(ep)
797
+ const statusUrl = 'http://' + u.hostname + ':' + (parseInt(u.port, 10) + 1000) + '/discover'
798
+ const res = await fetch(statusUrl, { signal: AbortSignal.timeout(5000) })
799
+ if (!res.ok) continue
800
+ const data = await res.json()
801
+ if (!data.bridges) continue
802
+ for (const b of data.bridges) {
803
+ if (!b.endpoint) continue
804
+ if (b.pubkeyHex) registeredPubkeys.add(b.pubkeyHex)
805
+ seedEndpoints.add(b.endpoint)
806
+ if (knownEndpoints.has(b.endpoint)) continue
807
+ knownEndpoints.add(b.endpoint)
808
+ const conn = peerManager.connectToPeer({ endpoint: b.endpoint, pubkeyHex: b.pubkeyHex })
809
+ if (conn) {
810
+ conn.on('open', () => performOutboundHandshake(conn))
811
+ const msg = `Discovered new peer: ${b.name || b.pubkeyHex?.slice(0, 16) || b.endpoint}`
812
+ console.log(msg)
813
+ statusServer.addLog(msg)
814
+ }
815
+ }
816
+ } catch {}
817
+ }
818
+ }
819
+ await discoverNewPeers()
820
+ console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys after peer discovery`)
821
+ setTimeout(discoverNewPeers, 5000)
822
+ setInterval(discoverNewPeers, 300000)
823
+
824
+ // ── 9. Log events (dual: console + status server ring buffer) ──
825
+ peerManager.on('peer:connect', ({ pubkeyHex }) => {
826
+ const msg = `Peer connected: ${pubkeyHex.slice(0, 16)}...`
827
+ console.log(msg)
828
+ statusServer.addLog(msg)
829
+ })
830
+
831
+ peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
832
+ const msg = `Peer disconnected: ${pubkeyHex ? pubkeyHex.slice(0, 16) + '...' : 'unknown'}`
833
+ console.log(msg)
834
+ statusServer.addLog(msg)
835
+ })
836
+
837
+ headerRelay.on('header:sync', ({ pubkeyHex, added, bestHeight }) => {
838
+ const msg = `Synced ${added} headers from ${pubkeyHex.slice(0, 16)}... (height: ${bestHeight})`
839
+ console.log(msg)
840
+ statusServer.addLog(msg)
841
+ })
842
+
843
+ txRelay.on('tx:new', ({ txid }) => {
844
+ const msg = `New tx: ${txid}`
845
+ console.log(msg)
846
+ statusServer.addLog(msg)
847
+ })
848
+
849
+ dataRelay.on('data:new', ({ topic, pubkeyHex }) => {
850
+ const msg = `Data envelope: ${topic} from ${pubkeyHex.slice(0, 16)}...`
851
+ console.log(msg)
852
+ statusServer.addLog(msg)
853
+ })
854
+
855
+ watcher.on('utxo:received', ({ txid, vout, satoshis }) => {
856
+ const msg = `UTXO received: ${txid}:${vout} (${satoshis} sat)`
857
+ console.log(msg)
858
+ statusServer.addLog(msg)
859
+ })
860
+
861
+ watcher.on('utxo:spent', ({ txid, vout, spentByTxid }) => {
862
+ const msg = `UTXO spent: ${txid.slice(0, 16)}...:${vout} by ${spentByTxid.slice(0, 16)}...`
863
+ console.log(msg)
864
+ statusServer.addLog(msg)
865
+ })
866
+
867
+ // Phase 2 events
868
+ scoreActions.on('peer:disconnected', ({ pubkeyHex, score }) => {
869
+ console.log(`Score disconnect: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)})`)
870
+ })
871
+
872
+ scoreActions.on('peer:blacklisted', ({ pubkeyHex, score }) => {
873
+ console.log(`Blacklisted: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)}, 24h)`)
874
+ })
875
+
876
+ dataValidator.on('validation:fail', ({ pubkeyHex, type, reason }) => {
877
+ console.log(`Bad data from ${pubkeyHex.slice(0, 16)}...: ${type} — ${reason}`)
878
+ })
879
+
880
+ anchorManager.on('anchor:disconnect', ({ pubkeyHex }) => {
881
+ console.log(`Anchor disconnected: ${pubkeyHex.slice(0, 16)}...`)
882
+ })
883
+
884
+ anchorManager.on('anchor:low_score', ({ pubkeyHex, score }) => {
885
+ console.log(`Anchor low score: ${pubkeyHex.slice(0, 16)}... (${score.toFixed(2)})`)
886
+ })
887
+
888
+ peerHealth.on('peer:recovered', ({ pubkeyHex }) => {
889
+ console.log(`Peer recovered: ${pubkeyHex.slice(0, 16)}...`)
890
+ })
891
+
892
+ // ── 10. Graceful shutdown ─────────────────────────────────
893
+ const shutdown = async () => {
894
+ console.log('\nShutting down...')
895
+ clearInterval(pingTimer)
896
+ clearInterval(healthTimer)
897
+ anchorManager.stopMonitoring()
898
+ bsvNode.disconnect()
899
+ gossipManager.stop()
900
+ await statusServer.stop()
901
+ await peerManager.shutdown()
902
+ await store.close()
903
+ console.log('Database closed.')
904
+ process.exit(0)
905
+ }
906
+
907
+ process.on('SIGINT', shutdown)
908
+ process.on('SIGTERM', shutdown)
909
+ }
910
+
911
+ async function cmdStatus () {
912
+ const dir = defaultConfigDir()
913
+
914
+ if (!(await configExists(dir))) {
915
+ console.log('No config found. Run: relay-bridge init')
916
+ process.exit(1)
917
+ }
918
+
919
+ const config = await loadConfig(dir)
920
+ const statusPort = config.statusPort || 9333
921
+
922
+ let status
923
+ try {
924
+ const res = await fetch(`http://127.0.0.1:${statusPort}/status`)
925
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
926
+ status = await res.json()
927
+ } catch {
928
+ console.log('Bridge is not running.')
929
+ console.log(` (expected status server on port ${statusPort})`)
930
+ console.log(' Start it with: relay-bridge start')
931
+ process.exit(1)
932
+ }
933
+
934
+ console.log('Bridge Status\n')
935
+
936
+ // Bridge identity
937
+ console.log(' Bridge')
938
+ console.log(` Pubkey: ${status.bridge.pubkeyHex}`)
939
+ console.log(` Endpoint: ${status.bridge.endpoint}`)
940
+ console.log(` Mesh: ${status.bridge.meshId}`)
941
+ console.log(` Uptime: ${formatUptime(status.bridge.uptimeSeconds)}`)
942
+
943
+ // Peers
944
+ console.log('')
945
+ console.log(` Peers (${status.peers.connected}/${status.peers.max})`)
946
+ if (status.peers.list.length === 0) {
947
+ console.log(' (no peers)')
948
+ } else {
949
+ for (const peer of status.peers.list) {
950
+ const tag = peer.connected ? 'connected' : 'disconnected'
951
+ console.log(` ${peer.pubkeyHex.slice(0, 16)}... ${peer.endpoint} (${tag})`)
952
+ }
953
+ }
954
+
955
+ // Headers
956
+ console.log('')
957
+ console.log(' Headers')
958
+ console.log(` Best Height: ${status.headers.bestHeight}`)
959
+ console.log(` Best Hash: ${status.headers.bestHash || '(none)'}`)
960
+ console.log(` Stored: ${status.headers.count}`)
961
+
962
+ // Transactions
963
+ console.log('')
964
+ console.log(' Transactions')
965
+ console.log(` Mempool: ${status.txs.mempool}`)
966
+ console.log(` Seen: ${status.txs.seen}`)
967
+
968
+ // BSV Node
969
+ if (status.bsvNode) {
970
+ console.log('')
971
+ console.log(' BSV Node')
972
+ console.log(` Status: ${status.bsvNode.connected ? 'Connected' : 'Disconnected'}`)
973
+ console.log(` Host: ${status.bsvNode.host || '-'}`)
974
+ console.log(` Height: ${status.bsvNode.height || '-'}`)
975
+ }
976
+
977
+ // Wallet
978
+ if (status.wallet) {
979
+ console.log('')
980
+ console.log(' Wallet')
981
+ console.log(` Balance: ${status.wallet.balanceSats !== null ? status.wallet.balanceSats + ' sats' : '-'}`)
982
+ }
983
+ }
984
+
985
+ async function cmdFund () {
986
+ const rawHex = process.argv[3]
987
+ if (!rawHex) {
988
+ console.log('Usage: relay-bridge fund <rawTxHex>')
989
+ console.log('')
990
+ console.log(' Provide the raw hex of a transaction that pays to this bridge.')
991
+ console.log(' Get the raw hex from your wallet or a block explorer after sending BSV.')
992
+ process.exit(1)
993
+ }
994
+
995
+ const dir = defaultConfigDir()
996
+
997
+ if (!(await configExists(dir))) {
998
+ console.log('No config found. Run: relay-bridge init')
999
+ process.exit(1)
1000
+ }
1001
+
1002
+ const config = await loadConfig(dir)
1003
+ const { PersistentStore } = await import('./lib/persistent-store.js')
1004
+ const { runFund } = await import('./lib/actions.js')
1005
+
1006
+ const dataDir = config.dataDir || join(dir, 'data')
1007
+ const store = new PersistentStore(dataDir)
1008
+ await store.open()
1009
+
1010
+ try {
1011
+ await runFund({
1012
+ config,
1013
+ store,
1014
+ rawHex,
1015
+ log: (type, msg) => console.log(` ${msg}`)
1016
+ })
1017
+ } catch (err) {
1018
+ console.log(`Fund failed: ${err.message}`)
1019
+ process.exit(1)
1020
+ } finally {
1021
+ await store.close()
1022
+ }
1023
+ }
1024
+
1025
+ async function cmdDeregister () {
1026
+ const dir = defaultConfigDir()
1027
+
1028
+ if (!(await configExists(dir))) {
1029
+ console.log('No config found. Run: relay-bridge init')
1030
+ process.exit(1)
1031
+ }
1032
+
1033
+ const config = await loadConfig(dir)
1034
+ const reason = process.argv[3] || 'shutdown'
1035
+
1036
+ const { PersistentStore } = await import('./lib/persistent-store.js')
1037
+ const { runDeregister } = await import('./lib/actions.js')
1038
+
1039
+ const dataDir = config.dataDir || join(dir, 'data')
1040
+ const store = new PersistentStore(dataDir)
1041
+ await store.open()
1042
+
1043
+ console.log('Deregistration details:\n')
1044
+
1045
+ try {
1046
+ await runDeregister({
1047
+ config,
1048
+ store,
1049
+ reason,
1050
+ log: (type, msg) => console.log(type === 'done' ? msg : ` ${msg}`)
1051
+ })
1052
+ console.log('')
1053
+ console.log('Your bridge will be removed from peer lists on next scan cycle.')
1054
+ } catch (err) {
1055
+ console.log(`Deregistration failed: ${err.message}`)
1056
+ await store.close()
1057
+ process.exit(1)
1058
+ }
1059
+
1060
+ await store.close()
1061
+ }
1062
+
1063
+ function formatUptime (seconds) {
1064
+ const days = Math.floor(seconds / 86400)
1065
+ const hours = Math.floor((seconds % 86400) / 3600)
1066
+ const minutes = Math.floor((seconds % 3600) / 60)
1067
+ const secs = seconds % 60
1068
+
1069
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`
1070
+ if (hours > 0) return `${hours}h ${minutes}m ${secs}s`
1071
+ if (minutes > 0) return `${minutes}m ${secs}s`
1072
+ return `${secs}s`
1073
+ }