@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 CHANGED
@@ -6,7 +6,7 @@ import { PeerManager } from './lib/peer-manager.js'
6
6
  import { HeaderRelay } from './lib/header-relay.js'
7
7
  import { TxRelay } from './lib/tx-relay.js'
8
8
  import { StatusServer } from './lib/status-server.js'
9
- import { fetchUtxos, broadcastTx } from '@relay-federation/common/network'
9
+ // network.js import removed register/deregister now use local UTXOs + P2P broadcast
10
10
 
11
11
  const command = process.argv[2]
12
12
 
@@ -23,9 +23,18 @@ switch (command) {
23
23
  case 'status':
24
24
  await cmdStatus()
25
25
  break
26
+ case 'fund':
27
+ await cmdFund()
28
+ break
26
29
  case 'deregister':
27
30
  await cmdDeregister()
28
31
  break
32
+ case 'secret':
33
+ await cmdSecret()
34
+ break
35
+ case 'backfill':
36
+ await cmdBackfill()
37
+ break
29
38
  default:
30
39
  console.log('relay-bridge — Federated SPV relay mesh bridge\n')
31
40
  console.log('Commands:')
@@ -33,7 +42,10 @@ switch (command) {
33
42
  console.log(' register Register this bridge on-chain')
34
43
  console.log(' start Start the bridge server')
35
44
  console.log(' status Show running bridge status')
45
+ console.log(' fund Import a funding transaction (raw hex)')
36
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')
37
49
  console.log('')
38
50
  console.log('Usage: relay-bridge <command> [options]')
39
51
  process.exit(command ? 1 : 0)
@@ -51,15 +63,167 @@ async function cmdInit () {
51
63
  const config = await initConfig(dir)
52
64
 
53
65
  console.log('Bridge initialized!\n')
54
- console.log(` Config: ${dir}/config.json`)
55
- console.log(` Pubkey: ${config.pubkeyHex}`)
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.')
56
74
  console.log('')
57
75
  console.log('Next steps:')
58
- console.log(' 1. Edit config.json — set your WSS endpoint and API key')
59
- console.log(' 2. Fund your bridge address with BSV')
76
+ console.log(` 1. Fund your bridge: send BSV to ${config.address}`)
77
+ console.log(' 2. Import the funding tx: relay-bridge fund <rawTxHex>')
60
78
  console.log(' 3. Run: relay-bridge register')
61
79
  }
62
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
+
63
227
  async function cmdRegister () {
64
228
  const dir = defaultConfigDir()
65
229
 
@@ -75,18 +239,30 @@ async function cmdRegister () {
75
239
  process.exit(1)
76
240
  }
77
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
+
78
249
  console.log('Registration details:\n')
79
- console.log(` Pubkey: ${config.pubkeyHex}`)
80
- console.log(` Endpoint: ${config.endpoint}`)
81
- console.log(` Mesh: ${config.meshId}`)
82
- console.log(` Capabilities: ${config.capabilities.join(', ')}`)
83
- console.log(` SPV Endpoint: ${config.spvEndpoint}`)
84
- console.log('')
85
- console.log('On-chain registration requires:')
86
- console.log(' - Funded wallet (stake bond + tx fees)')
87
- console.log(' - Valid API key for SPV bridge access')
88
- console.log('')
89
- console.log('Broadcast support coming in Phase 2.')
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()
90
266
  }
91
267
 
92
268
  async function cmdStart () {
@@ -98,18 +274,421 @@ async function cmdStart () {
98
274
  }
99
275
 
100
276
  const config = await loadConfig(dir)
101
- const peerArg = process.argv[3] // optional: ws://host:port
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
+ }
102
294
 
295
+ // ── 2. Core components ────────────────────────────────────
103
296
  const peerManager = new PeerManager({ maxPeers: config.maxPeers })
104
297
  const headerRelay = new HeaderRelay(peerManager)
105
298
  const txRelay = new TxRelay(peerManager)
106
299
 
107
- // Start server
108
- await peerManager.startServer({ port: config.port, host: '0.0.0.0' })
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 }) => {
324
+ peerHealth.recordSeen(pubkeyHex)
325
+ scorer.setStakeAge(pubkeyHex, 7)
326
+ })
327
+
328
+ peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
329
+ peerHealth.recordOffline(pubkeyHex)
330
+ })
331
+
332
+ // Wire peer:message → health.recordSeen (any message = peer is alive)
333
+ peerManager.on('peer:message', ({ pubkeyHex }) => {
334
+ peerHealth.recordSeen(pubkeyHex)
335
+ })
336
+
337
+ // Ping infrastructure — 60s interval, measures latency for scoring
338
+ const PING_INTERVAL_MS = 60000
339
+ const pendingPings = new Map() // pubkeyHex → timestamp
340
+
341
+ peerManager.on('peer:message', ({ pubkeyHex, message }) => {
342
+ if (message.type === 'ping') {
343
+ // Respond with pong
344
+ const conn = peerManager.peers.get(pubkeyHex)
345
+ if (conn) conn.send({ type: 'pong', nonce: message.nonce })
346
+ } else if (message.type === 'pong') {
347
+ // Record latency
348
+ const sentAt = pendingPings.get(pubkeyHex)
349
+ if (sentAt) {
350
+ const latency = Date.now() - sentAt
351
+ pendingPings.delete(pubkeyHex)
352
+ if (!peerHealth.isInGracePeriod(pubkeyHex)) {
353
+ scorer.recordPing(pubkeyHex, latency)
354
+ }
355
+ }
356
+ }
357
+ })
358
+
359
+ const pingTimer = setInterval(() => {
360
+ const nonce = Date.now().toString(36)
361
+ for (const [pubkeyHex, conn] of peerManager.peers) {
362
+ if (conn.connected) {
363
+ // Check for timed-out previous pings
364
+ if (pendingPings.has(pubkeyHex)) {
365
+ if (!peerHealth.isInGracePeriod(pubkeyHex)) {
366
+ scorer.recordPingTimeout(pubkeyHex)
367
+ }
368
+ pendingPings.delete(pubkeyHex)
369
+ }
370
+ pendingPings.set(pubkeyHex, Date.now())
371
+ conn.send({ type: 'ping', nonce })
372
+ }
373
+ }
374
+ }, PING_INTERVAL_MS)
375
+ if (pingTimer.unref) pingTimer.unref()
376
+
377
+ // Health check — every 10 minutes, detect inactive peers
378
+ const HEALTH_CHECK_INTERVAL_MS = 10 * 60 * 1000
379
+ const healthTimer = setInterval(() => {
380
+ const { grace, inactive } = peerHealth.checkAll()
381
+ for (const pk of inactive) {
382
+ console.log(`Peer inactive (7d+): ${pk.slice(0, 16)}...`)
383
+ }
384
+ }, HEALTH_CHECK_INTERVAL_MS)
385
+ if (healthTimer.unref) healthTimer.unref()
386
+
387
+ // Start anchor monitoring
388
+ anchorManager.startMonitoring()
389
+
390
+ console.log(` Security: scoring, validation, health, anchors active`)
391
+
392
+ // ── 3. Address watcher — watch our own address + beacon ──
393
+ const { AddressWatcher } = await import('./lib/address-watcher.js')
394
+ const { addressToHash160 } = await import('./lib/output-parser.js')
395
+ const { BEACON_ADDRESS } = await import('@relay-federation/common/protocol')
396
+ const watcher = new AddressWatcher(txRelay, store)
397
+ watcher.watchPubkey(config.pubkeyHex, 'self')
398
+ const beaconHash160 = addressToHash160(BEACON_ADDRESS)
399
+ watcher.watchHash160(beaconHash160, 'beacon')
400
+ console.log(` Watching own address + beacon (${BEACON_ADDRESS})`)
401
+
402
+ // ── 4. Gossip manager — P2P peer discovery ────────────────
403
+ const { GossipManager } = await import('./lib/gossip.js')
404
+ const privKey = PrivateKey.fromWif(config.wif)
405
+ const gossipManager = new GossipManager(peerManager, {
406
+ privKey,
407
+ pubkeyHex: config.pubkeyHex,
408
+ endpoint: config.endpoint,
409
+ meshId: config.meshId
410
+ })
411
+
412
+ // Add seed peers to gossip directory
413
+ const seedPeers = config.seedPeers || []
414
+ for (const seed of seedPeers) {
415
+ gossipManager.addSeed(seed)
416
+ }
417
+
418
+ // ── 4a. Registry — track registered pubkeys for handshake gating ──
419
+ const registeredPubkeys = new Set()
420
+ registeredPubkeys.add(config.pubkeyHex) // always trust self
421
+ const seedEndpoints = new Set()
422
+ for (const seed of seedPeers) {
423
+ if (seed.pubkeyHex) registeredPubkeys.add(seed.pubkeyHex)
424
+ const ep = typeof seed === 'string' ? seed : seed.endpoint
425
+ if (ep) seedEndpoints.add(ep)
426
+ }
427
+ console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
428
+
429
+ // ── 4b. Beacon address watcher — detect on-chain registrations ──
430
+ const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('../registry/lib/cbor.js')
431
+ const { Transaction: BsvTx } = await import('@bsv/sdk')
432
+
433
+ watcher.on('utxo:received', async ({ txid, hash160 }) => {
434
+ if (hash160 !== beaconHash160) return
435
+
436
+ try {
437
+ const rawHex = await store.getTx(txid)
438
+ if (!rawHex) return
439
+
440
+ const tx = BsvTx.fromHex(rawHex)
441
+ const opReturnOutput = tx.outputs.find(out =>
442
+ out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
443
+ )
444
+ if (!opReturnOutput) return
445
+
446
+ const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
447
+ if (prefix !== PROTOCOL_PREFIX) return
448
+
449
+ const entry = decodePayload(cborBytes)
450
+
451
+ if (entry.action === 'register') {
452
+ const pubHex = Buffer.from(entry.pubkey).toString('hex')
453
+ if (pubHex === config.pubkeyHex) return // skip self
454
+
455
+ registeredPubkeys.add(pubHex)
456
+ gossipManager.addSeed({
457
+ pubkeyHex: pubHex,
458
+ endpoint: entry.endpoint,
459
+ meshId: entry.mesh_id
460
+ })
461
+ console.log(`Beacon: new registration detected — ${pubHex.slice(0, 16)}... @ ${entry.endpoint}`)
462
+ const stakeAgeDays = Math.max(0, (Date.now() / 1000 - entry.timestamp) / 86400)
463
+ scorer.setStakeAge(pubHex, stakeAgeDays)
464
+ } else if (entry.action === 'deregister') {
465
+ const pubHex = Buffer.from(entry.pubkey).toString('hex')
466
+ registeredPubkeys.delete(pubHex)
467
+ console.log(`Beacon: deregistration detected — ${pubHex.slice(0, 16)}...`)
468
+ }
469
+ } catch {
470
+ // Skip unparseable beacon txs
471
+ }
472
+ })
473
+
474
+
475
+ // ── Outbound handshake helper ──────────────────────────────
476
+ function performOutboundHandshake (conn) {
477
+ const { message: helloMsg, nonce } = handshake.createHello()
478
+ conn.send(helloMsg)
479
+
480
+ const onMessage = (msg) => {
481
+ if (msg.type === 'challenge_response') {
482
+ conn.removeListener('message', onMessage)
483
+ clearTimeout(timeout)
484
+ const result = handshake.handleChallengeResponse(msg, nonce, conn.isSeed ? null : registeredPubkeys)
485
+ if (result.error) {
486
+ console.log(`Handshake failed with ${conn.pubkeyHex.slice(0, 16)}...: ${result.error}`)
487
+ conn.destroy()
488
+ return
489
+ }
490
+ // Re-key if we didn't know their real pubkey
491
+ if (result.peerPubkey !== conn.pubkeyHex) {
492
+ peerManager.peers.delete(conn.pubkeyHex)
493
+ conn.pubkeyHex = result.peerPubkey
494
+ }
495
+
496
+ // Tie-break duplicate connections (inbound may have been accepted during handshake)
497
+ const existing = peerManager.peers.get(result.peerPubkey)
498
+ if (existing && existing !== conn) {
499
+ if (config.pubkeyHex > result.peerPubkey) {
500
+ // Higher pubkey drops outbound — keep existing inbound
501
+ console.log(` Duplicate: keeping inbound from ${result.peerPubkey.slice(0, 16)}...`)
502
+ conn._shouldReconnect = false
503
+ conn.destroy()
504
+ return
505
+ }
506
+ // Lower pubkey keeps outbound — drop existing inbound
507
+ console.log(` Duplicate: keeping outbound to ${result.peerPubkey.slice(0, 16)}...`)
508
+ existing._shouldReconnect = false
509
+ existing.destroy()
510
+ }
511
+
512
+ peerManager.peers.set(result.peerPubkey, conn)
513
+ // Learn seed pubkeys so future inbound connections from them pass registry check
514
+ if (conn.isSeed && !registeredPubkeys.has(result.peerPubkey)) {
515
+ registeredPubkeys.add(result.peerPubkey)
516
+ console.log(` Seed pubkey learned: ${result.peerPubkey.slice(0, 16)}...`)
517
+ }
518
+ console.log(` Peer identified: ${result.peerPubkey.slice(0, 16)}... (v${result.selectedVersion})`)
519
+
520
+ // Send verify to complete handshake
521
+ conn.send(result.message)
522
+ // Handshake complete — now safe to announce peer:connect
523
+ peerManager.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
524
+ }
525
+ }
526
+ conn.on('message', onMessage)
527
+
528
+ // Timeout: if no challenge_response within 10s, drop
529
+ const timeout = setTimeout(() => {
530
+ conn.removeListener('message', onMessage)
531
+ if (!conn.connected) return
532
+ console.log(`Handshake timeout: ${conn.pubkeyHex.slice(0, 16)}...`)
533
+ conn.destroy()
534
+ }, 10000)
535
+ if (timeout.unref) timeout.unref()
536
+
537
+ // Clean up on close — prevent stale handler on reconnect
538
+ conn.once('close', () => {
539
+ clearTimeout(timeout)
540
+ conn.removeListener('message', onMessage)
541
+ })
542
+ }
543
+
544
+ // ── 5. Start server ───────────────────────────────────────
545
+ await peerManager.startServer({ port: config.port, host: '0.0.0.0', pubkeyHex: config.pubkeyHex, endpoint: config.endpoint, handshake, registeredPubkeys, seedEndpoints })
109
546
  console.log(`Bridge listening on port ${config.port}`)
