@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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-middleware.js — HTTP 402 payment gate for relay federation bridges.
|
|
3
|
+
*
|
|
4
|
+
* Free reads, paid writes. Operator auth bypasses payment.
|
|
5
|
+
* Design reviewed by Codex over 8 rounds (27+ security mitigations).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const gate = createPaymentGate(config, store, fetchTx)
|
|
9
|
+
* const result = await gate(method, path, req)
|
|
10
|
+
* if (!result.ok) { res.writeHead(result.status, ...); res.end(...); return }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { addressToHash160 } from './output-parser.js'
|
|
14
|
+
|
|
15
|
+
const MAX_CONCURRENT = 50
|
|
16
|
+
const FETCH_TIMEOUT_MS = 5000
|
|
17
|
+
const TXID_RE = /^[0-9a-f]{64}$/i
|
|
18
|
+
const NEG_CACHE_MAX = 10000
|
|
19
|
+
|
|
20
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert BSV decimal string to satoshis as BigInt. No floats.
|
|
24
|
+
* @param {string|number} value — e.g. '0.00001000'
|
|
25
|
+
* @returns {bigint}
|
|
26
|
+
*/
|
|
27
|
+
function bsvToSats (value) {
|
|
28
|
+
const s = String(value)
|
|
29
|
+
if (!/^\d+(\.\d{1,8})?$/.test(s)) throw new Error('bad_value')
|
|
30
|
+
const [whole, frac = ''] = s.split('.')
|
|
31
|
+
const fracPadded = (frac + '00000000').slice(0, 8)
|
|
32
|
+
return BigInt(whole) * 100000000n + BigInt(fracPadded)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract hash160 from a P2PKH locking script hex.
|
|
37
|
+
* Returns null if not P2PKH.
|
|
38
|
+
* @param {string} hex — locking script hex
|
|
39
|
+
* @returns {string|null} 40-char hash160 hex or null
|
|
40
|
+
*/
|
|
41
|
+
function extractP2PKH (hex) {
|
|
42
|
+
if (typeof hex !== 'string') return null
|
|
43
|
+
if (hex.length === 50 && hex.startsWith('76a914') && hex.endsWith('88ac')) {
|
|
44
|
+
return hex.slice(6, 46)
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get satoshis from a vout entry. Prefers integer fields over BSV decimals.
|
|
51
|
+
* @param {object} v — vout entry from tx JSON
|
|
52
|
+
* @returns {bigint}
|
|
53
|
+
*/
|
|
54
|
+
function getVoutSats (v) {
|
|
55
|
+
// Prefer integer satoshi fields (no float conversion needed)
|
|
56
|
+
if (v.valueSat !== undefined && v.valueSat !== null) return BigInt(v.valueSat)
|
|
57
|
+
if (v.satoshis !== undefined && v.satoshis !== null) return BigInt(v.satoshis)
|
|
58
|
+
// Fallback to BSV decimal
|
|
59
|
+
if (v.value !== undefined && v.value !== null) return bsvToSats(v.value)
|
|
60
|
+
return 0n
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find P2PKH outputs paying the expected address. Sums all matching outputs.
|
|
65
|
+
* @param {object} txJson — { vout: [{ value, scriptPubKey: { hex } }] }
|
|
66
|
+
* @param {string} expectedHash160 — 40-char hex
|
|
67
|
+
* @param {bigint} minSats — minimum required payment
|
|
68
|
+
* @returns {{ ok: true, totalPaid: bigint, matched: Array } | null}
|
|
69
|
+
*/
|
|
70
|
+
function findPaymentOutput (txJson, expectedHash160, minSats) {
|
|
71
|
+
let totalPaid = 0n
|
|
72
|
+
const matched = []
|
|
73
|
+
for (let i = 0; i < txJson.vout.length; i++) {
|
|
74
|
+
const v = txJson.vout[i]
|
|
75
|
+
const hash160 = extractP2PKH(v.scriptPubKey?.hex || '')
|
|
76
|
+
if (!hash160) continue
|
|
77
|
+
if (hash160 !== expectedHash160) continue
|
|
78
|
+
const sats = getVoutSats(v)
|
|
79
|
+
totalPaid += sats
|
|
80
|
+
matched.push({ vout: i, sats: sats.toString() })
|
|
81
|
+
}
|
|
82
|
+
if (totalPaid >= minSats) return { ok: true, totalPaid, matched }
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize a URL path: collapse double slashes, strip trailing slash,
|
|
88
|
+
* decode segments, reject smuggled slashes.
|
|
89
|
+
* Returns '/' for root path (never returns empty string).
|
|
90
|
+
* @param {string} raw
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function normalizePath (raw) {
|
|
94
|
+
const collapsed = raw.replace(/\/+/g, '/').replace(/\/$/, '')
|
|
95
|
+
if (!collapsed) return '/'
|
|
96
|
+
const segments = collapsed.split('/')
|
|
97
|
+
const decoded = segments.map(seg => {
|
|
98
|
+
try {
|
|
99
|
+
const d = decodeURIComponent(seg)
|
|
100
|
+
if (d.includes('/')) throw new Error('smuggled_slash')
|
|
101
|
+
return d
|
|
102
|
+
} catch { return seg }
|
|
103
|
+
})
|
|
104
|
+
return decoded.join('/') || '/'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a route table from config endpoint keys and match against a path.
|
|
109
|
+
* Supports parameterized patterns like /inscription/:txid/:vout/content.
|
|
110
|
+
* @param {string} method — uppercased HTTP method
|
|
111
|
+
* @param {string} path — normalized path
|
|
112
|
+
* @param {Array} routes — pre-built route table
|
|
113
|
+
* @returns {string|null} — matched route key or null
|
|
114
|
+
*/
|
|
115
|
+
function matchRoute (method, path, routes) {
|
|
116
|
+
for (const route of routes) {
|
|
117
|
+
if (route.method !== method) continue
|
|
118
|
+
const routeParts = route.parts
|
|
119
|
+
const pathParts = path.split('/')
|
|
120
|
+
if (routeParts.length !== pathParts.length) continue
|
|
121
|
+
const match = routeParts.every((part, i) =>
|
|
122
|
+
part.startsWith(':') || part === pathParts[i]
|
|
123
|
+
)
|
|
124
|
+
if (match) return route.key
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wrap fetchTx with an AbortController timeout.
|
|
131
|
+
* @param {function} fetchTx — async function(txid) → txJson
|
|
132
|
+
* @param {string} txid
|
|
133
|
+
* @param {number} timeoutMs
|
|
134
|
+
* @returns {Promise<object>}
|
|
135
|
+
*/
|
|
136
|
+
async function fetchTxWithTimeout (fetchTx, txid, timeoutMs) {
|
|
137
|
+
const controller = new AbortController()
|
|
138
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
139
|
+
try {
|
|
140
|
+
return await fetchTx(txid, { signal: controller.signal })
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(timer)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Payment Gate Factory ─────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create the x402 payment gate.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} config — bridge config with x402 section
|
|
152
|
+
* @param {object} store — PersistentStore instance (has claimTxid, releaseClaim, etc.)
|
|
153
|
+
* @param {function} fetchTx — async function(txid, opts?) → { txid, vout: [...] }
|
|
154
|
+
* Must throw with { httpStatus } property to distinguish 404 vs upstream failure.
|
|
155
|
+
* @returns {function} async checkPayment(method, rawPath, req) → result
|
|
156
|
+
*/
|
|
157
|
+
export function createPaymentGate (config, store, fetchTx) {
|
|
158
|
+
const pricingMap = config.x402?.endpoints || {}
|
|
159
|
+
const payTo = config.x402?.payTo || ''
|
|
160
|
+
const enabled = !!(config.x402?.enabled && payTo)
|
|
161
|
+
|
|
162
|
+
const _pending = new Map() // txid → { promise, routeKey, price }
|
|
163
|
+
const _negCache = new Map() // txid → { expiry, reason, status }
|
|
164
|
+
|
|
165
|
+
// Build route table from config keys (once at startup)
|
|
166
|
+
const routes = []
|
|
167
|
+
let expectedHash160 = null
|
|
168
|
+
|
|
169
|
+
if (enabled) {
|
|
170
|
+
// Validate payTo — P2PKH only, fail fast
|
|
171
|
+
expectedHash160 = addressToHash160(payTo)
|
|
172
|
+
|
|
173
|
+
for (const [key, price] of Object.entries(pricingMap)) {
|
|
174
|
+
if (!Number.isSafeInteger(price) || price < 0) {
|
|
175
|
+
throw new Error(`[x402] Invalid price for ${key}: must be a non-negative integer`)
|
|
176
|
+
}
|
|
177
|
+
const colonIdx = key.indexOf(':')
|
|
178
|
+
if (colonIdx === -1) {
|
|
179
|
+
throw new Error(`[x402] Invalid endpoint key ${key}: must be METHOD:/path`)
|
|
180
|
+
}
|
|
181
|
+
const method = key.slice(0, colonIdx).toUpperCase()
|
|
182
|
+
const pattern = key.slice(colonIdx + 1)
|
|
183
|
+
if (!pattern.startsWith('/')) {
|
|
184
|
+
throw new Error(`[x402] Invalid endpoint pattern ${pattern}: must start with /`)
|
|
185
|
+
}
|
|
186
|
+
routes.push({ method, pattern, parts: pattern.split('/'), key })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`[x402] Payment gate enabled: payTo=${payTo}, ${routes.length} paid endpoints`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Negative cache ──
|
|
193
|
+
|
|
194
|
+
function isNegativelyCached (txid) {
|
|
195
|
+
const entry = _negCache.get(txid)
|
|
196
|
+
if (!entry) return null
|
|
197
|
+
if (Date.now() > entry.expiry) { _negCache.delete(txid); return null }
|
|
198
|
+
return entry
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cacheNegative (txid, reason, ttlMs, status) {
|
|
202
|
+
const ttl = ttlMs || (reason === 'tx_not_found' ? 8000 : 60000)
|
|
203
|
+
_negCache.set(txid, { expiry: Date.now() + ttl, reason, status: status || 402 })
|
|
204
|
+
if (_negCache.size > NEG_CACHE_MAX) {
|
|
205
|
+
const now = Date.now()
|
|
206
|
+
// First pass: evict expired
|
|
207
|
+
for (const [k, v] of _negCache) {
|
|
208
|
+
if (now > v.expiry) _negCache.delete(k)
|
|
209
|
+
}
|
|
210
|
+
// Second pass: FIFO trim if still oversized
|
|
211
|
+
if (_negCache.size > NEG_CACHE_MAX) {
|
|
212
|
+
const excess = _negCache.size - NEG_CACHE_MAX
|
|
213
|
+
let removed = 0
|
|
214
|
+
for (const k of _negCache.keys()) {
|
|
215
|
+
if (removed >= excess) break
|
|
216
|
+
_negCache.delete(k)
|
|
217
|
+
removed++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── The gate function ──
|
|
224
|
+
|
|
225
|
+
return async function checkPayment (method, rawPath, req) {
|
|
226
|
+
if (!enabled) return { ok: true }
|
|
227
|
+
|
|
228
|
+
method = method.toUpperCase()
|
|
229
|
+
const path = normalizePath(rawPath)
|
|
230
|
+
if (!path.startsWith('/')) return { ok: true }
|
|
231
|
+
|
|
232
|
+
const routeKey = matchRoute(method, path, routes)
|
|
233
|
+
if (!routeKey) return { ok: true }
|
|
234
|
+
const price = pricingMap[routeKey] || 0
|
|
235
|
+
if (price === 0) return { ok: true }
|
|
236
|
+
|
|
237
|
+
// Normalize proof header (handle array, empty, whitespace)
|
|
238
|
+
let proofRaw = req.headers['x-402-proof']
|
|
239
|
+
if (Array.isArray(proofRaw)) proofRaw = proofRaw[0]
|
|
240
|
+
if (!proofRaw || !proofRaw.trim()) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false, status: 402,
|
|
243
|
+
body: {
|
|
244
|
+
x402Version: '1', scheme: 'bsv-direct', error: 'payment_required',
|
|
245
|
+
endpoint: routeKey, satoshis: price,
|
|
246
|
+
accepts: [{ scheme: 'bsv-direct', network: 'mainnet', satoshis: price, payTo }]
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Parse proof: accept <txid> or <txid>:<commit> (v2 stub)
|
|
252
|
+
const proofStr = proofRaw.trim().toLowerCase().slice(0, 256)
|
|
253
|
+
const txid = proofStr.split(':')[0]
|
|
254
|
+
|
|
255
|
+
if (!TXID_RE.test(txid)) {
|
|
256
|
+
return { ok: false, status: 400, body: { error: 'invalid_txid_format' } }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Two-tier negative cache
|
|
260
|
+
const cached = isNegativelyCached(txid)
|
|
261
|
+
if (cached) {
|
|
262
|
+
return { ok: false, status: cached.status, body: { error: cached.reason } }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Cross-endpoint protection
|
|
266
|
+
if (_pending.has(txid)) {
|
|
267
|
+
const inflight = _pending.get(txid)
|
|
268
|
+
if (inflight.routeKey !== routeKey || inflight.price !== price) {
|
|
269
|
+
return { ok: false, status: 402, body: { error: 'already_used' } }
|
|
270
|
+
}
|
|
271
|
+
return await inflight.promise
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Cap concurrent verifications
|
|
275
|
+
if (_pending.size >= MAX_CONCURRENT) {
|
|
276
|
+
return { ok: false, status: 503, body: { error: 'too_many_verifications' } }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const verifyPromise = (async () => {
|
|
280
|
+
// Atomic claim in LevelDB — put-if-absent (u!{txid} key)
|
|
281
|
+
const claim = await store.claimTxid(txid, { routeKey, price, createdAt: Date.now() })
|
|
282
|
+
if (!claim.ok) {
|
|
283
|
+
return { ok: false, status: 402, body: { error: 'already_used' } }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// Fetch tx with timeout — distinguish 404 vs upstream failure
|
|
288
|
+
let txJson
|
|
289
|
+
try {
|
|
290
|
+
txJson = await fetchTxWithTimeout(fetchTx, txid, FETCH_TIMEOUT_MS)
|
|
291
|
+
} catch (err) {
|
|
292
|
+
await store.releaseClaim(txid)
|
|
293
|
+
// 404 = tx genuinely not found (short cache)
|
|
294
|
+
// Anything else = upstream outage (don't punish user, very short cache)
|
|
295
|
+
if (err.httpStatus === 404) {
|
|
296
|
+
cacheNegative(txid, 'tx_not_found', 8000, 402)
|
|
297
|
+
return { ok: false, status: 402, body: { error: 'tx_not_found' } }
|
|
298
|
+
}
|
|
299
|
+
cacheNegative(txid, 'upstream_unavailable', 3000, 503)
|
|
300
|
+
return { ok: false, status: 503, body: { error: 'upstream_unavailable' } }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Sanity checks
|
|
304
|
+
const returnedId = txJson?.txid || txJson?.hash
|
|
305
|
+
if (!returnedId || returnedId !== txid || !Array.isArray(txJson.vout) ||
|
|
306
|
+
txJson.vout.length > 1000) {
|
|
307
|
+
await store.releaseClaim(txid)
|
|
308
|
+
cacheNegative(txid, 'invalid_payment', 60000, 402)
|
|
309
|
+
return { ok: false, status: 402, body: { error: 'invalid_payment' } }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Find P2PKH outputs paying our address
|
|
313
|
+
const payment = findPaymentOutput(txJson, expectedHash160, BigInt(price))
|
|
314
|
+
if (!payment) {
|
|
315
|
+
await store.releaseClaim(txid)
|
|
316
|
+
cacheNegative(txid, 'insufficient_payment', 60000, 402)
|
|
317
|
+
return { ok: false, status: 402, body: { error: 'insufficient_payment' } }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Promote claim to permanent receipt
|
|
321
|
+
const receipt = {
|
|
322
|
+
txid,
|
|
323
|
+
satoshisRequired: String(price),
|
|
324
|
+
satoshisPaid: payment.totalPaid.toString(),
|
|
325
|
+
matchedVouts: payment.matched.map(m => m.vout),
|
|
326
|
+
endpointKey: routeKey,
|
|
327
|
+
createdAt: Date.now(),
|
|
328
|
+
confirmed: false,
|
|
329
|
+
confirmedHeight: null
|
|
330
|
+
}
|
|
331
|
+
await store.finalizePayment(txid, receipt)
|
|
332
|
+
return { ok: true, receipt }
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// Safety net: release claim on ANY unexpected error (bad BigInt, store throw, etc.)
|
|
335
|
+
await store.releaseClaim(txid).catch(() => {})
|
|
336
|
+
console.error(`[x402] unexpected verify error for ${txid}:`, err)
|
|
337
|
+
return { ok: false, status: 500, body: { error: 'internal_error' } }
|
|
338
|
+
}
|
|
339
|
+
})()
|
|
340
|
+
|
|
341
|
+
_pending.set(txid, { promise: verifyPromise, routeKey, price })
|
|
342
|
+
try {
|
|
343
|
+
return await verifyPromise
|
|
344
|
+
} finally {
|
|
345
|
+
_pending.delete(txid)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|