@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 +2 -5
- package/dashboard/index.html +241 -5
- package/lib/bsv-node-client.js +15 -0
- package/lib/bsv-peer.js +2 -2
- package/lib/persistent-store.js +72 -0
- package/lib/status-server.js +232 -2
- package/lib/tx-relay.js +29 -0
- package/lib/x402-endpoints.js +46 -0
- package/lib/x402-middleware.js +348 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -744,13 +744,10 @@ async function cmdStart () {
|
|
|
744
744
|
}
|
|
745
745
|
})
|
|
746
746
|
|
|
747
|
-
//
|
|
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
|
-
|
|
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
|
|
package/dashboard/index.html
CHANGED
|
@@ -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.
|
|
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 + ' — ' + 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();
|
|
2523
|
-
function logoutOperator(bridgeUrl) { delete operatorTokens[bridgeUrl]; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens));
|
|
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 = '';
|
package/lib/bsv-node-client.js
CHANGED
|
@@ -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] =
|
|
726
|
+
payload[offset] = 1; offset += 1
|
|
727
727
|
|
|
728
728
|
this._sendMessage('version', payload.subarray(0, offset))
|
|
729
729
|
}
|
package/lib/persistent-store.js
CHANGED
|
@@ -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) */
|
package/lib/status-server.js
CHANGED
|
@@ -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
|
+
}
|