110
547
  console.log(` Pubkey: ${config.pubkeyHex}`)
111
548
  console.log(` Mesh: ${config.meshId}`)
112
549
 
550
+ // ── 6. Persistence layer — save headers/txs to LevelDB ───
551
+ headerRelay.on('header:new', async (header) => {
552
+ try { await store.putHeader(header) } catch {}
553
+ })
554
+
555
+ headerRelay.on('header:sync', async ({ headers }) => {
556
+ if (headers && headers.length) {
557
+ try { await store.putHeaders(headers) } catch {}
558
+ }
559
+ })
560
+
561
+ txRelay.on('tx:new', async ({ txid, rawHex }) => {
562
+ try { await store.putTx(txid, rawHex) } catch {}
563
+ try { await store.updateTxStatus(txid, 'mempool', { source: 'p2p' }) } catch {}
564
+ // Index inscriptions
565
+ try {
566
+ const { parseTx } = await import('./lib/output-parser.js')
567
+ const parsed = parseTx(rawHex)
568
+ for (const output of parsed.outputs) {
569
+ if (output.type === 'ordinal' && output.parsed) {
570
+ await store.putInscription({
571
+ txid,
572
+ vout: output.vout,
573
+ contentType: output.parsed.contentType || null,
574
+ contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
575
+ isBsv20: output.parsed.isBsv20 || false,
576
+ bsv20: output.parsed.bsv20 || null,
577
+ timestamp: Date.now(),
578
+ address: output.hash160 || null
579
+ })
580
+ }
581
+ }
582
+ } catch {}
583
+ })
584
+
585
+ // ── 6b. BSV P2P header sync — connect to BSV nodes ──────
586
+ const { BSVNodeClient } = await import('./lib/bsv-node-client.js')
587
+ const bsvNode = new BSVNodeClient()
588
+
589
+ bsvNode.on('headers', async ({ headers, count }) => {
590
+ // Feed into HeaderRelay for peer propagation
591
+ const added = headerRelay.addHeaders(headers)
592
+ if (added > 0) {
593
+ console.log(`BSV P2P: synced ${added} headers (height: ${headerRelay.bestHeight})`)
594
+ // Persist to LevelDB
595
+ try { await store.putHeaders(headers) } catch {}
596
+ // Announce to federation peers
597
+ headerRelay.announceToAll()
598
+ }
599
+ })
600
+
601
+ bsvNode.on('connected', ({ host }) => {
602
+ console.log(`BSV P2P: connected to ${host}:8333`)
603
+ })
604
+
605
+ bsvNode.on('handshake', ({ userAgent, startHeight }) => {
606
+ console.log(`BSV P2P: handshake complete (${userAgent}, height: ${startHeight})`)
607
+ })
608
+
609
+ bsvNode.on('disconnected', () => {
610
+ console.log('BSV P2P: disconnected, will reconnect...')
611
+ })
612
+
613
+ bsvNode.on('error', (err) => {
614
+ // Don't crash — just log
615
+ if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') {
616
+ console.log(`BSV P2P: ${err.message}`)
617
+ }
618
+ })
619
+
620
+ // Auto-detect incoming payments: request txs announced via INV
621
+ bsvNode.on('tx:inv', ({ txids }) => {
622
+ for (const txid of txids) {
623
+ if (txRelay.seen.has(txid)) continue
624
+ bsvNode.getTx(txid, 10000).then(({ txid: id, rawHex }) => {
625
+ txRelay.broadcastTx(id, rawHex)
626
+ }).catch(() => {}) // ignore fetch failures
627
+ }
628
+ })
629
+
630
+ // Feed raw txs from BSV P2P into the mesh relay + address watcher
631
+ bsvNode.on('tx', ({ txid, rawHex }) => {
632
+ if (!txRelay.seen.has(txid)) {
633
+ txRelay.broadcastTx(txid, rawHex)
634
+ }
635
+ })
636
+
637
+ // Start the BSV P2P connection
638
+ bsvNode.connect()
639
+
640
+ // ── 7. Connect to peers ───────────────────────────────────
641
+ let gossipStarted = false
642
+
643
+ // Start gossip after first peer connection completes.
644
+ // With crypto handshake, peer:connect fires AFTER handshake verification,
645
+ // so gossip won't race the handshake anymore.
646
+ peerManager.on('peer:connect', () => {
647
+ if (!gossipStarted) {
648
+ gossipStarted = true
649
+ gossipManager.start()
650
+ gossipManager.requestPeersFromAll()
651
+ console.log('Gossip started')
652
+
653
+ // Periodic peer refresh — re-request peer lists every 10 minutes
654
+ // Catches registrations missed during downtime or initial gossip
655
+ const PEER_REFRESH_MS = 10 * 60 * 1000
656
+ const refreshTimer = setInterval(() => {
657
+ gossipManager.requestPeersFromAll()
658
+ }, PEER_REFRESH_MS)
659
+ if (refreshTimer.unref) refreshTimer.unref()
660
+ }
661
+ })
662
+
663
+ // Auto-connect to newly discovered peers (with reachability probe + IP diversity)
664
+ const { probeEndpoint } = await import('./lib/endpoint-probe.js')
665
+ const { checkIpDiversity } = await import('./lib/ip-diversity.js')
666
+
667
+ gossipManager.on('peer:discovered', async ({ pubkeyHex, endpoint }) => {
668
+ if (peerManager.peers.has(pubkeyHex) || pubkeyHex === config.pubkeyHex) return
669
+
670
+ // IP diversity check — prevent all peers clustering in one datacenter
671
+ const connectedEndpoints = [...peerManager.peers.values()]
672
+ .filter(c => c.endpoint).map(c => c.endpoint)
673
+ const diversity = checkIpDiversity(connectedEndpoints, endpoint)
674
+ if (!diversity.allowed) {
675
+ console.log(`IP diversity blocked: ${pubkeyHex.slice(0, 16)}... — ${diversity.reason}`)
676
+ return
677
+ }
678
+
679
+ const reachable = await probeEndpoint(endpoint)
680
+ if (!reachable) {
681
+ console.log(`Probe failed: ${pubkeyHex.slice(0, 16)}... ${endpoint} — skipping`)
682
+ return
683
+ }
684
+
685
+ console.log(`Discovered peer via gossip: ${pubkeyHex.slice(0, 16)}... ${endpoint}`)
686
+ const conn = peerManager.connectToPeer({ pubkeyHex, endpoint })
687
+ if (conn) {
688
+ conn.on('open', () => performOutboundHandshake(conn))
689
+ }
690
+ })
691
+
113
692
  if (peerArg) {
114
693
  // Manual peer connection
115
694
  console.log(`Connecting to peer: ${peerArg}`)
@@ -118,17 +697,24 @@ async function cmdStart () {
118
697
  endpoint: peerArg
119
698
  })
120
699
  if (conn) {
121
- conn.on('open', () => {
122
- conn.send({
123
- type: 'hello',
124
- pubkey: config.pubkeyHex,
125
- endpoint: config.endpoint
126
- })
127
- })
700
+ conn.on('open', () => performOutboundHandshake(conn))
701
+ }
702
+ } else if (seedPeers.length > 0) {
703
+ // Connect to seed peers (accept both string URLs and {pubkeyHex, endpoint} objects)
704
+ console.log(`Connecting to ${seedPeers.length} seed peer(s)...`)
705
+ for (let i = 0; i < seedPeers.length; i++) {
706
+ const seed = seedPeers[i]
707
+ const endpoint = typeof seed === 'string' ? seed : seed.endpoint
708
+ const pubkey = typeof seed === 'string' ? `seed_${i}` : seed.pubkeyHex
709
+ const conn = peerManager.connectToPeer({ pubkeyHex: pubkey, endpoint })
710
+ if (conn) {
711
+ conn.isSeed = true
712
+ conn.on('open', () => performOutboundHandshake(conn))
713
+ }
128
714
  }
129
715
  } else if (config.apiKey) {
130
- // Scan chain for peers
131
- console.log('Scanning chain for peers...')
716
+ // Fallback: scan chain for peers (legacy mode)
717
+ console.log('No seed peers configured. Scanning chain for peers...')
132
718
  try {
133
719
  const { scanRegistry } = await import('../registry/lib/scanner.js')
134
720
  const { buildPeerList, excludeSelf } = await import('../registry/lib/discovery.js')
@@ -151,13 +737,7 @@ async function cmdStart () {
151
737
  for (const peer of peers) {
152
738
  const conn = peerManager.connectToPeer(peer)
153
739
  if (conn) {
154
- conn.on('open', () => {
155
- conn.send({
156
- type: 'hello',
157
- pubkey: config.pubkeyHex,
158
- endpoint: config.endpoint
159
- })
160
- })
740
+ conn.on('open', () => performOutboundHandshake(conn))
161
741
  }
162
742
  }
163
743
  } catch (err) {
@@ -165,44 +745,105 @@ async function cmdStart () {
165
745
  console.log('Start with manual peer: relay-bridge start ws://peer:port')
166
746
  }
167
747
  } else {
168
- console.log('No peer specified and no API key configured.')
748
+ console.log('No seed peers, no manual peer, and no API key configured.')
169
749
  console.log('Usage: relay-bridge start ws://peer:port')
750
+ console.log(' or: Add seedPeers to config.json')
170
751
  }
171
752
 
172
- // Start status server (localhost only)
753
+ // ── 8. Status server ──────────────────────────────────────
173
754
  const statusPort = config.statusPort || 9333
174
755
  const statusServer = new StatusServer({
175
756
  port: statusPort,
176
757
  peerManager,
177
758
  headerRelay,
178
759
  txRelay,
179
- config
760
+ config,
761
+ scorer,
762
+ peerHealth,
763
+ bsvNodeClient: bsvNode,
764
+ store,
765
+ performOutboundHandshake,
766
+ registeredPubkeys,
767
+ gossipManager
180
768
  })
181
769
  await statusServer.start()
770
+ statusServer.startAppMonitoring()
182
771
  console.log(` Status: http://127.0.0.1:${statusPort}/status`)
183
772
 
184
- // Log events
773
+ // ── 9. Log events (dual: console + status server ring buffer) ──
185
774
  peerManager.on('peer:connect', ({ pubkeyHex }) => {
186
- console.log(`Peer connected: ${pubkeyHex}`)
775
+ const msg = `Peer connected: ${pubkeyHex.slice(0, 16)}...`
776
+ console.log(msg)
777
+ statusServer.addLog(msg)
187
778
  })
188
779
 
189
780
  peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
190
- console.log(`Peer disconnected: ${pubkeyHex}`)
781
+ const msg = `Peer disconnected: ${pubkeyHex ? pubkeyHex.slice(0, 16) + '...' : 'unknown'}`
782
+ console.log(msg)
783
+ statusServer.addLog(msg)
191
784
  })
