@relay-federation/bridge 0.3.14 → 0.3.16

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
 
@@ -894,6 +891,7 @@ async function cmdStart () {
894
891
  peerHealth,
895
892
  bsvNodeClient: bsvNode,
896
893
  store,
894
+ addressWatcher: watcher,
897
895
  performOutboundHandshake,
898
896
  registeredPubkeys,
899
897
  gossipManager
@@ -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 = '';