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>
100 lines
4.4 KiB
Python
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"
|