fix: the ritual and judgment were faucets too
The Passage faucet was not unique. The same shape — a client-sendable message that rewinds the guarding state — sat in both siblings, and for the same reason: nothing drove these handlers over a real connection. `ritual_start` resets `ritual_completed`, and the UI offers that button (honest play needs it after a failed roll). Every re-walk re-credited RITUAL_SUCCESS_ESSENCE and re-rolled an item drop. `ritual_step` has no limiter at all. Judgment was worse. Only `cross_over` was guarded; replaying any other verdict paid CORRECT_JUDGMENT_ESSENCE plus favor plus an item roll on every frame, unbounded. Favor is the damaging half — it pins at the +1.0 ceiling and then biases every future mint through apply_favor_bias, so the exploit permanently changed which spirits the seeker can meet. The frame also echoed the nominal deltas, which seance.tsx sums into displayed totals, so the screen and the ledger diverged. Guards mirror `passage_paid`: cleared only by a genuine summon, never by the rewinding message. `judged_verdicts` is keyed per verdict rather than a blanket latch, so correcting a wrong call still resolves; `test` is exempt since it never touches the ledger. Both frames now report what was applied. tests/test_ws_reward_replay.py drives each exploit and reads the ledger from the database. Proven both directions: reverting the guard fails with "minted 120 extra essence"; over-broadening it fails with "a fresh presence did not re-open the purse". Audited and found safe, with reasons in the report: summon trickle, device telemetry ingestion, at_peace writes, purchase_unlock, sigils, waitlist, scry/question/anomaly/manifest/fragment. Every essence write is with_for_update-locked. Known residual: guards live on SeanceState, so a reconnect resets them — but each payout still needs a summon, and summon_limiter is per user across connections, so income stays rate-bounded. Not closed, deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,31 @@ class SeanceState:
|
||||
ritual_steps: int = 0
|
||||
ritual_completed: bool = False
|
||||
ritual_success: bool = False
|
||||
# Whether the ritual milestone has ALREADY paid out for the current
|
||||
# presence. Exactly the same faucet `passage_paid` closes, on the other
|
||||
# rite: `ritual_start` rewinds `ritual_completed` to False at any time
|
||||
# (and the UI offers precisely that button — RitualPanel's "attempt
|
||||
# again", which honest play needs after a FAILED roll), so without this
|
||||
# a client could walk ritual_start + 4x ritual_step for +15 essence and
|
||||
# an item roll, restart, and repeat forever off a single summon. The
|
||||
# limiter caps the cadence, not the total. So the milestone pays the
|
||||
# FIRST time it succeeds for a given presence and never again;
|
||||
# re-attempting still rolls, still reveals the traits on success, but
|
||||
# mints no new essence and rolls no new item. Cleared only on a fresh
|
||||
# summon, never by `ritual_start` — that is the whole point.
|
||||
ritual_paid: bool = False
|
||||
# Which verdicts have ALREADY been applied to the ledger for the current
|
||||
# presence. Same class of hole: `judgment` had no once-per-presence
|
||||
# guard at all except for `cross_over`, so replaying {"type":
|
||||
# "judgment", "verdict": "trust"} against a benevolent spirit credited
|
||||
# CORRECT_JUDGMENT_ESSENCE *and* +0.05 favor *and* rolled an item on
|
||||
# every single frame — an unbounded faucet for both currencies (favor
|
||||
# pins to +1.0, which then biases every future mint) that the 10/60s
|
||||
# limiter only slowed down. Keyed by verdict rather than a single latch
|
||||
# so an honest correction (a wrong `trust`, then the right `banish` on
|
||||
# the same spirit) still resolves normally — what can never repeat is
|
||||
# the SAME verdict on the SAME presence. Cleared only on a fresh summon.
|
||||
judged_verdicts: set[str] = field(default_factory=set)
|
||||
# Workstream L (passage-doctrine spec): where the layered crossing rite
|
||||
# stands for the *current* entity. `passage_layer` is the next beat to
|
||||
# attempt, `passage_lied` remembers whether the layer just completed was
|
||||
@@ -583,6 +608,10 @@ async def _summon_locked(state: SeanceState) -> None:
|
||||
state.ritual_steps = 0
|
||||
state.ritual_completed = False
|
||||
state.ritual_success = False
|
||||
# Only a genuinely fresh summon re-opens the purse for the other two
|
||||
# rites, exactly as `_reset_passage(new_entity=True)` does below.
|
||||
state.ritual_paid = False
|
||||
state.judged_verdicts = set()
|
||||
_reset_passage(state, new_entity=True)
|
||||
await state.send_queue.put(
|
||||
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
|
||||
@@ -896,7 +925,11 @@ async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
|
||||
await state.send_queue.put(
|
||||
{"type": "ritual_complete", "success": success, "revealed": revealed}
|
||||
)
|
||||
if success:
|
||||
# The milestone pays once per presence. A re-attempt after a rewind
|
||||
# (`ritual_start`) still rolls and still reveals on success — it just
|
||||
# doesn't mint a second payout. See `SeanceState.ritual_paid`.
|
||||
if success and not state.ritual_paid:
|
||||
state.ritual_paid = True
|
||||
await _reward_ritual_success(state)
|
||||
|
||||
|
||||
@@ -938,13 +971,33 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
||||
ritual_success=state.ritual_success,
|
||||
)
|
||||
|
||||
# A verdict lands on a presence once. Replaying the same one — the UI
|
||||
# re-arms the panel after any result — re-reports the same reading for
|
||||
# free rather than paying it out again. See `judged_verdicts`; `test`
|
||||
# never touches the ledger so it is deliberately exempt and stays
|
||||
# freely repeatable.
|
||||
already_judged = verdict != "test" and verdict in state.judged_verdicts
|
||||
if verdict != "test":
|
||||
state.judged_verdicts.add(verdict)
|
||||
|
||||
# What will ACTUALLY reach the ledger. The frame below reports these,
|
||||
# not `outcome`'s face values: the client sums `essence_delta`/
|
||||
# `favor_delta` straight into its displayed totals (see
|
||||
# frontend/src/state/seance.tsx's `judgment_result` case), so echoing
|
||||
# the nominal amounts on a replay would show a seeker currency their
|
||||
# account never received.
|
||||
favor_delta = 0.0 if already_judged else outcome.favor_delta
|
||||
essence_delta = 0 if already_judged else outcome.essence_delta
|
||||
consequence = outcome.consequence
|
||||
|
||||
item = None
|
||||
# Skip the DB round-trip entirely when there's nothing to persist (e.g.
|
||||
# `test` without a completed ritual, or a resisted cross_over) — the
|
||||
# contract's "no crash, just no effect" for those cases.
|
||||
if outcome.favor_delta or outcome.essence_delta or outcome.consequence in (
|
||||
"reward",
|
||||
"crossed_over",
|
||||
if not already_judged and (
|
||||
outcome.favor_delta
|
||||
or outcome.essence_delta
|
||||
or outcome.consequence in ("reward", "crossed_over")
|
||||
):
|
||||
async with session_maker() as db:
|
||||
# Locked — see the same comment in _reward_summon above; this
|
||||
@@ -987,10 +1040,10 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
||||
{
|
||||
"type": "judgment_result",
|
||||
"correct": outcome.correct,
|
||||
"favor_delta": outcome.favor_delta,
|
||||
"essence_delta": outcome.essence_delta,
|
||||
"favor_delta": favor_delta,
|
||||
"essence_delta": essence_delta,
|
||||
"at_peace": outcome.at_peace,
|
||||
"consequence": outcome.consequence,
|
||||
"consequence": consequence,
|
||||
}
|
||||
)
|
||||
if item is not None:
|
||||
|
||||
Reference in New Issue
Block a user