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:
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: done
|
||||||
|
|
||||||
# 01 — C1: Authenticate hive gossip endpoints
|
# 01 — C1: Authenticate hive gossip endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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 & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
<section><h2>Nodes & 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 => ({"&":"&","<":"<",">":">",'"':"""}[c]));
|
c => ({"&":"&","<":"<",">":">",'"':"""}[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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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"] == [
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user