From 93b70b77ae1c170347e8e73d72e81661277053ae Mon Sep 17 00:00:00 2001 From: SR Date: Sun, 31 May 2026 02:02:35 -0600 Subject: [PATCH 1/2] feat: add slashing penalty core --- docs/slashing-penalty-demo.md | 51 ++++ node/slashing_penalties.py | 395 ++++++++++++++++++++++++++ node/tests/test_slashing_penalties.py | 103 +++++++ 3 files changed, 549 insertions(+) create mode 100644 docs/slashing-penalty-demo.md create mode 100644 node/slashing_penalties.py create mode 100644 node/tests/test_slashing_penalties.py diff --git a/docs/slashing-penalty-demo.md b/docs/slashing-penalty-demo.md new file mode 100644 index 000000000..a7c107e5a --- /dev/null +++ b/docs/slashing-penalty-demo.md @@ -0,0 +1,51 @@ +# Slashing Penalty Core Demo + +This snippet shows the focused slashing penalty core applying double-vote +evidence to a validator balance and future epoch enrollment rows. + +```bash +PYTHONPATH=node python - <<'PY' +import sqlite3 +from slashing_penalties import apply_slashing_evidence, filter_slashed_validators + +db = sqlite3.connect(":memory:") +db.executescript(""" +CREATE TABLE balances (miner_id TEXT PRIMARY KEY, amount_i64 INTEGER NOT NULL); +CREATE TABLE epoch_enroll ( + epoch INTEGER NOT NULL, + miner_pk TEXT NOT NULL, + weight REAL NOT NULL, + PRIMARY KEY (epoch, miner_pk) +); +INSERT INTO balances VALUES ('validator-a', 1000000); +INSERT INTO epoch_enroll VALUES (10, 'validator-a', 1.0); +INSERT INTO epoch_enroll VALUES (11, 'validator-a', 1.0); +INSERT INTO epoch_enroll VALUES (12, 'validator-a', 1.0); +""") + +result = apply_slashing_evidence( + db, + { + "validator_id": "validator-a", + "offense_type": "double_vote", + "epoch": 10, + "details": {"vote_a": "root-a", "vote_b": "root-b"}, + }, + current_epoch=10, + slash_fraction=0.10, + exclusion_epochs=2, + now_ts=1234, +) +print(result["penalty_urtc"], result["slashed_until_epoch"], result["removed_future_enrollments"]) +print(db.execute("SELECT amount_i64 FROM balances WHERE miner_id='validator-a'").fetchone()[0]) +print(filter_slashed_validators(db, ["validator-a", "validator-b"], 11)) +PY +``` + +Expected output: + +```text +100000 12 2 +900000 +['validator-b'] +``` diff --git a/node/slashing_penalties.py b/node/slashing_penalties.py new file mode 100644 index 000000000..f95fe923b --- /dev/null +++ b/node/slashing_penalties.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +Slashing penalty core for validator equivocation evidence. + +This module applies an already-verified slashable offense to local ledger state: +it records the evidence idempotently, burns part of the validator balance, and +marks the validator as ineligible for future epochs. +""" + +import hashlib +import json +import sqlite3 +import time +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union + +URTC_PER_RTC = 1_000_000 +DEFAULT_SLASH_FRACTION = 0.10 +DEFAULT_EXCLUSION_EPOCHS = 2 +SLASHABLE_OFFENSES = frozenset( + { + "double_vote", + "double_proposal", + "equivocation", + "surround_vote", + } +) + + +class SlashingError(ValueError): + """Raised when slashing evidence or penalty parameters are invalid.""" + + +@dataclass(frozen=True) +class SlashingEvidence: + validator_id: str + offense_type: str + epoch: int + evidence_hash: str + details: Dict[str, Any] + + def to_json(self) -> str: + return json.dumps( + { + "validator_id": self.validator_id, + "offense_type": self.offense_type, + "epoch": self.epoch, + "evidence_hash": self.evidence_hash, + "details": self.details, + }, + sort_keys=True, + separators=(",", ":"), + ) + + +def normalize_slashing_evidence( + evidence: Union[SlashingEvidence, Mapping[str, Any]] +) -> SlashingEvidence: + """Normalize mapping/dataclass evidence through one validation path.""" + if isinstance(evidence, SlashingEvidence): + payload = { + "validator_id": evidence.validator_id, + "offense_type": evidence.offense_type, + "epoch": evidence.epoch, + "evidence_hash": evidence.evidence_hash, + "details": evidence.details, + } + elif isinstance(evidence, Mapping): + payload = dict(evidence) + else: + raise SlashingError("evidence_must_be_mapping") + + validator_id = _required_text(payload.get("validator_id"), "validator_id") + offense_type = _required_text(payload.get("offense_type"), "offense_type") + if offense_type not in SLASHABLE_OFFENSES: + raise SlashingError("unsupported_offense_type") + + epoch = _required_int(payload.get("epoch"), "epoch") + if epoch < 0: + raise SlashingError("epoch_must_be_non_negative") + + details = payload["details"] if "details" in payload else {} + if not isinstance(details, Mapping): + raise SlashingError("details_must_be_mapping") + details = dict(details) + + evidence_hash = payload.get("evidence_hash") + if evidence_hash is None: + evidence_hash = _derive_evidence_hash(validator_id, offense_type, epoch, details) + evidence_hash = _required_text(evidence_hash, "evidence_hash") + + return SlashingEvidence( + validator_id=validator_id, + offense_type=offense_type, + epoch=epoch, + evidence_hash=evidence_hash, + details=details, + ) + + +def ensure_slashing_tables(conn: sqlite3.Connection) -> None: + """Create slashing tables when a legacy node DB does not have them yet.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS validator_slashes ( + evidence_hash TEXT PRIMARY KEY, + validator_id TEXT NOT NULL, + offense_type TEXT NOT NULL, + epoch INTEGER NOT NULL, + penalty_urtc INTEGER NOT NULL, + slashed_until_epoch INTEGER NOT NULL, + evidence_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS slashed_validators ( + validator_id TEXT PRIMARY KEY, + slashed_until_epoch INTEGER NOT NULL, + total_penalty_urtc INTEGER NOT NULL DEFAULT 0, + last_offense_type TEXT NOT NULL, + last_slashed_at INTEGER NOT NULL + ) + """ + ) + + +def apply_slashing_evidence( + conn: sqlite3.Connection, + evidence: Union[SlashingEvidence, Mapping[str, Any]], + *, + current_epoch: Optional[int] = None, + slash_fraction: float = DEFAULT_SLASH_FRACTION, + min_penalty_urtc: int = 1, + exclusion_epochs: int = DEFAULT_EXCLUSION_EPOCHS, + now_ts: Optional[int] = None, +) -> Dict[str, Any]: + """ + Apply one slashable offense idempotently. + + The evidence hash is the idempotency key. Replaying the same evidence returns + ``duplicate=True`` and does not burn funds again. + """ + normalized = normalize_slashing_evidence(evidence) + current_epoch = normalized.epoch if current_epoch is None else _required_int(current_epoch, "current_epoch") + if current_epoch < normalized.epoch: + raise SlashingError("current_epoch_before_evidence_epoch") + if not 0 < slash_fraction <= 1: + raise SlashingError("slash_fraction_out_of_range") + if min_penalty_urtc < 0: + raise SlashingError("min_penalty_urtc_must_be_non_negative") + if exclusion_epochs < 1: + raise SlashingError("exclusion_epochs_must_be_positive") + + ensure_slashing_tables(conn) + existing = conn.execute( + "SELECT penalty_urtc, slashed_until_epoch FROM validator_slashes WHERE evidence_hash = ?", + (normalized.evidence_hash,), + ).fetchone() + if existing: + return { + "applied": False, + "duplicate": True, + "validator_id": normalized.validator_id, + "offense_type": normalized.offense_type, + "penalty_urtc": int(existing[0]), + "slashed_until_epoch": int(existing[1]), + "evidence_hash": normalized.evidence_hash, + } + + balance = _get_balance_urtc(conn, normalized.validator_id) + penalty = _calculate_penalty_urtc(balance, slash_fraction, min_penalty_urtc) + if penalty: + _debit_balance(conn, normalized.validator_id, penalty) + + slashed_until = current_epoch + exclusion_epochs + now_ts = int(time.time()) if now_ts is None else _required_int(now_ts, "now_ts") + conn.execute( + """ + INSERT INTO validator_slashes ( + evidence_hash, validator_id, offense_type, epoch, penalty_urtc, + slashed_until_epoch, evidence_json, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + normalized.evidence_hash, + normalized.validator_id, + normalized.offense_type, + normalized.epoch, + penalty, + slashed_until, + normalized.to_json(), + now_ts, + ), + ) + conn.execute( + """ + INSERT INTO slashed_validators ( + validator_id, slashed_until_epoch, total_penalty_urtc, + last_offense_type, last_slashed_at + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(validator_id) DO UPDATE SET + slashed_until_epoch = MAX(slashed_until_epoch, excluded.slashed_until_epoch), + total_penalty_urtc = total_penalty_urtc + excluded.total_penalty_urtc, + last_offense_type = excluded.last_offense_type, + last_slashed_at = excluded.last_slashed_at + """, + (normalized.validator_id, slashed_until, penalty, normalized.offense_type, now_ts), + ) + removed_enrollments = _remove_future_enrollments( + conn, normalized.validator_id, current_epoch, slashed_until + ) + + return { + "applied": True, + "duplicate": False, + "validator_id": normalized.validator_id, + "offense_type": normalized.offense_type, + "penalty_urtc": penalty, + "balance_before_urtc": balance, + "balance_after_urtc": max(0, balance - penalty), + "slashed_until_epoch": slashed_until, + "removed_future_enrollments": removed_enrollments, + "evidence_hash": normalized.evidence_hash, + } + + +def is_validator_slashed(conn: sqlite3.Connection, validator_id: str, epoch: int) -> bool: + """Return whether a validator is excluded for the given epoch.""" + ensure_slashing_tables(conn) + validator_id = _required_text(validator_id, "validator_id") + epoch = _required_int(epoch, "epoch") + row = conn.execute( + """ + SELECT 1 FROM slashed_validators + WHERE validator_id = ? AND slashed_until_epoch >= ? + LIMIT 1 + """, + (validator_id, epoch), + ).fetchone() + return row is not None + + +def filter_slashed_validators( + conn: sqlite3.Connection, + validator_ids: Iterable[str], + epoch: int, +) -> List[str]: + """Filter an enrollment/validator candidate list against active slashes.""" + return [ + validator_id + for validator_id in validator_ids + if not is_validator_slashed(conn, validator_id, epoch) + ] + + +def _required_text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise SlashingError(f"{field}_must_be_non_empty_text") + return value.strip() + + +def _required_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise SlashingError(f"{field}_must_be_integer") + return value + + +def _derive_evidence_hash( + validator_id: str, + offense_type: str, + epoch: int, + details: Mapping[str, Any], +) -> str: + payload = json.dumps( + { + "validator_id": validator_id, + "offense_type": offense_type, + "epoch": epoch, + "details": details, + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _calculate_penalty_urtc(balance_urtc: int, slash_fraction: float, min_penalty_urtc: int) -> int: + if balance_urtc <= 0: + return 0 + fraction_penalty = int(balance_urtc * slash_fraction) + return min(balance_urtc, max(min_penalty_urtc, fraction_penalty)) + + +def _get_balance_urtc(conn: sqlite3.Connection, validator_id: str) -> int: + if not _table_exists(conn, "balances"): + return 0 + columns = _table_columns(conn, "balances") + key_column = _first_present(columns, ("miner_id", "miner_pk", "wallet", "validator_id")) + if not key_column: + return 0 + if "amount_i64" in columns: + row = conn.execute( + f"SELECT amount_i64 FROM balances WHERE {key_column} = ?", + (validator_id,), + ).fetchone() + return max(0, int(row[0])) if row else 0 + if "balance_rtc" in columns: + row = conn.execute( + f"SELECT balance_rtc FROM balances WHERE {key_column} = ?", + (validator_id,), + ).fetchone() + return max(0, int(float(row[0]) * URTC_PER_RTC)) if row else 0 + return 0 + + +def _debit_balance(conn: sqlite3.Connection, validator_id: str, penalty_urtc: int) -> None: + columns = _table_columns(conn, "balances") + key_column = _first_present(columns, ("miner_id", "miner_pk", "wallet", "validator_id")) + if not key_column: + return + if "amount_i64" in columns: + conn.execute( + f""" + UPDATE balances + SET amount_i64 = CASE + WHEN amount_i64 >= ? THEN amount_i64 - ? + ELSE 0 + END + WHERE {key_column} = ? + """, + (penalty_urtc, penalty_urtc, validator_id), + ) + elif "balance_rtc" in columns: + penalty_rtc = penalty_urtc / URTC_PER_RTC + conn.execute( + f""" + UPDATE balances + SET balance_rtc = CASE + WHEN balance_rtc >= ? THEN balance_rtc - ? + ELSE 0 + END + WHERE {key_column} = ? + """, + (penalty_rtc, penalty_rtc, validator_id), + ) + + +def _remove_future_enrollments( + conn: sqlite3.Connection, + validator_id: str, + current_epoch: int, + slashed_until_epoch: int, +) -> int: + if not _table_exists(conn, "epoch_enroll"): + return 0 + columns = _table_columns(conn, "epoch_enroll") + key_column = _first_present(columns, ("miner_pk", "validator_id", "miner_id")) + if not key_column or "epoch" not in columns: + return 0 + cursor = conn.execute( + f""" + DELETE FROM epoch_enroll + WHERE {key_column} = ? + AND epoch > ? + AND epoch <= ? + """, + (validator_id, current_epoch, slashed_until_epoch), + ) + return cursor.rowcount + + +def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + return row is not None + + +def _table_columns(conn: sqlite3.Connection, table_name: str) -> Sequence[str]: + return tuple(row[1] for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()) + + +def _first_present(columns: Sequence[str], candidates: Tuple[str, ...]) -> Optional[str]: + column_set = set(columns) + for candidate in candidates: + if candidate in column_set: + return candidate + return None diff --git a/node/tests/test_slashing_penalties.py b/node/tests/test_slashing_penalties.py new file mode 100644 index 000000000..9ec2622d8 --- /dev/null +++ b/node/tests/test_slashing_penalties.py @@ -0,0 +1,103 @@ +import sqlite3 + +import pytest +from slashing_penalties import ( + SlashingError, + SlashingEvidence, + apply_slashing_evidence, + filter_slashed_validators, + is_validator_slashed, + normalize_slashing_evidence, +) + + +def _evidence(**overrides): + data = { + "validator_id": "validator-a", + "offense_type": "double_vote", + "epoch": 10, + "details": {"vote_a": "root-a", "vote_b": "root-b"}, + } + data.update(overrides) + return data + + +def test_apply_slashing_evidence_burns_balance_and_excludes_future_epochs(): + conn = sqlite3.connect(":memory:") + conn.executescript( + """CREATE TABLE balances (miner_id TEXT PRIMARY KEY, amount_i64 INTEGER NOT NULL); + CREATE TABLE epoch_enroll (epoch INTEGER NOT NULL, miner_pk TEXT NOT NULL, weight REAL NOT NULL, PRIMARY KEY (epoch, miner_pk)); + INSERT INTO balances(miner_id, amount_i64) VALUES ('validator-a', 1000000); + INSERT INTO epoch_enroll(epoch, miner_pk, weight) VALUES + (10, 'validator-a', 1.0), + (11, 'validator-a', 1.0), + (12, 'validator-a', 1.0), + (13, 'validator-a', 1.0), + (14, 'validator-a', 1.0); + """ + ) + + result = apply_slashing_evidence(conn, _evidence(), current_epoch=10, slash_fraction=0.25, exclusion_epochs=3, now_ts=1234) + + assert result["applied"] is True + assert result["penalty_urtc"] == 250000 + assert result["slashed_until_epoch"] == 13 + assert result["removed_future_enrollments"] == 3 + assert conn.execute("SELECT amount_i64 FROM balances WHERE miner_id='validator-a'").fetchone()[0] == 750000 + assert conn.execute("SELECT epoch FROM epoch_enroll ORDER BY epoch").fetchall() == [(10,), (14,)] + assert is_validator_slashed(conn, "validator-a", 13) is True + assert is_validator_slashed(conn, "validator-a", 14) is False + assert filter_slashed_validators(conn, ["validator-a", "validator-b"], 11) == ["validator-b"] + + +def test_slashing_evidence_is_idempotent_by_evidence_hash(): + conn = sqlite3.connect(":memory:") + conn.executescript( + """CREATE TABLE balances (miner_id TEXT PRIMARY KEY, amount_i64 INTEGER NOT NULL); + INSERT INTO balances(miner_id, amount_i64) VALUES ('validator-a', 1000000); + """ + ) + evidence = _evidence(evidence_hash="same-proof") + + first = apply_slashing_evidence(conn, evidence, now_ts=1234) + second = apply_slashing_evidence(conn, evidence, now_ts=1235) + + assert first["applied"] is True + assert second["applied"] is False + assert second["duplicate"] is True + assert conn.execute("SELECT amount_i64 FROM balances WHERE miner_id='validator-a'").fetchone()[0] == 900000 + assert conn.execute("SELECT COUNT(*) FROM validator_slashes").fetchone()[0] == 1 + + +def test_balance_rtc_schema_is_supported_for_legacy_tables(): + conn = sqlite3.connect(":memory:") + conn.executescript( + """CREATE TABLE balances (miner_pk TEXT PRIMARY KEY, balance_rtc REAL NOT NULL); + INSERT INTO balances(miner_pk, balance_rtc) VALUES ('validator-a', 2.5); + """ + ) + + result = apply_slashing_evidence(conn, _evidence(offense_type="equivocation"), slash_fraction=0.5, now_ts=1234) + + assert result["penalty_urtc"] == 1250000 + assert conn.execute("SELECT balance_rtc FROM balances WHERE miner_pk='validator-a'").fetchone()[0] == 1.25 + + +def test_dataclass_evidence_uses_same_validation_path_as_mapping(): + with pytest.raises(SlashingError, match="validator_id_must_be_non_empty_text"): + normalize_slashing_evidence( + SlashingEvidence(validator_id="", offense_type="double_vote", epoch=10, evidence_hash="proof", details={}) + ) + + +@pytest.mark.parametrize( + "bad_evidence, error", + [ + (_evidence(offense_type="not-slashable"), "unsupported_offense_type"), + (_evidence(epoch=True), "epoch_must_be_integer"), + (_evidence(details=[]), "details_must_be_mapping"), + ], +) +def test_invalid_evidence_is_rejected(bad_evidence, error): + with pytest.raises(SlashingError, match=error): + normalize_slashing_evidence(bad_evidence) From 9752593a2eef1fb625528aab603f9a37793a3dad Mon Sep 17 00:00:00 2001 From: SR Date: Sun, 31 May 2026 12:54:00 -0600 Subject: [PATCH 2/2] fix: validate slashing evidence before penalties --- node/slashing_penalties.py | 176 ++++++++++++++++---------- node/tests/test_slashing_penalties.py | 62 ++++++++- 2 files changed, 172 insertions(+), 66 deletions(-) diff --git a/node/slashing_penalties.py b/node/slashing_penalties.py index f95fe923b..0c5808a24 100644 --- a/node/slashing_penalties.py +++ b/node/slashing_penalties.py @@ -26,6 +26,20 @@ "surround_vote", } ) +KNOWN_SCHEMA_TABLES = frozenset( + { + "balances", + "epoch_enroll", + "validator_slashes", + "slashed_validators", + } +) +OFFENSE_DETAIL_KEY_PAIRS = { + "double_vote": (("vote_a", "vote_b"), ("first", "second")), + "double_proposal": (("proposal_a", "proposal_b"), ("first", "second")), + "equivocation": (("evidence_a", "evidence_b"), ("vote_a", "vote_b"), ("first", "second")), + "surround_vote": (("surrounding_vote", "surrounded_vote"), ("vote_a", "vote_b"), ("first", "second")), +} class SlashingError(ValueError): @@ -84,11 +98,15 @@ def normalize_slashing_evidence( if not isinstance(details, Mapping): raise SlashingError("details_must_be_mapping") details = dict(details) + _validate_evidence_details(offense_type, details) + derived_hash = _derive_evidence_hash(validator_id, offense_type, epoch, details) evidence_hash = payload.get("evidence_hash") if evidence_hash is None: - evidence_hash = _derive_evidence_hash(validator_id, offense_type, epoch, details) + evidence_hash = derived_hash evidence_hash = _required_text(evidence_hash, "evidence_hash") + if evidence_hash != derived_hash: + raise SlashingError("evidence_hash_mismatch") return SlashingEvidence( validator_id=validator_id, @@ -155,66 +173,67 @@ def apply_slashing_evidence( if exclusion_epochs < 1: raise SlashingError("exclusion_epochs_must_be_positive") - ensure_slashing_tables(conn) - existing = conn.execute( - "SELECT penalty_urtc, slashed_until_epoch FROM validator_slashes WHERE evidence_hash = ?", - (normalized.evidence_hash,), - ).fetchone() - if existing: - return { - "applied": False, - "duplicate": True, - "validator_id": normalized.validator_id, - "offense_type": normalized.offense_type, - "penalty_urtc": int(existing[0]), - "slashed_until_epoch": int(existing[1]), - "evidence_hash": normalized.evidence_hash, - } - - balance = _get_balance_urtc(conn, normalized.validator_id) - penalty = _calculate_penalty_urtc(balance, slash_fraction, min_penalty_urtc) - if penalty: - _debit_balance(conn, normalized.validator_id, penalty) - - slashed_until = current_epoch + exclusion_epochs - now_ts = int(time.time()) if now_ts is None else _required_int(now_ts, "now_ts") - conn.execute( - """ - INSERT INTO validator_slashes ( - evidence_hash, validator_id, offense_type, epoch, penalty_urtc, - slashed_until_epoch, evidence_json, created_at + with conn: + ensure_slashing_tables(conn) + existing = conn.execute( + "SELECT penalty_urtc, slashed_until_epoch FROM validator_slashes WHERE evidence_hash = ?", + (normalized.evidence_hash,), + ).fetchone() + if existing: + return { + "applied": False, + "duplicate": True, + "validator_id": normalized.validator_id, + "offense_type": normalized.offense_type, + "penalty_urtc": int(existing[0]), + "slashed_until_epoch": int(existing[1]), + "evidence_hash": normalized.evidence_hash, + } + + balance = _get_balance_urtc(conn, normalized.validator_id) + penalty = _calculate_penalty_urtc(balance, slash_fraction, min_penalty_urtc) + if penalty: + _debit_balance(conn, normalized.validator_id, penalty) + + slashed_until = current_epoch + exclusion_epochs + now_ts = int(time.time()) if now_ts is None else _required_int(now_ts, "now_ts") + conn.execute( + """ + INSERT INTO validator_slashes ( + evidence_hash, validator_id, offense_type, epoch, penalty_urtc, + slashed_until_epoch, evidence_json, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + normalized.evidence_hash, + normalized.validator_id, + normalized.offense_type, + normalized.epoch, + penalty, + slashed_until, + normalized.to_json(), + now_ts, + ), ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - normalized.evidence_hash, - normalized.validator_id, - normalized.offense_type, - normalized.epoch, - penalty, - slashed_until, - normalized.to_json(), - now_ts, - ), - ) - conn.execute( - """ - INSERT INTO slashed_validators ( - validator_id, slashed_until_epoch, total_penalty_urtc, - last_offense_type, last_slashed_at + conn.execute( + """ + INSERT INTO slashed_validators ( + validator_id, slashed_until_epoch, total_penalty_urtc, + last_offense_type, last_slashed_at + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(validator_id) DO UPDATE SET + slashed_until_epoch = MAX(slashed_until_epoch, excluded.slashed_until_epoch), + total_penalty_urtc = total_penalty_urtc + excluded.total_penalty_urtc, + last_offense_type = excluded.last_offense_type, + last_slashed_at = excluded.last_slashed_at + """, + (normalized.validator_id, slashed_until, penalty, normalized.offense_type, now_ts), + ) + removed_enrollments = _remove_future_enrollments( + conn, normalized.validator_id, current_epoch, slashed_until ) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(validator_id) DO UPDATE SET - slashed_until_epoch = MAX(slashed_until_epoch, excluded.slashed_until_epoch), - total_penalty_urtc = total_penalty_urtc + excluded.total_penalty_urtc, - last_offense_type = excluded.last_offense_type, - last_slashed_at = excluded.last_slashed_at - """, - (normalized.validator_id, slashed_until, penalty, normalized.offense_type, now_ts), - ) - removed_enrollments = _remove_future_enrollments( - conn, normalized.validator_id, current_epoch, slashed_until - ) return { "applied": True, @@ -290,6 +309,34 @@ def _derive_evidence_hash( return hashlib.sha256(payload.encode("utf-8")).hexdigest() +def _validate_evidence_details(offense_type: str, details: Mapping[str, Any]) -> None: + for left_key, right_key in OFFENSE_DETAIL_KEY_PAIRS[offense_type]: + if left_key in details or right_key in details: + left = _required_detail_value(details.get(left_key), left_key) + right = _required_detail_value(details.get(right_key), right_key) + if _canonical_detail_value(left) == _canonical_detail_value(right): + raise SlashingError("details_must_describe_conflicting_evidence") + return + expected = " or ".join( + f"{left_key}/{right_key}" for left_key, right_key in OFFENSE_DETAIL_KEY_PAIRS[offense_type] + ) + raise SlashingError(f"details_missing_required_pair:{expected}") + + +def _required_detail_value(value: Any, field: str) -> Any: + if value is None: + raise SlashingError(f"{field}_is_required") + if isinstance(value, str) and not value.strip(): + raise SlashingError(f"{field}_is_required") + if isinstance(value, (Mapping, Sequence)) and not isinstance(value, (str, bytes, bytearray)) and not value: + raise SlashingError(f"{field}_is_required") + return value + + +def _canonical_detail_value(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + def _calculate_penalty_urtc(balance_urtc: int, slash_fraction: float, min_penalty_urtc: int) -> int: if balance_urtc <= 0: return 0 @@ -341,13 +388,10 @@ def _debit_balance(conn: sqlite3.Connection, validator_id: str, penalty_urtc: in conn.execute( f""" UPDATE balances - SET balance_rtc = CASE - WHEN balance_rtc >= ? THEN balance_rtc - ? - ELSE 0 - END + SET balance_rtc = MAX(0.0, balance_rtc - ?) WHERE {key_column} = ? """, - (penalty_rtc, penalty_rtc, validator_id), + (penalty_rtc, validator_id), ) @@ -384,7 +428,9 @@ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool: def _table_columns(conn: sqlite3.Connection, table_name: str) -> Sequence[str]: - return tuple(row[1] for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()) + if table_name not in KNOWN_SCHEMA_TABLES: + raise SlashingError("unknown_table") + return tuple(row[1] for row in conn.execute(f'PRAGMA table_info("{table_name}")').fetchall()) def _first_present(columns: Sequence[str], candidates: Tuple[str, ...]) -> Optional[str]: diff --git a/node/tests/test_slashing_penalties.py b/node/tests/test_slashing_penalties.py index 9ec2622d8..dd22c9198 100644 --- a/node/tests/test_slashing_penalties.py +++ b/node/tests/test_slashing_penalties.py @@ -5,6 +5,7 @@ SlashingError, SlashingEvidence, apply_slashing_evidence, + ensure_slashing_tables, filter_slashed_validators, is_validator_slashed, normalize_slashing_evidence, @@ -57,7 +58,7 @@ def test_slashing_evidence_is_idempotent_by_evidence_hash(): INSERT INTO balances(miner_id, amount_i64) VALUES ('validator-a', 1000000); """ ) - evidence = _evidence(evidence_hash="same-proof") + evidence = _evidence() first = apply_slashing_evidence(conn, evidence, now_ts=1234) second = apply_slashing_evidence(conn, evidence, now_ts=1235) @@ -83,6 +84,65 @@ def test_balance_rtc_schema_is_supported_for_legacy_tables(): assert conn.execute("SELECT balance_rtc FROM balances WHERE miner_pk='validator-a'").fetchone()[0] == 1.25 +def test_rejects_caller_controlled_evidence_hash_mismatch(): + with pytest.raises(SlashingError, match="evidence_hash_mismatch"): + normalize_slashing_evidence(_evidence(evidence_hash="same-proof")) + + +def test_distinct_slashing_evidence_gets_distinct_hashes(): + first = normalize_slashing_evidence(_evidence(details={"vote_a": "root-a", "vote_b": "root-b"})) + second = normalize_slashing_evidence(_evidence(details={"vote_a": "root-c", "vote_b": "root-d"})) + + assert first.evidence_hash != second.evidence_hash + + +@pytest.mark.parametrize( + "bad_evidence, error", + [ + (_evidence(details={}), "details_missing_required_pair"), + (_evidence(details={"vote_a": "same", "vote_b": "same"}), "details_must_describe_conflicting_evidence"), + (_evidence(details={"vote_a": "", "vote_b": "root-b"}), "vote_a_is_required"), + ], +) +def test_rejects_missing_or_non_conflicting_offense_details(bad_evidence, error): + with pytest.raises(SlashingError, match=error): + normalize_slashing_evidence(bad_evidence) + + +def test_accepts_offense_specific_detail_pairs(): + normalized = normalize_slashing_evidence( + _evidence(offense_type="double_proposal", details={"proposal_a": "root-a", "proposal_b": "root-b"}) + ) + + assert normalized.offense_type == "double_proposal" + + +def test_slashing_writes_roll_back_when_ledger_insert_fails(): + conn = sqlite3.connect(":memory:") + conn.executescript( + """CREATE TABLE balances (miner_id TEXT PRIMARY KEY, amount_i64 INTEGER NOT NULL); + INSERT INTO balances(miner_id, amount_i64) VALUES ('validator-a', 1000000); + """ + ) + ensure_slashing_tables(conn) + conn.execute( + """ + CREATE TRIGGER fail_slash_insert + BEFORE INSERT ON validator_slashes + BEGIN + SELECT RAISE(ABORT, 'boom'); + END + """ + ) + conn.commit() + + with pytest.raises(sqlite3.DatabaseError, match="boom"): + apply_slashing_evidence(conn, _evidence(), now_ts=1234) + + assert conn.execute("SELECT amount_i64 FROM balances WHERE miner_id='validator-a'").fetchone()[0] == 1000000 + assert conn.execute("SELECT COUNT(*) FROM validator_slashes").fetchone()[0] == 0 + + def test_dataclass_evidence_uses_same_validation_path_as_mapping(): with pytest.raises(SlashingError, match="validator_id_must_be_non_empty_text"): normalize_slashing_evidence(