Files
plm-lottery/tests/unit/test_pause_reason.py
davideandClaude Sonnet 5.5 a4b160e240 Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.

- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
  the history of the row's address and looks for every txid of the row. Present on any
  server -> promoted (adopting the version found). Absent is only "no server of the
  quorum knows it" and needs the active server plus the quorum minimum of the others to
  have answered; even then nothing is released without proof:
  (a) an input spent in a block by a transaction that is none of the row's versions,
      agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
  (b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
      refused by all of them, explicitly, for missing/spent inputs.
  Accepted or "already known" by any server means alive; a fee refusal is not proof
  (`building` -> `pending` for the bumper, `pending` untouched); too few answers or
  anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
  a 14-day watch: a failed row that a majority of the other servers (at least the quorum
  minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
  pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
  The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
  from POST /admin/pause (which accepts an optional `note` and never overwrites an
  automatic reason), cleared by resume, which logs the reason it lifted; exposed on
  /admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
  system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
  but never abandon.

Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:07:09 +02:00

100 lines
4.4 KiB
Python

"""B-78 (DAT-69, AUD-11, AUD-12, API-32): why the lottery is paused.
`paused_reason` is `operator` for POST /admin/pause and an automatic reason when the
system pauses itself (`resurrected_tx` so far). An operator pause never overwrites an
automatic reason, and POST /admin/resume — the only way out — clears whichever it is and
logs what it lifted.
"""
import json
from sqlalchemy import select
from app.db.models import AuditLog
from app.rounds.pause import pause_automatically
from tests.unit.test_admin import client # noqa: F401 (fixture)
_HEADERS = {"X-Admin-Token": "test-admin-token"}
async def _audit(event_type: str) -> list[dict]:
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == event_type).order_by(AuditLog.id))).all()
return [json.loads(r.payload_json) for r in rows]
async def test_an_operator_pause_has_the_reason_operator_and_may_carry_a_note(client):
assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] is None
response = await client.post("/admin/pause", headers=_HEADERS, json={"note": "migrating the server"})
assert response.status_code == 200
assert response.json()["paused"] is True and response.json()["paused_reason"] == "operator"
assert await _audit("lottery_paused") == [{"reason": "operator", "note": "migrating the server"}]
async def test_pause_still_works_without_a_body_and_rejects_a_note_over_256_characters(client):
assert (await client.post("/admin/pause", headers=_HEADERS)).status_code == 200
assert (await client.post("/admin/pause", headers=_HEADERS, json={"note": "x" * 257})).status_code == 422
assert await _audit("lottery_paused") == [{"reason": "operator"}]
async def test_an_automatic_reason_survives_an_operator_pause_and_resume_clears_it(client):
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
await pause_automatically(session, "resurrected_tx")
await session.commit()
assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] == "resurrected_tx"
again = await client.post("/admin/pause", headers=_HEADERS)
assert again.json()["paused_reason"] == "resurrected_tx" # the cause that needs a look is not hidden
assert (await _audit("lottery_paused"))[-1] == {"reason": "operator"} # but the row says who asked
resumed = await client.post("/admin/resume", headers=_HEADERS)
assert resumed.json()["paused"] is False and resumed.json()["paused_reason"] is None
assert (await _audit("lottery_resumed"))[-1] == {"reason": "resurrected_tx"} # what was lifted
async def test_resuming_a_lottery_that_was_not_paused_logs_a_null_reason(client):
await client.post("/admin/resume", headers=_HEADERS)
assert await _audit("lottery_resumed") == [{"reason": None}]
async def test_an_automatic_pause_overwrites_an_operator_reason(client):
"""DAT-69: the automatic reason is always written, even over an active pause."""
from app.db import base as db_base
await client.post("/admin/pause", headers=_HEADERS)
async with db_base.AsyncSessionLocal() as session:
await pause_automatically(session, "resurrected_tx")
await session.commit()
assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] == "resurrected_tx"
async def test_only_automatic_reasons_can_be_written_by_the_system(client):
import pytest
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
with pytest.raises(ValueError):
await pause_automatically(session, "operator")
async def test_the_public_round_endpoint_reports_the_reason(client):
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.api.routes.rounds import router as rounds_router
from app.rounds.events import broadcaster # noqa: F401
app = FastAPI()
app.include_router(rounds_router)
app.state.electrum_listener = type("L", (), {"tip_height": 0, "client": None})()
await client.post("/admin/pause", headers=_HEADERS)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as public:
body = (await public.get("/rounds/current")).json()
assert body["lottery_paused"] is True and body["paused_reason"] == "operator"