@relay-federation/bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js ADDED
@@ -0,0 +1,336 @@
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
+ import { fetchUtxos, broadcastTx } from '@relay-federation/common/network'
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 'deregister':
27
+ await cmdDeregister()
28
+ break
29
+ default:
30
+ console.log('relay-bridge — Federated SPV relay mesh bridge\n')
31
+ console.log('Commands:')
32
+ console.log(' init Generate bridge identity and config')
33
+ console.log(' register Register this bridge on-chain')
34
+ console.log(' start Start the bridge server')
35
+ console.log(' status Show running bridge status')
36
+ console.log(' deregister Deregister this bridge from the mesh')
37
+ console.log('')
38
+ console.log('Usage: relay-bridge <command> [options]')
39
+ process.exit(command ? 1 : 0)
40
+ }
41
+
42
+ async function cmdInit () {
43
+ const dir = defaultConfigDir()
44
+
45
+ if (await configExists(dir)) {
46
+ console.log(`Config already exists at ${dir}/config.json`)
47
+ console.log('To re-initialize, delete the existing config first.')
48
+ process.exit(1)
49
+ }
50
+
51
+ const config = await initConfig(dir)
52
+
53
+ console.log('Bridge initialized!\n')
54
+ console.log(` Config: ${dir}/config.json`)
55
+ console.log(` Pubkey: ${config.pubkeyHex}`)
56
+ console.log('')
57
+ 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')
61
+ }
62
+
63
+ async function cmdRegister () {
64
+ const dir = defaultConfigDir()
65
+
66
+ if (!(await configExists(dir))) {
67
+ console.log('No config found. Run: relay-bridge init')
68
+ process.exit(1)
69
+ }
70
+
71
+ const config = await loadConfig(dir)
72
+
73
+ if (config.endpoint === 'wss://your-bridge.example.com:8333') {
74
+ console.log('Error: Update your endpoint in config.json before registering.')
75
+ process.exit(1)
76
+ }
77
+
78
+ 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.')
90
+ }
91
+
92
+ async function cmdStart () {
93
+ const dir = defaultConfigDir()
94
+
95
+ if (!(await configExists(dir))) {
96
+ console.log('No config found. Run: relay-bridge init')
97
+ process.exit(1)
98
+ }
99
+
100
+ const config = await loadConfig(dir)
101
+ const peerArg = process.argv[3] // optional: ws://host:port
102
+
103
+ const peerManager = new PeerManager({ maxPeers: config.maxPeers })
104
+ const headerRelay = new HeaderRelay(peerManager)
105
+ const txRelay = new TxRelay(peerManager)
106
+
107
+ // Start server
108
+ await peerManager.startServer({ port: config.port, host: '0.0.0.0' })
109
+ console.log(`Bridge listening on port ${config.port}`)
110
+ console.log(` Pubkey: ${config.pubkeyHex}`)
111
+ console.log(` Mesh: ${config.meshId}`)
112
+
113
+ if (peerArg) {
114
+ // Manual peer connection
115
+ console.log(`Connecting to peer: ${peerArg}`)
116
+ const conn = peerManager.connectToPeer({
117
+ pubkeyHex: 'manual_peer',
118
+ endpoint: peerArg
119
+ })
120
+ if (conn) {
121
+ conn.on('open', () => {
122
+ conn.send({
123
+ type: 'hello',
124
+ pubkey: config.pubkeyHex,
125
+ endpoint: config.endpoint
126
+ })
127
+ })
128
+ }
129
+ } else if (config.apiKey) {
130
+ // Scan chain for peers
131
+ console.log('Scanning chain for peers...')
132
+ try {
133
+ const { scanRegistry } = await import('../registry/lib/scanner.js')
134
+ const { buildPeerList, excludeSelf } = await import('../registry/lib/discovery.js')
135
+ const { savePeerCache, loadPeerCache } = await import('../registry/lib/peer-cache.js')
136
+
137
+ const cachePath = join(dir, 'cache', 'peers.json')
138
+ let peers = await loadPeerCache(cachePath)
139
+
140
+ if (!peers) {
141
+ const entries = await scanRegistry({
142
+ spvEndpoint: config.spvEndpoint,
143
+ apiKey: config.apiKey
144
+ })
145
+ peers = buildPeerList(entries)
146
+ peers = excludeSelf(peers, config.pubkeyHex)
147
+ await savePeerCache(peers, cachePath)
148
+ }
149
+
150
+ console.log(`Found ${peers.length} peers`)
151
+ for (const peer of peers) {
152
+ const conn = peerManager.connectToPeer(peer)
153
+ if (conn) {
154
+ conn.on('open', () => {
155
+ conn.send({
156
+ type: 'hello',
157
+ pubkey: config.pubkeyHex,
158
+ endpoint: config.endpoint
159
+ })
160
+ })
161
+ }
162
+ }
163
+ } catch (err) {
164
+ console.log(`Peer scan failed: ${err.message}`)
165
+ console.log('Start with manual peer: relay-bridge start ws://peer:port')
166
+ }
167
+ } else {
168
+ console.log('No peer specified and no API key configured.')
169
+ console.log('Usage: relay-bridge start ws://peer:port')
170
+ }
171
+
172
+ // Start status server (localhost only)
173
+ const statusPort = config.statusPort || 9333
174
+ const statusServer = new StatusServer({
175
+ port: statusPort,
176
+ peerManager,
177
+ headerRelay,
178
+ txRelay,
179
+ config
180
+ })
181
+ await statusServer.start()
182
+ console.log(` Status: http://127.0.0.1:${statusPort}/status`)
183
+
184
+ // Log events
185
+ peerManager.on('peer:connect', ({ pubkeyHex }) => {
186
+ console.log(`Peer connected: ${pubkeyHex}`)
187
+ })
188
+
189
+ peerManager.on('peer:disconnect', ({ pubkeyHex }) => {
190
+ console.log(`Peer disconnected: ${pubkeyHex}`)
191
+ })
192
+
193
+ headerRelay.on('header:sync', ({ pubkeyHex, added, bestHeight }) => {
194
+ console.log(`Synced ${added} headers from ${pubkeyHex} (height: ${bestHeight})`)
195
+ })
196
+
197
+ txRelay.on('tx:new', ({ txid }) => {
198
+ console.log(`New tx: ${txid}`)
199
+ })
200
+
201
+ // Graceful shutdown
202
+ const shutdown = async () => {
203
+ console.log('\nShutting down...')
204
+ await statusServer.stop()
205
+ await peerManager.shutdown()
206
+ process.exit(0)
207
+ }
208
+
209
+ process.on('SIGINT', shutdown)
210
+ process.on('SIGTERM', shutdown)
211
+ }
212
+
213
+ async function cmdStatus () {
214
+ const dir = defaultConfigDir()
215
+
216
+ if (!(await configExists(dir))) {
217
+ console.log('No config found. Run: relay-bridge init')
218
+ process.exit(1)
219
+ }
220
+
221
+ const config = await loadConfig(dir)
222
+ const statusPort = config.statusPort || 9333
223
+
224
+ let status
225
+ try {
226
+ const res = await fetch(`http://127.0.0.1:${statusPort}/status`)
227
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
228
+ status = await res.json()
229
+ } catch {
230
+ console.log('Bridge is not running.')
231
+ console.log(` (expected status server on port ${statusPort})`)
232
+ console.log(' Start it with: relay-bridge start')
233
+ process.exit(1)
234
+ }
235
+
236
+ console.log('Bridge Status\n')
237
+
238
+ // Bridge identity
239
+ console.log(' Bridge')
240
+ console.log(` Pubkey: ${status.bridge.pubkeyHex}`)
241
+ console.log(` Endpoint: ${status.bridge.endpoint}`)
242
+ console.log(` Mesh: ${status.bridge.meshId}`)
243
+ console.log(` Uptime: ${formatUptime(status.bridge.uptimeSeconds)}`)
244
+
245
+ // Peers
246
+ console.log('')
247
+ console.log(` Peers (${status.peers.connected}/${status.peers.max})`)
248
+ if (status.peers.list.length === 0) {
249
+ console.log(' (no peers)')
250
+ } else {
251
+ for (const peer of status.peers.list) {
252
+ const tag = peer.connected ? 'connected' : 'disconnected'
253
+ console.log(` ${peer.pubkeyHex.slice(0, 16)}... ${peer.endpoint} (${tag})`)
254
+ }
255
+ }
256
+
257
+ // Headers
258
+ console.log('')
259
+ console.log(' Headers')
260
+ console.log(` Best Height: ${status.headers.bestHeight}`)
261
+ console.log(` Best Hash: ${status.headers.bestHash || '(none)'}`)
262
+ console.log(` Stored: ${status.headers.count}`)
263
+
264
+ // Transactions
265
+ console.log('')
266
+ console.log(' Transactions')
267
+ console.log(` Mempool: ${status.txs.mempool}`)
268
+ console.log(` Seen: ${status.txs.seen}`)
269
+ }
270
+
271
+ async function cmdDeregister () {
272
+ const dir = defaultConfigDir()
273
+
274
+ if (!(await configExists(dir))) {
275
+ console.log('No config found. Run: relay-bridge init')
276
+ process.exit(1)
277
+ }
278
+
279
+ const config = await loadConfig(dir)
280
+
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.')
284
+ process.exit(1)
285
+ }
286
+
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('')
294
+
295
+ try {
296
+ const { buildDeregistrationTx } = await import('../registry/lib/registration.js')
297
+
298
+ // Fetch UTXOs from SPV bridge
299
+ const utxos = await fetchUtxos(config.spvEndpoint, config.apiKey, config.pubkeyHex)
300
+
301
+ if (!utxos.length) {
302
+ console.log('Error: No UTXOs found. Wallet needs funding for tx fees.')
303
+ process.exit(1)
304
+ }
305
+
306
+ // Build deregistration tx
307
+ const { txHex, txid } = await buildDeregistrationTx({
308
+ wif: config.wif,
309
+ utxos,
310
+ reason
311
+ })
312
+
313
+ // Broadcast via SPV bridge
314
+ await broadcastTx(config.spvEndpoint, config.apiKey, txHex)
315
+
316
+ console.log('Deregistration broadcast successful!')
317
+ console.log(` txid: ${txid}`)
318
+ console.log('')
319
+ console.log('Your bridge will be removed from peer lists on next scan cycle.')
320
+ } catch (err) {
321
+ console.log(`Deregistration failed: ${err.message}`)
322
+ process.exit(1)
323
+ }
324
+ }
325
+
326
+ function formatUptime (seconds) {
327
+ const days = Math.floor(seconds / 86400)
328
+ const hours = Math.floor((seconds % 86400) / 3600)
329
+ const minutes = Math.floor((seconds % 3600) / 60)
330
+ const secs = seconds % 60
331
+
332
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`
333
+ if (hours > 0) return `${hours}h ${minutes}m ${secs}s`
334
+ if (minutes > 0) return `${minutes}m ${secs}s`
335
+ return `${secs}s`
336
+ }
@@ -0,0 +1,158 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ const DEFAULT_MIN_ANCHORS = 2
4
+ const DEFAULT_RECONNECT_INTERVAL_MS = 30000 // 30 seconds
5
+ const DEFAULT_LOW_SCORE_THRESHOLD = 0.3
6
+
7
+ /**
8
+ * AnchorManager — ensures minimum connections to well-known anchor bridges.
9
+ *
10
+ * Anchors are hardcoded in config. This manager:
11
+ * - Ensures at least N anchor connections are maintained
12
+ * - Auto-reconnects to anchors on disconnect
13
+ * - Allows dropping low-scoring anchors (with warning)
14
+ * - Does NOT make anchors immune to scoring
15
+ *
16
+ * Emits:
17
+ * 'anchor:connect' — { pubkeyHex, endpoint }
18
+ * 'anchor:disconnect' — { pubkeyHex, endpoint }
19
+ * 'anchor:low_score' — { pubkeyHex, score } — warning, anchor scoring below threshold
20
+ */
21
+ export class AnchorManager extends EventEmitter {
22
+ /**
23
+ * @param {import('./peer-manager.js').PeerManager} peerManager
24
+ * @param {object} [opts]
25
+ * @param {Array<{ pubkeyHex: string, endpoint: string }>} [opts.anchors=[]] — anchor bridge list
26
+ * @param {number} [opts.minAnchors=2] — minimum anchor connections to maintain
27
+ * @param {number} [opts.reconnectIntervalMs=30000] — how often to check anchor connections
28
+ * @param {number} [opts.lowScoreThreshold=0.3] — score below which to warn about anchor
29
+ */
30
+ constructor (peerManager, opts = {}) {
31
+ super()
32
+ this.peerManager = peerManager
33
+ this._anchors = opts.anchors || []
34
+ this._minAnchors = opts.minAnchors ?? DEFAULT_MIN_ANCHORS
35
+ this._reconnectIntervalMs = opts.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS
36
+ this._lowScoreThreshold = opts.lowScoreThreshold ?? DEFAULT_LOW_SCORE_THRESHOLD
37
+ this._reconnectTimer = null
38
+ this._anchorSet = new Set(this._anchors.map(a => a.pubkeyHex))
39
+
40
+ // Listen for disconnects to track anchor status
41
+ this.peerManager.on('peer:disconnect', ({ pubkeyHex, endpoint }) => {
42
+ if (this._anchorSet.has(pubkeyHex)) {
43
+ this.emit('anchor:disconnect', { pubkeyHex, endpoint })
44
+ }
45
+ })
46
+
47
+ this.peerManager.on('peer:connect', ({ pubkeyHex, endpoint }) => {
48
+ if (this._anchorSet.has(pubkeyHex)) {
49
+ this.emit('anchor:connect', { pubkeyHex, endpoint })
50
+ }
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Check if a pubkey is an anchor bridge.
56
+ * @param {string} pubkeyHex
57
+ * @returns {boolean}
58
+ */
59
+ isAnchor (pubkeyHex) {
60
+ return this._anchorSet.has(pubkeyHex)
61
+ }
62
+
63
+ /**
64
+ * Get the list of configured anchor bridges.
65
+ * @returns {Array<{ pubkeyHex: string, endpoint: string }>}
66
+ */
67
+ getAnchors () {
68
+ return [...this._anchors]
69
+ }
70
+
71
+ /**
72
+ * Get count of currently connected anchor bridges.
73
+ * @returns {number}
74
+ */
75
+ connectedAnchorCount () {
76
+ let count = 0
77
+ for (const anchor of this._anchors) {
78
+ const conn = this.peerManager.peers.get(anchor.pubkeyHex)
79
+ if (conn && conn.connected) count++
80
+ }
81
+ return count
82
+ }
83
+
84
+ /**
85
+ * Connect to all configured anchors that we're not already connected to.
86
+ * Respects maxPeers — if at capacity, still tries anchors (they're priority).
87
+ *
88
+ * @returns {number} Number of new connections initiated
89
+ */
90
+ ensureConnections () {
91
+ let initiated = 0
92
+
93
+ for (const anchor of this._anchors) {
94
+ const existing = this.peerManager.peers.get(anchor.pubkeyHex)
95
+ if (existing && existing.connected) continue
96
+
97
+ // If we have a disconnected connection object, remove it first
98
+ if (existing && !existing.connected) {
99
+ this.peerManager.peers.delete(anchor.pubkeyHex)
100
+ }
101
+
102
+ const conn = this.peerManager.connectToPeer(anchor)
103
+ if (conn) initiated++
104
+ }
105
+
106
+ return initiated
107
+ }
108
+
109
+ /**
110
+ * Start periodic anchor connection monitoring.
111
+ * Checks every reconnectIntervalMs and reconnects to missing anchors.
112
+ */
113
+ startMonitoring () {
114
+ if (this._reconnectTimer) return
115
+
116
+ this._reconnectTimer = setInterval(() => {
117
+ const connected = this.connectedAnchorCount()
118
+ if (connected < this._minAnchors) {
119
+ this.ensureConnections()
120
+ }
121
+ }, this._reconnectIntervalMs)
122
+
123
+ // Don't prevent process exit
124
+ if (this._reconnectTimer.unref) {
125
+ this._reconnectTimer.unref()
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Stop periodic monitoring.
131
+ */
132
+ stopMonitoring () {
133
+ if (this._reconnectTimer) {
134
+ clearInterval(this._reconnectTimer)
135
+ this._reconnectTimer = null
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Check anchor scores and emit warnings for low-scoring anchors.
141
+ *
142
+ * @param {import('./peer-scorer.js').PeerScorer} scorer
143
+ * @returns {Array<{ pubkeyHex: string, score: number }>} Low-scoring anchors
144
+ */
145
+ checkAnchorScores (scorer) {
146
+ const lowScoring = []
147
+
148
+ for (const anchor of this._anchors) {
149
+ const score = scorer.getScore(anchor.pubkeyHex)
150
+ if (score < this._lowScoreThreshold) {
151
+ lowScoring.push({ pubkeyHex: anchor.pubkeyHex, score })
152
+ this.emit('anchor:low_score', { pubkeyHex: anchor.pubkeyHex, score })
153
+ }
154
+ }
155
+
156
+ return lowScoring
157
+ }
158
+ }
package/lib/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { PrivateKey } from '@bsv/sdk'
5
+
6
+ const DEFAULT_DIR = join(homedir(), '.relay-bridge')
7
+ const CONFIG_FILE = 'config.json'
8
+
9
+ /**
10
+ * Get the default config directory path.
11
+ * @returns {string}
12
+ */
13
+ export function defaultConfigDir () {
14
+ return DEFAULT_DIR
15
+ }
16
+
17
+ /**
18
+ * Initialize a new bridge config with a fresh key pair.
19
+ *
20
+ * @param {string} [dir] — Config directory (default: ~/.relay-bridge)
21
+ * @returns {Promise<object>} The generated config
22
+ */
23
+ export async function initConfig (dir = DEFAULT_DIR) {
24
+ const privKey = PrivateKey.fromRandom()
25
+
26
+ const config = {
27
+ wif: privKey.toWif(),
28
+ pubkeyHex: privKey.toPublicKey().toString(),
29
+ endpoint: 'wss://your-bridge.example.com:8333',
30
+ meshId: 'indelible',
31
+ capabilities: ['tx_relay', 'header_sync', 'broadcast', 'address_history'],
32
+ spvEndpoint: 'https://relay.indelible.one',
33
+ apiKey: '',
34
+ port: 8333,
35
+ statusPort: 9333,
36
+ maxPeers: 20
37
+ }
38
+
39
+ await mkdir(dir, { recursive: true })
40
+ await writeFile(join(dir, CONFIG_FILE), JSON.stringify(config, null, 2))
41
+
42
+ return config
43
+ }
44
+
45
+ /**
46
+ * Load an existing bridge config.
47
+ *
48
+ * @param {string} [dir] — Config directory (default: ~/.relay-bridge)
49
+ * @returns {Promise<object>}
50
+ */
51
+ export async function loadConfig (dir = DEFAULT_DIR) {
52
+ const raw = await readFile(join(dir, CONFIG_FILE), 'utf8')
53
+ return JSON.parse(raw)
54
+ }
55
+
56
+ /**
57
+ * Check if a config file exists.
58
+ *
59
+ * @param {string} [dir] — Config directory (default: ~/.relay-bridge)
60
+ * @returns {Promise<boolean>}
61
+ */
62
+ export async function configExists (dir = DEFAULT_DIR) {
63
+ try {
64
+ await access(join(dir, CONFIG_FILE))
65
+ return true
66
+ } catch {
67
+ return false
68
+ }
69
+ }