@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.
@@ -0,0 +1,135 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ const DEFAULT_DISCONNECT_THRESHOLD = 0.3
4
+ const DEFAULT_BLACKLIST_THRESHOLD = 0.1
5
+ const DEFAULT_BLACKLIST_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
6
+
7
+ /**
8
+ * Score-based automatic actions.
9
+ *
10
+ * Listens to PeerScorer 'score:update' events and takes action:
11
+ * - score < 0.3 → auto-disconnect peer
12
+ * - score < 0.1 → blacklist peer for 24 hours
13
+ *
14
+ * Emits:
15
+ * 'peer:disconnected' — { pubkeyHex, score, reason }
16
+ * 'peer:blacklisted' — { pubkeyHex, score, expiresAt }
17
+ */
18
+ export class ScoreActions extends EventEmitter {
19
+ /**
20
+ * @param {import('./peer-scorer.js').PeerScorer} scorer
21
+ * @param {import('./peer-manager.js').PeerManager} peerManager
22
+ * @param {object} [opts]
23
+ * @param {number} [opts.disconnectThreshold=0.3]
24
+ * @param {number} [opts.blacklistThreshold=0.1]
25
+ * @param {number} [opts.blacklistDurationMs=86400000] — 24 hours
26
+ */
27
+ constructor (scorer, peerManager, opts = {}) {
28
+ super()
29
+ this.scorer = scorer
30
+ this.peerManager = peerManager
31
+ this._disconnectThreshold = opts.disconnectThreshold ?? DEFAULT_DISCONNECT_THRESHOLD
32
+ this._blacklistThreshold = opts.blacklistThreshold ?? DEFAULT_BLACKLIST_THRESHOLD
33
+ this._blacklistDurationMs = opts.blacklistDurationMs ?? DEFAULT_BLACKLIST_DURATION_MS
34
+ this._blacklist = new Map() // pubkeyHex → expiresAt (timestamp)
35
+
36
+ this.scorer.on('score:update', ({ pubkeyHex, score }) => {
37
+ this._evaluate(pubkeyHex, score)
38
+ })
39
+ }
40
+
41
+ /**
42
+ * Evaluate a peer's score and take action if below thresholds.
43
+ * @param {string} pubkeyHex
44
+ * @param {number} score
45
+ */
46
+ _evaluate (pubkeyHex, score) {
47
+ if (score < this._blacklistThreshold) {
48
+ this._blacklistPeer(pubkeyHex, score)
49
+ } else if (score < this._disconnectThreshold) {
50
+ this._disconnectPeer(pubkeyHex, score)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Disconnect a low-scoring peer.
56
+ * @param {string} pubkeyHex
57
+ * @param {number} score
58
+ */
59
+ _disconnectPeer (pubkeyHex, score) {
60
+ this.peerManager.disconnectPeer(pubkeyHex)
61
+ this.emit('peer:disconnected', {
62
+ pubkeyHex,
63
+ score,
64
+ reason: 'low_score'
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Blacklist a peer — disconnect and prevent reconnection for the blacklist duration.
70
+ * @param {string} pubkeyHex
71
+ * @param {number} score
72
+ */
73
+ _blacklistPeer (pubkeyHex, score) {
74
+ const expiresAt = Date.now() + this._blacklistDurationMs
75
+
76
+ this._blacklist.set(pubkeyHex, expiresAt)
77
+ this.peerManager.disconnectPeer(pubkeyHex)
78
+
79
+ this.emit('peer:blacklisted', {
80
+ pubkeyHex,
81
+ score,
82
+ expiresAt
83
+ })
84
+ }
85
+
86
+ /**
87
+ * Check if a peer is currently blacklisted.
88
+ * Automatically cleans up expired entries.
89
+ *
90
+ * @param {string} pubkeyHex
91
+ * @returns {boolean}
92
+ */
93
+ isBlacklisted (pubkeyHex) {
94
+ if (!this._blacklist.has(pubkeyHex)) return false
95
+
96
+ const expiresAt = this._blacklist.get(pubkeyHex)
97
+ if (Date.now() >= expiresAt) {
98
+ this._blacklist.delete(pubkeyHex)
99
+ return false
100
+ }
101
+
102
+ return true
103
+ }
104
+
105
+ /**
106
+ * Get the blacklist expiry timestamp for a peer, or null.
107
+ * @param {string} pubkeyHex
108
+ * @returns {number|null}
109
+ */
110
+ getBlacklistExpiry (pubkeyHex) {
111
+ if (!this.isBlacklisted(pubkeyHex)) return null
112
+ return this._blacklist.get(pubkeyHex)
113
+ }
114
+
115
+ /**
116
+ * Get all currently blacklisted peers.
117
+ * @returns {Map<string, number>} pubkeyHex → expiresAt
118
+ */
119
+ getBlacklist () {
120
+ // Clean up expired entries
121
+ const now = Date.now()
122
+ for (const [pubkey, expiresAt] of this._blacklist) {
123
+ if (now >= expiresAt) this._blacklist.delete(pubkey)
124
+ }
125
+ return new Map(this._blacklist)
126
+ }
127
+
128
+ /**
129
+ * Manually remove a peer from the blacklist.
130
+ * @param {string} pubkeyHex
131
+ */
132
+ unblacklist (pubkeyHex) {
133
+ this._blacklist.delete(pubkeyHex)
134
+ }
135
+ }
@@ -0,0 +1,115 @@
1
+ import { createServer } from 'node:http'
2
+
3
+ /**
4
+ * StatusServer — localhost-only HTTP server exposing bridge status.
5
+ *
6
+ * Started by `relay-bridge start`, queried by `relay-bridge status`.
7
+ * Binds to 127.0.0.1 only — not accessible from outside the machine.
8
+ *
9
+ * Endpoints:
10
+ * GET /status — JSON object with bridge state
11
+ */
12
+ export class StatusServer {
13
+ /**
14
+ * @param {object} opts
15
+ * @param {number} [opts.port=9333] — HTTP port for status endpoint
16
+ * @param {import('./peer-manager.js').PeerManager} [opts.peerManager]
17
+ * @param {import('./header-relay.js').HeaderRelay} [opts.headerRelay]
18
+ * @param {import('./tx-relay.js').TxRelay} [opts.txRelay]
19
+ * @param {object} [opts.config] — Bridge config (pubkeyHex, endpoint, meshId)
20
+ */
21
+ constructor (opts = {}) {
22
+ this._port = opts.port || 9333
23
+ this._peerManager = opts.peerManager || null
24
+ this._headerRelay = opts.headerRelay || null
25
+ this._txRelay = opts.txRelay || null
26
+ this._config = opts.config || {}
27
+ this._startedAt = Date.now()
28
+ this._server = null
29
+ }
30
+
31
+ /**
32
+ * Build the status object from current bridge state.
33
+ * @returns {object}
34
+ */
35
+ getStatus () {
36
+ const peers = []
37
+ if (this._peerManager) {
38
+ for (const [pubkeyHex, conn] of this._peerManager.peers) {
39
+ peers.push({
40
+ pubkeyHex,
41
+ endpoint: conn.endpoint,
42
+ connected: !!conn.connected
43
+ })
44
+ }
45
+ }
46
+
47
+ return {
48
+ bridge: {
49
+ pubkeyHex: this._config.pubkeyHex || null,
50
+ endpoint: this._config.endpoint || null,
51
+ meshId: this._config.meshId || null,
52
+ uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
53
+ },
54
+ peers: {
55
+ connected: this._peerManager ? this._peerManager.connectedCount() : 0,
56
+ max: this._peerManager ? this._peerManager.maxPeers : 0,
57
+ list: peers
58
+ },
59
+ headers: {
60
+ bestHeight: this._headerRelay ? this._headerRelay.bestHeight : -1,
61
+ bestHash: this._headerRelay ? this._headerRelay.bestHash : null,
62
+ count: this._headerRelay ? this._headerRelay.headers.size : 0
63
+ },
64
+ txs: {
65
+ mempool: this._txRelay ? this._txRelay.mempool.size : 0,
66
+ seen: this._txRelay ? this._txRelay.seen.size : 0
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Start the HTTP server on localhost.
73
+ * @returns {Promise<void>}
74
+ */
75
+ start () {
76
+ return new Promise((resolve, reject) => {
77
+ this._server = createServer((req, res) => {
78
+ if (req.method === 'GET' && req.url === '/status') {
79
+ const status = this.getStatus()
80
+ res.writeHead(200, { 'Content-Type': 'application/json' })
81
+ res.end(JSON.stringify(status))
82
+ } else {
83
+ res.writeHead(404)
84
+ res.end('Not Found')
85
+ }
86
+ })
87
+
88
+ this._server.listen(this._port, '127.0.0.1', () => resolve())
89
+ this._server.on('error', reject)
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Stop the HTTP server.
95
+ * @returns {Promise<void>}
96
+ */
97
+ stop () {
98
+ return new Promise((resolve) => {
99
+ if (this._server) {
100
+ this._server.close(() => resolve())
101
+ this._server = null
102
+ } else {
103
+ resolve()
104
+ }
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Get the port this server is configured to use.
110
+ * @returns {number}
111
+ */
112
+ get port () {
113
+ return this._port
114
+ }
115
+ }
@@ -0,0 +1,117 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ /**
4
+ * TxRelay — relays transactions between peers.
5
+ *
6
+ * Uses the INV/GETDATA pattern (like Bitcoin P2P):
7
+ * 1. Peer announces a txid via tx_announce
8
+ * 2. If we haven't seen it, we request the full tx via tx_request
9
+ * 3. Peer responds with the raw tx hex via tx message
10
+ * 4. We store it and re-announce to other peers
11
+ *
12
+ * Message types:
13
+ * tx_announce — { type, txid }
14
+ * tx_request — { type, txid }
15
+ * tx — { type, txid, rawHex }
16
+ *
17
+ * Events:
18
+ * 'tx:new' — { txid, rawHex } — new transaction received or submitted
19
+ */
20
+ export class TxRelay extends EventEmitter {
21
+ /**
22
+ * @param {import('./peer-manager.js').PeerManager} peerManager
23
+ * @param {object} [opts]
24
+ * @param {number} [opts.maxMempool=1000] — Max txs in local mempool
25
+ */
26
+ constructor (peerManager, opts = {}) {
27
+ super()
28
+ this.peerManager = peerManager
29
+ /** @type {Map<string, string>} txid → rawHex */
30
+ this.mempool = new Map()
31
+ /** @type {Set<string>} txids we've already seen (dedup) */
32
+ this.seen = new Set()
33
+ this._maxMempool = opts.maxMempool || 1000
34
+
35
+ this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
36
+ this._handleMessage(pubkeyHex, message)
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Submit a new tx for relay to all peers.
42
+ * @param {string} txid
43
+ * @param {string} rawHex
44
+ * @returns {number} Number of peers the announce was sent to
45
+ */
46
+ broadcastTx (txid, rawHex) {
47
+ if (this.seen.has(txid)) return 0
48
+ this.seen.add(txid)
49
+ this._storeTx(txid, rawHex)
50
+ this.emit('tx:new', { txid, rawHex })
51
+ return this.peerManager.broadcast({ type: 'tx_announce', txid })
52
+ }
53
+
54
+ /**
55
+ * Get a tx from the local mempool.
56
+ * @param {string} txid
57
+ * @returns {string|null} rawHex or null
58
+ */
59
+ getTx (txid) {
60
+ return this.mempool.get(txid) || null
61
+ }
62
+
63
+ /** @private */
64
+ _storeTx (txid, rawHex) {
65
+ if (this.mempool.size >= this._maxMempool) {
66
+ const oldest = this.mempool.keys().next().value
67
+ this.mempool.delete(oldest)
68
+ }
69
+ this.mempool.set(txid, rawHex)
70
+ }
71
+
72
+ /** @private */
73
+ _handleMessage (pubkeyHex, message) {
74
+ switch (message.type) {
75
+ case 'tx_announce':
76
+ this._onTxAnnounce(pubkeyHex, message)
77
+ break
78
+ case 'tx_request':
79
+ this._onTxRequest(pubkeyHex, message)
80
+ break
81
+ case 'tx':
82
+ this._onTx(pubkeyHex, message)
83
+ break
84
+ }
85
+ }
86
+
87
+ /** @private */
88
+ _onTxAnnounce (pubkeyHex, msg) {
89
+ if (this.seen.has(msg.txid)) return
90
+ this.seen.add(msg.txid)
91
+ const conn = this.peerManager.peers.get(pubkeyHex)
92
+ if (conn) {
93
+ conn.send({ type: 'tx_request', txid: msg.txid })
94
+ }
95
+ }
96
+
97
+ /** @private */
98
+ _onTxRequest (pubkeyHex, msg) {
99
+ const rawHex = this.mempool.get(msg.txid)
100
+ if (rawHex) {
101
+ const conn = this.peerManager.peers.get(pubkeyHex)
102
+ if (conn) {
103
+ conn.send({ type: 'tx', txid: msg.txid, rawHex })
104
+ }
105
+ }
106
+ }
107
+
108
+ /** @private */
109
+ _onTx (pubkeyHex, msg) {
110
+ if (!msg.txid || !msg.rawHex) return
111
+ if (this.mempool.has(msg.txid)) return
112
+ this._storeTx(msg.txid, msg.rawHex)
113
+ this.emit('tx:new', { txid: msg.txid, rawHex: msg.rawHex })
114
+ // Re-announce to all peers except the source
115
+ this.peerManager.broadcast({ type: 'tx_announce', txid: msg.txid }, pubkeyHex)
116
+ }
117
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@relay-federation/bridge",
3
+ "version": "0.1.0",
4
+ "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "relay-bridge": "./cli.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test test/*.test.js"
12
+ },
13
+ "dependencies": {
14
+ "@bsv/sdk": "^1.10.1",
15
+ "@relay-federation/common": "^0.1.0",
16
+ "ws": "^8.19.0"
17
+ },
18
+ "license": "MIT",
19
+ "author": "zcooL",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/zcoolz/relay-federation",
23
+ "directory": "packages/bridge"
24
+ },
25
+ "keywords": ["bsv", "spv", "relay", "bridge", "federation", "bitcoin", "mesh"],
26
+ "engines": { "node": ">=18" },
27
+ "files": ["index.js", "cli.js", "lib/"]
28
+ }