192
785
 
193
786
  headerRelay.on('header:sync', ({ pubkeyHex, added, bestHeight }) => {
194
- console.log(`Synced ${added} headers from ${pubkeyHex} (height: ${bestHeight})`)
787
+ const msg = `Synced ${added} headers from ${pubkeyHex.slice(0, 16)}... (height: ${bestHeight})`
788
+ console.log(msg)
789
+ statusServer.addLog(msg)
195
790
  })
196
791
 
197
792
  txRelay.on('tx:new', ({ txid }) => {
198
- console.log(`New tx: ${txid}`)
793
+ const msg = `New tx: ${txid}`
794
+ console.log(msg)
795
+ statusServer.addLog(msg)
796
+ })
797
+
798
+ watcher.on('utxo:received', ({ txid, vout, satoshis }) => {
799
+ const msg = `UTXO received: ${txid}:${vout} (${satoshis} sat)`
800
+ console.log(msg)
801
+ statusServer.addLog(msg)
802
+ })
803
+
804
+ watcher.on('utxo:spent', ({ txid, vout, spentByTxid }) => {
805
+ const msg = `UTXO spent: ${txid.slice(0, 16)}...:${vout} by ${spentByTxid.slice(0, 16)}...`
806
+ console.log(msg)
807
+ statusServer.addLog(msg)
808
+ })
809
+
810
+ // Phase 2 events
811
+ scoreActions.on('peer:disconnected', ({ pubkeyHex, score }) => {
812
+ console.log(`Score disconnect: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)})`)
813
+ })
814
+
815
+ scoreActions.on('peer:blacklisted', ({ pubkeyHex, score }) => {
816
+ console.log(`Blacklisted: ${pubkeyHex.slice(0, 16)}... (score: ${score.toFixed(2)}, 24h)`)
817
+ })
818
+
819
+ dataValidator.on('validation:fail', ({ pubkeyHex, type, reason }) => {
820
+ console.log(`Bad data from ${pubkeyHex.slice(0, 16)}...: ${type} — ${reason}`)
821
+ })
822
+
823
+ anchorManager.on('anchor:disconnect', ({ pubkeyHex }) => {
824
+ console.log(`Anchor disconnected: ${pubkeyHex.slice(0, 16)}...`)
199
825
  })
