@relay-federation/bridge 0.3.14 → 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 CHANGED
@@ -744,13 +744,10 @@ async function cmdStart () {
744
744
  }
745
745
  })
746
746
 
747
- // Auto-detect incoming payments: request txs announced via INV
747
+ // Track txids announced via BSV P2P inv (lightweight, no full tx fetch)
748
748
  bsvNode.on('tx:inv', ({ txids }) => {
749
749
  for (const txid of txids) {
750
- if (txRelay.seen.has(txid)) continue
751
- bsvNode.getTx(txid, 10000).then(({ txid: id, rawHex }) => {
752
- txRelay.broadcastTx(id, rawHex)
753
- }).catch(() => {}) // ignore fetch failures
750
+ txRelay.trackTxid(txid)
754
751
  }
755
752
  })
756
753
 
@@ -951,6 +951,7 @@
951
951
  <button onclick="setTab('inscriptions')">Inscriptions</button>
952
952
  <button onclick="setTab('tokens')">Tokens</button>
953
953
  <button onclick="setTab('apps')">Apps</button>
954
+ <button onclick="setTab('x402')">x402</button>
954
955
  </div>
955
956
  <div class="header-right">
956
957
  <div class="header-stats">
@@ -1100,9 +1101,11 @@ let activeTab = 'overview';
1100
1101
  let savedExplorerInput = '';
1101
1102
  let savedExplorerResult = '';
1102
1103
  let appsData = null;
1104
+ let x402Data = null;
1103
1105
  let latestPrice = null;
1104
1106
  let mempoolHistory = [];
1105
1107
  let peerCountHistory = [];
1108
+ let rssHistory = [];
1106
1109
 
