@relay-federation/bridge 0.3.13 → 0.3.15
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 +2 -5
- package/dashboard/index.html +241 -5
- package/lib/bsv-node-client.js +28 -1
- package/lib/bsv-peer.js +21 -14
- package/lib/persistent-store.js +72 -0
- package/lib/status-server.js +232 -2
- package/lib/tx-relay.js +29 -0
- package/lib/x402-endpoints.js +46 -0
- package/lib/x402-middleware.js +348 -0
- package/package.json +1 -1
package/lib/status-server.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
1
2
|
import { createServer } from 'node:http'
|
|
2
3
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
4
5
|
import { join, dirname } from 'node:path'
|
|
5
6
|
import { fileURLToPath } from 'node:url'
|
|
6
7
|
import https from 'node:https'
|
|
7
8
|
import { parseTx } from './output-parser.js'
|
|
8
9
|
import { scanAddress } from './address-scanner.js'
|
|
9
10
|
import { handlePostData, handleGetTopics, handleGetData } from './data-endpoints.js'
|
|
11
|
+
import { createPaymentGate } from './x402-middleware.js'
|
|
12
|
+
import { handleWellKnownX402 } from './x402-endpoints.js'
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* StatusServer — public-facing HTTP server exposing bridge status and APIs.
|
|
@@ -82,6 +85,44 @@ export class StatusServer {
|
|
|
82
85
|
try { this._appBridgeDomains.add(new URL(app.url).hostname) } catch {}
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
|
|
89
|
+
// x402 payment gate
|
|
90
|
+
this._paymentGate = null
|
|
91
|
+
if (this._config.x402?.enabled && this._config.x402?.payTo && this._store) {
|
|
92
|
+
try {
|
|
93
|
+
const fetchTx = async (txid, opts) => {
|
|
94
|
+
// Check mempool first
|
|
95
|
+
if (this._txRelay?.mempool.has(txid)) {
|
|
96
|
+
const raw = this._txRelay.mempool.get(txid)
|
|
97
|
+
const p = parseTx(raw)
|
|
98
|
+
return { txid: p.txid, vout: p.outputs.map(o => ({ satoshis: o.satoshis, scriptPubKey: { hex: o.scriptHex } })) }
|
|
99
|
+
}
|
|
100
|
+
// Try BSV P2P
|
|
101
|
+
if (this._bsvNodeClient) {
|
|
102
|
+
try {
|
|
103
|
+
const { rawHex } = await this._bsvNodeClient.getTx(txid, 5000)
|
|
104
|
+
const p = parseTx(rawHex)
|
|
105
|
+
return { txid: p.txid, vout: p.outputs.map(o => ({ satoshis: o.satoshis, scriptPubKey: { hex: o.scriptHex } })) }
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
// WoC fallback
|
|
109
|
+
const resp = await fetch(
|
|
110
|
+
`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}`,
|
|
111
|
+
{ signal: opts?.signal || AbortSignal.timeout(5000) }
|
|
112
|
+
)
|
|
113
|
+
if (!resp.ok) {
|
|
114
|
+
const err = new Error(`WoC ${resp.status}`)
|
|
115
|
+
err.httpStatus = resp.status
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
118
|
+
return await resp.json()
|
|
119
|
+
}
|
|
120
|
+
this._paymentGate = createPaymentGate(this._config, this._store, fetchTx)
|
|
121
|
+
this._store.cleanupStaleClaims().catch(() => {})
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error('[x402] Failed to create payment gate:', err.message)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
85
126
|
}
|
|
86
127
|
|
|
87
128
|
/**
|
|
@@ -138,12 +179,25 @@ export class StatusServer {
|
|
|
138
179
|
},
|
|
139
180
|
txs: {
|
|
140
181
|
mempool: this._txRelay ? this._txRelay.mempool.size : 0,
|
|
182
|
+
known: this._txRelay ? this._txRelay.knownTxids.size : 0,
|
|
141
183
|
seen: this._txRelay ? this._txRelay.seen.size : 0
|
|
142
184
|
},
|
|
143
185
|
bsvNode: {
|
|
144
186
|
connected: this._bsvNodeClient ? this._bsvNodeClient.connectedCount > 0 : false,
|
|
145
187
|
peers: this._bsvNodeClient ? this._bsvNodeClient.connectedCount : 0,
|
|
146
188
|
height: this._bsvNodeClient ? this._bsvNodeClient.bestHeight : null
|
|
189
|
+
},
|
|
190
|
+
system: {
|
|
191
|
+
totalMemMB: Math.round(os.totalmem() / 1048576),
|
|
192
|
+
freeMemMB: Math.round(os.freemem() / 1048576),
|
|
193
|
+
usedMemMB: Math.round((os.totalmem() - os.freemem()) / 1048576),
|
|
194
|
+
processRssMB: Math.round(process.memoryUsage.rss() / 1048576),
|
|
195
|
+
cpuCount: os.cpus().length,
|
|
196
|
+
loadAvg: os.loadavg().map(v => Math.round(v * 100) / 100),
|
|
197
|
+
platform: os.platform(),
|
|
198
|
+
arch: os.arch(),
|
|
199
|
+
nodeVersion: process.version,
|
|
200
|
+
osUptime: Math.floor(os.uptime())
|
|
147
201
|
}
|
|
148
202
|
}
|
|
149
203
|
|
|
@@ -335,7 +389,7 @@ export class StatusServer {
|
|
|
335
389
|
this._server = createServer((req, res) => {
|
|
336
390
|
// CORS headers for federation dashboard
|
|
337
391
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
338
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
392
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS')
|
|
339
393
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
340
394
|
|
|
341
395
|
if (req.method === 'OPTIONS') {
|
|
@@ -390,6 +444,23 @@ export class StatusServer {
|
|
|
390
444
|
}
|
|
391
445
|
}
|
|
392
446
|
|
|
447
|
+
// GET /.well-known/x402 — pricing discovery (always free)
|
|
448
|
+
if (req.method === 'GET' && path === '/.well-known/x402') {
|
|
449
|
+
handleWellKnownX402(this._config, PKG_VERSION, res)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// x402 payment gate — authenticated (operator) requests bypass
|
|
454
|
+
if (this._paymentGate && !authenticated) {
|
|
455
|
+
const result = await this._paymentGate(req.method, path, req)
|
|
456
|
+
if (!result.ok) {
|
|
457
|
+
res.writeHead(result.status, { 'Content-Type': 'application/json' })
|
|
458
|
+
res.end(JSON.stringify(result.body))
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (result.receipt) req._x402Receipt = result.receipt
|
|
462
|
+
}
|
|
463
|
+
|
|
393
464
|
// GET /status — public or operator status
|
|
394
465
|
if (req.method === 'GET' && path === '/status') {
|
|
395
466
|
const status = await this.getStatus({ authenticated })
|
|
@@ -430,6 +501,24 @@ export class StatusServer {
|
|
|
430
501
|
return
|
|
431
502
|
}
|
|
432
503
|
|
|
504
|
+
// GET /mempool/known/:txid — fast check if txid was seen on the BSV network
|
|
505
|
+
const knownMatch = path.match(/^\/mempool\/known\/([0-9a-f]{64})$/)
|
|
506
|
+
if (req.method === 'GET' && knownMatch) {
|
|
507
|
+
const txid = knownMatch[1]
|
|
508
|
+
if (this._txRelay && this._txRelay.mempool.has(txid)) {
|
|
509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
510
|
+
res.end(JSON.stringify({ known: true, source: 'mempool' }))
|
|
511
|
+
} else if (this._txRelay && this._txRelay.knownTxids.has(txid)) {
|
|
512
|
+
const firstSeen = this._txRelay.knownTxids.get(txid)
|
|
513
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
514
|
+
res.end(JSON.stringify({ known: true, source: 'inv', firstSeen }))
|
|
515
|
+
} else {
|
|
516
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
517
|
+
res.end(JSON.stringify({ known: false }))
|
|
518
|
+
}
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
|
|
433
522
|
// GET /discover — public list of all known bridges in the mesh
|
|
434
523
|
if (req.method === 'GET' && path === '/discover') {
|
|
435
524
|
const bridges = []
|
|
@@ -1111,6 +1200,147 @@ export class StatusServer {
|
|
|
1111
1200
|
return
|
|
1112
1201
|
}
|
|
1113
1202
|
|
|
1203
|
+
// GET /x402 — payment gate stats (operator-only details when authenticated)
|
|
1204
|
+
if (req.method === 'GET' && path === '/x402') {
|
|
1205
|
+
const x402Config = this._config.x402 || {}
|
|
1206
|
+
const enabled = !!(x402Config.enabled && x402Config.payTo)
|
|
1207
|
+
const result = {
|
|
1208
|
+
enabled,
|
|
1209
|
+
payTo: x402Config.payTo || '',
|
|
1210
|
+
endpoints: []
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Build pricing table
|
|
1214
|
+
if (x402Config.endpoints) {
|
|
1215
|
+
for (const [key, satoshis] of Object.entries(x402Config.endpoints)) {
|
|
1216
|
+
const colonIdx = key.indexOf(':')
|
|
1217
|
+
if (colonIdx === -1) continue
|
|
1218
|
+
result.endpoints.push({
|
|
1219
|
+
method: key.slice(0, colonIdx),
|
|
1220
|
+
path: key.slice(colonIdx + 1),
|
|
1221
|
+
satoshis
|
|
1222
|
+
})
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Read receipts from LevelDB if store is available
|
|
1227
|
+
if (this._store && this._store._paymentReceipts) {
|
|
1228
|
+
let totalReceipts = 0
|
|
1229
|
+
let totalSatsEarned = 0n
|
|
1230
|
+
let pendingClaims = 0
|
|
1231
|
+
const recentReceipts = []
|
|
1232
|
+
const now = Date.now()
|
|
1233
|
+
const oneDayAgo = now - 86400000
|
|
1234
|
+
const oneWeekAgo = now - 604800000
|
|
1235
|
+
let todaySats = 0n
|
|
1236
|
+
let weekSats = 0n
|
|
1237
|
+
|
|
1238
|
+
try {
|
|
1239
|
+
for await (const [key, val] of this._store._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
|
|
1240
|
+
if (val.status === 'receipt') {
|
|
1241
|
+
totalReceipts++
|
|
1242
|
+
const paid = BigInt(val.satoshisPaid || val.satoshisRequired || '0')
|
|
1243
|
+
totalSatsEarned += paid
|
|
1244
|
+
if (val.createdAt && val.createdAt > oneDayAgo) todaySats += paid
|
|
1245
|
+
if (val.createdAt && val.createdAt > oneWeekAgo) weekSats += paid
|
|
1246
|
+
if (recentReceipts.length < 20) {
|
|
1247
|
+
recentReceipts.push({
|
|
1248
|
+
txid: val.txid || key.slice(2),
|
|
1249
|
+
satoshisPaid: (val.satoshisPaid || val.satoshisRequired || '0'),
|
|
1250
|
+
endpoint: val.endpointKey || val.endpoint || '',
|
|
1251
|
+
createdAt: val.createdAt || null
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
} else if (val.status === 'claimed') {
|
|
1255
|
+
pendingClaims++
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
} catch {}
|
|
1259
|
+
|
|
1260
|
+
result.revenue = {
|
|
1261
|
+
totalReceipts,
|
|
1262
|
+
totalSatsEarned: totalSatsEarned.toString(),
|
|
1263
|
+
todaySats: todaySats.toString(),
|
|
1264
|
+
weekSats: weekSats.toString(),
|
|
1265
|
+
pendingClaims
|
|
1266
|
+
}
|
|
1267
|
+
if (authenticated) {
|
|
1268
|
+
result.recentReceipts = recentReceipts.reverse()
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1273
|
+
res.end(JSON.stringify(result))
|
|
1274
|
+
return
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// PATCH /x402 — update x402 settings (operator-only)
|
|
1278
|
+
if (req.method === 'PATCH' && path === '/x402') {
|
|
1279
|
+
if (!authenticated) {
|
|
1280
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
1281
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
try {
|
|
1286
|
+
const chunks = []
|
|
1287
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
1288
|
+
const body = JSON.parse(Buffer.concat(chunks).toString())
|
|
1289
|
+
|
|
1290
|
+
// Update in-memory config
|
|
1291
|
+
if (!this._config.x402) this._config.x402 = {}
|
|
1292
|
+
if (body.enabled !== undefined) this._config.x402.enabled = !!body.enabled
|
|
1293
|
+
if (body.payTo !== undefined) this._config.x402.payTo = String(body.payTo)
|
|
1294
|
+
if (body.endpoints !== undefined && typeof body.endpoints === 'object') {
|
|
1295
|
+
// Validate all prices are non-negative safe integers
|
|
1296
|
+
for (const [key, price] of Object.entries(body.endpoints)) {
|
|
1297
|
+
if (!Number.isSafeInteger(price) || price < 0) {
|
|
1298
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1299
|
+
res.end(JSON.stringify({ error: `Invalid price for ${key}: must be a non-negative integer` }))
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
this._config.x402.endpoints = body.endpoints
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Write config to disk
|
|
1307
|
+
const configDir = this._config.dataDir ? dirname(this._config.dataDir) : join(os.homedir(), '.relay-bridge')
|
|
1308
|
+
const configPath = join(configDir, 'config.json')
|
|
1309
|
+
writeFileSync(configPath, JSON.stringify(this._config, null, 2))
|
|
1310
|
+
|
|
1311
|
+
// Recreate payment gate with new settings
|
|
1312
|
+
if (this._config.x402.enabled && this._config.x402.payTo && this._store) {
|
|
1313
|
+
try {
|
|
1314
|
+
const fetchTx = async (txid, opts) => {
|
|
1315
|
+
const resp = await fetch(
|
|
1316
|
+
`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}`,
|
|
1317
|
+
{ signal: opts?.signal || AbortSignal.timeout(5000) }
|
|
1318
|
+
)
|
|
1319
|
+
if (!resp.ok) {
|
|
1320
|
+
const err = new Error(`WoC ${resp.status}`)
|
|
1321
|
+
err.httpStatus = resp.status
|
|
1322
|
+
throw err
|
|
1323
|
+
}
|
|
1324
|
+
return await resp.json()
|
|
1325
|
+
}
|
|
1326
|
+
this._paymentGate = createPaymentGate(this._config, this._store, fetchTx)
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
console.error('[x402] Failed to recreate payment gate:', err.message)
|
|
1329
|
+
this._paymentGate = null
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
this._paymentGate = null
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1336
|
+
res.end(JSON.stringify({ ok: true, x402: this._config.x402 }))
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1339
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1340
|
+
}
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1114
1344
|
// GET /health — MCP/CLI compatibility
|
|
1115
1345
|
if (req.method === 'GET' && path === '/health') {
|
|
1116
1346
|
const status = await this.getStatus()
|
package/lib/tx-relay.js
CHANGED
|
@@ -32,6 +32,11 @@ export class TxRelay extends EventEmitter {
|
|
|
32
32
|
this.seen = new Set()
|
|
33
33
|
this._maxMempool = opts.maxMempool || 1000
|
|
34
34
|
|
|
35
|
+
/** @type {Map<string, number>} txid → timestamp first seen via BSV P2P inv */
|
|
36
|
+
this.knownTxids = new Map()
|
|
37
|
+
this._knownTxidMax = opts.maxKnownTxids || 50000
|
|
38
|
+
this._knownTxidTtlMs = opts.knownTxidTtlMs || 600000 // 10 min
|
|
39
|
+
|
|
35
40
|
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
36
41
|
this._handleMessage(pubkeyHex, message)
|
|
37
42
|
})
|
|
@@ -60,6 +65,30 @@ export class TxRelay extends EventEmitter {
|
|
|
60
65
|
return this.mempool.get(txid) || null
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Record a txid as "seen on the BSV network" without storing the full tx.
|
|
70
|
+
* @param {string} txid
|
|
71
|
+
*/
|
|
72
|
+
trackTxid (txid) {
|
|
73
|
+
if (this.knownTxids.has(txid)) return
|
|
74
|
+
this.knownTxids.set(txid, Date.now())
|
|
75
|
+
if (this.knownTxids.size > this._knownTxidMax) {
|
|
76
|
+
const now = Date.now()
|
|
77
|
+
for (const [id, ts] of this.knownTxids) {
|
|
78
|
+
if (now - ts > this._knownTxidTtlMs) this.knownTxids.delete(id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if we've seen a txid on the network (inv or mempool).
|
|
85
|
+
* @param {string} txid
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
hasSeen (txid) {
|
|
89
|
+
return this.seen.has(txid) || this.knownTxids.has(txid)
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
/** @private */
|
|
64
93
|
_storeTx (txid, rawHex) {
|
|
65
94
|
if (this.mempool.size >= this._maxMempool) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-endpoints.js — Discovery endpoint for x402 payment middleware.
|
|
3
|
+
*
|
|
4
|
+
* GET /.well-known/x402 — returns pricing info, free endpoints, payTo address.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle GET /.well-known/x402 — pricing discovery.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config — bridge config with x402 section
|
|
11
|
+
* @param {string} version — bridge version string
|
|
12
|
+
* @param {import('node:http').ServerResponse} res
|
|
13
|
+
*/
|
|
14
|
+
export function handleWellKnownX402 (config, version, res) {
|
|
15
|
+
const pricingMap = config.x402?.endpoints || {}
|
|
16
|
+
const endpoints = []
|
|
17
|
+
for (const [key, satoshis] of Object.entries(pricingMap)) {
|
|
18
|
+
const colonIdx = key.indexOf(':')
|
|
19
|
+
if (colonIdx === -1) continue
|
|
20
|
+
const method = key.slice(0, colonIdx)
|
|
21
|
+
const path = key.slice(colonIdx + 1)
|
|
22
|
+
endpoints.push({ method, path, satoshis })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
|
26
|
+
res.end(JSON.stringify({
|
|
27
|
+
x402Version: '1',
|
|
28
|
+
bridge: 'relay-federation',
|
|
29
|
+
version,
|
|
30
|
+
payTo: config.x402?.payTo || '',
|
|
31
|
+
enabled: !!(config.x402?.enabled && config.x402?.payTo),
|
|
32
|
+
endpoints,
|
|
33
|
+
freeEndpoints: [
|
|
34
|
+
'/health',
|
|
35
|
+
'/.well-known/x402',
|
|
36
|
+
'/status',
|
|
37
|
+
'/api/address/*/unspent',
|
|
38
|
+
'/api/address/*/history',
|
|
39
|
+
'/api/address/*/balance',
|
|
40
|
+
'/api/tx/*/hex',
|
|
41
|
+
'/api/tx/*',
|
|
42
|
+
'/api/sessions/*',
|
|
43
|
+
'/api/sessions/index'
|
|
44
|
+
]
|
|
45
|
+
}))
|
|
46
|
+
}
|