200
826
 
201
- // Graceful shutdown
827
+ anchorManager.on('anchor:low_score', ({ pubkeyHex, score }) => {
828
+ console.log(`Anchor low score: ${pubkeyHex.slice(0, 16)}... (${score.toFixed(2)})`)
829
+ })
830
+
831
+ peerHealth.on('peer:recovered', ({ pubkeyHex }) => {
832
+ console.log(`Peer recovered: ${pubkeyHex.slice(0, 16)}...`)
833
+ })
834
+
835
+ // ── 10. Graceful shutdown ─────────────────────────────────
202
836
  const shutdown = async () => {
203
837
  console.log('\nShutting down...')
838
+ clearInterval(pingTimer)
839
+ clearInterval(healthTimer)
840
+ anchorManager.stopMonitoring()
841
+ bsvNode.disconnect()
842
+ gossipManager.stop()
204
843
  await statusServer.stop()
205
844
  await peerManager.shutdown()
845
+ await store.close()
846
+ console.log('Database closed.')
206
847
  process.exit(0)
207
848
  }
208
849
 
@@ -266,9 +907,34 @@ async function cmdStatus () {
266
907
  console.log(' Transactions')
267
908
  console.log(` Mempool: ${status.txs.mempool}`)
268
909
  console.log(` Seen: ${status.txs.seen}`)
910
+
911
+ // BSV Node
912
+ if (status.bsvNode) {
913
+ console.log('')
914
+ console.log(' BSV Node')
915
+ console.log(` Status: ${status.bsvNode.connected ? 'Connected' : 'Disconnected'}`)
916
+ console.log(` Host: ${status.bsvNode.host || '-'}`)
917
+ console.log(` Height: ${status.bsvNode.height || '-'}`)
918
+ }
919
+
920
+ // Wallet
921
+ if (status.wallet) {
922
+ console.log('')
923
+ console.log(' Wallet')
924
+ console.log(` Balance: ${status.wallet.balanceSats !== null ? status.wallet.balanceSats + ' sats' : '-'}`)
925
+ }
269
926
  }
