diff --git a/backend/services/infonet/markets/dispute.py b/backend/services/infonet/markets/dispute.py index be1a51a..dc0de2d 100644 --- a/backend/services/infonet/markets/dispute.py +++ b/backend/services/infonet/markets/dispute.py @@ -28,6 +28,7 @@ Two effects: from __future__ import annotations from dataclasses import dataclass, field +from math import isfinite from typing import Any, Iterable from services.infonet.config import CONFIG @@ -38,6 +39,15 @@ def _payload(event: dict[str, Any]) -> dict[str, Any]: return p if isinstance(p, dict) else {} +def _finite_float(value: Any) -> float | None: + """Parse a chain numeric, rejecting malformed and non-finite values.""" + try: + parsed = float(value) + except (TypeError, ValueError, OverflowError): + return None + return parsed if isfinite(parsed) else None + + @dataclass class DisputeView: """Chain-derived view of a single dispute.""" @@ -87,18 +97,18 @@ def collect_disputes( if not open_events: return [] - # Build by dispute_id keyed off the open event. + # Build by dispute_id keyed off the open event. A malformed + # authoritative open must not be normalized into active state. disputes: dict[str, DisputeView] = {} open_id_by_market_event: dict[str, str] = {} for ev in open_events: p = _payload(ev) + cstake = _finite_float(p.get("challenger_stake")) + opened_at = _finite_float(ev.get("timestamp")) + if cstake is None or opened_at is None: + continue did = _dispute_id(ev) challenger = ev.get("node_id") or "" - try: - cstake = float(p.get("challenger_stake") or 0.0) - except (TypeError, ValueError): - cstake = 0.0 - opened_at = float(ev.get("timestamp") or 0.0) disputes[did] = DisputeView( dispute_id=did, market_id=str(market_id), challenger_id=str(challenger), challenger_stake=cstake, @@ -120,11 +130,8 @@ def collect_disputes( rep_type = p.get("rep_type") if rep_type not in ("oracle", "common"): continue - try: - amount = float(p.get("amount") or 0.0) - except (TypeError, ValueError): - continue - if amount <= 0: + amount = _finite_float(p.get("amount")) + if amount is None or amount <= 0: continue record = { "node_id": ev.get("node_id") or "", @@ -134,7 +141,8 @@ def collect_disputes( target = disputes[did].confirm_stakes if side == "confirm" else disputes[did].reverse_stakes target.append(record) - # Resolution events. + # Resolution events. Invalid resolution timestamps fail closed: + # they cannot exert consensus authority through resolved_outcome. for ev in chain_list: if ev.get("event_type") != "dispute_resolve": continue @@ -145,8 +153,11 @@ def collect_disputes( outcome = p.get("outcome") if outcome not in ("upheld", "reversed", "tie"): continue + resolved_at = _finite_float(ev.get("timestamp")) + if resolved_at is None: + continue disputes[did].resolved_outcome = outcome - disputes[did].resolved_at = float(ev.get("timestamp") or 0.0) + disputes[did].resolved_at = resolved_at return sorted(disputes.values(), key=lambda d: (d.opened_at, d.dispute_id)) diff --git a/backend/services/infonet/tests/test_dispute_nonfinite_values.py b/backend/services/infonet/tests/test_dispute_nonfinite_values.py new file mode 100644 index 0000000..1c82416 --- /dev/null +++ b/backend/services/infonet/tests/test_dispute_nonfinite_values.py @@ -0,0 +1,110 @@ +"""Regression coverage for malformed and non-finite dispute numerics.""" + +from services.infonet.markets.dispute import collect_disputes + + +def _open_event( + *, dispute_id: str, market_id: str, timestamp: object, challenger_stake: object +) -> dict: + return { + "event_type": "dispute_open", + "event_id": dispute_id, + "node_id": "challenger", + "timestamp": timestamp, + "payload": { + "market_id": market_id, + "challenger_stake": challenger_stake, + }, + } + + +def test_malformed_authoritative_open_events_are_excluded() -> None: + chain = [ + _open_event( + dispute_id="bad-time", + market_id="market-1", + timestamp="not-a-timestamp", + challenger_stake=3.0, + ), + _open_event( + dispute_id="bad-stake", + market_id="market-1", + timestamp=10.0, + challenger_stake=float("nan"), + ), + ] + + assert collect_disputes("market-1", chain) == [] + + +def test_invalid_economic_and_resolution_values_fail_closed() -> None: + chain = [ + _open_event( + dispute_id="dispute-1", + market_id="market-1", + timestamp=10.0, + challenger_stake=3.0, + ), + { + "event_type": "dispute_stake", + "node_id": "oracle-1", + "timestamp": 11.0, + "payload": { + "dispute_id": "dispute-1", + "side": "confirm", + "rep_type": "oracle", + "amount": float("nan"), + }, + }, + { + "event_type": "dispute_resolve", + "timestamp": float("inf"), + "payload": { + "dispute_id": "dispute-1", + "outcome": "reversed", + }, + }, + ] + + dispute = collect_disputes("market-1", chain)[0] + + assert dispute.confirm_stakes == [] + assert dispute.resolved_outcome is None + assert dispute.resolved_at is None + + +def test_collect_disputes_preserves_finite_numeric_strings() -> None: + chain = [ + _open_event( + dispute_id="dispute-2", + market_id="market-2", + timestamp="12.5", + challenger_stake="3.5", + ), + { + "event_type": "dispute_stake", + "node_id": "oracle-2", + "payload": { + "dispute_id": "dispute-2", + "side": "reverse", + "rep_type": "oracle", + "amount": "2.25", + }, + }, + { + "event_type": "dispute_resolve", + "timestamp": "13.5", + "payload": { + "dispute_id": "dispute-2", + "outcome": "reversed", + }, + }, + ] + + dispute = collect_disputes("market-2", chain)[0] + + assert dispute.challenger_stake == 3.5 + assert dispute.opened_at == 12.5 + assert dispute.reverse_stakes[0]["amount"] == 2.25 + assert dispute.resolved_outcome == "reversed" + assert dispute.resolved_at == 13.5