Skip to content

security: delayed RIP-200 settlement can silently drop eligible miners #2159

@createkr

Description

@createkr

Delayed epoch settlement silently drops miners from reward distribution

Severity: High
Component: node/rip_200_round_robin_1cpu1vote.py (calculate_epoch_rewards_time_aged), node/anti_double_mining.py (get_epoch_miner_groups, detect_duplicate_identities)
Labels: bug, rewards, settlement, security

Summary

The RIP-200 settlement path (POST /rewards/settlesettle_epoch_rip200calculate_epoch_rewards_time_aged) reads eligible miners from miner_attest_recent filtered by the epoch's time window. But miner_attest_recent is a single-row-per-miner table — each new attestation overwrites the previous row's ts_ok.

When settlement of epoch N is delayed (node restart, auto-settler catch-up), any miner who re-attested in epoch N+1 has their ts_ok moved forward. The time-window query WHERE ts_ok >= epoch_N_start AND ts_ok <= epoch_N_end silently drops them. They receive zero rewards for epoch N.

The inline finalize_epoch() path (called from POST /submit) does not have this bug — it reads from epoch_enroll, a per-epoch snapshot table. The two settlement paths therefore produce different reward distributions for the same epoch, with no reconciliation mechanism or warning.

Root Cause

calculate_epoch_rewards_time_aged (rip_200_round_robin_1cpu1vote.py)

# Queries miner_attest_recent — a "latest attestation" table
cursor.execute("""
    SELECT DISTINCT miner, device_arch, COALESCE(fingerprint_passed, 1)
    FROM miner_attest_recent
    WHERE ts_ok >= ? AND ts_ok <= ?
""", (epoch_start_ts - ATTESTATION_TTL, epoch_end_ts))

When a miner re-attests after epoch N ends, their ts_ok is no longer in epoch N's window. They disappear from the settlement query.

get_epoch_miner_groups and detect_duplicate_identities (anti_double_mining.py)

Same pattern — both functions query miner_attest_recent WHERE ts_ok >= ? AND ts_ok <= ? and suffer the same staleness issue.

finalize_epoch (rustchain_v2_integrated_v2.2.1_rip200.py) — NOT affected

# Reads epoch_enroll — a per-epoch snapshot
miners = c.execute(
    "SELECT miner_pk, weight FROM epoch_enroll WHERE epoch = ?", (epoch,)
).fetchall()

This is correct because epoch_enroll is keyed by (epoch, miner_pk) and never overwritten by later attestations.

Exploit Path

  1. Epoch 100: Miner A (G4, weight 2.5) and Miner B (modern, weight 1.0) both attest and enroll.
  2. Epoch 101: Miner A re-attests (normal TTL refresh). miner_attest_recent.ts_ok moves to epoch 101.
  3. Node crashes or /submit path doesn't fire. Epoch 100 is unsettled.
  4. Node restarts. Auto-settler calls POST /rewards/settle for epoch 100.
  5. calculate_epoch_rewards_time_aged queries miner_attest_recent WHERE ts_ok IN epoch_100_window. Miner A is missing.
  6. Miner B receives 100% of epoch 100 rewards (1.5 RTC). Miner A receives 0%.
  7. If finalize_epoch had run, it would have distributed: A ≈ 1.07 RTC, B ≈ 0.43 RTC.

Silent. No error, no warning, no audit trail. The discrepancy is undetectable without cross-referencing epoch_enroll against epoch_rewards.

Impact

  • Silent reward loss for legitimate miners under normal operational conditions (delayed settlement).
  • Non-deterministic — same epoch produces different results depending on which path settles it.
  • Undermines auditability — no reconciliation mechanism between the two paths.
  • Exploitable — an attacker who can influence settlement timing and control re-attestation can skew reward distribution.

Distinction from Prior Submissions

Prior Issue Topic Why Distinct
Future epoch settlement (#2157-range) epoch > current_epoch acceptance Temporal validation, not eligibility source
Double-credit race Concurrent settlement on separate connections Transaction/connection ownership bug
Attestation overwrite reward loss INSERT OR REPLACE downgrades state Self-inflicted state overwrite
Zero-weight enroll downgrade External /epoch/enroll abuse Endpoint abuse, not stale reads

This finding covers the dual-path eligibility source mismatch — neither path's individual logic was previously reported as a cross-path integrity issue.

Fix

Use epoch_enroll as the canonical miner list in all settlement paths, falling back to the miner_attest_recent time-window query only when epoch_enroll has no rows (legacy compatibility).

Three functions patched:

  1. calculate_epoch_rewards_time_aged in rip_200_round_robin_1cpu1vote.py
  2. get_epoch_miner_groups in anti_double_mining.py
  3. detect_duplicate_identities in anti_double_mining.py

Each now queries epoch_enroll WHERE epoch = ? first. If rows exist, uses them as the miner list and looks up device_arch/fingerprint_passed from miner_attest_recent for multiplier calculation. Falls back to the old time-window query only for epochs without enrollment records.

Files Changed

  • node/rip_200_round_robin_1cpu1vote.pycalculate_epoch_rewards_time_aged
  • node/anti_double_mining.pyget_epoch_miner_groups, detect_duplicate_identities
  • node/tests/test_settlement_integrity.py — 6 new tests

Validation

tests/test_settlement_integrity.py: 6 passed
tests/test_rewards_settle_race.py: 4 passed (existing, no regression)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions