From 5cd9d09b954f97b0f89e42afe244b45e22f5a882 Mon Sep 17 00:00:00 2001 From: Vyacheslav-Tomashevskiy Date: Fri, 3 Jul 2026 16:49:01 +0200 Subject: [PATCH] fix(sophia): reject negative enroll weights that amplify epoch payouts (#14583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /epoch/enroll endpoint parsed `temporal`/`rtc` weight factors with `_finite_float`, which accepts any finite value including negatives. The resulting `total_weight = temporal * rtc * hw` was stored verbatim, so a caller with a valid ticket could enroll a negative weight. At settlement, `finalize_epoch` computes `sum_w = sum(weights)`; a negative weight shrinks sum_w and inflates every other miner's pro-rata share (`w / sum_w`), enabling reward theft / denial-of-weight. This was the only enrollment path in the node that did not exclude non-positive weights — rip0202_enrollment and rustchain_block_producer already do (`weight <= 0` / "negative weights canonicalise to 0 units"). Fix (defense in depth): - /epoch/enroll rejects negative `temporal`/`rtc` and any `total_weight <= 0` with `invalid_weights`, before consuming the ticket. - enroll_epoch() backstops direct callers: non-finite / non-positive weights are never persisted. - finalize_epoch() excludes non-positive / non-finite weights from sum_w and payouts, so a poisoned legacy row cannot distort settlement. Adds tests covering the endpoint rejection (ticket preserved), the enroll_epoch backstop, and settlement exclusion of a poisoned negative row. Co-Authored-By: Claude Opus 4.8 (1M context) --- node/sophia_elya_service.py | 27 +++++++++- node/tests/test_sophia_elya_service.py | 70 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/node/sophia_elya_service.py b/node/sophia_elya_service.py index 9fdabb85f..e0e917a5e 100644 --- a/node/sophia_elya_service.py +++ b/node/sophia_elya_service.py @@ -203,8 +203,15 @@ def enroll_epoch(epoch, miner_pk, weight): distortion vector where an attacker could overwrite a legitimate miner's weight via repeated enroll calls. """ + weight = float(weight) + # Backstop: never persist a non-positive / non-finite weight. A negative + # weight shrinks the epoch's total weight (sum_w) at settlement and + # amplifies every other miner's pro-rata share. Callers validate first; + # this guard protects any internal enrollment path as well. + if not math.isfinite(weight) or weight <= 0: + return with sqlite3.connect(DB_PATH) as c: - c.execute("INSERT OR IGNORE INTO epoch_enroll(epoch, miner_pk, weight) VALUES (?,?,?)", (epoch, miner_pk, float(weight))) + c.execute("INSERT OR IGNORE INTO epoch_enroll(epoch, miner_pk, weight) VALUES (?,?,?)", (epoch, miner_pk, weight)) def finalize_epoch(epoch, per_block_rtc): """Finalize epoch and distribute rewards""" @@ -244,6 +251,14 @@ def finalize_epoch(epoch, per_block_rtc): try: total_reward = per_block_rtc * blocks miners = list(c.execute("SELECT miner_pk, weight FROM epoch_enroll WHERE epoch=?", (epoch,))) + # Exclude non-positive / non-finite weights before computing sum_w. + # A poisoned legacy row (e.g. a negative weight enrolled before this + # guard existed) must not shrink sum_w and inflate other payouts. + miners = [ + (pk, w) + for pk, w in miners + if isinstance(w, (int, float)) and math.isfinite(w) and w > 0 + ] sum_w = sum(w for _, w in miners) or 0.0 payouts = [] @@ -409,10 +424,18 @@ def epoch_enroll(): # Calculate weight = temporal × rtc × hardware temporal = _finite_float(weights.get("temporal", 1.0)) rtc = _finite_float(weights.get("rtc", 1.0)) - if temporal is None or rtc is None: + # Reject negative factors up front. A negative temporal/rtc weight (or a + # product that lands at <= 0) shrinks the epoch's total weight (sum_w) at + # settlement, amplifying every other miner's pro-rata payout and enabling + # reward theft / denial-of-weight. Every other enrollment path in the node + # already excludes non-positive weights (rip0202_enrollment, + # rustchain_block_producer); this endpoint was the last one that did not. + if temporal is None or rtc is None or temporal < 0 or rtc < 0: return jsonify({"ok": False, "reason": "invalid_weights"}), 400 hw = get_hardware_weight(device) total_weight = temporal * rtc * hw + if not (total_weight > 0): + return jsonify({"ok": False, "reason": "invalid_weights"}), 400 # Enroll # Consume ticket after all request validation so malformed requests do not diff --git a/node/tests/test_sophia_elya_service.py b/node/tests/test_sophia_elya_service.py index 83d226377..c9024dfba 100644 --- a/node/tests/test_sophia_elya_service.py +++ b/node/tests/test_sophia_elya_service.py @@ -1,3 +1,4 @@ +import sqlite3 import time from node import sophia_elya_service as elya @@ -88,6 +89,75 @@ def test_elya_epoch_enroll_rejects_invalid_weights_before_consuming_ticket(): assert ticket_id in elya.tickets_db +def test_elya_epoch_enroll_rejects_negative_weight_before_consuming_ticket(): + ticket_id = "negative-weight-ticket" + elya.tickets_db[ticket_id] = {"expires_at": time.time() + 60} + + resp = _client().post( + "/epoch/enroll", + json={ + "miner_pubkey": "miner-a", + "ticket_id": ticket_id, + "weights": {"temporal": -5.0}, + }, + ) + + assert resp.status_code == 400 + assert resp.get_json() == {"ok": False, "reason": "invalid_weights"} + # A rejected request must not burn the ticket. + assert ticket_id in elya.tickets_db + + +def test_enroll_epoch_ignores_non_positive_weight(): + # Backstop: even a direct call must never persist a non-positive weight, + # which would shrink sum_w at settlement and amplify other payouts. + elya.init_db() + epoch = 990001 + elya.enroll_epoch(epoch, "miner-neg", -3.0) + elya.enroll_epoch(epoch, "miner-zero", 0.0) + elya.enroll_epoch(epoch, "miner-ok", 2.0) + + with sqlite3.connect(elya.DB_PATH) as conn: + rows = dict( + conn.execute( + "SELECT miner_pk, weight FROM epoch_enroll WHERE epoch=?", (epoch,) + ) + ) + + assert rows == {"miner-ok": 2.0} + + +def test_finalize_epoch_excludes_poisoned_negative_weight_rows(): + # A poisoned legacy row (negative weight written before the guard existed) + # must not shrink sum_w and inflate the honest miner's payout. + elya.init_db() + epoch = 990002 + with sqlite3.connect(elya.DB_PATH) as conn: + conn.execute( + "INSERT OR REPLACE INTO epoch_enroll(epoch, miner_pk, weight) VALUES (?,?,?)", + (epoch, "honest", 1.0), + ) + conn.execute( + "INSERT OR REPLACE INTO epoch_enroll(epoch, miner_pk, weight) VALUES (?,?,?)", + (epoch, "poisoned", -9.0), + ) + conn.execute( + "INSERT OR IGNORE INTO epoch_state(epoch, accepted_blocks, finalized, settled) " + "VALUES (?,?,0,0)", + (epoch, 1), + ) + conn.commit() + + result = elya.finalize_epoch(epoch, per_block_rtc=10.0) + + assert result["ok"] is True + # sum_w counts only the honest positive weight, so the whole reward goes + # to the honest miner instead of being amplified by the negative row. + assert result["sum_w"] == 1.0 + paid = dict(result["payouts"]) + assert set(paid) == {"honest"} + + def test_elya_attest_submit_rejects_non_object_report(): resp = _client().post("/attest/submit", json={"report": ["not", "object"]})