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/settle → settle_epoch_rip200 → calculate_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
- Epoch 100: Miner A (G4, weight 2.5) and Miner B (modern, weight 1.0) both attest and enroll.
- Epoch 101: Miner A re-attests (normal TTL refresh).
miner_attest_recent.ts_ok moves to epoch 101.
- Node crashes or
/submit path doesn't fire. Epoch 100 is unsettled.
- Node restarts. Auto-settler calls
POST /rewards/settle for epoch 100.
calculate_epoch_rewards_time_aged queries miner_attest_recent WHERE ts_ok IN epoch_100_window. Miner A is missing.
- Miner B receives 100% of epoch 100 rewards (1.5 RTC). Miner A receives 0%.
- 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:
calculate_epoch_rewards_time_aged in rip_200_round_robin_1cpu1vote.py
get_epoch_miner_groups in anti_double_mining.py
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.py — calculate_epoch_rewards_time_aged
node/anti_double_mining.py — get_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)
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/settle→settle_epoch_rip200→calculate_epoch_rewards_time_aged) reads eligible miners fromminer_attest_recentfiltered by the epoch's time window. Butminer_attest_recentis a single-row-per-miner table — each new attestation overwrites the previous row'sts_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_okmoved forward. The time-window queryWHERE ts_ok >= epoch_N_start AND ts_ok <= epoch_N_endsilently drops them. They receive zero rewards for epoch N.The inline
finalize_epoch()path (called fromPOST /submit) does not have this bug — it reads fromepoch_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)When a miner re-attests after epoch N ends, their
ts_okis no longer in epoch N's window. They disappear from the settlement query.get_epoch_miner_groupsanddetect_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 affectedThis is correct because
epoch_enrollis keyed by(epoch, miner_pk)and never overwritten by later attestations.Exploit Path
miner_attest_recent.ts_okmoves to epoch 101./submitpath doesn't fire. Epoch 100 is unsettled.POST /rewards/settlefor epoch 100.calculate_epoch_rewards_time_agedqueriesminer_attest_recent WHERE ts_ok IN epoch_100_window. Miner A is missing.finalize_epochhad 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_enrollagainstepoch_rewards.Impact
Distinction from Prior Submissions
epoch > current_epochacceptanceINSERT OR REPLACEdowngrades state/epoch/enrollabuseThis 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_enrollas the canonical miner list in all settlement paths, falling back to theminer_attest_recenttime-window query only whenepoch_enrollhas no rows (legacy compatibility).Three functions patched:
calculate_epoch_rewards_time_agedinrip_200_round_robin_1cpu1vote.pyget_epoch_miner_groupsinanti_double_mining.pydetect_duplicate_identitiesinanti_double_mining.pyEach now queries
epoch_enroll WHERE epoch = ?first. If rows exist, uses them as the miner list and looks updevice_arch/fingerprint_passedfromminer_attest_recentfor multiplier calculation. Falls back to the old time-window query only for epochs without enrollment records.Files Changed
node/rip_200_round_robin_1cpu1vote.py—calculate_epoch_rewards_time_agednode/anti_double_mining.py—get_epoch_miner_groups,detect_duplicate_identitiesnode/tests/test_settlement_integrity.py— 6 new testsValidation