feat(tracker): unified auth boundary — gossip HMAC + validator token + admin-gated reads (alpha 01/02/20, ADR-0017)

Wire server.py handlers to the auth helper: forfeit requires validator
service token or admin session (client API keys rejected); billing summary/
settlements/registry-wallets and benchmark endpoints require admin/service;
the three gossip mutation endpoints require a fresh hive HMAC signature and
outgoing gossip pushes are signed. Dashboard sends its session token on
panel fetches. Existing tests updated for the new gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dobromir Popov
2026-07-05 00:08:25 +02:00
parent 7414ce1e29
commit 81719ed84b
12 changed files with 4591 additions and 4557 deletions

View File

@@ -1,4 +1,4 @@
Status: ready-for-agent Status: done
# 01 — C1: Authenticate hive gossip endpoints # 01 — C1: Authenticate hive gossip endpoints

View File

@@ -1,4 +1,4 @@
Status: ready-for-agent Status: done
# 02 — A2: Unified auth boundary for privileged and financial reads # 02 — A2: Unified auth boundary for privileged and financial reads

View File

@@ -1,4 +1,4 @@
Status: ready-for-agent Status: done
# 20 — Validator service token for `/v1/billing/forfeit` # 20 — Validator service token for `/v1/billing/forfeit`

View File

@@ -1,383 +1,383 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>meshnet tracker</title> <title>meshnet tracker</title>
<style> <style>
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9; :root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
--dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; } --dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); body { margin:0; background:var(--bg); color:var(--fg);
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
header { display:flex; align-items:baseline; gap:14px; padding:14px 20px; header { display:flex; align-items:baseline; gap:14px; padding:14px 20px;
border-bottom:1px solid var(--border); } border-bottom:1px solid var(--border); }
header h1 { font-size:16px; margin:0; color:var(--accent); } header h1 { font-size:16px; margin:0; color:var(--accent); }
header .meta { color:var(--dim); font-size:12px; } header .meta { color:var(--dim); font-size:12px; }
main { display:grid; grid-template-columns:repeat(auto-fit,minmax(340px,1fr)); main { display:grid; grid-template-columns:repeat(auto-fit,minmax(340px,1fr));
gap:14px; padding:14px 20px; } gap:14px; padding:14px 20px; }
section { background:var(--panel); border:1px solid var(--border); section { background:var(--panel); border:1px solid var(--border);
border-radius:8px; padding:12px 14px; min-height:80px; } border-radius:8px; padding:12px 14px; min-height:80px; }
section h2 { margin:0 0 8px; font-size:12px; text-transform:uppercase; section h2 { margin:0 0 8px; font-size:12px; text-transform:uppercase;
letter-spacing:.08em; color:var(--dim); } letter-spacing:.08em; color:var(--dim); }
table { width:100%; border-collapse:collapse; font-size:12px; } table { width:100%; border-collapse:collapse; font-size:12px; }
th { text-align:left; color:var(--dim); font-weight:normal; th { text-align:left; color:var(--dim); font-weight:normal;
border-bottom:1px solid var(--border); padding:2px 6px 4px 0; } border-bottom:1px solid var(--border); padding:2px 6px 4px 0; }
td { padding:3px 6px 3px 0; border-bottom:1px solid #21262d; } td { padding:3px 6px 3px 0; border-bottom:1px solid #21262d; }
.ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); } .ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
.dim { color:var(--dim); } .num { text-align:right; } .dim { color:var(--dim); } .num { text-align:right; }
a { color:var(--accent); text-decoration:none; } a { color:var(--accent); text-decoration:none; }
.empty { color:var(--dim); font-style:italic; } .empty { color:var(--dim); font-style:italic; }
.pill { display:inline-block; padding:0 7px; border-radius:9px; .pill { display:inline-block; padding:0 7px; border-radius:9px;
border:1px solid var(--border); font-size:11px; } border:1px solid var(--border); font-size:11px; }
input, button { font:inherit; color:var(--fg); background:var(--bg); input, button { font:inherit; color:var(--fg); background:var(--bg);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; } border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
input { width:100%; margin-bottom:6px; } input { width:100%; margin-bottom:6px; }
button { cursor:pointer; color:var(--accent); } button { cursor:pointer; color:var(--accent); }
button:hover { border-color:var(--accent); } button:hover { border-color:var(--accent); }
button.small { font-size:11px; padding:1px 7px; } button.small { font-size:11px; padding:1px 7px; }
.form-row { display:flex; gap:8px; } .form-row { display:flex; gap:8px; }
.form-row button { white-space:nowrap; } .form-row button { white-space:nowrap; }
.error-msg { color:var(--bad); font-size:12px; min-height:16px; } .error-msg { color:var(--bad); font-size:12px; min-height:16px; }
.keybox { word-break:break-all; background:var(--bg); border:1px solid var(--border); .keybox { word-break:break-all; background:var(--bg); border:1px solid var(--border);
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; } border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
.tabs { display:flex; gap:10px; margin-bottom:8px; } .tabs { display:flex; gap:10px; margin-bottom:8px; }
.tabs a { color:var(--dim); cursor:pointer; } .tabs a { color:var(--dim); cursor:pointer; }
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); } .tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>meshnet tracker</h1> <h1>meshnet tracker</h1>
<span class="meta" id="self-url"></span> <span class="meta" id="self-url"></span>
<span class="meta" id="refreshed"></span> <span class="meta" id="refreshed"></span>
</header> </header>
<main> <main>
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section> <section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section> <section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section> <section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
<section><h2>Nodes &amp; coverage</h2><div id="nodes" class="empty">loading…</div></section> <section><h2>Nodes &amp; coverage</h2><div id="nodes" class="empty">loading…</div></section>
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section> <section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
<section><h2>Node pending payouts</h2><div id="pending" class="empty">loading…</div></section> <section><h2>Node pending payouts</h2><div id="pending" class="empty">loading…</div></section>
<section><h2>Settlement history</h2><div id="settlements" class="empty">loading…</div></section> <section><h2>Settlement history</h2><div id="settlements" class="empty">loading…</div></section>
<section><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">loading…</div></section> <section><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">loading…</div></section>
<section><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section> <section><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
<section><h2>Node throughput</h2><div id="throughput" class="empty">loading…</div></section> <section><h2>Node throughput</h2><div id="throughput" class="empty">loading…</div></section>
</main> </main>
<script> <script>
"use strict"; "use strict";
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, const esc = s => String(s).replace(/[&<>"]/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6); const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6);
const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1); const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1);
const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; }; const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
async function fetchJson(path) { async function fetchJson(path) {
try { try {
const headers = {}; const headers = {};
const token = localStorage.getItem("meshnet_session"); const token = localStorage.getItem("meshnet_session");
if (token) headers["Authorization"] = "Bearer " + token; if (token) headers["Authorization"] = "Bearer " + token;
const r = await fetch(path, { headers }); const r = await fetch(path, { headers });
if (!r.ok) return null; if (!r.ok) return null;
return await r.json(); return await r.json();
} catch { return null; } } catch { return null; }
} }
function table(headers, rows) { function table(headers, rows) {
if (!rows.length) return '<div class="empty">nothing yet</div>'; if (!rows.length) return '<div class="empty">nothing yet</div>';
return '<table><tr>' + headers.map(h => `<th>${esc(h)}</th>`).join("") + '</tr>' return '<table><tr>' + headers.map(h => `<th>${esc(h)}</th>`).join("") + '</tr>'
+ rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join("") + '</tr>').join("") + rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join("") + '</tr>').join("")
+ '</table>'; + '</table>';
} }
function renderHive(raft) { function renderHive(raft) {
const role = raft && (raft.state || raft.role); const role = raft && (raft.state || raft.role);
if (!raft || role === "standalone") { if (!raft || role === "standalone") {
$("hive").innerHTML = '<span class="pill">standalone</span> single tracker, no cluster'; $("hive").innerHTML = '<span class="pill">standalone</span> single tracker, no cluster';
return; return;
} }
const cls = role === "leader" ? "ok" : "warn"; const cls = role === "leader" ? "ok" : "warn";
$("hive").innerHTML = $("hive").innerHTML =
`role <span class="${cls}">${esc(role || "?")}</span> · term ${esc(raft.term ?? "?")}` + `role <span class="${cls}">${esc(role || "?")}</span> · term ${esc(raft.term ?? "?")}` +
`<br>leader: ${raft.leader ? esc(raft.leader) : '<span class="dim">unknown</span>'}` + `<br>leader: ${raft.leader ? esc(raft.leader) : '<span class="dim">unknown</span>'}` +
(raft.peers ? `<br>peers: ${esc(Array.isArray(raft.peers) ? raft.peers.join(", ") : raft.peers)}` : ""); (raft.peers ? `<br>peers: ${esc(Array.isArray(raft.peers) ? raft.peers.join(", ") : raft.peers)}` : "");
} }
function renderNodes(map) { function renderNodes(map) {
const nodes = (map && map.nodes) || []; const nodes = (map && map.nodes) || [];
if (!nodes.length) { if (!nodes.length) {
$("nodes").innerHTML = '<div class="empty">no nodes registered</div>'; return; $("nodes").innerHTML = '<div class="empty">no nodes registered</div>'; return;
} }
const byModel = {}; const byModel = {};
for (const n of nodes) { for (const n of nodes) {
const key = n.model || n.hf_repo || "?"; const key = n.model || n.hf_repo || "?";
(byModel[key] = byModel[key] || []).push(n); (byModel[key] = byModel[key] || []).push(n);
} }
let html = ""; let html = "";
for (const [model, group] of Object.entries(byModel)) { for (const [model, group] of Object.entries(byModel)) {
html += `<div><b>${esc(model)}</b> <span class="dim">(${group.length} node${group.length===1?"":"s"})</span></div>`; html += `<div><b>${esc(model)}</b> <span class="dim">(${group.length} node${group.length===1?"":"s"})</span></div>`;
html += table(["node", "shard", "tps (1h)", "queue", "health"], group.map(n => { html += table(["node", "shard", "tps (1h)", "queue", "health"], group.map(n => {
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {}; const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
return [ return [
esc(short(n.node_id || "?")), esc(short(n.node_id || "?")),
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`), esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
`<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`,
esc(String((n.stats && n.stats.queue_depth) ?? 0)), esc(String((n.stats && n.stats.queue_depth) ?? 0)),
(n.stats && (n.stats.alive === false || n.stats.healthy === false)) (n.stats && (n.stats.alive === false || n.stats.healthy === false))
? '<span class="bad">down</span>' : '<span class="ok">up</span>', ? '<span class="bad">down</span>' : '<span class="ok">up</span>',
]; })); ]; }));
} }
$("nodes").innerHTML = html; $("nodes").innerHTML = html;
} }
function renderBilling(summary) { function renderBilling(summary) {
if (!summary) { if (!summary) {
$("clients").innerHTML = '<div class="empty">billing disabled</div>'; $("clients").innerHTML = '<div class="empty">billing disabled</div>';
$("pending").innerHTML = '<div class="empty">billing disabled</div>'; $("pending").innerHTML = '<div class="empty">billing disabled</div>';
return summary; return summary;
} }
const clients = Object.entries(summary.clients || {}); const clients = Object.entries(summary.clients || {});
$("clients").innerHTML = table(["api key", "balance (USDT)"], $("clients").innerHTML = table(["api key", "balance (USDT)"],
clients.map(([k, v]) => [esc(short(k)), clients.map(([k, v]) => [esc(short(k)),
`<span class="num ${v > 0 ? "ok" : "bad"}">${usdt(v)}</span>`])); `<span class="num ${v > 0 ? "ok" : "bad"}">${usdt(v)}</span>`]));
const pending = Object.entries(summary.node_pending || {}); const pending = Object.entries(summary.node_pending || {});
const cut = summary.protocol_cut || 0; const cut = summary.protocol_cut || 0;
$("pending").innerHTML = $("pending").innerHTML =
table(["node wallet", "pending (USDT)"], table(["node wallet", "pending (USDT)"],
pending.map(([w, v]) => [esc(short(w)), `<span class="num">${usdt(v)}</span>`])) pending.map(([w, v]) => [esc(short(w)), `<span class="num">${usdt(v)}</span>`]))
+ `<div style="margin-top:6px" class="dim">protocol cut: <b>${usdt(cut)}</b> USDT</div>`; + `<div style="margin-top:6px" class="dim">protocol cut: <b>${usdt(cut)}</b> USDT</div>`;
return summary; return summary;
} }
function renderSettlements(data) { function renderSettlements(data) {
if (!data || !data.settlements) { if (!data || !data.settlements) {
$("settlements").innerHTML = '<div class="empty">billing disabled</div>'; return; $("settlements").innerHTML = '<div class="empty">billing disabled</div>'; return;
} }
const rows = data.settlements.slice(-15).reverse().map(s => { const rows = data.settlements.slice(-15).reverse().map(s => {
const total = (s.payouts || []).reduce((a, p) => a + p.amount, 0); const total = (s.payouts || []).reduce((a, p) => a + p.amount, 0);
const sig = s.signature; const sig = s.signature;
const link = sig && !sig.startsWith("fake-") && !sig.startsWith("local-") const link = sig && !sig.startsWith("fake-") && !sig.startsWith("local-")
? `<a target="_blank" href="https://explorer.solana.com/tx/${encodeURIComponent(sig)}?cluster=devnet">${esc(short(sig))}</a>` ? `<a target="_blank" href="https://explorer.solana.com/tx/${encodeURIComponent(sig)}?cluster=devnet">${esc(short(sig))}</a>`
: (sig ? esc(short(sig)) : '<span class="warn">unconfirmed</span>'); : (sig ? esc(short(sig)) : '<span class="warn">unconfirmed</span>');
return [new Date(s.ts * 1000).toLocaleTimeString(), String((s.payouts || []).length), return [new Date(s.ts * 1000).toLocaleTimeString(), String((s.payouts || []).length),
`<span class="num">${usdt(total)}</span>`, link]; `<span class="num">${usdt(total)}</span>`, link];
}); });
$("settlements").innerHTML = table(["time", "payouts", "total (USDT)", "tx"], rows); $("settlements").innerHTML = table(["time", "payouts", "total (USDT)", "tx"], rows);
} }
function renderFraud(wallets, summary) { function renderFraud(wallets, summary) {
const rows = Object.entries((wallets && wallets.wallets) || {}) const rows = Object.entries((wallets && wallets.wallets) || {})
.filter(([, w]) => w.strike_count > 0 || w.banned) .filter(([, w]) => w.strike_count > 0 || w.banned)
.map(([addr, w]) => [esc(short(addr)), String(w.strike_count), .map(([addr, w]) => [esc(short(addr)), String(w.strike_count),
w.banned ? '<span class="bad">banned</span>' : '<span class="ok">active</span>']); w.banned ? '<span class="bad">banned</span>' : '<span class="ok">active</span>']);
let html = rows.length ? table(["wallet", "strikes", "status"], rows) let html = rows.length ? table(["wallet", "strikes", "status"], rows)
: '<div class="empty">no strikes recorded</div>'; : '<div class="empty">no strikes recorded</div>';
const forfeits = (summary && summary.forfeits) || []; const forfeits = (summary && summary.forfeits) || [];
if (forfeits.length) { if (forfeits.length) {
html += '<div style="margin-top:6px"><b class="dim">recent forfeitures</b></div>' html += '<div style="margin-top:6px"><b class="dim">recent forfeitures</b></div>'
+ table(["wallet", "amount", "reason"], forfeits.slice(-8).reverse().map(f => + table(["wallet", "amount", "reason"], forfeits.slice(-8).reverse().map(f =>
[esc(short(f.wallet)), `<span class="num bad">-${usdt(f.amount)}</span>`, esc(f.reason)])); [esc(short(f.wallet)), `<span class="num bad">-${usdt(f.amount)}</span>`, esc(f.reason)]));
} }
$("fraud").innerHTML = html; $("fraud").innerHTML = html;
} }
function renderStats(stats) { function renderStats(stats) {
const models = (stats && (stats.models || stats.stats)) || stats; const models = (stats && (stats.models || stats.stats)) || stats;
if (!models || typeof models !== "object" || !Object.keys(models).length) { if (!models || typeof models !== "object" || !Object.keys(models).length) {
$("stats").innerHTML = '<div class="empty">no requests yet</div>'; return; $("stats").innerHTML = '<div class="empty">no requests yet</div>'; return;
} }
const rows = Object.entries(models).map(([m, s]) => [ const rows = Object.entries(models).map(([m, s]) => [
esc(m), esc(m),
esc(String(s.rpm_last_hour ?? "?")), esc(String(s.rpm_last_hour ?? "?")),
esc(String(s.rpm_last_day ?? "?")), esc(String(s.rpm_last_day ?? "?")),
esc(String(s.rpm_last_month ?? "?")), esc(String(s.rpm_last_month ?? "?")),
]); ]);
$("stats").innerHTML = table(["model", "rpm (1h)", "rpm (24h)", "rpm (30d)"], rows); $("stats").innerHTML = table(["model", "rpm (1h)", "rpm (24h)", "rpm (30d)"], rows);
} }
function renderThroughput(stats) { function renderThroughput(stats) {
const nodes = (stats && stats.nodes) || {}; const nodes = (stats && stats.nodes) || {};
const rows = []; const rows = [];
for (const [nodeId, nodeStats] of Object.entries(nodes)) { for (const [nodeId, nodeStats] of Object.entries(nodes)) {
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) { for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
rows.push([ rows.push([
esc(short(nodeId)), esc(short(nodeId)),
esc(short(model, 24)), esc(short(model, 24)),
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`, `<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
]); ]);
} }
} }
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows); $("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
} }
// ---- account panel (registration / login / balance / usage / API keys) ---- // ---- account panel (registration / login / balance / usage / API keys) ----
let sessionToken = localStorage.getItem("meshnet_session") || null; let sessionToken = localStorage.getItem("meshnet_session") || null;
let authTab = "login"; let authTab = "login";
async function apiCall(path, method, body) { async function apiCall(path, method, body) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken; if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
try { try {
const r = await fetch(path, { const r = await fetch(path, {
method: method || "GET", method: method || "GET",
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
return { ok: r.ok, status: r.status, data }; return { ok: r.ok, status: r.status, data };
} catch { } catch {
return { ok: false, status: 0, data: {} }; return { ok: false, status: 0, data: {} };
} }
} }
function setSession(token) { function setSession(token) {
sessionToken = token; sessionToken = token;
if (token) localStorage.setItem("meshnet_session", token); if (token) localStorage.setItem("meshnet_session", token);
else localStorage.removeItem("meshnet_session"); else localStorage.removeItem("meshnet_session");
} }
function renderAuthForms(errorMsg) { function renderAuthForms(errorMsg) {
const tab = (name, label) => const tab = (name, label) =>
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`; `<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields = const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' + '<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">'; '<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">';
const form = authTab === "login" const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' + ? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' + '<input id="auth-password" type="password" placeholder="password">' +
'<div class="form-row"><button onclick="doLogin()">Log in</button></div>' '<div class="form-row"><button onclick="doLogin()">Log in</button></div>'
: identityFields + : identityFields +
'<input id="auth-password" type="password" placeholder="password (min 8 chars)">' + '<input id="auth-password" type="password" placeholder="password (min 8 chars)">' +
'<div class="form-row"><button onclick="doRegister()">Create account</button></div>'; '<div class="form-row"><button onclick="doRegister()">Create account</button></div>';
$("account").innerHTML = $("account").innerHTML =
`<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` + `<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` +
form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`; form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`;
$("admin-section").style.display = "none"; $("admin-section").style.display = "none";
} }
function switchAuthTab(name) { authTab = name; renderAuthForms(); } function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() { async function doRegister() {
const email = $("auth-email").value.trim(); const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim(); const wallet = $("auth-wallet").value.trim();
const password = $("auth-password").value; const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST", const r = await apiCall("/v1/auth/register", "POST",
{ email: email || null, wallet: wallet || null, password }); { email: email || null, wallet: wallet || null, password });
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; } if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token); setSession(r.data.session_token);
await renderAccountPanel(); await renderAccountPanel();
} }
async function doLogin() { async function doLogin() {
const identifier = $("auth-identifier").value.trim(); const identifier = $("auth-identifier").value.trim();
const password = $("auth-password").value; const password = $("auth-password").value;
const r = await apiCall("/v1/auth/login", "POST", { identifier, password }); const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; } if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token); setSession(r.data.session_token);
await renderAccountPanel(); await renderAccountPanel();
} }
async function doLogout() { async function doLogout() {
await apiCall("/v1/auth/logout", "POST", {}); await apiCall("/v1/auth/logout", "POST", {});
setSession(null); setSession(null);
renderAuthForms(); renderAuthForms();
} }
async function createKey() { async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {}); const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel(); if (r.ok) await renderAccountPanel();
} }
async function revokeKey(key) { async function revokeKey(key) {
if (!confirm("Revoke this API key? Clients using it will stop working.")) return; if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key }); await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
await renderAccountPanel(); await renderAccountPanel();
} }
async function renderAccountPanel() { async function renderAccountPanel() {
const r = await apiCall("/v1/account"); const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker if (r.status === 404) { // accounts disabled on this tracker
$("account-section").style.display = "none"; $("account-section").style.display = "none";
$("admin-section").style.display = "none"; $("admin-section").style.display = "none";
return; return;
} }
if (!r.ok) { setSession(null); renderAuthForms(); return; } if (!r.ok) { setSession(null); renderAuthForms(); return; }
const { account, api_keys, balances, total_balance, usage } = r.data; const { account, api_keys, balances, total_balance, usage } = r.data;
const who = account.email || account.wallet || account.account_id; const who = account.email || account.wallet || account.account_id;
let html = let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` + `<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` + `<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` + `<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` + `${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`; `tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' + html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
'<button class="small" onclick="createKey()">+ new key</button></div>'; '<button class="small" onclick="createKey()">+ new key</button></div>';
if (api_keys.length) { if (api_keys.length) {
for (const key of api_keys) { for (const key of api_keys) {
html += `<div class="keybox">${esc(key)}` + html += `<div class="keybox">${esc(key)}` +
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` + ` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`; ` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
} }
} else { } else {
html += '<div class="empty">no active keys</div>'; html += '<div class="empty">no active keys</div>';
} }
if (usage.recent && usage.recent.length) { if (usage.recent && usage.recent.length) {
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' + html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [ table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
new Date(u.ts * 1000).toLocaleTimeString(), new Date(u.ts * 1000).toLocaleTimeString(),
esc(short(u.model || "?", 24)), esc(short(u.model || "?", 24)),
`<span class="num">${esc(String(u.total_tokens))}</span>`, `<span class="num">${esc(String(u.total_tokens))}</span>`,
`<span class="num">${usdt(u.cost)}</span>`, `<span class="num">${usdt(u.cost)}</span>`,
])); ]));
} }
$("account").innerHTML = html; $("account").innerHTML = html;
if (account.role === "admin") await renderAdminPanel(); if (account.role === "admin") await renderAdminPanel();
else $("admin-section").style.display = "none"; else $("admin-section").style.display = "none";
} }
async function renderAdminPanel() { async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts"); const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { $("admin-section").style.display = "none"; return; } if (!r.ok) { $("admin-section").style.display = "none"; return; }
$("admin-section").style.display = ""; $("admin-section").style.display = "";
const rows = (r.data.accounts || []).map(a => { const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0); const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [ return [
esc(short(a.email || a.wallet || a.account_id, 24)), esc(short(a.email || a.wallet || a.account_id, 24)),
`<span class="pill">${esc(a.role)}</span>`, `<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length), String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`, `<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
new Date(a.created_ts * 1000).toLocaleDateString(), new Date(a.created_ts * 1000).toLocaleDateString(),
]; ];
}); });
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows); $("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
} }
async function refresh() { async function refresh() {
$("self-url").textContent = location.host; $("self-url").textContent = location.host;
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([ const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
fetchJson("/v1/raft/status"), fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"), fetchJson("/v1/network/map"),
fetchJson("/v1/billing/summary"), fetchJson("/v1/billing/summary"),
fetchJson("/v1/billing/settlements"), fetchJson("/v1/billing/settlements"),
fetchJson("/v1/registry/wallets"), fetchJson("/v1/registry/wallets"),
fetchJson("/v1/stats"), fetchJson("/v1/stats"),
]); ]);
renderHive(raft); renderHive(raft);
renderNodes(map); renderNodes(map);
renderBilling(summary); renderBilling(summary);
renderSettlements(settlements); renderSettlements(settlements);
renderFraud(wallets, summary); renderFraud(wallets, summary);
renderStats(stats); renderStats(stats);
renderThroughput(stats); renderThroughput(stats);
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString(); $("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
} }
refresh(); refresh();
renderAccountPanel(); renderAccountPanel();
setInterval(refresh, 4000); setInterval(refresh, 4000);
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000); setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,221 +1,231 @@
"""Dashboard user accounts: registration, login, roles, API keys, usage. """Dashboard user accounts: registration, login, roles, API keys, usage.
Unit tests for AccountStore plus HTTP integration on the tracker: Unit tests for AccountStore plus HTTP integration on the tracker:
register/login/logout, per-account balance and usage, API-key lifecycle register/login/logout, per-account balance and usage, API-key lifecycle
(revoked keys rejected by the OpenAI proxy), and the admin listing. (revoked keys rejected by the OpenAI proxy), and the admin listing.
""" """
import json import json
import urllib.error import urllib.error
import urllib.request import urllib.request
import pytest import pytest
from meshnet_tracker.accounts import AccountStore from meshnet_tracker.accounts import AccountStore
from meshnet_tracker.billing import BillingLedger from meshnet_tracker.auth import sign_hive_request
from meshnet_tracker.server import TrackerServer from meshnet_tracker.billing import BillingLedger
from meshnet_tracker.server import TrackerServer
# ---------------------------------------------------------------- unit tests HIVE_SECRET = "test-hive-secret"
def test_first_account_is_admin_then_users(): # ---------------------------------------------------------------- unit tests
store = AccountStore()
first = store.register(email="admin@example.com", password="secret-123")
second = store.register(email="user@example.com", password="secret-123") def test_first_account_is_admin_then_users():
assert first["role"] == "admin" store = AccountStore()
assert second["role"] == "user" first = store.register(email="admin@example.com", password="secret-123")
second = store.register(email="user@example.com", password="secret-123")
assert first["role"] == "admin"
def test_register_requires_email_or_wallet_and_password_length(): assert second["role"] == "user"
store = AccountStore()
with pytest.raises(ValueError, match="email or a wallet"):
store.register(password="secret-123") def test_register_requires_email_or_wallet_and_password_length():
with pytest.raises(ValueError, match="invalid email"): store = AccountStore()
store.register(email="not-an-email", password="secret-123") with pytest.raises(ValueError, match="email or a wallet"):
with pytest.raises(ValueError, match="at least 8"): store.register(password="secret-123")
store.register(email="a@b.co", password="short") with pytest.raises(ValueError, match="invalid email"):
store.register(email="not-an-email", password="secret-123")
with pytest.raises(ValueError, match="at least 8"):
def test_register_rejects_duplicate_identifiers(): store.register(email="a@b.co", password="short")
store = AccountStore()
store.register(email="dup@example.com", password="secret-123")
with pytest.raises(ValueError, match="already exists"): def test_register_rejects_duplicate_identifiers():
store.register(email="DUP@example.com", password="other-secret") store = AccountStore()
store.register(email="dup@example.com", password="secret-123")
with pytest.raises(ValueError, match="already exists"):
def test_login_by_email_or_wallet(): store.register(email="DUP@example.com", password="other-secret")
store = AccountStore()
account = store.register(
email="both@example.com", wallet="WalletXYZ", password="secret-123" def test_login_by_email_or_wallet():
) store = AccountStore()
assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"] account = store.register(
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"] email="both@example.com", wallet="WalletXYZ", password="secret-123"
assert store.verify_login("both@example.com", "wrong-password") is None )
assert store.verify_login("nobody@example.com", "secret-123") is None assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"]
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"]
assert store.verify_login("both@example.com", "wrong-password") is None
def test_sessions_resolve_and_destroy(): assert store.verify_login("nobody@example.com", "secret-123") is None
store = AccountStore()
account = store.register(email="s@example.com", password="secret-123")
token = store.create_session(account["account_id"]) def test_sessions_resolve_and_destroy():
assert store.session_account(token)["account_id"] == account["account_id"] store = AccountStore()
store.destroy_session(token) account = store.register(email="s@example.com", password="secret-123")
assert store.session_account(token) is None token = store.create_session(account["account_id"])
assert store.session_account("bogus") is None assert store.session_account(token)["account_id"] == account["account_id"]
store.destroy_session(token)
assert store.session_account(token) is None
def test_api_key_lifecycle(): assert store.session_account("bogus") is None
store = AccountStore()
account = store.register(email="k@example.com", password="secret-123")
other = store.register(email="other@example.com", password="secret-123") def test_api_key_lifecycle():
key = store.create_api_key(account["account_id"]) store = AccountStore()
assert key.startswith("sk-mesh-") account = store.register(email="k@example.com", password="secret-123")
assert store.keys_for(account["account_id"]) == [key] other = store.register(email="other@example.com", password="secret-123")
# someone else's account cannot revoke it key = store.create_api_key(account["account_id"])
assert store.revoke_api_key(other["account_id"], key) is False assert key.startswith("sk-mesh-")
assert store.revoke_api_key(account["account_id"], key) is True assert store.keys_for(account["account_id"]) == [key]
assert store.keys_for(account["account_id"]) == [] # someone else's account cannot revoke it
assert store.is_key_revoked(key) assert store.revoke_api_key(other["account_id"], key) is False
assert store.revoke_api_key(account["account_id"], key) is True
assert store.keys_for(account["account_id"]) == []
def test_accounts_persist_across_restart(tmp_path): assert store.is_key_revoked(key)
db = str(tmp_path / "accounts.db")
store = AccountStore(db_path=db)
account = store.register(email="p@example.com", password="secret-123") def test_accounts_persist_across_restart(tmp_path):
key = store.create_api_key(account["account_id"]) db = str(tmp_path / "accounts.db")
store.save_to_db() store = AccountStore(db_path=db)
account = store.register(email="p@example.com", password="secret-123")
reloaded = AccountStore(db_path=db) key = store.create_api_key(account["account_id"])
assert reloaded.verify_login("p@example.com", "secret-123") is not None store.save_to_db()
assert reloaded.keys_for(account["account_id"]) == [key]
reloaded = AccountStore(db_path=db)
assert reloaded.verify_login("p@example.com", "secret-123") is not None
def test_account_events_replicate_and_dedupe(): assert reloaded.keys_for(account["account_id"]) == [key]
leader = AccountStore()
follower = AccountStore()
account = leader.register(email="r@example.com", password="secret-123") def test_account_events_replicate_and_dedupe():
key = leader.create_api_key(account["account_id"]) leader = AccountStore()
leader.revoke_api_key(account["account_id"], key) follower = AccountStore()
account = leader.register(email="r@example.com", password="secret-123")
events, cursor = leader.events_since(0) key = leader.create_api_key(account["account_id"])
assert follower.apply_events(events) == len(events) leader.revoke_api_key(account["account_id"], key)
assert follower.apply_events(events) == 0 # replay is a no-op
assert follower.verify_login("r@example.com", "secret-123") is not None events, cursor = leader.events_since(0)
assert follower.is_key_revoked(key) assert follower.apply_events(events) == len(events)
more, _ = leader.events_since(cursor) assert follower.apply_events(events) == 0 # replay is a no-op
assert more == [] assert follower.verify_login("r@example.com", "secret-123") is not None
assert follower.is_key_revoked(key)
more, _ = leader.events_since(cursor)
# ---------------------------------------------------------- HTTP integration assert more == []
def _call(url, method="GET", body=None, token=None): # ---------------------------------------------------------- HTTP integration
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}" def _call(url, method="GET", body=None, token=None):
data = json.dumps(body).encode() if body is not None else None headers = {"Content-Type": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method=method) if token:
with urllib.request.urlopen(req) as r: headers["Authorization"] = f"Bearer {token}"
return json.loads(r.read()) data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as r:
@pytest.fixture return json.loads(r.read())
def account_tracker():
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
tracker = TrackerServer(billing=ledger, accounts=AccountStore()) @pytest.fixture
port = tracker.start() def account_tracker():
yield f"http://127.0.0.1:{port}", ledger ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
tracker.stop() tracker = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET)
port = tracker.start()
yield f"http://127.0.0.1:{port}", ledger
def test_register_login_and_account_view(account_tracker): tracker.stop()
url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "admin@example.com", "password": "secret-123"}) def test_register_login_and_account_view(account_tracker):
assert reg["account"]["role"] == "admin" url, _ = account_tracker
assert reg["api_key"].startswith("sk-mesh-") reg = _call(f"{url}/v1/auth/register", "POST",
assert reg["session_token"] {"email": "admin@example.com", "password": "secret-123"})
assert reg["account"]["role"] == "admin"
login = _call(f"{url}/v1/auth/login", "POST", assert reg["api_key"].startswith("sk-mesh-")
{"identifier": "admin@example.com", "password": "secret-123"}) assert reg["session_token"]
me = _call(f"{url}/v1/account", token=login["session_token"])
assert me["account"]["email"] == "admin@example.com" login = _call(f"{url}/v1/auth/login", "POST",
assert me["api_keys"] == [reg["api_key"]] {"identifier": "admin@example.com", "password": "secret-123"})
# registration granted Caller Credit on the ledger me = _call(f"{url}/v1/account", token=login["session_token"])
assert me["total_balance"] == pytest.approx(0.5) assert me["account"]["email"] == "admin@example.com"
assert me["usage"]["requests"] == 0 assert me["api_keys"] == [reg["api_key"]]
# registration granted Caller Credit on the ledger
assert me["total_balance"] == pytest.approx(0.5)
def test_bad_credentials_and_missing_session_are_401(account_tracker): assert me["usage"]["requests"] == 0
url, _ = account_tracker
_call(f"{url}/v1/auth/register", "POST",
{"email": "a@example.com", "password": "secret-123"}) def test_bad_credentials_and_missing_session_are_401(account_tracker):
with pytest.raises(urllib.error.HTTPError) as exc_info: url, _ = account_tracker
_call(f"{url}/v1/auth/login", "POST", _call(f"{url}/v1/auth/register", "POST",
{"identifier": "a@example.com", "password": "wrong-pass"}) {"email": "a@example.com", "password": "secret-123"})
assert exc_info.value.code == 401 with pytest.raises(urllib.error.HTTPError) as exc_info:
with pytest.raises(urllib.error.HTTPError) as exc_info: _call(f"{url}/v1/auth/login", "POST",
_call(f"{url}/v1/account") {"identifier": "a@example.com", "password": "wrong-pass"})
assert exc_info.value.code == 401 assert exc_info.value.code == 401
with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account")
def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker): assert exc_info.value.code == 401
url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "k@example.com", "password": "secret-123"}) def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker):
token = reg["session_token"] url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"] {"email": "k@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=token) token = reg["session_token"]
assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"]
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token) me = _call(f"{url}/v1/account", token=token)
with pytest.raises(urllib.error.HTTPError) as exc_info: assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
_call(f"{url}/v1/chat/completions", "POST",
{"model": "any", "messages": []}, token=new_key) _call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token)
assert exc_info.value.code == 401 with pytest.raises(urllib.error.HTTPError) as exc_info:
assert "revoked" in exc_info.value.read().decode() _call(f"{url}/v1/chat/completions", "POST",
{"model": "any", "messages": []}, token=new_key)
assert exc_info.value.code == 401
def test_admin_listing_requires_admin_role(account_tracker): assert "revoked" in exc_info.value.read().decode()
url, _ = account_tracker
admin = _call(f"{url}/v1/auth/register", "POST",
{"email": "admin@example.com", "password": "secret-123"}) def test_admin_listing_requires_admin_role(account_tracker):
user = _call(f"{url}/v1/auth/register", "POST", url, _ = account_tracker
{"wallet": "WalletUser1", "password": "secret-123"}) admin = _call(f"{url}/v1/auth/register", "POST",
{"email": "admin@example.com", "password": "secret-123"})
with pytest.raises(urllib.error.HTTPError) as exc_info: user = _call(f"{url}/v1/auth/register", "POST",
_call(f"{url}/v1/admin/accounts", token=user["session_token"]) {"wallet": "WalletUser1", "password": "secret-123"})
assert exc_info.value.code == 403
with pytest.raises(urllib.error.HTTPError) as exc_info:
listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"]) _call(f"{url}/v1/admin/accounts", token=user["session_token"])
accounts = listing["accounts"] assert exc_info.value.code == 403
assert len(accounts) == 2
assert accounts[0]["role"] == "admin" listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"])
assert accounts[1]["wallet"] == "WalletUser1" accounts = listing["accounts"]
assert "balances" in accounts[0] assert len(accounts) == 2
assert accounts[0]["role"] == "admin"
assert accounts[1]["wallet"] == "WalletUser1"
def test_accounts_gossip_endpoint_applies_events(account_tracker): assert "balances" in accounts[0]
url, _ = account_tracker
peer = AccountStore()
peer.register(email="remote@example.com", password="secret-123") def test_accounts_gossip_endpoint_applies_events(account_tracker):
events, _ = peer.events_since(0) url, _ = account_tracker
result = _call(f"{url}/v1/accounts/gossip", "POST", {"events": events}) peer = AccountStore()
assert result["applied"] == len(events) peer.register(email="remote@example.com", password="secret-123")
login = _call(f"{url}/v1/auth/login", "POST", events, _ = peer.events_since(0)
{"identifier": "remote@example.com", "password": "secret-123"}) body = json.dumps({"events": events}).encode()
assert login["account"]["email"] == "remote@example.com" req = urllib.request.Request(
f"{url}/v1/accounts/gossip", data=body,
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
def test_accounts_endpoints_404_when_disabled(): method="POST",
tracker = TrackerServer() # no accounts, no billing )
port = tracker.start() with urllib.request.urlopen(req) as r:
try: result = json.loads(r.read())
with pytest.raises(urllib.error.HTTPError) as exc_info: assert result["applied"] == len(events)
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST", login = _call(f"{url}/v1/auth/login", "POST",
{"email": "x@example.com", "password": "secret-123"}) {"identifier": "remote@example.com", "password": "secret-123"})
assert exc_info.value.code == 404 assert login["account"]["email"] == "remote@example.com"
finally:
tracker.stop()
def test_accounts_endpoints_404_when_disabled():
tracker = TrackerServer() # no accounts, no billing
port = tracker.start()
try:
with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST",
{"email": "x@example.com", "password": "secret-123"})
assert exc_info.value.code == 404
finally:
tracker.stop()

View File

@@ -1,315 +1,320 @@
"""US-031: billing ledger — per-token pricing, 90/10 split, pending balances. """US-031: billing ledger — per-token pricing, 90/10 split, pending balances.
Unit tests for BillingLedger math/persistence/replication, plus HTTP Unit tests for BillingLedger math/persistence/replication, plus HTTP
integration: 401 without an API key, billed 200 with one, 402 once the integration: 401 without an API key, billed 200 with one, 402 once the
Caller Credit is exhausted (rejected before routing — no free work). Caller Credit is exhausted (rejected before routing — no free work).
""" """
import http.server import http.server
import json import json
import socketserver import socketserver
import threading import threading
import urllib.error import urllib.error
import urllib.request import urllib.request
import pytest import pytest
from meshnet_tracker.billing import BillingLedger from meshnet_tracker.auth import sign_hive_request
from meshnet_tracker.server import TrackerServer from meshnet_tracker.billing import BillingLedger
from meshnet_tracker.server import TrackerServer
MODEL = "openai-community/gpt2"
MODEL = "openai-community/gpt2"
HIVE_SECRET = "test-hive-secret"
# ---------------------------------------------------------------- unit tests
# ---------------------------------------------------------------- unit tests
def test_charge_single_node_gets_90_percent():
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.ensure_client("key-a") def test_charge_single_node_gets_90_percent():
event = ledger.charge_request("key-a", MODEL, total_tokens=1000, node_work=[("wallet-1", 12)]) ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
assert event["cost"] == pytest.approx(0.02) ledger.ensure_client("key-a")
assert ledger.get_client_balance("key-a") == pytest.approx(1.0 - 0.02) event = ledger.charge_request("key-a", MODEL, total_tokens=1000, node_work=[("wallet-1", 12)])
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90) assert event["cost"] == pytest.approx(0.02)
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10) assert ledger.get_client_balance("key-a") == pytest.approx(1.0 - 0.02)
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
def test_charge_three_node_split_by_work_units():
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.ensure_client("key-a") def test_charge_three_node_split_by_work_units():
ledger.charge_request( ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
"key-a", MODEL, total_tokens=1000, ledger.ensure_client("key-a")
node_work=[("wallet-1", 6), ("wallet-2", 3), ("wallet-3", 3)], ledger.charge_request(
) "key-a", MODEL, total_tokens=1000,
pool = 0.02 * 0.90 node_work=[("wallet-1", 6), ("wallet-2", 3), ("wallet-3", 3)],
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool * 6 / 12) )
assert ledger.get_node_pending("wallet-2") == pytest.approx(pool * 3 / 12) pool = 0.02 * 0.90
assert ledger.get_node_pending("wallet-3") == pytest.approx(pool * 3 / 12) assert ledger.get_node_pending("wallet-1") == pytest.approx(pool * 6 / 12)
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10) assert ledger.get_node_pending("wallet-2") == pytest.approx(pool * 3 / 12)
assert ledger.get_node_pending("wallet-3") == pytest.approx(pool * 3 / 12)
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
def test_walletless_node_share_accrues_to_protocol_cut():
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.ensure_client("key-a") def test_walletless_node_share_accrues_to_protocol_cut():
ledger.charge_request( ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
"key-a", MODEL, total_tokens=1000, ledger.ensure_client("key-a")
node_work=[("wallet-1", 6), (None, 6)], ledger.charge_request(
) "key-a", MODEL, total_tokens=1000,
pool = 0.02 * 0.90 node_work=[("wallet-1", 6), (None, 6)],
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool / 2) )
# walletless half of the pool + the 10% cut both land in protocol_cut pool = 0.02 * 0.90
assert ledger.snapshot()["protocol_cut"] == pytest.approx(pool / 2 + 0.02 * 0.10) assert ledger.get_node_pending("wallet-1") == pytest.approx(pool / 2)
# walletless half of the pool + the 10% cut both land in protocol_cut
assert ledger.snapshot()["protocol_cut"] == pytest.approx(pool / 2 + 0.02 * 0.10)
def test_per_model_price_override():
ledger = BillingLedger(
starting_credit=1.0, def test_per_model_price_override():
default_price_per_1k=0.02, ledger = BillingLedger(
prices={MODEL: 0.10}, starting_credit=1.0,
) default_price_per_1k=0.02,
ledger.ensure_client("key-a") prices={MODEL: 0.10},
event = ledger.charge_request("key-a", MODEL, 500, [("wallet-1", 1)]) )
assert event["cost"] == pytest.approx(0.10 * 500 / 1000) ledger.ensure_client("key-a")
event = ledger.charge_request("key-a", "other-model", 500, [("wallet-1", 1)]) event = ledger.charge_request("key-a", MODEL, 500, [("wallet-1", 1)])
assert event["cost"] == pytest.approx(0.02 * 500 / 1000) assert event["cost"] == pytest.approx(0.10 * 500 / 1000)
event = ledger.charge_request("key-a", "other-model", 500, [("wallet-1", 1)])
assert event["cost"] == pytest.approx(0.02 * 500 / 1000)
def test_payout_and_forfeit_hooks():
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)]) def test_payout_and_forfeit_hooks():
pending = ledger.get_node_pending("wallet-1") ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
ledger.settle_node_payout("wallet-1", pending, reference="tx-sig-1") ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0) pending = ledger.get_node_pending("wallet-1")
ledger.settle_node_payout("wallet-1", pending, reference="tx-sig-1")
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)]) assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
cut_before = ledger.snapshot()["protocol_cut"]
forfeited = ledger.forfeit_pending("wallet-1")["amount"] ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
assert forfeited == pytest.approx(0.02 * 0.90) cut_before = ledger.snapshot()["protocol_cut"]
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0) forfeited = ledger.forfeit_pending("wallet-1")["amount"]
assert ledger.snapshot()["protocol_cut"] == pytest.approx(cut_before + forfeited) assert forfeited == pytest.approx(0.02 * 0.90)
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
assert ledger.snapshot()["protocol_cut"] == pytest.approx(cut_before + forfeited)
def test_restart_persistence(tmp_path):
db = str(tmp_path / "billing.db")
ledger = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02) def test_restart_persistence(tmp_path):
ledger.credit_client("key-a", 5.0) db = str(tmp_path / "billing.db")
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 12)]) ledger = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
ledger.save_to_db() ledger.credit_client("key-a", 5.0)
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 12)])
reloaded = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02) ledger.save_to_db()
assert reloaded.get_client_balance("key-a") == pytest.approx(
ledger.get_client_balance("key-a") reloaded = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
) assert reloaded.get_client_balance("key-a") == pytest.approx(
assert reloaded.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90) ledger.get_client_balance("key-a")
assert reloaded.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10) )
assert reloaded.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
assert reloaded.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkeypatch):
from meshnet_tracker.billing import DEFAULT_BILLING_DB_PATH
def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) from meshnet_tracker.billing import DEFAULT_BILLING_DB_PATH
tracker = TrackerServer(enable_billing=True)
port = tracker.start() monkeypatch.chdir(tmp_path)
try: tracker = TrackerServer(enable_billing=True)
summary = json.loads( port = tracker.start()
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/billing/summary").read() try:
) # /v1/billing/summary is admin-gated now; just confirm the server is up.
assert "protocol_cut" in summary health = json.loads(
finally: urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/health").read()
tracker.stop() )
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists() assert health["status"] == "ok"
finally:
tracker.stop()
def test_event_replication_converges_and_dedupes(): # enabling billing creates the ledger DB in the working directory
leader = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02) assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
follower = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
leader.credit_client("key-a", 10.0) def test_event_replication_converges_and_dedupes():
leader.charge_request("key-a", MODEL, 2000, [("wallet-1", 8), ("wallet-2", 4)]) leader = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
follower = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
events, cursor = leader.events_since(0)
assert follower.apply_events(events) == len(events) leader.credit_client("key-a", 10.0)
# replaying the same batch must be a no-op leader.charge_request("key-a", MODEL, 2000, [("wallet-1", 8), ("wallet-2", 4)])
assert follower.apply_events(events) == 0
events, cursor = leader.events_since(0)
assert follower.get_client_balance("key-a") == pytest.approx( assert follower.apply_events(events) == len(events)
leader.get_client_balance("key-a") # replaying the same batch must be a no-op
) assert follower.apply_events(events) == 0
assert follower.get_node_pending("wallet-1") == pytest.approx(
leader.get_node_pending("wallet-1") assert follower.get_client_balance("key-a") == pytest.approx(
) leader.get_client_balance("key-a")
assert follower.snapshot()["protocol_cut"] == pytest.approx( )
leader.snapshot()["protocol_cut"] assert follower.get_node_pending("wallet-1") == pytest.approx(
) leader.get_node_pending("wallet-1")
# incremental cursor: nothing new after full sync )
more, _ = leader.events_since(cursor) assert follower.snapshot()["protocol_cut"] == pytest.approx(
assert more == [] leader.snapshot()["protocol_cut"]
)
# incremental cursor: nothing new after full sync
# ---------------------------------------------------------- HTTP integration more, _ = leader.events_since(cursor)
assert more == []
class _UsageStubNode:
"""Minimal head node returning a chat completion with real usage numbers.""" # ---------------------------------------------------------- HTTP integration
def __init__(self, total_tokens: int = 1000):
self.total_tokens = total_tokens class _UsageStubNode:
outer = self """Minimal head node returning a chat completion with real usage numbers."""
class Handler(http.server.BaseHTTPRequestHandler): def __init__(self, total_tokens: int = 1000):
def log_message(self, fmt, *args): self.total_tokens = total_tokens
pass outer = self
def do_POST(self): class Handler(http.server.BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length", 0)) def log_message(self, fmt, *args):
self.rfile.read(length) pass
body = json.dumps({
"id": "chatcmpl-stub", def do_POST(self):
"object": "chat.completion", length = int(self.headers.get("Content-Length", 0))
"model": MODEL, self.rfile.read(length)
"choices": [{ body = json.dumps({
"index": 0, "id": "chatcmpl-stub",
"message": {"role": "assistant", "content": "ok"}, "object": "chat.completion",
"finish_reason": "stop", "model": MODEL,
}], "choices": [{
"usage": { "index": 0,
"prompt_tokens": 0, "message": {"role": "assistant", "content": "ok"},
"completion_tokens": outer.total_tokens, "finish_reason": "stop",
"total_tokens": outer.total_tokens, }],
}, "usage": {
}).encode() "prompt_tokens": 0,
self.send_response(200) "completion_tokens": outer.total_tokens,
self.send_header("Content-Type", "application/json") "total_tokens": outer.total_tokens,
self.send_header("Content-Length", str(len(body))) },
self.end_headers() }).encode()
self.wfile.write(body) self.send_response(200)
self.send_header("Content-Type", "application/json")
self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler) self.send_header("Content-Length", str(len(body)))
self._server.daemon_threads = True self.end_headers()
self._thread: threading.Thread | None = None self.wfile.write(body)
def start(self) -> int: self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._server.daemon_threads = True
self._thread.start() self._thread: threading.Thread | None = None
return self._server.server_address[1]
def start(self) -> int:
def stop(self): self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._server.shutdown() self._thread.start()
self._server.server_close() return self._server.server_address[1]
def stop(self):
@pytest.fixture self._server.shutdown()
def billed_tracker(): self._server.server_close()
ledger = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
tracker = TrackerServer(
model_presets={ @pytest.fixture
MODEL: { def billed_tracker():
"layers_start": 0, ledger = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
"layers_end": 11, tracker = TrackerServer(
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024}, model_presets={
} MODEL: {
}, "layers_start": 0,
billing=ledger, "layers_end": 11,
) "bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
port = tracker.start() }
tracker_url = f"http://127.0.0.1:{port}" },
billing=ledger,
stub = _UsageStubNode(total_tokens=1000) hive_secret=HIVE_SECRET,
stub_port = stub.start() )
reg = json.dumps({ port = tracker.start()
"endpoint": f"http://127.0.0.1:{stub_port}", tracker_url = f"http://127.0.0.1:{port}"
"shard_start": 0,
"shard_end": 11, stub = _UsageStubNode(total_tokens=1000)
"model": MODEL, stub_port = stub.start()
"hardware_profile": {}, reg = json.dumps({
"score": 1.0, "endpoint": f"http://127.0.0.1:{stub_port}",
"tracker_mode": True, "shard_start": 0,
"wallet_address": "wallet-head", "shard_end": 11,
}).encode() "model": MODEL,
req = urllib.request.Request( "hardware_profile": {},
f"{tracker_url}/v1/nodes/register", "score": 1.0,
data=reg, "tracker_mode": True,
headers={"Content-Type": "application/json"}, "wallet_address": "wallet-head",
method="POST", }).encode()
) req = urllib.request.Request(
with urllib.request.urlopen(req) as r: f"{tracker_url}/v1/nodes/register",
r.read() data=reg,
headers={"Content-Type": "application/json"},
yield tracker_url, ledger method="POST",
)
stub.stop() with urllib.request.urlopen(req) as r:
tracker.stop() r.read()
yield tracker_url, ledger
def _chat(tracker_url: str, api_key: str | None):
data = json.dumps({ stub.stop()
"model": MODEL, tracker.stop()
"messages": [{"role": "user", "content": "hi"}],
}).encode()
headers = {"Content-Type": "application/json"} def _chat(tracker_url: str, api_key: str | None):
if api_key: data = json.dumps({
headers["Authorization"] = f"Bearer {api_key}" "model": MODEL,
req = urllib.request.Request( "messages": [{"role": "user", "content": "hi"}],
f"{tracker_url}/v1/chat/completions", }).encode()
data=data, headers = {"Content-Type": "application/json"}
headers=headers, if api_key:
method="POST", headers["Authorization"] = f"Bearer {api_key}"
) req = urllib.request.Request(
with urllib.request.urlopen(req) as r: f"{tracker_url}/v1/chat/completions",
return json.loads(r.read()) data=data,
headers=headers,
method="POST",
def test_proxy_chat_requires_api_key_when_billing_enabled(billed_tracker): )
tracker_url, _ = billed_tracker with urllib.request.urlopen(req) as r:
with pytest.raises(urllib.error.HTTPError) as exc_info: return json.loads(r.read())
_chat(tracker_url, api_key=None)
assert exc_info.value.code == 401
def test_proxy_chat_requires_api_key_when_billing_enabled(billed_tracker):
tracker_url, _ = billed_tracker
def test_proxy_chat_bills_client_and_credits_node(billed_tracker): with pytest.raises(urllib.error.HTTPError) as exc_info:
tracker_url, ledger = billed_tracker _chat(tracker_url, api_key=None)
_chat(tracker_url, api_key="client-1") assert exc_info.value.code == 401
# 1000 tokens at 0.02/1K = 0.02 USDT; starting credit 0.03
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90) def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
tracker_url, ledger = billed_tracker
summary = json.loads( _chat(tracker_url, api_key="client-1")
urllib.request.urlopen(f"{tracker_url}/v1/billing/summary").read() # 1000 tokens at 0.02/1K = 0.02 USDT; starting credit 0.03
) assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90) assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
# /v1/billing/summary is admin-gated now (ADR-0017); auth is covered in
stats = json.loads(urllib.request.urlopen(f"{tracker_url}/v1/stats").read()) # test_auth_boundary.py, so verify the numbers via the ledger snapshot.
node_stats = next(iter(stats["nodes"].values()))["models"][MODEL] summary = ledger.snapshot()
assert node_stats["tokens_per_sec_last_hour"] is not None assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90)
assert node_stats["sample_count_last_hour"] == 1 assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
stats = json.loads(urllib.request.urlopen(f"{tracker_url}/v1/stats").read())
def test_proxy_chat_402_when_balance_exhausted(billed_tracker): node_stats = next(iter(stats["nodes"].values()))["models"][MODEL]
tracker_url, ledger = billed_tracker assert node_stats["tokens_per_sec_last_hour"] is not None
_chat(tracker_url, api_key="client-2") # 0.03 -> 0.01 assert node_stats["sample_count_last_hour"] == 1
_chat(tracker_url, api_key="client-2") # 0.01 -> -0.01 (post-pay drift)
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
with pytest.raises(urllib.error.HTTPError) as exc_info: def test_proxy_chat_402_when_balance_exhausted(billed_tracker):
_chat(tracker_url, api_key="client-2") tracker_url, ledger = billed_tracker
assert exc_info.value.code == 402 _chat(tracker_url, api_key="client-2") # 0.03 -> 0.01
# rejected before routing: nothing further was billed _chat(tracker_url, api_key="client-2") # 0.01 -> -0.01 (post-pay drift)
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01) assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
with pytest.raises(urllib.error.HTTPError) as exc_info:
_chat(tracker_url, api_key="client-2")
def test_billing_gossip_endpoint_applies_events(billed_tracker): assert exc_info.value.code == 402
tracker_url, ledger = billed_tracker # rejected before routing: nothing further was billed
peer = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02) assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
peer.credit_client("remote-client", 7.0)
events, _ = peer.events_since(0)
body = json.dumps({"events": events}).encode() def test_billing_gossip_endpoint_applies_events(billed_tracker):
req = urllib.request.Request( tracker_url, ledger = billed_tracker
f"{tracker_url}/v1/billing/gossip", peer = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
data=body, peer.credit_client("remote-client", 7.0)
headers={"Content-Type": "application/json"}, events, _ = peer.events_since(0)
method="POST", body = json.dumps({"events": events}).encode()
) req = urllib.request.Request(
with urllib.request.urlopen(req) as r: f"{tracker_url}/v1/billing/gossip",
assert json.loads(r.read())["applied"] == len(events) data=body,
# only the replicated credit event lands here — Caller Credit was granted headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
# on the peer that first saw the key, and its event replicates separately method="POST",
assert ledger.get_client_balance("remote-client") == pytest.approx(7.0) )
with urllib.request.urlopen(req) as r:
assert json.loads(r.read())["applied"] == len(events)
# only the replicated credit event lands here — Caller Credit was granted
# on the peer that first saw the key, and its event replicates separately
assert ledger.get_client_balance("remote-client") == pytest.approx(7.0)

View File

@@ -4,6 +4,7 @@ import json
import urllib.request import urllib.request
from meshnet_contracts import LocalSolanaContracts from meshnet_contracts import LocalSolanaContracts
from meshnet_tracker.accounts import AccountStore
from meshnet_tracker.billing import BillingLedger from meshnet_tracker.billing import BillingLedger
from meshnet_tracker.server import TrackerServer from meshnet_tracker.server import TrackerServer
@@ -48,12 +49,17 @@ def test_registry_wallets_endpoint():
contracts = LocalSolanaContracts() contracts = LocalSolanaContracts()
contracts.registry.submit_stake("wallet-a", 100) contracts.registry.submit_stake("wallet-a", 100)
contracts.registry.record_strike("wallet-a") contracts.registry.record_strike("wallet-a")
tracker = TrackerServer(contracts=contracts) accounts = AccountStore()
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
session = accounts.create_session(admin["account_id"])
tracker = TrackerServer(contracts=contracts, accounts=accounts)
port = tracker.start() port = tracker.start()
try: try:
data = json.loads(urllib.request.urlopen( req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/registry/wallets" f"http://127.0.0.1:{port}/v1/registry/wallets",
).read()) headers={"Authorization": f"Bearer {session}"},
)
data = json.loads(urllib.request.urlopen(req).read())
assert data["wallets"]["wallet-a"]["strike_count"] == 1 assert data["wallets"]["wallet-a"]["strike_count"] == 1
assert data["wallets"]["wallet-a"]["banned"] is False assert data["wallets"]["wallet-a"]["banned"] is False
finally: finally:

View File

@@ -157,7 +157,9 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)]) ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
pending = ledger.get_node_pending("wallet-x") pending = ledger.get_node_pending("wallet-x")
tracker = TrackerServer(contracts=contracts, billing=ledger) tracker = TrackerServer(
contracts=contracts, billing=ledger, validator_service_token="test-svc-token",
)
port = tracker.start() port = tracker.start()
url = f"http://127.0.0.1:{port}/v1/billing/forfeit" url = f"http://127.0.0.1:{port}/v1/billing/forfeit"
body = json.dumps({"wallet": "wallet-x", "reason": "fraud"}).encode() body = json.dumps({"wallet": "wallet-x", "reason": "fraud"}).encode()
@@ -171,7 +173,7 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
req = urllib.request.Request( req = urllib.request.Request(
url, data=body, url, data=body,
headers={"Content-Type": "application/json", "Authorization": "Bearer validator"}, headers={"Content-Type": "application/json", "Authorization": "Bearer test-svc-token"},
method="POST", method="POST",
) )
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:

View File

@@ -41,6 +41,7 @@ def benchmark_setup(tmp_path):
} }
}, },
benchmark_results_path=results_path, benchmark_results_path=results_path,
validator_service_token="bench",
) )
port = tracker.start() port = tracker.start()
tracker_url = f"http://127.0.0.1:{port}" tracker_url = f"http://127.0.0.1:{port}"

View File

@@ -73,9 +73,9 @@ def test_threshold_triggers_payout_and_zeroes_pending():
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))] assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0) assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
history = json.loads(urllib.request.urlopen( # /v1/billing/settlements is admin-gated now (ADR-0017, covered in
f"http://127.0.0.1:{port}/v1/billing/settlements" # test_auth_boundary.py); verify content via the ledger directly.
).read())["settlements"] history = ledger.settlement_history()
assert len(history) == 1 assert len(history) == 1
assert history[0]["signature"] == "fake-tx-1" assert history[0]["signature"] == "fake-tx-1"
assert history[0]["payouts"] == [ assert history[0]["payouts"] == [

View File

@@ -12,8 +12,11 @@ import pytest
from meshnet_gateway.server import GatewayServer, _banned_route_wallet from meshnet_gateway.server import GatewayServer, _banned_route_wallet
from meshnet_node.server import StubNodeServer from meshnet_node.server import StubNodeServer
from meshnet_contracts import LocalSolanaContracts from meshnet_contracts import LocalSolanaContracts
from meshnet_tracker.auth import sign_hive_request
from meshnet_tracker.server import TrackerServer, _NodeEntry, _registration_ban_error from meshnet_tracker.server import TrackerServer, _NodeEntry, _registration_ban_error
_TEST_HIVE_SECRET = "test-hive-secret"
def _post_json(url: str, payload: dict) -> dict: def _post_json(url: str, payload: dict) -> dict:
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
@@ -1558,7 +1561,7 @@ def test_stats_sqlite_persistence_survives_restart(tmp_path):
def test_stats_gossip_endpoint_merges_peer_slice(): def test_stats_gossip_endpoint_merges_peer_slice():
tracker = TrackerServer() tracker = TrackerServer(hive_secret=_TEST_HIVE_SECRET)
port = tracker.start() port = tracker.start()
try: try:
peer_payload = { peer_payload = {
@@ -1571,7 +1574,14 @@ def test_stats_gossip_endpoint_merges_peer_slice():
} }
}, },
} }
_post_json(f"http://127.0.0.1:{port}/v1/stats/gossip", peer_payload) body = json.dumps(peer_payload).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/stats/gossip", data=body,
headers={"Content-Type": "application/json", **sign_hive_request(_TEST_HIVE_SECRET, body)},
method="POST",
)
with urllib.request.urlopen(req) as r:
r.read()
combined = _get_json(f"http://127.0.0.1:{port}/v1/stats") combined = _get_json(f"http://127.0.0.1:{port}/v1/stats")
assert "remote-model" in combined["models"] assert "remote-model" in combined["models"]