270
927
 
271
- async function cmdDeregister () {
928
+ async function cmdFund () {
929
+ const rawHex = process.argv[3]
930
+ if (!rawHex) {
931
+ console.log('Usage: relay-bridge fund <rawTxHex>')
932
+ console.log('')
933
+ console.log(' Provide the raw hex of a transaction that pays to this bridge.')
934
+ console.log(' Get the raw hex from your wallet or a block explorer after sending BSV.')
935
+ process.exit(1)
936
+ }
937
+
272
938
  const dir = defaultConfigDir()
273
939
 
274
940
  if (!(await configExists(dir))) {
@@ -277,50 +943,64 @@ async function cmdDeregister () {
277
943
  }
278
944
 
279
945
  const config = await loadConfig(dir)
946
+ const { PersistentStore } = await import('./lib/persistent-store.js')
947
+ const { runFund } = await import('./lib/actions.js')
280
948
 
281
- if (!config.apiKey) {
282
- console.log('Error: API key required for deregistration (broadcasts via SPV bridge).')
283
- console.log('Set "apiKey" in config.json.')
949
+ const dataDir = config.dataDir || join(dir, 'data')
950
+ const store = new PersistentStore(dataDir)
951
+ await store.open()
952
+
953
+ try {
954
+ await runFund({
955
+ config,
956
+ store,
957
+ rawHex,
958
+ log: (type, msg) => console.log(` ${msg}`)
959
+ })
960
+ } catch (err) {
961
+ console.log(`Fund failed: ${err.message}`)
284
962
  process.exit(1)
963
+ } finally {
964
+ await store.close()
285
965
  }
966
+ }
286
967
 
287
- const reason = process.argv[3] || 'shutdown'
288
-
289
- console.log('Deregistration details:\n')
290
- console.log(` Pubkey: ${config.pubkeyHex}`)
291
- console.log(` Reason: ${reason}`)
292
- console.log(` SPV: ${config.spvEndpoint}`)
293
- console.log('')
968
+ async function cmdDeregister () {
969
+ const dir = defaultConfigDir()
294
970
 
295
- try {
296
- const { buildDeregistrationTx } = await import('../registry/lib/registration.js')
971
+ if (!(await configExists(dir))) {
972
+ console.log('No config found. Run: relay-bridge init')
973
+ process.exit(1)
974
+ }
297
975
 
298
- // Fetch UTXOs from SPV bridge
299
- const utxos = await fetchUtxos(config.spvEndpoint, config.apiKey, config.pubkeyHex)
976
+ const config = await loadConfig(dir)
977
+ const reason = process.argv[3] || 'shutdown'
300
978
 
301
- if (!utxos.length) {
302
- console.log('Error: No UTXOs found. Wallet needs funding for tx fees.')
303
- process.exit(1)
304
- }
979
+ const { PersistentStore } = await import('./lib/persistent-store.js')
980
+ const { runDeregister } = await import('./lib/actions.js')
305
981
 
306
- // Build deregistration tx
307
- const { txHex, txid } = await buildDeregistrationTx({
308
- wif: config.wif,
309
- utxos,
310
- reason
311
- })
982
+ const dataDir = config.dataDir || join(dir, 'data')
983
+ const store = new PersistentStore(dataDir)
984
+ await store.open()
312
985
 
313
- // Broadcast via SPV bridge
314
- await broadcastTx(config.spvEndpoint, config.apiKey, txHex)
986
+ console.log('Deregistration details:\n')
315
987
 
316
- console.log('Deregistration broadcast successful!')
317
- console.log(` txid: ${txid}`)
988
+ try {
989
+ await runDeregister({
990
+ config,
991
+ store,
992
+ reason,
993
+ log: (type, msg) => console.log(type === 'done' ? msg : ` ${msg}`)
994
+ })
318
995
  console.log('')
319
996
  console.log('Your bridge will be removed from peer lists on next scan cycle.')
320
997
  } catch (err) {
321
998
  console.log(`Deregistration failed: ${err.message}`)
999
+ await store.close()
322
1000
  process.exit(1)
323
1001
  }
1002
+
1003
+ await store.close()
324
1004
  }
325
1005
 
326
1006
  function formatUptime (seconds) {