@relay-federation/bridge 0.3.10 → 0.3.12
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 +109 -0
- package/lib/address-scanner.js +4 -3
- package/lib/status-server.js +174 -3
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -11,6 +11,112 @@ import { StatusServer } from './lib/status-server.js'
|
|
|
11
11
|
|
|
12
12
|
const command = process.argv[2]
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Scan historical beacon registrations on startup.
|
|
16
|
+
* Fetches beacon address history from WhatsOnChain and parses each tx
|
|
17
|
+
* to populate the registeredPubkeys Set with all valid registrations.
|
|
18
|
+
*
|
|
19
|
+
* @param {Set<string>} registeredPubkeys - Set to populate with registered pubkeys
|
|
20
|
+
* @param {string} selfPubkey - Our own pubkey (skip self-registration)
|
|
21
|
+
*/
|
|
22
|
+
async function backfillBeaconRegistry (registeredPubkeys, selfPubkey) {
|
|
23
|
+
const { BEACON_ADDRESS, PROTOCOL_PREFIX } = await import('@relay-federation/common/protocol')
|
|
24
|
+
const { extractOpReturnData, decodePayload } = await import('@relay-federation/registry/lib/cbor.js')
|
|
25
|
+
const { Transaction } = await import('@bsv/sdk')
|
|
26
|
+
|
|
27
|
+
console.log(` Beacon backfill: scanning ${BEACON_ADDRESS}...`)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Fetch confirmed history (registrations should be confirmed)
|
|
31
|
+
const historyResp = await fetch(
|
|
32
|
+
`https://api.whatsonchain.com/v1/bsv/main/address/${BEACON_ADDRESS}/confirmed/history`,
|
|
33
|
+
{ signal: AbortSignal.timeout(30000) }
|
|
34
|
+
)
|
|
35
|
+
if (!historyResp.ok) {
|
|
36
|
+
console.log(` Beacon backfill: WoC returned ${historyResp.status}, skipping`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await historyResp.json()
|
|
41
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
42
|
+
if (history.length === 0) {
|
|
43
|
+
console.log(` Beacon backfill: no history found`)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(` Beacon backfill: found ${history.length} transactions`)
|
|
48
|
+
|
|
49
|
+
// Track registrations and deregistrations to handle order correctly
|
|
50
|
+
// Process oldest first (history is typically newest-first from WoC)
|
|
51
|
+
const sortedHistory = [...history].sort((a, b) => (a.height || 0) - (b.height || 0))
|
|
52
|
+
|
|
53
|
+
let registered = 0
|
|
54
|
+
let deregistered = 0
|
|
55
|
+
let skipped = 0
|
|
56
|
+
|
|
57
|
+
for (const item of sortedHistory) {
|
|
58
|
+
const txid = item.tx_hash
|
|
59
|
+
if (!txid) continue
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Rate limit to avoid WoC throttling
|
|
63
|
+
await new Promise(r => setTimeout(r, 100))
|
|
64
|
+
|
|
65
|
+
const txResp = await fetch(
|
|
66
|
+
`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`,
|
|
67
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
68
|
+
)
|
|
69
|
+
if (!txResp.ok) {
|
|
70
|
+
skipped++
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawHex = await txResp.text()
|
|
75
|
+
const tx = Transaction.fromHex(rawHex)
|
|
76
|
+
|
|
77
|
+
// Find OP_RETURN output
|
|
78
|
+
const opReturnOutput = tx.outputs.find(out =>
|
|
79
|
+
out.satoshis === 0 && out.lockingScript.toHex().startsWith('006a')
|
|
80
|
+
)
|
|
81
|
+
if (!opReturnOutput) {
|
|
82
|
+
skipped++
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { prefix, cborBytes } = extractOpReturnData(opReturnOutput.lockingScript)
|
|
87
|
+
if (prefix !== PROTOCOL_PREFIX) {
|
|
88
|
+
skipped++
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const entry = decodePayload(cborBytes)
|
|
93
|
+
if (!entry || !entry.pubkey) {
|
|
94
|
+
skipped++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pubHex = Buffer.from(entry.pubkey).toString('hex')
|
|
99
|
+
if (pubHex === selfPubkey) continue // skip self
|
|
100
|
+
|
|
101
|
+
if (entry.action === 'register') {
|
|
102
|
+
registeredPubkeys.add(pubHex)
|
|
103
|
+
registered++
|
|
104
|
+
} else if (entry.action === 'deregister') {
|
|
105
|
+
registeredPubkeys.delete(pubHex)
|
|
106
|
+
deregistered++
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
skipped++
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(` Beacon backfill: +${registered} registered, -${deregistered} deregistered, ${skipped} skipped`)
|
|
114
|
+
console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys after backfill`)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.log(` Beacon backfill failed: ${err.message}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
14
120
|
switch (command) {
|
|
15
121
|
case 'init':
|
|
16
122
|
await cmdInit()
|
|
@@ -434,6 +540,9 @@ async function cmdStart () {
|
|
|
434
540
|
}
|
|
435
541
|
console.log(` Registry: ${registeredPubkeys.size} trusted pubkeys (self + seeds)`)
|
|
436
542
|
|
|
543
|
+
// ── 4a-2. Beacon backfill — scan historical registrations on startup ──
|
|
544
|
+
await backfillBeaconRegistry(registeredPubkeys, config.pubkeyHex)
|
|
545
|
+
|
|
437
546
|
// ── 4b. Beacon address watcher — detect on-chain registrations ──
|
|
438
547
|
const { extractOpReturnData, decodePayload, PROTOCOL_PREFIX } = await import('@relay-federation/registry/lib/cbor.js')
|
|
439
548
|
const { Transaction: BsvTx } = await import('@bsv/sdk')
|
package/lib/address-scanner.js
CHANGED
|
@@ -25,14 +25,15 @@ export async function scanAddress (address, store, onProgress = () => {}) {
|
|
|
25
25
|
// Phase 1: Fetch tx history from WhatsOnChain
|
|
26
26
|
onProgress({ phase: 'discovery', current: 0, total: 0, message: 'Fetching transaction history...' })
|
|
27
27
|
|
|
28
|
-
const historyUrl = `${WOC_BASE}/address/${address}/history`
|
|
28
|
+
const historyUrl = `${WOC_BASE}/address/${address}/confirmed/history`
|
|
29
29
|
const histRes = await fetchWithRetry(historyUrl)
|
|
30
30
|
if (!histRes.ok) {
|
|
31
31
|
throw new Error(`WhatsOnChain returned ${histRes.status} for address history`)
|
|
32
32
|
}
|
|
33
|
-
const
|
|
33
|
+
const data = await histRes.json()
|
|
34
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
34
35
|
|
|
35
|
-
if (
|
|
36
|
+
if (history.length === 0) {
|
|
36
37
|
onProgress({ phase: 'done', current: 0, total: 0, message: 'No transactions found for this address' })
|
|
37
38
|
return { address, txsScanned: 0, inscriptionsFound: 0, errors: 0 }
|
|
38
39
|
}
|
package/lib/status-server.js
CHANGED
|
@@ -415,7 +415,7 @@ export class StatusServer {
|
|
|
415
415
|
isP2PKH: o.isP2PKH,
|
|
416
416
|
hash160: o.hash160,
|
|
417
417
|
type: o.type,
|
|
418
|
-
data: o.data,
|
|
418
|
+
data: o.data ? o.data.map(d => d.length > 128 ? d.slice(0, 128) + '...' : d) : o.data,
|
|
419
419
|
protocol: o.protocol,
|
|
420
420
|
parsed: o.parsed
|
|
421
421
|
}))
|
|
@@ -804,9 +804,10 @@ export class StatusServer {
|
|
|
804
804
|
return
|
|
805
805
|
}
|
|
806
806
|
try {
|
|
807
|
-
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
|
|
807
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
|
|
808
808
|
if (!resp.ok) throw new Error('WoC returned ' + resp.status)
|
|
809
|
-
const
|
|
809
|
+
const data = await resp.json()
|
|
810
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
810
811
|
this._addressCache.set(addr, { data: history, time: Date.now() })
|
|
811
812
|
// Prune cache if it grows too large
|
|
812
813
|
if (this._addressCache.size > 100) {
|
|
@@ -1092,6 +1093,176 @@ export class StatusServer {
|
|
|
1092
1093
|
return
|
|
1093
1094
|
}
|
|
1094
1095
|
|
|
1096
|
+
// GET /health — MCP/CLI compatibility
|
|
1097
|
+
if (req.method === 'GET' && path === '/health') {
|
|
1098
|
+
const status = await this.getStatus()
|
|
1099
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1100
|
+
res.end(JSON.stringify({
|
|
1101
|
+
status: 'ok',
|
|
1102
|
+
headerHeight: status.headers.bestHeight,
|
|
1103
|
+
connectedPeers: status.bsvNode.peers,
|
|
1104
|
+
synced: status.headers.bestHeight > 0
|
|
1105
|
+
}))
|
|
1106
|
+
return
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// GET /api/address/:addr/unspent — UTXO lookup via GorillaPool ordinals
|
|
1110
|
+
const unspentMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/unspent$/)
|
|
1111
|
+
if (req.method === 'GET' && unspentMatch) {
|
|
1112
|
+
const addr = unspentMatch[1]
|
|
1113
|
+
try {
|
|
1114
|
+
const resp = await fetch(
|
|
1115
|
+
`https://ordinals.gorillapool.io/api/txos/address/${addr}/unspent`,
|
|
1116
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
1117
|
+
)
|
|
1118
|
+
if (!resp.ok) throw new Error(`GorillaPool ${resp.status}`)
|
|
1119
|
+
const data = await resp.json()
|
|
1120
|
+
// Transform GorillaPool format → WoC format
|
|
1121
|
+
const utxos = data.map(u => ({
|
|
1122
|
+
tx_hash: u.txid,
|
|
1123
|
+
tx_pos: u.vout,
|
|
1124
|
+
value: u.satoshis
|
|
1125
|
+
}))
|
|
1126
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1127
|
+
res.end(JSON.stringify(utxos))
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1130
|
+
res.end(JSON.stringify({ error: 'UTXO fetch failed: ' + err.message }))
|
|
1131
|
+
}
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// GET /api/tx/:txid/hex — raw transaction hex
|
|
1136
|
+
const hexMatch = path.match(/^\/api\/tx\/([0-9a-f]{64})\/hex$/)
|
|
1137
|
+
if (req.method === 'GET' && hexMatch) {
|
|
1138
|
+
const txid = hexMatch[1]
|
|
1139
|
+
let rawHex = null
|
|
1140
|
+
// Mempool first
|
|
1141
|
+
if (this._txRelay && this._txRelay.mempool.has(txid)) {
|
|
1142
|
+
rawHex = this._txRelay.mempool.get(txid)
|
|
1143
|
+
}
|
|
1144
|
+
// P2P second
|
|
1145
|
+
if (!rawHex && this._bsvNodeClient) {
|
|
1146
|
+
try {
|
|
1147
|
+
const result = await this._bsvNodeClient.getTx(txid, 5000)
|
|
1148
|
+
rawHex = result.rawHex
|
|
1149
|
+
} catch {}
|
|
1150
|
+
}
|
|
1151
|
+
// WoC fallback
|
|
1152
|
+
if (!rawHex) {
|
|
1153
|
+
try {
|
|
1154
|
+
const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
|
|
1155
|
+
if (resp.ok) rawHex = await resp.text()
|
|
1156
|
+
} catch {}
|
|
1157
|
+
}
|
|
1158
|
+
if (rawHex) {
|
|
1159
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
1160
|
+
res.end(rawHex)
|
|
1161
|
+
} else {
|
|
1162
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
1163
|
+
res.end(JSON.stringify({ error: 'tx not found' }))
|
|
1164
|
+
}
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// POST /api/broadcast — MCP/CLI compatibility (accepts { rawTx } key)
|
|
1169
|
+
if (req.method === 'POST' && path === '/api/broadcast') {
|
|
1170
|
+
const body = await this._readBody(req)
|
|
1171
|
+
const rawHex = body.rawTx || body.rawHex
|
|
1172
|
+
if (!rawHex || typeof rawHex !== 'string') {
|
|
1173
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1174
|
+
res.end(JSON.stringify({ error: 'rawTx or rawHex required' }))
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
if (!/^[0-9a-fA-F]+$/.test(rawHex) || rawHex.length % 2 !== 0) {
|
|
1178
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1179
|
+
res.end(JSON.stringify({ error: 'Invalid hex string' }))
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
const buf = Buffer.from(rawHex, 'hex')
|
|
1183
|
+
const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
|
|
1184
|
+
const txid = Buffer.from(hash).reverse().toString('hex')
|
|
1185
|
+
const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
|
|
1186
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1187
|
+
res.end(JSON.stringify({ txid, peers: sent }))
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// GET /api/address/:addr/history — proxy to WoC confirmed/history (web app compat)
|
|
1192
|
+
const apiHistMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
|
|
1193
|
+
if (req.method === 'GET' && apiHistMatch) {
|
|
1194
|
+
const addr = apiHistMatch[1]
|
|
1195
|
+
const cached = this._addressCache.get(addr)
|
|
1196
|
+
if (cached && Date.now() - cached.time < 60000) {
|
|
1197
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1198
|
+
res.end(JSON.stringify(cached.data))
|
|
1199
|
+
return
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
|
|
1203
|
+
if (!resp.ok) throw new Error('WoC returned ' + resp.status)
|
|
1204
|
+
const data = await resp.json()
|
|
1205
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
1206
|
+
this._addressCache.set(addr, { data: history, time: Date.now() })
|
|
1207
|
+
if (this._addressCache.size > 100) {
|
|
1208
|
+
const oldest = this._addressCache.keys().next().value
|
|
1209
|
+
this._addressCache.delete(oldest)
|
|
1210
|
+
}
|
|
1211
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1212
|
+
res.end(JSON.stringify(history))
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1215
|
+
res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
|
|
1216
|
+
}
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// GET /api/address/:addr/balance — sum UTXOs from GorillaPool (web app compat)
|
|
1221
|
+
const apiBalMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/balance$/)
|
|
1222
|
+
if (req.method === 'GET' && apiBalMatch) {
|
|
1223
|
+
const addr = apiBalMatch[1]
|
|
1224
|
+
try {
|
|
1225
|
+
const resp = await fetch(
|
|
1226
|
+
`https://ordinals.gorillapool.io/api/txos/address/${addr}/unspent`,
|
|
1227
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
1228
|
+
)
|
|
1229
|
+
if (!resp.ok) throw new Error(`GorillaPool ${resp.status}`)
|
|
1230
|
+
const data = await resp.json()
|
|
1231
|
+
const confirmed = data.reduce((sum, u) => sum + (u.satoshis || 0), 0)
|
|
1232
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1233
|
+
res.end(JSON.stringify({ confirmed, unconfirmed: 0 }))
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1236
|
+
res.end(JSON.stringify({ error: 'Balance fetch failed: ' + err.message }))
|
|
1237
|
+
}
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// GET /api/tx/:txid — full tx JSON via WoC (web app compat)
|
|
1242
|
+
const apiTxMatch = path.match(/^\/api\/tx\/([0-9a-f]{64})$/)
|
|
1243
|
+
if (req.method === 'GET' && apiTxMatch) {
|
|
1244
|
+
const txid = apiTxMatch[1]
|
|
1245
|
+
try {
|
|
1246
|
+
const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}`, { signal: AbortSignal.timeout(10000) })
|
|
1247
|
+
if (!resp.ok) throw new Error('WoC returned ' + resp.status)
|
|
1248
|
+
const data = await resp.json()
|
|
1249
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1250
|
+
res.end(JSON.stringify(data))
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1253
|
+
res.end(JSON.stringify({ error: 'tx fetch failed: ' + err.message }))
|
|
1254
|
+
}
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// GET /api/mesh/status — alias for /status (web app compat)
|
|
1259
|
+
if (req.method === 'GET' && path === '/api/mesh/status') {
|
|
1260
|
+
const status = await this.getStatus({ authenticated })
|
|
1261
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1262
|
+
res.end(JSON.stringify(status))
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1095
1266
|
res.writeHead(404)
|
|
1096
1267
|
res.end('Not Found')
|
|
1097
1268
|
}
|