feat: add probation and ban enforcement
This commit is contained in:
@@ -26,7 +26,10 @@ def test_client_can_fund_api_key_with_testnet_sol():
|
||||
|
||||
def test_epoch_settlement_debits_clients_and_rewards_nodes_and_validator():
|
||||
"""Settlement distributes native token rewards according to recorded work."""
|
||||
contracts = LocalSolanaContracts(cost_per_layer_token_lamport=2)
|
||||
contracts = LocalSolanaContracts(
|
||||
cost_per_layer_token_lamport=2,
|
||||
probationary_job_count=0,
|
||||
)
|
||||
contracts.payment.fund_api_key("api-key-a", lamports=1_000)
|
||||
contracts.payment.record_attribution(
|
||||
session_id="session-a",
|
||||
@@ -61,6 +64,28 @@ def test_epoch_settlement_debits_clients_and_rewards_nodes_and_validator():
|
||||
assert contracts.settlement.get_token_balance("validator-wallet") == 32
|
||||
|
||||
|
||||
def test_failed_settlement_does_not_advance_probation_or_settle_attribution():
|
||||
"""Settlement state is unchanged when caller balance validation fails."""
|
||||
contracts = LocalSolanaContracts(starting_credit_lamports=0, probationary_job_count=1)
|
||||
contracts.payment.record_attribution(
|
||||
session_id="session-a",
|
||||
api_key="api-key-a",
|
||||
node_wallet="node-wallet-a",
|
||||
layer_start=0,
|
||||
layer_end=0,
|
||||
tokens=1,
|
||||
)
|
||||
|
||||
try:
|
||||
contracts.settlement.settle_epoch(epoch=1)
|
||||
raise AssertionError("Expected insufficient balance")
|
||||
except ValueError as exc:
|
||||
assert "insufficient SOL balance" in str(exc)
|
||||
|
||||
assert contracts.registry.get_wallet("node-wallet-a").completed_job_count == 0
|
||||
assert contracts.payment.list_attributions(include_settled=False)
|
||||
|
||||
|
||||
def test_slash_proof_reduces_stake_increments_strikes_and_warns(capsys):
|
||||
"""Submitting a slash proof updates registry state and notifies the operator."""
|
||||
contracts = LocalSolanaContracts()
|
||||
@@ -79,3 +104,69 @@ def test_slash_proof_reduces_stake_increments_strikes_and_warns(capsys):
|
||||
assert wallet.strike_count == 1
|
||||
assert wallet.banned is False
|
||||
assert "WARNING: node node-wallet-a slashed" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_probationary_wallet_earns_only_after_required_jobs():
|
||||
"""A new wallet's first N completed jobs are counted but not rewarded."""
|
||||
contracts = LocalSolanaContracts(probationary_job_count=2)
|
||||
contracts.payment.fund_api_key("api-key-a", lamports=1_000)
|
||||
for job_number in range(3):
|
||||
contracts.payment.record_attribution(
|
||||
session_id=f"session-{job_number}",
|
||||
api_key="api-key-a",
|
||||
node_wallet="new-wallet",
|
||||
layer_start=0,
|
||||
layer_end=0,
|
||||
tokens=10,
|
||||
)
|
||||
|
||||
result = contracts.settlement.settle_epoch(epoch=1)
|
||||
|
||||
assert result.client_debits == {"api-key-a": 30}
|
||||
assert result.node_rewards == {"new-wallet": 10}
|
||||
assert contracts.registry.get_wallet("new-wallet").completed_job_count == 3
|
||||
assert contracts.registry.probationary_jobs_remaining("new-wallet") == 0
|
||||
assert contracts.settlement.get_token_balance("new-wallet") == 10
|
||||
|
||||
|
||||
def test_probationary_wallet_receives_no_rewards_before_threshold():
|
||||
"""Settlement state verifies a wallet earns zero during probation."""
|
||||
contracts = LocalSolanaContracts(probationary_job_count=50)
|
||||
contracts.payment.fund_api_key("api-key-a", lamports=1_000)
|
||||
contracts.payment.record_attribution(
|
||||
session_id="session-a",
|
||||
api_key="api-key-a",
|
||||
node_wallet="new-wallet",
|
||||
layer_start=0,
|
||||
layer_end=9,
|
||||
tokens=1,
|
||||
)
|
||||
|
||||
result = contracts.settlement.settle_epoch(epoch=1)
|
||||
|
||||
assert result.node_rewards == {"new-wallet": 0}
|
||||
assert contracts.registry.get_wallet("new-wallet").completed_job_count == 1
|
||||
assert contracts.registry.probationary_jobs_remaining("new-wallet") == 49
|
||||
assert contracts.settlement.get_token_balance("new-wallet") == 0
|
||||
|
||||
|
||||
def test_slash_proof_at_threshold_marks_wallet_banned():
|
||||
"""Registry marks a wallet banned when strikes reach the configured threshold."""
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("node-wallet-a", 500)
|
||||
contracts.registry.submit_slash_proof(
|
||||
wallet_address="node-wallet-a",
|
||||
slash_amount=10,
|
||||
strike_threshold=2,
|
||||
)
|
||||
|
||||
receipt = contracts.registry.submit_slash_proof(
|
||||
wallet_address="node-wallet-a",
|
||||
slash_amount=10,
|
||||
strike_threshold=2,
|
||||
)
|
||||
|
||||
assert receipt.banned is True
|
||||
wallet = contracts.registry.get_wallet("node-wallet-a")
|
||||
assert wallet.strike_count == 2
|
||||
assert wallet.banned is True
|
||||
|
||||
@@ -10,8 +10,9 @@ import pytest
|
||||
|
||||
from meshnet_node.downloader import download_shard
|
||||
from meshnet_node.hardware import detect_hardware
|
||||
from meshnet_node.startup import run_startup
|
||||
from meshnet_node.startup import _probationary_status_line, run_startup
|
||||
from meshnet_node.wallet import _b58encode, load_or_create_wallet
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
|
||||
@@ -129,6 +130,17 @@ def test_download_shard_stub_idempotent(tmp_path):
|
||||
assert shard_dir.exists()
|
||||
|
||||
|
||||
def test_startup_formats_probationary_jobs_remaining():
|
||||
"""Startup status tells a node how many free jobs remain before earning."""
|
||||
contracts = LocalSolanaContracts(probationary_job_count=50)
|
||||
for _ in range(12):
|
||||
contracts.registry.record_completed_job("node-wallet-a")
|
||||
|
||||
line = _probationary_status_line(contracts, "node-wallet-a")
|
||||
|
||||
assert line == "Probationary period: 38 jobs remaining before earning"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracker assign endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,10 +7,10 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from meshnet_gateway.server import GatewayServer
|
||||
from meshnet_gateway.server import GatewayServer, _banned_route_wallet
|
||||
from meshnet_node.server import StubNodeServer
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
from meshnet_tracker.server import TrackerServer, _registration_ban_error
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict) -> dict:
|
||||
@@ -252,11 +252,15 @@ def test_tracker_excludes_banned_wallets_from_routes():
|
||||
{"endpoint": "http://127.0.0.1:9001", "shard_start": 0, "shard_end": 15,
|
||||
"hardware_profile": {}, "wallet_address": "wallet-a", "score": 1.0},
|
||||
)
|
||||
_post_json(
|
||||
f"http://127.0.0.1:{tracker_port}/v1/nodes/register",
|
||||
{"endpoint": "http://127.0.0.1:9002", "shard_start": 16, "shard_end": 31,
|
||||
"hardware_profile": {}, "wallet_address": "wallet-b", "score": 1.0},
|
||||
)
|
||||
try:
|
||||
_post_json(
|
||||
f"http://127.0.0.1:{tracker_port}/v1/nodes/register",
|
||||
{"endpoint": "http://127.0.0.1:9002", "shard_start": 16, "shard_end": 31,
|
||||
"hardware_profile": {}, "wallet_address": "wallet-b", "score": 1.0},
|
||||
)
|
||||
raise AssertionError("Expected 403 for banned wallet registration")
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert exc.code == 403
|
||||
|
||||
try:
|
||||
_get_json(f"http://127.0.0.1:{tracker_port}/v1/route?model=stub-model")
|
||||
@@ -269,6 +273,52 @@ def test_tracker_excludes_banned_wallets_from_routes():
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_tracker_rejects_banned_wallet_registration():
|
||||
"""A banned wallet cannot register with the tracker."""
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("wallet-b", 1_000)
|
||||
contracts.registry.ban_wallet("wallet-b")
|
||||
tracker = TrackerServer(contracts=contracts, minimum_stake=100)
|
||||
tracker_port = tracker.start()
|
||||
try:
|
||||
try:
|
||||
_post_json(
|
||||
f"http://127.0.0.1:{tracker_port}/v1/nodes/register",
|
||||
{"endpoint": "http://127.0.0.1:9002", "shard_start": 0, "shard_end": 31,
|
||||
"hardware_profile": {}, "wallet_address": "wallet-b", "score": 1.0},
|
||||
)
|
||||
raise AssertionError("Expected 403 for banned wallet registration")
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert exc.code == 403
|
||||
body = json.loads(exc.read())
|
||||
assert body == {"error": "wallet is banned"}
|
||||
finally:
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_tracker_ban_registration_guard_reads_contract_state():
|
||||
"""The tracker registration guard reads ban status from the contract facade."""
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.ban_wallet("wallet-b")
|
||||
|
||||
assert _registration_ban_error(contracts, "wallet-b") == "wallet is banned"
|
||||
assert _registration_ban_error(contracts, "wallet-a") is None
|
||||
assert _registration_ban_error(None, "wallet-b") is None
|
||||
|
||||
|
||||
def test_gateway_route_guard_rejects_banned_wallet_metadata():
|
||||
"""Gateway rejects tracker routes that include a wallet now banned on-chain."""
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.ban_wallet("wallet-b")
|
||||
|
||||
banned_wallet = _banned_route_wallet(
|
||||
contracts,
|
||||
[{"endpoint": "http://node", "wallet_address": "wallet-b"}],
|
||||
)
|
||||
|
||||
assert banned_wallet == "wallet-b"
|
||||
|
||||
|
||||
def test_gateway_routes_even_when_compute_node_has_low_stake():
|
||||
"""Routing is not based on a compute node's token balance."""
|
||||
contracts = LocalSolanaContracts()
|
||||
|
||||
Reference in New Issue
Block a user