feat: USDT reward system — billing ledger, devnet treasury, settlement loop, forfeiture PoW, tracker dashboard (US-030…US-035, ADR-0015)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,12 @@ class BillingLedger:
|
||||
self._lock = threading.Lock()
|
||||
self._client_balances: dict[str, float] = {}
|
||||
self._node_pending: dict[str, float] = {}
|
||||
self._wallet_bindings: dict[str, str] = {} # client wallet pubkey -> api_key
|
||||
self._protocol_cut: float = 0.0
|
||||
self._pending_since: dict[str, float] = {} # wallet -> ts pending became > 0
|
||||
self._last_payout_ts: dict[str, float] = {}
|
||||
# settlement id -> {"payouts": [(wallet, amount)], "signature": str|None, "ts": float}
|
||||
self._settlements: dict[str, dict] = {}
|
||||
self._seen_event_ids: set[str] = set()
|
||||
self._event_log: list[dict] = [] # full log, order of local application
|
||||
self._dirty = False
|
||||
@@ -144,6 +149,117 @@ class BillingLedger:
|
||||
self._apply_locked(event)
|
||||
return event
|
||||
|
||||
def bind_wallet(self, api_key: str, wallet: str) -> dict:
|
||||
"""Bind a client wallet pubkey to an API key (US-032 deposits)."""
|
||||
with self._lock:
|
||||
event = {
|
||||
"id": f"bind-{uuid.uuid4().hex}",
|
||||
"type": "bind",
|
||||
"api_key": api_key,
|
||||
"wallet": wallet,
|
||||
"ts": time.time(),
|
||||
}
|
||||
self._apply_locked(event)
|
||||
return event
|
||||
|
||||
def api_key_for_wallet(self, wallet: str) -> str | None:
|
||||
with self._lock:
|
||||
return self._wallet_bindings.get(wallet)
|
||||
|
||||
def credit_deposit(self, api_key: str, amount: float, signature: str) -> dict | None:
|
||||
"""Credit an on-chain deposit exactly once.
|
||||
|
||||
The event id embeds the transaction signature, so replayed or
|
||||
re-observed transfers — locally or via hive gossip — are no-ops.
|
||||
Returns None when the signature was already credited.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise ValueError("deposit amount must be positive")
|
||||
event_id = f"deposit-{signature}"
|
||||
with self._lock:
|
||||
if event_id in self._seen_event_ids:
|
||||
return None
|
||||
event = {
|
||||
"id": event_id,
|
||||
"type": "credit",
|
||||
"api_key": api_key,
|
||||
"amount": amount,
|
||||
"signature": signature,
|
||||
"ts": time.time(),
|
||||
"note": "onchain-deposit",
|
||||
}
|
||||
self._apply_locked(event)
|
||||
return event
|
||||
|
||||
def has_event(self, event_id: str) -> bool:
|
||||
with self._lock:
|
||||
return event_id in self._seen_event_ids
|
||||
|
||||
# ---- settlement (US-033) ----
|
||||
|
||||
def payables(
|
||||
self,
|
||||
*,
|
||||
threshold: float,
|
||||
max_period: float,
|
||||
dust_floor: float,
|
||||
now: float | None = None,
|
||||
exclude: set[str] | None = None,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""Wallets due a payout: pending ≥ threshold OR pending age ≥ max_period,
|
||||
never below the dust floor (mining-pool standard, ADR-0015)."""
|
||||
now = now if now is not None else time.time()
|
||||
exclude = exclude or set()
|
||||
due: list[tuple[str, float]] = []
|
||||
with self._lock:
|
||||
for wallet, pending in self._node_pending.items():
|
||||
if wallet in exclude or pending < max(dust_floor, 1e-9):
|
||||
continue
|
||||
if pending >= threshold:
|
||||
due.append((wallet, pending))
|
||||
continue
|
||||
since = self._pending_since.get(wallet)
|
||||
if since is not None and now - since >= max_period:
|
||||
due.append((wallet, pending))
|
||||
return due
|
||||
|
||||
def confirm_settlement(self, settlement_id: str, signature: str) -> dict:
|
||||
"""Record the on-chain transaction signature for a settlement batch."""
|
||||
with self._lock:
|
||||
event = {
|
||||
"id": f"settlement-{settlement_id}",
|
||||
"type": "settlement",
|
||||
"settlement_id": settlement_id,
|
||||
"signature": signature,
|
||||
"ts": time.time(),
|
||||
}
|
||||
self._apply_locked(event)
|
||||
return event
|
||||
|
||||
def unconfirmed_settlements(self) -> dict[str, list[tuple[str, float]]]:
|
||||
"""Settlement batches whose pending was debited but whose transaction
|
||||
has not been confirmed — resend these (idempotent by settlement id)."""
|
||||
with self._lock:
|
||||
return {
|
||||
sid: list(s["payouts"])
|
||||
for sid, s in self._settlements.items()
|
||||
if s["signature"] is None and s["payouts"]
|
||||
}
|
||||
|
||||
def settlement_history(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return [
|
||||
{
|
||||
"settlement_id": sid,
|
||||
"signature": s["signature"],
|
||||
"payouts": [
|
||||
{"wallet": w, "amount": a} for w, a in s["payouts"]
|
||||
],
|
||||
"ts": s["ts"],
|
||||
}
|
||||
for sid, s in sorted(self._settlements.items(), key=lambda kv: kv[1]["ts"])
|
||||
]
|
||||
|
||||
def settle_node_payout(self, wallet: str, amount: float, *, reference: str = "") -> dict:
|
||||
"""Deduct a paid-out amount from a node's pending balance (US-033 hook)."""
|
||||
if amount <= 0:
|
||||
@@ -203,15 +319,35 @@ class BillingLedger:
|
||||
self._client_balances[key] = self._client_balances.get(key, 0.0) - float(event["cost"])
|
||||
for wallet, amount in (event.get("shares") or {}).items():
|
||||
self._node_pending[wallet] = self._node_pending.get(wallet, 0.0) + float(amount)
|
||||
if amount > 0:
|
||||
self._pending_since.setdefault(wallet, float(event.get("ts", time.time())))
|
||||
self._protocol_cut += float(event.get("protocol_amount", 0.0))
|
||||
elif etype == "payout":
|
||||
wallet = event["wallet"]
|
||||
self._node_pending[wallet] = self._node_pending.get(wallet, 0.0) - float(event["amount"])
|
||||
amount = float(event["amount"])
|
||||
ts = float(event.get("ts", time.time()))
|
||||
self._node_pending[wallet] = self._node_pending.get(wallet, 0.0) - amount
|
||||
self._pending_since.pop(wallet, None)
|
||||
self._last_payout_ts[wallet] = ts
|
||||
reference = event.get("reference") or ""
|
||||
if reference:
|
||||
settlement = self._settlements.setdefault(
|
||||
reference, {"payouts": [], "signature": None, "ts": ts}
|
||||
)
|
||||
settlement["payouts"].append((wallet, amount))
|
||||
elif etype == "settlement":
|
||||
settlement = self._settlements.setdefault(
|
||||
event["settlement_id"],
|
||||
{"payouts": [], "signature": None, "ts": float(event.get("ts", 0.0))},
|
||||
)
|
||||
settlement["signature"] = event.get("signature")
|
||||
elif etype == "forfeit":
|
||||
wallet = event["wallet"]
|
||||
amount = float(event.get("amount", 0.0))
|
||||
self._node_pending[wallet] = self._node_pending.get(wallet, 0.0) - amount
|
||||
self._protocol_cut += amount
|
||||
elif etype == "bind":
|
||||
self._wallet_bindings[event["wallet"]] = event["api_key"]
|
||||
else:
|
||||
return
|
||||
self._seen_event_ids.add(event["id"])
|
||||
@@ -233,11 +369,21 @@ class BillingLedger:
|
||||
return {
|
||||
"clients": dict(self._client_balances),
|
||||
"node_pending": dict(self._node_pending),
|
||||
"wallet_bindings": dict(self._wallet_bindings),
|
||||
"protocol_cut": self._protocol_cut,
|
||||
"prices": dict(self._prices),
|
||||
"default_price_per_1k_tokens": self._default_price_per_1k,
|
||||
"node_share": self._node_share,
|
||||
"event_count": len(self._event_log),
|
||||
"forfeits": [
|
||||
{
|
||||
"wallet": e["wallet"],
|
||||
"amount": e.get("amount", 0.0),
|
||||
"reason": e.get("reason", ""),
|
||||
"ts": e.get("ts", 0.0),
|
||||
}
|
||||
for e in self._event_log if e.get("type") == "forfeit"
|
||||
][-20:],
|
||||
}
|
||||
|
||||
# ---- persistence ----
|
||||
|
||||
@@ -1,85 +1,130 @@
|
||||
"""meshnet-tracker CLI entry point."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .server import TrackerServer, derive_relay_url_from_public_tracker_url
|
||||
|
||||
|
||||
def main() -> None:
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--host", default="0.0.0.0", help="Host interface to listen on")
|
||||
common.add_argument("--port", type=int, default=8080, help="Port to listen on")
|
||||
common.add_argument(
|
||||
"--heartbeat-timeout",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="Seconds before a node is removed from the registry after missed heartbeat",
|
||||
)
|
||||
common.add_argument(
|
||||
"--cluster-peers",
|
||||
default="",
|
||||
help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--self-url",
|
||||
default=None,
|
||||
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--stats-db",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="SQLite database path for persistent model usage statistics",
|
||||
)
|
||||
common.add_argument(
|
||||
"--relay-url",
|
||||
default=None,
|
||||
help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws",
|
||||
)
|
||||
common.add_argument(
|
||||
"--billing-db",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="SQLite database path for the USDT billing ledger (enables billing; ADR-0015)",
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="meshnet-tracker",
|
||||
description="Distributed Inference Network node registry and route selection",
|
||||
parents=[common],
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
subparsers.add_parser("start", help="Start the tracker server", parents=[common])
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in {None, "start"}:
|
||||
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
|
||||
relay_url = args.relay_url or derive_relay_url_from_public_tracker_url(args.self_url)
|
||||
server = TrackerServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
heartbeat_timeout=args.heartbeat_timeout,
|
||||
cluster_peers=cluster_peers or None,
|
||||
cluster_self_url=args.self_url,
|
||||
stats_db=getattr(args, "stats_db", None),
|
||||
relay_url=relay_url,
|
||||
billing_db=getattr(args, "billing_db", None),
|
||||
)
|
||||
port = server.start()
|
||||
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
sys.exit(0)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""meshnet-tracker CLI entry point."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .server import TrackerServer, derive_relay_url_from_public_tracker_url
|
||||
|
||||
|
||||
def main() -> None:
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--host", default="0.0.0.0", help="Host interface to listen on")
|
||||
common.add_argument("--port", type=int, default=8080, help="Port to listen on")
|
||||
common.add_argument(
|
||||
"--heartbeat-timeout",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="Seconds before a node is removed from the registry after missed heartbeat",
|
||||
)
|
||||
common.add_argument(
|
||||
"--cluster-peers",
|
||||
default="",
|
||||
help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--self-url",
|
||||
default=None,
|
||||
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--stats-db",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="SQLite database path for persistent model usage statistics",
|
||||
)
|
||||
common.add_argument(
|
||||
"--relay-url",
|
||||
default=None,
|
||||
help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws",
|
||||
)
|
||||
common.add_argument(
|
||||
"--billing-db",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="SQLite database path for the USDT billing ledger (enables billing; ADR-0015)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--solana-rpc-url",
|
||||
default=None,
|
||||
help="Solana RPC URL (e.g. https://api.devnet.solana.com); enables the on-chain treasury",
|
||||
)
|
||||
common.add_argument(
|
||||
"--usdt-mint",
|
||||
default=None,
|
||||
help="SPL mint address of (mock) USDT — see scripts/devnet_setup.py",
|
||||
)
|
||||
common.add_argument(
|
||||
"--treasury-keypair",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="Treasury keypair JSON path (only on settlement-capable trackers)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--settle-period",
|
||||
type=float,
|
||||
default=86400.0,
|
||||
help="Max seconds between payouts to a node (dev: 60, prod: 86400)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--payout-threshold",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Pending USDT that triggers an immediate payout (dev: 0)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--payout-dust-floor",
|
||||
type=float,
|
||||
default=0.01,
|
||||
help="Never pay out less than this many USDT",
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="meshnet-tracker",
|
||||
description="Distributed Inference Network node registry and route selection",
|
||||
parents=[common],
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
subparsers.add_parser("start", help="Start the tracker server", parents=[common])
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in {None, "start"}:
|
||||
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
|
||||
relay_url = args.relay_url or derive_relay_url_from_public_tracker_url(args.self_url)
|
||||
treasury = None
|
||||
if args.solana_rpc_url and args.usdt_mint and args.treasury_keypair:
|
||||
from meshnet_contracts.solana_adapter import SolanaCustodialTreasury
|
||||
|
||||
treasury = SolanaCustodialTreasury(
|
||||
args.solana_rpc_url, args.usdt_mint, args.treasury_keypair,
|
||||
)
|
||||
server = TrackerServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
heartbeat_timeout=args.heartbeat_timeout,
|
||||
cluster_peers=cluster_peers or None,
|
||||
cluster_self_url=args.self_url,
|
||||
stats_db=getattr(args, "stats_db", None),
|
||||
relay_url=relay_url,
|
||||
billing_db=getattr(args, "billing_db", None),
|
||||
treasury=treasury,
|
||||
settle_period=args.settle_period,
|
||||
payout_threshold=args.payout_threshold,
|
||||
payout_dust_floor=args.payout_dust_floor,
|
||||
)
|
||||
port = server.start()
|
||||
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
sys.exit(0)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
197
packages/tracker/meshnet_tracker/dashboard.html
Normal file
197
packages/tracker/meshnet_tracker/dashboard.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>meshnet tracker</title>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
|
||||
--dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg);
|
||||
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
||||
header { display:flex; align-items:baseline; gap:14px; padding:14px 20px;
|
||||
border-bottom:1px solid var(--border); }
|
||||
header h1 { font-size:16px; margin:0; color:var(--accent); }
|
||||
header .meta { color:var(--dim); font-size:12px; }
|
||||
main { display:grid; grid-template-columns:repeat(auto-fit,minmax(340px,1fr));
|
||||
gap:14px; padding:14px 20px; }
|
||||
section { background:var(--panel); border:1px solid var(--border);
|
||||
border-radius:8px; padding:12px 14px; min-height:80px; }
|
||||
section h2 { margin:0 0 8px; font-size:12px; text-transform:uppercase;
|
||||
letter-spacing:.08em; color:var(--dim); }
|
||||
table { width:100%; border-collapse:collapse; font-size:12px; }
|
||||
th { text-align:left; color:var(--dim); font-weight:normal;
|
||||
border-bottom:1px solid var(--border); padding:2px 6px 4px 0; }
|
||||
td { padding:3px 6px 3px 0; border-bottom:1px solid #21262d; }
|
||||
.ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
|
||||
.dim { color:var(--dim); } .num { text-align:right; }
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
.empty { color:var(--dim); font-style:italic; }
|
||||
.pill { display:inline-block; padding:0 7px; border-radius:9px;
|
||||
border:1px solid var(--border); font-size:11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>meshnet tracker</h1>
|
||||
<span class="meta" id="self-url"></span>
|
||||
<span class="meta" id="refreshed"></span>
|
||||
</header>
|
||||
<main>
|
||||
<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>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>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>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g,
|
||||
c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6);
|
||||
const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
|
||||
|
||||
async function fetchJson(path) {
|
||||
try {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
if (!rows.length) return '<div class="empty">nothing yet</div>';
|
||||
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("")
|
||||
+ '</table>';
|
||||
}
|
||||
|
||||
function renderHive(raft) {
|
||||
const role = raft && (raft.state || raft.role);
|
||||
if (!raft || role === "standalone") {
|
||||
$("hive").innerHTML = '<span class="pill">standalone</span> single tracker, no cluster';
|
||||
return;
|
||||
}
|
||||
const cls = role === "leader" ? "ok" : "warn";
|
||||
$("hive").innerHTML =
|
||||
`role <span class="${cls}">${esc(role || "?")}</span> · term ${esc(raft.term ?? "?")}` +
|
||||
`<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)}` : "");
|
||||
}
|
||||
|
||||
function renderNodes(map) {
|
||||
const nodes = (map && map.nodes) || [];
|
||||
if (!nodes.length) {
|
||||
$("nodes").innerHTML = '<div class="empty">no nodes registered</div>'; return;
|
||||
}
|
||||
const byModel = {};
|
||||
for (const n of nodes) {
|
||||
const key = n.model || n.hf_repo || "?";
|
||||
(byModel[key] = byModel[key] || []).push(n);
|
||||
}
|
||||
let html = "";
|
||||
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 += table(["node", "shard", "mode", "health"], group.map(n => [
|
||||
esc(short(n.node_id || "?")),
|
||||
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
|
||||
n.tracker_mode ? '<span class="pill">tracker</span>' : '<span class="dim">worker</span>',
|
||||
(n.stats && (n.stats.alive === false || n.stats.healthy === false))
|
||||
? '<span class="bad">down</span>' : '<span class="ok">up</span>',
|
||||
]));
|
||||
}
|
||||
$("nodes").innerHTML = html;
|
||||
}
|
||||
|
||||
function renderBilling(summary) {
|
||||
if (!summary) {
|
||||
$("clients").innerHTML = '<div class="empty">billing disabled</div>';
|
||||
$("pending").innerHTML = '<div class="empty">billing disabled</div>';
|
||||
return summary;
|
||||
}
|
||||
const clients = Object.entries(summary.clients || {});
|
||||
$("clients").innerHTML = table(["api key", "balance (USDT)"],
|
||||
clients.map(([k, v]) => [esc(short(k)),
|
||||
`<span class="num ${v > 0 ? "ok" : "bad"}">${usdt(v)}</span>`]));
|
||||
const pending = Object.entries(summary.node_pending || {});
|
||||
const cut = summary.protocol_cut || 0;
|
||||
$("pending").innerHTML =
|
||||
table(["node wallet", "pending (USDT)"],
|
||||
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>`;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function renderSettlements(data) {
|
||||
if (!data || !data.settlements) {
|
||||
$("settlements").innerHTML = '<div class="empty">billing disabled</div>'; return;
|
||||
}
|
||||
const rows = data.settlements.slice(-15).reverse().map(s => {
|
||||
const total = (s.payouts || []).reduce((a, p) => a + p.amount, 0);
|
||||
const sig = s.signature;
|
||||
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>`
|
||||
: (sig ? esc(short(sig)) : '<span class="warn">unconfirmed</span>');
|
||||
return [new Date(s.ts * 1000).toLocaleTimeString(), String((s.payouts || []).length),
|
||||
`<span class="num">${usdt(total)}</span>`, link];
|
||||
});
|
||||
$("settlements").innerHTML = table(["time", "payouts", "total (USDT)", "tx"], rows);
|
||||
}
|
||||
|
||||
function renderFraud(wallets, summary) {
|
||||
const rows = Object.entries((wallets && wallets.wallets) || {})
|
||||
.filter(([, w]) => w.strike_count > 0 || w.banned)
|
||||
.map(([addr, w]) => [esc(short(addr)), String(w.strike_count),
|
||||
w.banned ? '<span class="bad">banned</span>' : '<span class="ok">active</span>']);
|
||||
let html = rows.length ? table(["wallet", "strikes", "status"], rows)
|
||||
: '<div class="empty">no strikes recorded</div>';
|
||||
const forfeits = (summary && summary.forfeits) || [];
|
||||
if (forfeits.length) {
|
||||
html += '<div style="margin-top:6px"><b class="dim">recent forfeitures</b></div>'
|
||||
+ 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)]));
|
||||
}
|
||||
$("fraud").innerHTML = html;
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
const models = (stats && (stats.models || stats.stats)) || stats;
|
||||
if (!models || typeof models !== "object" || !Object.keys(models).length) {
|
||||
$("stats").innerHTML = '<div class="empty">no requests yet</div>'; return;
|
||||
}
|
||||
const rows = Object.entries(models).map(([m, s]) => [
|
||||
esc(m),
|
||||
esc(String(s.rpm_last_hour ?? "?")),
|
||||
esc(String(s.rpm_last_day ?? "?")),
|
||||
esc(String(s.rpm_last_month ?? "?")),
|
||||
]);
|
||||
$("stats").innerHTML = table(["model", "rpm (1h)", "rpm (24h)", "rpm (30d)"], rows);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
fetchJson("/v1/stats"),
|
||||
]);
|
||||
renderHive(raft);
|
||||
renderNodes(map);
|
||||
renderBilling(summary);
|
||||
renderSettlements(settlements);
|
||||
renderFraud(wallets, summary);
|
||||
renderStats(stats);
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,7 +23,9 @@ HTTP API contract:
|
||||
|
||||
import http.server
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -718,6 +720,30 @@ def _relay_http_request(
|
||||
return None
|
||||
|
||||
|
||||
def _find_pinned_route(
|
||||
nodes: list[_NodeEntry],
|
||||
required_start: int,
|
||||
required_end: int,
|
||||
hop_count: int,
|
||||
) -> list[_NodeEntry] | None:
|
||||
"""First combination of exactly ``hop_count`` distinct nodes covering the
|
||||
layer range, where every node extends coverage (US-030 benchmark routes)."""
|
||||
for combo in itertools.permutations(nodes, hop_count):
|
||||
covered = required_start - 1
|
||||
valid = True
|
||||
for candidate in combo:
|
||||
if candidate.shard_start is None or candidate.shard_end is None:
|
||||
valid = False
|
||||
break
|
||||
if candidate.shard_start > covered + 1 or candidate.shard_end <= covered:
|
||||
valid = False
|
||||
break
|
||||
covered = candidate.shard_end
|
||||
if valid and covered >= required_end:
|
||||
return list(combo)
|
||||
return None
|
||||
|
||||
|
||||
def _nodes_and_bounds_for_model(
|
||||
server: "_TrackerHTTPServer",
|
||||
model: str,
|
||||
@@ -1046,6 +1072,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
gossip: "NodeGossip | None" = None,
|
||||
stats: "_StatsCollector | None" = None,
|
||||
billing: "BillingLedger | None" = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
self.registry = registry
|
||||
@@ -1059,6 +1086,10 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.gossip = gossip
|
||||
self.stats: _StatsCollector | None = stats
|
||||
self.billing: BillingLedger | None = billing
|
||||
self.benchmark_results_path = benchmark_results_path or os.path.join(
|
||||
os.getcwd(), "benchmark_results.json"
|
||||
)
|
||||
self.benchmark_lock = threading.Lock()
|
||||
|
||||
|
||||
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -1114,6 +1145,15 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/billing/gossip":
|
||||
self._handle_billing_gossip()
|
||||
return
|
||||
if self.path == "/v1/billing/forfeit":
|
||||
self._handle_billing_forfeit()
|
||||
return
|
||||
if self.path == "/v1/benchmark/hop-penalty":
|
||||
self._handle_benchmark_hop_penalty()
|
||||
return
|
||||
if self.path == "/v1/wallet/register":
|
||||
self._handle_wallet_register()
|
||||
return
|
||||
parts = self.path.split("/")
|
||||
# /v1/nodes/<node_id>/heartbeat -> ['', 'v1', 'nodes', '<id>', 'heartbeat']
|
||||
if len(parts) == 5 and parts[1] == "v1" and parts[2] == "nodes" and parts[4] == "heartbeat":
|
||||
@@ -1148,6 +1188,14 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._handle_stats()
|
||||
elif parsed.path == "/v1/billing/summary":
|
||||
self._handle_billing_summary()
|
||||
elif parsed.path == "/v1/billing/settlements":
|
||||
self._handle_billing_settlements()
|
||||
elif parsed.path == "/v1/benchmark/results":
|
||||
self._handle_benchmark_results()
|
||||
elif parsed.path == "/v1/registry/wallets":
|
||||
self._handle_registry_wallets()
|
||||
elif parsed.path in ("/dashboard", "/dashboard/"):
|
||||
self._handle_dashboard()
|
||||
elif parsed.path == "/v1/health":
|
||||
self._send_json(200, {"status": "ok"})
|
||||
else:
|
||||
@@ -1394,32 +1442,63 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
}})
|
||||
return
|
||||
|
||||
# Find a live tracker-mode node for this model
|
||||
with server.lock:
|
||||
self._purge_expired_nodes()
|
||||
candidates = [
|
||||
n for n in server.registry.values()
|
||||
if n.tracker_mode and _node_matches_model(n, model)
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
# Fall back: any node serving shard_start=0 for this model
|
||||
# US-030: optional pinned route — "route": [node_id, ...] uses those
|
||||
# nodes in order instead of auto-selection. Absent field: unchanged.
|
||||
pinned_ids = body.get("route")
|
||||
pinned_nodes: list[_NodeEntry] | None = None
|
||||
if pinned_ids is not None:
|
||||
if (
|
||||
not isinstance(pinned_ids, list)
|
||||
or not pinned_ids
|
||||
or not all(isinstance(nid, str) and nid for nid in pinned_ids)
|
||||
):
|
||||
self._send_json(400, {"error": {
|
||||
"message": "route must be a non-empty list of node id strings",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_route",
|
||||
}})
|
||||
return
|
||||
with server.lock:
|
||||
self._purge_expired_nodes()
|
||||
missing = [nid for nid in pinned_ids if nid not in server.registry]
|
||||
if missing:
|
||||
self._send_json(400, {"error": {
|
||||
"message": f"unknown node ids in route: {missing}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "unknown_route_nodes",
|
||||
}})
|
||||
return
|
||||
pinned_nodes = [server.registry[nid] for nid in pinned_ids]
|
||||
|
||||
if pinned_nodes is not None:
|
||||
node = pinned_nodes[0]
|
||||
else:
|
||||
# Find a live tracker-mode node for this model
|
||||
with server.lock:
|
||||
self._purge_expired_nodes()
|
||||
candidates = [
|
||||
n for n in server.registry.values()
|
||||
if n.shard_start == 0 and _node_matches_model(n, model)
|
||||
if n.tracker_mode and _node_matches_model(n, model)
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
self._send_json(503, {"error": {
|
||||
"message": f"no nodes available for model {model!r}",
|
||||
"type": "service_unavailable",
|
||||
"code": "model_not_available",
|
||||
}})
|
||||
return
|
||||
if not candidates:
|
||||
# Fall back: any node serving shard_start=0 for this model
|
||||
with server.lock:
|
||||
candidates = [
|
||||
n for n in server.registry.values()
|
||||
if n.shard_start == 0 and _node_matches_model(n, model)
|
||||
]
|
||||
|
||||
# Simple round-robin via list length modulo (stateless, good enough)
|
||||
node = candidates[int(time.time() * 1000) % len(candidates)]
|
||||
if not candidates:
|
||||
self._send_json(503, {"error": {
|
||||
"message": f"no nodes available for model {model!r}",
|
||||
"type": "service_unavailable",
|
||||
"code": "model_not_available",
|
||||
}})
|
||||
return
|
||||
|
||||
# Simple round-robin via list length modulo (stateless, good enough)
|
||||
node = candidates[int(time.time() * 1000) % len(candidates)]
|
||||
target_url = f"{node.endpoint}/v1/chat/completions"
|
||||
request_id = str(body.get("id") or f"req-{time.time_ns():x}")
|
||||
|
||||
@@ -1439,7 +1518,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
and n.shard_start is not None and n.num_layers is not None
|
||||
]
|
||||
rs, re = 0, (max((n.num_layers for n in all_nodes), default=1) - 1)
|
||||
route_nodes, _ = _select_route(all_nodes, rs, re)
|
||||
if pinned_nodes is not None:
|
||||
route_nodes = pinned_nodes
|
||||
else:
|
||||
route_nodes, _ = _select_route(all_nodes, rs, re)
|
||||
# Compute start_layer for each hop: each node begins where the previous ended + 1.
|
||||
# This allows overlapping shard registrations without double-computation.
|
||||
covered_up_to = rs - 1
|
||||
@@ -1616,6 +1698,19 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is None or api_key is None:
|
||||
return
|
||||
# Probationary period (issue 08): a wallet's first N jobs earn nothing —
|
||||
# its share stays in the protocol cut. Job counts live in the registry
|
||||
# contract, so this only applies when the tracker runs with contracts.
|
||||
if server.contracts is not None:
|
||||
adjusted: list[tuple[str | None, int]] = []
|
||||
for wallet, work in node_work:
|
||||
if wallet:
|
||||
in_probation = server.contracts.registry.probationary_jobs_remaining(wallet) > 0
|
||||
server.contracts.registry.record_completed_job(wallet)
|
||||
if in_probation:
|
||||
wallet = None
|
||||
adjusted.append((wallet, work))
|
||||
node_work = adjusted
|
||||
try:
|
||||
event = server.billing.charge_request(api_key, model, total_tokens, node_work)
|
||||
print(
|
||||
@@ -1984,6 +2079,47 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send_json(200, server.billing.snapshot())
|
||||
|
||||
def _handle_dashboard(self):
|
||||
"""Serve the read-only web dashboard (US-035). Any tracker in the
|
||||
mesh — leader or follower — serves it from its replicated state."""
|
||||
try:
|
||||
html = files("meshnet_tracker").joinpath("dashboard.html").read_text()
|
||||
except (FileNotFoundError, OSError):
|
||||
self._send_json(404, {"error": "dashboard asset missing"})
|
||||
return
|
||||
body = html.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
def _handle_registry_wallets(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.contracts is None:
|
||||
self._send_json(200, {"wallets": {}})
|
||||
return
|
||||
wallets = server.contracts.registry.list_wallets()
|
||||
self._send_json(200, {"wallets": {
|
||||
wallet: {
|
||||
"stake_balance": state.stake_balance,
|
||||
"strike_count": state.strike_count,
|
||||
"banned": state.banned,
|
||||
"completed_jobs": state.completed_job_count,
|
||||
}
|
||||
for wallet, state in wallets.items()
|
||||
}})
|
||||
|
||||
def _handle_billing_settlements(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
self._send_json(200, {"settlements": server.billing.settlement_history()})
|
||||
|
||||
def _handle_billing_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
@@ -1999,6 +2135,178 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
applied = server.billing.apply_events([e for e in events if isinstance(e, dict)])
|
||||
self._send_json(200, {"applied": applied})
|
||||
|
||||
def _handle_billing_forfeit(self):
|
||||
"""Privileged: forfeit a node's pending balance + record a strike (US-034).
|
||||
|
||||
Auth is a header-presence stub (non-empty Authorization), matching the
|
||||
benchmark endpoints — real validator auth arrives with on-chain keys.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self.headers.get("Authorization"):
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
wallet = body.get("wallet")
|
||||
if not wallet or not isinstance(wallet, str):
|
||||
self._send_json(400, {"error": "wallet is required"})
|
||||
return
|
||||
reason = body.get("reason") or "fraud"
|
||||
event = server.billing.forfeit_pending(wallet, reason=str(reason))
|
||||
strike_state: dict = {}
|
||||
if server.contracts is not None:
|
||||
server.contracts.registry.record_strike(wallet)
|
||||
wallet_state = server.contracts.registry.get_wallet(wallet)
|
||||
strike_state = {
|
||||
"strike_count": wallet_state.strike_count,
|
||||
"banned": wallet_state.banned,
|
||||
}
|
||||
print(
|
||||
f"[tracker] forfeited pending balance of {wallet}: "
|
||||
f"{event['amount']:.6f} USDT ({reason}) strikes={strike_state.get('strike_count', 'n/a')}",
|
||||
flush=True,
|
||||
)
|
||||
self._send_json(200, {"forfeited": event["amount"], **strike_state})
|
||||
|
||||
def _handle_wallet_register(self):
|
||||
"""Bind the caller's client wallet pubkey to their API key (US-032).
|
||||
|
||||
Deposits from that wallet into the treasury are then credited to the
|
||||
API key's ledger balance by the deposit watcher.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
api_key = _api_key_from_headers(self.headers)
|
||||
if not api_key:
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
wallet = body.get("wallet")
|
||||
if not wallet or not isinstance(wallet, str):
|
||||
self._send_json(400, {"error": "wallet is required"})
|
||||
return
|
||||
server.billing.bind_wallet(api_key, wallet)
|
||||
print(f"[tracker] wallet bound: {wallet} -> api_key …{api_key[-6:]}", flush=True)
|
||||
self._send_json(200, {"wallet": wallet, "bound": True})
|
||||
|
||||
def _handle_benchmark_hop_penalty(self):
|
||||
"""Privileged: run the same prompt through 1/2/3-node pinned routes (US-030).
|
||||
|
||||
Data collection only — the routing algorithm is unchanged. Per-hop
|
||||
latency is derived incrementally: the k-node route's final hop penalty
|
||||
is its total minus the (k-1)-node route's total.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
auth = self.headers.get("Authorization")
|
||||
if not auth:
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
model = body.get("model", "")
|
||||
if not model:
|
||||
self._send_json(400, {"error": "model is required"})
|
||||
return
|
||||
prompt = body.get("prompt") or "Benchmark: say OK."
|
||||
max_new_tokens = int(body.get("max_new_tokens", 64))
|
||||
|
||||
with server.lock:
|
||||
self._purge_expired_nodes()
|
||||
resolved = _nodes_and_bounds_for_model(server, model)
|
||||
if resolved is None or not resolved[0]:
|
||||
self._send_json(404, {"error": f"no nodes registered for model {model!r}"})
|
||||
return
|
||||
all_nodes, rs, re = resolved
|
||||
|
||||
self_url = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||||
route_results: list[dict] = []
|
||||
prev_total_ms: float | None = None
|
||||
prev_per_hop: list[float] = []
|
||||
for hop_count in (1, 2, 3):
|
||||
combo = _find_pinned_route(all_nodes, rs, re, hop_count)
|
||||
if combo is None:
|
||||
continue # insufficient coverage for this hop count — skip
|
||||
request_body = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_new_tokens,
|
||||
"route": [n.node_id for n in combo],
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self_url}/v1/chat/completions",
|
||||
data=request_body,
|
||||
headers={"Content-Type": "application/json", "Authorization": auth},
|
||||
method="POST",
|
||||
)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
||||
payload = json.loads(resp.read())
|
||||
except Exception as exc:
|
||||
route_results.append({
|
||||
"route": [n.node_id for n in combo],
|
||||
"error": str(exc),
|
||||
})
|
||||
continue
|
||||
total_ms = (time.monotonic() - started) * 1000.0
|
||||
if prev_total_ms is not None and len(prev_per_hop) == hop_count - 1:
|
||||
per_hop_ms = prev_per_hop + [max(0.0, total_ms - prev_total_ms)]
|
||||
else:
|
||||
per_hop_ms = [total_ms / hop_count] * hop_count
|
||||
prev_total_ms = total_ms
|
||||
prev_per_hop = per_hop_ms
|
||||
route_results.append({
|
||||
"route": [n.node_id for n in combo],
|
||||
"total_ms": total_ms,
|
||||
"per_hop_ms": per_hop_ms,
|
||||
"tokens_generated": _usage_total_tokens(payload) or 0,
|
||||
})
|
||||
|
||||
record = {
|
||||
"timestamp": time.time(),
|
||||
"model": model,
|
||||
"prompt_hash": hashlib.sha256(prompt.encode()).hexdigest()[:16],
|
||||
"routes": route_results,
|
||||
}
|
||||
with server.benchmark_lock:
|
||||
existing: list = []
|
||||
if os.path.exists(server.benchmark_results_path):
|
||||
try:
|
||||
with open(server.benchmark_results_path, encoding="utf-8") as fh:
|
||||
existing = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = []
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
existing.append(record)
|
||||
with open(server.benchmark_results_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(existing, fh, indent=2)
|
||||
self._send_json(200, record)
|
||||
|
||||
def _handle_benchmark_results(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self.headers.get("Authorization"):
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
return
|
||||
results: list = []
|
||||
with server.benchmark_lock:
|
||||
if os.path.exists(server.benchmark_results_path):
|
||||
try:
|
||||
with open(server.benchmark_results_path, encoding="utf-8") as fh:
|
||||
results = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
results = []
|
||||
self._send_json(200, {"results": results if isinstance(results, list) else []})
|
||||
|
||||
def _handle_assign(self, parsed: urllib.parse.ParseResult):
|
||||
"""Return an optimal shard assignment for a node given its hardware profile.
|
||||
|
||||
@@ -2430,6 +2738,13 @@ class TrackerServer:
|
||||
relay_url: str | None = None,
|
||||
billing: BillingLedger | None = None,
|
||||
billing_db: str | None = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
treasury: Any | None = None,
|
||||
deposit_poll_interval: float = 15.0,
|
||||
settle_period: float = 86400.0,
|
||||
payout_threshold: float = 5.0,
|
||||
payout_dust_floor: float = 0.01,
|
||||
settlement_check_interval: float | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._requested_port = port
|
||||
@@ -2463,6 +2778,19 @@ class TrackerServer:
|
||||
billing = BillingLedger(db_path=billing_db, prices=preset_prices)
|
||||
self._billing: BillingLedger | None = billing
|
||||
self._billing_gossip_cursor = 0
|
||||
self._benchmark_results_path = benchmark_results_path
|
||||
self._treasury = treasury
|
||||
self._deposit_poll_interval = deposit_poll_interval
|
||||
self._deposit_stop = threading.Event()
|
||||
self._deposit_thread: threading.Thread | None = None
|
||||
self._settle_period = settle_period
|
||||
self._payout_threshold = payout_threshold
|
||||
self._payout_dust_floor = payout_dust_floor
|
||||
self._settlement_check_interval = settlement_check_interval or max(
|
||||
1.0, min(settle_period / 4.0, 15.0)
|
||||
)
|
||||
self._settlement_stop = threading.Event()
|
||||
self._settlement_thread: threading.Thread | None = None
|
||||
self.port: int | None = None
|
||||
|
||||
def start(self) -> int:
|
||||
@@ -2487,6 +2815,7 @@ class TrackerServer:
|
||||
relay_url=effective_relay_url,
|
||||
stats=self._stats,
|
||||
billing=self._billing,
|
||||
benchmark_results_path=self._benchmark_results_path,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
|
||||
@@ -2512,8 +2841,107 @@ class TrackerServer:
|
||||
self._rebalance_thread.start()
|
||||
self._stats_thread = threading.Thread(target=self._stats_loop, daemon=True)
|
||||
self._stats_thread.start()
|
||||
if self._treasury is not None and self._billing is not None:
|
||||
self._deposit_stop.clear()
|
||||
self._deposit_thread = threading.Thread(target=self._deposit_loop, daemon=True)
|
||||
self._deposit_thread.start()
|
||||
self._settlement_stop.clear()
|
||||
self._settlement_thread = threading.Thread(target=self._settlement_loop, daemon=True)
|
||||
self._settlement_thread.start()
|
||||
return self.port
|
||||
|
||||
def _settlement_loop(self) -> None:
|
||||
"""Leader-only on-chain settlement (US-033, ADR-0015).
|
||||
|
||||
Pay a node when pending ≥ threshold OR its pending age ≥ max period,
|
||||
with a dust floor. Pending is debited (payout events, replicated)
|
||||
before the transaction is sent; unconfirmed batches are resent with
|
||||
the same settlement id, so retries never double-pay.
|
||||
"""
|
||||
billing = self._billing
|
||||
treasury = self._treasury
|
||||
assert billing is not None and treasury is not None
|
||||
while not self._settlement_stop.wait(self._settlement_check_interval):
|
||||
if self._raft is not None and not self._raft.is_leader():
|
||||
continue # followers replicate the ledger but never sign
|
||||
# resend batches whose transaction never confirmed
|
||||
for settlement_id, payouts in billing.unconfirmed_settlements().items():
|
||||
self._send_settlement(settlement_id, payouts)
|
||||
banned: set[str] = set()
|
||||
if self._server is not None and self._server.contracts is not None:
|
||||
registry = self._server.contracts.registry
|
||||
banned = {
|
||||
wallet for wallet, _ in billing.payables(
|
||||
threshold=0.0, max_period=0.0, dust_floor=0.0,
|
||||
)
|
||||
if registry.get_wallet(wallet).banned
|
||||
}
|
||||
due = billing.payables(
|
||||
threshold=self._payout_threshold,
|
||||
max_period=self._settle_period,
|
||||
dust_floor=self._payout_dust_floor,
|
||||
exclude=banned,
|
||||
)
|
||||
if not due:
|
||||
continue
|
||||
settlement_id = uuid.uuid4().hex
|
||||
for wallet, amount in due:
|
||||
billing.settle_node_payout(wallet, amount, reference=settlement_id)
|
||||
self._send_settlement(settlement_id, due)
|
||||
|
||||
def _send_settlement(self, settlement_id: str, payouts: list) -> None:
|
||||
billing = self._billing
|
||||
treasury = self._treasury
|
||||
assert billing is not None and treasury is not None
|
||||
try:
|
||||
signature = treasury.send_payouts([(w, a) for w, a in payouts])
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[tracker] settlement {settlement_id} failed (will retry): {exc}",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
billing.confirm_settlement(settlement_id, signature)
|
||||
total = sum(a for _, a in payouts)
|
||||
print(
|
||||
f"[tracker] settled {settlement_id}: {len(payouts)} payout(s), "
|
||||
f"{total:.6f} USDT total (tx {signature})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _deposit_loop(self) -> None:
|
||||
"""Poll the treasury token account and credit confirmed deposits (US-032)."""
|
||||
billing = self._billing
|
||||
treasury = self._treasury
|
||||
assert billing is not None and treasury is not None
|
||||
while not self._deposit_stop.wait(self._deposit_poll_interval):
|
||||
try:
|
||||
deposits = treasury.list_new_deposits(
|
||||
lambda sig: billing.has_event(f"deposit-{sig}")
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[tracker] deposit poll failed: {exc}", flush=True)
|
||||
continue
|
||||
for deposit in deposits:
|
||||
api_key = billing.api_key_for_wallet(deposit.sender_wallet)
|
||||
if api_key is None:
|
||||
print(
|
||||
f"[tracker] unattributed deposit {deposit.signature}: "
|
||||
f"{deposit.amount_usdt} USDT from unbound wallet "
|
||||
f"{deposit.sender_wallet} — register via /v1/wallet/register",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
credited = billing.credit_deposit(
|
||||
api_key, deposit.amount_usdt, deposit.signature
|
||||
)
|
||||
if credited is not None:
|
||||
print(
|
||||
f"[tracker] deposit credited: {deposit.amount_usdt} USDT "
|
||||
f"-> api_key …{api_key[-6:]} (tx {deposit.signature})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _raft_apply(self, command: str, payload: dict) -> None:
|
||||
"""Called by RaftNode when a log entry is committed — replicate to local registry."""
|
||||
if command != "register":
|
||||
@@ -2611,6 +3039,8 @@ class TrackerServer:
|
||||
return
|
||||
self._rebalance_stop.set()
|
||||
self._stats_stop.set()
|
||||
self._deposit_stop.set()
|
||||
self._settlement_stop.set()
|
||||
if self._stats is not None:
|
||||
self._stats.save_to_db()
|
||||
if self._billing is not None:
|
||||
@@ -2623,6 +3053,12 @@ class TrackerServer:
|
||||
self._rebalance_thread.join(timeout=1)
|
||||
if self._stats_thread is not None:
|
||||
self._stats_thread.join(timeout=1)
|
||||
if self._deposit_thread is not None:
|
||||
self._deposit_thread.join(timeout=1)
|
||||
self._deposit_thread = None
|
||||
if self._settlement_thread is not None:
|
||||
self._settlement_thread.join(timeout=1)
|
||||
self._settlement_thread = None
|
||||
self._server = None
|
||||
self._thread = None
|
||||
self._rebalance_thread = None
|
||||
|
||||
@@ -20,4 +20,4 @@ where = ["."]
|
||||
include = ["meshnet_tracker*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
meshnet_tracker = ["*.json"]
|
||||
meshnet_tracker = ["*.json", "*.html"]
|
||||
|
||||
Reference in New Issue
Block a user