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:
Dobromir Popov
2026-07-02 22:31:02 +02:00
parent 4d4ab607ca
commit 1e0aa6ea8f
24 changed files with 3168 additions and 994 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
"""Custodial Solana treasury adapter (ADR-0015, US-032/US-033).
The entire on-chain surface is plain SPL token operations against a single
project-owned treasury wallet: read confirmed incoming USDT transfers, send
batched payout transfers, plus devnet helpers to create the mock-USDT mint.
No Anchor programs.
RPC transport is a minimal urllib JSON-RPC client (the installed solana-py
0.40 removed its sync client); signing and instruction building use solders /
spl.token. Import this module only where a cluster is actually configured —
the deterministic local boundary in ``meshnet_contracts`` stays
dependency-free for CI.
"""
from __future__ import annotations
import base64
import json
import os
import urllib.request
from dataclasses import dataclass
from solders.hash import Hash
from solders.keypair import Keypair
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountParams, create_account
from solders.transaction import Transaction
from spl.token.constants import TOKEN_PROGRAM_ID
from spl.token.instructions import (
create_associated_token_account,
get_associated_token_address,
initialize_mint,
mint_to,
transfer_checked,
)
from spl.token.models import (
InitializeMintParams,
MintToParams,
TransferCheckedParams,
)
USDT_DECIMALS = 6 # matches real USDT on Solana mainnet
_MINT_ACCOUNT_SIZE = 82
@dataclass(frozen=True)
class Deposit:
"""A confirmed incoming USDT transfer into the treasury token account."""
signature: str
sender_wallet: str
amount_usdt: float
def load_keypair(path: str) -> Keypair:
"""Load a keypair from a solana-cli style JSON byte-array file."""
with open(os.path.expanduser(path), encoding="utf-8") as fh:
secret = json.load(fh)
return Keypair.from_bytes(bytes(secret))
class RpcClient:
"""Minimal synchronous Solana JSON-RPC client."""
def __init__(self, url: str, timeout: float = 30.0) -> None:
self.url = url
self._timeout = timeout
self._request_id = 0
def call(self, method: str, params: list | None = None):
self._request_id += 1
body = json.dumps({
"jsonrpc": "2.0",
"id": self._request_id,
"method": method,
"params": params or [],
}).encode()
request = urllib.request.Request(
self.url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read())
if "error" in payload:
raise RuntimeError(f"RPC {method} failed: {payload['error']}")
return payload.get("result")
class SolanaCustodialTreasury:
"""Read deposits into / send payouts from the custodial treasury wallet."""
def __init__(
self,
rpc_url: str,
usdt_mint: str,
treasury_keypair: Keypair | str,
*,
decimals: int = USDT_DECIMALS,
) -> None:
self.rpc = RpcClient(rpc_url)
self._mint = Pubkey.from_string(usdt_mint)
self._treasury = (
load_keypair(treasury_keypair)
if isinstance(treasury_keypair, str)
else treasury_keypair
)
self._decimals = decimals
self._treasury_ata = get_associated_token_address(
self._treasury.pubkey(), self._mint
)
@property
def treasury_wallet(self) -> str:
return str(self._treasury.pubkey())
@property
def treasury_token_account(self) -> str:
return str(self._treasury_ata)
# ---- deposits (US-032) ----
def list_new_deposits(self, is_seen, *, limit: int = 200) -> list[Deposit]:
"""Confirmed incoming USDT transfers whose signature is not yet seen.
``is_seen(signature) -> bool`` lets the caller (the deposit watcher)
dedupe against its ledger, so replayed and re-observed transactions
are credited exactly once.
"""
infos = self.rpc.call(
"getSignaturesForAddress",
[str(self._treasury_ata), {"limit": limit}],
) or []
deposits: list[Deposit] = []
for info in infos:
signature = info.get("signature", "")
if not signature or info.get("err") is not None or is_seen(signature):
continue
deposit = self._parse_deposit(signature)
if deposit is not None:
deposits.append(deposit)
return deposits
def _parse_deposit(self, signature: str) -> Deposit | None:
result = self.rpc.call("getTransaction", [
signature,
{"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0},
])
meta = (result or {}).get("meta")
if not meta:
return None
pre = {b["accountIndex"]: b for b in meta.get("preTokenBalances") or []}
post = {b["accountIndex"]: b for b in meta.get("postTokenBalances") or []}
treasury_delta = 0
sender_wallet: str | None = None
for index, balance in post.items():
if balance.get("mint") != str(self._mint):
continue
before = pre.get(index)
before_amount = int(before["uiTokenAmount"]["amount"]) if before else 0
delta = int(balance["uiTokenAmount"]["amount"]) - before_amount
owner = balance.get("owner")
if owner == self.treasury_wallet and delta > 0:
treasury_delta = delta
elif delta < 0 and owner:
sender_wallet = owner
if sender_wallet is None:
# a token account emptied and closed may appear only in pre balances
for index, balance in pre.items():
if balance.get("mint") != str(self._mint):
continue
if index not in post and balance.get("owner"):
sender_wallet = balance["owner"]
if treasury_delta <= 0 or sender_wallet is None:
return None
return Deposit(
signature=signature,
sender_wallet=sender_wallet,
amount_usdt=treasury_delta / (10 ** self._decimals),
)
# ---- payouts (US-033) ----
def send_payouts(self, payouts: list[tuple[str, float]]) -> str:
"""Send one batched transaction of USDT transfers treasury → wallets.
Creates the recipient's associated token account when missing (fee
paid by the treasury). Returns the transaction signature.
"""
if not payouts:
raise ValueError("payouts must be non-empty")
instructions = []
for wallet, amount in payouts:
recipient = Pubkey.from_string(wallet)
recipient_ata = get_associated_token_address(recipient, self._mint)
if not self._account_exists(recipient_ata):
instructions.append(create_associated_token_account(
payer=self._treasury.pubkey(),
owner=recipient,
mint=self._mint,
))
instructions.append(transfer_checked(TransferCheckedParams(
program_id=TOKEN_PROGRAM_ID,
source=self._treasury_ata,
mint=self._mint,
dest=recipient_ata,
owner=self._treasury.pubkey(),
amount=int(round(amount * (10 ** self._decimals))),
decimals=self._decimals,
)))
return self.send_instructions(instructions)
# ---- shared plumbing + devnet helpers (scripts/devnet_setup.py) ----
def _account_exists(self, pubkey: Pubkey) -> bool:
result = self.rpc.call("getAccountInfo", [str(pubkey), {"encoding": "base64"}])
return bool(result and result.get("value"))
def send_instructions(self, instructions: list, extra_signers: list | None = None) -> str:
blockhash_result = self.rpc.call("getLatestBlockhash")
blockhash = Hash.from_string(blockhash_result["value"]["blockhash"])
transaction = Transaction.new_signed_with_payer(
instructions,
self._treasury.pubkey(),
[self._treasury, *(extra_signers or [])],
blockhash,
)
encoded = base64.b64encode(bytes(transaction)).decode()
return self.rpc.call("sendTransaction", [
encoded,
{"encoding": "base64", "preflightCommitment": "confirmed"},
])
def get_sol_balance(self, wallet: str | None = None) -> float:
result = self.rpc.call("getBalance", [wallet or self.treasury_wallet])
return result["value"] / 1e9
def request_airdrop(self, sol: float = 2.0) -> str:
return self.rpc.call(
"requestAirdrop", [self.treasury_wallet, int(sol * 1e9)]
)
def create_mock_usdt_mint(self) -> tuple["SolanaCustodialTreasury", str]:
"""Create a fresh 6-decimal mock-USDT mint (treasury = mint authority).
Returns a new adapter bound to the created mint plus its address.
"""
mint_keypair = Keypair()
rent = self.rpc.call(
"getMinimumBalanceForRentExemption", [_MINT_ACCOUNT_SIZE]
)
instructions = [
create_account(CreateAccountParams(
from_pubkey=self._treasury.pubkey(),
to_pubkey=mint_keypair.pubkey(),
lamports=rent,
space=_MINT_ACCOUNT_SIZE,
owner=TOKEN_PROGRAM_ID,
)),
initialize_mint(InitializeMintParams(
program_id=TOKEN_PROGRAM_ID,
mint=mint_keypair.pubkey(),
decimals=self._decimals,
mint_authority=self._treasury.pubkey(),
freeze_authority=None,
)),
]
self.send_instructions(instructions, extra_signers=[mint_keypair])
fresh = SolanaCustodialTreasury(
self.rpc.url,
str(mint_keypair.pubkey()),
self._treasury,
decimals=self._decimals,
)
return fresh, str(mint_keypair.pubkey())
def ensure_token_account(self, wallet: str) -> str:
owner = Pubkey.from_string(wallet)
ata = get_associated_token_address(owner, self._mint)
if not self._account_exists(ata):
self.send_instructions([create_associated_token_account(
payer=self._treasury.pubkey(), owner=owner, mint=self._mint,
)])
return str(ata)
def mint_mock_usdt(self, wallet: str, amount: float) -> str:
"""Mint mock USDT to a wallet (devnet only — treasury is mint authority)."""
ata = self.ensure_token_account(wallet)
return self.send_instructions([mint_to(MintToParams(
program_id=TOKEN_PROGRAM_ID,
mint=self._mint,
dest=Pubkey.from_string(ata),
mint_authority=self._treasury.pubkey(),
amount=int(round(amount * (10 ** self._decimals))),
))])

View File

@@ -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 ----

View File

@@ -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()

View 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 &amp; coverage</h2><div id="nodes" class="empty">loading…</div></section>
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
<section><h2>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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>

View File

@@ -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

View File

@@ -20,4 +20,4 @@ where = ["."]
include = ["meshnet_tracker*"]
[tool.setuptools.package-data]
meshnet_tracker = ["*.json"]
meshnet_tracker = ["*.json", "*.html"]

View File

@@ -0,0 +1,46 @@
# meshnet-validator
Optimistic fraud detection (ADR-0003, penalty amended by ADR-0015): the
validator re-runs a random ~5% sample of completed inference requests against
a trusted reference node and, on divergence, submits a slash proof and
forfeits the node's pending balance.
## Why the penalty deters cheating
There is no upfront stake. Settlement is periodic (US-033), so a node always
has an unpaid **pending balance** — that balance *is* the collateral.
At a sampling rate `p`, a cheater is caught on average once every `1/p`
fraudulent jobs, so cheating is unprofitable when:
```
penalty > per_job_gain / p # p = 0.05 → penalty > 20 × per_job_gain
```
With the production settlement period of 24h, the pending balance at any
moment approximates a full day's earnings — hundreds to thousands of jobs —
which is far above the 20× bar. Each catch also records a strike; three
strikes ban the wallet (registration rejected, excluded from routes, unpaid
pending never settled), and the probationary period (first N jobs unpaid)
makes re-entry with a fresh wallet costly.
Two operational notes:
- Shortening the settlement period shrinks the collateral. Period changes
must weigh chain overhead against deterrence.
- A cheater immediately after a payout has little to forfeit — the
strike/ban ladder covers that window.
## Usage
```python
ValidatorProcess(
contracts=contracts, # registry/validation boundary
billing=ledger, # BillingLedger — enables forfeiture
reference_node_url="http://...",
sample_rate=0.05,
)
```
Remote validators can instead call the tracker's privileged
`POST /v1/billing/forfeit` endpoint (non-empty Authorization header).

View File

@@ -1,159 +1,170 @@
"""Optimistic fraud validator for completed inference requests."""
import json
import math
import random
import threading
import time
import urllib.request
from typing import Any
__version__ = "0.1.0"
class ValidatorProcess:
"""Separate validator loop that samples completed requests and submits slashes."""
def __init__(
self,
*,
contracts: Any,
reference_node_url: str,
sample_rate: float = 0.05,
tolerance: float = 1e-6,
slash_amount: int = 100,
strike_threshold: int = 3,
random_seed: int | None = None,
webhook_url: str | None = None,
interval_seconds: float = 1.0,
) -> None:
if not 0.0 <= sample_rate <= 1.0:
raise ValueError("sample_rate must be between 0 and 1")
if tolerance < 0:
raise ValueError("tolerance must be non-negative")
if slash_amount <= 0:
raise ValueError("slash_amount must be positive")
if strike_threshold <= 0:
raise ValueError("strike_threshold must be positive")
if interval_seconds <= 0:
raise ValueError("interval_seconds must be positive")
self._contracts = contracts
self._reference_node_url = reference_node_url.rstrip("/")
self._sample_rate = sample_rate
self._tolerance = tolerance
self._slash_amount = slash_amount
self._strike_threshold = strike_threshold
self._webhook_url = webhook_url
self._interval_seconds = interval_seconds
self._random = random.Random(random_seed)
self._last_event_index = -1
self._running = False
self._thread: threading.Thread | None = None
self.sampled_count = 0
def validate_once(self) -> list[Any]:
"""Run one validation cycle and return slash receipts submitted this cycle."""
receipts: list[Any] = []
events = self._contracts.validation.list_completed_inferences(
after_index=self._last_event_index,
)
for event in events:
self._last_event_index = max(self._last_event_index, event.index)
if self._random.random() >= self._sample_rate:
continue
self.sampled_count += 1
reference_output = self._run_reference(event.messages)
if _outputs_match(event.observed_output, reference_output, self._tolerance):
continue
receipts.extend(self._slash_route(event.route_nodes, event.observed_output, reference_output))
return receipts
def start(self) -> None:
if self._running:
raise RuntimeError("ValidatorProcess is already running")
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
def _run_loop(self) -> None:
while self._running:
self.validate_once()
time.sleep(self._interval_seconds)
def _run_reference(self, messages: list[dict]) -> str:
response = _post_json(
f"{self._reference_node_url}/v1/infer",
{"messages": messages},
)
text = response.get("text")
if not isinstance(text, str):
raise ValueError("reference node response did not contain text")
return text
def _slash_route(
self,
route_nodes: list[dict],
observed_output: str,
reference_output: str,
) -> list[Any]:
receipts: list[Any] = []
node = _final_text_node(route_nodes)
wallet_address = node.get("wallet_address") if node else None
if not wallet_address:
return receipts
if self._contracts.registry.get_wallet(wallet_address).banned:
return receipts
receipts.append(self._contracts.registry.submit_slash_proof(
wallet_address=wallet_address,
slash_amount=self._slash_amount,
strike_threshold=self._strike_threshold,
reason=(
"reference output diverged "
f"(observed={observed_output!r}, reference={reference_output!r})"
),
webhook_url=self._webhook_url,
))
return receipts
def _final_text_node(route_nodes: list[dict]) -> dict | None:
if not route_nodes:
return None
return max(route_nodes, key=lambda node: int(node.get("shard_end", 0)))
def _outputs_match(observed: str, reference: str, tolerance: float) -> bool:
observed_float = _parse_float(observed)
reference_float = _parse_float(reference)
if observed_float is not None and reference_float is not None:
return math.isclose(observed_float, reference_float, rel_tol=tolerance, abs_tol=tolerance)
return observed == reference
def _parse_float(value: str) -> float | None:
try:
return float(value)
except ValueError:
return None
def _post_json(url: str, payload: dict, timeout: float = 5.0) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read())
__all__ = ["ValidatorProcess"]
"""Optimistic fraud validator for completed inference requests."""
import json
import math
import random
import threading
import time
import urllib.request
from typing import Any
__version__ = "0.1.0"
class ValidatorProcess:
"""Separate validator loop that samples completed requests and submits slashes."""
def __init__(
self,
*,
contracts: Any,
reference_node_url: str,
sample_rate: float = 0.05,
tolerance: float = 1e-6,
slash_amount: int = 100,
strike_threshold: int = 3,
random_seed: int | None = None,
webhook_url: str | None = None,
interval_seconds: float = 1.0,
billing: Any | None = None,
) -> None:
if not 0.0 <= sample_rate <= 1.0:
raise ValueError("sample_rate must be between 0 and 1")
if tolerance < 0:
raise ValueError("tolerance must be non-negative")
if slash_amount <= 0:
raise ValueError("slash_amount must be positive")
if strike_threshold <= 0:
raise ValueError("strike_threshold must be positive")
if interval_seconds <= 0:
raise ValueError("interval_seconds must be positive")
self._contracts = contracts
self._billing = billing
self._reference_node_url = reference_node_url.rstrip("/")
self._sample_rate = sample_rate
self._tolerance = tolerance
self._slash_amount = slash_amount
self._strike_threshold = strike_threshold
self._webhook_url = webhook_url
self._interval_seconds = interval_seconds
self._random = random.Random(random_seed)
self._last_event_index = -1
self._running = False
self._thread: threading.Thread | None = None
self.sampled_count = 0
def validate_once(self) -> list[Any]:
"""Run one validation cycle and return slash receipts submitted this cycle."""
receipts: list[Any] = []
events = self._contracts.validation.list_completed_inferences(
after_index=self._last_event_index,
)
for event in events:
self._last_event_index = max(self._last_event_index, event.index)
if self._random.random() >= self._sample_rate:
continue
self.sampled_count += 1
reference_output = self._run_reference(event.messages)
if _outputs_match(event.observed_output, reference_output, self._tolerance):
continue
receipts.extend(self._slash_route(event.route_nodes, event.observed_output, reference_output))
return receipts
def start(self) -> None:
if self._running:
raise RuntimeError("ValidatorProcess is already running")
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
def _run_loop(self) -> None:
while self._running:
self.validate_once()
time.sleep(self._interval_seconds)
def _run_reference(self, messages: list[dict]) -> str:
response = _post_json(
f"{self._reference_node_url}/v1/infer",
{"messages": messages},
)
text = response.get("text")
if not isinstance(text, str):
raise ValueError("reference node response did not contain text")
return text
def _slash_route(
self,
route_nodes: list[dict],
observed_output: str,
reference_output: str,
) -> list[Any]:
receipts: list[Any] = []
node = _final_text_node(route_nodes)
wallet_address = node.get("wallet_address") if node else None
if not wallet_address:
return receipts
if self._contracts.registry.get_wallet(wallet_address).banned:
return receipts
receipts.append(self._contracts.registry.submit_slash_proof(
wallet_address=wallet_address,
slash_amount=self._slash_amount,
strike_threshold=self._strike_threshold,
reason=(
"reference output diverged "
f"(observed={observed_output!r}, reference={reference_output!r})"
),
webhook_url=self._webhook_url,
))
# ADR-0015: the pending balance is the collateral — forfeit it in the
# same validation cycle as the strike.
if self._billing is not None:
forfeit = self._billing.forfeit_pending(wallet_address, reason="fraud-divergence")
print(
f"[validator] forfeited pending balance of {wallet_address}: "
f"{forfeit['amount']:.6f} USDT (fraud-divergence)",
flush=True,
)
return receipts
def _final_text_node(route_nodes: list[dict]) -> dict | None:
if not route_nodes:
return None
return max(route_nodes, key=lambda node: int(node.get("shard_end", 0)))
def _outputs_match(observed: str, reference: str, tolerance: float) -> bool:
observed_float = _parse_float(observed)
reference_float = _parse_float(reference)
if observed_float is not None and reference_float is not None:
return math.isclose(observed_float, reference_float, rel_tol=tolerance, abs_tol=tolerance)
return observed == reference
def _parse_float(value: str) -> float | None:
try:
return float(value)
except ValueError:
return None
def _post_json(url: str, payload: dict, timeout: float = 5.0) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read())
__all__ = ["ValidatorProcess"]