@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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {