@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.
@@ -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
+ }