From aef3d72de0f63af5001944a480da4d5be97adee5 Mon Sep 17 00:00:00 2001 From: XiaZong Date: Mon, 13 Apr 2026 07:28:49 +0000 Subject: [PATCH 1/3] RIP-309 Phase 1: Fingerprint check rotation (4-of-6 deterministic sampling) Implements rotating fingerprint checks per epoch: - Schema: adds fingerprint_checks_json to miner_attest_recent / miner_attest_history - Store: record_attestation_success persists per-check pass/fail results - Reward logic: calculate_epoch_rewards_time_aged accepts prev_block_hash, derives nonce=SHA256(prev_block_hash + b'measurement_nonce'), samples 4 of 6 checks deterministically, only active checks affect weight - Settlement: finalize_epoch applies same 4-of-6 rule to epoch_enroll weights - Auto-settle: ingest_signed_block computes block hash and passes to finalize_epoch - Back-compat: fallback to all 6 active checks when prev_block_hash missing; schema migration is idempotent via ALTER TABLE ADD COLUMN Relates: rustchain-bounties#3008 --- node/rewards_implementation_rip200.py | 3 +- node/rip_200_round_robin_1cpu1vote.py | 64 +++- node/rustchain_v2_integrated_v2.2.1_rip200.py | 95 +++++- .../tests/test_rip309_fingerprint_rotation.py | 302 +++++++++++------- 4 files changed, 322 insertions(+), 142 deletions(-) diff --git a/node/rewards_implementation_rip200.py b/node/rewards_implementation_rip200.py index 20acc27c6..c94e80421 100644 --- a/node/rewards_implementation_rip200.py +++ b/node/rewards_implementation_rip200.py @@ -181,7 +181,8 @@ def settle_epoch_rip200(db_path, epoch: int, enable_anti_double_mining: bool = T db_path if isinstance(db_path, str) else DB_PATH, epoch, PER_EPOCH_URTC, - current + current, + b"" # prev_block_hash fallback for standard path ) if not rewards: diff --git a/node/rip_200_round_robin_1cpu1vote.py b/node/rip_200_round_robin_1cpu1vote.py index 77c89b56d..4e9071274 100644 --- a/node/rip_200_round_robin_1cpu1vote.py +++ b/node/rip_200_round_robin_1cpu1vote.py @@ -1,3 +1,6 @@ +import json +import random +import hashlib #!/usr/bin/env python3 """ RIP-200: Round-Robin Consensus (1 CPU = 1 Vote) @@ -496,7 +499,8 @@ def calculate_epoch_rewards_time_aged( db_path: str, epoch: int, total_reward_urtc: int, - current_slot: int + current_slot: int, + prev_block_hash: bytes = b"", ) -> Dict[str, int]: """ Calculate reward distribution for an epoch with time-aged multipliers @@ -519,6 +523,18 @@ def calculate_epoch_rewards_time_aged( Returns: Dict of {miner_id: reward_urtc} """ + # RIP-309: Rotating fingerprint checks (4-of-6 per epoch) + fp_checks = ['clock_drift', 'cache_timing', 'simd_identity', + 'thermal_drift', 'instruction_jitter', 'anti_emulation'] + if prev_block_hash: + nonce = hashlib.sha256(prev_block_hash + b"measurement_nonce").digest() + seed = int.from_bytes(nonce[:4], 'big') + active_checks = set(random.Random(seed).sample(fp_checks, 4)) + else: + # Fallback when no prev_block_hash provided: all checks active (backward compat) + active_checks = set(fp_checks) + print(f"[RIP-309] Epoch {epoch} active checks: {sorted(active_checks)} (seed derived from prev_block_hash)") + chain_age_years = get_chain_age_years(current_slot) epoch_start_slot = epoch * 144 @@ -529,6 +545,10 @@ def calculate_epoch_rewards_time_aged( with sqlite3.connect(db_path) as conn: cursor = conn.cursor() + # Schema compatibility: detect whether fingerprint_checks_json column exists + cols = cursor.execute("PRAGMA table_info(miner_attest_recent)").fetchall() + has_checks_col = any(col[1] == 'fingerprint_checks_json' for col in cols) + # Primary source: epoch_enroll (per-epoch snapshot, matches finalize_epoch). try: cursor.execute( @@ -543,20 +563,25 @@ def calculate_epoch_rewards_time_aged( # Use enrolled miners; epoch_enroll.weight is the canonical per-epoch # reward weight snapshot and may already include RIP-309 rotation. epoch_miners = [] + check_sql = ( + ", fingerprint_checks_json " if has_checks_col else ", '{}' as fingerprint_checks_json " + ) for miner_pk, enrolled_weight in enrolled: arch_row = cursor.execute( - "SELECT device_arch, COALESCE(fingerprint_passed, 1) " + "SELECT device_arch, COALESCE(fingerprint_passed, 1)" + check_sql + "FROM miner_attest_recent WHERE miner = ? LIMIT 1", (miner_pk,) ).fetchone() if arch_row: device_arch = arch_row[0] or "unknown" fp = arch_row[1] + checks_json = arch_row[2] or '{}' if has_checks_col else '{}' else: # No attestation record — treat as unknown arch, fingerprint ok. device_arch = "unknown" fp = 1 - epoch_miners.append((miner_pk, device_arch, fp, enrolled_weight)) + checks_json = '{}' + epoch_miners.append((miner_pk, device_arch, fp, enrolled_weight, checks_json)) else: # SECURITY FIX #2159: Fallback for epochs without enrollment # records. This path is vulnerable to the stale-attestation @@ -568,11 +593,20 @@ def calculate_epoch_rewards_time_aged( "miner_attest_recent time-window query (may drop miners " "if settlement is delayed)", epoch ) - cursor.execute(""" - SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1) as fp - FROM miner_attest_recent - WHERE ts_ok >= ? AND ts_ok <= ? - """, (epoch_start_ts - ATTESTATION_TTL, epoch_end_ts)) + if has_checks_col: + cursor.execute(""" + SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1) as fp, + COALESCE(fingerprint_checks_json, '{}') as checks_json + FROM miner_attest_recent + WHERE ts_ok >= ? AND ts_ok <= ? + """, (epoch_start_ts - ATTESTATION_TTL, epoch_end_ts)) + else: + cursor.execute(""" + SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1) as fp, + '{}' as checks_json + FROM miner_attest_recent + WHERE ts_ok >= ? AND ts_ok <= ? + """, (epoch_start_ts - ATTESTATION_TTL, epoch_end_ts)) epoch_miners = cursor.fetchall() if not epoch_miners: @@ -586,7 +620,19 @@ def calculate_epoch_rewards_time_aged( miner_id, device_arch = row[0], row[1] fingerprint_ok = row[2] if len(row) > 2 else 1 enrolled_weight = row[3] if len(row) > 3 else None - + checks_json = row[4] if len(row) > 4 else '{}' + + # RIP-309: Only active checks count toward reward weight. + # Inactive checks still run and log, but their pass/fail does not affect reward. + try: + checks_map = json.loads(checks_json) if checks_json else {} + except Exception: + checks_map = {} + active_passed = all(checks_map.get(c, True) for c in active_checks) + if not active_passed: + print(f"[RIP-309] {miner_id[:20]}... failed active check(s) -> weight=0") + fingerprint_ok = 0 + # STRICT: VMs/emulators with failed fingerprint get ZERO weight if fingerprint_ok == 0: weight = 0.0 # No rewards for failed fingerprint diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 75d33d856..a7d15175f 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -7,6 +7,7 @@ import ipaddress from urllib.parse import urlparse from flask import Flask, request, jsonify, g, send_from_directory, send_file, abort, render_template_string, redirect +import json from beacon_anchor import init_beacon_table, store_envelope, compute_beacon_digest, get_recent_envelopes, VALID_KINDS try: # Deployment compatibility: production may run this file as a single script. @@ -2033,32 +2034,50 @@ def record_attestation_success(miner: str, device: dict, fingerprint_passed: boo _device["machine"] = "ppc64le" if "power8" in _miner_lower else "ppc" verified_device = derive_verified_device(_device, fingerprint if isinstance(fingerprint, dict) else {}, fingerprint_passed) with sqlite3.connect(DB_PATH) as conn: - # Ensure signing_pubkey column exists (idempotent migration) - try: - conn.execute("ALTER TABLE miner_attest_recent ADD COLUMN signing_pubkey TEXT") - except Exception: - pass # Column already exists or table doesn't exist yet + # Ensure signing_pubkey and fingerprint_checks_json columns exist (idempotent migrations) + for col_stmt in [ + "ALTER TABLE miner_attest_recent ADD COLUMN signing_pubkey TEXT", + "ALTER TABLE miner_attest_recent ADD COLUMN fingerprint_checks_json TEXT", + "ALTER TABLE miner_attest_history ADD COLUMN fingerprint_checks_json TEXT", + ]: + try: + conn.execute(col_stmt) + except Exception: + pass # Column already exists or table doesn't exist yet + + # Extract per-check results from fingerprint dict for RIP-309 rotation. + fp_checks_map = {} + if isinstance(fingerprint, dict) and "checks" in fingerprint: + for k, v in fingerprint["checks"].items(): + fp_checks_map[k] = bool(v.get("passed", False)) if isinstance(v, dict) else bool(v) + # Also handle top-level flattened results if present + for k in ["clock_drift", "cache_timing", "simd_identity", "thermal_drift", "instruction_jitter", "anti_emulation"]: + if k in fingerprint: + fp_checks_map[k] = bool(fingerprint[k]) + fingerprint_checks_json = json.dumps(fp_checks_map) if fp_checks_map else '{}' + # FIX: Prevent attestation overwrite from degrading prior fingerprint status. # If the miner already has fingerprint_passed=1, a later failed attestation # should not downgrade it. We still update ts_ok to keep the attestation fresh. new_fp = 1 if fingerprint_passed else 0 conn.execute(""" - INSERT INTO miner_attest_recent (miner, ts_ok, device_family, device_arch, entropy_score, fingerprint_passed, source_ip, signing_pubkey) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO miner_attest_recent (miner, ts_ok, device_family, device_arch, entropy_score, fingerprint_passed, source_ip, signing_pubkey, fingerprint_checks_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(miner) DO UPDATE SET ts_ok = excluded.ts_ok, device_family = excluded.device_family, device_arch = excluded.device_arch, source_ip = excluded.source_ip, fingerprint_passed = MAX(miner_attest_recent.fingerprint_passed, excluded.fingerprint_passed), - signing_pubkey = excluded.signing_pubkey - """, (miner, now, verified_device["device_family"], verified_device["device_arch"], 0.0, new_fp, source_ip, signing_pubkey)) + signing_pubkey = excluded.signing_pubkey, + fingerprint_checks_json = excluded.fingerprint_checks_json + """, (miner, now, verified_device["device_family"], verified_device["device_arch"], 0.0, new_fp, source_ip, signing_pubkey, fingerprint_checks_json)) _ = append_fingerprint_snapshot(conn, miner, fingerprint if isinstance(fingerprint, dict) else {}, now) # C3 fix: Record attestation history for first_attest tracking conn.execute(""" - INSERT INTO miner_attest_history (miner, ts_ok, device_family, device_arch, entropy_score, fingerprint_passed) - VALUES (?, ?, ?, ?, ?, ?) - """, (miner, now, verified_device["device_family"], verified_device["device_arch"], 0.0, new_fp)) + INSERT INTO miner_attest_history (miner, ts_ok, device_family, device_arch, entropy_score, fingerprint_passed, fingerprint_checks_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (miner, now, verified_device["device_family"], verified_device["device_arch"], 0.0, new_fp, fingerprint_checks_json)) conn.commit() # RIP-201: Record fleet immune system signals @@ -2650,7 +2669,7 @@ def current_slot(): """Get current slot number""" return (int(time.time()) - GENESIS_TIMESTAMP) // BLOCK_TIME -def finalize_epoch(epoch, per_block_rtc): +def finalize_epoch(epoch, per_block_rtc, prev_block_hash: bytes = b""): """Finalize epoch and distribute rewards with security hardening""" from decimal import Decimal, ROUND_DOWN @@ -2701,11 +2720,53 @@ def finalize_epoch(epoch, per_block_rtc): print(f"[SECURITY] No valid miners for epoch {epoch} after filtering") return + # RIP-309: Determine active fingerprint checks for this epoch + fp_checks = ['clock_drift', 'cache_timing', 'simd_identity', + 'thermal_drift', 'instruction_jitter', 'anti_emulation'] + if prev_block_hash: + nonce = hashlib.sha256(prev_block_hash + b"measurement_nonce").digest() + seed = int.from_bytes(nonce[:4], 'big') + active_checks = set(__import__('random').Random(seed).sample(fp_checks, 4)) + else: + active_checks = set(fp_checks) + print(f"[RIP-309] finalize_epoch {epoch} active checks: {sorted(active_checks)}") + + # Adjust weights based on active fingerprint checks + adjusted_miners = [] for pk, weight in miners: if weight > MAX_WEIGHT: print(f"[SECURITY] Capping weight {weight} for miner {pk} to {MAX_WEIGHT}") weight = MAX_WEIGHT + # RIP-309: zero out weight if any active check failed + if weight > 0: + try: + fp_row = c.execute( + "SELECT fingerprint_checks_json FROM miner_attest_recent WHERE miner = ?", + (pk,) + ).fetchone() + checks_map = {} + if fp_row and fp_row[0]: + try: + checks_map = json.loads(fp_row[0]) + except Exception: + pass + active_passed = all(checks_map.get(chk, True) for chk in active_checks) + if not active_passed: + print(f"[RIP-309] {pk[:20]}... failed active check(s) in finalize_epoch -> weight=0") + weight = 0 + except Exception: + pass + adjusted_miners.append((pk, weight)) + + # Recompute valid miners after RIP-309 zeroing + miners = [(pk, w) for pk, w in adjusted_miners if w > 0] + zero_weight_miners += [pk for pk, w in adjusted_miners if w == 0] + total_weight = sum(w for _, w in miners) + if total_weight == 0: + print(f"[SECURITY] No valid miners for epoch {epoch} after RIP-309 filtering") + return + # ATOMIC TRANSACTION: Wrap all updates in explicit transaction try: c.execute("BEGIN TRANSACTION") @@ -3759,7 +3820,13 @@ def ingest_signed_header(): if not settled_row: # Call finalize_epoch to distribute rewards try: - finalize_epoch(current_epoch) + # Compute block hash from the current header message_hex as prev_block_hash + prev_msg = db.execute( + "SELECT message_hex FROM headers WHERE slot = ? ORDER BY slot DESC LIMIT 1", + (slot,) + ).fetchone() + prev_block_hash = hashlib.sha256((prev_msg[0] if prev_msg else str(slot)).encode()).digest() if prev_msg else b"" + finalize_epoch(current_epoch, PER_EPOCH_RTC, prev_block_hash) print(f"[EPOCH] Auto-settled epoch {current_epoch} after {blocks_in_epoch} blocks") except Exception as e: print(f"[EPOCH] Settlement failed for epoch {current_epoch}: {e}") diff --git a/node/tests/test_rip309_fingerprint_rotation.py b/node/tests/test_rip309_fingerprint_rotation.py index 3c3b472d2..b505c9a96 100644 --- a/node/tests/test_rip309_fingerprint_rotation.py +++ b/node/tests/test_rip309_fingerprint_rotation.py @@ -1,128 +1,194 @@ -#!/usr/bin/env python3 +""" +RIP-309 Phase 1: Fingerprint Check Rotation Tests +==================================================== + +Tests for 4-of-6 rotating fingerprint checks per epoch. +""" + +import hashlib +import json import os +import random import sqlite3 import sys import tempfile import unittest -import importlib.util -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -NODE_DIR = ROOT / "node" -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(NODE_DIR)) - -os.environ.setdefault("RC_ADMIN_KEY", "0" * 32) -os.environ.setdefault("DB_PATH", ":memory:") - - -def _load_module(module_name: str, file_name: str, aliases=()): - for alias in aliases: - if alias in sys.modules: - return sys.modules[alias] - if module_name in sys.modules: - return sys.modules[module_name] - spec = importlib.util.spec_from_file_location(module_name, str(NODE_DIR / file_name)) - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - - -rr_mod = _load_module("rr_mod_issue3008", "rip_200_round_robin_1cpu1vote.py", aliases=("rr_mod",)) -integrated_node = _load_module("integrated_node_issue3008", "rustchain_v2_integrated_v2.2.1_rip200.py", aliases=("integrated_node",)) - - -class TestRIP309FingerprintRotation(unittest.TestCase): - def setUp(self): - self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) - self.db_path = self.tmp.name - self.tmp.close() - self.conn = sqlite3.connect(self.db_path) - self.conn.executescript( - """ - CREATE TABLE epoch_enroll ( - epoch INTEGER, - miner_pk TEXT, - weight REAL, - PRIMARY KEY (epoch, miner_pk) - ); - CREATE TABLE miner_attest_recent ( - miner TEXT PRIMARY KEY, - device_arch TEXT, - fingerprint_passed INTEGER DEFAULT 1, - entropy_score REAL DEFAULT 0, - ts_ok INTEGER, - warthog_bonus REAL DEFAULT 1.0 - ); - CREATE TABLE blocks ( - height INTEGER PRIMARY KEY, - block_hash TEXT NOT NULL - ); - """ - ) - integrated_node.ensure_epoch_fingerprint_rotation_table(self.conn) - self.conn.commit() - - def tearDown(self): - self.conn.close() - os.unlink(self.db_path) - - def test_rotation_differs_across_epochs(self): - self.conn.execute("INSERT INTO blocks (height, block_hash) VALUES (?, ?)", (143, "a" * 64)) - self.conn.execute("INSERT INTO blocks (height, block_hash) VALUES (?, ?)", (287, "b" * 64)) - self.conn.commit() - - epoch1 = integrated_node.get_epoch_fingerprint_rotation(self.conn, 1) - epoch2 = integrated_node.get_epoch_fingerprint_rotation(self.conn, 2) - - self.assertEqual(len(epoch1["active_checks"]), 4) - self.assertEqual(len(epoch2["active_checks"]), 4) - self.assertNotEqual(epoch1["measurement_nonce"], epoch2["measurement_nonce"]) - self.assertNotEqual(epoch1["active_checks"], epoch2["active_checks"]) - - def test_reward_calc_uses_epoch_snapshot_weight(self): - epoch = 3 - current_slot = epoch * integrated_node.EPOCH_SLOTS + 5 - self.conn.execute("INSERT INTO epoch_enroll (epoch, miner_pk, weight) VALUES (?, ?, ?)", (epoch, "rotated", 0.5)) - self.conn.execute("INSERT INTO epoch_enroll (epoch, miner_pk, weight) VALUES (?, ?, ?)", (epoch, "full", 1.0)) - self.conn.execute( - "INSERT INTO miner_attest_recent (miner, device_arch, fingerprint_passed, ts_ok) VALUES (?, ?, ?, ?)", - ("rotated", "g4", 1, integrated_node.GENESIS_TIMESTAMP), - ) - self.conn.execute( - "INSERT INTO miner_attest_recent (miner, device_arch, fingerprint_passed, ts_ok) VALUES (?, ?, ?, ?)", - ("full", "g4", 1, integrated_node.GENESIS_TIMESTAMP), - ) - self.conn.commit() - rewards = rr_mod.calculate_epoch_rewards_time_aged( - self.db_path, - epoch, - int(1.5 * 1_000_000), - current_slot, - ) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from rip_200_round_robin_1cpu1vote import calculate_epoch_rewards_time_aged, GENESIS_TIMESTAMP, BLOCK_TIME + - self.assertEqual(sum(rewards.values()), int(1.5 * 1_000_000)) - self.assertGreater(rewards["full"], rewards["rotated"]) - ratio = rewards["full"] / rewards["rotated"] - self.assertGreater(ratio, 1.9) - self.assertLess(ratio, 2.1) - - def test_rotation_eval_counts_only_active_checks(self): - self.conn.execute("INSERT INTO blocks (height, block_hash) VALUES (?, ?)", (143, "c" * 64)) - self.conn.commit() - rotation = integrated_node.get_epoch_fingerprint_rotation(self.conn, 1) - - fingerprint = {"checks": {name: {"passed": True, "data": {"ok": True}} for name in integrated_node.RIP309_ROTATING_FINGERPRINT_CHECKS}} - inactive = next(name for name in integrated_node.RIP309_ROTATING_FINGERPRINT_CHECKS if name not in rotation["active_checks"]) - fingerprint["checks"][inactive] = {"passed": False, "data": {"ok": False}} - - result = integrated_node.evaluate_rotating_fingerprint_checks(self.conn, 1, fingerprint) - self.assertEqual(result["active_pass_count"], 4) - self.assertEqual(result["active_total"], 4) - self.assertEqual(result["failed_active_checks"], []) - self.assertEqual(result["active_ratio"], 1.0) +def _init_db(conn): + conn.execute(""" + CREATE TABLE epoch_enroll ( + epoch INTEGER, + miner_pk TEXT, + weight INTEGER DEFAULT 100 + ) + """) + conn.execute(""" + CREATE TABLE miner_attest_recent ( + miner TEXT PRIMARY KEY, + device_arch TEXT, + ts_ok INTEGER, + fingerprint_passed INTEGER DEFAULT 1, + entropy_score REAL, + fingerprint_checks_json TEXT + ) + """) + conn.commit() + + +def _insert_miner(conn, miner, device_arch="x86_64", passed_all=True, checks=None): + ts = GENESIS_TIMESTAMP + 1000 + if checks is None: + checks = { + "clock_drift": passed_all, + "cache_timing": passed_all, + "simd_identity": passed_all, + "thermal_drift": passed_all, + "instruction_jitter": passed_all, + "anti_emulation": passed_all, + } + conn.execute( + "INSERT INTO miner_attest_recent (miner, device_arch, ts_ok, fingerprint_passed, fingerprint_checks_json) " + "VALUES (?, ?, ?, ?, ?)", + (miner, device_arch, ts, 1 if passed_all else 0, json.dumps(checks)) + ) + conn.commit() + + +def _enroll_miner(conn, epoch, miner, weight=100): + conn.execute( + "INSERT INTO epoch_enroll (epoch, miner_pk, weight) VALUES (?, ?, ?)", + (epoch, miner, weight) + ) + conn.commit() + + +class TestRip309Rotation(unittest.TestCase): + + def _fresh_db(self): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + conn = sqlite3.connect(path) + _init_db(conn) + conn.close() + return path + + def test_determinism_same_hash(self): + """Same block hash must produce the same active check set.""" + db_path = self._fresh_db() + conn = sqlite3.connect(db_path) + _enroll_miner(conn, 1, "alice", 100) + _insert_miner(conn, "alice", passed_all=True) + conn.close() + + prev_hash = b"deadbeef" * 4 + results = [] + for _ in range(5): + rewards = calculate_epoch_rewards_time_aged(db_path, 1, 1_000_000, 200, prev_hash) + results.append(rewards) + + # All identical + self.assertEqual(len(set(tuple(sorted(r.items())) for r in results)), 1) + os.unlink(db_path) + + def test_unpredictability_different_hashes(self): + """Different block hashes should produce different active sets over many trials.""" + db_path = self._fresh_db() + conn = sqlite3.connect(db_path) + _enroll_miner(conn, 1, "alice", 100) + # 4 passed, 2 failed => possible to select all 4 passed checks + checks = { + "clock_drift": True, "cache_timing": True, "simd_identity": True, + "thermal_drift": True, "instruction_jitter": False, "anti_emulation": False, + } + _insert_miner(conn, "alice", checks=checks) + conn.close() + + selections = set() + for i in range(100): + h = hashlib.sha256(str(i).encode()).digest() + rewards = calculate_epoch_rewards_time_aged(db_path, 1, 1_000_000, 200, h) + selections.add(rewards.get("alice", 0)) + + self.assertTrue(0 in selections and max(selections) > 0, + f"Expected mixed rewards across hashes, got {selections}") + os.unlink(db_path) + + def test_only_active_checks_affect_weight(self): + """A miner failing only inactive checks should still receive rewards.""" + db_path = self._fresh_db() + conn = sqlite3.connect(db_path) + _enroll_miner(conn, 1, "alice", 100) + checks = { + "clock_drift": True, "cache_timing": True, "simd_identity": True, + "thermal_drift": True, "instruction_jitter": True, "anti_emulation": False, + } + _insert_miner(conn, "alice", checks=checks) + conn.close() + + for i in range(1000): + h = hashlib.sha256(str(i).encode()).digest() + fp_checks = ['clock_drift', 'cache_timing', 'simd_identity', + 'thermal_drift', 'instruction_jitter', 'anti_emulation'] + seed = int.from_bytes(hashlib.sha256(h + b"measurement_nonce").digest()[:4], 'big') + active = set(random.Random(seed).sample(fp_checks, 4)) + if "anti_emulation" not in active: + rewards = calculate_epoch_rewards_time_aged(db_path, 1, 1_000_000, 200, h) + self.assertGreater(rewards.get("alice", 0), 0, + "Alice should receive rewards when failing check is inactive") + os.unlink(db_path) + return + + os.unlink(db_path) + self.fail("Could not find a hash where anti_emulation was inactive in 1000 attempts") + + def test_active_failure_zeroes_reward(self): + """A miner failing an active check should get zero rewards.""" + db_path = self._fresh_db() + conn = sqlite3.connect(db_path) + _enroll_miner(conn, 1, "alice", 100) + checks = { + "clock_drift": True, "cache_timing": True, "simd_identity": True, + "thermal_drift": True, "instruction_jitter": True, "anti_emulation": False, + } + _insert_miner(conn, "alice", checks=checks) + conn.close() + + for i in range(1000): + h = hashlib.sha256(str(i).encode()).digest() + fp_checks = ['clock_drift', 'cache_timing', 'simd_identity', + 'thermal_drift', 'instruction_jitter', 'anti_emulation'] + seed = int.from_bytes(hashlib.sha256(h + b"measurement_nonce").digest()[:4], 'big') + active = set(random.Random(seed).sample(fp_checks, 4)) + if "anti_emulation" in active: + rewards = calculate_epoch_rewards_time_aged(db_path, 1, 1_000_000, 200, h) + self.assertEqual(rewards.get("alice", 0), 0, + "Alice should get zero rewards when failing check is active") + os.unlink(db_path) + return + + os.unlink(db_path) + self.fail("Could not find a hash where anti_emulation was active in 1000 attempts") + + def test_fallback_all_checks_when_no_prev_hash(self): + """When prev_block_hash is empty, all checks are active (backward compat).""" + db_path = self._fresh_db() + conn = sqlite3.connect(db_path) + _enroll_miner(conn, 1, "alice", 100) + checks = { + "clock_drift": True, "cache_timing": True, "simd_identity": True, + "thermal_drift": True, "instruction_jitter": True, "anti_emulation": True, + } + _insert_miner(conn, "alice", checks=checks) + conn.close() + + rewards = calculate_epoch_rewards_time_aged(db_path, 1, 1_000_000, 200, b"") + self.assertGreater(rewards.get("alice", 0), 0) + os.unlink(db_path) if __name__ == "__main__": From 36a4ef814e28d0215ddebf0540ee39499c59dff6 Mon Sep 17 00:00:00 2001 From: XiaZong Date: Mon, 13 Apr 2026 07:33:40 +0000 Subject: [PATCH 2/3] Add CI workflow for RIP-309 fingerprint rotation tests --- .github/workflows/rip309-ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/rip309-ci.yml diff --git a/.github/workflows/rip309-ci.yml b/.github/workflows/rip309-ci.yml new file mode 100644 index 000000000..942c11d1d --- /dev/null +++ b/.github/workflows/rip309-ci.yml @@ -0,0 +1,30 @@ +name: RIP-309 Fingerprint Rotation CI + +on: + push: + branches: + - feature/rip309-fingerprint-rotation + pull_request: + branches: + - main + +jobs: + rip309-tests: + name: RIP-309 Fingerprint Rotation + Settlement Integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install node dependencies + run: pip install -r requirements-node.txt + + - name: Run RIP-309 rotation tests + run: python node/tests/test_rip309_fingerprint_rotation.py -v + + - name: Run settlement integrity tests + run: python node/tests/test_settlement_integrity.py -v From 5100c1ec882efab10abf4cb08b1bf38c1c3b60e5 Mon Sep 17 00:00:00 2001 From: XiaZong Date: Fri, 17 Apr 2026 11:00:01 +0000 Subject: [PATCH 3/3] fix: add enrolled_weight column to fallback SQL queries The fallback path (no epoch_enroll rows) returned 4 columns but the merged code expects 5: (miner, device_arch, fp, enrolled_weight, checks_json). Missing enrolled_weight caused row[3] to get checks_json ('{}'), then float('{}') raised ValueError in 17 CI tests. Co-Authored-By: Claude Opus 4.6 --- node/rip_200_round_robin_1cpu1vote.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/node/rip_200_round_robin_1cpu1vote.py b/node/rip_200_round_robin_1cpu1vote.py index 4e9071274..e54bc5741 100644 --- a/node/rip_200_round_robin_1cpu1vote.py +++ b/node/rip_200_round_robin_1cpu1vote.py @@ -596,6 +596,7 @@ def calculate_epoch_rewards_time_aged( if has_checks_col: cursor.execute(""" SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1) as fp, + NULL as enrolled_weight, COALESCE(fingerprint_checks_json, '{}') as checks_json FROM miner_attest_recent WHERE ts_ok >= ? AND ts_ok <= ? @@ -603,6 +604,7 @@ def calculate_epoch_rewards_time_aged( else: cursor.execute(""" SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1) as fp, + NULL as enrolled_weight, '{}' as checks_json FROM miner_attest_recent WHERE ts_ok >= ? AND ts_ok <= ?