@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 +336 -0
- package/lib/anchor-manager.js +158 -0
- package/lib/config.js +69 -0
- package/lib/data-validator.js +205 -0
- package/lib/handshake.js +165 -0
- package/lib/header-relay.js +184 -0
- package/lib/peer-connection.js +114 -0
- package/lib/peer-health.js +172 -0
- package/lib/peer-manager.js +214 -0
- package/lib/peer-scorer.js +285 -0
- package/lib/score-actions.js +135 -0
- package/lib/status-server.js +115 -0
- package/lib/tx-relay.js +117 -0
- package/package.json +28 -0
|
@@ -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
|
+
}
|
package/lib/tx-relay.js
ADDED
|
@@ -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
|
+
}
|