1107
1110
  function openCardOverlay(type) {
1108
1111
  const b = bridgeData.get(selectedBridge);
@@ -1196,7 +1199,8 @@ function renderCardExpanded(type, b, op) {
1196
1199
  html += '<div class="panel-row"><span class="label">Mesh Bridges</span><span class="value">' + b.peers.connected + ' connected</span></div>';
1197
1200
  html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Transactions</div>';
1198
1201
  html += '<div class="panel-row"><span class="label">Mempool</span><span class="value">' + b.txs.mempool + ' txs</span></div>';
1199
- html += '<div class="panel-row"><span class="label">Seen</span><span class="value">' + b.txs.seen + ' txs</span></div>';
1202
+ html += '<div class="panel-row"><span class="label">Network Seen</span><span class="value">' + (b.txs.known || 0).toLocaleString() + ' txids</span></div>';
1203
+ html += '<div class="panel-row"><span class="label">Relayed</span><span class="value">' + b.txs.seen + ' txs</span></div>';
1200
1204
  // Mempool sparkline (last 20 polls)
1201
1205
  if (mempoolHistory.length > 1) {
1202
1206
  html += '<div style="margin-top:12px"><div style="font-size:11px;color:var(--text-dim);margin-bottom:6px">Mempool History (last ' + mempoolHistory.length + ' polls)</div>';
@@ -1280,6 +1284,40 @@ function renderCardExpanded(type, b, op) {
1280
1284
  html += '<p style="margin-bottom:8px">More BSV bonded + longer held unspent = higher BND score. Minimum: 0.01 BSV.</p>';
1281
1285
  html += '</div></div>';
1282
1286
  break;
1287
+ case 'system':
1288
+ if (!b.system) return '';
1289
+ var sysMem = b.system;
1290
+ var sysMemPct = Math.round(sysMem.usedMemMB / sysMem.totalMemMB * 100);
1291
+ var sysMemClr = sysMemPct > 85 ? 'var(--accent-red)' : sysMemPct > 70 ? 'var(--accent-yellow)' : 'var(--accent-green)';
1292
+ html += '<h2>System</h2>';
1293
+ // RAM hero
1294
+ html += '<div style="text-align:center;padding:20px 0 16px">';
1295
+ html += '<div style="font-size:32px;font-weight:700;color:' + sysMemClr + ';letter-spacing:-1px">' + sysMemPct + '%</div>';
1296
+ html += '<div style="font-size:14px;color:var(--text-muted);margin-top:4px">RAM Usage</div>';
1297
+ html += '</div>';
1298
+ // RAM bar
1299
+ html += '<div style="margin:0 0 20px"><div style="height:8px;background:var(--border);border-radius:4px;overflow:hidden"><div style="height:100%;width:' + sysMemPct + '%;background:' + sysMemClr + ';border-radius:4px;transition:width 0.5s"></div></div>';
1300
+ html += '<div style="display:flex;justify-content:space-between;font-size:11px;margin-top:4px;color:var(--text-dim)"><span>Used: ' + sysMem.usedMemMB + ' MB</span><span>Free: ' + sysMem.freeMemMB + ' MB</span><span>Total: ' + sysMem.totalMemMB + ' MB</span></div></div>';
1301
+ // Process
1302
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px">Process</div>';
1303
+ html += '<div class="panel-row"><span class="label">RSS</span><span class="value">' + sysMem.processRssMB + ' MB</span></div>';
1304
+ html += '<div class="panel-row"><span class="label">Node.js</span><span class="value">' + sysMem.nodeVersion + '</span></div>';
1305
+ // CPU
1306
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">CPU</div>';
1307
+ html += '<div class="panel-row"><span class="label">Cores</span><span class="value">' + sysMem.cpuCount + '</span></div>';
1308
+ html += '<div class="panel-row"><span class="label">Load (1m)</span><span class="value">' + sysMem.loadAvg[0] + '</span></div>';
1309
+ html += '<div class="panel-row"><span class="label">Load (5m)</span><span class="value">' + sysMem.loadAvg[1] + '</span></div>';
1310
+ html += '<div class="panel-row"><span class="label">Load (15m)</span><span class="value">' + sysMem.loadAvg[2] + '</span></div>';
1311
+ // OS
1312
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Host</div>';
1313
+ html += '<div class="panel-row"><span class="label">Platform</span><span class="value">' + sysMem.platform + ' / ' + sysMem.arch + '</span></div>';
1314
+ html += '<div class="panel-row"><span class="label">OS Uptime</span><span class="value">' + fmtUptime(sysMem.osUptime) + '</span></div>';
1315
+ // RSS sparkline
1316
+ if (rssHistory && rssHistory.length > 1) {
1317
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Process RSS (last ' + rssHistory.length + ' polls)</div>';
1318
+ html += renderSparkline(rssHistory, 'rgba(210,153,34,0.7)');
1319
+ }
1320
+ break;
1283
1321
  default: return '';
1284
1322
  }
1285
1323
  return html;
@@ -1311,6 +1349,7 @@ function renderSparkline(data, color) {
1311
1349
  return svg + '</svg>';
1312
1350
  }
1313
1351
  function scoreColor(v) { return v >= 0.7 ? 'green' : v >= 0.4 ? 'yellow' : 'red'; }
1352
+ function fmtAge(ts) { var s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; }
1314
1353
  function fmtSats(n) { return n === null || n === undefined ? '-' : n.toLocaleString(); }
1315
1354
  function fmtHeight(n) { return n > 0 ? n.toLocaleString() : '-'; }
1316
1355
  function isOperator(url) { return !!operatorTokens[url]; }
@@ -1390,7 +1429,11 @@ async function discoverBridges() {
1390
1429
  }
1391
1430
 
1392
1431
  // ── Poll ───────────────────────────────────────────
1432
+ let polling = false;
1393
1433
  async function pollAll() {
1434
+ if (polling) return;
1435
+ polling = true;
1436
+ try {
1394
1437
  if (!discoveryDone) await discoverBridges();
1395
1438
  const results = await Promise.all(BRIDGES.map(fetchBridge));
1396
1439
  const mempools = await Promise.all(BRIDGES.map(fetchMempool));
@@ -1405,12 +1448,14 @@ async function pollAll() {
1405
1448
  const sel = bridgeData.get(selectedBridge);
1406
1449
  if (sel && sel.txs) { mempoolHistory.push(sel.txs.mempool); if (mempoolHistory.length > 20) mempoolHistory.shift(); }
1407
1450
  if (sel && sel.peers) { peerCountHistory.push(sel.peers.connected); if (peerCountHistory.length > 20) peerCountHistory.shift(); }
1451
+ if (sel && sel.system) { rssHistory.push(sel.system.processRssMB); if (rssHistory.length > 20) rssHistory.shift(); }
1408
1452
  // Fetch price from self (same-origin, always works)
1409
1453
  fetch('/price', { signal: AbortSignal.timeout(5000) })
1410
1454
  .then(r => r.ok ? r.json() : null).then(d => { if (d) { latestPrice = d; const el = document.querySelector('.stats-hero .stat-card:last-child .stat-value'); if (el) el.textContent = '$' + d.usd.toFixed(2); } }).catch(() => {});
1411
1455
  renderHeader();
1412
1456
  renderBridgeRail();
1413
1457
  renderActiveTab(true);
1458
+ } finally { polling = false; }
1414
1459
  }
1415
1460
 
1416
1461
  // ── Header stats ───────────────────────────────────
@@ -1464,17 +1509,18 @@ function setTab(tab) {
1464
1509
  if (activeTab === 'overview' && tab !== 'overview') destroyMeshMap3D();
1465
1510
  activeTab = tab;
1466
1511
  const buttons = document.querySelectorAll('#headerTabs button');
1467
- const tabs = ['overview', 'mempool', 'explorer', 'inscriptions', 'tokens', 'apps'];
1512
+ const tabs = ['overview', 'mempool', 'explorer', 'inscriptions', 'tokens', 'apps', 'x402'];
1468
1513
  buttons.forEach((btn, i) => btn.classList.toggle('active', tabs[i] === tab));
1469
1514
  renderActiveTab();
1470
1515
  if (tab === 'apps') setTimeout(fetchAppsData, 50);
1471
1516
  if (tab === 'tokens') setTimeout(fetchTokens, 50);
1517
+ if (tab === 'x402') setTimeout(fetchX402Data, 50);
1472
1518
  }
1473
1519
 
1474
1520
  function renderActiveTab(fromPoll) {
1475
1521
  const el = document.getElementById('tabContent');
1476
1522
  // Skip apps/explorer re-render during poll (preserves state)
1477
- if (fromPoll && (activeTab === 'apps' || activeTab === 'explorer' || activeTab === 'inscriptions' || activeTab === 'tokens')) return;
1523
+ if (fromPoll && (activeTab === 'apps' || activeTab === 'explorer' || activeTab === 'inscriptions' || activeTab === 'tokens' || activeTab === 'x402')) return;
1478
1524
  // On poll, if overview tab has active 3D scene, just update data — don't rebuild DOM
1479
1525
  if (fromPoll && activeTab === 'overview' && mesh3d) {
1480
1526
  updateMeshMap3D();
@@ -1490,6 +1536,7 @@ function renderActiveTab(fromPoll) {
1490
1536
  case 'inscriptions': html = renderInscriptionsTab(); break;
1491
1537
  case 'tokens': html = renderTokensTab(); break;
1492
1538
  case 'apps': html = renderAppsTab(); break;
1539
+ case 'x402': html = renderX402Tab(); break;
1493
1540
  default: html = renderOverviewTab();
1494
1541
  }
1495
1542
  // Only update DOM if content actually changed (prevents flash)
@@ -1637,6 +1684,18 @@ function renderOverviewTab() {
1637
1684
  }
1638
1685
  html += '</div>';
1639
1686
 
1687
+ // System card
1688
+ if (b.system) {
1689
+ var memPct = Math.round(b.system.usedMemMB / b.system.totalMemMB * 100);
1690
+ var memColor = memPct > 85 ? 'red' : memPct > 70 ? 'yellow' : 'green';
1691
+ html += '<div class="detail-card glass" onclick="openCardOverlay(\'system\')"><h3>System <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span></h3>';
1692
+ html += '<div class="panel-row"><span class="label">RAM</span><span class="value ' + memColor + '">' + b.system.usedMemMB + ' / ' + b.system.totalMemMB + ' MB (' + memPct + '%)</span></div>';
1693
+ html += '<div class="panel-row"><span class="label">Process RSS</span><span class="value">' + b.system.processRssMB + ' MB</span></div>';
1694
+ html += '<div class="panel-row"><span class="label">CPU Load</span><span class="value">' + b.system.loadAvg[0] + ' / ' + b.system.loadAvg[1] + ' / ' + b.system.loadAvg[2] + '</span></div>';
1695
+ html += '<div class="panel-row"><span class="label">OS Uptime</span><span class="value">' + fmtUptime(b.system.osUptime) + '</span></div>';
1696
+ html += '</div>';
1697
+ }
1698
+
1640
1699
  html += '</div>'; // cards-grid
1641
1700
 
1642
1701
  return html;
@@ -1649,6 +1708,14 @@ function renderMempoolTab() {
1649
1708
 
1650
1709
  let html = '<div class="tab-title">Mempool <span class="count">' + (b._mempool ? b._mempool.count : 0) + ' transactions</span></div>';
1651
1710
 
1711
+ const known = b.txs ? b.txs.known || 0 : 0;
1712
+ if (known > 0) {
1713
+ html += '<div class="glass" style="padding:16px;margin-bottom:12px;display:flex;gap:24px;align-items:center">';
1714
+ html += '<div><span class="label">Network Txids Seen</span><div style="font-size:1.5em;font-weight:700;color:var(--accent)">' + known.toLocaleString() + '</div></div>';
1715
+ html += '<div style="color:var(--text-dim);font-size:0.85em">Transactions observed via P2P (10 min window)</div>';
1716
+ html += '</div>';
1717
+ }
1718
+
1652
1719
  if (!b._mempool || b._mempool.txs.length === 0) {
1653
1720
  html += '<div class="glass" style="padding:40px;text-align:center"><div style="color:var(--text-dim)">No transactions in mempool</div></div>';
1654
1721
  return html;
@@ -2519,8 +2586,8 @@ function showAuthModal(bridgeUrl) {
2519
2586
  openModal('<h2>Operator Login</h2><div style="margin-bottom:12px;font-size:13px;color:var(--text-secondary)">' + bName + ' &mdash; ' + bIp + '</div><div class="form-group"><label>statusSecret (from bridge config.json)</label><input type="password" id="authInput" value="' + existing + '" placeholder="64-char hex token"></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveAuth(\'' + bridgeUrl + '\')">Login</button></div>');
2520
2587
  setTimeout(() => document.getElementById('authInput').focus(), 100);
2521
2588
  }
2522
- function saveAuth(bridgeUrl) { const t = document.getElementById('authInput').value.trim(); if (!t) delete operatorTokens[bridgeUrl]; else operatorTokens[bridgeUrl] = t; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); closeModal(); pollAll(); }
2523
- function logoutOperator(bridgeUrl) { delete operatorTokens[bridgeUrl]; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); pollAll(); }
2589
+ async function saveAuth(bridgeUrl) { const t = document.getElementById('authInput').value.trim(); if (!t) delete operatorTokens[bridgeUrl]; else operatorTokens[bridgeUrl] = t; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); closeModal(); destroyMeshMap3D(); const bridge = BRIDGES.find(b => b.url === bridgeUrl); if (bridge) { const data = await fetchBridge(bridge); bridgeData.set(data._name, data); } renderActiveTab(false); }
2590
+ async function logoutOperator(bridgeUrl) { delete operatorTokens[bridgeUrl]; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); destroyMeshMap3D(); const bridge = BRIDGES.find(b => b.url === bridgeUrl); if (bridge) { const data = await fetchBridge(bridge); bridgeData.set(data._name, data); } renderActiveTab(false); }
2524
2591
 
2525
2592
  function showRegisterModal(bridgeUrl) {
2526
2593
  openModal('<h2>Register Bridge</h2><div class="warning">This will broadcast a stake bond + registration transaction to the BSV network. Ensure your bridge wallet is funded.</div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" id="regBtn" onclick="doRegister(\'' + bridgeUrl + '\')">Register</button></div><div id="jobLog" class="job-log" style="display:none"></div>');
@@ -2968,6 +3035,175 @@ async function fetchAppsData() {
2968
3035
  }
2969
3036
  }
2970
3037
 
3038
+ // ── x402 tab ──────────────────────────────────────
3039
+ function renderX402Tab() {
3040
+ let html = '<div style="margin-bottom:10px"><span style="font-size:15px;font-weight:600;color:var(--text-primary)">x402 Payment Gate</span>';
3041
+ html += '<span style="font-size:11px;color:var(--text-muted);margin-left:10px">Free reads, paid writes</span></div>';
3042
+
3043
+ if (!x402Data) {
3044
+ html += '<div class="glass" style="padding:20px;color:var(--text-muted)">Loading...</div>';
3045
+ return html;
3046
+ }
3047
+
3048
+ // Status row
3049
+ var statusClr = x402Data.enabled ? 'var(--accent-green)' : 'var(--text-dim)';
3050
+ var statusTxt = x402Data.enabled ? 'ENABLED' : 'DISABLED';
3051
+ html += '<div class="glass" style="padding:10px 16px;margin-bottom:8px;display:flex;align-items:center;gap:10px">';
3052
+ html += '<div style="width:8px;height:8px;border-radius:50%;background:' + statusClr + '"></div>';
3053
+ html += '<span style="font-size:13px;font-weight:600;color:' + statusClr + '">' + statusTxt + '</span>';
3054
+ if (x402Data.payTo) {
3055
+ html += '<span style="margin-left:auto;font-family:monospace;font-size:11px;color:var(--text-muted)">' + escapeHtml(x402Data.payTo) + '</span>';
3056
+ }
3057
+ html += '</div>';
3058
+
3059
+ // Revenue card
3060
+ if (x402Data.revenue) {
3061
+ var rev = x402Data.revenue;
3062
+ html += '<div class="glass" style="padding:12px 16px;margin-bottom:8px">';
3063
+ html += '<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Revenue</div>';
3064
+ html += '<div style="display:flex;align-items:baseline;gap:16px;margin-bottom:8px">';
3065
+ html += '<div><span style="font-size:22px;font-weight:700;color:var(--accent-green)">' + Number(rev.totalSatsEarned).toLocaleString() + '</span><span style="font-size:11px;color:var(--text-muted);margin-left:6px">total sats</span></div>';
3066
+ html += '<div style="font-size:12px;color:var(--text-dim)">' + Number(rev.todaySats).toLocaleString() + ' today</div>';
3067
+ html += '<div style="font-size:12px;color:var(--text-dim)">' + Number(rev.weekSats).toLocaleString() + ' this week</div>';
3068
+ html += '</div>';
3069
+ html += '<div class="panel-row"><span class="label">Receipts</span><span class="value">' + rev.totalReceipts + '</span></div>';
3070
+ html += '<div class="panel-row"><span class="label">Pending</span><span class="value">' + rev.pendingClaims + '</span></div>';
3071
+ html += '</div>';
3072
+ }
3073
+
3074
+ // Pricing table
3075
+ if (x402Data.endpoints && x402Data.endpoints.length > 0) {
3076
+ html += '<div class="glass" style="padding:16px;margin-bottom:12px">';
3077
+ html += '<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">Pricing</div>';
3078
+ for (var i = 0; i < x402Data.endpoints.length; i++) {
3079
+ var ep = x402Data.endpoints[i];
3080
+ html += '<div class="panel-row"><span class="label" style="font-family:monospace;font-size:11px">' + escapeHtml(ep.method) + ' ' + escapeHtml(ep.path) + '</span><span class="value" style="color:var(--accent-green)">' + ep.satoshis.toLocaleString() + ' sats</span></div>';
3081
+ }
3082
+ html += '</div>';
3083
+ }
3084
+
3085
+ // Recent receipts (operator only)
3086
+ if (x402Data.recentReceipts && x402Data.recentReceipts.length > 0) {
3087
+ html += '<div class="glass" style="padding:16px;margin-bottom:12px">';
3088
+ html += '<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">Recent Receipts</div>';
3089
+ for (var j = 0; j < x402Data.recentReceipts.length; j++) {
3090
+ var rx = x402Data.recentReceipts[j];
3091
+ var age = rx.createdAt ? fmtAge(rx.createdAt) : '?';
3092
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border);font-size:11px">';
3093
+ html += '<span style="font-family:monospace;color:var(--text-muted)">' + escapeHtml(rx.txid.slice(0, 12)) + '...</span>';
3094
+ html += '<span style="color:var(--text-dim)">' + escapeHtml(rx.endpoint) + '</span>';
3095
+ html += '<span style="color:var(--accent-green)">' + Number(rx.satoshisPaid).toLocaleString() + ' sats</span>';
3096
+ html += '<span style="color:var(--text-dim)">' + age + '</span>';
3097
+ html += '</div>';
3098
+ }
3099
+ html += '</div>';
3100
+ }
3101
+
3102
+ // Settings panel (operator only)
3103
+ var b = bridgeData.get(selectedBridge);
3104
+ if (b && isOperator(b._url)) {
3105
+ html += '<div class="glass" style="padding:12px 16px;margin-bottom:8px">';
3106
+ html += '<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Settings</div>';
3107
+
3108
+ // Enable/disable checkbox
3109
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">';
3110
+ html += '<input type="checkbox" id="x402Enabled"' + (x402Data.enabled ? ' checked' : '') + ' style="cursor:pointer">';
3111
+ html += '<label for="x402Enabled" class="label" style="cursor:pointer">Enable Payment Gate</label>';
3112
+ html += '</div>';
3113
+
3114
+ // PayTo address
3115
+ html += '<div style="margin-bottom:8px">';
3116
+ html += '<div class="label" style="margin-bottom:4px">Pay To Address</div>';
3117
+ html += '<input type="text" id="x402PayTo" value="' + escapeHtml(x402Data.payTo || '') + '" placeholder="1YourBSVAddress..." style="width:100%;padding:6px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-family:monospace;font-size:11px;box-sizing:border-box">';
3118
+ html += '</div>';
3119
+
3120
+ // Endpoint pricing
3121
+ html += '<div class="label" style="margin-bottom:8px">Endpoint Pricing (satoshis)</div>';
3122
+ var epList = x402Data.endpoints || [];
3123
+ html += '<div id="x402Endpoints">';
3124
+ for (var k = 0; k < epList.length; k++) {
3125
+ html += '<div style="display:flex;gap:8px;margin-bottom:6px;align-items:center">';
3126
+ html += '<input type="text" value="' + escapeHtml(epList[k].method + ':' + epList[k].path) + '" class="x402-ep-key" style="flex:2;padding:4px 6px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-family:monospace;font-size:11px">';
3127
+ html += '<input type="number" value="' + epList[k].satoshis + '" class="x402-ep-val" style="flex:1;padding:4px 6px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-family:monospace;font-size:11px">';
3128
+ html += '<button onclick="removeX402Endpoint(this)" style="background:none;border:none;color:var(--accent-red);cursor:pointer;font-size:14px">×</button>';
3129
+ html += '</div>';
3130
+ }
3131
+ html += '</div>';
3132
+ html += '<button class="btn" style="font-size:11px;padding:4px 12px;margin-bottom:12px" onclick="addX402Endpoint()">+ Add Endpoint</button>';
3133
+
3134
+ // Save button
3135
+ html += '<div style="margin-top:8px;text-align:right">';
3136
+ html += '<button class="btn primary" style="font-size:12px;padding:6px 20px" onclick="saveX402Settings()">Save Settings</button>';
3137
+ html += '</div>';
3138
+ html += '</div>';
3139
+ }
3140
+
3141
+ // Discovery endpoint hint
3142
+ html += '<div class="glass" style="padding:12px;font-size:11px;color:var(--text-dim)">';
3143
+ html += 'Discovery: <span style="font-family:monospace;color:var(--text-muted)">GET /.well-known/x402</span>';
3144
+ html += '</div>';
3145
+
3146
+ return html;
3147
+ }
3148
+
3149
+ async function fetchX402Data() {
3150
+ var b = bridgeData.get(selectedBridge);
3151
+ if (!b) return;
3152
+ try {
3153
+ var r = await fetch(b._url + '/x402' + getAuthParam(b._url), { signal: AbortSignal.timeout(10000) });
3154
+ if (!r.ok) { x402Data = { enabled: false, endpoints: [] }; if (activeTab === 'x402') { var el = document.getElementById('tabContent'); if (el) el.innerHTML = renderX402Tab(); } return; }
3155
+ x402Data = await r.json();
3156
+ if (activeTab === 'x402') {
3157
+ var el = document.getElementById('tabContent');
3158
+ if (el) el.innerHTML = renderX402Tab();
3159
+ }
3160
+ } catch (e) {
3161
+ x402Data = { enabled: false, endpoints: [] };
3162
+ if (activeTab === 'x402') {
3163
+ var el = document.getElementById('tabContent');
3164
+ if (el) el.innerHTML = '<div class="glass" style="padding:20px;color:var(--accent-red)">' + escapeHtml(e.message) + '</div>';
3165
+ }
3166
+ }
3167
+ }
3168
+
3169
+
3170
+ function addX402Endpoint() {
3171
+ var container = document.getElementById('x402Endpoints');
3172
+ if (!container) return;
3173
+ var div = document.createElement('div');
3174
+ div.style.cssText = 'display:flex;gap:8px;margin-bottom:6px;align-items:center';
3175
+ div.innerHTML = '<input type="text" value="POST:/api/broadcast" class="x402-ep-key" style="flex:2;padding:4px 6px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-family:monospace;font-size:11px">' +
3176
+ '<input type="number" value="1000" class="x402-ep-val" style="flex:1;padding:4px 6px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-family:monospace;font-size:11px">' +
3177
+ '<button onclick="removeX402Endpoint(this)" style="background:none;border:none;color:var(--accent-red);cursor:pointer;font-size:14px">\u00d7</button>';
3178
+ container.appendChild(div);
3179
+ }
3180
+
3181
+ function removeX402Endpoint(btn) { btn.parentElement.remove(); }
3182
+
3183
+ async function saveX402Settings() {
3184
+ var b = bridgeData.get(selectedBridge);
3185
+ if (!b) return;
3186
+ var enabled = document.getElementById('x402Enabled')?.checked || false;
3187
+ var payTo = document.getElementById('x402PayTo').value.trim();
3188
+ var keys = document.querySelectorAll('.x402-ep-key');
3189
+ var vals = document.querySelectorAll('.x402-ep-val');
3190
+ var endpoints = {};
3191
+ for (var i = 0; i < keys.length; i++) {
3192
+ var k = keys[i].value.trim();
3193
+ var v = parseInt(vals[i].value, 10);
3194
+ if (k && !isNaN(v)) endpoints[k] = v;
3195
+ }
3196
+ try {
3197
+ var r = await fetch(b._url + '/x402' + getAuthParam(b._url), {
3198
+ method: 'PATCH',
3199
+ headers: { 'Content-Type': 'application/json' },
3200
+ body: JSON.stringify({ enabled: enabled, payTo: payTo, endpoints: endpoints })
3201
+ });
3202
+ if (r.ok) { fetchX402Data(); }
3203
+ else { var err = await r.json().catch(function() { return {}; }); alert(err.error || 'Save failed'); }
3204
+ } catch (e) { alert(e.message); }
3205
+ }
3206
+
2971
3207
  // ── Log viewer ─────────────────────────────────────
2972
3208
  function openLogViewer(bridgeUrl) {
2973
3209
  document.getElementById('logViewerBody').innerHTML = '';
@@ -145,6 +145,21 @@ export class BSVNodeClient extends EventEmitter {
145
145
  return Promise.reject(new Error('not connected to BSV node'))
146
146
  }
147
147
 
148
+ /**
149
+ * Fetch a transaction from a specific peer (the one that announced it via inv).
150
+ * Falls back to any connected peer if the target peer is unavailable.
151
+ * @param {import('./bsv-peer.js').BSVPeer} peer
152
+ * @param {string} txid
153
+ * @param {number} [timeoutMs=5000]
154
+ * @returns {Promise<{ txid, rawHex }>}
155
+ */
156
+ getTxFromPeer (peer, txid, timeoutMs = 5000) {
157
+ if (peer && peer._handshakeComplete) {
158
+ return peer.getTx(txid, timeoutMs)
159
+ }
160
+ return this.getTx(txid, timeoutMs)
161
+ }
162
+
148
163
  /**
149
164
  * Trigger header sync on connected peers.
150
165
  */
package/lib/bsv-peer.js CHANGED
@@ -593,7 +593,7 @@ export class BSVPeer extends EventEmitter {
593
593
  }
594
594
 
595
595
  if (txids.length > 0) {
596
- this.emit('tx:inv', { txids })
596
+ this.emit('tx:inv', { txids, peer: this })
597
597
  }
598
598
  }
599
599
 
@@ -723,7 +723,7 @@ export class BSVPeer extends EventEmitter {
723
723
  offset += writeVarInt(payload, offset, userAgentBuf.length)
724
724
  userAgentBuf.copy(payload, offset); offset += userAgentBuf.length
725
725
  payload.writeInt32LE(this._bestHeight, offset); offset += 4
726
- payload[offset] = 0; offset += 1
726
+ payload[offset] = 1; offset += 1
727
727
 
728
728
  this._sendMessage('version', payload.subarray(0, offset))
729
729
  }
@@ -43,6 +43,7 @@ export class PersistentStore extends EventEmitter {
43
43
  this._content = null
44
44
  this._tokens = null
45
45
  this._sessions = null
46
+ this._paymentReceipts = null
46
47
  this._contentDir = join(dataDir, 'content')
47
48
  }
48
49
 
@@ -63,6 +64,7 @@ export class PersistentStore extends EventEmitter {
63
64
  this._content = this.db.sublevel('content', { valueEncoding: 'json' })
64
65
  this._tokens = this.db.sublevel('tokens', { valueEncoding: 'json' })
65
66
  this._sessions = this.db.sublevel('sessions', { valueEncoding: 'json' })
67
+ this._paymentReceipts = this.db.sublevel('payment_receipts', { valueEncoding: 'json' })
66
68
  await mkdir(this._contentDir, { recursive: true })
67
69
  this.emit('open')
68
70
  }
@@ -801,6 +803,76 @@ export class PersistentStore extends EventEmitter {
801
803
  for await (const _ of this._inscriptions.keys()) count++
802
804
  return count
803
805
  }
806
+
807
+ // ── x402 Payment Receipts ──────────────────────────────
808
+
809
+ /**
810
+ * Atomic claim — put-if-absent. Returns { ok: true } if claimed,
811
+ * { ok: false } if txid already exists (replay blocked).
812
+ */
813
+ async claimTxid (txid, { routeKey, price, createdAt }) {
814
+ const key = `u!${txid}`
815
+ try {
816
+ await this._paymentReceipts.put(key,
817
+ { status: 'claimed', routeKey, price, createdAt },
818
+ { ifNotExists: true })
819
+ return { ok: true }
820
+ } catch (err) {
821
+ if (err.code !== 'LEVEL_KEY_EXISTS' && err?.cause?.code !== 'LEVEL_KEY_EXISTS')
822
+ console.error(`[x402] unexpected claimTxid error for ${txid}:`, err.message)
823
+ return { ok: false }
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Release a claim (verification failed). Only deletes if status is 'claimed'.
829
+ * Never deletes receipts — finalized payments are permanent.
830
+ */
831
+ async releaseClaim (txid) {
832
+ const key = `u!${txid}`
833
+ try {
834
+ const val = await this._paymentReceipts.get(key)
835
+ if (val && val.status === 'claimed') await this._paymentReceipts.del(key)
836
+ } catch {}
837
+ }
838
+
839
+ /**
840
+ * Promote claim to permanent receipt. Overwrites in-place — key is
841
+ * NEVER deleted after this, blocking replay permanently.
842
+ */
843
+ async finalizePayment (txid, receipt) {
844
+ await this._paymentReceipts.put(`u!${txid}`, { ...receipt, status: 'receipt' })
845
+ }
846
+
847
+ /**
848
+ * Startup sweep — delete stale claims older than maxAgeMs (default 5 min).
849
+ * Only touches status === 'claimed' keys. Receipts are untouched.
850
+ */
851
+ async cleanupStaleClaims (maxAgeMs = 300000) {
852
+ const now = Date.now()
853
+ for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
854
+ if (val.status !== 'claimed') continue
855
+ if (!val.createdAt || (now - val.createdAt) > maxAgeMs)
856
+ await this._paymentReceipts.del(key)
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Prune old receipts — chunked batch deletes for receipts older than N months.
862
+ */
863
+ async pruneOldReceipts (monthsToKeep = 6) {
864
+ const cutoffMs = Date.now() - (monthsToKeep * 30 * 24 * 60 * 60 * 1000)
865
+ const CHUNK = 500
866
+ let ops = []
867
+ for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
868
+ if (val.status !== 'receipt') continue
869
+ if (val.createdAt && val.createdAt < cutoffMs) {
870
+ ops.push({ type: 'del', key })
871
+ if (ops.length >= CHUNK) { await this._paymentReceipts.batch(ops); ops = [] }
872
+ }
873
+ }
874
+ if (ops.length > 0) await this._paymentReceipts.batch(ops)
875
+ }
804
876
  }
805
877
 
806
878
  /** Double SHA-256 (Bitcoin standard) */
@@ -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
+ }
@@ -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.14",
3
+ "version": "0.3.15",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {