Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/rip309-ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion node/rewards_implementation_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
66 changes: 57 additions & 9 deletions node/rip_200_round_robin_1cpu1vote.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json
import random
import hashlib
#!/usr/bin/env python3
Comment on lines +1 to 4
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shebang is no longer the first line of the file (imports were added above it). If this file is ever executed directly (./rip_200_round_robin_1cpu1vote.py), the OS may not invoke Python correctly. Move the shebang back to line 1 (or remove it if the file isn’t intended to be executed as a script).

Suggested change
import json
import random
import hashlib
#!/usr/bin/env python3
#!/usr/bin/env python3
import json
import random
import hashlib

Copilot uses AI. Check for mistakes.
"""
RIP-200: Round-Robin Consensus (1 CPU = 1 Vote)
Expand Down Expand Up @@ -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
Expand All @@ -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')
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIP-309 seed derivation truncates SHA256 to the first 4 bytes (32-bit). This significantly increases the chance of collisions across epochs and reduces the effective unpredictability of the 4-of-6 selection. Consider seeding with the full 32-byte digest (e.g., int.from_bytes(nonce, 'big')) so the active-check set changes with the entire prev_block_hash.

Suggested change
seed = int.from_bytes(nonce[:4], 'big')
seed = int.from_bytes(nonce, 'big')

Copilot uses AI. Check for mistakes.
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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -568,11 +593,22 @@ 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,
NULL as enrolled_weight,
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,
NULL as enrolled_weight,
'{}' 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:
Expand All @@ -586,7 +622,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
Expand Down
95 changes: 81 additions & 14 deletions node/rustchain_v2_integrated_v2.2.1_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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])
Comment on lines +2054 to +2056
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fingerprint is optional (default None) but this loop uses if k in fingerprint, which raises a TypeError when fingerprint is None or non-dict. Guard this block with isinstance(fingerprint, dict) (or iterate only over fp_checks_map extracted from the dict) to keep attestation recording from crashing when no fingerprint is submitted.

Suggested change
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])
if isinstance(fingerprint, dict):
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])

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Comment on lines +2726 to +2730
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIP-309 active-check selection uses only nonce[:4] to build the RNG seed and uses __import__('random') for sampling. Using the full SHA256 digest as the seed avoids 32-bit collisions, and importing random normally is clearer/cheaper than dynamic import.

Copilot uses AI. Check for mistakes.
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")
Expand Down Expand Up @@ -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""
Comment on lines +3823 to +3828
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prev_block_hash is computed as SHA256 over the ASCII hex string (message_hex.encode()), and the query fetches the current slot’s header (WHERE slot = ?). This is a non-standard “block hash” definition and may not match other components’ expectations for prev_block_hash. Consider hashing the underlying message bytes (hex-decode first) and (if intended) selecting the previous slot/header deterministically (e.g., slot-1 or the last header before epoch end).

Suggested change
# 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""
# Compute prev_block_hash from the canonical last header in the epoch being settled.
prev_msg = db.execute(
"SELECT message_hex FROM headers WHERE slot >= ? AND slot < ? ORDER BY slot DESC LIMIT 1",
(epoch_start, epoch_end)
).fetchone()
if prev_msg and prev_msg[0]:
try:
prev_block_hash = hashlib.sha256(bytes.fromhex(prev_msg[0])).digest()
except ValueError as hex_err:
raise ValueError(f"Invalid message_hex for slot header used in epoch {current_epoch} settlement: {hex_err}")
else:
prev_block_hash = b""

Copilot uses AI. Check for mistakes.
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}")
Expand Down
Loading
Loading