@relay-federation/bridge 0.1.2 → 0.3.0

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