148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""US-007 integration tests: optimistic fraud detection and slashing."""
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from meshnet_contracts import LocalSolanaContracts
|
|
from meshnet_gateway.server import GatewayServer
|
|
from meshnet_node.server import StubNodeServer
|
|
from meshnet_tracker.server import TrackerServer
|
|
from meshnet_validator import ValidatorProcess
|
|
|
|
|
|
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 resp:
|
|
return json.loads(resp.read())
|
|
|
|
|
|
def _register_node(
|
|
tracker_url: str,
|
|
endpoint: str,
|
|
shard_start: int,
|
|
shard_end: int,
|
|
wallet_address: str,
|
|
) -> None:
|
|
_post_json(
|
|
f"{tracker_url}/v1/nodes/register",
|
|
{
|
|
"endpoint": endpoint,
|
|
"shard_start": shard_start,
|
|
"shard_end": shard_end,
|
|
"hardware_profile": {},
|
|
"wallet_address": wallet_address,
|
|
"score": 1.0,
|
|
},
|
|
)
|
|
|
|
|
|
def _send_completion(gateway_url: str, prompt: str) -> str:
|
|
body = _post_json(
|
|
f"{gateway_url}/v1/chat/completions",
|
|
{
|
|
"model": "stub-model",
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
},
|
|
{"Authorization": "Bearer fraud-test-key"},
|
|
)
|
|
return body["choices"][0]["message"]["content"]
|
|
|
|
|
|
def test_bad_node_is_slashed_and_excluded_from_gateway_routes(capsys):
|
|
"A bad final shard is slashed by the validator and then excluded by routing.\n\nTags: billing, routing, security"
|
|
|
|
contracts = LocalSolanaContracts()
|
|
contracts.registry.submit_stake("wallet-good", 500)
|
|
contracts.registry.submit_stake("wallet-bad", 500)
|
|
tracker = TrackerServer(contracts=contracts, enable_billing=False)
|
|
tracker_port = tracker.start()
|
|
tracker_url = f"http://127.0.0.1:{tracker_port}"
|
|
|
|
good_node = StubNodeServer(shard_start=0, shard_end=15, is_last_shard=False)
|
|
bad_node = StubNodeServer(
|
|
shard_start=16,
|
|
shard_end=31,
|
|
is_last_shard=True,
|
|
response_prefix="fraudulent response to:",
|
|
)
|
|
reference_node = StubNodeServer(shard_start=0, shard_end=31, is_last_shard=True)
|
|
good_port = good_node.start()
|
|
bad_port = bad_node.start()
|
|
reference_port = reference_node.start()
|
|
|
|
_register_node(tracker_url, f"http://127.0.0.1:{good_port}", 0, 15, "wallet-good")
|
|
_register_node(tracker_url, f"http://127.0.0.1:{bad_port}", 16, 31, "wallet-bad")
|
|
|
|
gateway = GatewayServer(tracker_url=tracker_url, contracts=contracts)
|
|
gateway_port = gateway.start()
|
|
gateway_url = f"http://127.0.0.1:{gateway_port}"
|
|
validator = ValidatorProcess(
|
|
contracts=contracts,
|
|
reference_node_url=f"http://127.0.0.1:{reference_port}",
|
|
sample_rate=1.0,
|
|
slash_amount=125,
|
|
strike_threshold=1,
|
|
random_seed=7,
|
|
)
|
|
|
|
try:
|
|
for i in range(20):
|
|
assert _send_completion(gateway_url, f"prompt {i}").startswith("fraudulent response to:")
|
|
|
|
receipts = validator.validate_once()
|
|
|
|
bad_wallet = contracts.registry.get_wallet("wallet-bad")
|
|
assert receipts
|
|
assert bad_wallet.stake_balance == 375
|
|
assert bad_wallet.strike_count == 1
|
|
assert bad_wallet.banned is True
|
|
assert "WARNING: node wallet-bad slashed" in capsys.readouterr().out
|
|
|
|
try:
|
|
_send_completion(gateway_url, "after slash")
|
|
raise AssertionError("Expected no route after bad wallet is excluded")
|
|
except urllib.error.HTTPError as exc:
|
|
assert exc.code == 503
|
|
finally:
|
|
gateway.stop()
|
|
reference_node.stop()
|
|
bad_node.stop()
|
|
good_node.stop()
|
|
tracker.stop()
|
|
|
|
|
|
def test_validator_sampling_rate_is_configurable():
|
|
"The validator only reruns requests selected by its configured sample rate.\n\nTags: billing, security"
|
|
|
|
contracts = LocalSolanaContracts()
|
|
|
|
class InProcessReferenceValidator(ValidatorProcess):
|
|
def _run_reference(self, messages: list[dict]) -> str:
|
|
return f"stub response to: {messages[-1]['content']}"
|
|
|
|
validator = InProcessReferenceValidator(
|
|
contracts=contracts,
|
|
reference_node_url="http://reference-node.invalid",
|
|
sample_rate=0.05,
|
|
random_seed=1,
|
|
)
|
|
|
|
for i in range(1_000):
|
|
contracts.validation.record_completed_inference(
|
|
session_id=f"session-{i}",
|
|
model="stub-model",
|
|
messages=[{"role": "user", "content": f"prompt {i}"}],
|
|
observed_output=f"stub response to: prompt {i}",
|
|
route_nodes=[{"wallet_address": "wallet-good"}],
|
|
)
|
|
|
|
validator.validate_once()
|
|
|
|
assert 30 <= validator.sampled_count <= 70
|