feat: add fraud detection validator
This commit is contained in:
@@ -6,6 +6,8 @@ replace this adapter later without changing gateway or tracker call sites.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -49,6 +51,33 @@ class ComputeAttribution:
|
||||
return self.layer_count * self.tokens
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationEvent:
|
||||
"""Completed inference data consumed by fraud validators."""
|
||||
|
||||
index: int
|
||||
session_id: str
|
||||
model: str
|
||||
messages: list[dict]
|
||||
observed_output: str
|
||||
route_nodes: list[dict]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlashReceipt:
|
||||
"""Local slash transaction receipt."""
|
||||
|
||||
signature: str
|
||||
cluster: str
|
||||
wallet_address: str
|
||||
slash_amount: int
|
||||
stake_before: int
|
||||
stake_after: int
|
||||
strike_count: int
|
||||
banned: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettlementResult:
|
||||
"""Summary returned by an epoch settlement transaction."""
|
||||
@@ -64,6 +93,8 @@ class _LocalContractState:
|
||||
registry: dict[str, RegistryWallet] = field(default_factory=dict)
|
||||
api_keys: dict[str, ApiKeyBalance] = field(default_factory=dict)
|
||||
attributions: list[ComputeAttribution] = field(default_factory=list)
|
||||
validation_events: list[ValidationEvent] = field(default_factory=list)
|
||||
slash_receipts: list[SlashReceipt] = field(default_factory=list)
|
||||
token_balances: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -98,6 +129,46 @@ class RegistryContract:
|
||||
)
|
||||
return {"signature": f"local-strike-{wallet_address}", "cluster": self._cluster}
|
||||
|
||||
def submit_slash_proof(
|
||||
self,
|
||||
*,
|
||||
wallet_address: str,
|
||||
slash_amount: int,
|
||||
strike_threshold: int = 3,
|
||||
reason: str = "fraudulent inference output",
|
||||
webhook_url: str | None = None,
|
||||
) -> SlashReceipt:
|
||||
if not wallet_address:
|
||||
raise ValueError("wallet_address is required")
|
||||
if slash_amount <= 0:
|
||||
raise ValueError("slash_amount must be positive")
|
||||
if strike_threshold <= 0:
|
||||
raise ValueError("strike_threshold must be positive")
|
||||
|
||||
current = self.get_wallet(wallet_address)
|
||||
stake_after = max(0, current.stake_balance - slash_amount)
|
||||
strike_count = current.strike_count + 1
|
||||
banned = current.banned or strike_count >= strike_threshold
|
||||
self._state.registry[wallet_address] = RegistryWallet(
|
||||
stake_balance=stake_after,
|
||||
strike_count=strike_count,
|
||||
banned=banned,
|
||||
)
|
||||
receipt = SlashReceipt(
|
||||
signature=f"local-slash-{wallet_address}-{len(self._state.slash_receipts) + 1}",
|
||||
cluster=self._cluster,
|
||||
wallet_address=wallet_address,
|
||||
slash_amount=slash_amount,
|
||||
stake_before=current.stake_balance,
|
||||
stake_after=stake_after,
|
||||
strike_count=strike_count,
|
||||
banned=banned,
|
||||
reason=reason,
|
||||
)
|
||||
self._state.slash_receipts.append(receipt)
|
||||
_notify_slash(receipt, webhook_url)
|
||||
return receipt
|
||||
|
||||
def ban_wallet(self, wallet_address: str) -> dict:
|
||||
current = self.get_wallet(wallet_address)
|
||||
self._state.registry[wallet_address] = RegistryWallet(
|
||||
@@ -114,6 +185,49 @@ class RegistryContract:
|
||||
return wallet.stake_balance >= minimum_stake
|
||||
|
||||
|
||||
class ValidationContract:
|
||||
"""Validation event log consumed by the optimistic fraud detector."""
|
||||
|
||||
def __init__(self, state: _LocalContractState) -> None:
|
||||
self._state = state
|
||||
|
||||
def record_completed_inference(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
observed_output: str,
|
||||
route_nodes: list[dict],
|
||||
) -> ValidationEvent:
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
if not model:
|
||||
raise ValueError("model is required")
|
||||
if not isinstance(messages, list):
|
||||
raise ValueError("messages must be a list")
|
||||
if not isinstance(observed_output, str):
|
||||
raise ValueError("observed_output must be a string")
|
||||
if not isinstance(route_nodes, list):
|
||||
raise ValueError("route_nodes must be a list")
|
||||
event = ValidationEvent(
|
||||
index=len(self._state.validation_events),
|
||||
session_id=session_id,
|
||||
model=model,
|
||||
messages=[dict(message) for message in messages],
|
||||
observed_output=observed_output,
|
||||
route_nodes=[dict(node) for node in route_nodes],
|
||||
)
|
||||
self._state.validation_events.append(event)
|
||||
return event
|
||||
|
||||
def list_completed_inferences(self, *, after_index: int = -1) -> list[ValidationEvent]:
|
||||
return [
|
||||
event for event in self._state.validation_events
|
||||
if event.index > after_index
|
||||
]
|
||||
|
||||
|
||||
class PaymentContract:
|
||||
"""Payment wrapper for funded API keys and compute attribution."""
|
||||
|
||||
@@ -297,6 +411,7 @@ class LocalSolanaContracts:
|
||||
self.cluster = cluster
|
||||
self._state = _LocalContractState()
|
||||
self.registry = RegistryContract(self._state, cluster)
|
||||
self.validation = ValidationContract(self._state)
|
||||
self.payment = PaymentContract(self._state, cluster, starting_credit_lamports)
|
||||
self.settlement = SettlementContract(
|
||||
self._state,
|
||||
@@ -305,6 +420,35 @@ class LocalSolanaContracts:
|
||||
)
|
||||
|
||||
|
||||
def _notify_slash(receipt: SlashReceipt, webhook_url: str | None) -> None:
|
||||
message = (
|
||||
f"WARNING: node {receipt.wallet_address} slashed "
|
||||
f"by {receipt.slash_amount} lamports; strikes={receipt.strike_count}; "
|
||||
f"banned={receipt.banned}; reason={receipt.reason}"
|
||||
)
|
||||
print(message, flush=True)
|
||||
if not webhook_url:
|
||||
return
|
||||
payload = json.dumps({
|
||||
"event": "node_slashed",
|
||||
"wallet_address": receipt.wallet_address,
|
||||
"slash_amount": receipt.slash_amount,
|
||||
"strike_count": receipt.strike_count,
|
||||
"banned": receipt.banned,
|
||||
"reason": receipt.reason,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2).read()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyBalance",
|
||||
"ComputeAttribution",
|
||||
@@ -314,4 +458,7 @@ __all__ = [
|
||||
"RegistryWallet",
|
||||
"SettlementContract",
|
||||
"SettlementResult",
|
||||
"SlashReceipt",
|
||||
"ValidationContract",
|
||||
"ValidationEvent",
|
||||
]
|
||||
|
||||
@@ -147,10 +147,10 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
text = node_resp["text"]
|
||||
model = body.get("model", "stub-model")
|
||||
session_id = str(uuid.uuid4())
|
||||
if server.contracts is not None and route_nodes:
|
||||
api_key = _api_key_from_authorization(self.headers.get("Authorization"))
|
||||
if api_key:
|
||||
session_id = str(uuid.uuid4())
|
||||
tokens = max(1, _estimate_token_count(messages, text))
|
||||
for node in route_nodes:
|
||||
wallet_address = node.get("wallet_address")
|
||||
@@ -165,6 +165,14 @@ class _GatewayHandler(http.server.BaseHTTPRequestHandler):
|
||||
tokens=tokens,
|
||||
speed_score=float(node.get("score", 1.0)),
|
||||
)
|
||||
if server.contracts is not None:
|
||||
server.contracts.validation.record_completed_inference(
|
||||
session_id=session_id,
|
||||
model=model,
|
||||
messages=messages if isinstance(messages, list) else [],
|
||||
observed_output=text,
|
||||
route_nodes=route_nodes,
|
||||
)
|
||||
|
||||
if streaming:
|
||||
self._send_sse_response(text, model)
|
||||
|
||||
@@ -28,11 +28,20 @@ def _make_stub_activations(prompt: str) -> dict:
|
||||
|
||||
|
||||
class _StubHTTPServer(http.server.HTTPServer):
|
||||
def __init__(self, addr, handler, shard_start: int, shard_end: int, is_last_shard: bool):
|
||||
def __init__(
|
||||
self,
|
||||
addr,
|
||||
handler,
|
||||
shard_start: int,
|
||||
shard_end: int,
|
||||
is_last_shard: bool,
|
||||
response_prefix: str,
|
||||
):
|
||||
super().__init__(addr, handler)
|
||||
self.shard_start = shard_start
|
||||
self.shard_end = shard_end
|
||||
self.is_last_shard = is_last_shard
|
||||
self.response_prefix = response_prefix
|
||||
self.received_activations: bool = False
|
||||
|
||||
|
||||
@@ -57,7 +66,7 @@ class _StubHandler(http.server.BaseHTTPRequestHandler):
|
||||
server.received_activations = True
|
||||
prompt = body["activations"].get("context", {}).get("prompt", "")
|
||||
if server.is_last_shard:
|
||||
payload = json.dumps({"text": f"stub response to: {prompt}"}).encode()
|
||||
payload = json.dumps({"text": f"{server.response_prefix} {prompt}"}).encode()
|
||||
else:
|
||||
payload = json.dumps({"activations": _make_stub_activations(prompt)}).encode()
|
||||
else:
|
||||
@@ -66,7 +75,7 @@ class _StubHandler(http.server.BaseHTTPRequestHandler):
|
||||
last_content = messages[-1]["content"] if messages else ""
|
||||
if server.is_last_shard:
|
||||
# Single-node shortcut: no activation hop needed.
|
||||
payload = json.dumps({"text": f"stub response to: {last_content}"}).encode()
|
||||
payload = json.dumps({"text": f"{server.response_prefix} {last_content}"}).encode()
|
||||
else:
|
||||
payload = json.dumps({"activations": _make_stub_activations(last_content)}).encode()
|
||||
|
||||
@@ -92,6 +101,7 @@ class StubNodeServer:
|
||||
shard_start: int = 0,
|
||||
shard_end: int = 31,
|
||||
is_last_shard: bool = True,
|
||||
response_prefix: str = "stub response to:",
|
||||
):
|
||||
self._host = host
|
||||
self._requested_port = port
|
||||
@@ -100,6 +110,7 @@ class StubNodeServer:
|
||||
self._shard_start = shard_start
|
||||
self._shard_end = shard_end
|
||||
self._is_last_shard = is_last_shard
|
||||
self._response_prefix = response_prefix
|
||||
self._server: _StubHTTPServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self.port: int | None = None
|
||||
@@ -119,6 +130,7 @@ class StubNodeServer:
|
||||
self._shard_start,
|
||||
self._shard_end,
|
||||
self._is_last_shard,
|
||||
self._response_prefix,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
159
packages/validator/meshnet_validator/__init__.py
Normal file
159
packages/validator/meshnet_validator/__init__.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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"]
|
||||
12
packages/validator/pyproject.toml
Normal file
12
packages/validator/pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshnet-validator"
|
||||
version = "0.1.0"
|
||||
description = "Optimistic fraud validator for the Distributed Inference Network"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["meshnet_validator*"]
|
||||
Reference in New Issue
Block a user