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
27 changes: 25 additions & 2 deletions node/sophia_elya_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions node/tests/test_sophia_elya_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sqlite3
import time

from node import sophia_elya_service as elya
Expand Down Expand Up @@ -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"]})

Expand Down
Loading