feat(alpha): complete hardening backlog
Complete the alpha-hardening Ralph task set, including tracker billing/accounting guards, validator fraud-audit primitives, wallet binding proof support, documentation runbooks, and updated tests. Verification: .venv/bin/python -m compileall -q packages tests; .venv/bin/python -m pytest -q --tb=short (313 passed, 3 skipped, 1 failed: tests/test_mining_cli.py::test_legacy_start_without_port_uses_next_available_port because meshnet-node pid 1263451 is already listening on port 7000).
This commit is contained in:
@@ -124,8 +124,8 @@ def _call(url, method="GET", body=None, token=None):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
||||
def account_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET)
|
||||
port = tracker.start()
|
||||
yield f"http://127.0.0.1:{port}", ledger
|
||||
@@ -145,8 +145,7 @@ def test_register_login_and_account_view(account_tracker):
|
||||
me = _call(f"{url}/v1/account", token=login["session_token"])
|
||||
assert me["account"]["email"] == "admin@example.com"
|
||||
assert me["api_keys"] == [reg["api_key"]]
|
||||
# registration granted Caller Credit on the ledger
|
||||
assert me["total_balance"] == pytest.approx(0.5)
|
||||
assert me["total_balance"] == pytest.approx(0.0)
|
||||
assert me["usage"]["requests"] == 0
|
||||
|
||||
|
||||
|
||||
193
tests/test_adaptive_audit_sampling.py
Normal file
193
tests/test_adaptive_audit_sampling.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""AH-009 (FRAUD: reputation-weighted routing + adaptive audit rate).
|
||||
|
||||
Covers the audit-sampling half of the issue: per-wallet audit probability as
|
||||
a function of tenure/reputation, a fleet-wide budget balancer that keeps the
|
||||
realized audit rate anchored to a configured target, and passive tripwire
|
||||
escalation (ADR-0018 §1, §6-7).
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_node.server import StubNodeServer
|
||||
from meshnet_validator import AdaptiveAuditSampler, AuditRateConfig, ValidatorProcess, detect_output_tripwire
|
||||
|
||||
MODEL = "stub-model"
|
||||
|
||||
|
||||
# ---- per-wallet base rate: newcomer high, veteran low, floor >= 2% ----
|
||||
|
||||
|
||||
def test_newcomer_gets_elevated_audit_rate():
|
||||
sampler = AdaptiveAuditSampler()
|
||||
rate = sampler.wallet_base_rate(completed_job_count=0, reputation=1.0)
|
||||
assert 0.20 <= rate <= 0.30
|
||||
|
||||
|
||||
def test_veteran_in_good_standing_floors_near_target():
|
||||
sampler = AdaptiveAuditSampler()
|
||||
rate = sampler.wallet_base_rate(completed_job_count=1000, reputation=1.0)
|
||||
assert rate == pytest.approx(0.02)
|
||||
|
||||
|
||||
def test_veteran_rate_never_drops_below_floor():
|
||||
sampler = AdaptiveAuditSampler(AuditRateConfig(veteran_floor=0.02))
|
||||
rate = sampler.wallet_base_rate(completed_job_count=10_000, reputation=1.0)
|
||||
assert rate >= 0.02
|
||||
|
||||
|
||||
def test_low_reputation_wallet_sampled_more_than_high_reputation_wallet():
|
||||
"""Red (test-first item 1): a uniform sampler ignores reputation. A
|
||||
low-reputation wallet must get a higher rate than a high-reputation one
|
||||
with the same tenure."""
|
||||
sampler = AdaptiveAuditSampler()
|
||||
low_rep_rate = sampler.sample_rate_for(completed_job_count=200, reputation=0.1)
|
||||
high_rep_rate = sampler.sample_rate_for(completed_job_count=200, reputation=1.0)
|
||||
assert low_rep_rate > high_rep_rate
|
||||
|
||||
|
||||
def test_low_reputation_escalates_even_for_a_tenured_wallet():
|
||||
sampler = AdaptiveAuditSampler()
|
||||
rate = sampler.wallet_base_rate(completed_job_count=1000, reputation=0.0)
|
||||
assert rate == pytest.approx(sampler.config.newcomer_rate)
|
||||
|
||||
|
||||
# ---- fleet-wide budget balance ----
|
||||
|
||||
|
||||
def test_fleet_wide_audit_rate_tracks_configured_target_within_one_point():
|
||||
"""Test-first item 2: over >=1000 requests with a fixed seed and a mixed
|
||||
wallet population, the measured fleet audit rate lands within +-1.0
|
||||
percentage point of the configured 5% target."""
|
||||
sampler = AdaptiveAuditSampler(random_seed=1234)
|
||||
rng = random.Random(99)
|
||||
|
||||
audited = 0
|
||||
total = 6000
|
||||
for _ in range(total):
|
||||
roll = rng.random()
|
||||
if roll < 0.7:
|
||||
wallet = dict(completed_job_count=800, reputation=1.0) # veteran
|
||||
elif roll < 0.9:
|
||||
wallet = dict(completed_job_count=150, reputation=0.9) # mid-tenure
|
||||
else:
|
||||
wallet = dict(completed_job_count=0, reputation=1.0) # newcomer
|
||||
if sampler.should_audit(**wallet):
|
||||
audited += 1
|
||||
|
||||
measured_rate = audited / total
|
||||
assert abs(measured_rate - sampler.config.target_rate) <= 0.01
|
||||
|
||||
|
||||
def test_fleet_wide_audit_rate_respects_custom_target():
|
||||
sampler = AdaptiveAuditSampler(AuditRateConfig(target_rate=0.10), random_seed=42)
|
||||
audited = sum(
|
||||
1
|
||||
for _ in range(2000)
|
||||
if sampler.should_audit(completed_job_count=800, reputation=1.0)
|
||||
)
|
||||
measured_rate = audited / 2000
|
||||
assert abs(measured_rate - 0.10) <= 0.01
|
||||
|
||||
|
||||
def test_sampling_is_deterministic_for_a_fixed_seed():
|
||||
sampler_a = AdaptiveAuditSampler(random_seed=7)
|
||||
sampler_b = AdaptiveAuditSampler(random_seed=7)
|
||||
decisions_a = [sampler_a.should_audit(completed_job_count=0, reputation=1.0) for _ in range(200)]
|
||||
decisions_b = [sampler_b.should_audit(completed_job_count=0, reputation=1.0) for _ in range(200)]
|
||||
assert decisions_a == decisions_b
|
||||
|
||||
|
||||
# ---- passive tripwires bump rate only ----
|
||||
|
||||
|
||||
def test_tripwire_flag_bumps_audit_rate_for_that_wallet():
|
||||
sampler = AdaptiveAuditSampler()
|
||||
normal_rate = sampler.sample_rate_for(completed_job_count=800, reputation=1.0, tripwire=False)
|
||||
flagged_rate = sampler.sample_rate_for(completed_job_count=800, reputation=1.0, tripwire=True)
|
||||
assert flagged_rate > normal_rate
|
||||
|
||||
|
||||
def test_tripwire_does_not_change_other_wallets_rate():
|
||||
"""A tripwire hit must never leak the multiplier into the shared
|
||||
budget-balance history -- only the wallet's un-boosted base rate is
|
||||
recorded, so a flagged decision affects the running budget scale exactly
|
||||
like a plain decision for the same wallet would, and never inflates or
|
||||
depresses everyone else's rate on top of that."""
|
||||
flagged = AdaptiveAuditSampler(random_seed=5)
|
||||
flagged.should_audit(completed_job_count=800, reputation=1.0, tripwire=True)
|
||||
|
||||
plain = AdaptiveAuditSampler(random_seed=5)
|
||||
plain.should_audit(completed_job_count=800, reputation=1.0, tripwire=False)
|
||||
|
||||
after_flagged = flagged.sample_rate_for(completed_job_count=800, reputation=1.0)
|
||||
after_plain = plain.sample_rate_for(completed_job_count=800, reputation=1.0)
|
||||
assert after_flagged == pytest.approx(after_plain)
|
||||
|
||||
|
||||
def test_detect_output_tripwire_flags_repetition_loop():
|
||||
degenerate = " ".join(["loop"] * 20)
|
||||
assert detect_output_tripwire(degenerate) is True
|
||||
|
||||
|
||||
def test_detect_output_tripwire_flags_empty_output():
|
||||
assert detect_output_tripwire("") is True
|
||||
|
||||
|
||||
def test_detect_output_tripwire_passes_normal_prose():
|
||||
normal = "The quick brown fox jumps over the lazy dog near the riverbank."
|
||||
assert detect_output_tripwire(normal) is False
|
||||
|
||||
|
||||
# ---- ValidatorProcess wiring: reputation-weighted sampling end to end ----
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference_node():
|
||||
node = StubNodeServer(shard_start=0, shard_end=31, is_last_shard=True)
|
||||
port = node.start()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
node.stop()
|
||||
|
||||
|
||||
def _record_event(contracts, session_id: str, wallet: str) -> None:
|
||||
contracts.validation.record_completed_inference(
|
||||
session_id=session_id,
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": "2+2"}],
|
||||
observed_output="4",
|
||||
route_nodes=[{"wallet_address": wallet, "shard_end": 31}],
|
||||
)
|
||||
|
||||
|
||||
def test_validator_uses_audit_sampler_when_configured(reference_node):
|
||||
"""A flagged low-reputation wallet gets audited far more often than a
|
||||
veteran in good standing when routed through the same validator."""
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_completed_jobs("wallet-veteran", 800)
|
||||
for _ in range(9):
|
||||
contracts.registry.record_audit_outcome("wallet-shady", passed=False) # reputation -> 0.0
|
||||
|
||||
trials = 400
|
||||
shady_audits = 0
|
||||
veteran_audits = 0
|
||||
for i in range(trials):
|
||||
sampler = AdaptiveAuditSampler(random_seed=i)
|
||||
validator = ValidatorProcess(
|
||||
contracts=contracts,
|
||||
reference_node_url=reference_node,
|
||||
audit_sampler=sampler,
|
||||
random_seed=i,
|
||||
)
|
||||
shady_audits += int(validator._should_sample(_FakeEvent("wallet-shady")))
|
||||
veteran_audits += int(validator._should_sample(_FakeEvent("wallet-veteran")))
|
||||
|
||||
assert shady_audits > veteran_audits
|
||||
|
||||
|
||||
class _FakeEvent:
|
||||
def __init__(self, wallet: str) -> None:
|
||||
self.route_nodes = [{"wallet_address": wallet, "shard_end": 31}]
|
||||
self.observed_output = "4"
|
||||
@@ -1,8 +1,8 @@
|
||||
"""US-031: billing ledger — per-token pricing, 90/10 split, pending balances.
|
||||
|
||||
Unit tests for BillingLedger math/persistence/replication, plus HTTP
|
||||
integration: 401 without an API key, billed 200 with one, 402 once the
|
||||
Caller Credit is exhausted (rejected before routing — no free work).
|
||||
integration: 401 without an API key, 402 for unfunded keys, billed 200 after
|
||||
explicit credit, and 402 once the balance is exhausted.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
@@ -15,8 +15,14 @@ import urllib.request
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.auth import sign_hive_request
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
from meshnet_tracker.billing import DEFAULT_STARTING_CREDIT, BillingLedger
|
||||
from meshnet_tracker.server import (
|
||||
TrackerServer,
|
||||
_billable_non_stream_tokens,
|
||||
_billable_stream_tokens,
|
||||
_estimate_prompt_tokens,
|
||||
_observed_stream_tokens,
|
||||
)
|
||||
|
||||
MODEL = "openai-community/gpt2"
|
||||
HIVE_SECRET = "test-hive-secret"
|
||||
@@ -35,6 +41,16 @@ def test_charge_single_node_gets_90_percent():
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_default_starting_credit_is_zero_and_fresh_key_is_unfunded():
|
||||
ledger = BillingLedger(default_price_per_1k=0.02)
|
||||
|
||||
assert DEFAULT_STARTING_CREDIT == 0.0
|
||||
assert ledger.ensure_client("fresh-key") == pytest.approx(0.0)
|
||||
assert ledger.get_client_balance("fresh-key") == pytest.approx(0.0)
|
||||
assert ledger.has_funds("fresh-key") is False
|
||||
assert ledger.events_since(0)[0] == []
|
||||
|
||||
|
||||
def test_charge_three_node_split_by_work_units():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
@@ -75,6 +91,28 @@ def test_per_model_price_override():
|
||||
assert event["cost"] == pytest.approx(0.02 * 500 / 1000)
|
||||
|
||||
|
||||
def test_non_stream_billable_tokens_cap_usage_by_request_bound():
|
||||
payload = {"usage": {"total_tokens": 1_000_000}}
|
||||
request = {"messages": [{"role": "user", "content": "hello tracker"}], "max_tokens": 3}
|
||||
|
||||
assert _estimate_prompt_tokens(request) == 2
|
||||
assert _billable_non_stream_tokens(payload, request) == 5
|
||||
|
||||
|
||||
def test_stream_billable_tokens_allow_usage_to_lower_observed_count_only():
|
||||
observed_payload = {
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": "hello tracker"},
|
||||
"finish_reason": None,
|
||||
}]
|
||||
}
|
||||
|
||||
assert _observed_stream_tokens(observed_payload) == 2
|
||||
assert _billable_stream_tokens(observed_tokens=2, reported_tokens=1_000_000) == 2
|
||||
assert _billable_stream_tokens(observed_tokens=2, reported_tokens=1) == 1
|
||||
|
||||
|
||||
def test_payout_and_forfeit_hooks():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
|
||||
@@ -155,8 +193,17 @@ def test_event_replication_converges_and_dedupes():
|
||||
class _UsageStubNode:
|
||||
"""Minimal head node returning a chat completion with real usage numbers."""
|
||||
|
||||
def __init__(self, total_tokens: int = 1000):
|
||||
def __init__(
|
||||
self,
|
||||
total_tokens: int = 1000,
|
||||
*,
|
||||
stream_chunks: list[str] | None = None,
|
||||
stream_usage_total: int | None = None,
|
||||
):
|
||||
self.total_tokens = total_tokens
|
||||
self.stream_chunks = stream_chunks or []
|
||||
self.stream_usage_total = stream_usage_total
|
||||
self.request_count = 0
|
||||
outer = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -164,8 +211,44 @@ class _UsageStubNode:
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
outer.request_count += 1
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
self.rfile.read(length)
|
||||
raw_body = self.rfile.read(length)
|
||||
try:
|
||||
request_body = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
request_body = {}
|
||||
if request_body.get("stream"):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.end_headers()
|
||||
for chunk in outer.stream_chunks:
|
||||
payload = json.dumps({
|
||||
"id": "chatcmpl-stub",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": chunk},
|
||||
"finish_reason": None,
|
||||
}],
|
||||
}).encode()
|
||||
self.wfile.write(b"data: " + payload + b"\n\n")
|
||||
if outer.stream_usage_total is not None:
|
||||
usage_payload = json.dumps({
|
||||
"id": "chatcmpl-stub",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": outer.stream_usage_total,
|
||||
"total_tokens": outer.stream_usage_total,
|
||||
},
|
||||
}).encode()
|
||||
self.wfile.write(b"data: " + usage_payload + b"\n\n")
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
return
|
||||
body = json.dumps({
|
||||
"id": "chatcmpl-stub",
|
||||
"object": "chat.completion",
|
||||
@@ -203,7 +286,7 @@ class _UsageStubNode:
|
||||
|
||||
@pytest.fixture
|
||||
def billed_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
@@ -239,17 +322,19 @@ def billed_tracker():
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
yield tracker_url, ledger
|
||||
yield tracker_url, ledger, stub
|
||||
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def _chat(tracker_url: str, api_key: str | None):
|
||||
data = json.dumps({
|
||||
def _chat(tracker_url: str, api_key: str | None, **body_overrides):
|
||||
body = {
|
||||
"model": MODEL,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}).encode()
|
||||
}
|
||||
body.update(body_overrides)
|
||||
data = json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -260,20 +345,32 @@ def _chat(tracker_url: str, api_key: str | None):
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
if body.get("stream"):
|
||||
return r.read().decode()
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def test_proxy_chat_requires_api_key_when_billing_enabled(billed_tracker):
|
||||
tracker_url, _ = billed_tracker
|
||||
tracker_url, _, _ = billed_tracker
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key=None)
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
def test_proxy_chat_402_for_fresh_key_before_routing(billed_tracker):
|
||||
tracker_url, ledger, stub = billed_tracker
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key="fresh-client")
|
||||
assert exc_info.value.code == 402
|
||||
assert ledger.get_client_balance("fresh-client") == pytest.approx(0.0)
|
||||
assert stub.request_count == 0
|
||||
|
||||
|
||||
def test_proxy_chat_bills_credited_client_and_credits_node(billed_tracker):
|
||||
tracker_url, ledger, _ = billed_tracker
|
||||
ledger.credit_client("client-1", 0.03, note="admin-credit")
|
||||
_chat(tracker_url, api_key="client-1")
|
||||
# 1000 tokens at 0.02/1K = 0.02 USDT; starting credit 0.03
|
||||
# 1000 tokens at 0.02/1K = 0.02 USDT; explicit credit 0.03
|
||||
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
|
||||
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
|
||||
|
||||
@@ -289,8 +386,128 @@ def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
|
||||
assert node_stats["sample_count_last_hour"] == 1
|
||||
|
||||
|
||||
def test_proxy_chat_caps_inflated_non_streaming_usage_by_request_bounds(billed_tracker):
|
||||
tracker_url, ledger, stub = billed_tracker
|
||||
stub.total_tokens = 1_000_000
|
||||
ledger.credit_client("bounded-client", 100.0, note="admin-credit")
|
||||
|
||||
_chat(tracker_url, api_key="bounded-client", max_tokens=2)
|
||||
|
||||
# messages=[{"content": "hi"}] has a local prompt estimate of one token, so
|
||||
# billable total is capped at max_tokens + prompt estimate, not node usage.
|
||||
expected_tokens = 3
|
||||
assert ledger.get_client_balance("bounded-client") == pytest.approx(100.0 - 0.02 * expected_tokens / 1000)
|
||||
events, _ = ledger.events_since(0)
|
||||
charge = next(event for event in events if event["type"] == "charge")
|
||||
assert charge["total_tokens"] == expected_tokens
|
||||
|
||||
|
||||
def test_proxy_chat_caps_inflated_streaming_usage_by_observed_chunks():
|
||||
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
|
||||
ledger.credit_client("stream-client", 1.0, note="admin-credit")
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
"layers_start": 0,
|
||||
"layers_end": 11,
|
||||
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
|
||||
}
|
||||
},
|
||||
billing=ledger,
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
stub = _UsageStubNode(stream_chunks=["one", "two"], stream_usage_total=1_000_000)
|
||||
stub_port = stub.start()
|
||||
try:
|
||||
reg = json.dumps({
|
||||
"endpoint": f"http://127.0.0.1:{stub_port}",
|
||||
"shard_start": 0,
|
||||
"shard_end": 11,
|
||||
"model": MODEL,
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
"tracker_mode": True,
|
||||
"wallet_address": "wallet-head",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/nodes/register",
|
||||
data=reg,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
_chat(tracker_url, api_key="stream-client", stream=True, max_tokens=2)
|
||||
|
||||
events, _ = ledger.events_since(0)
|
||||
charge = next(event for event in events if event["type"] == "charge")
|
||||
assert charge["total_tokens"] == 2
|
||||
assert ledger.get_client_balance("stream-client") == pytest.approx(1.0 - 0.02 * 2 / 1000)
|
||||
finally:
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_proxy_chat_splits_payout_by_tracker_assigned_route_span():
|
||||
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
|
||||
ledger.credit_client("route-client", 1.0, note="admin-credit")
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
"total_layers": 12,
|
||||
"bytes_per_layer": {"bfloat16": 1_000},
|
||||
}
|
||||
},
|
||||
billing=ledger,
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
head = _UsageStubNode(total_tokens=1000)
|
||||
head_port = head.start()
|
||||
try:
|
||||
for endpoint, wallet, vram, bench in (
|
||||
(f"http://127.0.0.1:{head_port}", "wallet-head", 5_000, 2.0),
|
||||
("http://127.0.0.1:1", "wallet-tail", 10_000, 1.0),
|
||||
):
|
||||
reg = json.dumps({
|
||||
"endpoint": endpoint,
|
||||
"model": MODEL,
|
||||
"vram_bytes": vram,
|
||||
"ram_bytes": 10_000,
|
||||
"quantizations": ["bfloat16"],
|
||||
"benchmark_tokens_per_sec": bench,
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
"tracker_mode": endpoint.endswith(str(head_port)),
|
||||
"wallet_address": wallet,
|
||||
"managed_assignment": True,
|
||||
"shard_start": 0,
|
||||
"shard_end": 999,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/nodes/register",
|
||||
data=reg,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
_chat(tracker_url, api_key="route-client", max_tokens=1000)
|
||||
|
||||
pool = 0.02 * 0.90
|
||||
assert ledger.get_node_pending("wallet-head") == pytest.approx(pool * 4 / 12)
|
||||
assert ledger.get_node_pending("wallet-tail") == pytest.approx(pool * 8 / 12)
|
||||
finally:
|
||||
head.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_proxy_chat_402_when_balance_exhausted(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
tracker_url, ledger, _ = billed_tracker
|
||||
ledger.credit_client("client-2", 0.03, note="admin-credit")
|
||||
_chat(tracker_url, api_key="client-2") # 0.03 -> 0.01
|
||||
_chat(tracker_url, api_key="client-2") # 0.01 -> -0.01 (post-pay drift)
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
@@ -301,8 +518,59 @@ def test_proxy_chat_402_when_balance_exhausted(billed_tracker):
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
|
||||
|
||||
def test_proxy_chat_rejects_request_above_spend_cap_before_routing():
|
||||
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
|
||||
ledger.credit_client("capped-client", 10.0, note="admin-credit")
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
"layers_start": 0,
|
||||
"layers_end": 11,
|
||||
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
|
||||
}
|
||||
},
|
||||
billing=ledger,
|
||||
max_charge_per_request=0.01,
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
stub = _UsageStubNode(total_tokens=1000)
|
||||
stub_port = stub.start()
|
||||
try:
|
||||
reg = json.dumps({
|
||||
"endpoint": f"http://127.0.0.1:{stub_port}",
|
||||
"shard_start": 0,
|
||||
"shard_end": 11,
|
||||
"model": MODEL,
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
"tracker_mode": True,
|
||||
"wallet_address": "wallet-head",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/nodes/register",
|
||||
data=reg,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key="capped-client", max_tokens=1000)
|
||||
body = json.loads(exc_info.value.read())
|
||||
assert exc_info.value.code == 402
|
||||
assert body["error"]["code"] == "spend_cap_exceeded"
|
||||
assert "max_charge_per_request" in body["error"]["message"]
|
||||
assert ledger.get_client_balance("capped-client") == pytest.approx(10.0)
|
||||
assert stub.request_count == 0
|
||||
finally:
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_billing_gossip_endpoint_applies_events(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
tracker_url, ledger, _ = billed_tracker
|
||||
peer = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
peer.credit_client("remote-client", 7.0)
|
||||
events, _ = peer.events_since(0)
|
||||
@@ -315,6 +583,5 @@ def test_billing_gossip_endpoint_applies_events(billed_tracker):
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert json.loads(r.read())["applied"] == len(events)
|
||||
# only the replicated credit event lands here — Caller Credit was granted
|
||||
# on the peer that first saw the key, and its event replicates separately
|
||||
# only the replicated credit event lands here.
|
||||
assert ledger.get_client_balance("remote-client") == pytest.approx(7.0)
|
||||
|
||||
@@ -13,9 +13,24 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
from meshnet_tracker.wallet_proof import binding_message
|
||||
|
||||
|
||||
def _keypair():
|
||||
"""Deterministic-enough local ed25519 keypair + base58 address for tests."""
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
from meshnet_node.wallet import _b58encode
|
||||
|
||||
public = priv.public_key().public_bytes_raw()
|
||||
return priv, _b58encode(public)
|
||||
|
||||
|
||||
def _sign(priv: Ed25519PrivateKey, api_key: str, wallet: str) -> str:
|
||||
return priv.sign(binding_message(api_key, wallet)).hex()
|
||||
|
||||
|
||||
class _FakeDeposit:
|
||||
@@ -78,14 +93,15 @@ def test_wallet_register_requires_auth(watched_tracker):
|
||||
|
||||
def test_deposit_credits_bound_api_key_exactly_once(watched_tracker):
|
||||
tracker_url, ledger, treasury = watched_tracker
|
||||
priv, wallet = _keypair()
|
||||
reply = _post_json(
|
||||
f"{tracker_url}/v1/wallet/register",
|
||||
{"wallet": "So1anaWa11et111"},
|
||||
{"wallet": wallet, "signature": _sign(priv, "client-key-1", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
assert reply["bound"] is True
|
||||
|
||||
treasury.deposits.append(_FakeDeposit("sig-1", "So1anaWa11et111", 25.0))
|
||||
treasury.deposits.append(_FakeDeposit("sig-1", wallet, 25.0))
|
||||
assert _wait_for(lambda: ledger.get_client_balance("client-key-1") > 0)
|
||||
assert ledger.get_client_balance("client-key-1") == pytest.approx(25.0)
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_node.server import StubNodeServer
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
from meshnet_validator import ValidatorProcess
|
||||
from meshnet_validator import ToplocAuditConfig, ValidatorProcess
|
||||
from meshnet_validator.audit import build_activation_proofs
|
||||
|
||||
MODEL = "stub-model"
|
||||
|
||||
@@ -261,3 +262,142 @@ def test_probation_earns_nothing_then_earning_begins():
|
||||
finally:
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
class _FakeToploc:
|
||||
"""Fingerprint = the activations themselves; verify is exact equality
|
||||
(same contract as tests/test_hop_bisection.py's fake backend)."""
|
||||
|
||||
def build_proofs_base64(self, activations, *, decode_batching_size, topk, skip_prefill):
|
||||
return {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
def verify_proofs_base64(self, activations, proofs, *, decode_batching_size, topk, skip_prefill):
|
||||
return proofs == {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
|
||||
class _HopReferenceValidator(ValidatorProcess):
|
||||
"""Stands in for the reference node: returns canned ground-truth
|
||||
activations at each hop cut-point instead of making an HTTP call."""
|
||||
|
||||
def __init__(self, *args, reference_activations_by_hop, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._reference_activations_by_hop = reference_activations_by_hop
|
||||
|
||||
def _run_teacher_forced_prefill_hops(self, **kwargs):
|
||||
return self._reference_activations_by_hop
|
||||
|
||||
|
||||
def _record_two_hop_event(
|
||||
contracts, session_id: str, *, hop0_activations, hop1_activations, config, backend,
|
||||
) -> None:
|
||||
"""Two-hop route (wallet-hop0 upstream, wallet-hop1 downstream), each hop
|
||||
carrying its own on-demand TOPLOC commitment (AH-007 bisection format)."""
|
||||
hop0_claim = build_activation_proofs(hop0_activations, config=config, backend=backend)
|
||||
hop1_claim = build_activation_proofs(hop1_activations, config=config, backend=backend)
|
||||
token_ids = [101, 202, 303]
|
||||
contracts.validation.record_completed_inference(
|
||||
session_id=session_id,
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": "2+2"}],
|
||||
observed_output="two-hop pipeline output",
|
||||
route_nodes=[
|
||||
{
|
||||
"wallet_address": "wallet-hop0",
|
||||
"shard_start": 0,
|
||||
"shard_end": 15,
|
||||
"toploc_proof": hop0_claim.as_mapping(),
|
||||
"claimed_token_ids": token_ids,
|
||||
},
|
||||
{
|
||||
"wallet_address": "wallet-hop1",
|
||||
"shard_start": 16,
|
||||
"shard_end": 31,
|
||||
"toploc_proof": hop1_claim.as_mapping(),
|
||||
"claimed_token_ids": token_ids,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_60_request_stream_bans_intermittent_first_hop_cheater_not_last_hop():
|
||||
"""Integration (AH-010): a 60-request stream through a two-hop route where
|
||||
the *first* hop (not the last) cheats on 3 of the jobs. TOPLOC bisection
|
||||
(issue 07) must blame wallet-hop0 specifically -- the old last-hop-only
|
||||
heuristic would have blamed the innocent wallet-hop1 instead. Each catch
|
||||
forfeits wallet-hop0's pending balance and strikes it in the same
|
||||
validation cycle; the third strike bans it within the 60-request stream,
|
||||
and the settlement loop's payables() then excludes only the banned wallet
|
||||
while the honest downstream hop keeps earning (ADR-0015/ADR-0018).
|
||||
"""
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
backend = _FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
corrupted_hop0 = [[9.0, 9.0], [9.0, 9.0]]
|
||||
cheat_at = {20, 40, 60}
|
||||
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("wallet-hop0", 500)
|
||||
contracts.registry.submit_stake("wallet-hop1", 500)
|
||||
ledger = BillingLedger(starting_credit=1000.0, default_price_per_1k=0.02)
|
||||
|
||||
validator = _HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
billing=ledger,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
strike_threshold=3,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
banned_at: int | None = None
|
||||
for i in range(1, 61):
|
||||
ledger.charge_request(
|
||||
"client", MODEL, 1000, [("wallet-hop0", 6), ("wallet-hop1", 6)],
|
||||
)
|
||||
_record_two_hop_event(
|
||||
contracts,
|
||||
f"sess-{i}",
|
||||
hop0_activations=corrupted_hop0 if i in cheat_at else reference_hop0,
|
||||
hop1_activations=reference_hop1,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
validator.validate_once()
|
||||
if banned_at is None and contracts.registry.get_wallet("wallet-hop0").banned:
|
||||
banned_at = i
|
||||
|
||||
assert banned_at is not None and banned_at <= 60
|
||||
cheater = contracts.registry.get_wallet("wallet-hop0")
|
||||
honest = contracts.registry.get_wallet("wallet-hop1")
|
||||
assert cheater.strike_count == 3
|
||||
assert cheater.banned
|
||||
assert not honest.banned
|
||||
# Every catch blamed the true (first, non-last) culprit hop -- never the
|
||||
# innocent downstream wallet.
|
||||
assert ledger.get_node_pending("wallet-hop0") == pytest.approx(0.0)
|
||||
assert ledger.get_node_pending("wallet-hop1") > 0.0
|
||||
|
||||
# Settlement-loop wiring (server.py `_settlement_loop`): banned wallets
|
||||
# are excluded from payables even though their (forfeited-to-zero)
|
||||
# balance would fail the dust floor anyway -- the honest hop is still due.
|
||||
banned_wallets = {
|
||||
wallet for wallet in ("wallet-hop0", "wallet-hop1")
|
||||
if contracts.registry.get_wallet(wallet).banned
|
||||
}
|
||||
due = dict(ledger.payables(threshold=0.0, max_period=0.0, dust_floor=0.0, exclude=banned_wallets))
|
||||
assert "wallet-hop0" not in due
|
||||
assert due.get("wallet-hop1", 0.0) > 0.0
|
||||
|
||||
279
tests/test_hop_bisection.py
Normal file
279
tests/test_hop_bisection.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""AH-007: on-demand hop-boundary commitments + first-divergent-hop bisection.
|
||||
|
||||
`_final_text_node` (blame `max(shard_end)`) is wrong for multi-hop pipelines:
|
||||
a corrupt early hop still produces a route whose *last* hop reported perfectly
|
||||
honest activations. These tests build a two-hop route, corrupt one hop's
|
||||
committed fingerprint, and assert the referee blames that hop specifically —
|
||||
not always the last one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshnet_validator import ToplocAuditConfig, ValidatorProcess
|
||||
from meshnet_validator.audit import build_activation_proofs
|
||||
|
||||
|
||||
class FakeToploc:
|
||||
"""Fingerprint = the activations themselves; verify is exact equality,
|
||||
matching the encode/verify contract exercised in test_toploc_audit.py."""
|
||||
|
||||
def build_proofs_base64(self, activations, *, decode_batching_size, topk, skip_prefill):
|
||||
return {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
def verify_proofs_base64(self, activations, proofs, *, decode_batching_size, topk, skip_prefill):
|
||||
return proofs == {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
|
||||
class FakeValidationLog:
|
||||
def __init__(self, events) -> None:
|
||||
self._events = events
|
||||
|
||||
def list_completed_inferences(self, *, after_index: int = -1):
|
||||
return [event for event in self._events if event.index > after_index]
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.slashes: list[dict] = []
|
||||
self.audit_outcomes: list[dict] = []
|
||||
|
||||
def get_wallet(self, wallet_address: str):
|
||||
return SimpleNamespace(banned=False)
|
||||
|
||||
def submit_slash_proof(self, **kwargs):
|
||||
self.slashes.append(kwargs)
|
||||
return kwargs
|
||||
|
||||
def record_audit_outcome(self, wallet_address: str, *, passed: bool):
|
||||
self.audit_outcomes.append({"wallet_address": wallet_address, "passed": passed})
|
||||
|
||||
|
||||
class FakeContracts:
|
||||
def __init__(self, events) -> None:
|
||||
self.validation = FakeValidationLog(events)
|
||||
self.registry = FakeRegistry()
|
||||
|
||||
|
||||
def _hop_route_event(*, index, hop0_claim_activations, hop1_claim_activations, config, backend, ts=None):
|
||||
"""Two-hop route where each hop carries its own TOPLOC commitment built
|
||||
from the given (possibly corrupted) activations."""
|
||||
hop0_claim = build_activation_proofs(hop0_claim_activations, config=config, backend=backend)
|
||||
hop1_claim = build_activation_proofs(hop1_claim_activations, config=config, backend=backend)
|
||||
route_nodes = [
|
||||
{
|
||||
"wallet_address": "wallet-hop0",
|
||||
"shard_start": 0,
|
||||
"shard_end": 15,
|
||||
"toploc_proof": hop0_claim.as_mapping(),
|
||||
},
|
||||
{
|
||||
"wallet_address": "wallet-hop1",
|
||||
"shard_start": 16,
|
||||
"shard_end": 31,
|
||||
"toploc_proof": hop1_claim.as_mapping(),
|
||||
},
|
||||
]
|
||||
event = SimpleNamespace(
|
||||
index=index,
|
||||
session_id=f"session-hop-{index}",
|
||||
model="Qwen2.5-0.5B-Instruct",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
observed_output="two-hop pipeline output",
|
||||
route_nodes=route_nodes,
|
||||
claimed_token_ids=[101, 202, 303],
|
||||
)
|
||||
if ts is not None:
|
||||
event.ts = ts
|
||||
return event
|
||||
|
||||
|
||||
class HopReferenceValidator(ValidatorProcess):
|
||||
"""Stands in for the reference node: returns canned ground-truth
|
||||
activations at each hop cut-point instead of making an HTTP call."""
|
||||
|
||||
def __init__(self, *args, reference_activations_by_hop, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.reference_activations_by_hop = reference_activations_by_hop
|
||||
self.hops_calls: list[dict] = []
|
||||
self.text_reference_calls: list[list[dict]] = []
|
||||
|
||||
def _run_teacher_forced_prefill_hops(self, **kwargs):
|
||||
self.hops_calls.append(kwargs)
|
||||
return self.reference_activations_by_hop
|
||||
|
||||
def _run_reference(self, messages: list[dict]) -> str:
|
||||
self.text_reference_calls.append(messages)
|
||||
return "two-hop pipeline output"
|
||||
|
||||
|
||||
def test_bisection_blames_first_divergent_hop_not_last_hop():
|
||||
"""Red: corrupt hop-0 only. The old `_final_text_node` bug blames
|
||||
max(shard_end) == hop-1 (wallet-hop1), which is innocent here."""
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
backend = FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
corrupted_hop0_claim_activations = [[9.0, 9.0], [9.0, 9.0]] # diverges from reference_hop0
|
||||
|
||||
event = _hop_route_event(
|
||||
index=0,
|
||||
hop0_claim_activations=corrupted_hop0_claim_activations,
|
||||
hop1_claim_activations=reference_hop1,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
receipts = validator.validate_once()
|
||||
|
||||
assert len(receipts) == 1
|
||||
assert contracts.registry.slashes[0]["wallet_address"] == "wallet-hop0"
|
||||
assert "hop 0" in contracts.registry.slashes[0]["reason"]
|
||||
|
||||
|
||||
def test_bisection_blames_the_actual_faulty_hop_when_it_is_the_second_hop():
|
||||
"""Integration test: multi-hop pipeline, fault injected at a known
|
||||
(non-first) hop — proves blame follows the real culprit, not a fixed index."""
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
backend = FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
corrupted_hop1_claim_activations = [[0.0, 0.0], [0.0, 0.0]]
|
||||
|
||||
event = _hop_route_event(
|
||||
index=0,
|
||||
hop0_claim_activations=reference_hop0,
|
||||
hop1_claim_activations=corrupted_hop1_claim_activations,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
receipts = validator.validate_once()
|
||||
|
||||
assert len(receipts) == 1
|
||||
assert contracts.registry.slashes[0]["wallet_address"] == "wallet-hop1"
|
||||
|
||||
|
||||
def test_honest_two_hop_route_is_not_slashed():
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
backend = FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
|
||||
event = _hop_route_event(
|
||||
index=0,
|
||||
hop0_claim_activations=reference_hop0,
|
||||
hop1_claim_activations=reference_hop1,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
assert validator.validate_once() == []
|
||||
assert contracts.registry.slashes == []
|
||||
|
||||
|
||||
def test_expired_commitment_window_falls_back_to_text_only_audit():
|
||||
"""ADR-0018 §3: nodes only retain boundary activations briefly. Once the
|
||||
on-demand TTL has passed, bisection can't be verified — the validator must
|
||||
fall back to the text-comparison path instead of erroring out."""
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16, commitment_ttl_seconds=1.0)
|
||||
backend = FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
|
||||
event = _hop_route_event(
|
||||
index=0,
|
||||
hop0_claim_activations=reference_hop0,
|
||||
hop1_claim_activations=reference_hop1,
|
||||
config=config,
|
||||
backend=backend,
|
||||
ts=0.0, # far outside any TTL relative to real wall-clock time
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
assert validator.validate_once() == []
|
||||
assert validator.hops_calls == []
|
||||
assert len(validator.text_reference_calls) == 1
|
||||
|
||||
|
||||
def test_hop_commitments_are_not_requested_unless_the_event_is_audit_selected():
|
||||
"""On-demand: the (expensive) per-hop commitment retrieval only happens
|
||||
for events the tracker RNG actually selects for audit — sample_rate is
|
||||
the selection gate, and a miss must not touch the reference node at all."""
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
backend = FakeToploc()
|
||||
reference_hop0 = [[1.0, 2.0], [3.0, 4.0]]
|
||||
reference_hop1 = [[5.0, 6.0], [7.0, 8.0]]
|
||||
|
||||
event = _hop_route_event(
|
||||
index=0,
|
||||
hop0_claim_activations=reference_hop0,
|
||||
hop1_claim_activations=reference_hop1,
|
||||
config=config,
|
||||
backend=backend,
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = HopReferenceValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=0.0, # never selects any event for audit
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=backend,
|
||||
reference_activations_by_hop=[reference_hop0, reference_hop1],
|
||||
)
|
||||
|
||||
assert validator.validate_once() == []
|
||||
assert validator.hops_calls == []
|
||||
assert validator.text_reference_calls == []
|
||||
assert validator.sampled_count == 0
|
||||
227
tests/test_reputation_scoring.py
Normal file
227
tests/test_reputation_scoring.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Issue 08 (FRAUD: reputation model): scoring rules on top of the persisted
|
||||
reputation/strike/ban fields from issue 05 (ADR-0018 §6).
|
||||
|
||||
Score derives only from tracker audit outcomes + uptime/latency: slow build,
|
||||
instant loss, inactivity decay. Strikes apply a ×0.8 routing multiplier per
|
||||
strike, separate from the (full) forfeiture penalty owned by issue 10.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_contracts import (
|
||||
REPUTATION_CLEAN_AUDIT_DELTA,
|
||||
REPUTATION_FAILED_AUDIT_DELTA,
|
||||
LocalSolanaContracts,
|
||||
)
|
||||
from meshnet_node.server import StubNodeServer
|
||||
from meshnet_validator import ValidatorProcess
|
||||
|
||||
MODEL = "stub-model"
|
||||
|
||||
|
||||
# ---- documented deltas, applied via the persisted issue-05 fields ----
|
||||
|
||||
|
||||
def test_clean_audit_increases_reputation_by_documented_delta():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_audit_outcome("wallet-a", passed=False) # 1.0 -> 0.7
|
||||
before = contracts.registry.get_wallet("wallet-a").reputation
|
||||
|
||||
contracts.registry.record_audit_outcome("wallet-a", passed=True)
|
||||
|
||||
after = contracts.registry.get_wallet("wallet-a").reputation
|
||||
assert after == pytest.approx(before + REPUTATION_CLEAN_AUDIT_DELTA)
|
||||
|
||||
|
||||
def test_failed_audit_decreases_reputation_by_documented_delta():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_audit_outcome("wallet-b", passed=False)
|
||||
assert contracts.registry.get_wallet("wallet-b").reputation == pytest.approx(
|
||||
1.0 + REPUTATION_FAILED_AUDIT_DELTA
|
||||
)
|
||||
|
||||
|
||||
def test_reputation_clamps_to_documented_bounds():
|
||||
contracts = LocalSolanaContracts()
|
||||
for _ in range(10):
|
||||
contracts.registry.record_audit_outcome("wallet-c", passed=False)
|
||||
assert contracts.registry.get_wallet("wallet-c").reputation == 0.0
|
||||
|
||||
for _ in range(30):
|
||||
contracts.registry.record_audit_outcome("wallet-c", passed=True)
|
||||
assert contracts.registry.get_wallet("wallet-c").reputation == 1.0
|
||||
|
||||
|
||||
def test_reputation_uses_persisted_fields_not_a_second_schema(tmp_path):
|
||||
"""Red (issue 08 test-first item 1): reputation deltas must persist and
|
||||
reload through the same event log issue 05 already wired — no parallel
|
||||
storage path."""
|
||||
db = str(tmp_path / "registry.sqlite")
|
||||
contracts = LocalSolanaContracts(registry_db=db)
|
||||
contracts.registry.record_audit_outcome("wallet-d", passed=False)
|
||||
contracts.save_to_db()
|
||||
|
||||
reopened = LocalSolanaContracts(registry_db=db)
|
||||
wallet = reopened.registry.get_wallet("wallet-d")
|
||||
assert wallet.reputation == pytest.approx(1.0 + REPUTATION_FAILED_AUDIT_DELTA)
|
||||
assert wallet.strike_count == 0 # audit_outcome never strikes on its own
|
||||
|
||||
|
||||
# ---- strike -> routing multiplier, separate from forfeiture ----
|
||||
|
||||
|
||||
def test_strike_applies_graduated_routing_multiplier_not_full_penalty():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("wallet-e", 500)
|
||||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(1.0)
|
||||
|
||||
contracts.registry.record_strike("wallet-e", ban_threshold=10)
|
||||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(0.8)
|
||||
|
||||
contracts.registry.record_strike("wallet-e", ban_threshold=10)
|
||||
assert contracts.registry.routing_multiplier("wallet-e") == pytest.approx(0.8 * 0.8)
|
||||
# stake (forfeiture surface) is untouched by strikes alone
|
||||
assert contracts.registry.get_wallet("wallet-e").stake_balance == 500
|
||||
|
||||
|
||||
def test_three_strikes_still_bans_and_probation_still_enforced():
|
||||
contracts = LocalSolanaContracts(probationary_job_count=2)
|
||||
for _ in range(3):
|
||||
contracts.registry.record_strike("wallet-f")
|
||||
wallet = contracts.registry.get_wallet("wallet-f")
|
||||
assert wallet.strike_count == 3
|
||||
assert wallet.banned is True
|
||||
|
||||
contracts.registry.record_completed_job("wallet-g")
|
||||
assert contracts.registry.probationary_jobs_remaining("wallet-g") == 1
|
||||
|
||||
|
||||
# ---- inactivity decay ----
|
||||
|
||||
|
||||
def test_inactivity_decay_after_idle_days_without_completed_jobs():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_completed_jobs("wallet-h", 1, ts=0.0)
|
||||
|
||||
idle_seconds = 30 * 86400.0
|
||||
decayed = contracts.registry.apply_inactivity_decay(
|
||||
idle_seconds=idle_seconds, decay_amount=0.05, now=idle_seconds + 1.0,
|
||||
)
|
||||
|
||||
assert "wallet-h" in decayed
|
||||
assert contracts.registry.get_wallet("wallet-h").reputation == pytest.approx(0.95)
|
||||
|
||||
|
||||
def test_inactivity_decay_skips_wallets_active_within_the_window():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_completed_jobs("wallet-i", 1, ts=1000.0)
|
||||
|
||||
decayed = contracts.registry.apply_inactivity_decay(
|
||||
idle_seconds=30 * 86400.0, now=1000.0 + 60.0,
|
||||
)
|
||||
|
||||
assert decayed == {}
|
||||
assert contracts.registry.get_wallet("wallet-i").reputation == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_inactivity_decay_applies_at_most_once_per_idle_window():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_completed_jobs("wallet-j", 1, ts=0.0)
|
||||
idle_seconds = 30 * 86400.0
|
||||
|
||||
contracts.registry.apply_inactivity_decay(idle_seconds=idle_seconds, now=idle_seconds + 1.0)
|
||||
again = contracts.registry.apply_inactivity_decay(idle_seconds=idle_seconds, now=idle_seconds + 2.0)
|
||||
|
||||
assert again == {}
|
||||
assert contracts.registry.get_wallet("wallet-j").reputation == pytest.approx(0.95)
|
||||
|
||||
|
||||
def test_inactivity_decay_skips_banned_wallets():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_completed_jobs("wallet-k", 1, ts=0.0)
|
||||
contracts.registry.ban_wallet("wallet-k")
|
||||
|
||||
decayed = contracts.registry.apply_inactivity_decay(now=30 * 86400.0 + 1.0)
|
||||
|
||||
assert decayed == {}
|
||||
|
||||
|
||||
# ---- no peer-to-peer reputation inputs ----
|
||||
|
||||
|
||||
def test_registry_contract_has_no_peer_rating_api():
|
||||
contracts = LocalSolanaContracts()
|
||||
for forbidden in ("rate_peer", "submit_peer_rating", "peer_reputation"):
|
||||
assert not hasattr(contracts.registry, forbidden)
|
||||
|
||||
|
||||
# ---- validator wiring: audit outcomes feed reputation end-to-end ----
|
||||
|
||||
|
||||
def _record_event(contracts, session_id: str, output: str, wallet: str) -> None:
|
||||
contracts.validation.record_completed_inference(
|
||||
session_id=session_id,
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": "2+2"}],
|
||||
observed_output=output,
|
||||
route_nodes=[{"wallet_address": wallet, "shard_end": 31}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference_node():
|
||||
node = StubNodeServer(shard_start=0, shard_end=31, is_last_shard=True)
|
||||
port = node.start()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
node.stop()
|
||||
|
||||
|
||||
def _reference_output(reference_url: str) -> str:
|
||||
req = urllib.request.Request(
|
||||
f"{reference_url}/v1/infer",
|
||||
data=json.dumps({"messages": [{"role": "user", "content": "2+2"}]}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())["text"]
|
||||
|
||||
|
||||
def test_validator_credits_clean_audit_via_persisted_reputation(reference_node):
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_audit_outcome("wallet-good", passed=False) # 1.0 -> 0.7
|
||||
validator = ValidatorProcess(
|
||||
contracts=contracts, reference_node_url=reference_node, sample_rate=1.0, random_seed=7,
|
||||
)
|
||||
_record_event(contracts, "sess-clean", _reference_output(reference_node), "wallet-good")
|
||||
|
||||
validator.validate_once()
|
||||
|
||||
assert contracts.registry.get_wallet("wallet-good").reputation == pytest.approx(
|
||||
0.7 + REPUTATION_CLEAN_AUDIT_DELTA
|
||||
)
|
||||
|
||||
|
||||
def test_validator_docks_reputation_on_failed_audit_without_double_striking(reference_node):
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("wallet-bad", 500)
|
||||
validator = ValidatorProcess(
|
||||
contracts=contracts,
|
||||
reference_node_url=reference_node,
|
||||
sample_rate=1.0,
|
||||
slash_amount=100,
|
||||
strike_threshold=3,
|
||||
random_seed=7,
|
||||
)
|
||||
_record_event(contracts, "sess-fraud", "fraudulent nonsense", "wallet-bad")
|
||||
|
||||
validator.validate_once()
|
||||
|
||||
wallet = contracts.registry.get_wallet("wallet-bad")
|
||||
assert wallet.reputation == pytest.approx(1.0 + REPUTATION_FAILED_AUDIT_DELTA)
|
||||
# exactly one strike from submit_slash_proof — record_audit_outcome must
|
||||
# not add a second
|
||||
assert wallet.strike_count == 1
|
||||
201
tests/test_toploc_audit.py
Normal file
201
tests/test_toploc_audit.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""AH-006: validator TOPLOC audit primitive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshnet_validator import ToplocAuditConfig, ValidatorProcess
|
||||
from meshnet_validator.audit import build_activation_proofs, verify_activation_proofs
|
||||
|
||||
|
||||
class FakeToploc:
|
||||
def __init__(self) -> None:
|
||||
self.build_calls: list[dict] = []
|
||||
self.verify_calls: list[dict] = []
|
||||
|
||||
def build_proofs_base64(
|
||||
self,
|
||||
activations,
|
||||
*,
|
||||
decode_batching_size: int,
|
||||
topk: int,
|
||||
skip_prefill: bool,
|
||||
):
|
||||
self.build_calls.append({
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
})
|
||||
return {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
def verify_proofs_base64(
|
||||
self,
|
||||
activations,
|
||||
proofs,
|
||||
*,
|
||||
decode_batching_size: int,
|
||||
topk: int,
|
||||
skip_prefill: bool,
|
||||
):
|
||||
self.verify_calls.append({
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
})
|
||||
return proofs == {
|
||||
"activation_fingerprint": tuple(tuple(row) for row in activations),
|
||||
"decode_batching_size": decode_batching_size,
|
||||
"topk": topk,
|
||||
"skip_prefill": skip_prefill,
|
||||
}
|
||||
|
||||
|
||||
class FakeValidationLog:
|
||||
def __init__(self, events) -> None:
|
||||
self._events = events
|
||||
|
||||
def list_completed_inferences(self, *, after_index: int = -1):
|
||||
return [event for event in self._events if event.index > after_index]
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.slashes: list[dict] = []
|
||||
self.audit_outcomes: list[dict] = []
|
||||
|
||||
def get_wallet(self, wallet_address: str):
|
||||
return SimpleNamespace(banned=False)
|
||||
|
||||
def submit_slash_proof(self, **kwargs):
|
||||
self.slashes.append(kwargs)
|
||||
return kwargs
|
||||
|
||||
def record_audit_outcome(self, wallet_address: str, *, passed: bool):
|
||||
self.audit_outcomes.append({"wallet_address": wallet_address, "passed": passed})
|
||||
|
||||
|
||||
class FakeContracts:
|
||||
def __init__(self, events) -> None:
|
||||
self.validation = FakeValidationLog(events)
|
||||
self.registry = FakeRegistry()
|
||||
|
||||
|
||||
class TeacherForcedValidator(ValidatorProcess):
|
||||
def __init__(self, *args, reference_activations, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.reference_activations = reference_activations
|
||||
self.teacher_forced_calls: list[dict] = []
|
||||
|
||||
def _run_reference(self, messages: list[dict]) -> str:
|
||||
raise AssertionError("TOPLOC audits must not free-generate reference text")
|
||||
|
||||
def _run_teacher_forced_prefill(self, **kwargs):
|
||||
self.teacher_forced_calls.append(kwargs)
|
||||
return self.reference_activations
|
||||
|
||||
|
||||
def test_stub_activation_tensors_round_trip_through_toploc_proofs():
|
||||
fake_toploc = FakeToploc()
|
||||
activations = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
|
||||
claim = build_activation_proofs(activations, config=config, backend=fake_toploc)
|
||||
|
||||
assert verify_activation_proofs(activations, claim, config=config, backend=fake_toploc) is True
|
||||
assert fake_toploc.build_calls == [{
|
||||
"decode_batching_size": 16,
|
||||
"topk": 2,
|
||||
"skip_prefill": True,
|
||||
}]
|
||||
assert fake_toploc.verify_calls == [{
|
||||
"decode_batching_size": 16,
|
||||
"topk": 2,
|
||||
"skip_prefill": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_validator_teacher_forces_claimed_tokens_for_toploc_audit():
|
||||
fake_toploc = FakeToploc()
|
||||
activations = [[0.25, 0.5], [0.75, 1.0]]
|
||||
config = ToplocAuditConfig(topk=2, decode_batching_size=16)
|
||||
claim = build_activation_proofs(activations, config=config, backend=fake_toploc)
|
||||
event = SimpleNamespace(
|
||||
index=0,
|
||||
session_id="session-toploc-ok",
|
||||
model="Qwen2.5-0.5B-Instruct",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
observed_output="honest but hardware-divergent text",
|
||||
route_nodes=[{"wallet_address": "wallet-good", "shard_end": 31}],
|
||||
claimed_token_ids=[101, 202, 303],
|
||||
toploc_proof=claim.as_mapping(),
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = TeacherForcedValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=config,
|
||||
toploc_backend=fake_toploc,
|
||||
reference_activations=activations,
|
||||
)
|
||||
|
||||
assert validator.validate_once() == []
|
||||
assert contracts.registry.slashes == []
|
||||
assert validator.teacher_forced_calls == [{
|
||||
"model": "Qwen2.5-0.5B-Instruct",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"claimed_token_ids": [101, 202, 303],
|
||||
"claim": claim,
|
||||
}]
|
||||
|
||||
|
||||
def test_validator_rejects_swapped_precision_toploc_claim():
|
||||
fake_toploc = FakeToploc()
|
||||
activations = [[0.25, 0.5], [0.75, 1.0]]
|
||||
canonical = ToplocAuditConfig(
|
||||
dtype="bfloat16",
|
||||
quantization="bfloat16",
|
||||
topk=2,
|
||||
decode_batching_size=16,
|
||||
)
|
||||
swapped = ToplocAuditConfig(
|
||||
dtype="bfloat16",
|
||||
quantization="int8",
|
||||
topk=2,
|
||||
decode_batching_size=16,
|
||||
)
|
||||
claim = build_activation_proofs(activations, config=swapped, backend=fake_toploc)
|
||||
event = SimpleNamespace(
|
||||
index=0,
|
||||
session_id="session-toploc-bad",
|
||||
model="Qwen2.5-0.5B-Instruct",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
observed_output="looks plausible",
|
||||
route_nodes=[{"wallet_address": "wallet-bad", "shard_end": 31}],
|
||||
audit={
|
||||
"claimed_token_ids": [101, 202, 303],
|
||||
"toploc_proof": claim.as_mapping(),
|
||||
},
|
||||
)
|
||||
contracts = FakeContracts([event])
|
||||
validator = TeacherForcedValidator(
|
||||
contracts=contracts,
|
||||
reference_node_url="http://reference.invalid",
|
||||
sample_rate=1.0,
|
||||
random_seed=7,
|
||||
toploc_config=canonical,
|
||||
toploc_backend=fake_toploc,
|
||||
reference_activations=activations,
|
||||
)
|
||||
|
||||
receipts = validator.validate_once()
|
||||
|
||||
assert len(receipts) == 1
|
||||
assert contracts.registry.slashes[0]["wallet_address"] == "wallet-bad"
|
||||
assert "TOPLOC activation proof mismatch" in contracts.registry.slashes[0]["reason"]
|
||||
@@ -1592,14 +1592,14 @@ def test_stats_gossip_endpoint_merges_peer_slice():
|
||||
|
||||
# ---------------------------------------------------------------- US-027 / US-028 routing
|
||||
|
||||
def _make_node(node_id, shard_start, shard_end, bench=1.0, queue_depth=0):
|
||||
def _make_node(node_id, shard_start, shard_end, bench=1.0, queue_depth=0, wallet_address=None):
|
||||
"""Helper: build a _NodeEntry with minimal fields."""
|
||||
from meshnet_tracker.server import _NodeEntry
|
||||
n = _NodeEntry(
|
||||
node_id=node_id, endpoint=f"http://fake:{9000 + shard_start}",
|
||||
shard_start=shard_start, shard_end=shard_end,
|
||||
model="m", shard_checksum=None, hardware_profile={},
|
||||
wallet_address=None, score=1.0,
|
||||
wallet_address=wallet_address, score=1.0,
|
||||
benchmark_tokens_per_sec=bench,
|
||||
)
|
||||
n.queue_depth = queue_depth
|
||||
@@ -1683,6 +1683,49 @@ def test_select_route_throughput_accounts_for_queue_depth():
|
||||
assert route[0].node_id in ("busy", "idle")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- AH-009 reputation-weighted routing
|
||||
|
||||
|
||||
def test_select_route_prefers_higher_reputation_when_throughput_equal():
|
||||
"""AH-009 test-first item 3: among candidates that advance coverage
|
||||
equally with the same effective throughput, the higher-reputation
|
||||
wallet's node wins the tiebreak (ADR-0018 §6 -- earnings scale with
|
||||
tenure/standing)."""
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.server import _select_route
|
||||
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_audit_outcome("wallet-veteran", passed=False) # 1.0 -> 0.7
|
||||
# wallet-fresh keeps the default reputation of 1.0
|
||||
|
||||
veteran = _make_node("veteran", 0, 11, bench=10.0, wallet_address="wallet-veteran")
|
||||
fresh = _make_node("fresh", 0, 11, bench=10.0, wallet_address="wallet-fresh")
|
||||
|
||||
route, err = _select_route([veteran, fresh], 0, 11, contracts=contracts)
|
||||
assert err == ""
|
||||
assert route[0].node_id == "fresh"
|
||||
|
||||
|
||||
def test_select_route_reputation_never_overrides_coverage():
|
||||
"""A lower-reputation node that covers more layers still wins -- the
|
||||
reputation multiplier is a tiebreak among equal-coverage candidates,
|
||||
never a substitute for coverage maximization."""
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.server import _select_route
|
||||
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.record_audit_outcome("wallet-shady", passed=False)
|
||||
for _ in range(9):
|
||||
contracts.registry.record_audit_outcome("wallet-shady", passed=False) # reputation -> 0.0
|
||||
|
||||
wide_shady = _make_node("wide-shady", 0, 11, bench=1.0, wallet_address="wallet-shady")
|
||||
narrow_good = _make_node("narrow-good", 0, 5, bench=1.0, wallet_address="wallet-good")
|
||||
|
||||
route, err = _select_route([wide_shady, narrow_good], 0, 11, contracts=contracts)
|
||||
assert err == ""
|
||||
assert [n.node_id for n in route] == ["wide-shady"]
|
||||
|
||||
|
||||
def test_two_stub_nodes_complete_pipeline_via_tracker():
|
||||
"""Integration: two StubNodeServer instances serving complementary shards
|
||||
produce a full inference response through the tracker route."""
|
||||
|
||||
182
tests/test_wallet_binding_proof.py
Normal file
182
tests/test_wallet_binding_proof.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""C6: wallet binding requires proof of ownership, and rebinding is safe.
|
||||
|
||||
Any Bearer key could previously bind any wallet string with no proof of
|
||||
ownership, and gossip `bind` events overwrote an existing binding directly.
|
||||
These tests cover: signature-gated binding, rebind protection (with an
|
||||
admin-override escape hatch), and gossip-safe conflict handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from meshnet_node.wallet import _b58encode
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
from meshnet_tracker.wallet_proof import binding_message, verify_wallet_signature
|
||||
|
||||
HIVE_SECRET = "test-hive-secret"
|
||||
|
||||
|
||||
def _keypair():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
return priv, _b58encode(priv.public_key().public_bytes_raw())
|
||||
|
||||
|
||||
def _sign(priv, api_key: str, wallet: str) -> str:
|
||||
return priv.sign(binding_message(api_key, wallet)).hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_verify_wallet_signature_accepts_valid_and_rejects_forged():
|
||||
priv, wallet = _keypair()
|
||||
other_priv, _ = _keypair()
|
||||
message = binding_message("client-key-1", wallet)
|
||||
|
||||
assert verify_wallet_signature(wallet, message, priv.sign(message).hex())
|
||||
# signed by a different key entirely
|
||||
assert not verify_wallet_signature(wallet, message, other_priv.sign(message).hex())
|
||||
# signature for a different api_key can't be replayed
|
||||
other_message = binding_message("client-key-2", wallet)
|
||||
assert not verify_wallet_signature(wallet, other_message, priv.sign(message).hex())
|
||||
assert not verify_wallet_signature(wallet, message, "not-hex")
|
||||
|
||||
|
||||
def test_bind_wallet_rejects_conflicting_rebind_without_admin_override():
|
||||
ledger = BillingLedger(starting_credit=0.0)
|
||||
ledger.bind_wallet("key-1", "WalletA")
|
||||
assert ledger.api_key_for_wallet("WalletA") == "key-1"
|
||||
|
||||
event = ledger.bind_wallet("key-2", "WalletA")
|
||||
assert event.get("rejected") is True
|
||||
assert ledger.api_key_for_wallet("WalletA") == "key-1" # unchanged
|
||||
|
||||
override = ledger.bind_wallet("key-2", "WalletA", admin_override=True)
|
||||
assert not override.get("rejected")
|
||||
assert ledger.api_key_for_wallet("WalletA") == "key-2"
|
||||
|
||||
|
||||
def test_gossip_bind_event_cannot_overwrite_existing_binding():
|
||||
"""A conflicting `bind` event applied via gossip must not clobber."""
|
||||
leader = BillingLedger(starting_credit=0.0)
|
||||
leader.bind_wallet("key-1", "WalletA")
|
||||
|
||||
follower = BillingLedger(starting_credit=0.0)
|
||||
follower.bind_wallet("key-9", "WalletA") # follower already has its own binding
|
||||
|
||||
events, _ = leader.events_since(0)
|
||||
applied = follower.apply_events(events)
|
||||
assert applied == 1 # event was processed (and rejected), not dropped/ignored
|
||||
# the follower's pre-existing binding is untouched by the conflicting gossip event
|
||||
assert follower.api_key_for_wallet("WalletA") == "key-9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker():
|
||||
ledger = BillingLedger(starting_credit=0.0)
|
||||
server = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET)
|
||||
port = server.start()
|
||||
yield f"http://127.0.0.1:{port}", ledger
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_bind_without_signature_is_rejected(tracker):
|
||||
url, ledger = tracker
|
||||
_, wallet = _keypair()
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
assert ledger.api_key_for_wallet(wallet) is None
|
||||
|
||||
|
||||
def test_bind_with_forged_signature_is_rejected(tracker):
|
||||
url, ledger = tracker
|
||||
priv, wallet = _keypair()
|
||||
forger_priv, _ = _keypair()
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "signature": _sign(forger_priv, "client-key-1", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
assert exc_info.value.code == 401
|
||||
assert ledger.api_key_for_wallet(wallet) is None
|
||||
|
||||
|
||||
def test_valid_signature_binds_wallet(tracker):
|
||||
url, ledger = tracker
|
||||
priv, wallet = _keypair()
|
||||
reply = _post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "signature": _sign(priv, "client-key-1", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
assert reply == {"wallet": wallet, "bound": True}
|
||||
assert ledger.api_key_for_wallet(wallet) == "client-key-1"
|
||||
|
||||
|
||||
def test_second_key_cannot_steal_bound_wallet(tracker):
|
||||
url, ledger = tracker
|
||||
priv, wallet = _keypair()
|
||||
_post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "signature": _sign(priv, "client-key-1", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
|
||||
# key-2 gets the wallet owner's genuine signature over *their* api_key,
|
||||
# proving they control the private key — still must not steal the binding.
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "signature": _sign(priv, "client-key-2", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-2"},
|
||||
)
|
||||
assert exc_info.value.code == 409
|
||||
assert ledger.api_key_for_wallet(wallet) == "client-key-1"
|
||||
|
||||
|
||||
def test_admin_session_can_force_rebind(tracker):
|
||||
url, ledger = tracker
|
||||
priv, wallet = _keypair()
|
||||
_post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "signature": _sign(priv, "client-key-1", wallet)},
|
||||
headers={"Authorization": "Bearer client-key-1"},
|
||||
)
|
||||
|
||||
admin = _post_json(
|
||||
f"{url}/v1/auth/register",
|
||||
{"email": "admin@example.com", "password": "secret-123"},
|
||||
)
|
||||
reply = _post_json(
|
||||
f"{url}/v1/wallet/register",
|
||||
{"wallet": wallet, "api_key": "client-key-2"},
|
||||
headers={"Authorization": f"Bearer {admin['session_token']}"},
|
||||
)
|
||||
assert reply == {"wallet": wallet, "bound": True}
|
||||
assert ledger.api_key_for_wallet(wallet) == "client-key-2"
|
||||
Reference in New Issue
Block a user