poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
153 lines
6.0 KiB
Python
153 lines
6.0 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request, status
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app.logging_config import setup_logging
|
|
|
|
setup_logging()
|
|
|
|
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
|
|
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
|
|
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
|
from app.api.routes.admin import router as admin_router
|
|
from app.api.routes.bets import router as bets_router
|
|
from app.api.routes.bug_reports import router as bug_reports_router
|
|
from app.api.routes.qr import router as qr_router
|
|
from app.api.routes.rounds import router as rounds_router
|
|
from app.api.routes.users import router as users_router
|
|
from app.api.routes.withdrawals import router as withdrawals_router
|
|
from app.auth.routes import router as auth_router
|
|
from app.api.errors import ApiError
|
|
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
|
|
from app.db.base import AsyncSessionLocal
|
|
from app.deposits.reconcile import DepositReconciler
|
|
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
|
from app.electrum.listener import ElectrumListener
|
|
from app.rounds.scheduler import RoundScheduler
|
|
from app.tx.broadcast import RbfBumper
|
|
from app.tx.confirmation import ConfirmationPoller
|
|
from app.tx.locks import UserLocks
|
|
from app.tx.reconcile import PendingTransactionReconciler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
|
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Refuses to serve rather than starting up half-configured (B-15).
|
|
validate_runtime_secrets()
|
|
|
|
endpoints = parse_endpoints(
|
|
settings.electrum_host,
|
|
settings.electrum_port,
|
|
settings.electrum_use_ssl,
|
|
settings.electrum_fallback_servers,
|
|
settings.electrum_cert_pin,
|
|
)
|
|
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
|
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
|
|
|
listener = ElectrumListener(
|
|
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
|
|
)
|
|
app.state.electrum_listener = listener
|
|
app.state.user_locks = UserLocks()
|
|
|
|
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
|
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
# Resolves in-flight transactions against the chain — the piece that lets the
|
|
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
|
|
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
# Periodic safety net for deposit crediting/external-spend detection,
|
|
# independent of scripthash-change notifications — catches a subscription
|
|
# silently lost on an otherwise healthy connection (B-30).
|
|
deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener)
|
|
|
|
tasks = [
|
|
asyncio.create_task(listener.run()),
|
|
asyncio.create_task(scheduler.run()),
|
|
asyncio.create_task(poller.run()),
|
|
asyncio.create_task(bumper.run()),
|
|
asyncio.create_task(reconciler.run()),
|
|
asyncio.create_task(deposit_reconciler.run()),
|
|
]
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
if listener.client is not None:
|
|
await listener.client.close()
|
|
|
|
|
|
# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin
|
|
# endpoints included, to anyone who requests them (B-42) — disabled unless
|
|
# ENABLE_API_DOCS is explicitly set, which should only happen in development.
|
|
app = FastAPI(
|
|
title="PLM Lottery",
|
|
lifespan=lifespan,
|
|
docs_url="/docs" if settings.enable_api_docs else None,
|
|
redoc_url="/redoc" if settings.enable_api_docs else None,
|
|
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
|
)
|
|
app.include_router(auth_router)
|
|
app.include_router(users_router)
|
|
app.include_router(bets_router)
|
|
app.include_router(withdrawals_router)
|
|
app.include_router(admin_router)
|
|
app.include_router(bug_reports_router)
|
|
app.include_router(qr_router)
|
|
app.include_router(rounds_router)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
"""Answers with the same structured `detail` shape as every deliberate failure
|
|
(app/api/errors.py) so clients never have to special-case unexpected errors.
|
|
The exception itself stays in logs/app.log only — never in the response body."""
|
|
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def index_page() -> FileResponse:
|
|
return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/admin", include_in_schema=False)
|
|
async def admin_panel() -> FileResponse:
|
|
return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/guida", include_in_schema=False)
|
|
async def user_guide() -> FileResponse:
|
|
return FileResponse("app/static/guida.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/report-bug", include_in_schema=False)
|
|
async def report_bug_page() -> FileResponse:
|
|
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
|