mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-19 02:07:21 +02:00
230 lines
9.0 KiB
Python
230 lines
9.0 KiB
Python
"""Sprint 2 — identity rotation gates and descendant tracking.
|
|
|
|
Maps to IMPLEMENTATION_PLAN.md §7.1 Sprint 2 row:
|
|
"Identity rotation during active stakes is rejected."
|
|
|
|
The non-hostile UX rule (BUILD_LOG.md cross-cutting design rule #1)
|
|
also applies: rejection MUST come back as structured ``RotationBlocker``
|
|
data so the UI can offer the user a path forward, not a 4xx wall.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from services.infonet.identity_rotation import (
|
|
RotationBlocker,
|
|
RotationDecision,
|
|
rotation_descendants,
|
|
validate_rotation,
|
|
)
|
|
from services.infonet.tests._chain_factory import make_event, make_market_chain
|
|
|
|
|
|
def _rotation_event(old: str, new: str, *, ts: float = 2_000_000.0) -> dict:
|
|
return make_event(
|
|
"identity_rotate",
|
|
new, # signed by the new identity
|
|
{
|
|
"old_node_id": old,
|
|
"old_public_key": "old-pk",
|
|
"old_public_key_algo": "ed25519",
|
|
"new_public_key": "new-pk",
|
|
"new_public_key_algo": "ed25519",
|
|
"old_signature": "sig",
|
|
},
|
|
timestamp=ts,
|
|
sequence=1,
|
|
)
|
|
|
|
|
|
def test_rotation_with_no_active_stakes_accepted():
|
|
chain: list[dict] = []
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=2_000_000.0)
|
|
assert decision.accepted
|
|
assert decision.blockers == ()
|
|
|
|
|
|
def test_rotation_blocked_by_active_resolution_stake():
|
|
"""resolution_stake exists for an unfinalized market → reject."""
|
|
base = 1_700_000_000.0
|
|
# Set up a market in the resolution phase but NOT finalized.
|
|
chain = [
|
|
make_event("prediction_create", "creator",
|
|
{"market_id": "m1", "market_type": "objective",
|
|
"question": "?", "trigger_date": base + 1, "creation_bond": 3},
|
|
timestamp=base, sequence=1),
|
|
make_event("market_snapshot", "creator",
|
|
{"market_id": "m1", "frozen_participant_count": 5,
|
|
"frozen_total_stake": 10.0, "frozen_predictor_ids": [],
|
|
"frozen_probability_state": {"yes": 0.5, "no": 0.5},
|
|
"frozen_at": base + 100},
|
|
timestamp=base + 100, sequence=2),
|
|
make_event("resolution_stake", "alice",
|
|
{"market_id": "m1", "side": "yes", "amount": 5.0, "rep_type": "oracle"},
|
|
timestamp=base + 200, sequence=3),
|
|
]
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 300)
|
|
assert not decision.accepted
|
|
assert any(b.kind == "resolution_stake" for b in decision.blockers)
|
|
res_blocker = next(b for b in decision.blockers if b.kind == "resolution_stake")
|
|
assert res_blocker.count == 1
|
|
assert "m1" in res_blocker.sample_ids
|
|
|
|
|
|
def test_rotation_unblocked_after_market_finalizes():
|
|
base = 1_700_000_000.0
|
|
chain = make_market_chain(
|
|
"m1", "creator",
|
|
outcome="yes",
|
|
predictions=[{"node_id": "alice", "side": "yes", "probability_at_bet": 30.0}],
|
|
base_ts=base,
|
|
)
|
|
# Plus a resolution_stake from alice that the chain factory does NOT add.
|
|
chain.append(make_event(
|
|
"resolution_stake", "alice",
|
|
{"market_id": "m1", "side": "yes", "amount": 5.0, "rep_type": "oracle"},
|
|
timestamp=base + 5000, sequence=99,
|
|
))
|
|
# Market finalized in make_market_chain → status is "final" → not blocking.
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 8000)
|
|
assert decision.accepted
|
|
|
|
|
|
def test_rotation_blocked_by_active_dispute_stake():
|
|
base = 1_700_000_000.0
|
|
chain = [
|
|
make_event("dispute_open", "alice",
|
|
{"market_id": "m1", "challenger_stake": 5.0, "reason": "wrong"},
|
|
timestamp=base, sequence=1),
|
|
make_event("dispute_stake", "alice",
|
|
{"dispute_id": "d1", "side": "confirm", "amount": 5.0, "rep_type": "oracle"},
|
|
timestamp=base + 100, sequence=2),
|
|
]
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 200)
|
|
assert not decision.accepted
|
|
assert any(b.kind == "dispute_stake" for b in decision.blockers)
|
|
|
|
|
|
def test_rotation_blocked_by_active_truth_stake():
|
|
base = 1_700_000_000.0
|
|
chain = [
|
|
make_event("truth_stake_place", "alice",
|
|
{"message_id": "msg1", "poster_id": "bob", "side": "truth",
|
|
"amount": 5.0, "duration_days": 3},
|
|
timestamp=base, sequence=1),
|
|
]
|
|
# Within the 3-day window — still active.
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 86400)
|
|
assert not decision.accepted
|
|
truth_blockers = [b for b in decision.blockers if b.kind == "truth_stake"]
|
|
assert truth_blockers and truth_blockers[0].count == 1
|
|
|
|
|
|
def test_rotation_unblocked_after_truth_stake_resolves():
|
|
base = 1_700_000_000.0
|
|
chain = [
|
|
make_event("truth_stake_place", "alice",
|
|
{"message_id": "msg1", "poster_id": "bob", "side": "truth",
|
|
"amount": 5.0, "duration_days": 3},
|
|
timestamp=base, sequence=1),
|
|
make_event("truth_stake_resolve", "creator",
|
|
{"message_id": "msg1", "outcome": "truth"},
|
|
timestamp=base + 100, sequence=2),
|
|
]
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 86400)
|
|
assert decision.accepted
|
|
|
|
|
|
def test_rotation_unblocked_after_truth_stake_window_expires():
|
|
base = 1_700_000_000.0
|
|
chain = [
|
|
make_event("truth_stake_place", "alice",
|
|
{"message_id": "msg1", "poster_id": "bob", "side": "truth",
|
|
"amount": 5.0, "duration_days": 3},
|
|
timestamp=base, sequence=1),
|
|
]
|
|
# > 3 days past → window closed even without resolve event.
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 4 * 86400)
|
|
assert decision.accepted
|
|
|
|
|
|
def test_rotation_blockers_include_structured_diagnostic():
|
|
"""UX contract: every blocker carries kind + count + sample_ids
|
|
so the UI can offer a non-hostile retry path."""
|
|
base = 1_700_000_000.0
|
|
chain = [
|
|
make_event("resolution_stake", "alice",
|
|
{"market_id": "m1", "side": "yes", "amount": 5.0, "rep_type": "oracle"},
|
|
timestamp=base, sequence=1),
|
|
make_event("resolution_stake", "alice",
|
|
{"market_id": "m2", "side": "no", "amount": 5.0, "rep_type": "oracle"},
|
|
timestamp=base + 1, sequence=2),
|
|
]
|
|
decision = validate_rotation(_rotation_event("alice", "alice2"), chain, now=base + 100)
|
|
assert isinstance(decision, RotationDecision)
|
|
assert not decision.accepted
|
|
res_blocker = next(b for b in decision.blockers if b.kind == "resolution_stake")
|
|
assert isinstance(res_blocker, RotationBlocker)
|
|
assert res_blocker.count == 2
|
|
assert set(res_blocker.sample_ids) == {"m1", "m2"}
|
|
|
|
|
|
def test_rotation_descendants_simple_chain():
|
|
base = 2_000_000.0
|
|
chain = [
|
|
_rotation_event("alice", "alice2", ts=base),
|
|
_rotation_event("alice2", "alice3", ts=base + 100),
|
|
_rotation_event("alice3", "alice4", ts=base + 200),
|
|
]
|
|
desc = rotation_descendants("alice", chain)
|
|
assert desc == {"alice2", "alice3", "alice4"}
|
|
|
|
|
|
def test_rotation_descendants_handles_branching():
|
|
"""Pathological case: a single old_node_id appears in two
|
|
rotations (key compromise scenario). Both branches are followed."""
|
|
base = 2_000_000.0
|
|
chain = [
|
|
_rotation_event("alice", "alice2", ts=base),
|
|
_rotation_event("alice", "alice_alt", ts=base + 50),
|
|
_rotation_event("alice2", "alice3", ts=base + 100),
|
|
]
|
|
desc = rotation_descendants("alice", chain)
|
|
assert desc == {"alice2", "alice3", "alice_alt"}
|
|
|
|
|
|
def test_rotation_descendants_excludes_self():
|
|
base = 2_000_000.0
|
|
chain = [_rotation_event("alice", "alice2", ts=base)]
|
|
desc = rotation_descendants("alice", chain)
|
|
assert "alice" not in desc
|
|
|
|
|
|
def test_rotation_descendants_terminates_on_cycle():
|
|
"""Defense against malicious self-cycle: must not infinite-loop."""
|
|
base = 2_000_000.0
|
|
chain = [
|
|
_rotation_event("alice", "alice2", ts=base),
|
|
_rotation_event("alice2", "alice", ts=base + 100), # forbidden in production but defensive
|
|
]
|
|
desc = rotation_descendants("alice", chain)
|
|
# The cycle bridges alice → alice2 → alice. Self-rotations to the
|
|
# same id are filtered earlier; cross-cycles are clamped by the
|
|
# "already seen" check.
|
|
assert "alice2" in desc
|
|
|
|
|
|
def test_validate_rotation_rejects_non_rotation_event():
|
|
with pytest.raises(ValueError):
|
|
validate_rotation(make_event("uprep", "x", {"target_node_id": "y", "target_event_id": "e"},
|
|
timestamp=1, sequence=1), [], now=2.0)
|
|
|
|
|
|
def test_validate_rotation_rejects_missing_old_node_id():
|
|
base = 2_000_000.0
|
|
bad = _rotation_event("alice", "alice2", ts=base)
|
|
bad["payload"].pop("old_node_id")
|
|
with pytest.raises(ValueError):
|
|
validate_rotation(bad, [], now=base + 1)
|